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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+29
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+5
-4
@@ -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' =>");
|
||||
|
||||
Reference in New Issue
Block a user