From b42a1a69a0d217c6072ddd6730f7733d5823a9c1 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Thu, 26 Mar 2026 20:15:36 +0100 Subject: [PATCH] Refactor and centralize Shelly rate-limiting logic in the `shelly` class, add global enforcement and unit tests, and remove redundant implementations in self-serve lane controllers. --- .tmp-db-clone/n8n-database.sqlite | Bin 0 -> 585728 bytes services/nginx/app/classes/shelly.php | 89 ++++++++- .../traits/selfserve_lane_command_t.php | 24 ++- .../selfserve_lane_port_controller_t.php | 12 +- .../selfserve_lane_relay_controller_t.php | 90 ++------- .../shelly/helpers/shelly_device_switch.php | 10 +- .../SelfserveLanePortControllerTest.php | 24 +++ .../SelfserveLaneRelayShellyBatchingTest.php | 27 ++- .../Selfserve/SelfserveLaneStopFlowTest.php | 34 +++- .../ShellyGlobalRateLimitBehaviorTest.php | 173 ++++++++++++++++++ .../ShellyGlobalRateLimitWiringTest.php | 25 +++ test/orderBookingsPost.http | 18 ++ 12 files changed, 415 insertions(+), 111 deletions(-) create mode 100644 .tmp-db-clone/n8n-database.sqlite create mode 100644 services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitBehaviorTest.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/ShellyGlobalRateLimitWiringTest.php diff --git a/.tmp-db-clone/n8n-database.sqlite b/.tmp-db-clone/n8n-database.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..102ce97afb6380df15bd76de11748bca4218f260 GIT binary patch literal 585728 zcmeFa4VWBPdFR`uk>*PpwZS%R*|A-2doaOaU zvUE7h`JMNC*6C9VC#LP1#TUwz6{E(>q3c51 zwuN5D^Px~^8@=C6?>Ez%qp$bUzuW2Ee9^TRd~FNeQ~Ah_@IQqH!=GisZx8>k@ZX33 zpYUIV|1A8q@R!414F6vEv)le@sIqN|OZ3k6PQ74^|8fHB>DTl{RF%7^t{?I~-BB}g zMXP45td`gJ(X~77;4XUCjHRqqs@b)*n*V9{#C$55N%5KF;pr3~Z3RU6(Wq@kdAn4z zmaGbYd_FysoIlMUOP%JEi{I%A53|X0KSIa!Z1rOid*hr!)NMLS}v{lb%V9jtrk3A6^+BHu>SB2Zm=3 z3>QYm_>tMUlOwShrB+{M@m#G4V~t%m_C?Yn*Vt#5OzV7l@>EvP3#KIsT3*O0x@gAp z`M6+cN+BPo%$SU&QLK7Qw|gYhvy-V)eDw0_QGRZgk8X^FQLnA7TG1Ha*l|6X6XP1g zzB#(C3TjSPL`_n2%p@%?#3f4-^>gLQnL@FAu9~G1tiF1qDcx0FQ`#eN%rKVHCbMJk z)vg`;eLGYoClv%)kxk8z1U+6bL|Io9Ij*%w^Qw(@bXV9&tmx;x<+~Wzy z%bQ_tw%fmjy~I7wZVzM`Y+j*=eJ>CAGJ)JYWMXcXJT#dm*N;ve%XZIEBFazA&8N~w zW}RBomST*bPfexfQ?nDP1>QHsb0x3t#h8;!rlwO=KPQq46UoUGyRvwElGWJRIX*d& zNzcuW@f{X*FKe;WA~%!m#%yQUG-XMo)zN`3|lT^Zhwq zNGJ(YH)JCbS9CQYD2kveRy>hwsW;u=~f)9T%9H|#;=!l^hL(U zxl3UuC)Rl@U$5EalIH=P-+S9~m7a3i)zqoHMe(zWTPrqqb?HS2sW z*k%{(l3iW4sD|Xq<)USjf`Kb#v(?%M11eUnvNl)ndjcwc=h8wwpSP-2R;|1~f zM*a9XE4N%OpYhzH{oAfKr--%(vh;!+n*Dg7h4U1S8?|M>K!WI3tlDz9RaU{EQn#Y_ z;tY_d(pseyrmfNvOUJWgtZhI+rn>obY#|LV=~!(2{=UA*zJ1)qV{Hw7lZV&sd0+cG zfV7AHSkOoMAoNYfJHg

?qJsN%HTpFRbuPK-ae>#}`kTb$H)wtXL>&Tnk9 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 PKmY_l00cnb{0RIXD(;+8 literal 0 HcmV?d00001 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