b48ed73b4e
CI / test (push) Has been cancelled
Symptom: wakeTimes schedules silently never fire on non-UTC devices.
Reported live by Matt's EDT frame: wakeTimes=[12:30 PM NY] saved,
12:30 came and went, no rotation. Same bug pattern would fire
*every* poll on east-of-UTC tzs.
Root cause: device_image_history.served_at is `timestamp without time
zone`, written by `new DateTimeImmutable()` so it stores UTC
components ("2026-05-08 16:28:50"). The boundary in isDue() was
bound through Doctrine with the device's local tz still attached,
so Doctrine's format() emitted local-tz components ("12:30:00").
Postgres compared the strings literally — for west-of-UTC tzs the
UTC timestamp is numerically larger than the local-tz boundary, so
every same-day row falsely satisfied `servedAt >= :wakeTime` and
isDue returned false.
Fix: $boundary->setTimezone(UTC) before binding. Both sides now
format in UTC components, so Postgres's literal compare is correct.
Regression test ID-TZ-01: device in America/New_York, wakeTimes
[12:30 PM NY], history at 12:00 PM NY (= 16:00 UTC). With the fix
isDue returns true; without it the test falsely-matches and fails.
Skipped before 13:00 NY since the assertion needs the wake slot to
have already passed today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
504 lines
20 KiB
PHP
504 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Unit\Service;
|
|
|
|
use App\Entity\Device;
|
|
use App\Entity\DeviceImageHistory;
|
|
use App\Entity\Image;
|
|
use App\Entity\RenderedAsset;
|
|
use App\Enum\DeviceModel;
|
|
use App\Enum\Orientation;
|
|
use App\Enum\RenderStatus;
|
|
use App\Enum\RotationMode;
|
|
use App\Service\RotationService;
|
|
use App\Tests\AppKernelTestCase;
|
|
|
|
/**
|
|
* Integration tests for RotationService (requires real DB + dama rollback).
|
|
*/
|
|
class RotationServiceTest extends AppKernelTestCase
|
|
{
|
|
private RotationService $rotation;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->rotation = static::getContainer()->get(RotationService::class);
|
|
}
|
|
|
|
/**
|
|
* Creates a User+Device approved for a ready Image with V1/Landscape asset.
|
|
* Returns [Device, Image].
|
|
*/
|
|
private function createDeviceWithReadyImage(
|
|
string $email = 'test@example.com',
|
|
RenderStatus $status = RenderStatus::Ready,
|
|
bool $approveForDevice = true,
|
|
?\DateTimeImmutable $uploadedAt = null,
|
|
): array {
|
|
$user = $this->createUser($email);
|
|
|
|
$device = new Device();
|
|
$device->setMac(strtoupper(bin2hex(random_bytes(3)) . ':' . bin2hex(random_bytes(3)) . ':AA'));
|
|
$device->setName('Test Device');
|
|
$device->setUser($user);
|
|
$device->setModel(DeviceModel::V1);
|
|
$device->setOrientation(Orientation::Landscape);
|
|
self::em()->persist($device);
|
|
|
|
$image = new Image();
|
|
$image->setUser($user);
|
|
$image->setOriginalFilename('test.jpg');
|
|
$image->setStoragePath('var/storage/images/test/original.jpg');
|
|
if ($uploadedAt !== null) {
|
|
// Use reflection to set uploadedAt since it's set in constructor
|
|
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
|
|
$ref->setAccessible(true);
|
|
$ref->setValue($image, $uploadedAt);
|
|
}
|
|
if ($approveForDevice) {
|
|
$image->approveForDevice($device);
|
|
}
|
|
self::em()->persist($image);
|
|
|
|
$asset = (new RenderedAsset())
|
|
->setImage($image)
|
|
->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)
|
|
->setStatus($status)
|
|
->setFilePath('var/storage/images/test/v1_landscape.bin');
|
|
self::em()->persist($asset);
|
|
|
|
self::em()->flush();
|
|
|
|
return [$device, $image];
|
|
}
|
|
|
|
public function test_advance_returns_null_when_pool_empty(): void
|
|
{
|
|
$user = $this->createUser('empty@example.com');
|
|
$device = new Device();
|
|
$device->setMac('AA:BB:CC:DD:EE:FF');
|
|
$device->setName('Empty Device');
|
|
$device->setUser($user);
|
|
self::em()->persist($device);
|
|
self::em()->flush();
|
|
|
|
$result = $this->rotation->advance($device);
|
|
|
|
$this->assertNull($result);
|
|
}
|
|
|
|
public function test_advance_picks_oldest_image(): void
|
|
{
|
|
$user = $this->createUser('oldest@example.com');
|
|
|
|
$device = new Device();
|
|
$device->setMac('AA:BB:CC:DD:EE:01');
|
|
$device->setName('Device');
|
|
$device->setUser($user);
|
|
self::em()->persist($device);
|
|
|
|
$older = new Image();
|
|
$older->setUser($user)->setOriginalFilename('old.jpg')->setStoragePath('x');
|
|
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
|
|
$ref->setAccessible(true);
|
|
$ref->setValue($older, new \DateTimeImmutable('2024-01-01'));
|
|
$older->approveForDevice($device);
|
|
self::em()->persist($older);
|
|
|
|
$assetOld = (new RenderedAsset())->setImage($older)->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('x/old.bin');
|
|
self::em()->persist($assetOld);
|
|
|
|
$newer = new Image();
|
|
$newer->setUser($user)->setOriginalFilename('new.jpg')->setStoragePath('y');
|
|
$ref->setValue($newer, new \DateTimeImmutable('2024-06-01'));
|
|
$newer->approveForDevice($device);
|
|
self::em()->persist($newer);
|
|
|
|
$assetNew = (new RenderedAsset())->setImage($newer)->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('y/new.bin');
|
|
self::em()->persist($assetNew);
|
|
|
|
self::em()->flush();
|
|
|
|
$result = $this->rotation->advance($device);
|
|
|
|
$this->assertNotNull($result);
|
|
$this->assertSame($older->getId(), $result->getId());
|
|
}
|
|
|
|
public function test_advance_skips_recently_shown(): void
|
|
{
|
|
[$device, $imageA] = $this->createDeviceWithReadyImage('skip@example.com');
|
|
|
|
$imageB = new Image();
|
|
$imageB->setUser($device->getUser())->setOriginalFilename('b.jpg')->setStoragePath('b');
|
|
$imageB->approveForDevice($device);
|
|
self::em()->persist($imageB);
|
|
|
|
$assetB = (new RenderedAsset())->setImage($imageB)->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('b.bin');
|
|
self::em()->persist($assetB);
|
|
|
|
// Put imageA in history
|
|
$history = new DeviceImageHistory($device, $imageA);
|
|
self::em()->persist($history);
|
|
|
|
$device->setUniquenessWindow(2);
|
|
self::em()->flush();
|
|
|
|
$result = $this->rotation->advance($device);
|
|
|
|
$this->assertNotNull($result);
|
|
$this->assertSame($imageB->getId(), $result->getId());
|
|
}
|
|
|
|
public function test_advance_resets_when_all_in_window(): void
|
|
{
|
|
[$device, $imageA] = $this->createDeviceWithReadyImage('reset@example.com');
|
|
|
|
$imageB = new Image();
|
|
$imageB->setUser($device->getUser())->setOriginalFilename('b.jpg')->setStoragePath('b');
|
|
$imageB->approveForDevice($device);
|
|
self::em()->persist($imageB);
|
|
|
|
$assetB = (new RenderedAsset())->setImage($imageB)->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('b.bin');
|
|
self::em()->persist($assetB);
|
|
|
|
$histA = new DeviceImageHistory($device, $imageA);
|
|
$histB = new DeviceImageHistory($device, $imageB);
|
|
self::em()->persist($histA);
|
|
self::em()->persist($histB);
|
|
|
|
$device->setUniquenessWindow(2);
|
|
self::em()->flush();
|
|
|
|
$result = $this->rotation->advance($device);
|
|
|
|
$this->assertNotNull($result);
|
|
}
|
|
|
|
public function test_advance_respects_uniqueness_window(): void
|
|
{
|
|
$user = $this->createUser('window@example.com');
|
|
|
|
$device = new Device();
|
|
$device->setMac('AA:BB:CC:DD:EE:02');
|
|
$device->setName('Window Device');
|
|
$device->setUser($user);
|
|
$device->setUniquenessWindow(1);
|
|
self::em()->persist($device);
|
|
|
|
$images = [];
|
|
for ($i = 0; $i < 3; $i++) {
|
|
$img = new Image();
|
|
$img->setUser($user)->setOriginalFilename("img{$i}.jpg")->setStoragePath("p{$i}");
|
|
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
|
|
$ref->setAccessible(true);
|
|
$ref->setValue($img, new \DateTimeImmutable("2024-01-0" . ($i + 1)));
|
|
$img->approveForDevice($device);
|
|
self::em()->persist($img);
|
|
$asset = (new RenderedAsset())->setImage($img)->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath("p{$i}.bin");
|
|
self::em()->persist($asset);
|
|
$images[] = $img;
|
|
}
|
|
|
|
// Put image[0] in history (most recent)
|
|
$hist = new DeviceImageHistory($device, $images[0]);
|
|
self::em()->persist($hist);
|
|
self::em()->flush();
|
|
|
|
$result = $this->rotation->advance($device);
|
|
|
|
$this->assertNotNull($result);
|
|
$this->assertNotSame($images[0]->getId(), $result->getId());
|
|
}
|
|
|
|
public function test_advance_persists_history(): void
|
|
{
|
|
[$device] = $this->createDeviceWithReadyImage('history@example.com');
|
|
|
|
$this->rotation->advance($device);
|
|
|
|
$count = self::em()->createQueryBuilder()
|
|
->select('COUNT(h.id)')
|
|
->from(DeviceImageHistory::class, 'h')
|
|
->where('h.device = :device')
|
|
->setParameter('device', $device)
|
|
->getQuery()
|
|
->getSingleScalarResult();
|
|
|
|
$this->assertSame(1, (int) $count);
|
|
}
|
|
|
|
public function test_advance_updates_current_image(): void
|
|
{
|
|
[$device, $image] = $this->createDeviceWithReadyImage('current@example.com');
|
|
|
|
$this->rotation->advance($device);
|
|
|
|
self::em()->refresh($device);
|
|
|
|
$this->assertNotNull($device->getCurrentImage());
|
|
$this->assertSame($image->getId(), $device->getCurrentImage()->getId());
|
|
}
|
|
|
|
public function test_advance_excludes_pending_asset(): void
|
|
{
|
|
[$device, ] = $this->createDeviceWithReadyImage('pending@example.com', RenderStatus::Pending);
|
|
|
|
$result = $this->rotation->advance($device);
|
|
|
|
$this->assertNull($result);
|
|
}
|
|
|
|
public function test_advance_excludes_unapproved_image(): void
|
|
{
|
|
[$device, ] = $this->createDeviceWithReadyImage('unapproved@example.com', RenderStatus::Ready, false);
|
|
|
|
$result = $this->rotation->advance($device);
|
|
|
|
$this->assertNull($result);
|
|
}
|
|
|
|
/**
|
|
* Builds a device approved for N ready images with explicit uploadedAt
|
|
* timestamps. Returns [device, [image, ...]] with images in upload-time
|
|
* order (oldest first, newest last).
|
|
*
|
|
* @param string[] $uploadedAt ISO-8601 dates, oldest first
|
|
* @return array{0: Device, 1: Image[]}
|
|
*/
|
|
private function setupDeviceAndImages(string $email, array $uploadedAt): array
|
|
{
|
|
$user = $this->createUser($email);
|
|
$device = new Device();
|
|
$device->setMac(strtoupper(bin2hex(random_bytes(3)) . ':' . bin2hex(random_bytes(3)) . ':BB'));
|
|
$device->setName('Test Device');
|
|
$device->setUser($user);
|
|
// Loose window so the uniqueness filter never empties the candidate set
|
|
// unintentionally — we want each test to drive its own filter behavior.
|
|
$device->setUniquenessWindow(1);
|
|
self::em()->persist($device);
|
|
|
|
$images = [];
|
|
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
|
|
$ref->setAccessible(true);
|
|
foreach ($uploadedAt as $i => $ts) {
|
|
$img = new Image();
|
|
$img->setUser($user)
|
|
->setOriginalFilename("img{$i}.jpg")
|
|
->setStoragePath("p{$i}");
|
|
$ref->setValue($img, new \DateTimeImmutable($ts));
|
|
$img->approveForDevice($device);
|
|
self::em()->persist($img);
|
|
$asset = (new RenderedAsset())
|
|
->setImage($img)
|
|
->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)
|
|
->setStatus(RenderStatus::Ready)
|
|
->setFilePath("p{$i}.bin");
|
|
self::em()->persist($asset);
|
|
$images[] = $img;
|
|
}
|
|
self::em()->flush();
|
|
return [$device, $images];
|
|
}
|
|
|
|
/** Backdate a history row so least-recently-shown ordering is testable. */
|
|
private function recordHistoryAt(Device $device, Image $image, string $servedAt): void
|
|
{
|
|
$history = new DeviceImageHistory($device, $image);
|
|
$ref = new \ReflectionProperty(DeviceImageHistory::class, 'servedAt');
|
|
$ref->setAccessible(true);
|
|
$ref->setValue($history, new \DateTimeImmutable($servedAt));
|
|
self::em()->persist($history);
|
|
self::em()->flush();
|
|
}
|
|
|
|
// ID-TZ-01: Regression — isDue() in a non-UTC device timezone.
|
|
//
|
|
// Bug: device_image_history.served_at is `timestamp without time zone`
|
|
// storing UTC components, while the boundary in isDue() was bound with
|
|
// the device's local-tz components. Postgres compared the strings
|
|
// literally, so for tzs west of UTC every same-day row falsely matched
|
|
// the `>= boundary` predicate and isDue returned false — wakeTimes
|
|
// schedules silently never fired in EDT/PST/CST/etc.
|
|
//
|
|
// Real-world repro (Matt's EDT frame, 2026-05-08): wakeTimes=[12:30 PM
|
|
// NY], history at 12:28:50 EDT = 16:28:50 UTC. Boundary serialized as
|
|
// "12:30:00" (NY components), history stored as "16:28:50" (UTC):
|
|
// "16:28:50" >= "12:30:00" lexically → false-match → bug. Fix: bind
|
|
// the boundary in UTC so both sides agree.
|
|
//
|
|
// Test windowing: needs NY local to be past 12:30 PM (so the wake slot
|
|
// qualifies as "today's most-recent past"). Skipped before 13:00 NY.
|
|
public function test_id_tz_01_isDue_correct_in_non_utc_timezone(): void
|
|
{
|
|
$nyHour = (int) (new \DateTimeImmutable('now', new \DateTimeZone('America/New_York')))->format('G');
|
|
if ($nyHour < 13) {
|
|
$this->markTestSkipped('Test exercises a 12:30 PM NY-local boundary; skip until after 13:00 NY');
|
|
}
|
|
|
|
[$device, $images] = $this->setupDeviceAndImages('tz-edt@example.com', [
|
|
'2024-01-01',
|
|
]);
|
|
$device->setTimezone('America/New_York');
|
|
$device->setWakeTimes([12 * 60 + 30]); // 12:30 PM NY-local
|
|
self::em()->flush();
|
|
|
|
// Record a history row at 12:00 PM NY today, stored as UTC components
|
|
// (16:00:00 in EDT, 17:00:00 in EST). The buggy comparison sees
|
|
// "16:00:00" >= "12:30:00" (boundary was NY-formatted) → falsely
|
|
// matches → isDue returns false. The fixed comparison binds the
|
|
// boundary as "16:30:00" UTC → "16:00:00" < "16:30:00" → no match →
|
|
// isDue returns true.
|
|
$historyUtc = (new \DateTimeImmutable('today 12:00:00', new \DateTimeZone('America/New_York')))
|
|
->setTimezone(new \DateTimeZone('UTC'));
|
|
$this->recordHistoryAt($device, $images[0], $historyUtc->format('Y-m-d H:i:s'));
|
|
|
|
$this->assertTrue(
|
|
$this->rotation->isDue($device),
|
|
'isDue must use a tz-consistent comparison; the regression is invisible on UTC-tz devices but breaks every other tz',
|
|
);
|
|
}
|
|
|
|
// RM-01: NewestUpload picks the most recent upload.
|
|
public function test_newest_upload_mode_picks_newest(): void
|
|
{
|
|
[$device, $images] = $this->setupDeviceAndImages('newest@example.com', [
|
|
'2024-01-01', '2024-06-01', '2024-12-01',
|
|
]);
|
|
$device->setRotationMode(RotationMode::NewestUpload);
|
|
self::em()->flush();
|
|
|
|
$result = $this->rotation->advance($device);
|
|
|
|
$this->assertNotNull($result);
|
|
$this->assertSame($images[2]->getId(), $result->getId(), 'newest upload should win');
|
|
}
|
|
|
|
// RM-02: Random returns *some* eligible candidate. Run a few times so a
|
|
// freak coincidence with a deterministic mode would still be unlikely to
|
|
// pass. We can't assert exact distribution without a seedable RNG, but we
|
|
// can assert randomness produces more than one distinct outcome over a
|
|
// handful of calls (probabilistic; failure is ~1/3^7 = 0.05%).
|
|
public function test_random_mode_yields_variety(): void
|
|
{
|
|
[$device, $images] = $this->setupDeviceAndImages('random@example.com', [
|
|
'2024-01-01', '2024-06-01', '2024-12-01',
|
|
]);
|
|
$device->setRotationMode(RotationMode::Random);
|
|
$device->setUniquenessWindow(1); // only the very last is forbidden
|
|
self::em()->flush();
|
|
|
|
$imageIds = array_map(static fn(Image $i) => $i->getId(), $images);
|
|
$seen = [];
|
|
for ($i = 0; $i < 7; $i++) {
|
|
$result = $this->rotation->advance($device);
|
|
$this->assertNotNull($result);
|
|
$this->assertContains($result->getId(), $imageIds, 'random pick must come from the candidate pool');
|
|
$seen[$result->getId()] = true;
|
|
}
|
|
$this->assertGreaterThan(1, count($seen), 'random over 7 picks should yield more than 1 distinct image');
|
|
}
|
|
|
|
// RM-03: LeastRecentlyShown sorts by oldest most-recent serve.
|
|
public function test_least_recently_shown_picks_oldest_history(): void
|
|
{
|
|
[$device, $images] = $this->setupDeviceAndImages('lrs@example.com', [
|
|
'2024-01-01', '2024-06-01', '2024-12-01',
|
|
]);
|
|
$device->setRotationMode(RotationMode::LeastRecentlyShown);
|
|
$device->setUniquenessWindow(1); // allow image[0] back in the candidate pool
|
|
self::em()->flush();
|
|
|
|
// Serve history: image[0] longest ago, image[1] middle, image[2] most recent.
|
|
// We expect image[0] to be picked.
|
|
$this->recordHistoryAt($device, $images[0], '2025-01-01');
|
|
$this->recordHistoryAt($device, $images[1], '2025-06-01');
|
|
$this->recordHistoryAt($device, $images[2], '2025-12-01');
|
|
|
|
$result = $this->rotation->advance($device);
|
|
|
|
$this->assertNotNull($result);
|
|
$this->assertSame($images[0]->getId(), $result->getId(), 'oldest last-served should win');
|
|
}
|
|
|
|
// RM-04: LeastRecentlyShown — never-shown sorts before any shown image.
|
|
public function test_least_recently_shown_prefers_never_shown(): void
|
|
{
|
|
[$device, $images] = $this->setupDeviceAndImages('lrs-never@example.com', [
|
|
'2024-01-01', '2024-06-01',
|
|
]);
|
|
$device->setRotationMode(RotationMode::LeastRecentlyShown);
|
|
$device->setUniquenessWindow(1);
|
|
self::em()->flush();
|
|
|
|
// image[0] has been shown; image[1] never has.
|
|
$this->recordHistoryAt($device, $images[0], '2024-12-01');
|
|
|
|
$result = $this->rotation->advance($device);
|
|
|
|
$this->assertNotNull($result);
|
|
$this->assertSame($images[1]->getId(), $result->getId(), 'never-shown image beats any history');
|
|
}
|
|
|
|
// RM-05: prioritizeNeverShown narrows the candidate set to never-shown
|
|
// images before the mode runs, even when the mode would normally pick
|
|
// a shown image.
|
|
public function test_prioritize_never_shown_narrows_candidate_set(): void
|
|
{
|
|
[$device, $images] = $this->setupDeviceAndImages('prio@example.com', [
|
|
'2024-01-01', // oldest — Oldest mode would normally pick this
|
|
'2024-06-01',
|
|
'2024-12-01', // newest, but never-shown
|
|
]);
|
|
$device->setRotationMode(RotationMode::OldestUpload);
|
|
$device->setPrioritizeNeverShown(true);
|
|
$device->setUniquenessWindow(1);
|
|
self::em()->flush();
|
|
|
|
// Mark the older two as already shown. Only image[2] is never-shown.
|
|
$this->recordHistoryAt($device, $images[0], '2025-06-01');
|
|
$this->recordHistoryAt($device, $images[1], '2025-06-02');
|
|
|
|
$result = $this->rotation->advance($device);
|
|
|
|
$this->assertNotNull($result);
|
|
$this->assertSame(
|
|
$images[2]->getId(),
|
|
$result->getId(),
|
|
'never-shown narrowing must override the OldestUpload mode',
|
|
);
|
|
}
|
|
|
|
// RM-06: prioritizeNeverShown is a no-op when no never-shown images
|
|
// remain — falls through to the mode.
|
|
public function test_prioritize_never_shown_falls_through_when_all_shown(): void
|
|
{
|
|
[$device, $images] = $this->setupDeviceAndImages('prio-fall@example.com', [
|
|
'2024-01-01', '2024-12-01',
|
|
]);
|
|
$device->setRotationMode(RotationMode::NewestUpload);
|
|
$device->setPrioritizeNeverShown(true);
|
|
$device->setUniquenessWindow(1);
|
|
self::em()->flush();
|
|
|
|
// Both shown — never-shown set is empty, so mode (NewestUpload) takes over.
|
|
$this->recordHistoryAt($device, $images[0], '2025-06-01');
|
|
$this->recordHistoryAt($device, $images[1], '2025-06-02');
|
|
|
|
$result = $this->rotation->advance($device);
|
|
|
|
$this->assertNotNull($result);
|
|
$this->assertSame($images[1]->getId(), $result->getId(), 'falls through to NewestUpload mode');
|
|
}
|
|
}
|