fix(rotation): gate poll-driven advance() behind isDue() check
CI / test (push) Has been cancelled

Symptom: with wakeTimes=[4 AM, 9 PM, 9:15 PM], the frame rotated to a
fresh photo at 10:14 AM when the device was reconnected and polled.
The wakeTimes only governed *when* the device polled (via X-Interval-Ms);
they didn't gate whether the server picked a new image when it did.
Power-on or button-press polls would always rotate.

Fix: move the existing isDue() logic from AdvanceRotationMessageHandler
into RotationService as a public method, and gate
DeviceImageController::image so off-schedule polls return the device's
current image (which 304s when X-Current-Image-Id matches) rather than
calling advance(). The scheduler-driven handler still uses the same
isDue — both code paths now share one source of truth.

Tests:
  - DeviceImageControllerTest: new test asserting an off-schedule poll
    returns 304 without rotating; existing wakeTimes tests reworked to
    use slot lists that always have a past slot regardless of run time.
  - AdvanceRotationMessageHandlerTest: existing AR-04 through AR-07
    keep covering isDue's semantics — they now go through the service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 12:14:32 -04:00
parent bf9d4ebc58
commit 5b3e2e47d7
4 changed files with 118 additions and 63 deletions
@@ -336,13 +336,15 @@ class DeviceImageControllerTest extends AppWebTestCase
$this->assertResponseStatusCodeSame(204);
}
// When wakeTimes is set, X-Interval-Ms should be > 0 and <= 24h in ms
// When wakeTimes is set and one slot has already passed today, X-Interval-Ms
// should be > 0 and <= 24h. Use [0] (midnight) so the slot is always past
// regardless of wall-clock time at test execution.
public function test_wake_times_interval_used_when_set(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
$device->setWakeTimes([3 * 60])->setTimezone('UTC');
$device->setWakeTimes([0])->setTimezone('UTC');
$this->em()->flush();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
@@ -354,15 +356,16 @@ class DeviceImageControllerTest extends AppWebTestCase
}
// With multiple wake times, X-Interval-Ms must point to the *earliest*
// upcoming time, not just the first in the list.
// upcoming time, not just the first in the list. Use evenly-spaced slots
// including 00:00 so at least one is always past regardless of run time.
public function test_wake_times_picks_earliest_upcoming(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
// Use a fixed UTC tz; with three slots evenly spread, the gap to the
// next slot can never exceed 24h / count = 8h.
$device->setWakeTimes([6 * 60, 14 * 60, 22 * 60])->setTimezone('UTC');
// 00:00, 08:00, 16:00 — gap to next slot is at most 8h regardless of
// when the test runs, and at least one is always in the past.
$device->setWakeTimes([0, 8 * 60, 16 * 60])->setTimezone('UTC');
$this->em()->flush();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
@@ -373,6 +376,35 @@ class DeviceImageControllerTest extends AppWebTestCase
$this->assertLessThanOrEqual(8 * 60 * 60 * 1000, $intervalMs);
}
// Off-schedule poll (wakeTimes set, but the most-recent past slot has
// already been served): the controller MUST NOT advance the rotation.
// It returns 304 (device's X-Current-Image-Id matches the existing
// currentImage) instead of a fresh 200.
public function test_off_schedule_poll_returns_304_without_advancing(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
// First poll establishes a current image while wakeTimes is unset
// (so isDue is true and rotation runs).
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(200);
$imageId = $this->client->getResponse()->headers->get('X-Image-Id');
// Now configure wakeTimes such that the most-recent past slot has
// already been served (the poll above wrote a history entry just now,
// and 00:00 is the most-recent past slot in UTC).
$device->setWakeTimes([0])->setTimezone('UTC');
$this->em()->flush();
// A second poll right now is "off-schedule" — server should NOT
// advance, and since we report the current image, server should 304.
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
'HTTP_X_Current_Image_Id' => $imageId,
]);
$this->assertResponseStatusCodeSame(304);
}
// Returns 204 when RenderedAsset has Ready status but filePath is null (device.poll.no_asset path)
public function test_returns_204_when_ready_asset_has_null_file_path(): void
{