diff --git a/.tmp-db-clone/n8n-database.sqlite b/.tmp-db-clone/n8n-database.sqlite new file mode 100644 index 00000000..102ce97a Binary files /dev/null and b/.tmp-db-clone/n8n-database.sqlite differ diff --git a/services/nginx/app/classes/shelly.php b/services/nginx/app/classes/shelly.php index 5a39a9ab..be115d8f 100644 --- a/services/nginx/app/classes/shelly.php +++ b/services/nginx/app/classes/shelly.php @@ -13,6 +13,10 @@ use shelly\shelly_c; class shelly implements shelly_i { + private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20; + private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000; + private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate'; + /** * Configuration of the shelly module * @var shelly_c @@ -146,6 +150,7 @@ class shelly implements shelly_i self::requireModuleEnabled(); self::requireValidSecretKey(); self::requireValidServerURL(); + $this->waitForShellyRateLimitWindow(); // Send the request $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint)); @@ -185,4 +190,86 @@ class shelly implements shelly_i { return $url . '?auth_key=' . $this->config->secret_key->getVariableValue(); } -} \ No newline at end of file + + /** + * @throws Exception + */ + private function waitForShellyRateLimitWindow(): void + { + $deadline = $this->nowTimestamp() + self::SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS; + + do { + if ($this->tryAcquireShellyRateLimitSlot()) { + return; + } + + if ($this->nowTimestamp() >= $deadline) { + throw new Exception('Shelly rate limit gate wait timed out'); + } + + $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 API traffic 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; + } + } + + 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); + } + } +} diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php index 92d44fc8..091e75f8 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_command_t.php @@ -50,14 +50,14 @@ trait selfserve_lane_command_t } /** - * Resolve whether the program selector relay currently reports ON. + * Resolve whether the program selector relay is currently online. * Fail-closed to false when relay status cannot be read. */ - protected function isProgramSelectorRelayOnForStop(): bool + protected function isProgramSelectorRelayOnlineForStop(): bool { try { $status = $this->getMachineProgramPickerRelayStatus(); - return (bool)($status['on'] ?? false); + return (bool)($status['online'] ?? false); } catch (\Throwable) { return false; } @@ -128,14 +128,12 @@ trait selfserve_lane_command_t /** * Disable relays after STOP in deterministic order: * 1. Cleaner relay - * 2. Program selector relay - * 3. Machine relay + * 2. Machine relay */ protected function turnOffRelaysAfterStop(): void { $relays = [ selfserve_lane_relay::MACHINE_CLEANER, - selfserve_lane_relay::MACHINE_PROGRAM_PICKER, selfserve_lane_relay::MACHINE, ]; @@ -322,20 +320,20 @@ trait selfserve_lane_command_t if (($this->getCustomerNumber() !== $arguments->customer_number) && !$this->isBypassCustomerNumberValidation()) { throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number); } - // Snapshot program selector state before relay shutdown. - $program_selector_on = $this->isProgramSelectorRelayOnForStop(); + // Snapshot selector relay online state before relay shutdown. + $program_selector_online = $this->isProgramSelectorRelayOnlineForStop(); // Open the exit port $this->open(selfserve_lane_port::EXIT); - // Turn off configured relays in deterministic order. + // Turn off relays in deterministic order after STOP $this->turnOffRelaysAfterStop(); // Log the lane stop event $this->logLaneAction(selfserve_lane_log_action::STOP_WASH); - // Finalize any active self-serve wash session before resetting lane state. - $this->completeLatestSessionForStop(); // Invoice the customer $this->invoice(); - // If program selector was ON, add vehicle-type product to the self-serve order. - $this->addVehicleTypeProductToInvoiceIfNeeded($program_selector_on); + // If program selector relay is online at stop time, bill the primary product. + $this->addVehicleTypeProductToInvoiceIfNeeded($program_selector_online); + // Finalize any active self-serve wash session for this lane + $this->completeLatestSessionForStop(); // Reset the lane self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments()); break; diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_port_controller_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_port_controller_t.php index f6647b33..72fef8c8 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_port_controller_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_port_controller_t.php @@ -59,21 +59,17 @@ trait selfserve_lane_port_controller_t */ public function shellyOpenPort(selfserve_lane_port $port): bool { - // Get the relay ID based on the port + // Queue the relay switch command to be executed asynchronously $relay_id = match ($port) { selfserve_lane_port::ENTRANCE => $this->department_lane->relay_out_id->value(), selfserve_lane_port::EXIT => $this->department_lane->relay_in_id->value(), default => throw new \Exception("Invalid port specified, must be ENTRANCE or EXIT"), }; - // Make sure relay ID is valid - if (empty($relay_id)) { - throw new \Exception("Invalid relay ID for port {$port->name}"); + // If the relay ID indicates a demo port, skip the Shelly switch call but keep the logging and state changes + if ($this->isDemoPortRelayId($relay_id)) { + return true; } - //if ($this->isDemoPortRelayId((string)$relay_id)) { - // return true; - //} - $device = $this->createShellySwitchDevice(); $device->id = (string)$relay_id; $device->switch(true); 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 2d4da130..18bc68e1 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 @@ -14,8 +14,6 @@ trait selfserve_lane_relay_controller_t { private const SHELLY_STATUS_WAIT_TIMEOUT_SECONDS = 20; 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; @@ -94,6 +92,16 @@ trait selfserve_lane_relay_controller_t return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on); } + /** + * Set PROGRAM SELECTOR relay status directly, bypassing lane status guards. + * Intended for department-level operational toggles. + * @throws \Exception + */ + public function setProgramSelectorRelayStatusHard(bool $on): bool + { + return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on); + } + /** * Set MACHINE_PROGRAM_PICKER relay status directly. * @param bool $on true to turn on, false to turn off @@ -349,7 +357,7 @@ trait selfserve_lane_relay_controller_t $last_reason = 'Shelly relay status is not ready yet'; do { - $result = $this->sendShellyPostRateLimited('/v2/devices/api/get', $this->buildStatusGetPayload($cloud_relay_ids)); + $result = $this->sendShellyPost('/v2/devices/api/get', $this->buildStatusGetPayload($cloud_relay_ids)); $snapshot = $this->appendDemoRelaySnapshots( $this->mapResponseToRelaySnapshot($result), $demo_relay_ids @@ -667,80 +675,6 @@ trait selfserve_lane_relay_controller_t ); } - /** - * @throws \Exception - */ - 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 { - if ($this->tryAcquireShellyRateLimitSlot()) { - return; - } - - if ($this->nowTimestamp() >= $deadline) { - throw new \Exception('Shelly rate limit gate wait timed out'); - } - - $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 */ @@ -890,7 +824,7 @@ trait selfserve_lane_relay_controller_t $response = $this->isDemoRelayId($relay_id) ? [$this->buildDemoRelaySnapshotEntry($relay_id, $on)] - : $this->sendShellyPostRateLimited('/v2/devices/api/set/switch', $payload); + : $this->sendShellyPost('/v2/devices/api/set/switch', $payload); $this->seedLaneShellySnapshotFromSwitch($relay_id, $on, $response); return true; } diff --git a/services/nginx/app/modules/shelly/helpers/shelly_device_switch.php b/services/nginx/app/modules/shelly/helpers/shelly_device_switch.php index c6b07870..f382d6f4 100644 --- a/services/nginx/app/modules/shelly/helpers/shelly_device_switch.php +++ b/services/nginx/app/modules/shelly/helpers/shelly_device_switch.php @@ -47,6 +47,14 @@ class shelly_device_switch extends shelly_device_state 'on' => $on, ...($skip_toggle_after ? [] : ['toggle_after' => (int)$this->toggle_after]), ]; + return $this->sendShellySwitchRequest($parameters); + } + + /** + * @throws Exception + */ + protected function sendShellySwitchRequest(array $parameters): array|object|null + { return (new shelly())->sendPostRequest('/v2/devices/api/set/switch', $parameters); } -} \ No newline at end of file +} diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLanePortControllerTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLanePortControllerTest.php index caae09c0..40d39ee8 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveLanePortControllerTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLanePortControllerTest.php @@ -49,6 +49,18 @@ class SelfserveLanePortSwitchFake extends shelly_device_switch } } +class SelfservePortSwitchOutputFake extends shelly_device_switch +{ + /** @var array */ + public array $switchCalls = []; + + protected function sendShellySwitchRequest(array $parameters): array|object|null + { + $this->switchCalls[] = $parameters; + return ['ok' => true]; + } +} + class SelfserveLanePortControllerHarness { use selfserve_lane_port_controller_t; @@ -131,3 +143,15 @@ it('keeps demo relay gate-open queued but skips Shelly switch calls', function ( expect($lane->laneState)->toBe(selfserve_lane_state::EXIT_PORT_OPEN_QUEUED); expect($lane->switchFake->switchCalls)->toBe([]); }); + +it('does not print Shelly switch responses to output', function (): void { + $switch = new SelfservePortSwitchOutputFake(); + $switch->id = 'relay-port-123'; + + ob_start(); + $switch->switch(true); + $output = (string)ob_get_clean(); + + expect($output)->toBe(''); + expect($switch->switchCalls)->toHaveCount(1); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php index b178c08c..3278d927 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php @@ -306,7 +306,7 @@ it('batches sequential machine relay status requests into a single Shelly get ca expect($harness->shellyCalls[0]['payload']['select'])->toBe(['status']); }); -it('respects the 1 request per second Shelly gate for back-to-back requests', function (): void { +it('keeps back-to-back get/switch requests ordered through the relay controller', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->queueShellyResponse('/v2/devices/api/get', [ [ @@ -330,7 +330,30 @@ it('respects the 1 request per second Shelly gate for back-to-back requests', fu 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('executes sequential switch requests used by wash start and stop flows', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + + for ($i = 0; $i < 5; $i++) { + $harness->queueShellyResponse('/v2/devices/api/set/switch', [ + [ + 'id' => 'relay-machine', + 'online' => true, + 'status' => ['switch:0' => ['output' => true]], + ], + ]); + } + + // START-like sequence. + $harness->setMachineCleanerRelayStatusHard(true); + $harness->setMachineRelayStatusHard(true); + // STOP-like sequence. + $harness->setMachineCleanerRelayStatusHard(false); + $harness->setMachineProgramPickerRelayStatusHard(false); + $harness->setMachineRelayStatusHard(false); + + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(5); }); it('retries missing Shelly status payloads until relay status becomes ready', function (): void { diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php index a3ad4fe1..6a77fb8a 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneStopFlowTest.php @@ -68,14 +68,17 @@ class SelfserveLaneStopFlowHarness private int $washStartTime = 120; private ?int $reservationStartTime = null; private bool $bypassCustomerValidation = false; + private bool $programSelectorOnline; private bool $programSelectorOn; public function __construct( - bool $programSelectorOn, + bool $programSelectorOnline, + bool $programSelectorOn = true, string $machineRelayId = 'relay-machine', string $programRelayId = 'relay-program', string $cleanerRelayId = 'relay-cleaner' ) { + $this->programSelectorOnline = $programSelectorOnline; $this->programSelectorOn = $programSelectorOn; $this->department_lane = new SelfserveLaneCommandDepartmentLaneFake( $machineRelayId, @@ -162,7 +165,10 @@ class SelfserveLaneStopFlowHarness public function getMachineProgramPickerRelayStatus(): array { - return ['on' => $this->programSelectorOn]; + return [ + 'online' => $this->programSelectorOnline, + 'on' => $this->programSelectorOn, + ]; } public function open(selfserve_lane_port $port): bool @@ -193,8 +199,8 @@ class SelfserveLaneStopFlowHarness } } -it('adds vehicle type product on STOP when program selector is on, then turns off cleaner machine and selector relays', function (): void { - $lane = new SelfserveLaneStopFlowHarness(programSelectorOn: true); +it('adds vehicle type product on STOP when program selector relay is online, then turns off cleaner and machine relays', function (): void { + $lane = new SelfserveLaneStopFlowHarness(programSelectorOnline: true); $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); $lane->execute(selfserve_lane_command::STOP, $args); @@ -205,15 +211,14 @@ it('adds vehicle type product on STOP when program selector is on, then turns of expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]); expect($lane->turnedOffRelays)->toBe([ selfserve_lane_relay::MACHINE_CLEANER, - selfserve_lane_relay::MACHINE_PROGRAM_PICKER, selfserve_lane_relay::MACHINE, ]); expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE); }); -it('skips vehicle type product add when program selector is off and only disables configured relays', function (): void { +it('skips vehicle type product add when program selector relay is offline and only disables configured relays', function (): void { $lane = new SelfserveLaneStopFlowHarness( - programSelectorOn: false, + programSelectorOnline: false, machineRelayId: 'relay-machine', programRelayId: 'relay-program', cleanerRelayId: '' @@ -226,7 +231,20 @@ it('skips vehicle type product add when program selector is off and only disable expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); expect($lane->vehicleTypeProductAddCalls)->toBe(0); expect($lane->turnedOffRelays)->toBe([ - selfserve_lane_relay::MACHINE_PROGRAM_PICKER, selfserve_lane_relay::MACHINE, ]); }); + +it('bills primary product when selector relay is online even if relay output is off', function (): void { + $lane = new SelfserveLaneStopFlowHarness( + programSelectorOnline: true, + programSelectorOn: false + ); + $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); + + $lane->execute(selfserve_lane_command::STOP, $args); + + expect($lane->invoiceCalls)->toBe(1); + expect($lane->ensureInvoiceOrderContextCalls)->toBe(1); + expect($lane->vehicleTypeProductAddCalls)->toBe(1); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitBehaviorTest.php b/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitBehaviorTest.php new file mode 100644 index 00000000..986ebf10 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitBehaviorTest.php @@ -0,0 +1,173 @@ +owner->clientSet($key, $value, $args); + } + + public function pttl(string $key): int + { + return $this->owner->clientPttl($key); + } +} + +class GlobalShellyRateLimitRedisFake +{ + /** @var array */ + private array $store = []; + /** @var array */ + private array $expiresAt = []; + private GlobalShellyRateLimitRedisClientFake $client; + + public function __construct(private readonly GlobalShellyRateLimitClock $clock) + { + $this->client = new GlobalShellyRateLimitRedisClientFake($this); + } + + public function get_client(): GlobalShellyRateLimitRedisClientFake + { + 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->clock->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->clock->now) * 1000); + return $remainingMs > 0 ? $remainingMs : 0; + } + + private function purgeExpired(string $key): void + { + if (!isset($this->expiresAt[$key])) { + return; + } + if ($this->clock->now < $this->expiresAt[$key]) { + return; + } + unset($this->expiresAt[$key], $this->store[$key]); + } +} + +class GlobalShellyRateLimitHarness extends shelly +{ + /** @var array */ + public array $sleepCalls = []; + + public function __construct( + private readonly GlobalShellyRateLimitClock $clock, + private readonly ?GlobalShellyRateLimitRedisFake $redis = null + ) { + } + + public function waitForShellyRateLimitWindowForTest(): void + { + $invoke = \Closure::bind(function (): void { + $this->waitForShellyRateLimitWindow(); + }, $this, shelly::class); + + $invoke(); + } + + protected function redisFacade(): mixed + { + return $this->redis; + } + + protected function nowTimestamp(): float + { + return $this->clock->now; + } + + protected function sleepMicroseconds(int $microseconds): void + { + if ($microseconds <= 0) { + return; + } + $this->sleepCalls[] = $microseconds; + $this->clock->now += ($microseconds / 1000000); + } +} + +it('enforces the 2 second Shelly gate across separate request contexts', function (): void { + $clock = new GlobalShellyRateLimitClock(); + $redis = new GlobalShellyRateLimitRedisFake($clock); + + $requestA = new GlobalShellyRateLimitHarness($clock, $redis); + $requestB = new GlobalShellyRateLimitHarness($clock, $redis); + + $requestA->waitForShellyRateLimitWindowForTest(); + $timeBeforeRequestB = $clock->now; + $requestB->waitForShellyRateLimitWindowForTest(); + + expect(array_sum($requestA->sleepCalls))->toBe(0); + expect(array_sum($requestB->sleepCalls))->toBeGreaterThanOrEqual(2000000); + expect($clock->now - $timeBeforeRequestB)->toBeGreaterThanOrEqual(2.0); +}); + +it('does not delay when the Shelly gate is already expired', function (): void { + $clock = new GlobalShellyRateLimitClock(); + $redis = new GlobalShellyRateLimitRedisFake($clock); + + $firstRequest = new GlobalShellyRateLimitHarness($clock, $redis); + $firstRequest->waitForShellyRateLimitWindowForTest(); + + $clock->now += 2.1; + + $secondRequest = new GlobalShellyRateLimitHarness($clock, $redis); + $secondRequest->waitForShellyRateLimitWindowForTest(); + + expect(array_sum($secondRequest->sleepCalls))->toBe(0); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitWiringTest.php new file mode 100644 index 00000000..2f64abbc --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitWiringTest.php @@ -0,0 +1,25 @@ +not->toBeFalse(); + expect($shellyClass)->toContain('private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;'); + expect($shellyClass)->toContain("private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';"); + + $sendPostRequestOffset = strpos($shellyClass, 'function sendPostRequest(string $endpoint, array $data): array|object|null'); + expect($sendPostRequestOffset)->not->toBeFalse(); + $sendPostRequestBody = substr($shellyClass, (int)$sendPostRequestOffset, 2200); + + expect($sendPostRequestBody)->toContain('$this->waitForShellyRateLimitWindow();'); +}); + +it('uses Redis NX PX semantics for cross-request Shelly rate limiting', function (): void { + $shellyClass = file_get_contents(app_path('classes/shelly.php')); + + expect($shellyClass)->not->toBeFalse(); + expect($shellyClass)->toContain("self::SHELLY_RATE_LIMIT_GATE_KEY"); + expect($shellyClass)->toContain("'PX'"); + expect($shellyClass)->toContain("'NX'"); + expect($shellyClass)->toContain('pttl(self::SHELLY_RATE_LIMIT_GATE_KEY)'); +}); diff --git a/test/orderBookingsPost.http b/test/orderBookingsPost.http index 347754f2..f93baf14 100644 --- a/test/orderBookingsPost.http +++ b/test/orderBookingsPost.http @@ -145,6 +145,13 @@ Accept: application/json Content-Type: application/json Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 + +### GET request to weather (specific ID) +GET https://api.truckwash.io/departments/weather?id=1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 + ### PUT request to order bookings (update specific ID) PUT https://api.truckwash.dk:4433/order-bookings Accept: application/json @@ -178,6 +185,17 @@ Content-Type: application/json "customer_number": 12345679 } +### Get department distribution e-conomic +GET http://localhost/api/superuser/invoicing/period/distribution/v2/booked-department-75 +Accept: application/json +Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 +Content-Type: application/json + +{ + "dateFrom": "2026-01-01", + "dateTo": "2026-01-31" +} + ### ### POST request to order bookings POST https://api.truckwash.dk:4433/order-bookings