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); } }