From 416eba7de590a6469473c4fb143505e2afd637f0 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 25 Mar 2026 14:24:30 +0100 Subject: [PATCH] Add unit tests and route updates for department self-serve enabled flag relay synchronization. Refactor relay handling with hard set methods and lane status guard bypass. --- .../selfserve_lane_relay_controller_t.php | 49 ++++++++++--- .../nginx/app/routes/departmentsRoute.php | 72 ++++++++++++++++++- ...entSelfServeEnabledRelaySyncWiringTest.php | 15 ++++ .../SelfserveLaneRelayShellyBatchingTest.php | 23 ++++++ 4 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php 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 d7bce765..eb52eb95 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 @@ -83,6 +83,16 @@ trait selfserve_lane_relay_controller_t return $this->setRelayStatus(selfserve_lane_relay::MACHINE, $on); } + /** + * Set MACHINE relay status directly, bypassing lane status guards. + * Intended for department-level operational toggles. + * @throws \Exception + */ + public function setMachineRelayStatusHard(bool $on): bool + { + return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on); + } + /** * Set MACHINE_PROGRAM_PICKER relay status directly. * @param bool $on true to turn on, false to turn off @@ -94,6 +104,16 @@ trait selfserve_lane_relay_controller_t return $this->setRelayStatus(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on); } + /** + * Set MACHINE_PROGRAM_PICKER relay status directly, bypassing lane status guards. + * Intended for department-level operational toggles. + * @throws \Exception + */ + public function setMachineProgramPickerRelayStatusHard(bool $on): bool + { + return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on); + } + /** * Set MACHINE_CLEANER relay status directly. * @param bool $on true to turn on, false to turn off @@ -105,6 +125,16 @@ trait selfserve_lane_relay_controller_t return $this->setRelayStatus(selfserve_lane_relay::MACHINE_CLEANER, $on); } + /** + * Set MACHINE_CLEANER relay status directly, bypassing lane status guards. + * Intended for department-level operational toggles. + * @throws \Exception + */ + public function setMachineCleanerRelayStatusHard(bool $on): bool + { + return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_CLEANER, $on); + } + /** * Set relay status directly for a specific relay type. * @param selfserve_lane_relay $relay @@ -119,6 +149,15 @@ trait selfserve_lane_relay_controller_t : $this->forceTurnOffRelay($relay); } + /** + * Set relay status directly for a specific relay type, bypassing lane status guards. + * @throws \Exception + */ + public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool + { + return $this->sendRelaySwitchCommand($relay, $on); + } + /** * Resolve relay ID for the current lane. * @param selfserve_lane_relay $relay @@ -677,14 +716,9 @@ trait selfserve_lane_relay_controller_t 'id' => $relay_id, 'channel' => self::SHELLY_DEFAULT_CHANNEL, 'on' => $on, - // We keep toggle_after=0 to avoid unintended auto toggle behavior. - 'toggle_after' => 0, ]; - if ($duration !== null && $duration > 0) { - // Current relay API behavior intentionally keeps manual relay commands explicit; - // duration is accepted by route contracts but does not auto-toggle at Shelly level. - $payload['toggle_after'] = 0; - } + // Keep duration parameter for route compatibility, but do not pass toggle_after. + // Some Shelly firmware variants interpret toggle_after=0 as immediate toggle. $response = $this->sendShellyPostRateLimited('/v2/devices/api/set/switch', $payload); $this->seedLaneShellySnapshotFromSwitch($relay_id, $on, $response); @@ -737,4 +771,3 @@ trait selfserve_lane_relay_controller_t return $devices[0]; } } - diff --git a/services/nginx/app/routes/departmentsRoute.php b/services/nginx/app/routes/departmentsRoute.php index 642c6b90..64923784 100644 --- a/services/nginx/app/routes/departmentsRoute.php +++ b/services/nginx/app/routes/departmentsRoute.php @@ -3,8 +3,10 @@ namespace routes; use classes\authentication; +use classes\selfserve; use objects\categories_o; use objects\department_categories_o; +use objects\department_lanes_o; use objects\department_variables_o; use objects\departments_o; use objects\logs_o; @@ -287,6 +289,7 @@ class departmentsRoute $department_variables = (new department_variables_o())->selectDepartment($department->id); $enabled = self::getParameter('enabled') === 'true' || self::getParameter('enabled') === true || self::getParameter('enabled') === 1 || self::getParameter('enabled') === '1'; $department_variables->set('selfserve_enabled', $enabled ? 'true' : 'false'); + $this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled); // Log the incident (new logs_o())->add('departments', $department->id, 1, $user->id, 'EDIT_DEPARTMENT_SELFSERVE_ENABLED', 'Successfully edited department self-serve enabled status to ' . ($enabled ? 'true' : 'false')); @@ -490,4 +493,71 @@ class departmentsRoute ] ); } -} \ No newline at end of file + + protected function syncDepartmentSelfServeRelayStates(int $departmentId, bool $enabled): void + { + $selfserve = new selfserve(); + $lanes = (new department_lanes_o())->getDepartmentLanes($departmentId); + + foreach ($lanes as $department_lane) { + $lane_id = (int)$department_lane->id; + if ($lane_id <= 0) { + continue; + } + + try { + $lane = $selfserve->lane($lane_id); + } catch (\Throwable) { + continue; + } + + if ($enabled) { + // Self-serve enabled: keep machine stack off. + $this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void { + $lane->setMachineProgramPickerRelayStatusHard(false); + }); + $this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void { + $lane->setMachineCleanerRelayStatusHard(false); + }); + try { + $lane->setMachineRelayStatusHard(false); + } catch (\Throwable) {} + continue; + } + + // Self-serve disabled: restore machine stack on. + $this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void { + $lane->setMachineCleanerRelayStatusHard(true); + }); + try { + $lane->setMachineRelayStatusHard(true); + } catch (\Throwable) {} + $this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void { + $lane->setMachineProgramPickerRelayStatusHard(true); + }); + } + } + + protected function setOptionalLaneRelayState(object $lane, string $relayProperty, callable $callback): void + { + if ( + empty($lane->department_lane) + || !isset($lane->department_lane->{$relayProperty}) + || !is_object($lane->department_lane->{$relayProperty}) + || !method_exists($lane->department_lane->{$relayProperty}, 'value') + ) { + return; + } + + $relay_id = trim((string)$lane->department_lane->{$relayProperty}->value()); + if ($relay_id === '') { + return; + } + + try { + $callback(); + } catch (\Throwable) { + // Best effort only; this endpoint should still update the department variable. + } + } +} diff --git a/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php new file mode 100644 index 00000000..bab0ab3c --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/DepartmentSelfServeEnabledRelaySyncWiringTest.php @@ -0,0 +1,15 @@ +not->toBeFalse(); + expect($routeContent)->toContain('/departments/self-serve/enabled'); + expect($routeContent)->toContain('$this->syncDepartmentSelfServeRelayStates((int)$department->id, $enabled);'); + expect($routeContent)->toContain('setMachineProgramPickerRelayStatusHard(false)'); + expect($routeContent)->toContain('setMachineCleanerRelayStatusHard(false)'); + expect($routeContent)->toContain('setMachineRelayStatusHard(false)'); + expect($routeContent)->toContain('setMachineCleanerRelayStatusHard(true)'); + expect($routeContent)->toContain('setMachineRelayStatusHard(true)'); + expect($routeContent)->toContain('setMachineProgramPickerRelayStatusHard(true)'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php index 8e09c846..5af49f91 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveLaneRelayShellyBatchingTest.php @@ -378,4 +378,27 @@ it('uses direct set/switch and seeds cache so immediate status read does not cal 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'); + expect($harness->shellyCalls[0]['payload'])->toMatchArray([ + 'id' => 'relay-program', + 'channel' => 0, + 'on' => true, + ]); + expect(array_key_exists('toggle_after', $harness->shellyCalls[0]['payload']))->toBeFalse(); +}); + +it('supports hard relay set even when lane status is CLOSED', function (): void { + $harness = selfserve_lane_shelly_test_harness(); + $harness->setLaneStatus(selfserve_lane_status::CLOSED); + $harness->queueShellyResponse('/v2/devices/api/set/switch', [ + [ + 'id' => 'relay-machine', + 'online' => true, + 'status' => ['switch:0' => ['output' => false]], + ], + ]); + + $result = $harness->setMachineRelayStatusHard(false); + + expect($result)->toBeTrue(); + expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); });