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']; $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(); // 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, X-Interval-Ms should be > 0 and <= 24h in ms public function test_wake_times_interval_used_when_set(): void { $setup = $this->createTestSetup(); $device = $setup['device']; $device->setWakeTimes([3 * 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(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. public function test_wake_times_picks_earliest_upcoming(): void { $setup = $this->createTestSetup(); $device = $setup['device']; // Use a fixed UTC tz; with three slots evenly spread, the gap to the // next slot can never exceed 24h / count = 8h. $device->setWakeTimes([6 * 60, 14 * 60, 22 * 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); } // 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); } }