Add API test suite for Bird Voice Webhook endpoints, including comprehensive lifecycle tests for inbound call handling scenarios. Extend fixtures with department gate creation support and update OpenAPI spec validations.

This commit is contained in:
Jeppe Bundgaard
2026-04-13 17:08:37 +02:00
parent 2a8e8986e6
commit 8465ee794d
16 changed files with 3036 additions and 2106 deletions
+30
View File
@@ -55,9 +55,18 @@ All commands are run from `services/nginx/app`:
composer test
composer test:unit
composer test:integration
composer test:api
composer test:coverage
```
For local Docker development, run the PHP suites inside `php1`:
```powershell
docker exec php1 sh -lc "cd /var/www/html && composer test:unit"
docker exec php1 sh -lc "cd /var/www/html && composer test:integration"
docker exec php1 sh -lc "cd /var/www/html && composer test:api"
```
Integration tests are opt-in and should be run with required services available:
```powershell
@@ -65,6 +74,25 @@ $env:RUN_INTEGRATION_TESTS='1'
composer test:integration
```
### API Test Suite
The `Api` suite exercises real HTTP endpoints instead of calling route handlers in-process.
- `composer test:api` enables `RUN_API_TESTS=1` automatically.
- By default the suite starts a temporary PHP server with `php -S 127.0.0.1:18080 index.php` and hits the app over HTTP.
- The suite covers the real router, request parsing, auth headers, status codes, and JSON response envelopes.
- Tests run serially and use explicit database/Redis fixtures plus reverse-order cleanup instead of transaction rollbacks.
- The default phase-1 coverage includes `/ping`, auth session routes, departments, department categories, and orders CRUD including the legacy `PUT /order` alias.
- API server logs are written to `services/nginx/app/build/logs/api-server.out.log` and `services/nginx/app/build/logs/api-server.err.log`.
To point the suite at an already-running base URL instead of the self-started PHP server:
```powershell
$env:API_TEST_BASE_URL='http://127.0.0.1:18080'
composer test:api
```
The suite requires an initialized application schema. Redis-backed flows are used when Redis is configured, but the suite can still boot without `caddy` or the full reverse-proxy stack.
### Run Tests Against A Cloned Live DB (Docker-Isolated)
Use the helper scripts in `scripts/` to:
1. Clone the configured live DB into a local MySQL Docker container.
@@ -103,6 +131,8 @@ FORCE=1 ./scripts/clone-live-to-debug-db.sh
### Test Layout
- `tests/Unit/*`: isolated unit and route-level behavior tests.
- `tests/Integration/*`: Redis/DB-backed tests intended for Docker/CI environments.
- `tests/Api/*`: real HTTP endpoint tests that boot a temporary PHP server and assert full request/response behavior.
- `tests/Api/api_coverage_manifest.php`: selected phase-1 endpoint manifest used by the API meta-test to enforce happy-path and failure coverage.
- `tests/<legacy-domain>/*`: legacy procedural scripts retained during migration; keep them runnable until matching Pest coverage exists.
## Logs & Monitoring
+289 -92
View File
@@ -901,16 +901,22 @@ paths:
summary: Process inbound Bird voice call lifecycle
operationId: birdInboundVoiceCallWebhook
description: >
Stateful inbound-call webhook that answers the call immediately, runs a DTMF-driven IVR for
phone-controlled entrance and exit gates, and enforces a timeout hangup flow. During the
first 300 seconds from call start, DTMF input is extracted from payload fields such as
`dtmf`, `digit`, `digits`, `keys`, `result.keys`, and nested `conditions[].value`. Values
like `1#` are normalized to a single menu digit before the route resolves the department
selection and then the gate type selection (`1=entrance`, `2=exit`). When a valid gate is
resolved, the webhook triggers the corresponding `department_gates` phone-call gate, announces
the result, sends a hangup command, and keeps the completed state cached until Bird reports a
terminal call status. At or after 300 seconds, the webhook says `timeout reached`, waits 10
seconds, sends a hangup command once, and polls Bird call status until terminal.
Stateful inbound-call webhook that owns department and gate selection for phone-controlled
gates. The preferred Bird Flow Builder integration is the native-flow mode:
send top-level `callId`, `channelId`, and `workspaceId` to fetch IVR prompt data,
then submit the selected DTMF digits as `keys` in a follow-up request. In this mode
the webhook returns a plain `200 OK` JSON body with `prompt`, `stage`, and `gather`
settings that Bird native voice steps can consume directly. For backward compatibility,
the webhook also supports the older `{ payload, request, waitConditions }` contract and
returns a raw `202 Accepted` Bird `callCommand` gather envelope that resumes on
`call_command_gather_finished`. The Bird Flow itself must answer the inbound call before
invoking this HTTP step; the backend answer attempt is only a best-effort fallback.
Department options are generated from `department_gates` records with `config.type=PHONE_CALL`,
ordered by `departments.order_priority`, and support multi-digit DTMF selections such as `10#`.
After a department is chosen, the webhook returns compact gate options, for example `Press 1 for exit`
when exit is the only available phone-controlled gate for that department. When a valid gate is confirmed,
the webhook opens the gate through the corresponding `department_gates` phone-call record and returns
a `200 OK` completion result.
parameters:
- in: query
name: workspaceId
@@ -940,11 +946,25 @@ paths:
$ref: '#/components/schemas/BirdInboundCallWebhookRequest'
responses:
'200':
description: Lifecycle phase result for this webhook invocation
description: Native-flow gather data or gate action result for this webhook invocation
content:
application/json:
schema:
$ref: '#/components/schemas/BirdInboundCallWebhookResponse'
oneOf:
- $ref: '#/components/schemas/BirdInboundCallWebhookFlowGatherResponse'
- $ref: '#/components/schemas/BirdInboundCallWebhookActionResultResponse'
'202':
description: Gather command accepted and returned to Bird
content:
application/json:
schema:
$ref: '#/components/schemas/BirdInboundCallWebhookGatherAcceptedResponse'
'400':
description: Malformed Bird webhook payload
content:
application/json:
schema:
$ref: '#/components/schemas/BirdInboundCallWebhookTransportErrorResponse'
# Bird Numbers
/bird/numbers:
@@ -16460,122 +16480,299 @@ components:
channelId:
type: string
format: uuid
status:
type: string
description: Optional inbound call status from webhook payload
example: ongoing
payload:
type: object
additionalProperties: true
properties:
endKey:
type: string
example: "#"
retries:
type: integer
example: 3
timeout:
type: integer
example: 30
say:
type: object
additionalProperties: true
properties:
locale:
type: string
example: "en-US"
voice:
type: string
example: "female"
request:
type: object
additionalProperties: true
properties:
callId:
type: string
example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09"
workspaceId:
type: string
format: uuid
channelId:
type: string
format: uuid
waitConditions:
type: object
additionalProperties: true
properties:
timeout:
type: string
example: "PT10M"
events:
type: array
items:
type: object
additionalProperties: true
properties:
action:
type: string
example: "continue"
name:
type: string
example: "call_command_gather_finished"
event:
type: object
additionalProperties: true
result:
type: object
additionalProperties: true
resumeData:
type: object
additionalProperties: true
dtmf:
type: string
description: DTMF value when present, for example `1` or `1#`
example: "5"
description: DTMF value when present, for example `1`, `1#`, or `10#`
example: "10#"
digit:
type: string
description: Alternate DTMF field, also accepts values such as `1#`
example: "5"
description: Alternate DTMF field, also accepts values such as `10#`
example: "10#"
digits:
type: string
description: Alternate DTMF field
example: "5"
example: "10#"
keys:
type: string
description: Alternate DTMF field returned by gather results
example: "1#"
call:
type: object
additionalProperties: true
data:
type: object
additionalProperties: true
event:
example: "10#"
key:
type: string
example: "1"
input:
oneOf:
- type: string
- type: object
additionalProperties: true
conditions:
type: array
items:
type: object
additionalProperties: true
BirdInboundCallWebhookResponse:
type: object
oneOf:
- $ref: '#/components/schemas/BirdInboundCallWebhookFlowGatherResponse'
- $ref: '#/components/schemas/BirdInboundCallWebhookActionResultResponse'
- $ref: '#/components/schemas/BirdInboundCallWebhookGatherAcceptedResponse'
- $ref: '#/components/schemas/BirdInboundCallWebhookTransportErrorResponse'
BirdInboundCallWebhookFlowGatherResponse:
type: object
properties:
success:
requestId:
type: string
example: "request-123"
callId:
type: string
example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09"
status:
type: string
enum: [gather]
completed:
type: boolean
example: true
data:
enum: [false]
stage:
type: string
enum: [department_select, gate_type_select]
prompt:
type: string
gather:
type: object
properties:
phase:
input:
type: string
enum: [lock_not_acquired, input_window, timeout_window, completed, terminal_completion]
stage:
enum: [dtmf]
maxNumKeys:
type: integer
endKey:
type: string
nullable: true
enum: [department_select, gate_type_select, completed]
call_id:
type: string
example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09"
completed:
type: boolean
elapsed_seconds:
example: "#"
timeout:
type: integer
retries:
type: integer
say:
type: object
properties:
locale:
type: string
example: "en-US"
voice:
type: string
example: "female"
text:
type: string
selection:
type: object
properties:
departmentId:
type: integer
nullable: true
timeout_seconds:
type: integer
nullable: true
input_received:
type: boolean
nullable: true
input_changed:
type: boolean
nullable: true
last_input:
departmentName:
type: string
nullable: true
selected_department_id:
type: integer
nullable: true
selected_gate_type:
gateType:
type: string
nullable: true
enum: [entrance, exit]
gate_id:
gateId:
type: integer
nullable: true
gate_opened:
invalidSelectionCount:
type: integer
resumed:
type: boolean
statusCode:
type: integer
enum: [200]
statusText:
type: string
enum: [OK]
BirdInboundCallWebhookActionResultResponse:
type: object
properties:
requestId:
type: string
example: "request-123"
result:
type: object
properties:
callId:
type: string
example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09"
status:
type: string
enum: [completed, failed, ignored]
action:
type: string
enum: [gate_opened, gate_open_failed, no_action, ignored]
message:
type: string
departmentId:
type: integer
nullable: true
gateType:
type: string
nullable: true
enum: [entrance, exit]
gateId:
type: integer
nullable: true
gateOpened:
type: boolean
nullable: true
answered_at:
type: integer
nullable: true
timeout_announced_at:
type: integer
nullable: true
hangup_sent_at:
type: integer
nullable: true
poll_attempts:
type: integer
nullable: true
poll_error:
resumeData:
type: object
additionalProperties: true
properties:
action:
type: string
nullable: true
terminal_status:
example: "continue"
completed:
type: boolean
example: true
result:
type: string
nullable: true
reason:
example: "gate_opened"
gateOpened:
type: boolean
example: true
completedAt:
type: string
format: date-time
statusCode:
type: integer
enum: [200]
statusText:
type: string
enum: [OK]
BirdInboundCallWebhookGatherAcceptedResponse:
type: object
properties:
event:
type: object
additionalProperties: true
requestId:
type: string
example: "request-123"
result:
type: object
properties:
callId:
type: string
example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09"
command:
type: string
enum: [gather]
id:
type: string
status:
type: string
enum: [accepted]
resumeData:
type: object
additionalProperties: true
properties:
action:
type: string
example: "continue"
statusCode:
type: integer
enum: [202]
statusText:
type: string
enum: [Accepted]
suspendedAt:
type: string
format: date-time
resumedAt:
type: string
format: date-time
nullable: true
BirdInboundCallWebhookTransportErrorResponse:
type: object
properties:
requestId:
type: string
statusCode:
type: integer
enum: [400, 500]
statusText:
type: string
enum: [Bad Request, Internal Server Error]
error:
type: object
properties:
message:
type: string
nullable: true
meta:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
example: []
includes:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
example: []
BirdVoiceCallCreateRequest:
type: object
@@ -7,6 +7,26 @@ use InvalidArgumentException;
class orders_input_normalizer
{
public static function normalizeRegistrationNumber(mixed $value): string
{
if ($value === null) {
return '';
}
if (!is_scalar($value)) {
throw new InvalidArgumentException('registration number must be a string');
}
$normalized = strtoupper(trim((string)$value));
$normalized = preg_replace('/[^A-Z0-9]/', '', $normalized);
if (!is_string($normalized)) {
throw new InvalidArgumentException('registration number must be a string');
}
return $normalized;
}
public static function normalizeCreatedAt(mixed $value): string
{
if ($value instanceof \DateTimeInterface) {
+19 -1
View File
@@ -56,6 +56,24 @@ class response implements response_i
exit;
}
#[NoReturn] public function rawJson(mixed $data, int $status = 200): void
{
header('Content-Type: application/json; charset=utf-8');
http_response_code($status);
if (!is_array($data) && !is_object($data)) {
if (is_string($data) && json_decode($data) !== null) {
echo $data;
exit;
}
$data = ['message' => $data];
}
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
public function add_include(string $string, array $dataArray): void
{
$this->add_included($string, $dataArray);
@@ -286,4 +304,4 @@ class response implements response_i
// Return the user object
return $this->users_o;
}
}
}
+3 -2
View File
@@ -4,9 +4,10 @@ namespace interfaces;
interface response_i
{
public function response(bool $success, array $data, int $status = null): void;
public function response(bool $success, mixed $data, int $status = null): void;
public function success(mixed $data, int $status = null): void;
public function error(mixed $data, int $status = null): void;
public function rawJson(mixed $data, int $status = 200): void;
public function not_found(): void;
public function matching_route_found(): void;
public function method_not_allowed(): void;
@@ -14,4 +15,4 @@ interface response_i
public function forbidden(array $permissions): void;
public function add_data(string $key, mixed $value): void;
public function add_debug(mixed $data): void;
}
}
+86
View File
@@ -32,6 +32,92 @@ This module provides Bird API integration for voice calls and number management.
- `maxPollSeconds` (default `30`)
- `hangupCause`
- `POST /bird/voice/calls/webhook/inbound`
- Permission: `modules_bird_voice_call_webhooks_trigger`
- Stateful inbound IVR webhook for phone-controlled department gates.
- On the initial request, the webhook accepts the live inbound call through Bird before returning the first gather command.
- Supports both request formats:
- Native-flow mode: top-level `{ callId, channelId, workspaceId }` for the initial fetch, then `{ callId, channelId, workspaceId, keys }` for selections.
- Compatibility mode: `{ payload, request, waitConditions }`, which returns a raw Bird `callCommand` gather envelope.
- Uses `department_gates` rows with `config.type=PHONE_CALL` as the source of truth for which departments and entrance/exit gates are offered.
- Department menu order follows `departments.order_priority`.
- Supports multi-digit department selections such as `10#`.
- Always prompts for both department selection and gate selection, even when only one valid option exists.
- Uses compact gate numbering:
- both gates available: `1=entrance`, `2=exit`
- entrance only: `1=entrance`
- exit only: `1=exit`
### Bird Flow Builder setup
- Preferred native-flow setup:
- `Voice` trigger
- `Answer call`
- `Async HTTP request` (initial IVR state fetch)
- `Gather digits from a call`
- `Async HTTP request` (submit selected keys)
- If the second HTTP response returns `completed=false`, run another `Gather digits from a call` with the returned prompt and gather settings.
- If the second HTTP response returns `completed=true`, end the flow or optionally say the returned `message`.
- The caller must be connected in Bird before the HTTP webhook step runs. If the flow omits `Answer call`, the inbound call can keep ringing until Bird times it out.
- Initial HTTP step:
- URL: `https://api.truckwash.io:4433/bird/voice/calls/webhook/inbound`
- Content type: `application/json`
- Body:
```json
{
"callId": "{{callId}}",
"channelId": "{{channelId}}",
"workspaceId": "{{workspaceId}}"
}
```
- Example native-flow response:
```json
{
"requestId": "request-123",
"callId": "4015cf84-8028-46a1-a0d9-9213e5bf4f09",
"status": "gather",
"completed": false,
"stage": "department_select",
"prompt": "Choose department. Press 1 for Roskilde. Press 2 for Demo.",
"gather": {
"input": "dtmf",
"maxNumKeys": 1,
"endKey": "#",
"timeout": 30,
"retries": 3,
"say": {
"locale": "en-US",
"voice": "female",
"text": "Choose department. Press 1 for Roskilde. Press 2 for Demo."
}
}
}
```
- Use the initial HTTP step output to configure `Gather digits from a call`:
- Prompt/text from `prompt` or `gather.say.text`
- `maxNumKeys`, `endKey`, `timeout`, and `retries` from `gather`
- TTS `locale` and `voice` from `gather.say`
- Selection HTTP step:
- POST the same `callId`, `channelId`, and `workspaceId` plus the gathered digits as `keys`.
- Example body:
```json
{
"callId": "{{callId}}",
"channelId": "{{channelId}}",
"workspaceId": "{{workspaceId}}",
"keys": "{{gatheredKeys}}"
}
```
- Bird's current Voice API examples use BCP-47 locales such as `en-US` and TTS voices such as `female`/`male`. Avoid undocumented values like `alice`.
- Compatibility mode:
- The webhook still supports the older `{ payload, request, waitConditions }` contract and returns a raw `202 Accepted` Bird `callCommand` gather envelope.
- That mode is not suitable for Bird Flow Builder `Async HTTP request`, because Bird treats the response as data rather than executing the returned call command.
### Numbers
- `GET /bird/numbers`
- Permission: `modules_bird_numbers_list`
+288 -92
View File
@@ -901,16 +901,22 @@ paths:
summary: Process inbound Bird voice call lifecycle
operationId: birdInboundVoiceCallWebhook
description: >
Stateful inbound-call webhook that answers the call immediately, runs a DTMF-driven IVR for
phone-controlled entrance and exit gates, and enforces a timeout hangup flow. During the
first 300 seconds from call start, DTMF input is extracted from payload fields such as
`dtmf`, `digit`, `digits`, `keys`, `result.keys`, and nested `conditions[].value`. Values
like `1#` are normalized to a single menu digit before the route resolves the department
selection and then the gate type selection (`1=entrance`, `2=exit`). When a valid gate is
resolved, the webhook triggers the corresponding `department_gates` phone-call gate, announces
the result, sends a hangup command, and keeps the completed state cached until Bird reports a
terminal call status. At or after 300 seconds, the webhook says `timeout reached`, waits 10
seconds, sends a hangup command once, and polls Bird call status until terminal.
Stateful inbound-call webhook that owns department and gate selection for phone-controlled
gates. The preferred Bird Flow Builder integration is the native-flow mode:
send top-level `callId`, `channelId`, and `workspaceId` to fetch IVR prompt data,
then submit the selected DTMF digits as `keys` in a follow-up request. In this mode
the webhook returns a plain `200 OK` JSON body with `prompt`, `stage`, and `gather`
settings that Bird native voice steps can consume directly. For backward compatibility,
the webhook also supports the older `{ payload, request, waitConditions }` contract and
returns a raw `202 Accepted` Bird `callCommand` gather envelope that resumes on
`call_command_gather_finished`. The Bird Flow itself must answer the inbound call before
invoking this HTTP step; the backend answer attempt is only a best-effort fallback.
Department options are generated from `department_gates` records with `config.type=PHONE_CALL`,
ordered by `departments.order_priority`, and support multi-digit DTMF selections such as `10#`.
After a department is chosen, the webhook returns compact gate options, for example `Press 1 for exit`
when exit is the only available phone-controlled gate for that department. When a valid gate is confirmed,
the webhook opens the gate through the corresponding `department_gates` phone-call record and returns
a `200 OK` completion result.
parameters:
- in: query
name: workspaceId
@@ -940,11 +946,25 @@ paths:
$ref: '#/components/schemas/BirdInboundCallWebhookRequest'
responses:
'200':
description: Lifecycle phase result for this webhook invocation
description: Native-flow gather data or gate action result for this webhook invocation
content:
application/json:
schema:
$ref: '#/components/schemas/BirdInboundCallWebhookResponse'
oneOf:
- $ref: '#/components/schemas/BirdInboundCallWebhookFlowGatherResponse'
- $ref: '#/components/schemas/BirdInboundCallWebhookActionResultResponse'
'202':
description: Gather command accepted and returned to Bird
content:
application/json:
schema:
$ref: '#/components/schemas/BirdInboundCallWebhookGatherAcceptedResponse'
'400':
description: Malformed Bird webhook payload
content:
application/json:
schema:
$ref: '#/components/schemas/BirdInboundCallWebhookTransportErrorResponse'
# Bird Numbers
/bird/numbers:
@@ -16470,122 +16490,298 @@ components:
channelId:
type: string
format: uuid
status:
type: string
description: Optional inbound call status from webhook payload
example: ongoing
payload:
type: object
additionalProperties: true
properties:
endKey:
type: string
example: "#"
retries:
type: integer
example: 3
timeout:
type: integer
example: 30
say:
type: object
additionalProperties: true
properties:
locale:
type: string
example: "en-US"
voice:
type: string
example: "female"
request:
type: object
additionalProperties: true
properties:
callId:
type: string
example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09"
workspaceId:
type: string
format: uuid
channelId:
type: string
format: uuid
waitConditions:
type: object
additionalProperties: true
properties:
timeout:
type: string
example: "PT10M"
events:
type: array
items:
type: object
additionalProperties: true
properties:
action:
type: string
example: "continue"
name:
type: string
example: "call_command_gather_finished"
event:
type: object
additionalProperties: true
result:
type: object
additionalProperties: true
resumeData:
type: object
additionalProperties: true
dtmf:
type: string
description: DTMF value when present, for example `1` or `1#`
example: "5"
description: DTMF value when present, for example `1`, `1#`, or `10#`
example: "10#"
digit:
type: string
description: Alternate DTMF field, also accepts values such as `1#`
example: "5"
description: Alternate DTMF field, also accepts values such as `10#`
example: "10#"
digits:
type: string
description: Alternate DTMF field
example: "5"
example: "10#"
keys:
type: string
description: Alternate DTMF field returned by gather results
example: "1#"
call:
type: object
additionalProperties: true
data:
type: object
additionalProperties: true
event:
example: "10#"
key:
type: string
example: "1"
input:
oneOf:
- type: string
- type: object
additionalProperties: true
conditions:
type: array
items:
type: object
additionalProperties: true
BirdInboundCallWebhookResponse:
type: object
oneOf:
- $ref: '#/components/schemas/BirdInboundCallWebhookFlowGatherResponse'
- $ref: '#/components/schemas/BirdInboundCallWebhookActionResultResponse'
- $ref: '#/components/schemas/BirdInboundCallWebhookGatherAcceptedResponse'
- $ref: '#/components/schemas/BirdInboundCallWebhookTransportErrorResponse'
BirdInboundCallWebhookFlowGatherResponse:
type: object
properties:
success:
requestId:
type: string
example: "request-123"
callId:
type: string
example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09"
status:
type: string
enum: [gather]
completed:
type: boolean
example: true
data:
enum: [false]
stage:
type: string
enum: [department_select, gate_type_select]
prompt:
type: string
gather:
type: object
properties:
phase:
input:
type: string
enum: [lock_not_acquired, input_window, timeout_window, completed, terminal_completion]
stage:
enum: [dtmf]
maxNumKeys:
type: integer
endKey:
type: string
nullable: true
enum: [department_select, gate_type_select, completed]
call_id:
type: string
example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09"
completed:
type: boolean
elapsed_seconds:
example: "#"
timeout:
type: integer
retries:
type: integer
say:
type: object
properties:
locale:
type: string
example: "en-US"
voice:
type: string
example: "female"
text:
type: string
selection:
type: object
properties:
departmentId:
type: integer
nullable: true
timeout_seconds:
type: integer
nullable: true
input_received:
type: boolean
nullable: true
input_changed:
type: boolean
nullable: true
last_input:
departmentName:
type: string
nullable: true
selected_department_id:
type: integer
nullable: true
selected_gate_type:
gateType:
type: string
nullable: true
enum: [entrance, exit]
gate_id:
gateId:
type: integer
nullable: true
gate_opened:
invalidSelectionCount:
type: integer
resumed:
type: boolean
statusCode:
type: integer
enum: [200]
statusText:
type: string
enum: [OK]
BirdInboundCallWebhookActionResultResponse:
type: object
properties:
requestId:
type: string
example: "request-123"
result:
type: object
properties:
callId:
type: string
example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09"
status:
type: string
enum: [completed, failed, ignored]
action:
type: string
enum: [gate_opened, gate_open_failed, no_action, ignored]
message:
type: string
departmentId:
type: integer
nullable: true
gateType:
type: string
nullable: true
enum: [entrance, exit]
gateId:
type: integer
nullable: true
gateOpened:
type: boolean
nullable: true
answered_at:
type: integer
nullable: true
timeout_announced_at:
type: integer
nullable: true
hangup_sent_at:
type: integer
nullable: true
poll_attempts:
type: integer
nullable: true
poll_error:
resumeData:
type: object
additionalProperties: true
properties:
action:
type: string
nullable: true
terminal_status:
example: "continue"
completed:
type: boolean
example: true
result:
type: string
nullable: true
reason:
example: "gate_opened"
gateOpened:
type: boolean
example: true
completedAt:
type: string
format: date-time
statusCode:
type: integer
enum: [200]
statusText:
type: string
enum: [OK]
BirdInboundCallWebhookGatherAcceptedResponse:
type: object
properties:
event:
type: object
additionalProperties: true
requestId:
type: string
example: "request-123"
result:
type: object
properties:
callId:
type: string
example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09"
command:
type: string
enum: [gather]
id:
type: string
status:
type: string
example: "accepted"
resumeData:
type: object
properties:
action:
type: string
example: "continue"
resumedAt:
type: string
format: date-time
nullable: true
suspendedAt:
type: string
format: date-time
statusCode:
type: integer
enum: [202]
statusText:
type: string
enum: [Accepted]
BirdInboundCallWebhookTransportErrorResponse:
type: object
properties:
requestId:
type: string
statusCode:
type: integer
enum: [400, 500]
statusText:
type: string
enum: [Bad Request, Internal Server Error]
error:
type: object
properties:
message:
type: string
nullable: true
meta:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
example: []
includes:
oneOf:
- type: array
items: {}
- type: object
additionalProperties: true
example: []
BirdVoiceCallCreateRequest:
type: object
File diff suppressed because it is too large Load Diff
+67 -15
View File
@@ -133,16 +133,10 @@ class ordersRoute
if ($targetUser->requiresReference() && empty($data['reference'])) {
$response->error('Reference is required by the customer', 400);
}
// Get the registration number
$reg_1 = $data['reg_1'];
// Get the registration numbers (If they are set, they 2-3 are optional)
$reg_2 = $data['reg_2'] ?? '';
$reg_3 = $data['reg_3'] ?? '';
// Strip the registration numbers of any whitespace
$reg_1 = preg_replace('/\s+/', '', $reg_1);
$reg_2 = preg_replace('/\s+/', '', $reg_2);
$reg_3 = preg_replace('/\s+/', '', $reg_3);
try {
$reg_1 = orders_input_normalizer::normalizeRegistrationNumber($data['reg_1']);
$reg_2 = orders_input_normalizer::normalizeRegistrationNumber($data['reg_2'] ?? '');
$reg_3 = orders_input_normalizer::normalizeRegistrationNumber($data['reg_3'] ?? '');
$createdAt = orders_input_normalizer::normalizeCreatedAt($data['created_at'] ?? date('Y-m-d H:i:s'));
$includeInInvoice = array_key_exists('include_in_invoice', $data)
? orders_input_normalizer::normalizeIncludeInInvoice($data['include_in_invoice'])
@@ -942,6 +936,7 @@ class ordersRoute
if (!is_array($data)) {
$data = [];
}
$data = $this->normalizeLegacyEditableFieldPayload($data, $response);
// Check if the required fields are set
if (!isset($data['id'])) {
$response->error('ID is required', 400);
@@ -973,6 +968,11 @@ class ordersRoute
// Include the order ID (Even though it is not editable)
'id',
'po',
'reference',
'notes',
'reg_1',
'reg_2',
'reg_3',
];
// Check if the $data contains any non-allowed keys
foreach ( $data as $key => $value ) {
@@ -987,13 +987,13 @@ class ordersRoute
}
// Registration numbers
if (isset($data['reg_1'])) {
$order->reg_1->set((string)$data['reg_1']);
$order->reg_1->set($this->normalizeRegistrationNumberOrError($data['reg_1']));
}
if (isset($data['reg_2'])) {
$order->reg_2->set((string)$data['reg_2']);
$order->reg_2->set($this->normalizeRegistrationNumberOrError($data['reg_2']));
}
if (isset($data['reg_3'])) {
$order->reg_3->set((string)$data['reg_3']);
$order->reg_3->set($this->normalizeRegistrationNumberOrError($data['reg_3']));
}
// Reference
if (isset($data['reference'])) {
@@ -1028,15 +1028,15 @@ class ordersRoute
}
// If the registration number is set, validate it
if (isset($data['reg_1'])) {
$order->reg_1->set($data['reg_1']);
$order->reg_1->set($this->normalizeRegistrationNumberOrError($data['reg_1']));
}
// If the registration number 2 is set, validate it
if (isset($data['reg_2'])) {
$order->reg_2->set($data['reg_2']);
$order->reg_2->set($this->normalizeRegistrationNumberOrError($data['reg_2']));
}
// If the registration number 3 is set, validate it
if (isset($data['reg_3'])) {
$order->reg_3->set($data['reg_3']);
$order->reg_3->set($this->normalizeRegistrationNumberOrError($data['reg_3']));
}
// If the PO is set, validate it
if (isset($data['po'])) {
@@ -1097,6 +1097,47 @@ class ordersRoute
}
}
private function normalizeLegacyEditableFieldPayload(array $data, response $response): array
{
if (!array_key_exists('field', $data) && !array_key_exists('value', $data)) {
return $data;
}
if (!array_key_exists('field', $data) || !array_key_exists('value', $data)) {
$response->error('Both legacy field and value are required', 400);
}
$field = is_string($data['field']) ? trim($data['field']) : '';
$allowedLegacyFields = ['reference', 'notes', 'reg_1', 'reg_2', 'reg_3'];
if ($field === '' || !in_array($field, $allowedLegacyFields, true)) {
$displayField = is_scalar($data['field']) || $data['field'] === null
? (string)$data['field']
: gettype($data['field']);
$response->error('Unsupported legacy order field: ' . $displayField, 400);
}
if (!array_key_exists($field, $data)) {
$data[$field] = $data['value'];
}
unset($data['field'], $data['value']);
return $data;
}
private function normalizeRegistrationNumberOrError(mixed $value): string
{
global $response;
try {
return orders_input_normalizer::normalizeRegistrationNumber($value);
} catch (\InvalidArgumentException $e) {
$response->error($e->getMessage(), 400);
}
}
/**
* @param array<int, array<string, mixed>> $orders
* @return array<int, array<string, mixed>>
@@ -1359,6 +1400,17 @@ class ordersRoute
if (!isset($data['reg_1'])) {
$response->error('Registration number 1 is required', 400);
}
try {
$data['reg_1'] = orders_input_normalizer::normalizeRegistrationNumber($data['reg_1']);
if (array_key_exists('reg_2', $data)) {
$data['reg_2'] = orders_input_normalizer::normalizeRegistrationNumber($data['reg_2']);
}
if (array_key_exists('reg_3', $data)) {
$data['reg_3'] = orders_input_normalizer::normalizeRegistrationNumber($data['reg_3']);
}
} catch (\InvalidArgumentException $e) {
$response->error($e->getMessage(), 400);
}
if (strlen($data['reg_1']) < 4) {
$response->error('Registration number 1 must be at least 4 characters', 400);
}
@@ -0,0 +1,339 @@
<?php
declare(strict_types=1);
usesApiSuite();
function disable_bird_transport_for_api_test(): void
{
api_fixtures()->setModuleConfig('bird', 'enabled', 'false');
}
it('returns the raw 202 gather webhook contract over HTTP', function (): void {
api_test_covers('POST /bird/voice/calls/webhook/inbound', 'happy');
disable_bird_transport_for_api_test();
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
$north = api_fixtures()->createDepartment([
'name' => 'North HTTP',
'order_priority' => 1,
]);
$south = api_fixtures()->createDepartment([
'name' => 'South HTTP',
'order_priority' => 2,
]);
api_fixtures()->createDepartmentGate([
'department' => $north['id'],
'is_entrance' => true,
'is_exit' => false,
'name' => 'North Gate',
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => '+4511111111',
'call_duration_threshold' => 5,
],
]);
api_fixtures()->createDepartmentGate([
'department' => $south['id'],
'is_entrance' => true,
'is_exit' => false,
'name' => 'South Gate',
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => '+4522222222',
'call_duration_threshold' => 5,
],
]);
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
'payload' => [
'endKey' => '#',
'input' => 'dtmf',
'maxNumKeys' => 1,
'retries' => 3,
'say' => [
'locale' => 'en-US',
'voice' => 'female',
],
'speechLocale' => 'en-US',
'timeout' => 30,
],
'request' => [
'callId' => '212c606f-a906-4a50-be0b-db95d74f2ffd',
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
],
'waitConditions' => [
'events' => [
[
'action' => 'continue',
'name' => 'call_command_gather_finished',
],
],
'timeout' => 'PT10M',
],
], $session['headers']);
$response->assertStatus(202);
expect($response->headers['content-type'] ?? '')->toContain('application/json');
expect($response->json)->toBeArray();
expect($response->json)->not->toHaveKey('success');
expect($response->json['statusCode'] ?? null)->toBe(202);
expect($response->json['statusText'] ?? null)->toBe('Accepted');
expect($response->json['result']['status'] ?? null)->toBe('accepted');
expect($response->json['event']['callCommand']['type'] ?? null)->toBe('gather');
expect($response->json['event']['callCommand']['gather']['input'] ?? null)->toBe('dtmf');
expect($response->json['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(1);
expect($response->json['event']['callCommand']['gather']['say']['locale'] ?? null)->toBe('en-US');
expect($response->json['event']['callCommand']['gather']['say']['voice'] ?? null)->toBe('female');
expect($response->json['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.');
});
it('still prompts for department selection when only one department is eligible', function (): void {
disable_bird_transport_for_api_test();
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
$solo = api_fixtures()->createDepartment([
'name' => 'Solo HTTP',
'order_priority' => 1,
]);
api_fixtures()->createDepartmentGate([
'department' => $solo['id'],
'is_entrance' => true,
'is_exit' => false,
'name' => 'Solo Gate',
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => '+4533333333',
'call_duration_threshold' => 5,
],
]);
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
'payload' => [
'endKey' => '#',
'retries' => 3,
'timeout' => 30,
],
'request' => [
'callId' => '212c606f-a906-4a50-be0b-db95d74f2ffe',
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
],
'waitConditions' => [
'events' => [
[
'action' => 'continue',
'name' => 'call_command_gather_finished',
],
],
'timeout' => 'PT10M',
],
], $session['headers']);
$response->assertStatus(202);
expect($response->json['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(1);
expect($response->json['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.');
expect($response->json['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for Solo HTTP.');
});
it('accepts the legacy initial webhook body with top-level call identifiers', function (): void {
disable_bird_transport_for_api_test();
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
$solo = api_fixtures()->createDepartment([
'name' => 'Legacy HTTP',
'order_priority' => 1,
]);
api_fixtures()->createDepartmentGate([
'department' => $solo['id'],
'is_entrance' => true,
'is_exit' => false,
'name' => 'Legacy Gate',
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => '+4533333399',
'call_duration_threshold' => 5,
],
]);
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
'callId' => '212c606f-a906-4a50-be0b-db95d74f2ff1',
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
], $session['headers']);
$response->assertStatus(200);
expect($response->json['status'] ?? null)->toBe('gather');
expect($response->json['completed'] ?? null)->toBeFalse();
expect($response->json['stage'] ?? null)->toBe('department_select');
expect($response->json['gather']['input'] ?? null)->toBe('dtmf');
expect($response->json['gather']['maxNumKeys'] ?? null)->toBe(1);
expect($response->json['gather']['retries'] ?? null)->toBe(3);
expect($response->json['gather']['timeout'] ?? null)->toBe(30);
expect($response->json['gather']['endKey'] ?? null)->toBe('#');
expect($response->json['gatherInput'] ?? null)->toBe('dtmf');
expect($response->json['gatherMaxNumKeys'] ?? null)->toBe(1);
expect($response->json['gatherRetries'] ?? null)->toBe(3);
expect($response->json['gatherTimeout'] ?? null)->toBe(30);
expect($response->json['gatherEndKey'] ?? null)->toBe('#');
expect($response->json['gatherSayLocale'] ?? null)->toBe('en-US');
expect($response->json['gatherSayVoice'] ?? null)->toBe('female');
expect($response->json['gather']['say']['locale'] ?? null)->toBe('en-US');
expect($response->json['gather']['say']['voice'] ?? null)->toBe('female');
expect($response->json['gather']['say']['text'] ?? null)->toContain('Choose department.');
expect($response->json['gather']['say']['text'] ?? null)->toContain('Press 1 for Legacy HTTP.');
});
it('returns native flow gather data after a top-level department selection', function (): void {
disable_bird_transport_for_api_test();
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
$north = api_fixtures()->createDepartment([
'name' => 'North Flow',
'order_priority' => 1,
]);
$south = api_fixtures()->createDepartment([
'name' => 'South Flow',
'order_priority' => 2,
]);
api_fixtures()->createDepartmentGate([
'department' => $north['id'],
'is_entrance' => true,
'is_exit' => true,
'name' => 'North Both',
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => '+4511111199',
'call_duration_threshold' => 5,
],
]);
api_fixtures()->createDepartmentGate([
'department' => $south['id'],
'is_entrance' => true,
'is_exit' => false,
'name' => 'South Entrance',
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => '+4522222299',
'call_duration_threshold' => 5,
],
]);
$callId = '212c606f-a906-4a50-be0b-db95d74f2ff2';
$initialResponse = api_client()->post('/bird/voice/calls/webhook/inbound', [
'callId' => $callId,
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
], $session['headers']);
$initialResponse->assertStatus(200);
$prompt = (string)($initialResponse->json['prompt'] ?? '');
expect($prompt)->toContain('North Flow.');
expect(preg_match('/Press ([0-9]+) for North Flow\./', $prompt, $matches))->toBe(1);
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
'callId' => $callId,
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
'keys' => $matches[1],
], $session['headers']);
$response->assertStatus(200);
expect($response->json['status'] ?? null)->toBe('gather');
expect($response->json['completed'] ?? null)->toBeFalse();
expect($response->json['stage'] ?? null)->toBe('gate_type_select');
expect($response->json['prompt'] ?? null)->toContain('You selected North Flow.');
expect($response->json['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance. Press 2 for exit.');
expect($response->json['selection']['departmentId'] ?? null)->toBe((int)$north['id']);
expect($response->json['selection']['departmentName'] ?? null)->toBe('North Flow');
});
it('uses a multi-digit gather contract when 10 departments are eligible', function (): void {
disable_bird_transport_for_api_test();
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
for ($index = 1; $index <= 10; $index++) {
$department = api_fixtures()->createDepartment([
'name' => 'Department ' . $index . ' HTTP',
'order_priority' => $index,
]);
api_fixtures()->createDepartmentGate([
'department' => $department['id'],
'is_entrance' => true,
'is_exit' => false,
'name' => 'Gate ' . $index,
'config' => [
'type' => 'PHONE_CALL',
'phone_number' => sprintf('+45444444%02d', $index),
'call_duration_threshold' => 5,
],
]);
}
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
'payload' => [
'endKey' => '#',
'retries' => 3,
'timeout' => 30,
],
'request' => [
'callId' => '212c606f-a906-4a50-be0b-db95d74f2fff',
'channelId' => 'a2545e48-fe8c-5741-9bdc-42a081076bc9',
'workspaceId' => '3d5fae4f-9c2d-41aa-9840-28b18e6a94bc',
],
'waitConditions' => [
'events' => [
[
'action' => 'continue',
'name' => 'call_command_gather_finished',
],
],
'timeout' => 'PT10M',
],
], $session['headers']);
$response->assertStatus(202);
expect($response->json['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(2);
expect($response->json['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Enter the option number followed by pound.');
expect(preg_match(
'/Press [1-9][0-9]+ for Department 10 HTTP\./',
(string)($response->json['event']['callCommand']['gather']['say']['text'] ?? '')
))->toBe(1);
});
it('returns a raw 400 transport error for malformed webhook payloads', function (): void {
api_test_covers('POST /bird/voice/calls/webhook/inbound', 'failure');
disable_bird_transport_for_api_test();
$session = api_fixtures()->createUserSession(['modules_bird_voice_call_webhooks_trigger']);
$response = api_client()->post('/bird/voice/calls/webhook/inbound', [
'request' => [
'callId' => 'broken',
],
], $session['headers']);
$response->assertStatus(400);
expect($response->headers['content-type'] ?? '')->toContain('application/json');
expect($response->json)->toBeArray();
expect($response->json)->not->toHaveKey('success');
expect($response->json['statusCode'] ?? null)->toBe(400);
expect($response->json['statusText'] ?? null)->toBe('Bad Request');
expect($response->json['error']['message'] ?? null)->toContain('Malformed inbound Bird webhook payload');
});
+224 -5
View File
@@ -133,7 +133,9 @@ it('creates orders through the real endpoint', function (): void {
'department_id' => $department['id'],
'reference' => 'ORDER-POST',
'notes' => 'Created through HTTP',
'reg_1' => 'POST123',
'reg_1' => ' post-123 ',
'reg_2' => ' tr 9-8 ',
'reg_3' => ' 7z/x ',
], $session['headers']);
$response
@@ -144,6 +146,13 @@ it('creates orders through the real endpoint', function (): void {
$orderId = (int)($response->data()['id'] ?? 0);
expect($orderId)->toBeGreaterThan(0);
$row = api_fixtures()->fetchRowById('orders', $orderId);
expect($row)->not->toBeNull();
expect($row['reg_1'] ?? null)->toBe('POST123');
expect($row['reg_2'] ?? null)->toBe('TR98');
expect($row['reg_3'] ?? null)->toBe('7ZX');
api_fixtures()->cleanupDeleteById('orders', $orderId);
});
@@ -214,21 +223,48 @@ it('rejects invalid order creation requests', function (): void {
it('updates orders through the primary endpoint', function (): void {
api_test_covers('PUT /orders', 'happy');
$department = api_fixtures()->createDepartment();
$customer = api_fixtures()->createUser();
$department = api_fixtures()->createDepartment(['name' => 'Original Department']);
$updatedDepartment = api_fixtures()->createDepartment(['name' => 'Updated Department']);
$customer = api_fixtures()->createUser(['display_name' => 'Original Customer']);
$updatedCustomer = api_fixtures()->createUser(['display_name' => 'Updated Customer']);
$cashier = api_fixtures()->createUser(['display_name' => 'Order Editor']);
$updatedInvoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $updatedCustomer['customer_number'],
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'BEFORE-REF',
'reg_1' => 'BEFR123',
'reg_2' => 'OLD-2',
'reg_3' => 'OLD-3',
'notes' => 'Before update',
'po' => 'PO-BEFORE',
'lane' => 2,
'wash_id' => 'WASH-BEFORE',
'booking_id' => 321,
'include_in_invoice' => true,
'created_at' => '2026-04-08 08:44:07',
]);
$session = api_fixtures()->createUserSession(['edit_order']);
$response = api_client()->put('/orders', [
'id' => $order['id'],
'customer_id' => $updatedCustomer['customer_number'],
'department_id' => $updatedDepartment['id'],
'reference' => 'AFTER-REF',
'reg_1' => ' af-tr 123 ',
'reg_2' => ' new-2 ',
'reg_3' => ' new/3 ',
'notes' => 'After update',
'po' => 'PO-123',
'lane' => 7,
'wash_id' => 'WASH-123',
'booking_id' => 9876,
'invoice_collection_id' => $updatedInvoiceCollection['id'],
'created_at' => '2026-04-09 13:37:00',
'include_in_invoice' => false,
], $session['headers']);
$response
@@ -240,8 +276,67 @@ it('updates orders through the primary endpoint', function (): void {
$row = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($row)->not->toBeNull();
expect((int)($row['customer_id'] ?? 0))->toBe((int)$updatedCustomer['customer_number']);
expect((int)($row['department_id'] ?? 0))->toBe((int)$updatedDepartment['id']);
expect($row['reference'] ?? null)->toBe('AFTER-REF');
expect($row['reg_1'] ?? null)->toBe('AFTR123');
expect($row['reg_2'] ?? null)->toBe('NEW2');
expect($row['reg_3'] ?? null)->toBe('NEW3');
expect($row['notes'] ?? null)->toBe('After update');
expect($row['po'] ?? null)->toBe('PO-123');
expect((int)($row['lane'] ?? 0))->toBe(7);
expect($row['wash_id'] ?? null)->toBe('WASH-123');
expect((int)($row['booking_id'] ?? 0))->toBe(9876);
expect((int)($row['invoice_collection_id'] ?? 0))->toBe((int)$updatedInvoiceCollection['id']);
expect($row['created_at'] ?? null)->toBe('2026-04-09 13:37:00');
expect((int)($row['include_in_invoice'] ?? 1))->toBe(0);
});
it('supports legacy field-value metadata updates through the primary endpoint', function (): void {
api_test_covers('PUT /orders', 'happy');
$department = api_fixtures()->createDepartment(['name' => 'Legacy Field Department']);
$customer = api_fixtures()->createUser(['display_name' => 'Legacy Field Customer']);
$cashier = api_fixtures()->createUser(['display_name' => 'Legacy Field Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'START-REF',
'reg_1' => 'START123',
'reg_2' => 'START2',
'reg_3' => 'START3',
'notes' => 'Start note',
]);
$session = api_fixtures()->createUserSession(['edit_order']);
$payloads = [
['field' => 'reference', 'value' => 'LEGACY-REF'],
['field' => 'notes', 'value' => 'Legacy note'],
['field' => 'reg_1', 'value' => ' ab-12 34 '],
['field' => 'reg_2', 'value' => ' cd/56 78 '],
['field' => 'reg_3', 'value' => ' ef_90 12 '],
];
foreach ($payloads as $payload) {
api_client()->put('/orders', [
'id' => $order['id'],
...$payload,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Order updated successfully');
}
$row = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($row)->not->toBeNull();
expect($row['reference'] ?? null)->toBe('LEGACY-REF');
expect($row['notes'] ?? null)->toBe('Legacy note');
expect($row['reg_1'] ?? null)->toBe('AB1234');
expect($row['reg_2'] ?? null)->toBe('CD5678');
expect($row['reg_3'] ?? null)->toBe('EF9012');
});
it('rejects invalid updates through the primary order endpoint', function (): void {
@@ -274,20 +369,48 @@ it('rejects invalid updates through the primary order endpoint', function (): vo
it('updates orders through the legacy alias endpoint', function (): void {
api_test_covers('PUT /order', 'happy');
$department = api_fixtures()->createDepartment();
$customer = api_fixtures()->createUser();
$department = api_fixtures()->createDepartment(['name' => 'Legacy Department']);
$updatedDepartment = api_fixtures()->createDepartment(['name' => 'Legacy Updated Department']);
$customer = api_fixtures()->createUser(['display_name' => 'Legacy Original Customer']);
$updatedCustomer = api_fixtures()->createUser(['display_name' => 'Legacy Updated Customer']);
$cashier = api_fixtures()->createUser(['display_name' => 'Legacy Editor']);
$updatedInvoiceCollection = api_fixtures()->createInvoiceCollection([
'customer_number' => $updatedCustomer['customer_number'],
]);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'LEGACY-BEFORE',
'reg_1' => 'LGCY123',
'reg_2' => 'LGCY-2',
'reg_3' => 'LGCY-3',
'notes' => 'Legacy before',
'po' => 'LEGACY-PO',
'lane' => 4,
'wash_id' => 'LEGACY-WASH',
'booking_id' => 654,
'include_in_invoice' => false,
'created_at' => '2026-04-10 08:15:00',
]);
$session = api_fixtures()->createUserSession(['edit_order']);
$response = api_client()->put('/order', [
'id' => $order['id'],
'customer_id' => $updatedCustomer['customer_number'],
'department_id' => $updatedDepartment['id'],
'reference' => 'LEGACY-AFTER',
'reg_1' => ' lgcy-999 ',
'reg_2' => ' leg-2 ',
'reg_3' => ' leg/3 ',
'notes' => 'Legacy after',
'po' => 'LEGACY-PO-NEW',
'lane' => 9,
'wash_id' => 'LEGACY-WASH-NEW',
'booking_id' => 7654,
'invoice_collection_id' => $updatedInvoiceCollection['id'],
'created_at' => '2026-04-11 11:22:33',
'include_in_invoice' => true,
], $session['headers']);
$response
@@ -299,7 +422,67 @@ it('updates orders through the legacy alias endpoint', function (): void {
$row = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($row)->not->toBeNull();
expect((int)($row['customer_id'] ?? 0))->toBe((int)$updatedCustomer['customer_number']);
expect((int)($row['department_id'] ?? 0))->toBe((int)$updatedDepartment['id']);
expect($row['reference'] ?? null)->toBe('LEGACY-AFTER');
expect($row['reg_1'] ?? null)->toBe('LGCY999');
expect($row['reg_2'] ?? null)->toBe('LEG2');
expect($row['reg_3'] ?? null)->toBe('LEG3');
expect($row['notes'] ?? null)->toBe('Legacy after');
expect($row['po'] ?? null)->toBe('LEGACY-PO-NEW');
expect((int)($row['lane'] ?? 0))->toBe(9);
expect($row['wash_id'] ?? null)->toBe('LEGACY-WASH-NEW');
expect((int)($row['booking_id'] ?? 0))->toBe(7654);
expect((int)($row['invoice_collection_id'] ?? 0))->toBe((int)$updatedInvoiceCollection['id']);
expect($row['created_at'] ?? null)->toBe('2026-04-11 11:22:33');
expect((int)($row['include_in_invoice'] ?? 0))->toBe(1);
});
it('supports legacy field-value metadata updates through the alias endpoint', function (): void {
api_test_covers('PUT /order', 'happy');
$department = api_fixtures()->createDepartment(['name' => 'Alias Field Department']);
$customer = api_fixtures()->createUser(['display_name' => 'Alias Field Customer']);
$cashier = api_fixtures()->createUser(['display_name' => 'Alias Field Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'ALIAS-START',
'reg_1' => 'ALIAS123',
'reg_2' => 'ALIAS2',
'reg_3' => 'ALIAS3',
'notes' => 'Alias note',
]);
$session = api_fixtures()->createUserSession(['edit_order']);
$payloads = [
['field' => 'reference', 'value' => 'ALIAS-REF'],
['field' => 'notes', 'value' => 'Alias updated note'],
['field' => 'reg_1', 'value' => ' gh-12 34 '],
['field' => 'reg_2', 'value' => ' ij/56 78 '],
['field' => 'reg_3', 'value' => ' kl_90 12 '],
];
foreach ($payloads as $payload) {
api_client()->put('/order', [
'id' => $order['id'],
...$payload,
], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Order updated successfully');
}
$row = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($row)->not->toBeNull();
expect($row['reference'] ?? null)->toBe('ALIAS-REF');
expect($row['notes'] ?? null)->toBe('Alias updated note');
expect($row['reg_1'] ?? null)->toBe('GH1234');
expect($row['reg_2'] ?? null)->toBe('IJ5678');
expect($row['reg_3'] ?? null)->toBe('KL9012');
});
it('rejects invalid updates through the legacy alias endpoint', function (): void {
@@ -319,6 +502,42 @@ it('rejects invalid updates through the legacy alias endpoint', function (): voi
->assertMessageContains('must not be accessed before initialization');
});
it('rejects unsupported legacy field-value updates on both update endpoints', function (): void {
api_test_covers('PUT /orders', 'failure');
$department = api_fixtures()->createDepartment(['name' => 'Unsupported Field Department']);
$customer = api_fixtures()->createUser(['display_name' => 'Unsupported Field Customer']);
$cashier = api_fixtures()->createUser(['display_name' => 'Unsupported Field Cashier']);
$order = api_fixtures()->createOrder([
'customer_id' => $customer['customer_number'],
'cashier_id' => $cashier['id'],
'department_id' => $department['id'],
'reference' => 'UNCHANGED-REF',
'notes' => 'Unchanged note',
'reg_1' => 'UNCH123',
]);
$session = api_fixtures()->createUserSession(['edit_order']);
foreach (['/orders', '/order'] as $endpoint) {
api_client()->put($endpoint, [
'id' => $order['id'],
'field' => 'cashier_id',
'value' => 999999,
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Unsupported legacy order field: cashier_id');
}
$row = api_fixtures()->fetchRowById('orders', (int)$order['id']);
expect($row)->not->toBeNull();
expect($row['reference'] ?? null)->toBe('UNCHANGED-REF');
expect($row['notes'] ?? null)->toBe('Unchanged note');
expect($row['reg_1'] ?? null)->toBe('UNCH123');
});
it('deletes orders through the real endpoint', function (): void {
api_test_covers('DELETE /orders', 'happy');
@@ -15,6 +15,7 @@ return [
'POST /orders',
'PUT /orders',
'DELETE /orders',
'POST /bird/voice/calls/webhook/inbound',
],
'manual_operations' => [
'GET /ping',
@@ -132,6 +132,37 @@ final class ApiFixtures
return ['id' => $departmentId];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
public function createDepartmentGate(array $attributes): array
{
$departmentId = (int)($attributes['department'] ?? $attributes['department_id'] ?? 0);
if ($departmentId <= 0) {
throw new RuntimeException('Department gates require a department id.');
}
$gateId = $this->insertRow('department_gates', [
'department' => $departmentId,
'is_entrance' => (bool)($attributes['is_entrance'] ?? false),
'is_exit' => (bool)($attributes['is_exit'] ?? false),
'name' => (string)($attributes['name'] ?? ('API Gate ' . $this->uniqueSuffix())),
'config' => $attributes['config'] ?? [
'type' => 'PHONE_CALL',
'phone_number' => '+4511122233',
'call_duration_threshold' => 5,
],
'created_at' => $attributes['created_at'] ?? $this->now(),
'updated_at' => $attributes['updated_at'] ?? $this->now(),
'deleted_at' => $attributes['deleted_at'] ?? null,
]);
$this->cleanup->add(fn() => $this->deleteById('department_gates', $gateId));
return ['id' => $gateId, 'department' => $departmentId];
}
/**
* @param array<string, mixed> $attributes
* @return array<string, mixed>
@@ -76,6 +76,7 @@ it('documents flash hangup endpoint and marks end alias as deprecated', function
it('defines request and response schemas for Bird call command recording insight log and flash payloads', function (): void {
$content = bird_openapi_content_or_skip();
$webhookPath = bird_openapi_path_block_or_fail($content, '/bird/voice/calls/webhook/inbound');
expect($content)->toContain('BirdVoiceCallCommandResponse:');
expect($content)->toContain('BirdVoiceCallBridgeResponse:');
@@ -95,5 +96,14 @@ it('defines request and response schemas for Bird call command recording insight
expect($content)->toContain('BirdFlashCallEndRequest:');
expect($content)->toContain('BirdTestOutboundCallRequest:');
expect($content)->toContain('BirdInboundCallWebhookRequest:');
expect($content)->toContain('BirdInboundCallWebhookResponse:');
expect($content)->toContain('BirdInboundCallWebhookFlowGatherResponse:');
expect($content)->toContain('BirdInboundCallWebhookGatherAcceptedResponse:');
expect($content)->toContain('BirdInboundCallWebhookActionResultResponse:');
expect($webhookPath)->toContain('BirdInboundCallWebhookRequest');
expect($webhookPath)->toContain('BirdInboundCallWebhookFlowGatherResponse');
expect($webhookPath)->toContain('BirdInboundCallWebhookGatherAcceptedResponse');
expect($webhookPath)->toContain('BirdInboundCallWebhookActionResultResponse');
expect($webhookPath)->toContain("'202'");
expect($webhookPath)->toContain("'400'");
});
@@ -11,34 +11,38 @@ use objects\department_gates_o;
final class BirdVoiceWebhookLifecycleBirdClientFake extends bird
{
public array $answerPayloads = [];
public array $gatherPayloads = [];
public array $updatePayloads = [];
public ?string $answerErrorMessage = null;
public function __construct()
{
// Intentionally skip parent constructor for unit isolation.
}
public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
{
if ($this->answerErrorMessage !== null) {
throw new RuntimeException($this->answerErrorMessage);
}
$this->answerPayloads[] = compact('workspaceId', 'channelId', 'callId', 'payload');
return ['status' => 'accepted'];
}
public function gatherMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
$this->gatherPayloads[] = [
'workspaceId' => $workspaceId,
'channelId' => $channelId,
'callId' => $callId,
'payload' => $payload,
];
return ['status' => 'ok'];
$this->gatherPayloads[] = compact('workspaceId', 'channelId', 'callId', 'payload');
return ['status' => 'unexpected'];
}
public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
$this->updatePayloads[] = [
'workspaceId' => $workspaceId,
'channelId' => $channelId,
'callId' => $callId,
'payload' => $payload,
];
return ['status' => 'accepted'];
$this->updatePayloads[] = compact('workspaceId', 'channelId', 'callId', 'payload');
return ['status' => 'unexpected'];
}
}
@@ -59,26 +63,13 @@ final class BirdVoiceWebhookLifecycleGateFake extends department_gates_o
final class BirdVoiceWebhookLifecycleRouteTestDouble extends \routes\birdVoiceWebhooksRoute
{
public int $now = 1_700_000_000;
public bool $lockAvailable = true;
public array $rawState = [];
public array $ttls = [];
public array $activeLocks = [];
public array $slackMessages = [];
public array $sayTexts = [];
public array $gatherTexts = [];
public array $answerCalls = [];
public array $hangupCalls = [];
public array $sleepCalls = [];
public array $voiceCallSnapshots = [];
public int $pollFetchCalls = 0;
public int $pollIntervalSeconds = 1;
public int $pollMaxSeconds = 5;
public bool $providerManagedEnabled = false;
public bool $lockAvailable = true;
public ?BirdVoiceWebhookLifecycleBirdClientFake $lastClient = null;
public array $eligibleDepartments = [];
public array $gatesByDepartmentAndType = [];
public array $providerManagedBridgeOptions = [];
public array $openedGates = [];
public ?string $gateOpenErrorMessage = null;
@@ -106,137 +97,40 @@ final class BirdVoiceWebhookLifecycleRouteTestDouble extends \routes\birdVoiceWe
'11:exit' => new BirdVoiceWebhookLifecycleGateFake(1102),
'22:entrance' => new BirdVoiceWebhookLifecycleGateFake(2201),
];
$this->providerManagedBridgeOptions = [
1101 => ['from' => '+4532330288', 'to' => '+451101', 'ringTimeout' => 5, 'hangupAfterBridge' => true, 'ringTone' => 'dk', 'callFlow' => [['command' => 'hangup']]],
1102 => ['from' => '+4532330288', 'to' => '+451102', 'ringTimeout' => 5, 'hangupAfterBridge' => true, 'ringTone' => 'dk', 'callFlow' => [['command' => 'hangup']]],
2201 => ['from' => '+4532330288', 'to' => '+452201', 'ringTimeout' => 5, 'hangupAfterBridge' => true, 'ringTone' => 'dk', 'callFlow' => [['command' => 'hangup']]],
];
}
public function runLifecycle(array $payload, string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array
{
$client = new BirdVoiceWebhookLifecycleBirdClientFake();
$client = $this->lastClient ?? new BirdVoiceWebhookLifecycleBirdClientFake();
$this->lastClient = $client;
return $this->handleInboundCallLifecycle($client, $payload, $callId, $workspaceId, $channelId);
}
public function seedState(string $callId, array $state): void
{
$this->saveIvrState($callId, $state);
}
public function mapDepartments(array $departments): array
{
return $this->buildDepartmentOptionMap($departments);
}
public function loadState(string $callId): ?array
{
return $this->readIvrState($callId);
}
protected function currentTimestamp(): int
{
return $this->now;
}
protected function sleepSeconds(int $seconds): void
{
$this->sleepCalls[] = $seconds;
}
protected function sendSlackMessage(string $message): void
{
$this->slackMessages[] = $message;
}
protected function say(bird $client, string $ws, string $ch, string $callId, string $text, bool $hangup = false): void
{
$this->sayTexts[] = $text;
}
protected function gather(bird $client, string $ws, string $ch, string $callId, string $text): void
{
$this->gatherTexts[] = $text;
parent::gather($client, $ws, $ch, $callId, $text);
}
protected function sendAnswerCommand(bird $client, string $workspaceId, string $channelId, string $callId): void
{
$this->answerCalls[] = [
'workspaceId' => $workspaceId,
'channelId' => $channelId,
'callId' => $callId,
];
}
protected function sendHangupCommand(bird $client, string $workspaceId, string $channelId, string $callId): void
{
$this->hangupCalls[] = [
'workspaceId' => $workspaceId,
'channelId' => $channelId,
'callId' => $callId,
];
}
protected function fetchVoiceCallSnapshot(bird $client, string $workspaceId, string $channelId, string $callId): array|object|null
{
$this->pollFetchCalls++;
if ($this->voiceCallSnapshots === []) {
return ['status' => 'ongoing'];
}
$next = array_shift($this->voiceCallSnapshots);
if ($next instanceof \Throwable) {
throw $next;
}
if (is_string($next)) {
return ['status' => $next];
}
if (is_array($next) || is_object($next) || $next === null) {
return $next;
}
return ['status' => 'ongoing'];
}
protected function getTerminalPollIntervalSeconds(): int
{
return $this->pollIntervalSeconds;
}
protected function getTerminalPollMaxSeconds(): int
{
return $this->pollMaxSeconds;
}
protected function loadEligibleDepartmentSummaries(): array
{
return $this->eligibleDepartments;
}
protected function shouldAttemptProviderManagedIvr(array $eligibleDepartments): bool
{
return $this->providerManagedEnabled;
}
protected function resolvePhoneCallGate(int $departmentId, string $gateType): ?department_gates_o
{
return $this->gatesByDepartmentAndType[$departmentId . ':' . $gateType] ?? null;
}
protected function buildProviderManagedBridgeOptions(department_gates_o $gate): ?array
protected function shouldAcceptInboundCall(bird $client): bool
{
return $this->providerManagedBridgeOptions[(int)$gate->id] ?? null;
return true;
}
protected function triggerGateOpen(department_gates_o $gate): void
{
if ($this->gateOpenErrorMessage !== null) {
throw new \RuntimeException($this->gateOpenErrorMessage);
throw new RuntimeException($this->gateOpenErrorMessage);
}
$this->openedGates[] = (int)$gate->id;
@@ -260,17 +154,12 @@ final class BirdVoiceWebhookLifecycleRouteTestDouble extends \routes\birdVoiceWe
protected function acquireIvrLockRaw(string $key, int $ttlSeconds): bool
{
if (!$this->lockAvailable) {
return false;
}
if (isset($this->activeLocks[$key])) {
if (!$this->lockAvailable || isset($this->activeLocks[$key])) {
return false;
}
$this->activeLocks[$key] = [
'ttl' => $ttlSeconds,
'acquired_at' => $this->now,
];
$this->activeLocks[$key] = ['ttl' => $ttlSeconds];
return true;
}
@@ -280,380 +169,417 @@ final class BirdVoiceWebhookLifecycleRouteTestDouble extends \routes\birdVoiceWe
}
}
it('captures input and keeps prompting while the call is still in the menu flow', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$result = $route->runLifecycle(['dtmf' => '5']);
expect($result['phase'])->toBe('input_window');
expect($result['stage'])->toBe('department_select');
expect($result['input_received'])->toBeTrue();
expect($result['input_changed'])->toBeTrue();
expect($route->slackMessages)->toHaveCount(1);
expect($route->slackMessages[0])->toContain('Input: 5');
expect($route->gatherTexts)->toHaveCount(1);
expect($route->gatherTexts[0])->toContain('Invalid selection.');
expect($route->answerCalls)->toHaveCount(1);
$state = $route->loadState('call_1');
expect($state)->not->toBeNull();
expect($state['last_input'] ?? null)->toBe('5');
expect($state['last_input_changed_at'] ?? null)->toBe($route->now);
expect($state['answered_at'] ?? null)->toBe($route->now);
});
it('configures gather to check every 2 seconds with retry loop semantics until input is entered', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->runLifecycle([]);
$payload = $route->lastClient?->gatherPayloads[0]['payload'] ?? null;
expect($payload)->toBeArray();
expect($payload['input'] ?? null)->toBe('dtmf');
expect($payload['maxNumKeys'] ?? null)->toBe(1);
expect($payload['retries'] ?? null)->toBe(99);
expect($payload['timeout'] ?? null)->toBe(2);
expect($payload['say']['text'] ?? null)->toBeString();
});
it('installs a provider managed Bird call flow for menu-driven inbound calls when enabled', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->providerManagedEnabled = true;
$result = $route->runLifecycle([]);
expect($result['phase'])->toBe('provider_call_flow_started');
expect($result['stage'])->toBe('provider_managed');
expect($route->gatherTexts)->toBe([]);
expect($route->openedGates)->toBe([]);
$payload = $route->lastClient?->updatePayloads[0]['payload'] ?? null;
expect($payload)->toBeArray();
expect($payload['callFlow'] ?? null)->toBeArray();
expect($payload['callFlow'][0]['command'] ?? null)->toBe('gather');
expect($payload['callFlow'][0]['options']['maxNumKeys'] ?? null)->toBe(2);
expect($payload['callFlow'][0]['options']['timeout'] ?? null)->toBe(4);
expect($payload['callFlow'][0]['options']['retries'] ?? null)->toBe(1);
expect($payload['callFlow'][0]['options']['say']['text'] ?? null)->toContain('Choose gate.');
expect($payload['callFlow'][0]['options']['say']['text'] ?? null)->toContain('Press 1 then 1 for Nord entrance');
expect($payload['callFlow'][0]['options']['say']['text'] ?? null)->toContain('Press 1 then 2 for Nord exit');
expect($payload['callFlow'][0]['options']['say']['text'] ?? null)->toContain('Press 2 then 1 for Syd entrance');
$bridgeCommands = array_values(array_filter($payload['callFlow'], static function (array $command): bool {
return ($command['command'] ?? null) === 'bridge';
}));
expect($bridgeCommands)->toHaveCount(3);
expect($bridgeCommands[0]['conditions'][0]['value'] ?? null)->toBe('11');
expect($bridgeCommands[1]['conditions'][0]['value'] ?? null)->toBe('12');
expect($bridgeCommands[2]['conditions'][0]['value'] ?? null)->toBe('21');
expect($bridgeCommands[0]['options']['callFlow'][0]['command'] ?? null)->toBe('hangup');
$state = $route->loadState('call_1');
expect($state['provider_managed'] ?? false)->toBeTrue();
expect($state['provider_managed_action_count'] ?? 0)->toBe(3);
});
it('keeps provider managed inbound calls passive until a terminal webhook arrives', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->providerManagedEnabled = true;
$first = $route->runLifecycle([]);
expect($first['phase'])->toBe('provider_call_flow_started');
$firstClient = $route->lastClient;
expect($firstClient?->updatePayloads ?? [])->toHaveCount(1);
$route->now += 1;
$second = $route->runLifecycle(['status' => 'ongoing']);
expect($second['phase'])->toBe('provider_call_flow_active');
expect($second['stage'])->toBe('provider_managed');
expect($route->lastClient?->updatePayloads ?? [])->toHaveCount(0);
});
it('ignores outgoing bridged child call callbacks on the inbound webhook route', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$result = $route->runLifecycle([
'direction' => 'outgoing',
'parentId' => 'parent_123',
], 'child_123');
expect($result['phase'])->toBe('ignored_non_inbound_call');
expect($result['direction'])->toBe('outgoing');
expect($result['parent_call_id'])->toBe('parent_123');
expect($route->answerCalls)->toBe([]);
});
it('reads dtmf values from alternate gather payload shapes', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$result = $route->runLifecycle([
'result' => [
'keys' => '2',
],
]);
expect($result['phase'])->toBe('completed');
expect($result['selected_department_id'])->toBe(22);
expect($result['selected_gate_type'])->toBe('entrance');
expect($result['gate_id'])->toBe(2201);
expect($route->slackMessages)->toHaveCount(1);
expect($route->slackMessages[0])->toContain('Input: 2');
$route->now += 1;
$second = $route->runLifecycle([
'conditions' => [
[
'variable' => 'keys',
'operator' => 'eq',
'value' => '1',
function bird_webhook_initial_payload(string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array
{
return [
'payload' => [
'endKey' => '#',
'input' => 'speech',
'maxNumKeys' => 9,
'retries' => 3,
'speechLocale' => 'en-US',
'timeout' => 30,
'say' => [
'locale' => 'en-US',
'voice' => 'female',
],
],
]);
'request' => [
'callId' => $callId,
'channelId' => $channelId,
'workspaceId' => $workspaceId,
],
'requestId' => 'request-123',
'waitConditions' => [
'events' => [
[
'action' => 'continue',
'name' => 'call_command_gather_finished',
],
],
'timeout' => 'PT10M',
],
];
}
expect($second['phase'])->toBe('completed');
expect($second['selected_department_id'])->toBe(22);
expect($second['selected_gate_type'])->toBe('entrance');
expect($second['gate_id'])->toBe(2201);
});
function bird_webhook_resumed_payload(?string $eventKeys, ?string $fallbackKeys = null, string $callId = 'call_1', string $channelId = 'channel_1'): array
{
$payload = [
'event' => [
'callCommand' => [
'callId' => $callId,
'channelId' => $channelId,
'gather' => [],
],
],
'resumeData' => [
'action' => 'continue',
],
];
it('treats gather callbacks with completed status and dtmf as menu input instead of terminal completion', function (): void {
if ($eventKeys !== null) {
$payload['event']['callCommand']['gather']['keys'] = $eventKeys;
}
if ($fallbackKeys !== null) {
$payload['result'] = ['keys' => $fallbackKeys];
}
return $payload;
}
function bird_webhook_legacy_initial_payload(string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array
{
return [
'callId' => $callId,
'channelId' => $channelId,
'workspaceId' => $workspaceId,
];
}
function bird_webhook_flow_selection_payload(string $keys, string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array
{
return [
'callId' => $callId,
'channelId' => $channelId,
'workspaceId' => $workspaceId,
'keys' => $keys,
];
}
it('returns a raw 202 gather response for initial department selection', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$result = $route->runLifecycle([
'status' => 'completed',
'command' => 'gather',
'result' => [
'keys' => '2',
$result = $route->runLifecycle(bird_webhook_initial_payload());
expect($result['statusCode'])->toBe(202);
expect($result)->not->toHaveKey('success');
expect($result['result']['status'] ?? null)->toBe('accepted');
expect($result['event']['callCommand']['type'] ?? null)->toBe('gather');
expect($result['event']['callCommand']['gather']['input'] ?? null)->toBe('dtmf');
expect($result['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(1);
expect($result['event']['callCommand']['gather']['retries'] ?? null)->toBe(3);
expect($result['event']['callCommand']['gather']['timeout'] ?? null)->toBe(30);
expect($result['event']['callCommand']['gather']['say']['locale'] ?? null)->toBe('en-US');
expect($result['event']['callCommand']['gather']['say']['voice'] ?? null)->toBe('female');
expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.');
expect($route->lastClient?->answerPayloads ?? [])->toBe([
[
'workspaceId' => 'workspace_1',
'channelId' => 'channel_1',
'callId' => 'call_1',
'payload' => [],
],
]);
expect($result['phase'])->toBe('completed');
expect($result['selected_department_id'])->toBe(22);
expect($result['selected_gate_type'])->toBe('entrance');
expect($result['gate_id'])->toBe(2201);
expect($route->openedGates)->toBe([2201]);
});
it('opens the selected gate after department and gate type have been entered', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$first = $route->runLifecycle(['dtmf' => '1']);
expect($first['phase'])->toBe('input_window');
expect($first['stage'])->toBe('gate_type_select');
expect($first['selected_department_id'])->toBe(11);
expect($route->openedGates)->toBe([]);
$route->now += 1;
$second = $route->runLifecycle(['dtmf' => '2']);
expect($second['phase'])->toBe('completed');
expect($second['completed'])->toBeTrue();
expect($second['selected_department_id'])->toBe(11);
expect($second['selected_gate_type'])->toBe('exit');
expect($second['gate_id'])->toBe(1102);
expect($route->openedGates)->toBe([1102]);
expect($route->sayTexts)->toContain('Opening the exit gate now.');
expect($route->sleepCalls)->toBe([6]);
expect($route->hangupCalls)->toHaveCount(1);
expect($route->lastClient?->gatherPayloads ?? [])->toBe([]);
expect($route->lastClient?->updatePayloads ?? [])->toBe([]);
$state = $route->loadState('call_1');
expect($state)->not->toBeNull();
expect($state['ivr_completed'] ?? false)->toBeTrue();
expect($state['stage'] ?? null)->toBe('department_select');
expect($state['request_id'] ?? null)->toBe('request-123');
});
it('auto-opens the only eligible gate without asking for input', function (): void {
it('accepts legacy initial webhook payloads with top-level call identifiers', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->eligibleDepartments = [
$result = $route->runLifecycle(bird_webhook_legacy_initial_payload());
expect($result['statusCode'])->toBe(200);
expect($result['status'])->toBe('gather');
expect($result['completed'])->toBeFalse();
expect($result['stage'])->toBe('department_select');
expect($result['gather']['input'] ?? null)->toBe('dtmf');
expect($result['gather']['maxNumKeys'] ?? null)->toBe(1);
expect($result['gather']['retries'] ?? null)->toBe(3);
expect($result['gather']['timeout'] ?? null)->toBe(30);
expect($result['gather']['endKey'] ?? null)->toBe('#');
expect($result['gatherInput'] ?? null)->toBe('dtmf');
expect($result['gatherMaxNumKeys'] ?? null)->toBe(1);
expect($result['gatherRetries'] ?? null)->toBe(3);
expect($result['gatherTimeout'] ?? null)->toBe(30);
expect($result['gatherEndKey'] ?? null)->toBe('#');
expect($result['gatherSayLocale'] ?? null)->toBe('en-US');
expect($result['gatherSayVoice'] ?? null)->toBe('female');
expect($result['gather']['say']['locale'] ?? null)->toBe('en-US');
expect($result['gather']['say']['voice'] ?? null)->toBe('female');
expect($result['gather']['say']['text'] ?? null)->toContain('Choose department.');
expect($result['prompt'] ?? null)->toContain('Choose department.');
expect($route->lastClient?->answerPayloads ?? [])->toBe([
[
'department_id' => 44,
'department_name' => 'Solo',
'order_priority' => 1,
'has_entrance_gate' => true,
'has_exit_gate' => false,
'workspaceId' => 'workspace_1',
'channelId' => 'channel_1',
'callId' => 'call_1',
'payload' => [],
],
];
$route->gatesByDepartmentAndType = [
'44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401),
];
$result = $route->runLifecycle([]);
expect($result['phase'])->toBe('completed');
expect($result['selected_department_id'])->toBe(44);
expect($result['selected_gate_type'])->toBe('entrance');
expect($result['gate_id'])->toBe(4401);
expect($route->openedGates)->toBe([4401]);
expect($route->gatherTexts)->toBe([]);
});
it('reprompts when a gate type selection is invalid', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->eligibleDepartments = [
[
'department_id' => 11,
'department_name' => 'Nord',
'order_priority' => 1,
'has_entrance_gate' => true,
'has_exit_gate' => true,
],
];
$result = $route->runLifecycle(['dtmf' => '9']);
expect($result['phase'])->toBe('input_window');
expect($result['stage'])->toBe('gate_type_select');
expect($route->openedGates)->toBe([]);
expect($route->gatherTexts)->toHaveCount(1);
expect($route->gatherTexts[0])->toContain('Invalid selection.');
]);
$state = $route->loadState('call_1');
expect($state['invalid_selection_count'] ?? 0)->toBe(1);
expect($state)->not->toBeNull();
expect($state['stage'] ?? null)->toBe('department_select');
expect($state['resume_action'] ?? null)->toBe('continue');
expect($state['wait_timeout'] ?? null)->toBe('PT10M');
});
it('completes immediately when no phone call gates are configured', function (): void {
it('returns a flow-data gather payload after department selection in native flow mode', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->eligibleDepartments = [];
$result = $route->runLifecycle([]);
$route->runLifecycle(bird_webhook_legacy_initial_payload());
$result = $route->runLifecycle(bird_webhook_flow_selection_payload('1'));
expect($result['phase'])->toBe('completed');
expect($result['reason'])->toBe('no_eligible_departments');
expect($result['gate_opened'])->toBeFalse();
expect($route->sayTexts)->toBe(['No phone-controlled gates are configured.']);
expect($route->hangupCalls)->toHaveCount(1);
expect($result['statusCode'])->toBe(200);
expect($result['status'])->toBe('gather');
expect($result['completed'])->toBeFalse();
expect($result['stage'])->toBe('gate_type_select');
expect($result['prompt'] ?? null)->toContain('You selected Nord.');
expect($result['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance. Press 2 for exit.');
expect($result['selection']['departmentId'] ?? null)->toBe(11);
expect($result['selection']['departmentName'] ?? null)->toBe('Nord');
$state = $route->loadState('call_1');
expect($state)->not->toBeNull();
expect($state['stage'] ?? null)->toBe('gate_type_select');
expect($state['selected_department_id'] ?? null)->toBe(11);
});
it('reports gate open failures without reopening on retries', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->eligibleDepartments = [
[
'department_id' => 44,
'department_name' => 'Solo',
'order_priority' => 1,
'has_entrance_gate' => true,
'has_exit_gate' => false,
],
];
$route->gatesByDepartmentAndType = [
'44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401),
];
$route->gateOpenErrorMessage = 'simulated provider outage';
$result = $route->runLifecycle([]);
expect($result['phase'])->toBe('completed');
expect($result['reason'])->toBe('gate_open_failed');
expect($result['gate_opened'])->toBeFalse();
expect($route->openedGates)->toBe([]);
expect($route->sayTexts)->toBe(['The entrance gate could not be opened. Please try again later.']);
expect($route->sleepCalls)->toBe([6]);
expect($route->hangupCalls)->toHaveCount(1);
});
it('keeps gathering and does not hang up before 300 seconds have elapsed', function (): void {
it('returns a flow-data completion payload after gate confirmation in native flow mode', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->seedState('call_1', [
'started_at' => $route->now - 299,
'stage' => 'department_select',
'department_options' => $route->mapDepartments($route->eligibleDepartments),
]);
$route->runLifecycle(bird_webhook_legacy_initial_payload());
$route->runLifecycle(bird_webhook_flow_selection_payload('1'));
$result = $route->runLifecycle(bird_webhook_flow_selection_payload('2'));
$result = $route->runLifecycle([]);
expect($result['phase'])->toBe('input_window');
expect($route->hangupCalls)->toHaveCount(0);
expect($route->gatherTexts)->toHaveCount(1);
});
it('announces timeout waits 10 seconds and sends hangup once at or after 300 seconds', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->pollMaxSeconds = 1;
$route->voiceCallSnapshots = ['ongoing'];
$route->seedState('call_1', [
'started_at' => $route->now - 300,
'stage' => 'department_select',
'department_options' => $route->mapDepartments($route->eligibleDepartments),
]);
$result = $route->runLifecycle([]);
expect($result['phase'])->toBe('timeout_window');
expect($route->sayTexts)->toBe(['Time has expired.']);
expect($route->sleepCalls)->toBe([10]);
expect($route->hangupCalls)->toHaveCount(1);
});
it('keeps completed state until a terminal webhook status arrives, then clears it', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->eligibleDepartments = [
[
'department_id' => 44,
'department_name' => 'Solo',
'order_priority' => 1,
'has_entrance_gate' => true,
'has_exit_gate' => false,
],
];
$route->gatesByDepartmentAndType = [
'44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401),
];
$first = $route->runLifecycle([]);
expect($first['phase'])->toBe('completed');
expect($route->loadState('call_1'))->not->toBeNull();
$route->now += 1;
$second = $route->runLifecycle(['status' => 'completed']);
expect($second['phase'])->toBe('terminal_completion');
expect($second['terminal_status'])->toBe('completed');
expect($result['statusCode'])->toBe(200);
expect($result['status'] ?? null)->toBe('completed');
expect($result['completed'] ?? null)->toBeTrue();
expect($result['action'] ?? null)->toBe('gate_opened');
expect($result['gateOpened'] ?? null)->toBeTrue();
expect($result['departmentId'] ?? null)->toBe(11);
expect($result['departmentName'] ?? null)->toBe('Nord');
expect($result['gateType'] ?? null)->toBe('exit');
expect($result['gateId'] ?? null)->toBe(1102);
expect($route->openedGates)->toBe([1102]);
expect($route->loadState('call_1'))->toBeNull();
});
it('does not duplicate timeout or hangup actions across retries and lock contention', function (): void {
it('returns a second-stage raw 202 gather response after department selection', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->pollMaxSeconds = 1;
$route->voiceCallSnapshots = ['ongoing', 'ongoing'];
$route->seedState('call_1', [
'started_at' => $route->now - 320,
'stage' => 'department_select',
'department_options' => $route->mapDepartments($route->eligibleDepartments),
]);
$route->runLifecycle(bird_webhook_initial_payload());
$result = $route->runLifecycle(bird_webhook_resumed_payload('1'));
$first = $route->runLifecycle([]);
$route->now += 1;
$second = $route->runLifecycle([]);
expect($result['statusCode'])->toBe(202);
expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Nord.');
expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance. Press 2 for exit.');
expect($first['phase'])->toBe('timeout_window');
expect($second['phase'])->toBe('timeout_window');
expect($route->sayTexts)->toHaveCount(1);
expect($route->hangupCalls)->toHaveCount(1);
$route->lockAvailable = false;
$third = $route->runLifecycle([]);
expect($third['phase'])->toBe('lock_not_acquired');
expect($route->hangupCalls)->toHaveCount(1);
$state = $route->loadState('call_1');
expect($state)->not->toBeNull();
expect($state['stage'] ?? null)->toBe('gate_type_select');
expect($state['selected_department_id'] ?? null)->toBe(11);
expect($state['gate_options'] ?? null)->toBe(['1' => 'entrance', '2' => 'exit']);
expect($route->lastClient?->answerPayloads ?? [])->toHaveCount(1);
expect($route->openedGates)->toBe([]);
});
it('keeps state when polling fails so completion is not falsely reported', function (): void {
it('opens the selected gate and clears redis state on final selection', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->pollMaxSeconds = 2;
$route->voiceCallSnapshots = [new \RuntimeException('poll failed')];
$route->seedState('call_1', [
'started_at' => $route->now - 301,
'stage' => 'department_select',
'department_options' => $route->mapDepartments($route->eligibleDepartments),
]);
$route->runLifecycle(bird_webhook_initial_payload());
$route->runLifecycle(bird_webhook_resumed_payload('1'));
$result = $route->runLifecycle(bird_webhook_resumed_payload('2'));
$result = $route->runLifecycle([]);
expect($result['statusCode'])->toBe(200);
expect($result['statusText'])->toBe('OK');
expect($result['result']['action'] ?? null)->toBe('gate_opened');
expect($result['result']['gateOpened'] ?? null)->toBeTrue();
expect($result['result']['departmentId'] ?? null)->toBe(11);
expect($result['result']['gateType'] ?? null)->toBe('exit');
expect($result['result']['gateId'] ?? null)->toBe(1102);
expect($route->openedGates)->toBe([1102]);
expect($route->loadState('call_1'))->toBeNull();
});
expect($result['phase'])->toBe('timeout_window');
expect($result['completed'])->toBeFalse();
expect($result['poll_error'])->toBe('poll failed');
expect($route->loadState('call_1'))->not->toBeNull();
it('reprompts with invalid selection while keeping webhook state', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->runLifecycle(bird_webhook_initial_payload());
$result = $route->runLifecycle(bird_webhook_resumed_payload('9'));
expect($result['statusCode'])->toBe(202);
expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Invalid selection.');
expect($route->openedGates)->toBe([]);
$state = $route->loadState('call_1');
expect($state)->not->toBeNull();
expect($state['stage'] ?? null)->toBe('department_select');
expect($state['invalid_selection_count'] ?? 0)->toBe(1);
});
it('uses fallback dtmf extraction when event gather keys are missing', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->runLifecycle(bird_webhook_initial_payload());
$result = $route->runLifecycle(bird_webhook_resumed_payload(null, '2'));
expect($result['statusCode'])->toBe(202);
expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Syd.');
expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance.');
expect($route->openedGates)->toBe([]);
$state = $route->loadState('call_1');
expect($state)->not->toBeNull();
expect($state['selected_department_id'] ?? null)->toBe(22);
expect($state['gate_options'] ?? null)->toBe(['1' => 'entrance']);
});
it('prompts for department selection even when only one eligible department exists', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->eligibleDepartments = [
[
'department_id' => 44,
'department_name' => 'Solo',
'order_priority' => 1,
'has_entrance_gate' => true,
'has_exit_gate' => false,
],
];
$route->gatesByDepartmentAndType = [
'44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401),
];
$result = $route->runLifecycle(bird_webhook_initial_payload());
expect($result['statusCode'])->toBe(202);
expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.');
expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for Solo.');
expect($route->openedGates)->toBe([]);
$state = $route->loadState('call_1');
expect($state)->not->toBeNull();
expect($state['stage'] ?? null)->toBe('department_select');
});
it('prompts for a single available gate type and opens only after explicit confirmation', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->eligibleDepartments = [
[
'department_id' => 44,
'department_name' => 'Solo',
'order_priority' => 1,
'has_entrance_gate' => true,
'has_exit_gate' => false,
],
];
$route->gatesByDepartmentAndType = [
'44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401),
];
$route->runLifecycle(bird_webhook_initial_payload());
$gatePrompt = $route->runLifecycle(bird_webhook_resumed_payload('1'));
expect($gatePrompt['statusCode'])->toBe(202);
expect($gatePrompt['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Solo.');
expect($gatePrompt['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance.');
expect($route->openedGates)->toBe([]);
$result = $route->runLifecycle(bird_webhook_resumed_payload('1'));
expect($result['statusCode'])->toBe(200);
expect($result['result']['action'] ?? null)->toBe('gate_opened');
expect($result['result']['gateId'] ?? null)->toBe(4401);
expect($route->openedGates)->toBe([4401]);
expect($route->loadState('call_1'))->toBeNull();
});
it('supports multi-digit department selections before gate confirmation', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->eligibleDepartments = [];
$route->gatesByDepartmentAndType = [];
for ($index = 1; $index <= 10; $index++) {
$departmentId = 100 + $index;
$route->eligibleDepartments[] = [
'department_id' => $departmentId,
'department_name' => 'Department ' . $index,
'order_priority' => $index,
'has_entrance_gate' => true,
'has_exit_gate' => false,
];
$route->gatesByDepartmentAndType[$departmentId . ':entrance'] = new BirdVoiceWebhookLifecycleGateFake(1000 + $index);
}
$initial = $route->runLifecycle(bird_webhook_initial_payload());
expect($initial['statusCode'])->toBe(202);
expect($initial['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(2);
expect($initial['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Enter the option number followed by pound.');
expect($initial['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 10 for Department 10.');
$selected = $route->runLifecycle(bird_webhook_resumed_payload('10#'));
expect($selected['statusCode'])->toBe(202);
expect($selected['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Department 10.');
expect($selected['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance.');
$state = $route->loadState('call_1');
expect($state)->not->toBeNull();
expect($state['selected_department_id'] ?? null)->toBe(110);
expect($state['gate_options'] ?? null)->toBe(['1' => 'entrance']);
});
it('returns a direct raw 200 completion when no departments are eligible', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->eligibleDepartments = [];
$result = $route->runLifecycle(bird_webhook_initial_payload());
expect($result['statusCode'])->toBe(200);
expect($result['result']['action'] ?? null)->toBe('no_action');
expect($result['result']['message'] ?? null)->toContain('No phone-controlled gates are configured');
expect($route->lastClient?->answerPayloads ?? [])->toHaveCount(1);
expect($route->openedGates)->toBe([]);
});
it('continues with the first gather prompt when backend call acceptance fails', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->lastClient = new BirdVoiceWebhookLifecycleBirdClientFake();
$route->lastClient->answerErrorMessage = 'already answered';
$result = $route->runLifecycle(bird_webhook_initial_payload());
expect($result['statusCode'])->toBe(202);
expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.');
expect($route->lastClient?->answerPayloads ?? [])->toBe([]);
$state = $route->loadState('call_1');
expect($state)->not->toBeNull();
expect($state['stage'] ?? null)->toBe('department_select');
});
it('returns a business-failure raw 200 response when gate opening fails', function (): void {
$route = new BirdVoiceWebhookLifecycleRouteTestDouble();
$route->eligibleDepartments = [
[
'department_id' => 44,
'department_name' => 'Solo',
'order_priority' => 1,
'has_entrance_gate' => true,
'has_exit_gate' => false,
],
];
$route->gatesByDepartmentAndType = [
'44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401),
];
$route->gateOpenErrorMessage = 'relay offline';
$route->runLifecycle(bird_webhook_initial_payload());
$route->runLifecycle(bird_webhook_resumed_payload('1'));
$result = $route->runLifecycle(bird_webhook_resumed_payload('1'));
expect($result['statusCode'])->toBe(200);
expect($result['result']['status'] ?? null)->toBe('failed');
expect($result['result']['action'] ?? null)->toBe('gate_open_failed');
expect($result['result']['gateOpened'] ?? null)->toBeFalse();
expect($result['result']['message'] ?? null)->toContain('relay offline');
expect($route->loadState('call_1'))->toBeNull();
});
@@ -25,14 +25,24 @@ final class BirdVoiceWebhookRouteTestDouble extends \routes\birdVoiceWebhooksRou
return $this->buildGateTypePromptText($name);
}
public function gatePromptForOptions(string $name, array $options): string
{
return $this->buildGateTypePromptText($name, $options);
}
public function selectDepartment(array $map, string $digit): ?int
{
return $this->resolveDepartmentIdByDigit($map, $digit);
}
public function selectGateType(string $digit): ?string
public function selectGateType(string $digit, array $options = []): ?string
{
return $this->resolveGateTypeByDigit($digit);
return $this->resolveGateTypeByDigit($digit, $options);
}
public function mapGateOptions(array $types): array
{
return $this->buildGateOptionMap($types);
}
public function normalizeMenuDigit(?string $input): ?string
@@ -55,11 +65,6 @@ final class BirdVoiceWebhookRouteTestDouble extends \routes\birdVoiceWebhooksRou
$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);
@@ -87,9 +92,20 @@ final class BirdVoiceWebhookRouteTestDouble extends \routes\birdVoiceWebhooksRou
}
}
it('builds deterministic department digit map and caps to 9 options', function (): void {
it('builds deterministic department digit map without capping options', function (): void {
$route = new BirdVoiceWebhookRouteTestDouble();
$route->departmentNames = [11 => 'Nord', 22 => 'Syd', 33 => 'Vest'];
$route->departmentNames = [
11 => 'Nord',
22 => 'Syd',
33 => 'Vest',
44 => 'Ost',
55 => 'Midt',
66 => 'Aalborg',
77 => 'Odense',
88 => 'Esbjerg',
99 => 'Kolding',
111 => 'Roskilde',
];
$map = $route->mapDepartments([
['department_id' => 11, 'department_name' => 'Nord', 'has_entrance_gate' => true, 'has_exit_gate' => false],
@@ -103,16 +119,19 @@ it('builds deterministic department digit map and caps to 9 options', function (
77,
88,
99,
111,
]);
expect($map)->toHaveCount(9);
expect(array_keys($map))->toBe([1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect($map)->toHaveCount(10);
expect(array_keys($map))->toBe([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
expect($map['1']['department_id'])->toBe(11);
expect($map['2']['department_id'])->toBe(22);
expect($map['3']['department_id'])->toBe(33);
expect($map['10']['department_id'])->toBe(111);
expect($map['1']['department_name'])->toBe('Nord');
expect($map['2']['department_name'])->toBe('Syd');
expect($map['3']['department_name'])->toBe('Vest');
expect($map['10']['department_name'])->toBe('Roskilde');
expect($map['1']['has_entrance_gate'])->toBeTrue();
expect($map['1']['has_exit_gate'])->toBeFalse();
expect($map['2']['has_entrance_gate'])->toBeFalse();
@@ -128,17 +147,27 @@ it('resolves department ids by selected digit', function (): void {
expect($route->selectDepartment($map, '1'))->toBe(101);
expect($route->selectDepartment($map, '2'))->toBe(202);
expect($route->selectDepartment($map + ['10' => ['department_id' => 1001, 'department_name' => 'J']], '10'))->toBe(1001);
expect($route->selectDepartment($map, '9'))->toBeNull();
expect($route->selectDepartment($map, ''))->toBeNull();
});
it('resolves gate type digit to entrance or exit', function (): void {
it('builds compact gate option maps and resolves selected gate type by digit', function (): void {
$route = new BirdVoiceWebhookRouteTestDouble();
$both = $route->mapGateOptions(['entrance', 'exit']);
$exitOnly = $route->mapGateOptions(['exit']);
$entranceOnly = $route->mapGateOptions(['entrance']);
expect($route->selectGateType('1'))->toBe('entrance');
expect($route->selectGateType('2'))->toBe('exit');
expect($route->selectGateType('0'))->toBeNull();
expect($route->selectGateType('x'))->toBeNull();
expect($both)->toBe(['1' => 'entrance', '2' => 'exit']);
expect($exitOnly)->toBe(['1' => 'exit']);
expect($entranceOnly)->toBe(['1' => 'entrance']);
expect($route->selectGateType('1', $both))->toBe('entrance');
expect($route->selectGateType('2', $both))->toBe('exit');
expect($route->selectGateType('1', $exitOnly))->toBe('exit');
expect($route->selectGateType('2', $exitOnly))->toBeNull();
expect($route->selectGateType('0', $both))->toBeNull();
expect($route->selectGateType('x', $both))->toBeNull();
});
it('stores, loads and clears ivr state with ttl', function (): void {
@@ -147,8 +176,8 @@ it('stores, loads and clears ivr state with ttl', function (): void {
$state = [
'stage' => 'department_select',
'department_options' => ['1' => ['department_id' => 5, 'department_name' => 'North']],
'country_code' => 45,
'phone' => 12345678,
'gate_options' => ['1' => 'entrance'],
'gather_template' => ['timeout' => 30],
];
$route->storeState($callId, $state);
@@ -161,30 +190,31 @@ it('stores, loads and clears ivr state with ttl', function (): void {
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 {
it('builds department prompts with multi-digit guidance and compact gate prompts', function (): void {
$route = new BirdVoiceWebhookRouteTestDouble();
$prompt = $route->departmentPrompt([
'1' => ['department_id' => 1, 'department_name' => 'Nord'],
'2' => ['department_id' => 2, 'department_name' => 'Syd'],
'3' => ['department_id' => 3, 'department_name' => 'Vest'],
'4' => ['department_id' => 4, 'department_name' => 'Ost'],
'5' => ['department_id' => 5, 'department_name' => 'Midt'],
'6' => ['department_id' => 6, 'department_name' => 'Aalborg'],
'7' => ['department_id' => 7, 'department_name' => 'Odense'],
'8' => ['department_id' => 8, 'department_name' => 'Esbjerg'],
'9' => ['department_id' => 9, 'department_name' => 'Kolding'],
'10' => ['department_id' => 10, 'department_name' => 'Roskilde'],
]);
expect($prompt)->toContain('Choose department.');
expect($prompt)->toContain('Enter the option number followed by pound.');
expect($prompt)->toContain('Press 1 for Nord');
expect($prompt)->toContain('Press 2 for Syd');
expect($prompt)->toContain('Press 10 for Roskilde');
$gatePrompt = $route->gatePrompt('Nord');
$gatePrompt = $route->gatePromptForOptions('Nord', ['1' => 'exit']);
expect($gatePrompt)->toContain('You selected Nord.');
expect($gatePrompt)->toContain('Press 1 for entrance. Press 2 for exit.');
expect($gatePrompt)->toContain('Press 1 for exit.');
expect($gatePrompt)->not->toContain('Press 2 for exit.');
});
it('normalizes menu digit input from Bird dtmf payload values', function (): void {
@@ -193,8 +223,9 @@ it('normalizes menu digit input from Bird dtmf payload values', function (): voi
expect($route->normalizeMenuDigit('1'))->toBe('1');
expect($route->normalizeMenuDigit('1#'))->toBe('1');
expect($route->normalizeMenuDigit(' 2## '))->toBe('2');
expect($route->normalizeMenuDigit('10#'))->toBe('10');
expect($route->normalizeMenuDigit('12#'))->toBe('12');
expect($route->normalizeMenuDigit('09'))->toBeNull();
expect($route->normalizeMenuDigit('12#'))->toBeNull();
expect($route->normalizeMenuDigit('*'))->toBeNull();
expect($route->normalizeMenuDigit(null))->toBeNull();
});