Implement Bird gate IVR flow

This commit is contained in:
Jeppe Bundgaard
2026-03-09 11:03:45 +01:00
parent 682bf8151c
commit d82c7d88ec
21 changed files with 4616 additions and 3251 deletions
+92
View File
@@ -0,0 +1,92 @@
name: Tests
on:
pull_request:
push:
branches:
- main
jobs:
unit:
name: Unit (required)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mysqli, curl, openssl, json, redis, pcov
coverage: pcov
- name: Resolve dependencies
working-directory: services/nginx/app
run: composer update --no-interaction --prefer-dist
- name: Run unit tests
working-directory: services/nginx/app
run: composer test:unit
- name: Generate coverage report
working-directory: services/nginx/app
run: composer test:coverage
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: unit-coverage-clover
path: services/nginx/app/build/logs/clover.xml
if-no-files-found: warn
integration:
name: Integration (advisory)
runs-on: ubuntu-latest
continue-on-error: true
services:
redis:
image: redis:7
ports:
- 6379:6379
mysql:
image: mysql:8
env:
MYSQL_DATABASE: app_test
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mysqli, curl, openssl, json, redis
- name: Resolve dependencies
working-directory: services/nginx/app
run: composer update --no-interaction --prefer-dist
- name: Run integration tests
working-directory: services/nginx/app
env:
RUN_INTEGRATION_TESTS: '1'
REDIS_CONFIG_HOST: 127.0.0.1
REDIS_CONFIG_DATABASE: '0'
REDIS_CONFIG_PASSWORD: ''
CONFIG_DB_HOST: 127.0.0.1
CONFIG_DB_USER: root
CONFIG_DB_PASSWORD: root
CONFIG_DB_DATABASE: app_test
run: composer test:integration
+14 -11
View File
@@ -39,26 +39,29 @@ Configuration is primarily managed via environment variables.
## Testing
The project uses a lightweight testing approach using procedural PHP scripts.
The project now uses [Pest](https://pestphp.com/) as the primary test runner in `services/nginx/app`.
### Running Tests
Tests are located in `services/nginx/app/tests/`.
All commands are run from `services/nginx/app`:
**From the host (requires PHP 8.2):**
```powershell
php services/nginx/app/tests/subusers/SelfservePermissionInitTest.php
composer test
composer test:unit
composer test:integration
composer test:coverage
```
**Inside Docker (recommended for integration tests):**
Integration tests are opt-in and should be run with required services available:
```powershell
docker compose exec -T php1 php /var/www/html/tests/subusers/SelfservePermissionInitTest.php
$env:RUN_INTEGRATION_TESTS='1'
composer test:integration
```
### Creating New Tests
Place new tests under `services/nginx/app/tests/<domain>/`. Every test should define the `WD` constant to point to the app root:
```php
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
```
### Test Layout
- `tests/Unit/*`: isolated unit and route-level behavior tests.
- `tests/Integration/*`: Redis/DB-backed tests intended for Docker/CI environments.
- `tests/<legacy-domain>/*`: legacy procedural scripts retained during migration; keep them runnable until matching Pest coverage exists.
## Logs & Monitoring
- **Logs:** `docker compose logs -f [service_name]` (e.g., `php1`, `caddy`, `traefik`).
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -1,6 +1,14 @@
{
"scripts": {
"test": "composer test:unit",
"test:unit": "vendor/bin/pest --testsuite=Unit --colors=always",
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
"test:coverage": "php -r \"is_dir('build/logs') || mkdir('build/logs', 0777, true);\" && phpdbg -qrr vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always"
},
"require-dev": {
"rector/rector": "^1.2"
"rector/rector": "^1.2",
"pestphp/pest": "^3.8",
"phpunit/phpunit": "^11.5"
},
"require": {
"ext-mysqli": "*",
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory suffix="Test.php">tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">classes</directory>
<directory suffix=".php">modules</directory>
<directory suffix=".php">objects</directory>
<directory suffix=".php">routes</directory>
<directory suffix=".php">traits</directory>
</include>
</source>
</phpunit>
@@ -3,14 +3,10 @@
namespace routes;
use classes\bird;
use classes\shelly;
use classes\redis;
use classes\slack;
use modules\shelly\helpers\shelly_device_switch;
use objects\department_gates_o;
use objects\department_relays_o;
use objects\departments_o;
use objects\groups_o;
use objects\subuser_grants_o;
use objects\subusers_o;
use objects\users_o;
use traits\bird_route_helpers_t;
@@ -20,6 +16,13 @@ class birdVoiceWebhooksRoute
{
use route_t, bird_route_helpers_t;
private const IVR_STATE_TTL_SECONDS = 600;
private const IVR_STATE_PREFIX = 'bird_ivr_state:';
private const IVR_STAGE_DEPARTMENT_SELECT = 'department_select';
private const IVR_STAGE_GATE_TYPE_SELECT = 'gate_type_select';
private const IVR_GATE_TYPE_ENTRANCE = 'entrance';
private const IVR_GATE_TYPE_EXIT = 'exit';
public function run(): void
{
$this->post('/bird/voice/calls/webhook/inbound', function () {
@@ -27,7 +30,6 @@ class birdVoiceWebhooksRoute
//self::requirePermission('modules_bird_voice_call_webhooks_trigger');
$client = new bird();
$support_phone_number_forwarding = "+4555299500"; // Used when the user is not found, and the caller stays on the line to be forwarded to support instead of the gate opening.
$ws = $this->normalizeOptionalString($this->fromRequest('workspaceId') ?? $this->fromQuery('workspaceId'));
$ch = $this->normalizeOptionalString($this->fromRequest('channelId') ?? $this->fromQuery('channelId'));
@@ -44,97 +46,147 @@ class birdVoiceWebhooksRoute
$payload = $this->getParametersAsArray();
$callId = $this->normalizeOptionalString($this->fromRequest('call_id') ?? $this->fromQuery('call_id'));
$digits = $this->normalizeOptionalString($payload['digits'] ?? $this->fromRequest('digits') ?? $this->fromQuery('digits'));
if ($callId === '') {
$response->error('Missing required parameter: call_id', 400);
}
$caller = $this->extractCallerPhone($payload);
if ($caller === null) {
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke identificere dit telefonnummer. Kontakt venligst support.');
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke identificere dit telefonnummer. Kontakt venligst support.', true);
return;
}
[$countryCode, $localPhone] = $caller;
if ($digits !== '' && $callId !== '') {
$this->handleGatherResponse($client, $ws, $ch, $callId, $digits, $countryCode, $localPhone);
return;
}
$slack = new Slack();
$slack->send_message('Webhook received for incoming voice call (ID: ' . $callId . '): ' . json_encode($payload), 'Bird Voice Call Webhooks');
if (!$this->isRegisteredCaller($countryCode, $localPhone)) {
$this->say($client, $ws, $ch, $callId, 'Du er ikke sat op til automatisk portåbning med landekode ' . $countryCode . ' og telefonnummer ' . $localPhone . '. Kontakt venligst support for at blive sat op.');
$this->say($client, $ws, $ch, $callId, 'Du er ikke sat op til automatisk portaabning med landekode ' . $countryCode . ' og telefonnummer ' . $localPhone . '. Kontakt venligst support for at blive sat op.', true);
return;
}
// Identify caller's matching gates
$matchingGates = $this->getCallerMatchingGates($countryCode, $localPhone);
if (empty($matchingGates)) {
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke finde nogen porte tilknyttet din profil. Kontakt venligst support.');
$departments = $this->getCallerDepartments($countryCode, $localPhone);
if (empty($departments)) {
$this->clearIvrState($callId);
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke finde nogen afdelinger tilknyttet din profil. Kontakt venligst support.', true);
return;
}
// Prompt for selection
$this->promptForGateSelection($client, $ws, $ch, $callId, $matchingGates);
if ($digits !== '' && $callId !== '') {
$this->handleGatherResponse($client, $ws, $ch, $callId, $digits, $countryCode, $localPhone, $departments);
return;
}
$this->promptForDepartmentSelection($client, $ws, $ch, $callId, $departments, $countryCode, $localPhone);
}, [
'modules_bird_voice_call_webhooks_trigger' => 'Trigger a voice call via Bird webhooks',
]);
}
protected function handleGatherResponse(bird $client, string $ws, string $ch, string $callId, string $digits, int $countryCode, int $phone): void
{
$matchingGates = $this->getCallerMatchingGates($countryCode, $phone);
$index = (int)$digits - 1;
if (!isset($matchingGates[$index])) {
$this->say($client, $ws, $ch, $callId, 'Ugyldigt valg. Ring venligst op igen.', true);
protected function handleGatherResponse(
bird $client,
string $ws,
string $ch,
string $callId,
string $digits,
int $countryCode,
int $phone,
array $departments
): void {
$state = $this->readIvrState($callId);
if ($state === null || !$this->isStateForCaller($state, $countryCode, $phone)) {
$this->promptForDepartmentSelection($client, $ws, $ch, $callId, $departments, $countryCode, $phone);
return;
}
$gate = $matchingGates[$index];
$stage = (string)($state['stage'] ?? '');
if ($stage === self::IVR_STAGE_DEPARTMENT_SELECT) {
$this->handleDepartmentSelectionDigit($client, $ws, $ch, $callId, $digits, $state, $countryCode, $phone);
return;
}
if ($stage === self::IVR_STAGE_GATE_TYPE_SELECT) {
$this->handleGateTypeSelectionDigit($client, $ws, $ch, $callId, $digits, $state);
return;
}
$this->clearIvrState($callId);
$this->promptForDepartmentSelection($client, $ws, $ch, $callId, $departments, $countryCode, $phone);
}
protected function handleDepartmentSelectionDigit(
bird $client,
string $ws,
string $ch,
string $callId,
string $digits,
array $state,
int $countryCode,
int $phone
): void {
$optionMap = is_array($state['department_options'] ?? null) ? $state['department_options'] : [];
$selected = $this->resolveDepartmentIdByDigit($optionMap, $digits);
if ($selected === null) {
$this->promptForDepartmentRetry($client, $ws, $ch, $callId, $state);
return;
}
$state['stage'] = self::IVR_STAGE_GATE_TYPE_SELECT;
$state['selected_department'] = $selected;
$state['country_code'] = $countryCode;
$state['phone'] = $phone;
$state['selected_department_name'] = $this->getDepartmentNameById($selected);
$this->saveIvrState($callId, $state);
$this->promptForGateTypeSelection($client, $ws, $ch, $callId, $selected, (string)$state['selected_department_name']);
}
protected function handleGateTypeSelectionDigit(
bird $client,
string $ws,
string $ch,
string $callId,
string $digits,
array $state
): void {
$departmentId = (int)($state['selected_department'] ?? 0);
if ($departmentId <= 0) {
$this->clearIvrState($callId);
$this->say($client, $ws, $ch, $callId, 'Sessionen er udloeebet. Ring venligst op igen.', true);
return;
}
$gateType = $this->resolveGateTypeByDigit($digits);
if ($gateType === null) {
$departmentName = (string)($state['selected_department_name'] ?? $this->getDepartmentNameById($departmentId));
$this->promptForGateTypeRetry($client, $ws, $ch, $callId, $departmentId, $departmentName);
return;
}
$gate = $gateType === self::IVR_GATE_TYPE_ENTRANCE
? (new department_gates_o())->getEntranceGate($departmentId)
: (new department_gates_o())->getExitGate($departmentId);
$this->clearIvrState($callId);
if ($gate === null || !$gate->exists()) {
$directionLabel = $gateType === self::IVR_GATE_TYPE_ENTRANCE ? 'indgang' : 'udgang';
$this->say($client, $ws, $ch, $callId, 'Vi kunne ikke finde en ' . $directionLabel . 'sport for den valgte afdeling.', true);
return;
}
if ($this->callGateToOpen($gate)) {
$this->say($client, $ws, $ch, $callId, 'Åbner port: ' . $gate->name->value(), true);
} else {
$this->say($client, $ws, $ch, $callId, 'Kunne ikke aktivere relæet for ' . $gate->name->value() . '. Kontakt venligst support.', true);
}
$this->say($client, $ws, $ch, $callId, 'Aabner port: ' . $gate->name->value(), true);
return;
}
protected function getCallerMatchingGates(int $countryCode, int $phone): array
{
$departments = $this->getCallerDepartments($countryCode, $phone);
$matchingGates = [];
$seenGateIds = [];
foreach ($departments as $deptId) {
$deptGates = (new department_gates_o())->getDepartmentGates($deptId);
foreach ($deptGates as $gate) {
if (!in_array((int)$gate->id, $seenGateIds)) {
$matchingGates[] = $gate;
$seenGateIds[] = (int)$gate->id;
}
}
}
return $matchingGates;
}
protected function promptForGateSelection(bird $client, string $ws, string $ch, string $callId, array $gates): void
{
$options = [];
foreach ($gates as $i => $gate) {
$options[] = 'Tast ' . ($i + 1) . ' for ' . $gate->name->value();
}
$text = 'Der blev fundet flere porte. ' . implode('. ', $options) . '. Afslut med firkantstasten.';
$this->say($client, $ws, $ch, $callId, $text);
header('Content-Type: application/json');
echo json_encode([]);
exit;
$this->say($client, $ws, $ch, $callId, 'Kunne ikke aktivere relaeet for ' . $gate->name->value() . '. Kontakt venligst support.', true);
}
protected function getCallerDepartments(int $countryCode, int $phone): array
{
// Return all, since callers don't have specific department access, but the gates they have access to are determined by the departments they are in, and we want to include all possible gates for them.
// Return all visible departments.
$tmp = (new departments_o())->getFieldsWhere([
'visible' => 1,
], ['id']);
@@ -143,7 +195,250 @@ class birdVoiceWebhooksRoute
}, $tmp);
}
protected function promptForDepartmentSelection(
bird $client,
string $ws,
string $ch,
string $callId,
array $departments,
int $countryCode,
int $phone
): void {
$optionMap = $this->buildDepartmentOptionMap($departments);
if (empty($optionMap)) {
$this->say($client, $ws, $ch, $callId, 'Der er ingen valgbare afdelinger lige nu. Kontakt venligst support.', true);
return;
}
$state = [
'stage' => self::IVR_STAGE_DEPARTMENT_SELECT,
'department_options' => $optionMap,
'selected_department' => null,
'country_code' => $countryCode,
'phone' => $phone,
];
$this->saveIvrState($callId, $state);
$text = $this->buildDepartmentPromptText($optionMap);
$this->gather($client, $ws, $ch, $callId, $text);
}
protected function promptForDepartmentRetry(bird $client, string $ws, string $ch, string $callId, array $state): void
{
$optionMap = is_array($state['department_options'] ?? null) ? $state['department_options'] : [];
if (empty($optionMap)) {
$this->clearIvrState($callId);
$this->say($client, $ws, $ch, $callId, 'Sessionen er udloeebet. Ring venligst op igen.', true);
return;
}
$text = 'Ugyldigt valg. ' . $this->buildDepartmentPromptText($optionMap);
$this->gather($client, $ws, $ch, $callId, $text);
}
protected function promptForGateTypeSelection(
bird $client,
string $ws,
string $ch,
string $callId,
int $departmentId,
string $departmentName
): void {
$text = $this->buildGateTypePromptText($departmentName);
$this->gather($client, $ws, $ch, $callId, $text);
}
protected function promptForGateTypeRetry(
bird $client,
string $ws,
string $ch,
string $callId,
int $departmentId,
string $departmentName
): void {
$text = 'Ugyldigt valg. ' . $this->buildGateTypePromptText($departmentName);
$this->gather($client, $ws, $ch, $callId, $text);
}
protected function buildDepartmentOptionMap(array $departmentIds): array
{
$map = [];
$index = 1;
foreach ($departmentIds as $departmentId) {
if ($index > 9) {
break;
}
$id = (int)$departmentId;
if ($id <= 0) {
continue;
}
$digit = (string)$index;
$map[$digit] = [
'department_id' => $id,
'department_name' => $this->getDepartmentNameById($id),
];
$index++;
}
return $map;
}
protected function buildDepartmentPromptText(array $optionMap): string
{
$parts = [];
foreach ($optionMap as $digit => $department) {
$name = (string)($department['department_name'] ?? '');
if ($name === '') {
$name = 'afdeling ' . (string)($department['department_id'] ?? '');
}
$parts[] = 'Tast ' . $digit . ' for ' . $name;
}
return 'Vaelg afdeling. ' . implode('. ', $parts) . '. Afslut med firkantstasten.';
}
protected function buildGateTypePromptText(string $departmentName): string
{
$prefix = $departmentName !== '' ? 'Du valgte ' . $departmentName . '. ' : '';
return $prefix . 'Tast 1 for indgang. Tast 2 for udgang. Afslut med firkantstasten.';
}
protected function resolveDepartmentIdByDigit(array $optionMap, string $digits): ?int
{
$key = trim($digits);
if ($key === '' || !isset($optionMap[$key])) {
return null;
}
$id = (int)($optionMap[$key]['department_id'] ?? 0);
return $id > 0 ? $id : null;
}
protected function resolveGateTypeByDigit(string $digits): ?string
{
$key = trim($digits);
if ($key === '1') {
return self::IVR_GATE_TYPE_ENTRANCE;
}
if ($key === '2') {
return self::IVR_GATE_TYPE_EXIT;
}
return null;
}
protected function getDepartmentNameById(int $departmentId): string
{
$row = (new departments_o())->getDepartmentById($departmentId);
return trim((string)($row['name'] ?? 'Afdeling ' . $departmentId));
}
protected function gather(bird $client, string $ws, string $ch, string $callId, string $text): void
{
if ($callId === '') {
throw new \Exception('Call ID is missing, cannot gather input');
}
$client->gatherMessage($ws, $ch, $callId, [
'input' => 'dtmf',
'maxNumKeys' => 1,
'retries' => 1,
'timeout' => 8,
'endKey' => '#',
'say' => [
'text' => $text,
],
]);
$this->respondJsonAndExit();
}
protected function saveIvrState(string $callId, array $state): void
{
if ($callId === '') {
return;
}
try {
$encoded = json_encode($state);
if (!is_string($encoded) || $encoded === '') {
return;
}
$this->writeIvrStateRaw($this->ivrStateKey($callId), $encoded, self::IVR_STATE_TTL_SECONDS);
} catch (\Throwable) {
// State caching should not break webhook flow.
}
}
protected function readIvrState(string $callId): ?array
{
if ($callId === '') {
return null;
}
try {
$raw = $this->readIvrStateRaw($this->ivrStateKey($callId));
if (!is_string($raw) || trim($raw) === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
} catch (\Throwable) {
return null;
}
}
protected function clearIvrState(string $callId): void
{
if ($callId === '') {
return;
}
try {
$this->deleteIvrStateRaw($this->ivrStateKey($callId));
} catch (\Throwable) {
// Best effort only.
}
}
protected function isStateForCaller(array $state, int $countryCode, int $phone): bool
{
return (int)($state['country_code'] ?? 0) === $countryCode
&& (int)($state['phone'] ?? 0) === $phone;
}
protected function ivrStateKey(string $callId): string
{
return self::IVR_STATE_PREFIX . $callId;
}
protected function writeIvrStateRaw(string $key, string $value, int $ttlSeconds): void
{
$redis = $this->resolveRedisClient();
if ($redis === null) {
return;
}
$redis->set($key, $value);
$redis->expire($key, $ttlSeconds);
}
protected function readIvrStateRaw(string $key): ?string
{
$redis = $this->resolveRedisClient();
if ($redis === null) {
return null;
}
return $redis->get($key);
}
protected function deleteIvrStateRaw(string $key): void
{
$redis = $this->resolveRedisClient();
if ($redis === null) {
return;
}
$redis->delete($key);
}
protected function resolveRedisClient(): ?redis
{
try {
return (new redis())->connect();
} catch (\Throwable) {
return null;
}
}
public function callGateToOpen(department_gates_o $gate): bool
{
@@ -270,7 +565,6 @@ class birdVoiceWebhooksRoute
protected function say(bird $client, string $ws, string $ch, string $callId, string $text, bool $hangup = false): void
{
global $response;
if ($callId !== '') {
try {
$payload = ['text' => $text];
@@ -278,9 +572,7 @@ class birdVoiceWebhooksRoute
$payload['hangup'] = true;
}
$client->sayMessage($ws, $ch, $callId, $payload);
header('Content-Type: application/json');
echo json_encode([]);
exit;
$this->respondJsonAndExit();
} catch (\Throwable $e) {
// If REST API fails, fallback to standard response
throw new \Exception('Failed to say message and hang up: ' . $e->getMessage(), 0, $e);
@@ -290,6 +582,12 @@ class birdVoiceWebhooksRoute
}
}
protected function respondJsonAndExit(): void
{
header('Content-Type: application/json');
echo json_encode([]);
exit;
}
protected function extractCountryCodeFromPhoneNumber(string $phone): ?int
{
@@ -0,0 +1,42 @@
<?php
use classes\db;
it('can connect to a configured MySQL instance in integration mode', function (): void {
if (!integration_enabled()) {
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run DB integration tests.');
}
$host = getenv('CONFIG_DB_HOST') ?: null;
$user = getenv('CONFIG_DB_USER') ?: null;
$password = getenv('CONFIG_DB_PASSWORD') ?: '';
$database = getenv('CONFIG_DB_DATABASE') ?: null;
if (!$host || !$user || !$database) {
test()->markTestSkipped('Missing DB env vars: CONFIG_DB_HOST/CONFIG_DB_USER/CONFIG_DB_DATABASE.');
}
app_require('classes/db.php');
$GLOBALS['response'] = new class {
public function internal_server_error(string $message): void
{
throw new RuntimeException($message);
}
};
$db = new db([
'host' => $host,
'user' => $user,
'password' => $password,
'database' => $database,
]);
$db->connect();
expect($db->conn())->toBeInstanceOf(mysqli::class);
expect($db->conn()->connect_errno)->toBe(0);
$db->close();
});
@@ -0,0 +1,11 @@
<?php
it('keeps legacy route permission redis cache script green', function (): void {
if (!integration_enabled()) {
test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run Redis integration tests.');
}
$result = run_legacy_script('tests/permissions/PermissionRedisCacheTest.php');
expect($result['exitCode'])->toBe(0, $result['output']);
});
+6
View File
@@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/Support/bootstrap.php';
uses()->group('unit')->in('Unit');
uses()->group('integration')->in('Integration');
@@ -0,0 +1,40 @@
<?php
if (!defined('WD')) {
define('WD', dirname(__DIR__, 2));
}
function app_path(string $relative = ''): string
{
if ($relative === '') {
return WD;
}
return WD . DIRECTORY_SEPARATOR . ltrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative), DIRECTORY_SEPARATOR);
}
function app_require(string $relative): void
{
require_once app_path($relative);
}
function integration_enabled(): bool
{
return getenv('RUN_INTEGRATION_TESTS') === '1';
}
function run_legacy_script(string $relativeScriptPath): array
{
$script = app_path($relativeScriptPath);
if (!is_file($script)) {
return ['exitCode' => 1, 'output' => 'Missing legacy script: ' . $script];
}
$phpBinary = PHP_BINARY ?: 'php';
$command = escapeshellarg($phpBinary) . ' ' . escapeshellarg($script) . ' 2>&1';
$outputLines = [];
$exitCode = 0;
exec($command, $outputLines, $exitCode);
return ['exitCode' => $exitCode, 'output' => implode(PHP_EOL, $outputLines)];
}
@@ -0,0 +1,59 @@
<?php
namespace objects {
class users_o
{
public int $id;
public static array $existing = [];
public function getUserByCustomerNumber(int $customer_number): self
{
if (in_array($customer_number, self::$existing, true)) {
$this->id = 123;
}
return $this;
}
public function exists(): bool
{
return isset($this->id) && $this->id > 0;
}
}
class tokens_o
{
public static array $created = [];
public function create(int $user_id, string $token, string $type = 'AUTH_TOKEN'): void
{
self::$created[] = compact('user_id', 'token', 'type');
}
}
}
namespace {
app_require('interfaces/authentication_i.php');
app_require('classes/authentication.php');
}
namespace {
it('creates a 64-char auth token for an existing user', function (): void {
\objects\users_o::$existing = [111111];
\objects\tokens_o::$created = [];
$token = (new \classes\authentication())->create_token(111111);
expect($token)->toBeString()->toMatch('/^[a-f0-9]{64}$/');
expect(\objects\tokens_o::$created)->toHaveCount(1);
expect(\objects\tokens_o::$created[0]['user_id'])->toBe(123);
expect(\objects\tokens_o::$created[0]['type'])->toBe('AUTH_TOKEN');
});
it('throws a clear exception when customer user is missing', function (): void {
\objects\users_o::$existing = [];
(new \classes\authentication())->create_token(222222);
})->throws(\Exception::class, 'User not found for customer number: 222222');
}
@@ -0,0 +1,14 @@
<?php
it('keeps passkey challenge legacy script green during migration', function (): void {
$result = run_legacy_script('tests/auth/PasskeyChallengeTest.php');
expect($result['exitCode'])->toBe(0, $result['output']);
});
it('keeps register CVR legacy script green during migration', function (): void {
$result = run_legacy_script('tests/auth/RegisterCvrTest.php');
expect($result['exitCode'])->toBe(0, $result['output']);
});
@@ -0,0 +1,166 @@
<?php
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/';
app_require('routes/birdVoiceWebhooksRoute.php');
final class BirdVoiceWebhookRouteTestDouble extends \routes\birdVoiceWebhooksRoute
{
public array $departmentNames = [];
public array $rawState = [];
public array $ttls = [];
public function mapDepartments(array $ids): array
{
return $this->buildDepartmentOptionMap($ids);
}
public function departmentPrompt(array $map): string
{
return $this->buildDepartmentPromptText($map);
}
public function gatePrompt(string $name): string
{
return $this->buildGateTypePromptText($name);
}
public function selectDepartment(array $map, string $digit): ?int
{
return $this->resolveDepartmentIdByDigit($map, $digit);
}
public function selectGateType(string $digit): ?string
{
return $this->resolveGateTypeByDigit($digit);
}
public function storeState(string $callId, array $state): void
{
$this->saveIvrState($callId, $state);
}
public function loadState(string $callId): ?array
{
return $this->readIvrState($callId);
}
public function removeState(string $callId): void
{
$this->clearIvrState($callId);
}
public function stateForCaller(array $state, int $countryCode, int $phone): bool
{
return $this->isStateForCaller($state, $countryCode, $phone);
}
public function stateKey(string $callId): string
{
return $this->ivrStateKey($callId);
}
protected function getDepartmentNameById(int $departmentId): string
{
return $this->departmentNames[$departmentId] ?? ('Afdeling ' . $departmentId);
}
protected function writeIvrStateRaw(string $key, string $value, int $ttlSeconds): void
{
$this->rawState[$key] = $value;
$this->ttls[$key] = $ttlSeconds;
}
protected function readIvrStateRaw(string $key): ?string
{
return $this->rawState[$key] ?? null;
}
protected function deleteIvrStateRaw(string $key): void
{
unset($this->rawState[$key], $this->ttls[$key]);
}
}
it('builds deterministic department digit map and caps to 9 options', function (): void {
$route = new BirdVoiceWebhookRouteTestDouble();
$route->departmentNames = [11 => 'Nord', 22 => 'Syd', 33 => 'Vest'];
$map = $route->mapDepartments([11, 22, 0, -5, 33, 44, 55, 66, 77, 88, 99]);
expect($map)->toHaveCount(9);
expect(array_keys($map))->toBe(['1', '2', '3', '4', '5', '6', '7', '8', '9']);
expect($map['1']['department_id'])->toBe(11);
expect($map['2']['department_id'])->toBe(22);
expect($map['3']['department_id'])->toBe(33);
expect($map['1']['department_name'])->toBe('Nord');
expect($map['2']['department_name'])->toBe('Syd');
expect($map['3']['department_name'])->toBe('Vest');
});
it('resolves department ids by selected digit', function (): void {
$route = new BirdVoiceWebhookRouteTestDouble();
$map = [
'1' => ['department_id' => 101, 'department_name' => 'A'],
'2' => ['department_id' => 202, 'department_name' => 'B'],
];
expect($route->selectDepartment($map, '1'))->toBe(101);
expect($route->selectDepartment($map, '2'))->toBe(202);
expect($route->selectDepartment($map, '9'))->toBeNull();
expect($route->selectDepartment($map, ''))->toBeNull();
});
it('resolves gate type digit to entrance or exit', function (): void {
$route = new BirdVoiceWebhookRouteTestDouble();
expect($route->selectGateType('1'))->toBe('entrance');
expect($route->selectGateType('2'))->toBe('exit');
expect($route->selectGateType('0'))->toBeNull();
expect($route->selectGateType('x'))->toBeNull();
});
it('stores, loads and clears ivr state with ttl', function (): void {
$route = new BirdVoiceWebhookRouteTestDouble();
$callId = 'abc-123';
$state = [
'stage' => 'department_select',
'department_options' => ['1' => ['department_id' => 5, 'department_name' => 'North']],
'country_code' => 45,
'phone' => 12345678,
];
$route->storeState($callId, $state);
$key = $route->stateKey($callId);
expect($route->loadState($callId))->toBe($state);
expect($route->ttls[$key] ?? null)->toBe(600);
$route->removeState($callId);
expect($route->loadState($callId))->toBeNull();
});
it('validates state caller fingerprint matching', function (): void {
$route = new BirdVoiceWebhookRouteTestDouble();
$state = ['country_code' => 45, 'phone' => 12345678];
expect($route->stateForCaller($state, 45, 12345678))->toBeTrue();
expect($route->stateForCaller($state, 46, 12345678))->toBeFalse();
expect($route->stateForCaller($state, 45, 87654321))->toBeFalse();
});
it('builds department and gate prompts', function (): void {
$route = new BirdVoiceWebhookRouteTestDouble();
$prompt = $route->departmentPrompt([
'1' => ['department_id' => 1, 'department_name' => 'Nord'],
'2' => ['department_id' => 2, 'department_name' => 'Syd'],
]);
expect($prompt)->toContain('Tast 1 for Nord');
expect($prompt)->toContain('Tast 2 for Syd');
$gatePrompt = $route->gatePrompt('Nord');
expect($gatePrompt)->toContain('Du valgte Nord.');
expect($gatePrompt)->toContain('Tast 1 for indgang. Tast 2 for udgang.');
});
@@ -0,0 +1,36 @@
<?php
$_SERVER['REQUEST_URI'] = '/test-permissions';
app_require('classes/permission_node.php');
app_require('modules/subusers/helpers/subusers_permission_node_key.php');
app_require('traits/route_t.php');
use classes\permission_node;
use modules\subusers\helpers\subusers_permission_node_key;
use traits\route_t;
class PermissionRouteTestHost
{
use route_t;
public function __construct()
{
// no-op for unit tests
}
}
it('creates permission nodes linked to subuser permission keys', function (): void {
$host = new PermissionRouteTestHost();
$node = $host->definePermission('list_own_bookings', subusers_permission_node_key::BOOKINGS_LIST);
expect($node)->toBeInstanceOf(permission_node::class);
expect($node->permission)->toBe('list_own_bookings');
expect($node->subusers_node_key)->toBe(subusers_permission_node_key::BOOKINGS_LIST);
});
it('rejects empty permission definitions', function (): void {
$host = new PermissionRouteTestHost();
$host->definePermission('');
})->throws(\Exception::class, 'Permission cannot be empty');
@@ -0,0 +1,58 @@
<?php
namespace classes {
class db {}
class object_property
{
public function __construct(...$args) {}
public function value() { return null; }
public function set($v) {}
}
}
namespace traits {
trait db_object_t {}
}
namespace {
app_require('objects/department_selfserve_tasks_o.php');
app_require('modules/selfserve/helpers/selfserve_lane_services.php');
}
namespace {
use modules\selfserve\helpers\selfserve_lane_services;
use objects\department_selfserve_tasks_o;
it('normalizes button arrays, json and csv inputs', function (): void {
expect(department_selfserve_tasks_o::normalizeButtonsInput([1, '2', 3]))->toBe([1, 2, 3]);
expect(department_selfserve_tasks_o::normalizeButtonsInput('[4,5,5,0]'))->toBe([4, 5, 0]);
expect(department_selfserve_tasks_o::normalizeButtonsInput('6, 7 ,8'))->toBe([6, 7, 8]);
});
it('rejects invalid button inputs', function (): void {
department_selfserve_tasks_o::normalizeButtonsInput('["a", 2]');
})->throws(\Exception::class);
it('normalizes vehicle type values and null semantics', function (): void {
expect(department_selfserve_tasks_o::normalizeVehicleTypeInput(3))->toBe(3);
expect(department_selfserve_tasks_o::normalizeVehicleTypeInput('4'))->toBe(4);
expect(department_selfserve_tasks_o::normalizeVehicleTypeInput(' 5 '))->toBe(5);
expect(department_selfserve_tasks_o::normalizeVehicleTypeInput(null))->toBeNull();
expect(department_selfserve_tasks_o::normalizeVehicleTypeInput(''))->toBeNull();
});
it('rejects invalid vehicle type values', function (): void {
department_selfserve_tasks_o::normalizeVehicleTypeInput(-1);
})->throws(\Exception::class);
it('rejects non-numeric vehicle type strings', function (): void {
department_selfserve_tasks_o::normalizeVehicleTypeInput('abc');
})->throws(\Exception::class);
it('keeps lane service enum contract for MACHINE', function (): void {
$names = array_map(static fn(selfserve_lane_services $case): string => $case->name, selfserve_lane_services::cases());
expect($names)->toContain('MACHINE');
});
}
@@ -0,0 +1,75 @@
<?php
app_require('modules/subusers/interfaces/subusers_permission_node_i.php');
app_require('modules/subusers/interfaces/subusers_permission_nodes_i.php');
app_require('modules/subusers/interfaces/subusers_user_grant_i.php');
app_require('modules/subusers/helpers/subusers_permission_node_key.php');
app_require('modules/subusers/helpers/subusers_permission_type.php');
app_require('modules/subusers/traits/subusers_permission_node_t.php');
app_require('modules/subusers/classes/subusers_permission_node.php');
app_require('modules/subusers/traits/subusers_permission_nodes_t.php');
app_require('modules/subusers/classes/subusers_permission_nodes.php');
app_require('modules/subusers/traits/subuser_user_grant_t.php');
app_require('modules/subusers/traits/subusers_user_permissions_t.php');
app_require('modules/subusers/permissions/subusers_permission_nodes_bookings.php');
app_require('modules/subusers/permissions/subusers_permission_nodes_orders.php');
app_require('modules/subusers/permissions/subusers_permission_nodes_selfserve.php');
app_require('modules/subusers/permissions/subusers_permission_nodes_subusers.php');
app_require('modules/subusers/permissions/subusers_permission_nodes_vehicles.php');
app_require('modules/subusers/classes/subuser_user_grant.php');
use modules\subusers\classes\subuser_user_grant;
use modules\subusers\helpers\subusers_permission_node_key;
class TestSubuserGrant extends subuser_user_grant
{
private array $injectedPermissions;
public function __construct(int $subuser_id, int $customer_number, array $injectedPermissions)
{
$this->injectedPermissions = $injectedPermissions;
parent::__construct($subuser_id, $customer_number);
}
public function loadGrants(): void
{
$reflection = new \ReflectionClass($this);
foreach ($reflection->getProperties() as $property) {
$type = $property->getType();
if ($type && is_a($type->getName(), \modules\subusers\classes\subusers_permission_nodes::class, true)) {
if (method_exists($property, 'isInitialized') && !$property->isInitialized($this)) {
continue;
}
$container = $property->getValue($this);
if ($container instanceof \modules\subusers\classes\subusers_permission_nodes) {
foreach ($this->injectedPermissions as $permission) {
$key = is_string($permission) ? $permission : $permission->name;
$node = $container->getNodeByKey($key);
if ($node) {
$node->value = true;
}
}
}
}
}
}
}
it('initializes booking node grants without DB dependency', function (): void {
$grant = new TestSubuserGrant(9999, 42424242, [subusers_permission_node_key::BOOKINGS_LIST]);
expect($grant->hasNode(subusers_permission_node_key::BOOKINGS_LIST))->toBeTrue();
expect($grant->hasNode(subusers_permission_node_key::BOOKINGS_EDIT))->toBeFalse();
expect($grant->hasNode(subusers_permission_node_key::BOOKINGS_DELETE))->toBeFalse();
});
it('initializes selfserve node grants without DB dependency', function (): void {
$grant = new TestSubuserGrant(123, 456, [subusers_permission_node_key::SELFSERVE_ADD]);
expect($grant->hasNode(subusers_permission_node_key::SELFSERVE_ADD))->toBeTrue();
expect($grant->hasNode(subusers_permission_node_key::SELFSERVE_LIST))->toBeFalse();
expect($grant->hasNode(subusers_permission_node_key::SELFSERVE_EDIT))->toBeFalse();
expect($grant->hasNode(subusers_permission_node_key::SELFSERVE_DELETE))->toBeFalse();
});
@@ -0,0 +1,13 @@
<?php
it('keeps subusers route list permission linked to SUBUSERS_LIST node', function (): void {
$routeFile = app_path('routes/subusersRoute.php');
expect(is_file($routeFile))->toBeTrue();
$code = (string)file_get_contents($routeFile);
$normalized = preg_replace('/\s+/', ' ', $code);
$needle = "definePermission('list_own_subusers', subusers_permission_node_key::SUBUSERS_LIST)";
expect($normalized)->toContain($needle);
});
@@ -0,0 +1,119 @@
<?php
/**
* DepartmentDailyTargetsRendererTest.php
*
* Minimal, env-agnostic test to validate that custom per-department daily targets
* are taken into account by the Slack renderer and are shown in the message.
*/
namespace {
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
}
// --- Lightweight stubs to avoid DB access ---
namespace objects {
class departments_o {
public int $id = 0;
public $name;
public function select(int $id): self {
$o = new self();
$o->id = $id;
$o->name = new class($id) {
private int $id; public function __construct(int $id){ $this->id = $id; }
public function value(): string { return 'Afdeling ' . $this->id; }
};
return $o;
}
public function exists(): bool { return true; }
}
}
namespace {
// --- Includes: enums, traits, interfaces, classes used by goals_criteria ---
require_once WD . '/modules/goals/helpers/goals_criteria_type.php';
require_once WD . '/modules/goals/helpers/goals_criteria_progress_alert_frequency.php';
require_once WD . '/modules/goals/helpers/goals_criteria_progress_alert_destination.php';
require_once WD . '/modules/goals/helpers/goals_criteria_progress_alert_progress_type.php';
require_once WD . '/modules/goals/helpers/goals_criteria_progress_alert_style.php';
require_once WD . '/modules/goals/helpers/goals_criteria_progress_alert_weekday.php';
require_once WD . '/modules/goals/traits/goals_timeframe_t.php';
require_once WD . '/modules/goals/traits/goals_target_t.php';
require_once WD . '/modules/goals/traits/goals_result_parser_t.php';
require_once WD . '/modules/goals/traits/goals_label_t.php';
require_once WD . '/modules/goals/interfaces/goals_criteria_i.php';
require_once WD . '/modules/goals/interfaces/goals_criteria_users_i.php';
require_once WD . '/modules/goals/interfaces/goals_criteria_departments_i.php';
require_once WD . '/modules/goals/interfaces/goals_criteria_products_i.php';
require_once WD . '/modules/goals/traits/goals_criteria_users_t.php';
require_once WD . '/modules/goals/traits/goals_criteria_departments_t.php';
require_once WD . '/modules/goals/traits/goals_criteria_products_t.php';
require_once WD . '/modules/goals/classes/goals_criteria_users.php';
require_once WD . '/modules/goals/classes/goals_criteria_departments.php';
require_once WD . '/modules/goals/classes/goals_criteria_products.php';
require_once WD . '/modules/goals/classes/goals_criteria.php';
require_once WD . '/modules/goals/traits/goals_criteria_progress_alert_format_t.php';
require_once WD . '/modules/goals/formats/slack_progress_alert_format.php';
require_once WD . '/modules/goals/formats/email_progress_alert_format.php';
require_once WD . '/modules/goals/formats/sms_progress_alert_format.php';
require_once WD . '/modules/goals/services/goals_progress_alert_renderer.php';
use goals\classes\goals_criteria;
use goals\helpers\goals_criteria_type as Type;
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
use goals\helpers\goals_criteria_progress_alert_progress_type as PType;
// Build a criteria with two departments and custom per-department daily targets
$criteria = new goals_criteria();
$criteria->type = Type::NONE; // avoid DB in progress calc
$criteria->target = 100; // overall target (not used when overrides provided, but kept for completeness)
$criteria->label = 'Testmål';
$criteria->start = new \DateTime('yesterday 00:00:00');
$criteria->end = new \DateTime('yesterday 23:59:59');
$criteria->progress_alert_destination = Dest::SLACK;
$criteria->progress_alert_progress_type = PType::ALL;
// Departments (ids only)
$criteria->departments->set([
(new \objects\departments_o())->select(12),
(new \objects\departments_o())->select(15)
]);
// Override daily targets: dept 12 => 3, dept 15 => 5
$criteria->department_daily_targets = [12 => 3, 15 => 5];
// Render the Slack message
$msg = \goals\services\goals_progress_alert_renderer::render($criteria);
// Debug output
echo "--- Message ---\n" . $msg . "\n---------------\n";
// Expectations:
$expect1 = 'Daglige mål: Afdeling 12=3, Afdeling 15=5';
$expect2 = '*Igår:* 0 ud af 8 (0.00%)'; // one day range, total target = 3 + 5 = 8
$ok = true;
if (strpos($msg, $expect1) === false) {
echo "✖ Missing daily targets header\n";
$ok = false;
} else {
echo "✔ Daily targets header present\n";
}
if (strpos($msg, $expect2) === false) {
echo "✖ Incorrect total target computation for period (expected 'Igår: 0 ud af 8 (0.00%)')\n";
$ok = false;
} else {
echo "✔ Period total uses sum of department targets\n";
}
if ($ok) {
echo "DepartmentDailyTargetsRendererTest completed.\n";
exit(0);
}
exit(1);
}
@@ -0,0 +1,104 @@
<?php
/**
* MonthlyTargetRendererTest.php
*
* Verifies that the monthly line in goals_progress_alert_renderer uses
* the fixed monthly target (criteria->target) instead of a day-derived target.
*/
namespace {
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
}
// --- Lightweight stubs to avoid DB access ---
namespace objects {
class departments_o {
public int $id = 0;
public $name;
public function select(int $id): self {
$o = new self();
$o->id = $id;
$o->name = new class($id) {
private int $id;
public function __construct(int $id) { $this->id = $id; }
public function value(): string { return 'Afdeling ' . $this->id; }
};
return $o;
}
public function exists(): bool { return true; }
}
}
namespace {
require_once WD . '/modules/goals/helpers/goals_criteria_type.php';
require_once WD . '/modules/goals/helpers/goals_criteria_progress_alert_frequency.php';
require_once WD . '/modules/goals/helpers/goals_criteria_progress_alert_destination.php';
require_once WD . '/modules/goals/helpers/goals_criteria_progress_alert_progress_type.php';
require_once WD . '/modules/goals/helpers/goals_criteria_progress_alert_style.php';
require_once WD . '/modules/goals/helpers/goals_criteria_progress_alert_weekday.php';
require_once WD . '/modules/goals/traits/goals_timeframe_t.php';
require_once WD . '/modules/goals/traits/goals_target_t.php';
require_once WD . '/modules/goals/traits/goals_result_parser_t.php';
require_once WD . '/modules/goals/traits/goals_label_t.php';
require_once WD . '/modules/goals/interfaces/goals_criteria_i.php';
require_once WD . '/modules/goals/interfaces/goals_criteria_users_i.php';
require_once WD . '/modules/goals/interfaces/goals_criteria_departments_i.php';
require_once WD . '/modules/goals/interfaces/goals_criteria_products_i.php';
require_once WD . '/modules/goals/traits/goals_criteria_users_t.php';
require_once WD . '/modules/goals/traits/goals_criteria_departments_t.php';
require_once WD . '/modules/goals/traits/goals_criteria_products_t.php';
require_once WD . '/modules/goals/classes/goals_criteria_users.php';
require_once WD . '/modules/goals/classes/goals_criteria_departments.php';
require_once WD . '/modules/goals/classes/goals_criteria_products.php';
require_once WD . '/modules/goals/classes/goals_criteria.php';
require_once WD . '/modules/goals/traits/goals_criteria_progress_alert_format_t.php';
require_once WD . '/modules/goals/formats/slack_progress_alert_format.php';
require_once WD . '/modules/goals/formats/email_progress_alert_format.php';
require_once WD . '/modules/goals/formats/sms_progress_alert_format.php';
require_once WD . '/modules/goals/services/goals_progress_alert_renderer.php';
use goals\classes\goals_criteria;
use goals\helpers\goals_criteria_type as Type;
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
use goals\helpers\goals_criteria_progress_alert_progress_type as PType;
$criteria = new goals_criteria();
$criteria->type = Type::NONE; // keeps count deterministic (0)
$criteria->target = 70; // fixed monthly target we want to enforce
$criteria->label = 'Maanedsmaal test';
$criteria->start = new \DateTime('first day of this month 00:00:00');
$criteria->end = new \DateTime('now');
$criteria->progress_alert_destination = Dest::NONE;
$criteria->progress_alert_progress_type = PType::ALL;
$criteria->departments->set([
(new \objects\departments_o())->select(12)
]);
$msg = \goals\services\goals_progress_alert_renderer::render($criteria);
echo "--- Message ---\n" . $msg . "\n---------------\n";
$ok = true;
if (strpos($msg, 'ud af 70') === false) {
echo "FAIL: Monthly target is not rendered as 70.\n";
$ok = false;
} else {
echo "OK: Monthly target rendered with denominator 70.\n";
}
if (strpos($msg, 'Afdeling 12: 0 ud af 70 (0.00%)') === false) {
echo "FAIL: Department monthly line does not use fixed monthly target 70.\n";
$ok = false;
} else {
echo "OK: Department monthly line uses fixed monthly target.\n";
}
exit($ok ? 0 : 1);
}
+11
View File
@@ -0,0 +1,11 @@
### POST request to /auth/2fa/setup
POST http://localhost/api/auth/2fa/setup
Accept: application/json
Content-Type: application/json
Authorization: Bearer 9ed711260d58165ee7601060bf7463f6e201481edd16c8ee4fc4a5f04b79a232
X-Customer-Number: 42331128
{
"phone": 42331128,
"country_code": 45
}
+27 -1
View File
@@ -26,9 +26,35 @@ Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
### POST /bird/voice/calls/webhook/inbound
POST http://localhost/bird/voice/calls/webhook/inbound
Accept: application/json
content-type: application/json
{
"service": "channels",
"event": "voice.inbound",
"url": "https://yoururl.com",
"signingKey": "mysecretkey",
"eventFilters": [
{
"key": "channelId",
"value": "b827924e-9789-4c2f-a4d5-b352175354f6"
},
{
"key": "status",
"value": "completed"
},
{
"key": "status",
"value": "starting"
}
]
}
### POST request to /subusers/setup
POST https://api.truckwash.dk:4433/subusers/setup
POST http://localhost/api/subusers/setup
Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8