Files
api/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php
T

382 lines
12 KiB
PHP

<?php
app_require('modules/selfserve/traits/selfserve_lane_relay_controller_t.php');
app_require('modules/selfserve/helpers/selfserve_lane_relay.php');
app_require('modules/selfserve/helpers/selfserve_lane_status.php');
use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_lane_status;
use modules\selfserve\traits\selfserve_lane_relay_controller_t;
class SelfserveRelayValueFake
{
public function __construct(private readonly string $value) {}
public function value(): string
{
return $this->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<string,string> */
private array $store = [];
/** @var array<string,float> */
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<int,int> */
public array $sleepCalls = [];
/** @var array<int,array{endpoint:string,payload:array,time:float}> */
public array $shellyCalls = [];
/** @var array<string,array<int,array|object|null>> */
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');
});