Add Bird gate call flow unit tests, self-serve machine wash minutes config, relay sync improvements, and OpenAPI updates. Refactor Bird call handling with terminal status detection and timeout normalization.

This commit is contained in:
Jeppe Bundgaard
2026-03-26 13:16:18 +01:00
parent a4654398d0
commit 95063d2a70
20 changed files with 731 additions and 77 deletions
+141 -11
View File
@@ -12,6 +12,8 @@ class bird
public const TEST_OUTBOUND_NUMBER_RAW = '+45 42 33 11 28';
public const TEST_OUTBOUND_NUMBER_E164 = '+4542331128';
private const ALLOWED_HANGUP_CAUSES = ['rejected', 'busy', 'completed'];
private const ACCEPTED_CALL_STATUSES = ['accepted', 'ongoing'];
private const TERMINAL_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer', 'completed'];
/**
* Configuration of the Bird module
@@ -418,11 +420,7 @@ class bird
$targetNumber = self::TEST_OUTBOUND_NUMBER_E164;
}
$payload['to'] = $targetNumber;
// Ensure Bird terminates an unanswered call after we've stopped polling for it.
if (!isset($payload['timeout'])) {
$payload['timeout'] = $maxPollSeconds;
}
$payload = $this->normalizeCreateVoiceCallPayload($payload, $maxPollSeconds);
$this->logBirdAction(
$logPrefix . '_START',
@@ -443,18 +441,18 @@ class bird
}
$lastCall = null;
$acceptedStates = ['accepted', 'ongoing'];
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$current = $this->getVoiceCall($workspaceId, $channelId, $callId);
$lastCall = $current;
$status = $this->extractStatus($current);
$normalizedStatus = $status === null ? null : strtolower($status);
$this->logBirdAction(
$logPrefix . '_POLL',
'call=' . $callId . ' attempt=' . $attempt . '/' . $maxAttempts . ' status=' . ($status ?? 'unknown')
);
if ($status !== null && in_array(strtolower($status), $acceptedStates, true)) {
if ($normalizedStatus !== null && in_array($normalizedStatus, self::ACCEPTED_CALL_STATUSES, true)) {
$hangupPayload = [];
if (isset($options['hangupCause']) && is_string($options['hangupCause']) && $options['hangupCause'] !== '') {
$normalizedCause = strtolower(trim($options['hangupCause']));
@@ -476,8 +474,26 @@ class bird
];
}
if ($normalizedStatus !== null && in_array($normalizedStatus, self::TERMINAL_GATE_FAILURE_STATUSES, true)) {
$this->logBirdAction(
$logPrefix . '_TERMINAL',
'call=' . $callId . ' status=' . $status
);
return [
'to' => $targetNumber,
'to_e164' => $targetNumber,
'call_id' => $callId,
'final_status' => $status,
'hangup_sent' => false,
'terminal_failure' => true,
'created_call' => $createResponse,
'last_call_snapshot' => $lastCall,
'message' => 'Call reached terminal status before acceptance',
];
}
if ($attempt < $maxAttempts) {
sleep($pollIntervalSeconds);
$this->waitForCallPollInterval($pollIntervalSeconds);
}
}
@@ -499,6 +515,14 @@ class bird
];
}
/**
* Hook point for tests to avoid real waiting during call polling.
*/
protected function waitForCallPollInterval(int $pollIntervalSeconds): void
{
sleep($pollIntervalSeconds);
}
private function voiceBase(string $workspaceId, string $channelId): string
{
return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls';
@@ -626,12 +650,23 @@ class bird
public function callGateAndHangupWhenAccepted(int $countryCode, int $phone, int $timeout)
{
$ws = $this->config->workplaceId->getVariableValue();
$ch = $this->config->channelId->getVariableValue();
$ws = $this->getConfiguredWorkspaceId();
$ch = $this->getConfiguredChannelId();
if ($ws === '') {
throw new Exception('Bird workspaceId is not configured for gate calls');
}
if ($ch === '') {
throw new Exception('Bird channelId is not configured for gate calls');
}
$normalizedRingTimeout = $this->normalizeRingTimeoutValue($timeout);
if ($normalizedRingTimeout === null) {
$normalizedRingTimeout = 30;
}
$options = [
'to' => '+' . $countryCode . $phone,
'maxPollSeconds' => (int)$timeout,
'maxPollSeconds' => max(5, (int)$timeout),
'ringTimeout' => $normalizedRingTimeout,
];
$result = $this->executeCallAndHangupWhenAccepted($ws, $ch, $options, 'BIRD_GATE_CALL');
@@ -640,10 +675,105 @@ class bird
$msg = $result['message'] ?? 'Failed to call gate and hangup when accepted';
if ($result['timed_out_waiting_for_accepted'] ?? false) {
$msg = 'Timed out waiting for gate to accept call';
} elseif ($result['terminal_failure'] ?? false) {
$status = isset($result['final_status']) ? (string)$result['final_status'] : 'unknown';
$msg = 'Gate call reached terminal status: ' . $status;
}
throw new Exception("Failed to call gate (+{$countryCode}{$phone}): " . $msg);
}
}
protected function getConfiguredWorkspaceId(): string
{
if (!is_object($this->config)) {
return '';
}
$workspaceConfig = null;
if (property_exists($this->config, 'workspaceId')) {
$workspaceConfig = $this->config->workspaceId;
} elseif (property_exists($this->config, 'workplaceId')) {
// Backward compatibility with existing Bird module variable naming.
$workspaceConfig = $this->config->workplaceId;
}
if (!is_object($workspaceConfig) || !method_exists($workspaceConfig, 'getVariableValue')) {
return '';
}
return $this->normalizeOptionalString($workspaceConfig->getVariableValue());
}
protected function getConfiguredChannelId(): string
{
if (!is_object($this->config)) {
return '';
}
$channelConfig = null;
if (property_exists($this->config, 'channelId')) {
$channelConfig = $this->config->channelId;
}
if (!is_object($channelConfig) || !method_exists($channelConfig, 'getVariableValue')) {
return '';
}
return $this->normalizeOptionalString($channelConfig->getVariableValue());
}
protected function normalizeOptionalString(mixed $value): string
{
if (!is_scalar($value)) {
return '';
}
$normalized = trim((string)$value);
if ($normalized === '') {
return '';
}
$lower = strtolower($normalized);
if ($lower === 'undefined' || $lower === 'null') {
return '';
}
return $normalized;
}
/**
* Align create-call payload with Bird voice call schema.
* - Map legacy `timeout` to documented `ringTimeout`.
* - Clamp `ringTimeout` to documented [3,120] range.
*/
protected function normalizeCreateVoiceCallPayload(array $payload, int $fallbackRingTimeout): array
{
if (array_key_exists('timeout', $payload) && !array_key_exists('ringTimeout', $payload)) {
$payload['ringTimeout'] = $payload['timeout'];
}
unset($payload['timeout']);
$normalizedRingTimeout = null;
if (array_key_exists('ringTimeout', $payload)) {
$normalizedRingTimeout = $this->normalizeRingTimeoutValue($payload['ringTimeout']);
}
if ($normalizedRingTimeout === null) {
$normalizedRingTimeout = $this->normalizeRingTimeoutValue($fallbackRingTimeout);
}
if ($normalizedRingTimeout !== null) {
$payload['ringTimeout'] = $normalizedRingTimeout;
} else {
unset($payload['ringTimeout']);
}
return $payload;
}
protected function normalizeRingTimeoutValue(mixed $value): ?int
{
if (!is_numeric($value)) {
return null;
}
$timeout = (int)$value;
if ($timeout < 3) {
$timeout = 3;
}
if ($timeout > 120) {
$timeout = 120;
}
return $timeout;
}
}
@@ -0,0 +1,29 @@
<?php
namespace modules\selfserve\config;
use Exception;
use traits\module_config_variable;
class selfserve_machine_wash_minutes_included_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'selfserve',
'machine_wash_minutes_included',
'int',
true,
null,
'The number of machine wash minutes included before minute-based billing starts',
'20',
false,
20
);
}
}
@@ -3,8 +3,10 @@
namespace modules\selfserve;
require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php';
require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php';
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php';
use modules\selfserve\config\selfserve_enabled_c;
use modules\selfserve\config\selfserve_machine_wash_minutes_included_c;
use modules\selfserve\config\selfserve_minute_product_c;
use traits\module_config_t;
@@ -22,15 +24,22 @@ class selfserve_c
* @var selfserve_minute_product_c $minute_product
*/
public selfserve_minute_product_c $minute_product;
/**
* Included machine wash minutes before minute-based self-serve billing starts
* @var selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included
*/
public selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included;
public function __construct()
{
$this->setupConfig('selfserve');
$this->allowUpdate([
selfserve_enabled_c::class,
selfserve_minute_product_c::class
selfserve_minute_product_c::class,
selfserve_machine_wash_minutes_included_c::class
]);
$this->enabled = new selfserve_enabled_c();
$this->minute_product = new selfserve_minute_product_c();
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
}
}
}
@@ -73,6 +73,9 @@ trait selfserve_lane_command_t
}
try {
if (method_exists($this, 'ensureInvoiceOrderContextForVehicleProduct')) {
$this->ensureInvoiceOrderContextForVehicleProduct();
}
$this->addVehicleTypeProductToLastInvoiceOrder();
} catch (\Throwable) {
// Never block STOP on optional order-line enrichment.
@@ -206,7 +209,8 @@ trait selfserve_lane_command_t
try {
$this->openDepartmentGateForCommand($gate);
} catch (\Throwable $e) {
throw new \RuntimeException('Failed to open property ' . $commandLabel . ' gate: ' . $e->getMessage(), 0, $e);
$this->reportPropertyGateCommandFailure($commandLabel, $e);
throw new \RuntimeException($this->propertyGateCommandFailureMessage($isAccessGate), 0, $e);
}
}
@@ -223,6 +227,23 @@ trait selfserve_lane_command_t
$gate->openGate();
}
protected function propertyGateCommandFailureMessage(bool $isAccessGate): string
{
return $isAccessGate
? 'Failed to open property access gate.'
: 'Failed to open property exit gate.';
}
protected function reportPropertyGateCommandFailure(string $commandLabel, \Throwable $e): void
{
try {
$laneId = isset($this->id) ? (string)$this->id : 'unknown';
error_log('Self-serve property ' . $commandLabel . ' gate open failed for lane ' . $laneId . ': ' . $e->getMessage());
} catch (\Throwable) {
// Never block API flow on diagnostics logging.
}
}
/**
* Execute a command on a self-serve lane
* @param selfserve_lane_command $command The command to execute
@@ -22,6 +22,11 @@ trait selfserve_lane_invoice_t
* @var int|null $minute_billing_product_id
*/
public ?int $minute_billing_product_id = null;
/**
* Included machine wash minutes before minute billing starts.
* @var int|null $machine_wash_minutes_included
*/
public ?int $machine_wash_minutes_included = null;
/**
* Get the minute billing product ID
* @return int|null The product ID for minute-based billing, or null if not set
@@ -38,11 +43,56 @@ trait selfserve_lane_invoice_t
return $this->minute_billing_product_id;
}
/**
* Get included machine wash minutes before minute billing starts.
*/
public function getMachineWashMinutesIncluded(): int
{
$included_minutes = selfserve::getInstance()
->config
->machine_wash_minutes_included
->getVariableValue();
if (is_numeric($included_minutes)) {
$this->machine_wash_minutes_included = (int)$included_minutes;
}
if ($this->machine_wash_minutes_included === null || $this->machine_wash_minutes_included < 0) {
$this->machine_wash_minutes_included = 0;
}
return $this->machine_wash_minutes_included;
}
public function getLastInvoiceOrderId(): ?int
{
return $this->last_invoice_order_id;
}
/**
* Ensure there is an invoice order context for optional STOP follow-up lines
* (for example vehicle-type product) even when minute billing quantity is zero.
*/
public function ensureInvoiceOrderContextForVehicleProduct(): bool
{
if (!empty($this->last_invoice_order_id)) {
return true;
}
if (empty($this->id)) {
return false;
}
if ($this->getLaneStatus() !== selfserve_lane_status::OCCUPIED) {
return false;
}
if (empty($this->getCustomerNumber()) || empty($this->getLicensePlate())) {
return false;
}
$this->createInvoiceOrderContext();
return !empty($this->last_invoice_order_id);
}
/**
* Invoice for minute-based billing
* @return bool True on success, false on failure
@@ -50,36 +100,23 @@ trait selfserve_lane_invoice_t
*/
public function invoice(): bool
{
$this->last_invoice_order_id = null;
if (empty($this->id)) throw new \Exception("Lane ID is not set.");
if ($this->getLaneStatus() !== selfserve_lane_status::OCCUPIED) throw new \Exception("Lane ID {$this->id} is not occupied; cannot invoice.");
if (empty($this->getCustomerNumber())) throw new \Exception("Customer number is not set for lane ID {$this->id}.");
if (empty($this->getLicensePlate())) throw new \Exception("License plate is not set for lane ID {$this->id}.");
if (empty($product_id = $this->getMinuteBillingProductId())) throw new \Exception("Minute billing product ID is not set.");
// Calculate minutes used
$minutes = $this->getElapsedWashTime() / 60; // Convert seconds to minutes
$minutes = (int)ceil($minutes); // Round up to nearest whole minute
if ($minutes <= 0) throw new \Exception("No minutes to bill for lane ID {$this->id}.");
$amount = $minutes; // Assuming 1 unit per minute, adjust as needed
// Subtract any free minutes if applicable (If the machine was triggered - If the customer pays for the primary vehicle product type) TODO: Implement free minute logic if needed
// Create invoice order
$order = (new orders_o())->add(
$this->getCustomerNumber(),
self::INVOICE_SYSTEM_USER_ID,
'',
'',
(int)$this->department_lane->department->value(),
(string)$this->getLicensePlate()
);
$order->lane->set($this->id);
$this->last_invoice_order_id = (int)$order->id;
// Add product to order
$order_items = new order_items_o();
$order_items->addItemToOrder(
$order->id,
$product_id,
self::INVOICE_SYSTEM_USER_ID,
$amount,
);
$elapsed_minutes = $this->calculateElapsedMinutesForBilling($this->getElapsedWashTime());
$included_minutes = $this->getMachineWashMinutesIncluded();
$billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
if ($billable_minutes <= 0) {
return true;
}
$order = $this->createInvoiceOrderContext();
$this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
return true;
}
@@ -123,4 +160,49 @@ trait selfserve_lane_invoice_t
$product_id = (int)$vehicle->type->value();
return $product_id > 0 ? $product_id : null;
}
protected function calculateElapsedMinutesForBilling(?int $elapsed_wash_time_seconds): int
{
if ($elapsed_wash_time_seconds === null || $elapsed_wash_time_seconds <= 0) {
return 0;
}
return (int)ceil($elapsed_wash_time_seconds / 60);
}
protected function calculateBillableMinutes(int $elapsed_minutes, int $included_minutes): int
{
if ($elapsed_minutes <= 0) {
return 0;
}
$included_minutes = max(0, $included_minutes);
return max(0, $elapsed_minutes - $included_minutes);
}
protected function createInvoiceOrderContext(): orders_o
{
$order = (new orders_o())->add(
$this->getCustomerNumber(),
self::INVOICE_SYSTEM_USER_ID,
'',
'',
(int)$this->department_lane->department->value(),
(string)$this->getLicensePlate()
);
$order->lane->set($this->id);
$this->last_invoice_order_id = (int)$order->id;
return $order;
}
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): void
{
(new order_items_o())->addItemToOrder(
$order_id,
$product_id,
self::INVOICE_SYSTEM_USER_ID,
$quantity,
);
}
}
@@ -182,6 +182,14 @@ trait selfserve_lane_relay_controller_t
$machineVisible = in_array(selfserve_lane_services::MACHINE->name, $normalizedServices, true);
$relayAction = 'noop';
if (!$this->isDepartmentSelfServeRelayMutationsEnabled()) {
return [
'machine_visible' => $machineVisible,
'relay_action' => 'noop_selfserve_disabled',
'relay_target_on' => $machineVisible,
];
}
if (!$this->hasConfiguredRelay(selfserve_lane_relay::MACHINE)) {
return [
'machine_visible' => $machineVisible,
@@ -238,6 +246,19 @@ trait selfserve_lane_relay_controller_t
}
}
private function isDepartmentSelfServeRelayMutationsEnabled(): bool
{
if (!method_exists($this, 'isDepartmentSelfServeEnabled')) {
return true;
}
try {
return $this->isDepartmentSelfServeEnabled() === true;
} catch (\Throwable) {
return false;
}
}
/**
* Resolve relay ID for the current lane.
* @param selfserve_lane_relay $relay
@@ -854,6 +875,10 @@ trait selfserve_lane_relay_controller_t
*/
private function sendRelaySwitchCommand(selfserve_lane_relay $relay, bool $on, ?int $duration = null): bool
{
if (!$this->isDepartmentSelfServeRelayMutationsEnabled()) {
throw new \Exception("Cannot change relay state: Self-serve is not enabled for this lane's department.");
}
$relay_id = $this->getRelayId($relay);
$payload = [
'id' => $relay_id,
@@ -276,7 +276,7 @@ class department_gates_o extends db
);
} catch (\Throwable $e) {
$slack = new slack();
$slack->send_message('Failed to call gate for phone ' . $countryCode . $phone . ': ' . $e->getMessage(), 'Bird Voice Call Webhooks');
$slack->send_message('Failed to call gate for phone ' . $countryCode . ' ' . $phone . ': ' . $e->getMessage(), 'Bird Voice Call Webhooks');
throw new Exception('Failed to open gate relay via phone call', 0, $e);
}
}
@@ -82,6 +82,7 @@ class departmentGatesRelaysRoute
$config = is_array($config) ? $config : (array)$config;
$gateConfig = new department_gate_config($config);
$gateConfig->validate();
$gate = (new department_gates_o())->add(
$department,
+11 -24
View File
@@ -496,6 +496,11 @@ class departmentsRoute
protected function syncDepartmentSelfServeRelayStates(int $departmentId, bool $enabled): void
{
if (!$enabled) {
// Self-serve disabled: do not mutate lane relay states.
return;
}
$selfserve = new selfserve();
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
@@ -511,34 +516,16 @@ class departmentsRoute
continue;
}
if ($enabled) {
// Self-serve enabled: keep machine stack off.
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$lane->setMachineProgramPickerRelayStatusHard(false);
});
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatusHard(false);
});
try {
$lane->setMachineRelayStatusHard(false);
} catch (\Throwable) {}
continue;
}
// Self-serve disabled: restore machine stack on.
// Self-serve enabled: keep machine stack off.
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$lane->setMachineProgramPickerRelayStatusHard(false);
});
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatusHard(true);
$lane->setMachineCleanerRelayStatusHard(false);
});
try {
$lane->setMachineRelayStatusHard(true);
$lane->setMachineRelayStatusHard(false);
} catch (\Throwable) {}
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$lane->setMachineProgramPickerRelayStatusHard(true);
});
// Re-assert cleaner ON after stack enable sequence to avoid relay-side flip-back.
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatusHard(true);
});
}
}
@@ -124,7 +124,14 @@ class moduleSelfServeRoute
return date('Y-m-d H:i:s', $wash_start_time);
};
$included_minutes_when_machine_enabled = 20;
$selfserve = new selfserve();
$included_minutes_when_machine_enabled = (int)$selfserve
->config
->machine_wash_minutes_included
->getVariableValue();
if ($included_minutes_when_machine_enabled < 0) {
$included_minutes_when_machine_enabled = 0;
}
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere([
'lane_id' => $lane_id,
@@ -141,7 +148,6 @@ class moduleSelfServeRoute
}
if ($session === null) {
$selfserve = new selfserve();
$lane = $selfserve->lane($lane_id);
$lane_status = $lane->getLaneStatus();
$lane_state = $lane->getLaneState();
@@ -316,6 +322,15 @@ class moduleSelfServeRoute
'customer_number' => $lane->getCustomerNumber(),
]);
} catch (\Exception $e) {
if (
$command === selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE ||
$command === selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE
) {
error_log(
'Failed to execute self-serve property gate command ' . $command->name .
' for lane ' . $lane_id . ': ' . $e->getMessage()
);
}
$response->error("Failed to execute command: " . $e->getMessage());
}
},
@@ -416,7 +431,8 @@ class moduleSelfServeRoute
'state' => $lane->getLaneState()->name,
]);
} catch (\Exception $e) {
$response->error('Failed to open lane gate: ' . $e->getMessage(), 400);
error_log('Failed to open self-serve lane gate ' . $gate->name . ' for lane ' . $lane_id . ': ' . $e->getMessage());
$response->error('Failed to open ' . strtolower($gate->name) . ' gate.', 400);
}
}, [
'modules_selfserve_lane_gate_open' => 'Open ENTRANCE or EXIT gate for a self-serve lane'
@@ -0,0 +1,154 @@
<?php
app_require('classes/bird.php');
use classes\bird;
class BirdGateCallConfigValueFake
{
public function __construct(private readonly mixed $value) {}
public function getVariableValue(): mixed
{
return $this->value;
}
}
class BirdGateCallClientFake extends bird
{
/** @var string[] */
public array $statusQueue = [];
public int $hangupCalls = 0;
/** @var array<int,array<string,mixed>> */
public array $createPayloads = [];
/**
* @param string[] $statusQueue
*/
public function __construct(mixed $workspaceId = 'workspace_1', mixed $channelId = 'channel_1', array $statusQueue = ['accepted'])
{
$this->statusQueue = $statusQueue;
$this->config = (object)[
'workspaceId' => new BirdGateCallConfigValueFake($workspaceId),
'workplaceId' => new BirdGateCallConfigValueFake($workspaceId),
'channelId' => new BirdGateCallConfigValueFake($channelId),
];
}
public function createVoiceCall(string $workspaceId, string $channelId, array $payload): array|object|null
{
$this->createPayloads[] = [
'workspaceId' => $workspaceId,
'channelId' => $channelId,
'payload' => $payload,
];
return ['id' => 'call_123'];
}
public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null
{
$status = array_shift($this->statusQueue);
if (!is_string($status) || trim($status) === '') {
$status = 'ringing';
}
return [
'id' => $callId,
'status' => $status,
];
}
public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
{
$this->hangupCalls++;
return ['status' => 'completed'];
}
protected function waitForCallPollInterval(int $pollIntervalSeconds): void
{
// Avoid real sleeps in tests.
}
}
it('fails fast when workspace id is missing for gate calls', function (): void {
$client = new BirdGateCallClientFake(workspaceId: '', channelId: 'channel_1');
expect(fn() => $client->callGateAndHangupWhenAccepted(45, 12345678, 10))
->toThrow(\Exception::class, 'Bird workspaceId is not configured for gate calls');
});
it('fails fast when channel id is missing for gate calls', function (): void {
$client = new BirdGateCallClientFake(workspaceId: 'workspace_1', channelId: '');
expect(fn() => $client->callGateAndHangupWhenAccepted(45, 12345678, 10))
->toThrow(\Exception::class, 'Bird channelId is not configured for gate calls');
});
it('fails with terminal status details when call never reaches accepted state', function (): void {
$client = new BirdGateCallClientFake(
workspaceId: 'workspace_1',
channelId: 'channel_1',
statusQueue: ['busy']
);
try {
$client->callGateAndHangupWhenAccepted(45, 12345678, 10);
$thrown = null;
} catch (\Exception $e) {
$thrown = $e;
}
expect($thrown)->toBeInstanceOf(\Exception::class);
expect($thrown?->getMessage())->toContain('terminal status: busy');
expect($thrown?->getMessage())->not->toContain('Bird API request failed');
expect($client->hangupCalls)->toBe(0);
});
it('hangs up when call reaches accepted state', function (): void {
$client = new BirdGateCallClientFake(
workspaceId: 'workspace_1',
channelId: 'channel_1',
statusQueue: ['accepted']
);
$client->callGateAndHangupWhenAccepted(45, 12345678, 10);
expect($client->hangupCalls)->toBe(1);
expect($client->createPayloads)->toHaveCount(1);
expect($client->createPayloads[0]['workspaceId'])->toBe('workspace_1');
expect($client->createPayloads[0]['channelId'])->toBe('channel_1');
expect($client->createPayloads[0]['payload']['to'])->toBe('+4512345678');
expect($client->createPayloads[0]['payload']['ringTimeout'])->toBe(10);
expect(array_key_exists('timeout', $client->createPayloads[0]['payload']))->toBeFalse();
});
it('maps legacy timeout option to documented ringTimeout payload field', function (): void {
$client = new BirdGateCallClientFake(
workspaceId: 'workspace_1',
channelId: 'channel_1',
statusQueue: ['accepted']
);
$client->createOutboundTestCallAndHangupWhenAccepted('workspace_1', 'channel_1', [
'to' => '+4512345678',
'timeout' => 12,
]);
expect($client->createPayloads)->toHaveCount(1);
expect($client->createPayloads[0]['payload']['ringTimeout'])->toBe(12);
expect(array_key_exists('timeout', $client->createPayloads[0]['payload']))->toBeFalse();
});
it('clamps derived ringTimeout to Bird documented max when gate timeout is high', function (): void {
$client = new BirdGateCallClientFake(
workspaceId: 'workspace_1',
channelId: 'channel_1',
statusQueue: ['accepted']
);
$client->callGateAndHangupWhenAccepted(45, 12345678, 1000);
expect($client->createPayloads)->toHaveCount(1);
expect($client->createPayloads[0]['payload']['ringTimeout'])->toBe(120);
expect(array_key_exists('timeout', $client->createPayloads[0]['payload']))->toBeFalse();
});
@@ -0,0 +1,9 @@
<?php
it('validates department gate config on both create and update routes', function (): void {
$route = file_get_contents(app_path('routes/departmentGatesRelaysRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain('$gateConfig->validate();');
expect(substr_count($route, '$gateConfig->validate();'))->toBeGreaterThanOrEqual(2);
});
@@ -6,11 +6,12 @@ it('syncs lane relay states when department self-serve enabled flag changes', fu
expect($routeContent)->not->toBeFalse();
expect($routeContent)->toContain('/departments/self-serve/enabled');
expect($routeContent)->toContain('$this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled);');
expect($routeContent)->toContain('if (!$enabled) {');
expect($routeContent)->toContain('// Self-serve disabled: do not mutate lane relay states.');
expect($routeContent)->toContain('setMachineProgramPickerRelayStatusHard(false)');
expect($routeContent)->toContain('setMachineCleanerRelayStatusHard(false)');
expect($routeContent)->toContain('setMachineRelayStatusHard(false)');
expect($routeContent)->toContain('setMachineCleanerRelayStatusHard(true)');
expect($routeContent)->toContain('setMachineRelayStatusHard(true)');
expect($routeContent)->toContain('setMachineProgramPickerRelayStatusHard(true)');
expect(substr_count($routeContent, 'setMachineCleanerRelayStatusHard(true)'))->toBeGreaterThanOrEqual(2);
expect($routeContent)->not->toContain('setMachineCleanerRelayStatusHard(true)');
expect($routeContent)->not->toContain('setMachineRelayStatusHard(true)');
expect($routeContent)->not->toContain('setMachineProgramPickerRelayStatusHard(true)');
});
@@ -0,0 +1,60 @@
<?php
app_require('modules/selfserve/traits/selfserve_lane_invoice_t.php');
use modules\selfserve\traits\selfserve_lane_invoice_t;
class SelfserveLaneInvoiceIncludedMinutesHarness
{
use selfserve_lane_invoice_t;
public function elapsedMinutesForBilling(?int $elapsed_wash_time_seconds): int
{
return $this->calculateElapsedMinutesForBilling($elapsed_wash_time_seconds);
}
public function billableMinutesForBilling(int $elapsed_minutes, int $included_minutes): int
{
return $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
}
}
it('computes billable minutes when elapsed minutes exceed included minutes', function (): void {
$harness = new SelfserveLaneInvoiceIncludedMinutesHarness();
$elapsed_minutes = $harness->elapsedMinutesForBilling(181); // ceil(3.016...) = 4
$billable_minutes = $harness->billableMinutesForBilling($elapsed_minutes, 2);
expect($elapsed_minutes)->toBe(4);
expect($billable_minutes)->toBe(2);
});
it('computes zero billable minutes when elapsed minutes equal included minutes', function (): void {
$harness = new SelfserveLaneInvoiceIncludedMinutesHarness();
$elapsed_minutes = $harness->elapsedMinutesForBilling(120);
$billable_minutes = $harness->billableMinutesForBilling($elapsed_minutes, 2);
expect($elapsed_minutes)->toBe(2);
expect($billable_minutes)->toBe(0);
});
it('computes zero billable minutes when included minutes exceed elapsed minutes', function (): void {
$harness = new SelfserveLaneInvoiceIncludedMinutesHarness();
$elapsed_minutes = $harness->elapsedMinutesForBilling(59);
$billable_minutes = $harness->billableMinutesForBilling($elapsed_minutes, 5);
expect($elapsed_minutes)->toBe(1);
expect($billable_minutes)->toBe(0);
});
it('handles zero and low elapsed wash time edge cases', function (): void {
$harness = new SelfserveLaneInvoiceIncludedMinutesHarness();
expect($harness->elapsedMinutesForBilling(null))->toBe(0);
expect($harness->elapsedMinutesForBilling(0))->toBe(0);
expect($harness->elapsedMinutesForBilling(1))->toBe(1);
expect($harness->billableMinutesForBilling(0, 20))->toBe(0);
expect($harness->billableMinutesForBilling(1, 20))->toBe(0);
});
@@ -133,6 +133,18 @@ it('wraps low-level gate errors for property access command failures', function
expect(function () use ($lane): void {
$lane->execute(selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE, new selfserve_lane_command_arguments());
})->toThrow(\RuntimeException::class, 'Failed to open property access gate: simulated gateway timeout');
})->toThrow(\RuntimeException::class, 'Failed to open property access gate.');
});
it('wraps low-level gate errors for property exit command failures', function (): void {
$lane = new SelfserveLanePropertyGateHarness();
$lane->exitGate = new SelfserveLanePropertyGateFake(
existsFlag: true,
throwOnOpen: true,
throwMessage: 'simulated provider error',
);
expect(function () use ($lane): void {
$lane->execute(selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE, new selfserve_lane_command_arguments());
})->toThrow(\RuntimeException::class, 'Failed to open property exit gate.');
});
@@ -157,6 +157,7 @@ class SelfserveLaneRelayControllerHarness
public int $id = 1;
public object $department_lane;
public bool $selfServeEnabled = true;
public float $now = 0.0;
/** @var array<int,int> */
public array $sleepCalls = [];
@@ -248,6 +249,11 @@ class SelfserveLaneRelayControllerHarness
$this->now += ($microseconds / 1000000);
}
}
protected function isDepartmentSelfServeEnabled(): bool
{
return $this->selfServeEnabled;
}
}
function selfserve_lane_shelly_test_harness(bool $withRedis = true): SelfserveLaneRelayControllerHarness
@@ -411,6 +417,15 @@ it('supports hard relay set even when lane status is CLOSED', function (): void
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
});
it('blocks explicit relay writes when department self-serve is disabled', function (): void {
$harness = selfserve_lane_shelly_test_harness();
$harness->selfServeEnabled = false;
expect(fn() => $harness->setMachineRelayStatusHard(false))
->toThrow(\Exception::class, 'Self-serve is not enabled');
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
});
it('enables MACHINE relay when MACHINE is visible in allowed services', function (): void {
$harness = selfserve_lane_shelly_test_harness();
@@ -460,6 +475,21 @@ it('does not enable MACHINE relay when allowEnable is false even if MACHINE is v
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
});
it('returns disabled no-op from machine relay visibility sync when department self-serve is disabled', function (): void {
$harness = selfserve_lane_shelly_test_harness();
$harness->selfServeEnabled = false;
$result = $harness->syncMachineRelayFromVisibleServices(['machine'], true);
expect($result)->toMatchArray([
'machine_visible' => true,
'relay_action' => 'noop_selfserve_disabled',
'relay_target_on' => true,
]);
expect($harness->getLaneCache($harness->id, $harness::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES))->toBe(['MACHINE']);
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
});
it('is a safe no-op when MACHINE relay is not configured', function (): void {
$harness = selfserve_lane_shelly_test_harness();
$harness->department_lane = new SelfserveDepartmentLaneRelayFake(
@@ -53,6 +53,7 @@ class SelfserveLaneStopFlowHarness
public int $id = 77;
public object $department_lane;
public int $invoiceCalls = 0;
public int $ensureInvoiceOrderContextCalls = 0;
public int $vehicleTypeProductAddCalls = 0;
/** @var selfserve_lane_port[] */
public array $openedPorts = [];
@@ -147,6 +148,12 @@ class SelfserveLaneStopFlowHarness
return true;
}
public function ensureInvoiceOrderContextForVehicleProduct(): bool
{
$this->ensureInvoiceOrderContextCalls++;
return true;
}
public function addVehicleTypeProductToLastInvoiceOrder(): bool
{
$this->vehicleTypeProductAddCalls++;
@@ -170,6 +177,14 @@ class SelfserveLaneStopFlowHarness
return true;
}
public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on, ?int $toggle_after = null): bool
{
if ($on === false) {
$this->turnedOffRelays[] = $relay;
}
return true;
}
public function logLaneAction(...$args): void {}
protected function completeLatestSessionForStop(): void
@@ -185,12 +200,13 @@ it('adds vehicle type product on STOP when program selector is on, then turns of
$lane->execute(selfserve_lane_command::STOP, $args);
expect($lane->invoiceCalls)->toBe(1);
expect($lane->ensureInvoiceOrderContextCalls)->toBe(1);
expect($lane->vehicleTypeProductAddCalls)->toBe(1);
expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]);
expect($lane->turnedOffRelays)->toBe([
selfserve_lane_relay::MACHINE_CLEANER,
selfserve_lane_relay::MACHINE,
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
selfserve_lane_relay::MACHINE,
]);
expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE);
});
@@ -207,9 +223,10 @@ it('skips vehicle type product add when program selector is off and only disable
$lane->execute(selfserve_lane_command::STOP, $args);
expect($lane->invoiceCalls)->toBe(1);
expect($lane->ensureInvoiceOrderContextCalls)->toBe(0);
expect($lane->vehicleTypeProductAddCalls)->toBe(0);
expect($lane->turnedOffRelays)->toBe([
selfserve_lane_relay::MACHINE,
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
selfserve_lane_relay::MACHINE,
]);
});
@@ -86,3 +86,25 @@ it('documents in-progress self-serve wash start and machine relay fields', funct
expect($inProgressPathBlock)->toContain('machine_start_triggered_at:');
expect($inProgressPathBlock)->toContain('wash_started_at:');
});
it('documents self-serve machine wash included minutes in config schemas', function (): void {
$content = selfserve_openapi_content_or_skip();
expect($content)->toContain('SelfServeConfigEntry:');
expect($content)->toContain('machine_wash_minutes_included');
expect($content)->toContain('SelfServeConfig:');
});
it('documents property gate lane commands and sanitized gate failure responses', function (): void {
$content = selfserve_openapi_content_or_skip();
$commandPathBlock = selfserve_openapi_path_block_or_fail($content, '/modules/self-serve/lane/command');
$gateOpenPathBlock = selfserve_openapi_path_block_or_fail($content, '/modules/self-serve/lane/gate/open');
expect($commandPathBlock)->toContain('OPEN_PROPERTY_ACCESS_GATE');
expect($commandPathBlock)->toContain('OPEN_PROPERTY_EXIT_GATE');
expect($commandPathBlock)->toContain('Command execution failed');
expect($commandPathBlock)->toContain('Failed to execute command: Failed to open property access gate.');
expect($gateOpenPathBlock)->toContain('Gate open failed');
expect($gateOpenPathBlock)->toContain('Failed to open entrance gate.');
});
@@ -47,6 +47,14 @@ it('wires self-serve config draft/publish/rollback lifecycle endpoints', functio
expect($configRoute)->toContain('rollback_department_selfserve_config_versions');
});
it('wires machine wash included minutes into self-serve module config', function (): void {
$selfserveConfig = file_get_contents(app_path('modules/selfserve/selfserve_c.php'));
expect($selfserveConfig)->not->toBeFalse();
expect($selfserveConfig)->toContain('selfserve_machine_wash_minutes_included_c');
expect($selfserveConfig)->toContain('machine_wash_minutes_included');
});
it('keeps legacy self-serve CRUD routes syncing canonical drafts', function (): void {
$questionsRoute = file_get_contents(app_path('routes/departmentSelfserveQuestionsRoute.php'));
$conditionsRoute = file_get_contents(app_path('routes/departmentSelfserveConditionsRoute.php'));
@@ -96,16 +104,26 @@ it('wires self-serve lane gate open endpoint', function (): void {
expect($moduleSelfServeRoute)->toContain('selfserve_lane_port::ENTRANCE');
expect($moduleSelfServeRoute)->toContain('selfserve_lane_port::EXIT');
expect($moduleSelfServeRoute)->toContain('$lane->open($gate)');
expect($moduleSelfServeRoute)->toContain('Failed to open self-serve lane gate');
expect($moduleSelfServeRoute)->toContain("'Failed to open ' . strtolower(\$gate->name) . ' gate.'");
expect($moduleSelfServeRoute)->not->toContain('Failed to open lane gate: ');
});
it('wires self-serve property gate command permissions', function (): void {
$moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
$commandTrait = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_command_t.php'));
expect($moduleSelfServeRoute)->not->toBeFalse();
expect($moduleSelfServeRoute)->toContain('case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE');
expect($moduleSelfServeRoute)->toContain('case selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE');
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_access_gate');
expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_command_execute_open_property_exit_gate');
expect($moduleSelfServeRoute)->toContain('Failed to execute self-serve property gate command');
expect($commandTrait)->not->toBeFalse();
expect($commandTrait)->toContain('Failed to open property access gate.');
expect($commandTrait)->toContain('Failed to open property exit gate.');
expect($commandTrait)->not->toContain('Failed to open property access gate: ');
});
it('wires in-progress self-serve wash details endpoint', function (): void {
@@ -119,6 +137,7 @@ it('wires in-progress self-serve wash details endpoint', function (): void {
expect($moduleSelfServeRoute)->toContain('selfserve_lane_status::OCCUPIED');
expect($moduleSelfServeRoute)->toContain('selfserve_lane_state::IN_WASH');
expect($moduleSelfServeRoute)->toContain('getWashStartTime');
expect($moduleSelfServeRoute)->toContain('machine_wash_minutes_included');
expect($moduleSelfServeRoute)->toContain("'included_minutes' =>");
expect($moduleSelfServeRoute)->toContain("'machine_relay_enabled' =>");
expect($moduleSelfServeRoute)->toContain("'machine_relay_enabled_at' =>");