Files
football2801 38ea9b3d06
CI / test (push) Has been cancelled
fix(device-image): honor X-Draw-Pending to skip rotation during recovery
When the firmware sends X-Draw-Pending: 1, its drawNeeded NVS flag
survived a power-loss-during-draw — it has the bytes for the previous
image in its cached /img.bin and just needs another chance to finish
painting them. Return the device's current image (no rotation advance),
which lands as a 304 since the device claims the same image-id.

Crucially this overrides the X-Boot-Reason: cold force-resync. The
typical mid-draw-interruption cause IS a reset that turns the next
wake into a cold boot, so without this override force-resync chases
a fresh image every interruption and the device cycles through the
rotation leaving torn frames on the 13.3 panel.

Locked image still wins (user intent overrides recovery). Old firmware
that doesn't send the header is unaffected — branch is gated on the
header being present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:43:13 -04:00

748 lines
30 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',
);
}
// RECOVERY HANDSHAKE: X-Draw-Pending: 1 says the device is mid-recovery
// from a power-loss-during-draw. Even on a cold boot, the server MUST
// suppress rotation advancement and return the device's current image,
// so the firmware can repaint from its cached /img.bin instead of
// chasing a fresh image every reset. Without this override, force-
// resync defeats the firmware's drawNeeded recovery branch and the
// device churns through the pool leaving torn frames on the panel.
public function test_draw_pending_overrides_cold_boot_force_resync(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
// Land a first image so the device has a current_image set.
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(200);
$imageId = $this->client->getResponse()->headers->get('X-Image-Id');
// Sanity-check the no-pending path: a cold boot at this point WILL
// force-resync (this is the very behavior the pending header overrides).
$beforeColdNoPending = $this->countHistory($device);
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
'HTTP_X_Boot_Reason' => 'cold',
'HTTP_X_Current_Image_Id' => $imageId,
]);
$afterColdNoPending = $this->countHistory($device);
$this->assertSame(
$beforeColdNoPending + 1,
$afterColdNoPending,
'sanity: bare cold-boot must force-resync (baseline for the override below)',
);
// The actual feature: cold boot + X-Draw-Pending must NOT advance.
// Rotation advancement is what writes device_image_history rows, so
// an unchanged count is the proof that advance() was skipped.
$imageIdAfterFirstCold = $this->client->getResponse()->headers->get('X-Image-Id');
$beforePending = $this->countHistory($device);
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
'HTTP_X_Boot_Reason' => 'cold',
'HTTP_X_Current_Image_Id' => $imageIdAfterFirstCold,
'HTTP_X_Draw_Pending' => '1',
]);
$afterPending = $this->countHistory($device);
$this->assertSame(
$beforePending,
$afterPending,
'X-Draw-Pending must override cold-boot force-resync — no advance, no new history row',
);
$this->assertResponseStatusCodeSame(
304,
'with draw-pending, the device gets its current image back as 304 (it already has the bytes)',
);
}
// RECOVERY HANDSHAKE: X-Draw-Pending must also suppress the normal
// schedule-based rotation. The device is asking to redraw what it has,
// not for a fresh image — regardless of whether the schedule is due.
public function test_draw_pending_overrides_due_schedule(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
// Establish current image, then make the schedule "always due"
// (rotation interval of 0 means every poll is past-due).
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(200);
$imageId = $this->client->getResponse()->headers->get('X-Image-Id');
$device->setRotationIntervalMinutes(1);
$this->em()->flush();
// Backdate the served_at so isDue() reports true on the next poll.
$this->em()->getConnection()->executeStatement(
'UPDATE device_image_history SET served_at = served_at - INTERVAL \'1 hour\' WHERE device_id = :id',
['id' => $device->getId()],
);
$before = $this->countHistory($device);
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
'HTTP_X_Boot_Reason' => 'timer',
'HTTP_X_Current_Image_Id' => $imageId,
'HTTP_X_Draw_Pending' => '1',
]);
$after = $this->countHistory($device);
$this->assertSame(
$before,
$after,
'draw-pending must skip rotation even when the schedule reports due — no advance, no new history row',
);
$this->assertResponseStatusCodeSame(304);
}
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();
}
// BOOTSTRAP BYPASS: a freshly-claimed device has no NVS img_id and
// therefore sends no X-Current-Image-Id header. The schedule
// (defaulting to noon-daily) would otherwise refuse to advance for
// up to 24h, and the buyer would see a dark panel after registering.
// Bypass schedule-gating in that case and serve whatever's available.
public function test_bootstrap_poll_advances_even_when_schedule_says_not_due(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
// First, take the device through a poll cycle so it has currentImage,
// then configure wakeTimes such that the most-recent past slot has
// already been served. From this state, an off-schedule timer-wake
// poll WITH X-Current-Image-Id would 304 (covered elsewhere). But a
// bootstrap poll (no X-Current-Image-Id, simulating fresh NVS) MUST
// still advance — the user just claimed and is waiting for an image.
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(200);
$device->setWakeTimes([0])->setTimezone('UTC');
$this->em()->flush();
$beforeCount = (int) $this->em()->createQueryBuilder()
->select('COUNT(h.id)')
->from(\App\Entity\DeviceImageHistory::class, 'h')
->where('h.device = :d')
->setParameter('d', $device)
->getQuery()
->getSingleScalarResult();
// No X-Current-Image-Id header — bootstrap.
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseIsSuccessful();
$afterCount = (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($beforeCount + 1, $afterCount, 'bootstrap poll wrote a fresh history row → advance() ran');
}
// 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);
}
// ── X-Just-Provisioned gate ──────────────────────────────────────────
//
// The device sets a "just provisioned" flag in NVS at WiFi-setup time
// and sends X-Just-Provisioned: 1 on every poll until it sees X-Claimed
// back. The server's contract:
//
// - Header set + binding is stale → 204 (force the device to its
// setup-QR screen instead of leaking the prior owner's photo).
// - Header set + binding is fresh → normal response + X-Claimed: 1
// so the firmware clears the flag and resumes regular operation.
// - Header absent → no special behavior (legacy + steady-state polls).
public function test_just_provisioned_with_stale_binding_returns_204(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
// Backdate linkedAt to look stale (older than the FRESH_CLAIM window).
$ref = new \ReflectionProperty(\App\Entity\Device::class, 'linkedAt');
$ref->setAccessible(true);
$ref->setValue($device, new \DateTimeImmutable('-1 hour'));
$this->em()->flush();
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
'HTTP_X_Just_Provisioned' => '1',
]);
$this->assertResponseStatusCodeSame(204, 'stale binding + just-provisioned must NOT serve the prior owner\'s image');
$this->assertFalse(
$this->client->getResponse()->headers->has('X-Claimed'),
'X-Claimed must not be sent until the binding is fresh',
);
}
public function test_just_provisioned_with_fresh_binding_serves_with_X_Claimed(): void
{
$setup = $this->createTestSetup();
// createTestSetup() doesn't backdate linkedAt — it's now-ish, well
// within the FRESH_CLAIM window — so this exercises the post-claim
// transition branch.
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
'HTTP_X_Just_Provisioned' => '1',
]);
$this->assertResponseIsSuccessful();
$this->assertSame('1', $this->client->getResponse()->headers->get('X-Claimed'));
}
public function test_no_just_provisioned_header_means_no_X_Claimed_response_header(): void
{
$this->createTestSetup();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseIsSuccessful();
$this->assertFalse($this->client->getResponse()->headers->has('X-Claimed'));
}
// 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);
}
// Poll with X-Panel-Id matching a different DeviceModel must auto-update
// the device's model. New Devices are created with the V1 default, so a
// 13.3" unit ends up wrongly flagged until the controller corrects it.
public function test_x_panel_id_header_updates_device_model(): void
{
$setup = $this->createTestSetup(true, false);
$this->assertSame(DeviceModel::V1, $setup['device']->getModel());
$this->client->request(
'GET',
'/api/device/' . self::MAC . '/image',
[],
[],
['HTTP_X_PANEL_ID' => 'waveshare-13.3-spectra6'],
);
$this->em()->refresh($setup['device']);
$this->assertSame(DeviceModel::V2, $setup['device']->getModel());
}
// Same-model X-Panel-Id is a no-op — no churn on every poll.
public function test_x_panel_id_header_matching_current_model_does_not_change(): void
{
$setup = $this->createTestSetup(true, false);
$this->client->request(
'GET',
'/api/device/' . self::MAC . '/image',
[],
[],
['HTTP_X_PANEL_ID' => 'waveshare-7.3-spectra6'],
);
$this->em()->refresh($setup['device']);
$this->assertSame(DeviceModel::V1, $setup['device']->getModel());
}
// Unknown panel-id strings must be ignored — never silently drop a known
// device into an unknown state because firmware reported an unrecognised
// panel.
public function test_x_panel_id_header_unknown_leaves_model_alone(): void
{
$setup = $this->createTestSetup(true, false);
$this->client->request(
'GET',
'/api/device/' . self::MAC . '/image',
[],
[],
['HTTP_X_PANEL_ID' => 'totally-fake-panel'],
);
$this->em()->refresh($setup['device']);
$this->assertSame(DeviceModel::V1, $setup['device']->getModel());
}
}