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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user