081ca83613
CI / test (push) Has been cancelled
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>
778 lines
32 KiB
PHP
778 lines
32 KiB
PHP
<?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);
|
||
}
|
||
}
|