From 4c81cfd0fe0b9bbe48423f2a1c8dc186c42ce47f Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 25 Mar 2026 13:40:12 +0100 Subject: [PATCH] Add unit tests for lane relay Shelly batching, rate limit handling, and retry logic. Refactor Shelly relay state handling with snapshot caching, readiness checks, and reduced delay intervals. --- openapi.yaml | 146 +++++ .../selfserve_lane_relay_controller_t.php | 600 +++++++++++++----- .../nginx/app/routes/moduleSelfServeRoute.php | 157 +++++ .../SelfserveLaneRelayShellyBatchingTest.php | 381 +++++++++++ .../Selfserve/SelfserveOpenApiSpecTest.php | 2 + .../Selfserve/SelfserveRouteWiringTest.php | 21 + 6 files changed, 1142 insertions(+), 165 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php diff --git a/openapi.yaml b/openapi.yaml index eb9b20ca..4ba23c42 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7288,6 +7288,107 @@ paths: schema: $ref: '#/components/schemas/SelfServeLaneStatus' + /modules/self-serve/lane/wash/in-progress: + get: + tags: + - Modules + summary: Get in-progress self-serve wash customer and vehicle details + description: | + Returns the current open self-serve wash session details for a lane (if any), + including resolved customer and vehicle details. + operationId: getSelfServeLaneWashInProgress + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: In-progress wash details resolved + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + in_progress: + type: boolean + session: + type: object + nullable: true + properties: + id: + type: integer + status: + type: string + reg: + type: string + customer_number: + type: integer + nullable: true + vehicle_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + machine_type_id: + type: integer + nullable: true + machine_start_triggered: + type: boolean + machine_start_triggered_at: + type: string + nullable: true + created_at: + type: string + updated_at: + type: string + nullable: true + customer: + type: object + nullable: true + properties: + id: + type: integer + nullable: true + customer_number: + type: integer + nullable: true + display_name: + type: string + nullable: true + email: + type: string + nullable: true + phone_country_code: + type: integer + nullable: true + phone: + type: string + nullable: true + vehicle: + type: object + nullable: true + properties: + id: + type: integer + customer_id: + type: integer + type: + type: integer + reg: + type: string + reference: + type: string + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + /modules/self-serve/lane/command: post: tags: @@ -7366,6 +7467,51 @@ paths: '403': $ref: '#/components/responses/Forbidden' + /modules/self-serve/lane/gate/open: + post: + tags: + - Modules + summary: Open a self-serve lane gate + description: | + Opens either the ENTRANCE or EXIT gate relay for a self-serve lane. + operationId: openSelfServeLaneGate + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - gate + properties: + lane_id: + type: integer + gate: + type: string + enum: [ENTRANCE, EXIT] + responses: + '200': + description: Lane gate opened + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + gate: + type: string + enum: [ENTRANCE, EXIT] + opened: + type: boolean + state: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + /modules/self-serve/lane/relay/machine/status: get: tags: diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php index 95189d77..d7bce765 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_relay_controller_t.php @@ -5,18 +5,20 @@ require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php'; require_once WD . '/modules/selfserve/classes/selfserve_lane.php'; use classes\shelly; -use modules\selfserve\helpers\selfserve_lane_log_action; use modules\selfserve\helpers\selfserve_lane_relay; -use modules\selfserve\helpers\selfserve_lane_state; -use modules\selfserve\helpers\selfserve_lane_status; use modules\selfserve\helpers\selfserve_lane_services; -use modules\shelly\helpers\shelly_device_switch; +use modules\selfserve\helpers\selfserve_lane_status; use modules\shelly\helpers\shelly_request_body_get_states; trait selfserve_lane_relay_controller_t { private const SHELLY_STATUS_WAIT_TIMEOUT_SECONDS = 20; - private const SHELLY_RETRY_SLEEP_MICROSECONDS = 750000; + private const SHELLY_RETRY_SLEEP_MICROSECONDS = 250000; + private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 1000; + private const SHELLY_RATE_LIMIT_GATE_KEY = 'selfserve_shelly_cloud_rate_limit_gate'; + private const SHELLY_STATUS_SNAPSHOT_TTL_SECONDS = 1; + private const SHELLY_STATUS_SNAPSHOT_KEY_PREFIX = 'selfserve_lane_shelly_status_snapshot_'; + private const SHELLY_DEFAULT_CHANNEL = 0; /** * Get current MACHINE relay status from Shelly. @@ -57,15 +59,15 @@ trait selfserve_lane_relay_controller_t public function getRelayStatus(selfserve_lane_relay $relay): array { $relay_id = $this->getRelayId($relay); - $devices = $this->fetchRelaySwitches($relay_id); - if (count($devices) < 1) { - throw new \Exception("No Shelly device state returned for {$relay->name} relay"); + $snapshot = $this->getLaneShellyStatusSnapshot([$relay_id]); + if (!array_key_exists($relay_id, $snapshot)) { + throw new \Exception("Shelly did not return a status entry for {$relay->name} relay"); } - $device = $devices[0]; + $device = $snapshot[$relay_id]; return [ 'relay_id' => $relay_id, - 'online' => isset($device->online) && (int)$device->online === 1, + 'online' => $this->extractRelayOnlineState($device), 'on' => $this->extractRelayOnState($device, $relay), ]; } @@ -127,92 +129,127 @@ trait selfserve_lane_relay_controller_t if (empty($this->department_lane)) { throw new \Exception("Department lane object not found for lane ID {$this->id}"); } + $relay_id = match ($relay) { selfserve_lane_relay::MACHINE => (string)$this->department_lane->relay_machine_id->value(), selfserve_lane_relay::MACHINE_PROGRAM_PICKER => (string)$this->department_lane->relay_machine_program_picker_id->value(), selfserve_lane_relay::MACHINE_CLEANER => (string)$this->department_lane->relay_machine_cleaner_id->value(), - default => throw new \Exception("Invalid relay type: {$relay->name}"), }; + if ($relay_id === '') { throw new \Exception("Invalid relay ID for {$relay->name} relay"); } + return $relay_id; } /** - * Fetch Shelly switch state objects for a relay ID. - * @param string $relay_id - * @return array + * @return string[] * @throws \Exception */ - private function fetchRelaySwitches(string $relay_id): array + private function getConfiguredLaneRelayIds(): array { - $shelly = new shelly(); - $shelly->requireModuleEnabled(); - $shelly->requireValidSecretKey(); + if (empty($this->department_lane)) { + throw new \Exception("Department lane object not found for lane ID {$this->id}"); + } - $parameters = new shelly_request_body_get_states(); - $parameters->ids = [$relay_id]; - $parameters->select = ['status']; - $deadline = microtime(true) + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS; + $ids = [ + (string)$this->department_lane->relay_machine_id->value(), + (string)$this->department_lane->relay_machine_program_picker_id->value(), + (string)$this->department_lane->relay_machine_cleaner_id->value(), + ]; + $ids = array_values(array_unique(array_filter(array_map('trim', $ids), static fn(string $id): bool => $id !== ''))); + + if (count($ids) < 1) { + throw new \Exception('No configured Shelly relay IDs for this lane'); + } + + return $ids; + } + + /** + * @param string[] $required_relay_ids + * @return array + * @throws \Exception + */ + private function getLaneShellyStatusSnapshot(array $required_relay_ids): array + { + $cached = $this->getCachedLaneShellyStatusSnapshot(); + if ($cached !== null && $this->snapshotHasUsablePayload($cached, $required_relay_ids)) { + return $cached; + } + + $fresh = $this->fetchLaneShellyStatusSnapshotWithReadiness($required_relay_ids); + $this->setCachedLaneShellyStatusSnapshot($fresh); + return $fresh; + } + + /** + * @param string[] $required_relay_ids + * @return array + * @throws \Exception + */ + private function fetchLaneShellyStatusSnapshotWithReadiness(array $required_relay_ids): array + { + $relay_ids = $this->getConfiguredLaneRelayIds(); + $deadline = $this->nowTimestamp() + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS; $last_reason = 'Shelly relay status is not ready yet'; do { - $result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters); - $devices = $this->normalizeRelaySwitchesResponse($result); + $result = $this->sendShellyPostRateLimited('/v2/devices/api/get', $this->buildStatusGetPayload($relay_ids)); + $snapshot = $this->mapResponseToRelaySnapshot($result); - if (count($devices) > 0 && $this->relayStatusPayloadExists($devices[0])) { - return $devices; + if ($this->snapshotHasUsablePayload($snapshot, $required_relay_ids)) { + return $snapshot; } - $last_reason = $this->describeShellyNotReadyReason($result, $devices); - if (microtime(true) >= $deadline) { + $last_reason = $this->describeShellyNotReadyReason($result, $snapshot, $required_relay_ids); + if ($this->nowTimestamp() >= $deadline) { break; } - usleep(self::SHELLY_RETRY_SLEEP_MICROSECONDS); + $this->sleepMicroseconds(self::SHELLY_RETRY_SLEEP_MICROSECONDS); } while (true); throw new \Exception($last_reason); } /** - * Extract boolean on/off status from a Shelly switch state object. - * @throws \Exception + * @param string[] $relay_ids */ - private function extractRelayOnState(shelly_device_switch $device, selfserve_lane_relay $relay): bool + private function buildStatusGetPayload(array $relay_ids): array { - if (isset($device->on)) { - return (bool)$device->on; - } - if (!isset($device->status) || !is_object($device->status)) { - throw new \Exception('Missing status payload from Shelly response'); - } - - $status = (array)$device->status; - foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) { - if (!array_key_exists($switch_key, $status)) { - continue; - } - - $switch_state = $status[$switch_key]; - if (is_object($switch_state) && isset($switch_state->output)) { - return (bool)$switch_state->output; - } - if (is_array($switch_state) && array_key_exists('output', $switch_state)) { - return (bool)$switch_state['output']; - } - } - - throw new \Exception("Unable to determine {$relay->name} relay state from Shelly status payload"); + $parameters = new shelly_request_body_get_states(); + $parameters->ids = $relay_ids; + $parameters->select = ['status']; + return (array)$parameters; } /** - * Normalize Shelly relay status response to a switch object list. * @param array|object|null $response - * @return array + * @return array */ - private function normalizeRelaySwitchesResponse(array|object|null $response): array + private function mapResponseToRelaySnapshot(array|object|null $response): array { + $snapshot = []; + foreach ($this->normalizeRelayDevicesResponse($response) as $device) { + $id = isset($device['id']) ? trim((string)$device['id']) : ''; + if ($id === '') { + continue; + } + $snapshot[$id] = $this->normalizeRelaySnapshotEntry($device); + } + return $snapshot; + } + + /** + * @param array|object|null $response + * @return array + */ + private function normalizeRelayDevicesResponse(array|object|null $response): array + { + if ($response === null) { + return []; + } if (is_object($response)) { $response = [$response]; } @@ -221,67 +258,143 @@ trait selfserve_lane_relay_controller_t } $devices = []; - foreach ($response as $device) { - if (!is_array($device) && !is_object($device)) { + foreach ($response as $item) { + if (is_object($item)) { + $item = $this->normalizeRelaySnapshotEntry($item); + } + if (!is_array($item)) { continue; } - $devices[] = (new shelly_device_switch())->populate($device); + $devices[] = $this->normalizeRelaySnapshotEntry($item); } return $devices; } /** - * Determine if a parsed Shelly device has enough payload to resolve relay state. + * @param array|object $device + * @return array */ - private function relayStatusPayloadExists(shelly_device_switch $device): bool + private function normalizeRelaySnapshotEntry(array|object $device): array { - if (isset($device->on)) { - return true; + $encoded = json_encode($device, JSON_UNESCAPED_UNICODE); + if (!is_string($encoded)) { + return []; } - if (!isset($device->status) || !is_object($device->status)) { - return false; - } - $status = (array)$device->status; - foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) { - if (!array_key_exists($switch_key, $status)) { - continue; - } - $switch_state = $status[$switch_key]; - if (is_object($switch_state) && isset($switch_state->output)) { - return true; - } - if (is_array($switch_state) && array_key_exists('output', $switch_state)) { - return true; - } - } - return false; + $decoded = json_decode($encoded, true); + return is_array($decoded) ? $decoded : []; } /** - * Build a user-facing reason while waiting for Shelly to become ready. - * @param array|object|null $response - * @param array $devices + * @param array $snapshot + * @param string[] $required_relay_ids */ - private function describeShellyNotReadyReason(array|object|null $response, array $devices): string + private function snapshotHasUsablePayload(array $snapshot, array $required_relay_ids): bool + { + foreach ($required_relay_ids as $relay_id) { + if (!array_key_exists($relay_id, $snapshot)) { + return false; + } + if (!$this->relayStatusPayloadExists($snapshot[$relay_id])) { + return false; + } + } + return true; + } + + private function relayStatusPayloadExists(array $device): bool + { + $status = $this->getPayloadValue($device, 'status'); + if ($status !== null) { + foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) { + $switch_state = $this->getPayloadValue($status, $switch_key); + if ($switch_state === null) { + continue; + } + if ($this->getPayloadValue($switch_state, 'output') !== null) { + return true; + } + } + } + + return $this->getPayloadValue($device, 'on') !== null; + } + + private function extractRelayOnlineState(array $device): bool + { + $online = $this->getPayloadValue($device, 'online'); + if ($online === null) { + return false; + } + if (is_bool($online)) { + return $online; + } + return (int)$online === 1; + } + + /** + * Extract boolean on/off status from Shelly switch payload. + * @throws \Exception + */ + private function extractRelayOnState(array $device, selfserve_lane_relay $relay): bool + { + $direct = $this->getPayloadValue($device, 'on'); + if ($direct !== null) { + return (bool)$direct; + } + + $status = $this->getPayloadValue($device, 'status'); + foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) { + $switch_state = $this->getPayloadValue($status, $switch_key); + if ($switch_state === null) { + continue; + } + + $output = $this->getPayloadValue($switch_state, 'output'); + if ($output !== null) { + return (bool)$output; + } + } + + throw new \Exception("Unable to determine {$relay->name} relay state from Shelly status payload"); + } + + private function getPayloadValue(array|object|null $payload, string $key): mixed + { + if (is_array($payload)) { + return $payload[$key] ?? null; + } + if (is_object($payload)) { + return $payload->$key ?? null; + } + return null; + } + + /** + * @param array|object|null $response + * @param array $snapshot + * @param string[] $required_relay_ids + */ + private function describeShellyNotReadyReason(array|object|null $response, array $snapshot, array $required_relay_ids): string { $payload_text = strtolower($this->serializeShellyResponse($response)); - - // Shelly may respond with rate-limit style payloads when polled too quickly. foreach (['rate limit', 'ratelimit', 'too many', '429', 'throttle', 'retry'] as $token) { if (str_contains($payload_text, $token)) { return 'Shelly rate limit reached, waiting for next available window'; } } - if (count($devices) < 1) { - return 'Shelly returned no device status yet'; + foreach ($required_relay_ids as $relay_id) { + if (!array_key_exists($relay_id, $snapshot)) { + return 'Shelly returned no device status yet'; + } + if (!$this->relayStatusPayloadExists($snapshot[$relay_id])) { + return 'Shelly relay status payload is not ready yet'; + } } - return 'Shelly relay status payload is not ready yet'; + + return 'Shelly relay status is not ready yet'; } - /** - * Serialize a Shelly response payload into a safe compact string. - */ private function serializeShellyResponse(array|object|null $response): string { if ($response === null) { @@ -290,44 +403,174 @@ trait selfserve_lane_relay_controller_t if (is_scalar($response)) { return (string)$response; } - if (is_array($response) || is_object($response)) { - $encoded = json_encode($response, JSON_UNESCAPED_UNICODE); - return is_string($encoded) ? $encoded : ''; + $encoded = json_encode($response, JSON_UNESCAPED_UNICODE); + return is_string($encoded) ? $encoded : ''; + } + + private function getLaneShellyStatusSnapshotCacheKey(): string + { + return self::SHELLY_STATUS_SNAPSHOT_KEY_PREFIX . (int)$this->id; + } + + /** + * @return array|null + */ + private function getCachedLaneShellyStatusSnapshot(): ?array + { + $redis = $this->redisFacade(); + if ($redis === null) { + return null; } - return ''; + + $raw = $redis->get($this->getLaneShellyStatusSnapshotCacheKey()); + if (!is_string($raw) || $raw === '') { + return null; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return null; + } + + $snapshot = []; + foreach ($decoded as $relay_id => $entry) { + $id = trim((string)$relay_id); + if ($id === '' || !is_array($entry)) { + continue; + } + $snapshot[$id] = $entry; + } + return $snapshot; + } + + /** + * @param array $snapshot + */ + private function setCachedLaneShellyStatusSnapshot(array $snapshot): void + { + $redis = $this->redisFacade(); + if ($redis === null) { + return; + } + + $encoded = json_encode($snapshot, JSON_UNESCAPED_UNICODE); + if (!is_string($encoded)) { + return; + } + $redis->setEx( + $this->getLaneShellyStatusSnapshotCacheKey(), + $encoded, + self::SHELLY_STATUS_SNAPSHOT_TTL_SECONDS + ); } /** - * Execute switch command and retry while Shelly is rate-limited. * @throws \Exception */ - private function switchRelayWithRetry( - shelly_device_switch $device, - bool $on, - selfserve_lane_relay $relay - ): void { - $deadline = microtime(true) + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS; + private function sendShellyPostRateLimited(string $endpoint, array $payload): array|object|null + { + $this->waitForShellyRateLimitWindow(); + return $this->sendShellyPost($endpoint, $payload); + } + + /** + * @throws \Exception + */ + private function waitForShellyRateLimitWindow(): void + { + $deadline = $this->nowTimestamp() + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS; do { - $switch_response = $device->switch($on, true); - $payload_text = strtolower($this->serializeShellyResponse($switch_response)); - $rate_limited = false; - foreach (['rate limit', 'ratelimit', 'too many', '429', 'throttle', 'retry'] as $token) { - if (str_contains($payload_text, $token)) { - $rate_limited = true; - break; - } - } - if (!$rate_limited) { + if ($this->tryAcquireShellyRateLimitSlot()) { return; } - if (microtime(true) >= $deadline) { - throw new \Exception("Shelly rate limit prevented switching {$relay->name} relay in time"); + + if ($this->nowTimestamp() >= $deadline) { + throw new \Exception('Shelly rate limit gate wait timed out'); } - usleep(self::SHELLY_RETRY_SLEEP_MICROSECONDS); + + $remaining_ms = $this->getShellyRateLimitSlotRemainingMs(); + if ($remaining_ms <= 0) { + $remaining_ms = 50; + } + $this->sleepMicroseconds(min($remaining_ms, 250) * 1000); } while (true); } + private function tryAcquireShellyRateLimitSlot(): bool + { + $redis = $this->redisFacade(); + if ($redis === null) { + return true; + } + + try { + $result = $redis->get_client()->set( + self::SHELLY_RATE_LIMIT_GATE_KEY, + (string)$this->nowTimestamp(), + 'PX', + self::SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS, + 'NX' + ); + return $result === true || strtoupper((string)$result) === 'OK'; + } catch (\Throwable) { + // If Redis gate can't be evaluated, fail open to avoid blocking relay control completely. + return true; + } + } + + private function getShellyRateLimitSlotRemainingMs(): int + { + $redis = $this->redisFacade(); + if ($redis === null) { + return 0; + } + + try { + $ttl = $redis->get_client()->pttl(self::SHELLY_RATE_LIMIT_GATE_KEY); + if (!is_numeric($ttl)) { + return 0; + } + $ttl = (int)$ttl; + return $ttl > 0 ? $ttl : 0; + } catch (\Throwable) { + return 0; + } + } + + /** + * @throws \Exception + */ + protected function sendShellyPost(string $endpoint, array $payload): array|object|null + { + $shelly = $this->createShellyClient(); + $shelly->requireModuleEnabled(); + $shelly->requireValidSecretKey(); + return $shelly->sendPostRequest($endpoint, $payload); + } + + protected function createShellyClient(): shelly + { + return new shelly(); + } + + protected function redisFacade(): mixed + { + return defined('redis') ? redis : null; + } + + protected function nowTimestamp(): float + { + return microtime(true); + } + + protected function sleepMicroseconds(int $microseconds): void + { + if ($microseconds > 0) { + usleep($microseconds); + } + } + /** * Turn on the lane relay * @param selfserve_lane_relay $relay The relay to turn on (MACHINE or MACHINE_PROGRAM_PICKER) @@ -337,33 +580,19 @@ trait selfserve_lane_relay_controller_t */ public function turnOnRelay(selfserve_lane_relay $relay, ?int $duration = null): bool { - // Require department lane object if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}"); - // Check lane status (ensure initialization via getter) if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn on relay on CLOSED lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn on relay on MAINTENANCE lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn on relay on FAULT lane"); - - // Gating + if ($relay === selfserve_lane_relay::MACHINE) { - // Allowed services are stored as an array of names in lane cache $allowed = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES); if (!is_array($allowed) || !in_array(selfserve_lane_services::MACHINE->name, $allowed, true)) { throw new \Exception("MACHINE relay is not allowed to be enabled at this time"); } } - - // Get the relay ID based on the relay type - $relay_id = $this->getRelayId($relay); - $result = $this->fetchRelaySwitches($relay_id); - // Turn on the switch - foreach ($result as $device) { - // Ensure machine switches never auto-toggle off; enforce toggle_after = 0 - $device->toggle_after = 0; - $this->switchRelayWithRetry($device, true, $relay); - } - return true; + return $this->sendRelaySwitchCommand($relay, true, $duration); } /** @@ -387,23 +616,12 @@ trait selfserve_lane_relay_controller_t */ public function forceTurnOnRelay(selfserve_lane_relay $relay, ?int $duration = null): bool { - // Require department lane object if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}"); - // Basic sanity checks on lane status (still disallow clearly invalid states) if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn on relay on CLOSED lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn on relay on MAINTENANCE lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn on relay on FAULT lane"); - // Directly control Shelly without checking allowed services - $relay_id = $this->getRelayId($relay); - $result = $this->fetchRelaySwitches($relay_id); - // Turn on the switch - foreach ($result as $device) { - // Ensure machine switches never auto-toggle off; enforce toggle_after = 0 - $device->toggle_after = 0; - $this->switchRelayWithRetry($device, true, $relay); - } - return true; + return $this->sendRelaySwitchCommand($relay, true, $duration); } /** @@ -425,21 +643,12 @@ trait selfserve_lane_relay_controller_t */ public function forceTurnOffRelay(selfserve_lane_relay $relay): bool { - // Require department lane object if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}"); - // Basic sanity checks on lane status (still disallow clearly invalid states) if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn off relay on CLOSED lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn off relay on MAINTENANCE lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn off relay on FAULT lane"); - // Directly control Shelly without checking allowed services - $relay_id = $this->getRelayId($relay); - $result = $this->fetchRelaySwitches($relay_id); - // Turn off the switch - foreach ($result as $device) { - $this->switchRelayWithRetry($device, false, $relay); - } - return true; + return $this->sendRelaySwitchCommand($relay, false); } /** @@ -450,21 +659,82 @@ trait selfserve_lane_relay_controller_t */ public function turnOffRelay(selfserve_lane_relay $relay): bool { - // Require department lane object if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}"); - // Check lane status (ensure initialization via getter) if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn off relay on CLOSED lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn off relay on MAINTENANCE lane"); if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn off relay on FAULT lane"); - - // Get the relay ID based on the relay type - $relay_id = $this->getRelayId($relay); - $result = $this->fetchRelaySwitches($relay_id); - // Turn off the switch - foreach ($result as $device) { - $this->switchRelayWithRetry($device, false, $relay); + return $this->sendRelaySwitchCommand($relay, false); + } + + /** + * @throws \Exception + */ + private function sendRelaySwitchCommand(selfserve_lane_relay $relay, bool $on, ?int $duration = null): bool + { + $relay_id = $this->getRelayId($relay); + $payload = [ + 'id' => $relay_id, + 'channel' => self::SHELLY_DEFAULT_CHANNEL, + 'on' => $on, + // We keep toggle_after=0 to avoid unintended auto toggle behavior. + 'toggle_after' => 0, + ]; + if ($duration !== null && $duration > 0) { + // Current relay API behavior intentionally keeps manual relay commands explicit; + // duration is accepted by route contracts but does not auto-toggle at Shelly level. + $payload['toggle_after'] = 0; } + + $response = $this->sendShellyPostRateLimited('/v2/devices/api/set/switch', $payload); + $this->seedLaneShellySnapshotFromSwitch($relay_id, $on, $response); return true; } + + /** + * @throws \Exception + */ + private function seedLaneShellySnapshotFromSwitch(string $relay_id, bool $on, array|object|null $response): void + { + $snapshot = $this->getCachedLaneShellyStatusSnapshot() ?? []; + $existing = $snapshot[$relay_id] ?? []; + if (!is_array($existing)) { + $existing = []; + } + + $seed = [ + 'id' => $relay_id, + 'on' => $on, + 'status' => ['switch:0' => ['output' => $on]], + ]; + + $response_entry = $this->extractRelaySnapshotEntryFromSwitchResponse($response, $relay_id); + if ($response_entry !== null) { + $seed = array_replace_recursive($seed, $response_entry); + } + + $snapshot[$relay_id] = array_replace_recursive($existing, $seed); + $this->setCachedLaneShellyStatusSnapshot($snapshot); + } + + /** + * @return array|null + */ + private function extractRelaySnapshotEntryFromSwitchResponse(array|object|null $response, string $relay_id): ?array + { + $devices = $this->normalizeRelayDevicesResponse($response); + if (count($devices) < 1) { + return null; + } + + foreach ($devices as $device) { + $id = isset($device['id']) ? (string)$device['id'] : ''; + if ($id === $relay_id) { + return $device; + } + } + + return $devices[0]; + } } + diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index 58dcdd80..31c05e68 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -9,11 +9,15 @@ use classes\router; use classes\selfserve; use classes\stripe; use modules\selfserve\helpers\selfserve_lane_command; +use modules\selfserve\helpers\selfserve_lane_port; use modules\selfserve\helpers\selfserve_lane_relay; use objects\departments_o; use objects\logs_o; use objects\orders_o; +use objects\customer_vehicles_o; +use objects\selfserve_wash_sessions_o; use objects\stripe_module_customers_o; +use objects\users_o; use traits\route_t; class moduleSelfServeRoute @@ -50,6 +54,123 @@ class moduleSelfServeRoute ] ); + /** Modules > Self Serve > Lane > Wash > In-progress details */ + $this->get('/modules/self-serve/lane/wash/in-progress', function () { + global $response; + self::requirePermission('modules_selfserve_lane_wash_in_progress_view'); + self::requireParameters(['lane_id']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + + $rows = (new selfserve_wash_sessions_o())->getFieldsWhere([ + 'lane_id' => $lane_id, + 'completed_at' => null, + 'deleted_at' => null, + ], ['id']); + + if ($rows === []) { + $response->success([ + 'lane_id' => $lane_id, + 'in_progress' => false, + 'session' => null, + 'customer' => null, + 'vehicle' => null, + ]); + return; + } + + usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']); + $session = (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']); + if (!$session->exists()) { + $response->success([ + 'lane_id' => $lane_id, + 'in_progress' => false, + 'session' => null, + 'customer' => null, + 'vehicle' => null, + ]); + return; + } + + $customer = null; + $customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value(); + if ($customer_number !== null && $customer_number > 0) { + $customer_obj = (new users_o())->getUserByCustomerNumber($customer_number); + if ($customer_obj->exists()) { + $customer = [ + 'id' => (int)$customer_obj->id, + 'customer_number' => $customer_number, + 'display_name' => $customer_obj->display_name->value() === null ? null : (string)$customer_obj->display_name->value(), + 'email' => $customer_obj->email->value() === null ? null : (string)$customer_obj->email->value(), + 'phone_country_code' => $customer_obj->phone_country_code->value() === null ? null : (int)$customer_obj->phone_country_code->value(), + 'phone' => $customer_obj->phone->value() === null ? null : (string)$customer_obj->phone->value(), + ]; + } else { + $customer = [ + 'id' => null, + 'customer_number' => $customer_number, + 'display_name' => null, + 'email' => null, + 'phone_country_code' => null, + 'phone' => null, + ]; + } + } + + $vehicle = null; + $vehicle_obj = null; + $vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value(); + if ($vehicle_id !== null && $vehicle_id > 0) { + $tmp_vehicle = (new customer_vehicles_o())->select($vehicle_id); + if ($tmp_vehicle->exists()) { + $vehicle_obj = $tmp_vehicle; + } + } + if ($vehicle_obj === null) { + $session_reg = trim((string)$session->reg->value()); + if ($session_reg !== '') { + $tmp_vehicle = (new customer_vehicles_o())->selectByPlate($session_reg); + if ($tmp_vehicle->exists()) { + $vehicle_obj = $tmp_vehicle; + } + } + } + if ($vehicle_obj !== null) { + $vehicle = [ + 'id' => (int)$vehicle_obj->id, + 'customer_id' => (int)$vehicle_obj->customer_id->value(), + 'type' => (int)$vehicle_obj->type->value(), + 'reg' => (string)$vehicle_obj->reg->value(), + 'reference' => $vehicle_obj->reference->value() === null ? null : (string)$vehicle_obj->reference->value(), + ]; + } + + $response->success([ + 'lane_id' => $lane_id, + 'in_progress' => true, + 'session' => [ + 'id' => (int)$session->id, + 'status' => (string)$session->status->value(), + 'reg' => (string)$session->reg->value(), + 'customer_number' => $customer_number, + 'vehicle_id' => $vehicle_id, + 'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(), + 'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(), + 'machine_start_triggered' => (bool)$session->machine_start_triggered->value(), + 'machine_start_triggered_at' => $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value(), + 'created_at' => (string)$session->created_at->value(), + 'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(), + ], + 'customer' => $customer, + 'vehicle' => $vehicle, + ]); + }, + [ + 'modules_selfserve_lane_wash_in_progress_view' => 'View customer and vehicle details for an in-progress self-serve wash on a lane', + ] + ); + /** Modules > Self Serve > Lane > Command */ $this->post('/modules/self-serve/lane/command', function () { global $response; @@ -177,6 +298,42 @@ class moduleSelfServeRoute 'modules_selfserve_lane_services_set_allowed' => 'Set allowed services for a lane based on currently shown tasks (post-Q&A)' ]); + /** Modules > Self Serve > Lane > Gate > Open (ENTRANCE/EXIT) */ + $this->post('/modules/self-serve/lane/gate/open', function () { + global $response; + self::requirePermission('modules_selfserve_lane_gate_open'); + $selfserve = new selfserve(); + self::requireParameters(['lane_id', 'gate']); + $lane_id = (int)$this->getParameter('lane_id'); + self::requireType($lane_id, self::type_int()); + self::requireMinValue($lane_id, 1); + + $gate_name = strtoupper(trim((string)$this->getParameter('gate'))); + $gate = match ($gate_name) { + 'ENTRANCE' => selfserve_lane_port::ENTRANCE, + 'EXIT' => selfserve_lane_port::EXIT, + default => null, + }; + if ($gate === null) { + $response->error('Invalid gate value. Expected ENTRANCE or EXIT.', 400); + } + + $lane = $selfserve->lane($lane_id); + try { + $lane->open($gate); + $response->success([ + 'lane_id' => $lane_id, + 'gate' => $gate->name, + 'opened' => true, + 'state' => $lane->getLaneState()->name, + ]); + } catch (\Exception $e) { + $response->error('Failed to open lane gate: ' . $e->getMessage(), 400); + } + }, [ + 'modules_selfserve_lane_gate_open' => 'Open ENTRANCE or EXIT gate for a self-serve lane' + ]); + /** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER status */ $this->get('/modules/self-serve/lane/relay/machine_program_picker/status', function () { global $response; diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php new file mode 100644 index 00000000..8e09c846 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php @@ -0,0 +1,381 @@ +value; + } +} + +class SelfserveDepartmentLaneRelayFake +{ + public SelfserveRelayValueFake $relay_machine_id; + public SelfserveRelayValueFake $relay_machine_program_picker_id; + public SelfserveRelayValueFake $relay_machine_cleaner_id; + + public function __construct( + string $machineRelayId = 'relay-machine', + string $programPickerRelayId = 'relay-program', + string $cleanerRelayId = 'relay-cleaner' + ) { + $this->relay_machine_id = new SelfserveRelayValueFake($machineRelayId); + $this->relay_machine_program_picker_id = new SelfserveRelayValueFake($programPickerRelayId); + $this->relay_machine_cleaner_id = new SelfserveRelayValueFake($cleanerRelayId); + } +} + +class SelfserveShellyRedisClientFake +{ + public function __construct(private readonly SelfserveShellyRedisFake $owner) {} + + public function set(string $key, string $value, mixed ...$args): bool|string + { + return $this->owner->clientSet($key, $value, $args); + } + + public function pttl(string $key): int + { + return $this->owner->clientPttl($key); + } +} + +class SelfserveShellyRedisFake +{ + /** @var array */ + private array $store = []; + /** @var array */ + private array $expiresAt = []; + private SelfserveShellyRedisClientFake $client; + /** @var callable():float */ + private $nowProvider; + + public function __construct(callable $nowProvider) + { + $this->nowProvider = $nowProvider; + $this->client = new SelfserveShellyRedisClientFake($this); + } + + public function get(string $key): ?string + { + $this->purgeExpired($key); + return $this->store[$key] ?? null; + } + + public function setEx(string $key, string $value, int $ttl): self + { + $this->store[$key] = $value; + $this->expiresAt[$key] = $this->now() + max(1, $ttl); + return $this; + } + + public function get_client(): SelfserveShellyRedisClientFake + { + return $this->client; + } + + public function clientSet(string $key, string $value, array $args): bool|string + { + $this->purgeExpired($key); + + $useNx = false; + $ttlMs = null; + $count = count($args); + for ($i = 0; $i < $count; $i++) { + $token = strtoupper((string)$args[$i]); + if ($token === 'NX') { + $useNx = true; + continue; + } + if ($token === 'PX' && isset($args[$i + 1])) { + $ttlMs = max(1, (int)$args[$i + 1]); + $i++; + } + } + + if ($useNx && array_key_exists($key, $this->store)) { + return false; + } + + $this->store[$key] = $value; + if ($ttlMs === null) { + unset($this->expiresAt[$key]); + } else { + $this->expiresAt[$key] = $this->now() + ($ttlMs / 1000); + } + + return 'OK'; + } + + public function clientPttl(string $key): int + { + $this->purgeExpired($key); + if (!array_key_exists($key, $this->store)) { + return -2; + } + if (!isset($this->expiresAt[$key])) { + return -1; + } + + $remainingMs = (int)ceil(($this->expiresAt[$key] - $this->now()) * 1000); + return $remainingMs > 0 ? $remainingMs : 0; + } + + private function now(): float + { + $provider = $this->nowProvider; + return (float)$provider(); + } + + private function purgeExpired(string $key): void + { + if (!isset($this->expiresAt[$key])) { + return; + } + if ($this->now() < $this->expiresAt[$key]) { + return; + } + unset($this->expiresAt[$key], $this->store[$key]); + } +} + +class SelfserveLaneRelayControllerHarness +{ + use selfserve_lane_relay_controller_t; + + public const CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES = 'allowed_services'; + + public int $id = 1; + public object $department_lane; + public float $now = 0.0; + /** @var array */ + public array $sleepCalls = []; + /** @var array */ + public array $shellyCalls = []; + /** @var array> */ + private array $queuedResponses = []; + private selfserve_lane_status $laneStatus; + private ?SelfserveShellyRedisFake $redis; + + public function __construct(?SelfserveShellyRedisFake $redis = null) + { + $this->department_lane = new SelfserveDepartmentLaneRelayFake(); + $this->laneStatus = selfserve_lane_status::AVAILABLE; + $this->redis = $redis; + } + + public function queueShellyResponse(string $endpoint, array|object|null $response): void + { + if (!isset($this->queuedResponses[$endpoint])) { + $this->queuedResponses[$endpoint] = []; + } + $this->queuedResponses[$endpoint][] = $response; + } + + public function getShellyCallCount(string $endpoint): int + { + $count = 0; + foreach ($this->shellyCalls as $call) { + if ($call['endpoint'] === $endpoint) { + $count++; + } + } + return $count; + } + + public function getLaneStatus(): selfserve_lane_status + { + return $this->laneStatus; + } + + public function setLaneStatus(selfserve_lane_status $laneStatus): void + { + $this->laneStatus = $laneStatus; + } + + public function getLaneCache(int $lane_id, string $key): mixed + { + return []; + } + + protected function sendShellyPost(string $endpoint, array $payload): array|object|null + { + $this->shellyCalls[] = [ + 'endpoint' => $endpoint, + 'payload' => $payload, + 'time' => $this->now, + ]; + + if (!isset($this->queuedResponses[$endpoint]) || count($this->queuedResponses[$endpoint]) < 1) { + return []; + } + + return array_shift($this->queuedResponses[$endpoint]); + } + + protected function redisFacade(): mixed + { + return $this->redis; + } + + protected function nowTimestamp(): float + { + return $this->now; + } + + protected function sleepMicroseconds(int $microseconds): void + { + if ($microseconds > 0) { + $this->sleepCalls[] = $microseconds; + $this->now += ($microseconds / 1000000); + } + } +} + +function selfserve_lane_shelly_test_harness(bool $withRedis = true): SelfserveLaneRelayControllerHarness +{ + $harness = null; + $redis = null; + if ($withRedis) { + $redis = new SelfserveShellyRedisFake(static function () use (&$harness): float { + return $harness?->now ?? 0.0; + }); + } + + $harness = new SelfserveLaneRelayControllerHarness($redis); + return $harness; +} + +it('batches sequential machine relay status requests into a single Shelly get call', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->queueShellyResponse('/v2/devices/api/get', [ + [ + 'id' => 'relay-machine', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + [ + 'id' => 'relay-program', + 'online' => true, + 'status' => ['switch:0' => ['output' => false]], + ], + [ + 'id' => 'relay-cleaner', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + ]); + + $machine = $harness->getMachineRelayStatus(); + $programPicker = $harness->getMachineProgramPickerRelayStatus(); + $cleaner = $harness->getMachineCleanerRelayStatus(); + + expect($machine['relay_id'])->toBe('relay-machine'); + expect($machine['on'])->toBeTrue(); + expect($programPicker['relay_id'])->toBe('relay-program'); + expect($programPicker['on'])->toBeFalse(); + expect($cleaner['relay_id'])->toBe('relay-cleaner'); + expect($cleaner['on'])->toBeTrue(); + + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1); + expect($harness->shellyCalls[0]['payload']['ids'])->toBe(['relay-machine', 'relay-program', 'relay-cleaner']); + expect($harness->shellyCalls[0]['payload']['select'])->toBe(['status']); +}); + +it('respects the 1 request per second Shelly gate for back-to-back requests', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->queueShellyResponse('/v2/devices/api/get', [ + [ + 'id' => 'relay-machine', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + ]); + $harness->queueShellyResponse('/v2/devices/api/set/switch', [ + [ + 'id' => 'relay-machine', + 'online' => true, + 'status' => ['switch:0' => ['output' => false]], + ], + ]); + + $harness->getMachineRelayStatus(); + $harness->setMachineRelayStatus(false); + + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); + expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/get'); + expect($harness->shellyCalls[1]['endpoint'])->toBe('/v2/devices/api/set/switch'); + expect(array_sum($harness->sleepCalls))->toBeGreaterThanOrEqual(1000000); +}); + +it('retries missing Shelly status payloads until relay status becomes ready', function (): void { + $harness = selfserve_lane_shelly_test_harness(false); + $harness->queueShellyResponse('/v2/devices/api/get', [ + ['id' => 'relay-machine', 'online' => true], + ]); + $harness->queueShellyResponse('/v2/devices/api/get', [ + [ + 'id' => 'relay-machine', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + ]); + + $status = $harness->getMachineRelayStatus(); + + expect($status['relay_id'])->toBe('relay-machine'); + expect($status['on'])->toBeTrue(); + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(2); + expect($harness->sleepCalls)->toContain(250000); +}); + +it('times out with a clear Shelly readiness error when payload remains missing', function (): void { + $harness = selfserve_lane_shelly_test_harness(false); + for ($i = 0; $i < 100; $i++) { + $harness->queueShellyResponse('/v2/devices/api/get', [ + ['id' => 'relay-machine', 'online' => true], + ]); + } + + $exception = null; + try { + $harness->getMachineRelayStatus(); + } catch (\Exception $e) { + $exception = $e; + } + + expect($exception)->toBeInstanceOf(\Exception::class); + expect($exception?->getMessage())->toContain('not ready'); + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBeGreaterThan(1); +}); + +it('uses direct set/switch and seeds cache so immediate status read does not call Shelly get', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->queueShellyResponse('/v2/devices/api/set/switch', [ + [ + 'id' => 'relay-program', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + ]); + + $harness->setMachineProgramPickerRelayStatus(true); + $status = $harness->getMachineProgramPickerRelayStatus(); + + expect($status['relay_id'])->toBe('relay-program'); + expect($status['on'])->toBeTrue(); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); + expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0); + expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/set/switch'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php index 951b443a..de78a642 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveOpenApiSpecTest.php @@ -31,8 +31,10 @@ it('documents self-serve machine type, eligibility, summary, and webhook endpoin expect($content)->toContain('/department/selfserve/vehicle/allowed:'); expect($content)->toContain('/department/selfserve/washes/summary:'); expect($content)->toContain('/relay/button/press/post:'); + expect($content)->toContain('/modules/self-serve/lane/wash/in-progress:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine/status:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine/set:'); + expect($content)->toContain('/modules/self-serve/lane/gate/open:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine_program_picker/status:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine_program_picker/set:'); expect($content)->toContain('/modules/self-serve/lane/relay/machine_cleaner/status:'); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php index e1790a68..ff6f009f 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveRouteWiringTest.php @@ -49,3 +49,24 @@ it('wires machine relay status get and set endpoints', function (): void { expect($moduleSelfServeRoute)->toContain('getMachineCleanerRelayStatus'); expect($moduleSelfServeRoute)->toContain('setMachineCleanerRelayStatus'); }); + +it('wires self-serve lane gate open endpoint', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/gate/open'); + expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_gate_open'); + expect($moduleSelfServeRoute)->toContain('selfserve_lane_port::ENTRANCE'); + expect($moduleSelfServeRoute)->toContain('selfserve_lane_port::EXIT'); + expect($moduleSelfServeRoute)->toContain('$lane->open($gate)'); +}); + +it('wires in-progress self-serve wash details endpoint', function (): void { + $moduleSelfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php')); + + expect($moduleSelfServeRoute)->not->toBeFalse(); + expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/wash/in-progress'); + expect($moduleSelfServeRoute)->toContain('modules_selfserve_lane_wash_in_progress_view'); + expect($moduleSelfServeRoute)->toContain("'in_progress' => true"); + expect($moduleSelfServeRoute)->toContain("'in_progress' => false"); +});