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.
This commit is contained in:
Binary file not shown.
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,18 @@ class SelfserveLanePortSwitchFake extends shelly_device_switch
|
||||
}
|
||||
}
|
||||
|
||||
class SelfservePortSwitchOutputFake extends shelly_device_switch
|
||||
{
|
||||
/** @var array<int,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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/shelly.php');
|
||||
|
||||
use classes\shelly;
|
||||
|
||||
class GlobalShellyRateLimitClock
|
||||
{
|
||||
public float $now = 0.0;
|
||||
}
|
||||
|
||||
class GlobalShellyRateLimitRedisClientFake
|
||||
{
|
||||
public function __construct(private readonly GlobalShellyRateLimitRedisFake $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 GlobalShellyRateLimitRedisFake
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
private array $store = [];
|
||||
/** @var array<string,float> */
|
||||
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<int,int> */
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
it('enforces a global 2 second Shelly gate in sendPostRequest', function (): void {
|
||||
$shellyClass = file_get_contents(app_path('classes/shelly.php'));
|
||||
|
||||
expect($shellyClass)->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)');
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user