4002ff9fbf
CI / test (push) Has been cancelled
Web app: new entities (Image, RenderedAsset, SharedImage, Token, DeviceImageHistory), enums, repositories, controllers, message handlers, migrations, tests, frontend upload/library/sticker UI, Vue components. Firmware: EPD background screen binaries + gen scripts, setup_bg header. Infra: ddev config, test bundle, gitignore coverage dir. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
308 lines
10 KiB
PHP
308 lines
10 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);
|
|
$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_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);
|
|
}
|
|
}
|