2a8bf3895f
CI / test (push) Has been cancelled
Mirrors aqua-iq's pattern but adapted for pictureFrame's stack: postgres 16, php 8.4, node 22, imagick + pcov via apt extras, Mercure hub at https://pictureframe.ddev.site/.well-known/mercure, and four custom commands — `ddev tests`, `ddev coverage`, `ddev frontend` (vite HMR), `ddev worker`. Also restores dev deps (DAMA, Doctrine fixtures, symfony/uid) that got dropped during earlier composer reshuffles, and adds a separate `db_test` connection in .env.test so DAMA's transactional isolation doesn't share state with whatever dev is mid-experiment with. Two test fixes the new env exposed: - RotationServiceTest::test_prioritize_never_shown_falls_through_when_all_shown needed uniquenessWindow=2 so the recent-window filter wipes the set and the fallback restores the full pool — otherwise window=1 excluded the most-recently-served image and the assertion drifted. - DeviceImageControllerTest::test_locked_image_served_without_rotation_advance was asserting currentImage stays null on a lock poll, but the controller intentionally sets currentImage on the lock path so Home reflects the live frame state. Now asserts both the currentImage update AND that no DeviceImageHistory row was written (the actual rotation-bypass guarantee). Backend coverage (full suite via `ddev coverage`): 89.08% lines / 92.24% methods / 74.36% classes — the first real number we've had. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
500 lines
19 KiB
PHP
500 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Controller;
|
|
|
|
use App\Entity\Device;
|
|
use App\Entity\Image;
|
|
use App\Entity\RenderedAsset;
|
|
use App\Enum\DeviceModel;
|
|
use App\Enum\Orientation;
|
|
use App\Enum\RenderStatus;
|
|
use App\Tests\Functional\AppWebTestCase;
|
|
|
|
class DeviceImageControllerTest extends AppWebTestCase
|
|
{
|
|
private const MAC = 'AA:BB:CC:11:22:33';
|
|
private const BIN_PATH = 'var/storage/images/test-img/v1_landscape.bin';
|
|
|
|
private string $binAbsPath;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
|
|
$this->binAbsPath = $projectDir . '/' . self::BIN_PATH;
|
|
|
|
$dir = dirname($this->binAbsPath);
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0755, true);
|
|
}
|
|
file_put_contents($this->binAbsPath, 'FAKEBIN');
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
if (file_exists($this->binAbsPath)) {
|
|
unlink($this->binAbsPath);
|
|
}
|
|
parent::tearDown();
|
|
}
|
|
|
|
private function createTestSetup(bool $approveImage = true, bool $withReadyAsset = true): array
|
|
{
|
|
$user = $this->createUser('devimg@example.com');
|
|
|
|
$device = new Device();
|
|
$device->setMac(self::MAC);
|
|
$device->setName('Test Frame');
|
|
$device->setUser($user);
|
|
$device->setModel(DeviceModel::V1);
|
|
$device->setOrientation(Orientation::Landscape);
|
|
$device->setRotationIntervalMinutes(60);
|
|
$this->em()->persist($device);
|
|
|
|
$image = new Image();
|
|
$image->setUser($user)->setOriginalFilename('test.jpg')->setStoragePath('x');
|
|
if ($approveImage) {
|
|
$image->approveForDevice($device);
|
|
}
|
|
$this->em()->persist($image);
|
|
|
|
if ($withReadyAsset) {
|
|
$asset = (new RenderedAsset())
|
|
->setImage($image)
|
|
->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)
|
|
->setStatus(RenderStatus::Ready)
|
|
->setFilePath(self::BIN_PATH)
|
|
->setRenderedAt(new \DateTimeImmutable());
|
|
$this->em()->persist($asset);
|
|
}
|
|
|
|
$this->em()->flush();
|
|
|
|
return ['device' => $device, 'image' => $image, 'user' => $user];
|
|
}
|
|
|
|
public function test_returns_404_for_unknown_mac(): void
|
|
{
|
|
$this->client->request('GET', '/api/device/FF:FF:FF:FF:FF:FF/image');
|
|
|
|
$this->assertResponseStatusCodeSame(404);
|
|
}
|
|
|
|
public function test_returns_204_when_no_images(): void
|
|
{
|
|
$user = $this->createUser('noimg@example.com');
|
|
$device = new Device();
|
|
$device->setMac('AA:BB:CC:44:55:66');
|
|
$device->setName('No Image Device');
|
|
$device->setUser($user);
|
|
$this->em()->persist($device);
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/api/device/AA:BB:CC:44:55:66/image');
|
|
|
|
$this->assertResponseStatusCodeSame(204);
|
|
}
|
|
|
|
public function test_returns_200_with_binary_body_and_headers(): void
|
|
{
|
|
$this->createTestSetup();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
$response = $this->client->getResponse();
|
|
$this->assertNotEmpty($response->headers->get('X-Image-Id'));
|
|
$this->assertNotEmpty($response->headers->get('X-Interval-Ms'));
|
|
$this->assertSame('application/octet-stream', $response->headers->get('Content-Type'));
|
|
}
|
|
|
|
public function test_returns_304_when_current_image_id_matches(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$imageId = $setup['image']->getId();
|
|
|
|
// First call to advance rotation and get the image
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
// Second call with matching X-Current-Image-Id
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X-Current-Image-Id' => (string) $imageId,
|
|
]);
|
|
$this->assertResponseStatusCodeSame(304);
|
|
}
|
|
|
|
public function test_returns_200_when_current_image_id_is_stale(): void
|
|
{
|
|
$this->createTestSetup();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X-Current-Image-Id' => '99999',
|
|
]);
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
}
|
|
|
|
public function test_locked_image_served_without_rotation_advance(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
$deviceId = $device->getId();
|
|
$image = $setup['image'];
|
|
$imageId = $image->getId();
|
|
|
|
$device->setLockedImage($image);
|
|
$this->em()->flush();
|
|
|
|
$this->assertNull($device->getCurrentImage());
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
// After the locked-image poll, currentImage now points at the locked
|
|
// image — the controller sets it directly because RotationService::
|
|
// advance() was bypassed (lock takes precedence). This is what makes
|
|
// Home's preview reflect what the frame is actually showing. The
|
|
// "without rotation advance" guarantee is verified separately by
|
|
// checking that no DeviceImageHistory row was written.
|
|
$this->em()->clear();
|
|
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
|
|
$this->assertNotNull($device->getCurrentImage());
|
|
$this->assertSame($imageId, $device->getCurrentImage()->getId());
|
|
|
|
$historyCount = (int) $this->em()->createQueryBuilder()
|
|
->select('COUNT(h.id)')
|
|
->from(\App\Entity\DeviceImageHistory::class, 'h')
|
|
->where('h.device = :d')
|
|
->setParameter('d', $device)
|
|
->getQuery()
|
|
->getSingleScalarResult();
|
|
$this->assertSame(0, $historyCount, 'lock path must NOT write a history row');
|
|
}
|
|
|
|
public function test_returns_304_when_locked_image_matches_current_image_id(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
$image = $setup['image'];
|
|
$imageId = $image->getId();
|
|
|
|
// Simulate the device having already received this image at the current
|
|
// orientation and rendered_at — the 304 path now requires all three
|
|
// (image id, orientation, rendered_at) to match.
|
|
$asset = $this->em()->getRepository(RenderedAsset::class)->findOneBy(['image' => $image]);
|
|
$device->setLockedImage($image);
|
|
$device->setCurrentImageOrientation(Orientation::Landscape);
|
|
$device->setCurrentRenderedAt($asset->getRenderedAt());
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X-Current-Image-Id' => (string) $imageId,
|
|
]);
|
|
|
|
$this->assertResponseStatusCodeSame(304);
|
|
}
|
|
|
|
public function test_orientation_flip_returns_200_even_when_image_id_matches(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
$image = $setup['image'];
|
|
$imageId = $image->getId();
|
|
|
|
// Seed: device receives the image at landscape orientation.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
// User flips device to portrait and a portrait render is ready.
|
|
$this->em()->clear();
|
|
$device = $this->em()->find(Device::class, $setup['device']->getId());
|
|
$device->setOrientation(Orientation::Portrait);
|
|
$portraitAsset = (new RenderedAsset())
|
|
->setImage($this->em()->find(Image::class, $imageId))
|
|
->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Portrait)
|
|
->setStatus(RenderStatus::Ready)
|
|
->setFilePath(self::BIN_PATH);
|
|
$this->em()->persist($portraitAsset);
|
|
$this->em()->flush();
|
|
|
|
// Same image ID, but device's stored orientation (landscape) no longer
|
|
// matches the device's current orientation (portrait) → must re-send.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X-Current-Image-Id' => (string) $imageId,
|
|
]);
|
|
$this->assertResponseStatusCodeSame(200);
|
|
}
|
|
|
|
public function test_re_render_returns_200_even_when_image_id_and_orientation_match(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$deviceId = $setup['device']->getId();
|
|
$imageId = $setup['image']->getId();
|
|
|
|
// Seed: device receives the image once, server stores currentRenderedAt.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
// Simulate a re-render: the asset's rendered_at advances (e.g. user
|
|
// re-cropped the image, RenderImageMessageHandler ran again).
|
|
$this->em()->clear();
|
|
$asset = $this->em()->getRepository(RenderedAsset::class)->findOneBy([
|
|
'image' => $this->em()->find(Image::class, $imageId),
|
|
'orientation' => Orientation::Landscape,
|
|
]);
|
|
$asset->setRenderedAt(new \DateTimeImmutable('+1 minute'));
|
|
$this->em()->flush();
|
|
|
|
// Same image id, same orientation — but the bytes have changed, so
|
|
// the 304 cache must invalidate and the device must re-fetch.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X-Current-Image-Id' => (string) $imageId,
|
|
]);
|
|
$this->assertResponseStatusCodeSame(200);
|
|
}
|
|
|
|
public function test_poll_advances_current_image(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$deviceId = $setup['device']->getId();
|
|
$imageId = $setup['image']->getId();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
// Re-fetch from DB after request clears EM state
|
|
$this->em()->clear();
|
|
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
|
|
$this->assertNotNull($device->getCurrentImage());
|
|
$this->assertSame($imageId, $device->getCurrentImage()->getId());
|
|
}
|
|
|
|
public function test_x_interval_ms_equals_rotation_interval_minutes_times_60000(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$response = $this->client->getResponse();
|
|
$intervalMs = (int) $response->headers->get('X-Interval-Ms');
|
|
|
|
$this->assertSame($device->getRotationIntervalMinutes() * 60 * 1000, $intervalMs);
|
|
}
|
|
|
|
public function test_last_seen_at_updated_after_200_poll(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$deviceId = $setup['device']->getId();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
$this->em()->clear();
|
|
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
|
|
$this->assertNotNull($device->getLastSeenAt());
|
|
}
|
|
|
|
public function test_last_seen_at_updated_after_304_poll(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$deviceId = $setup['device']->getId();
|
|
$imageId = $setup['image']->getId();
|
|
|
|
// Seed rotation first
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X-Current-Image-Id' => (string) $imageId,
|
|
]);
|
|
$this->assertResponseStatusCodeSame(304);
|
|
|
|
// Re-fetch from DB since the EM may have been cleared by the request
|
|
$this->em()->clear();
|
|
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
|
|
$this->assertNotNull($device->getLastSeenAt());
|
|
}
|
|
|
|
// Returns 204 when image is approved but no Ready RenderedAsset exists
|
|
public function test_returns_204_when_no_ready_asset_for_approved_image(): void
|
|
{
|
|
$this->createTestSetup(true, false);
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(204);
|
|
}
|
|
|
|
// Returns 204 when RenderedAsset has Ready status but the file is missing from disk
|
|
public function test_returns_204_when_bin_file_missing_from_disk(): void
|
|
{
|
|
$setup = $this->createTestSetup(true, false);
|
|
|
|
$asset = (new RenderedAsset())
|
|
->setImage($setup['image'])
|
|
->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)
|
|
->setStatus(RenderStatus::Ready)
|
|
->setFilePath('var/storage/images/nonexistent/missing.bin');
|
|
$this->em()->persist($asset);
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(204);
|
|
}
|
|
|
|
// 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([0])->setTimezone('UTC');
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
$intervalMs = (int) $this->client->getResponse()->headers->get('X-Interval-Ms');
|
|
$this->assertGreaterThan(0, $intervalMs);
|
|
$this->assertLessThanOrEqual(24 * 60 * 60 * 1000, $intervalMs);
|
|
}
|
|
|
|
// With multiple wake times, X-Interval-Ms must point to the *earliest*
|
|
// 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'];
|
|
|
|
// 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');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
$intervalMs = (int) $this->client->getResponse()->headers->get('X-Interval-Ms');
|
|
$this->assertGreaterThan(0, $intervalMs);
|
|
$this->assertLessThanOrEqual(8 * 60 * 60 * 1000, $intervalMs);
|
|
}
|
|
|
|
// FORCE-RESYNC FEATURE: a cold-boot poll (X-Boot-Reason: cold) MUST
|
|
// advance the rotation even when the schedule says we're not due. This
|
|
// is documented behavior — unplug-and-replug = manual refresh — and
|
|
// this test exists specifically to keep the feature from regressing.
|
|
// See feedback_force_resync_via_powercycle.md in agent memory.
|
|
public function test_cold_boot_force_resyncs_off_schedule(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
|
|
// Establish a current image, then configure wakeTimes such that the
|
|
// most-recent-past slot has already been served — schedule says
|
|
// we're NOT due for another rotation right now.
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
$this->assertResponseStatusCodeSame(200);
|
|
$imageId = $this->client->getResponse()->headers->get('X-Image-Id');
|
|
$device->setWakeTimes([0])->setTimezone('UTC');
|
|
$this->em()->flush();
|
|
|
|
// Sanity-check: a *timer* wake at this point would 304 (no rotation).
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X_Boot_Reason' => 'timer',
|
|
'HTTP_X_Current_Image_Id' => $imageId,
|
|
]);
|
|
$this->assertResponseStatusCodeSame(304, 'timer wake must respect the schedule');
|
|
|
|
// The actual feature test: a cold-boot poll force-rotates regardless.
|
|
// It returns 200 (or 304 if the rotation happened to land on the same
|
|
// image again — depends on pool size). The proof is that it ran
|
|
// through the rotation path, which we verify by checking that a new
|
|
// history row was written.
|
|
$beforeCount = $this->countHistory($device);
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
|
'HTTP_X_Boot_Reason' => 'cold',
|
|
'HTTP_X_Current_Image_Id' => $imageId,
|
|
]);
|
|
$afterCount = $this->countHistory($device);
|
|
|
|
$this->assertSame(
|
|
$beforeCount + 1,
|
|
$afterCount,
|
|
'cold-boot poll must advance the rotation (write a fresh history row) even off-schedule',
|
|
);
|
|
}
|
|
|
|
private function countHistory(\App\Entity\Device $device): int
|
|
{
|
|
return (int) $this->em()
|
|
->createQueryBuilder()
|
|
->select('COUNT(h.id)')
|
|
->from(\App\Entity\DeviceImageHistory::class, 'h')
|
|
->where('h.device = :d')
|
|
->setParameter('d', $device)
|
|
->getQuery()
|
|
->getSingleScalarResult();
|
|
}
|
|
|
|
// 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
|
|
{
|
|
$setup = $this->createTestSetup(true, false);
|
|
|
|
$asset = (new RenderedAsset())
|
|
->setImage($setup['image'])
|
|
->setDeviceModel(DeviceModel::V1)
|
|
->setOrientation(Orientation::Landscape)
|
|
->setStatus(RenderStatus::Ready)
|
|
->setFilePath(null);
|
|
$this->em()->persist($asset);
|
|
$this->em()->flush();
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(204);
|
|
}
|
|
}
|