Files
pictureFrame-webApp/tests/Functional/Controller/DeviceApiControllerTest.php
T
football2801 081ca83613
CI / test (push) Has been cancelled
fix(v2): preview rotation + crop aspect for 13.3" hardware
Two related bugs that surfaced on the first 13.3" device's first photo:

1) Web-UI portrait preview was 90° sideways. DeviceApiController::
   renderBinToPng rotated whenever the device was Portrait — correct
   for V1 (landscape-native, Portrait => renderer rotated, so preview
   un-rotates) but wrong for V2 (portrait-native — the renderer
   doesn't rotate, so the preview shouldn't either). Now mirrors the
   render-pipeline check: rotate only when `orientation !==
   model->nativeOrientation()`. Two new functional tests pin the V2
   portrait and V2 landscape PNG dimensions to guard against
   regressions.

2) Cropped photo letterboxed on the 13.3" panel. CropEditor /
   StickerCanvas / FrameCard had V1 dimensions hardcoded (1600×960
   = 5:3 aspect). V2 is 4:3 (1200×1600 portrait / 1600×1200
   landscape), so a "full crop" came out the wrong shape and the
   server's white-canvas composite added bars. New `panelDims(model,
   orientation)` helper in @/types is the single source of truth on
   the frontend; matches DeviceModel::width/height on the server.
   Threaded `model` through Device serializer → Device type →
   UploadView → CropEditor / StickerCanvas, and HomeView → FrameCard.
   FrameCard tests updated to cover all four model × orientation
   placeholders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:02:39 -04:00

778 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Entity\Device;
use App\Entity\Image;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Tests\Functional\AppWebTestCase;
class DeviceApiControllerTest extends AppWebTestCase
{
private function makeDevice(string $mac, $user): Device
{
$device = new Device();
$device->setMac($mac);
$device->setName('Frame ' . $mac);
$device->setUser($user);
$device->setModel(DeviceModel::V1);
$device->setOrientation(Orientation::Landscape);
$this->em()->persist($device);
$this->em()->flush();
return $device;
}
private function makeImage($user): Image
{
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
return $image;
}
public function test_list_returns_own_devices(): void
{
$user = $this->createUser('list@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A1', $user);
$client = $this->loginAs($user);
$client->request('GET', '/api/devices');
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertCount(1, $data);
$this->assertSame($device->getMac(), $data[0]['mac']);
}
public function test_list_unauthenticated_returns_redirect(): void
{
$this->client->request('GET', '/api/devices');
// form_login firewall redirects unauthenticated requests to /login
$this->assertResponseRedirects('/login');
}
public function test_patch_updates_name_orientation_and_interval(): void
{
$user = $this->createUser('patch@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A2', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'name' => 'New Name',
'orientation' => 'portrait',
'rotationIntervalMinutes' => 30,
]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame('New Name', $data['name']);
$this->assertSame('portrait', $data['orientation']);
$this->assertSame(30, $data['rotationIntervalMinutes']);
}
public function test_patch_wrong_users_device_returns_404(): void
{
$owner = $this->createUser('owner@example.com');
$other = $this->createUser('other@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A3', $owner);
$client = $this->loginAs($other);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['name' => 'Hack']));
$this->assertResponseStatusCodeSame(404);
}
public function test_put_lock_sets_locked_image_id(): void
{
$user = $this->createUser('lock@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A4', $user);
$image = $this->makeImage($user);
$image->approveForDevice($device);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['imageId' => $image->getId()]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame($image->getId(), $data['lockedImageId']);
}
// A-06: PUT /lock with image not approved for the device → 422
public function test_put_lock_unapproved_image_returns_422(): void
{
$user = $this->createUser('lock422@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A8', $user);
$image = $this->makeImage($user);
// Image is owned by user but NOT approved for this device
$client = $this->loginAs($user);
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['imageId' => $image->getId()]));
$this->assertResponseStatusCodeSame(422);
}
public function test_put_lock_image_not_owned_by_user_returns_404(): void
{
$user1 = $this->createUser('lockown1@example.com');
$user2 = $this->createUser('lockown2@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A5', $user1);
$image = $this->makeImage($user2);
$client = $this->loginAs($user1);
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['imageId' => $image->getId()]));
$this->assertResponseStatusCodeSame(404);
}
public function test_delete_lock_clears_locked_image_id(): void
{
$user = $this->createUser('unlock@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A6', $user);
$image = $this->makeImage($user);
$device->setLockedImage($image);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('DELETE', '/api/devices/' . $device->getId() . '/lock');
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertNull($data['lockedImageId']);
}
public function test_put_lock_wrong_users_device_returns_404(): void
{
$owner = $this->createUser('lockwrong1@example.com');
$other = $this->createUser('lockwrong2@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A7', $owner);
$image = $this->makeImage($other);
$client = $this->loginAs($other);
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['imageId' => $image->getId()]));
$this->assertResponseStatusCodeSame(404);
}
public function test_patch_empty_name_returns_422(): void
{
$user = $this->createUser('patchname@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B1', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['name' => '']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_invalid_orientation_returns_422(): void
{
$user = $this->createUser('patchorient@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B2', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['orientation' => 'diagonal']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_invalid_timezone_returns_422(): void
{
$user = $this->createUser('patchtzone@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B3', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['timezone' => 'Not/Real']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_sets_wake_times(): void
{
$user = $this->createUser('patchwake@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B4', $user);
$client = $this->loginAs($user);
// 6:00 AM, 3:00 PM, 7:30 PM expressed as minutes since midnight
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => [6 * 60, 15 * 60, 19 * 60 + 30]]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame([360, 900, 1170], $data['wakeTimes']);
}
public function test_patch_rejects_out_of_range_wake_times(): void
{
$user = $this->createUser('patchwakebad@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B7', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => [1500]]));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_clears_wake_times_with_empty_array(): void
{
$user = $this->createUser('patchwakeclear@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B8', $user);
$device->setWakeTimes([6 * 60, 18 * 60]);
$em = static::getContainer()->get('doctrine')->getManager();
$em->flush();
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => []]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame([], $data['wakeTimes']);
}
public function test_patch_sets_uniqueness_window(): void
{
$user = $this->createUser('patchuniq@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B5', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['uniquenessWindow' => 14]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame(14, $data['uniquenessWindow']);
}
public function test_patch_sets_valid_timezone(): void
{
$user = $this->createUser('patchtzvld@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:C1', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['timezone' => 'America/New_York']));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame('America/New_York', $data['timezone']);
}
public function test_lock_with_no_image_id_returns_422(): void
{
$user = $this->createUser('locknoimgid@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:C2', $user);
$client = $this->loginAs($user);
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([]));
$this->assertResponseStatusCodeSame(422);
}
public function test_unlock_wrong_users_device_returns_404(): void
{
$owner = $this->createUser('unlockown@example.com');
$other = $this->createUser('unlockoth@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:C3', $owner);
$client = $this->loginAs($other);
$client->request('DELETE', '/api/devices/' . $device->getId() . '/lock');
$this->assertResponseStatusCodeSame(404);
}
// ── Validation branch coverage for PATCH /api/devices/{id} ───────────
public function test_patch_rejects_non_array_wakeTimes(): void
{
$user = $this->createUser('vbad-array@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D1', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => 'not-an-array']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_rejects_non_integer_wakeTimes_entries(): void
{
$user = $this->createUser('vbad-non-int@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D2', $user);
$client = $this->loginAs($user);
// Float-as-string is neither int nor digit-only-string → rejected.
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => ['1.5']]));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_accepts_digit_string_wakeTimes_for_pwa_form_submits(): void
{
// The PWA's <select> value comes through as a string when the body is
// form-encoded; the controller's ctype_digit branch must accept that.
$user = $this->createUser('vstr@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D3', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => ['360']]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame([360], $data['wakeTimes']);
}
public function test_patch_rejects_invalid_rotation_mode(): void
{
$user = $this->createUser('vmode@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D4', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['rotationMode' => 'shuffle-please']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_accepts_valid_rotation_modes(): void
{
$user = $this->createUser('vmode-ok@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D5', $user);
$client = $this->loginAs($user);
foreach (['random', 'least_recently_shown', 'newest_upload', 'oldest_upload'] as $mode) {
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['rotationMode' => $mode]));
$this->assertResponseIsSuccessful('mode=' . $mode);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame($mode, $data['rotationMode']);
}
}
public function test_patch_sets_prioritize_never_shown(): void
{
$user = $this->createUser('vprio@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D6', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['prioritizeNeverShown' => true]));
$this->assertResponseIsSuccessful();
$this->assertTrue(json_decode($client->getResponse()->getContent(), true)['prioritizeNeverShown']);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['prioritizeNeverShown' => false]));
$this->assertFalse(json_decode($client->getResponse()->getContent(), true)['prioritizeNeverShown']);
}
public function test_patch_rejects_empty_name(): void
{
$user = $this->createUser('vname@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D7', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['name' => ' ']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_rejects_invalid_orientation(): void
{
$user = $this->createUser('vori@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D8', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['orientation' => 'sideways']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_rejects_invalid_timezone(): void
{
$user = $this->createUser('vtz@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D9', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['timezone' => 'Mars/Olympus_Mons']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_404_for_other_users_device(): void
{
$owner = $this->createUser('vown@example.com');
$other = $this->createUser('vother@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:DA', $owner);
$client = $this->loginAs($other);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['name' => 'pwn']));
$this->assertResponseStatusCodeSame(404);
}
// ── /preview endpoint coverage ───────────────────────────────────────
public function test_preview_404_for_other_users_device(): void
{
$owner = $this->createUser('pown@example.com');
$other = $this->createUser('pother@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E0', $owner);
$client = $this->loginAs($other);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseStatusCodeSame(404);
}
public function test_preview_404_when_device_has_no_current_image(): void
{
$user = $this->createUser('pnocur@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E1', $user);
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseStatusCodeSame(404);
$this->assertStringContainsString('No current image', $client->getResponse()->getContent());
}
public function test_preview_404_when_no_ready_render_for_devices_orientation(): void
{
$user = $this->createUser('pnoasset@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E2', $user);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseStatusCodeSame(404);
$this->assertStringContainsString('Render not ready', $client->getResponse()->getContent());
}
public function test_preview_404_when_render_file_is_missing_from_disk(): void
{
$user = $this->createUser('pmissing@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E3', $user);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
$asset = (new \App\Entity\RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)
->setStatus(\App\Enum\RenderStatus::Ready)
->setFilePath('var/storage/images/nope/v1_landscape.bin');
$this->em()->persist($asset);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseStatusCodeSame(404);
$this->assertStringContainsString('Render file missing', $client->getResponse()->getContent());
}
public function test_preview_returns_png_for_landscape_device(): void
{
$user = $this->createUser('ppng-l@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E4', $user);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
// 800×480 EPD = 384,000 pixels. 4bpp packed = 192,000 bytes. Fill
// with palette index 0x1 (white) so the renderer produces a valid PNG.
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
$relPath = 'var/storage/images/test-preview/v1_landscape.bin';
$absPath = $projectDir . '/' . $relPath;
if (!is_dir(dirname($absPath))) {
mkdir(dirname($absPath), 0755, true);
}
file_put_contents($absPath, str_repeat(chr(0x11), 192000));
$asset = (new \App\Entity\RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)
->setStatus(\App\Enum\RenderStatus::Ready)
->setFilePath($relPath);
$this->em()->persist($asset);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseIsSuccessful();
$this->assertSame('image/png', $client->getResponse()->headers->get('Content-Type'));
// Cleanup the bin + the cached png.
@unlink($absPath);
@unlink(preg_replace('/\.bin$/', '.png', $absPath));
}
public function test_preview_returns_png_for_portrait_device(): void
{
// Portrait covers the rotateImage(90) branch in renderBinToPng. Same
// 192,000-byte buffer (the .bin is always EPD-native 800×480; orientation
// only affects post-decode rotation).
$user = $this->createUser('ppng-p@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E6', $user);
$device->setOrientation(Orientation::Portrait);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
$relPath = 'var/storage/images/test-preview-portrait/v1_portrait.bin';
$absPath = $projectDir . '/' . $relPath;
if (!is_dir(dirname($absPath))) {
mkdir(dirname($absPath), 0755, true);
}
// Mix in some 0x4 nibbles — 0x4 is unused in the Spectra-6 palette;
// the renderer must fall back to the white default rather than
// throwing on the unknown index.
file_put_contents($absPath, str_repeat(chr(0x14), 192000));
$asset = (new \App\Entity\RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Portrait)
->setStatus(\App\Enum\RenderStatus::Ready)
->setFilePath($relPath);
$this->em()->persist($asset);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseIsSuccessful();
$this->assertSame('image/png', $client->getResponse()->headers->get('Content-Type'));
@unlink($absPath);
@unlink(preg_replace('/\.bin$/', '.png', $absPath));
}
// V2 panel (13.3", 1200×1600 portrait-native) + Portrait orientation MUST
// NOT rotate the preview — Portrait is the natural scan orientation, the
// render pipeline didn't rotate the source, so the preview must mirror
// that. The bug before this guard: every V2 portrait preview came out
// 90° sideways in the web UI.
public function test_preview_v2_portrait_not_rotated(): void
{
$user = $this->createUser('ppng-v2p@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E7', $user);
$device->setModel(DeviceModel::V2);
$device->setOrientation(Orientation::Portrait);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
// 1200×1600 = 1,920,000 nibbles = 960,000 bytes.
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
$relPath = 'var/storage/images/test-preview-v2-portrait/v2_portrait.bin';
$absPath = $projectDir . '/' . $relPath;
if (!is_dir(dirname($absPath))) {
mkdir(dirname($absPath), 0755, true);
}
file_put_contents($absPath, str_repeat(chr(0x11), 960000));
$asset = (new \App\Entity\RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V2)
->setOrientation(Orientation::Portrait)
->setStatus(\App\Enum\RenderStatus::Ready)
->setFilePath($relPath);
$this->em()->persist($asset);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseIsSuccessful();
$this->assertSame('image/png', $client->getResponse()->headers->get('Content-Type'));
// Decode the PNG and confirm orientation — taller than wide means
// no spurious 90° rotation happened (would be 1600×1200 if it had).
$pngPath = preg_replace('/\.bin$/', '.png', $absPath);
$this->assertFileExists($pngPath);
$im = new \Imagick($pngPath);
$this->assertSame(1200, $im->getImageWidth(), 'V2 portrait PNG must be 1200 wide (not rotated)');
$this->assertSame(1600, $im->getImageHeight(), 'V2 portrait PNG must be 1600 tall (not rotated)');
$im->destroy();
@unlink($absPath);
@unlink($pngPath);
}
// V2 panel + Landscape orientation MUST rotate — Landscape is non-native
// for V2 (portrait-native), so the renderer pre-rotated and the preview
// needs to rotate back to upright landscape.
public function test_preview_v2_landscape_rotated(): void
{
$user = $this->createUser('ppng-v2l@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E8', $user);
$device->setModel(DeviceModel::V2);
$device->setOrientation(Orientation::Landscape);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
$relPath = 'var/storage/images/test-preview-v2-landscape/v2_landscape.bin';
$absPath = $projectDir . '/' . $relPath;
if (!is_dir(dirname($absPath))) {
mkdir(dirname($absPath), 0755, true);
}
file_put_contents($absPath, str_repeat(chr(0x11), 960000));
$asset = (new \App\Entity\RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V2)
->setOrientation(Orientation::Landscape)
->setStatus(\App\Enum\RenderStatus::Ready)
->setFilePath($relPath);
$this->em()->persist($asset);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseIsSuccessful();
// The .bin is panel-native 1200×1600 (tall); after the 90° rotation
// for non-native orientation the PNG must be 1600×1200 (wide).
$pngPath = preg_replace('/\.bin$/', '.png', $absPath);
$im = new \Imagick($pngPath);
$this->assertSame(1600, $im->getImageWidth(), 'V2 landscape PNG must be 1600 wide (rotated)');
$this->assertSame(1200, $im->getImageHeight(), 'V2 landscape PNG must be 1200 tall (rotated)');
$im->destroy();
@unlink($absPath);
@unlink($pngPath);
}
// ── DELETE /api/devices/{id} (sell/give-away) ────────────────────────
public function test_delete_removes_device_and_purges_history_and_approvals(): void
{
$owner = $this->createUser('del@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:F0', $owner);
$deviceId = $device->getId();
// Pre-existing approvals + history we expect the delete to wipe.
$image = $this->makeImage($owner);
$image->approveForDevice($device);
$history = new \App\Entity\DeviceImageHistory($device, $image);
$this->em()->persist($history);
$this->em()->flush();
$client = $this->loginAs($owner);
$client->request('DELETE', '/api/devices/' . $deviceId);
$this->assertResponseStatusCodeSame(204);
$this->em()->clear();
$this->assertNull($this->em()->find(\App\Entity\Device::class, $deviceId), 'device row removed');
// Approval revoked: image still exists, but no longer approved for the deleted device id.
$reloadedImage = $this->em()->find(\App\Entity\Image::class, $image->getId());
$approvedIds = array_map(
fn(\App\Entity\Device $d) => $d->getId(),
$reloadedImage->getApprovedDevices()->toArray(),
);
$this->assertNotContains($deviceId, $approvedIds);
// History rows for the deleted device cascaded out.
$count = (int) $this->em()->createQueryBuilder()
->select('COUNT(h.id)')
->from(\App\Entity\DeviceImageHistory::class, 'h')
->where('h.device = :id')
->setParameter('id', $deviceId)
->getQuery()
->getSingleScalarResult();
$this->assertSame(0, $count);
}
public function test_delete_404_for_other_users_device(): void
{
$owner = $this->createUser('del-own@example.com');
$other = $this->createUser('del-other@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:F1', $owner);
$deviceId = $device->getId();
$client = $this->loginAs($other);
$client->request('DELETE', '/api/devices/' . $deviceId);
$this->assertResponseStatusCodeSame(404);
// Owner's device must still exist — no cross-tenant nuking.
$this->em()->clear();
$this->assertNotNull($this->em()->find(\App\Entity\Device::class, $deviceId));
}
public function test_delete_unauthenticated_returns_unauthorized(): void
{
$owner = $this->createUser('del-anon@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:F2', $owner);
$this->client->request('DELETE', '/api/devices/' . $device->getId());
// Anon hits ROLE_USER firewall first — login redirect.
$this->assertResponseRedirects();
}
// After the seller deletes the device, the next poll from the physical
// hardware (with that MAC) sees an unknown MAC and the firmware shows the
// setup QR. Verifies the "delete leaves no server-side claim" guarantee.
public function test_after_delete_device_poll_returns_404(): void
{
$owner = $this->createUser('del-poll@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:F3', $owner);
$mac = $device->getMac();
$client = $this->loginAs($owner);
$client->request('DELETE', '/api/devices/' . $device->getId());
$this->assertResponseStatusCodeSame(204);
// Anonymous device-poll endpoint — same MAC, must now 404.
$client->request('GET', '/api/device/' . $mac . '/image');
$this->assertResponseStatusCodeSame(404);
}
public function test_preview_404_when_image_is_soft_deleted(): void
{
$user = $this->createUser('pdeleted@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E5', $user);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
// Soft-delete the image — preview must refuse rather than serve the
// last cached render.
$image->setDeletedAt(new \DateTimeImmutable());
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseStatusCodeSame(404);
}
}