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