Started: 89.08% backend / 97.01% frontend lines.
Landed: 99.69% backend / 98.62% frontend.
Closed gaps targeted at logic gates, branches, and assumption boundaries
that real users hit. Each test exercises a use case the production code
actually serves; nothing here is line-padding.
Backend additions:
- DeviceModelTest: pin landscape vs portrait dimension swap, plus the
nativeWidth/Height "ignore orientation" contract the firmware relies on.
- DeviceApiControllerTest: validation branches the PWA forms can't
even produce (raw API misuse) — non-array wakeTimes, non-int entries,
invalid rotation mode, invalid timezone, empty name, invalid orientation,
other-user PATCH returns 404. Plus full /preview coverage: 404 for
other-user / no-current / no-asset / missing-file / soft-deleted, and
happy paths for landscape AND portrait (the rotateImage(90) branch).
- ImageApiControllerTest: cropOrientation now exercised on both upload
and reprocess paths.
- TokenActionControllerTest: TK-01c covers the bad-device-id "continue"
branch in submit.
- RenderImageMessageHandlerTest: explicit portrait test pins the
rotateImage(-90) branch and the 192,000-byte EPD-native bin shape.
- SeedFakeDevicesCommandTest: 4 cases covering missing-user, fresh
create, idempotent re-run, and --remove path. The dev seed command
is load-bearing for the multi-frame UI; a silent break would surface
a week later.
- RerenderAssetsCommandTest: reset + dispatch path, no-assets path.
Frontend additions:
- FrameCardTest: lastSync-only and nextSync-only rendering branches.
- HomeView.test:
* + Add time fallback path when all 9 default candidates are taken.
* Multi-day "in Nd" nextSync formatting (offline / huge-interval case).
* Medium-horizon (5h) nextSync formats as clock-time + day label.
* visibilitychange triggers a silent re-fetch.
* add-photo handler creates input + navigates to /upload after pick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -311,4 +311,284 @@ class DeviceApiControllerTest extends AppWebTestCase
|
||||
|
||||
$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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +429,7 @@ class ImageApiControllerTest extends AppWebTestCase
|
||||
$client->request('POST', '/api/images', [
|
||||
'cropParams' => '{"x":0,"y":0,"w":100,"h":100}',
|
||||
'stickerState' => '{"stickers":[]}',
|
||||
'cropOrientation' => 'portrait',
|
||||
], [
|
||||
'file' => $this->makeUploadedFile(),
|
||||
]);
|
||||
@@ -437,6 +438,7 @@ class ImageApiControllerTest extends AppWebTestCase
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertNotNull($data['cropParams']);
|
||||
$this->assertNotNull($data['stickerState']);
|
||||
$this->assertSame('portrait', $data['cropOrientation']);
|
||||
}
|
||||
|
||||
public function test_original_returns_404_when_no_file_on_disk(): void
|
||||
@@ -488,6 +490,7 @@ class ImageApiControllerTest extends AppWebTestCase
|
||||
$client->request('POST', '/api/images/' . $imageId . '/reprocess', [
|
||||
'cropParams' => '{"x":10,"y":10}',
|
||||
'stickerState' => '{"stickers":[]}',
|
||||
'cropOrientation' => 'landscape',
|
||||
], [
|
||||
'file' => $this->makeUploadedFile(),
|
||||
]);
|
||||
@@ -496,6 +499,7 @@ class ImageApiControllerTest extends AppWebTestCase
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertNotNull($data['cropParams']);
|
||||
$this->assertNotNull($data['stickerState']);
|
||||
$this->assertSame('landscape', $data['cropOrientation']);
|
||||
}
|
||||
|
||||
public function test_share_with_self_returns_422(): void
|
||||
|
||||
@@ -91,6 +91,44 @@ class TokenActionControllerTest extends AppWebTestCase
|
||||
$this->assertSame($recipient->getId(), $sharedReloaded->getRecipientUser()->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* TK-01c: A device_ids list containing an unknown / not-recipient-owned id
|
||||
* MUST be silently skipped (not 500). Locks the "continue on bad id"
|
||||
* branch in TokenActionController::submit.
|
||||
*/
|
||||
public function test_approve_submit_skips_unknown_device_ids(): void
|
||||
{
|
||||
$sender = $this->createUser('tk01c_sender@example.com');
|
||||
$recipient = $this->createUser('tk01c_recip@example.com');
|
||||
$image = $this->makeImage($sender);
|
||||
$token = $this->issueToken($image, TokenType::ShareApprove);
|
||||
$shared = new SharedImage($image, $recipient, $sender);
|
||||
$this->em()->persist($shared);
|
||||
|
||||
// Recipient owns one device; the form will submit a real id and a bogus one.
|
||||
$real = new \App\Entity\Device();
|
||||
$real->setMac('AA:BB:CC:DD:EE:F1');
|
||||
$real->setName('Recip Frame');
|
||||
$real->setUser($recipient);
|
||||
$this->em()->persist($real);
|
||||
$this->em()->flush();
|
||||
|
||||
$client = $this->loginAs($recipient);
|
||||
$client->request('POST', '/token/' . $token->getUuid() . '/approve', [
|
||||
'device_ids' => [(string) $real->getId(), '999999'],
|
||||
]);
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
// The real device should now have the image approved; the bogus id is a no-op.
|
||||
$this->em()->clear();
|
||||
$reloadedImage = $this->em()->find(\App\Entity\Image::class, $image->getId());
|
||||
$approvedIds = array_map(
|
||||
fn(\App\Entity\Device $d) => $d->getId(),
|
||||
$reloadedImage->getApprovedDevices()->toArray(),
|
||||
);
|
||||
$this->assertContains($real->getId(), $approvedIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* TK-02: GET /token/{uuid}/approve with a missing UUID renders the invalid page.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user