c387260ee7
CI / test (push) Has been cancelled
The 304 short-circuit at DeviceImageController only compared image IDs, so flipping a device between landscape and portrait would not invalidate the cache: the device kept showing the previously-rendered .bin even after the user changed orientation in the webapp. Now the device row tracks currentImageOrientation — set whenever a 200 binary response is sent — and the 304 path requires both image id AND current orientation to match the device's stored orientation. An orientation flip naturally falls through to the 200 path on the next poll, the freshly-rendered portrait .bin is delivered, and the device redraws. No firmware change: the existing X-Current-Image-Id header from the device is sufficient. Existing devices migrate cleanly — null currentImageOrientation just forces one full re-send on first post- migration poll, which is harmless. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
343 lines
12 KiB
PHP
343 lines
12 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);
|
|
$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'];
|
|
|
|
$device->setLockedImage($image);
|
|
$this->em()->flush();
|
|
|
|
$this->assertNull($device->getCurrentImage());
|
|
|
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
|
|
|
$this->assertResponseStatusCodeSame(200);
|
|
|
|
// Re-fetch from DB; currentImage should still be null (advance() was never called)
|
|
$this->em()->clear();
|
|
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
|
|
$this->assertNull($device->getCurrentImage());
|
|
}
|
|
|
|
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();
|
|
|
|
$device->setLockedImage($image);
|
|
// Simulate the device having already received this image at the current
|
|
// orientation — the 304 path now requires this to match too.
|
|
$device->setCurrentImageOrientation(Orientation::Landscape);
|
|
$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_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 wakeHour is set, X-Interval-Ms should be > 0 and <= 24h in ms
|
|
public function test_wake_hour_interval_used_when_set(): void
|
|
{
|
|
$setup = $this->createTestSetup();
|
|
$device = $setup['device'];
|
|
|
|
$device->setWakeHour(3)->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);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|