Files
pictureFrame-webApp/tests/Functional/Controller/ImageApiControllerTest.php
T
football2801 a9ad014bd1
CI / test (push) Has been cancelled
test: tighten coverage to 99.69% backend / 98.62% frontend
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>
2026-05-08 14:22:46 -04:00

541 lines
20 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;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
class ImageApiControllerTest extends AppWebTestCase
{
private string $fixturePath;
protected function setUp(): void
{
parent::setUp();
$this->fixturePath = dirname(__DIR__, 2) . '/fixtures/test.jpg';
}
private function makeUploadedFile(): UploadedFile
{
// Copy to a temp file so the upload doesn't consume the original
$tmp = tempnam(sys_get_temp_dir(), 'img') . '.jpg';
copy($this->fixturePath, $tmp);
return new UploadedFile($tmp, 'test.jpg', 'image/jpeg', null, true);
}
public function test_upload_creates_image_and_returns_201(): void
{
$user = $this->createUser('upload@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/api/images', [], [
'file' => $this->makeUploadedFile(),
]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertArrayHasKey('id', $data);
$image = $this->em()->find(Image::class, $data['id']);
$this->assertNotNull($image);
$this->assertSame($user->getId(), $image->getUser()->getId());
}
public function test_upload_dispatches_render_message(): void
{
$user = $this->createUser('upload_msg@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/api/images', [], [
'file' => $this->makeUploadedFile(),
]);
$this->assertResponseStatusCodeSame(201);
/** @var InMemoryTransport $transport */
$transport = static::getContainer()->get('messenger.transport.image_processing');
$this->assertGreaterThan(0, count($transport->get()), 'Expected RenderImageMessage(s) to be dispatched');
}
public function test_upload_without_file_returns_422(): void
{
$user = $this->createUser('noupload@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/api/images', [], [], ['CONTENT_TYPE' => 'application/json'], '{}');
$this->assertResponseStatusCodeSame(422);
}
public function test_list_returns_users_images(): void
{
$user = $this->createUser('listimg@example.com');
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/images');
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertCount(1, $data);
$this->assertSame($image->getId(), $data[0]['id']);
}
public function test_delete_soft_deletes_image(): void
{
$user = $this->createUser('delimg@example.com');
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$imageId = $image->getId();
$client = $this->loginAs($user);
$client->request('DELETE', '/api/images/' . $imageId);
$this->assertResponseStatusCodeSame(204);
$this->em()->clear();
$reloaded = $this->em()->find(Image::class, $imageId);
$this->assertNotNull($reloaded);
$this->assertNotNull($reloaded->getDeletedAt());
}
public function test_delete_wrong_users_image_returns_404(): void
{
$owner = $this->createUser('delown@example.com');
$other = $this->createUser('deloth@example.com');
$image = (new Image())->setUser($owner)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$client = $this->loginAs($other);
$client->request('DELETE', '/api/images/' . $image->getId());
$this->assertResponseStatusCodeSame(404);
}
/**
* IMG-06: reprocess endpoint returns 200 and re-queues render messages.
*
* We first upload an image (which creates the RenderedAsset records via the controller),
* then call reprocess on the same image to verify it resets assets and dispatches new renders.
*/
public function test_reprocess_returns_200(): void
{
$user = $this->createUser('reprocess@example.com');
$client = $this->loginAs($user);
// Step 1: Upload so the controller creates RenderedAssets and storage directory
$client->request('POST', '/api/images', [], [
'file' => $this->makeUploadedFile(),
]);
$this->assertResponseStatusCodeSame(201);
$uploadData = json_decode($client->getResponse()->getContent(), true);
$imageId = $uploadData['id'];
// Step 2: Reprocess
$client->request('POST', '/api/images/' . $imageId . '/reprocess', [], [
'file' => $this->makeUploadedFile(),
]);
$this->assertResponseStatusCodeSame(200);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame($imageId, $data['id']);
}
public function test_upload_unsupported_mime_returns_422(): void
{
$user = $this->createUser('badmime@example.com');
$client = $this->loginAs($user);
$tmp = tempnam(sys_get_temp_dir(), 'txt') . '.txt';
file_put_contents($tmp, 'This is plain text content');
$file = new UploadedFile($tmp, 'test.txt', 'text/plain', null, true);
$client->request('POST', '/api/images', [], ['file' => $file]);
$this->assertResponseStatusCodeSame(422);
}
public function test_thumbnail_returns_200_after_upload(): void
{
$user = $this->createUser('thumb200@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/api/images', [], ['file' => $this->makeUploadedFile()]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$client->request('GET', '/api/images/' . $data['id'] . '/thumbnail');
$this->assertResponseIsSuccessful();
}
public function test_thumbnail_returns_404_for_unknown_image(): void
{
$user = $this->createUser('thumb404@example.com');
$client = $this->loginAs($user);
$client->request('GET', '/api/images/999999/thumbnail');
$this->assertResponseStatusCodeSame(404);
}
public function test_thumbnail_returns_404_when_file_not_present(): void
{
$user = $this->createUser('thumbnofile@example.com');
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/images/' . $image->getId() . '/thumbnail');
$this->assertResponseStatusCodeSame(404);
}
public function test_original_returns_200_after_upload(): void
{
$user = $this->createUser('orig200@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/api/images', [], ['file' => $this->makeUploadedFile()]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$client->request('GET', '/api/images/' . $data['id'] . '/original');
$this->assertResponseIsSuccessful();
}
public function test_original_returns_404_for_unknown_image(): void
{
$user = $this->createUser('orig404@example.com');
$client = $this->loginAs($user);
$client->request('GET', '/api/images/999999/original');
$this->assertResponseStatusCodeSame(404);
}
public function test_share_success_returns_204(): void
{
$sender = $this->createUser('sharesend@example.com');
$recipient = $this->createUser('sharerecip@example.com');
$image = (new Image())->setUser($sender)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$client = $this->loginAs($sender);
$client->request('POST', '/api/images/' . $image->getId() . '/share', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['recipientEmail' => 'sharerecip@example.com']));
$this->assertResponseStatusCodeSame(204);
}
public function test_share_invalid_email_returns_422(): void
{
$user = $this->createUser('shareinvalid@example.com');
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('POST', '/api/images/' . $image->getId() . '/share', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['recipientEmail' => 'not-an-email']));
$this->assertResponseStatusCodeSame(422);
}
public function test_share_nonexistent_recipient_returns_422(): void
{
$user = $this->createUser('sharenorecip@example.com');
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('POST', '/api/images/' . $image->getId() . '/share', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['recipientEmail' => 'nobody@example.com']));
$this->assertResponseStatusCodeSame(422);
}
public function test_share_nonexistent_image_returns_404(): void
{
$user = $this->createUser('sharenoimg@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/api/images/999999/share', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['recipientEmail' => 'someone@example.com']));
$this->assertResponseStatusCodeSame(404);
}
public function test_hard_delete_request_returns_204(): void
{
$user = $this->createUser('hdrequser@example.com');
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('POST', '/api/images/' . $image->getId() . '/hard-delete-request');
$this->assertResponseStatusCodeSame(204);
}
public function test_hard_delete_request_unknown_image_returns_404(): void
{
$user = $this->createUser('hdrnotfound@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/api/images/999999/hard-delete-request');
$this->assertResponseStatusCodeSame(404);
}
public function test_approve_and_revoke_device(): void
{
$user = $this->createUser('approvrevoke@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:FF:01:01');
$device->setName('Frame');
$device->setUser($user);
$device->setModel(DeviceModel::V1);
$device->setOrientation(Orientation::Landscape);
$this->em()->persist($device);
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$client = $this->loginAs($user);
// Approve
$client->request('POST', '/api/images/' . $image->getId() . '/approve/' . $device->getId());
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertContains($device->getId(), $data['approvedDeviceIds']);
// Revoke
$client->request('DELETE', '/api/images/' . $image->getId() . '/approve/' . $device->getId());
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertNotContains($device->getId(), $data['approvedDeviceIds']);
}
public function test_approve_unknown_device_returns_404(): void
{
$user = $this->createUser('apprunkndev@example.com');
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('POST', '/api/images/' . $image->getId() . '/approve/999999');
$this->assertResponseStatusCodeSame(404);
}
public function test_approve_unknown_image_returns_404(): void
{
$user = $this->createUser('apprunknimg@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:FF:02:01');
$device->setName('Frame2');
$device->setUser($user);
$device->setModel(DeviceModel::V1);
$device->setOrientation(Orientation::Landscape);
$this->em()->persist($device);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('POST', '/api/images/999999/approve/' . $device->getId());
$this->assertResponseStatusCodeSame(404);
}
public function test_revoke_unknown_image_returns_404(): void
{
$user = $this->createUser('revokeunknimg@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:FF:03:01');
$device->setName('Frame3');
$device->setUser($user);
$device->setModel(DeviceModel::V1);
$device->setOrientation(Orientation::Landscape);
$this->em()->persist($device);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('DELETE', '/api/images/999999/approve/' . $device->getId());
$this->assertResponseStatusCodeSame(404);
}
public function test_upload_too_large_returns_422(): void
{
$user = $this->createUser('toolarge@example.com');
$client = $this->loginAs($user);
// Create a file > MAX_BYTES (30 MB).
// Use UPLOAD_ERR_CANT_WRITE so HttpKernelBrowser::filterFiles() does not replace
// it with an empty-path stub (filterFiles only replaces *valid* oversized files).
// The controller's $file->getSize() check still sees the real file size.
$tmp = tempnam(sys_get_temp_dir(), 'large') . '.jpg';
$fp = fopen($tmp, 'wb');
$chunk = str_repeat("\0", 1024 * 256); // 256 KB chunks
for ($i = 0; $i < 121; $i++) { // 121 × 256 KB ≈ 31 MB > MAX_BYTES
fwrite($fp, $chunk);
}
fclose($fp);
unset($chunk);
$file = new UploadedFile($tmp, 'large.jpg', 'image/jpeg', \UPLOAD_ERR_CANT_WRITE, true);
$client->request('POST', '/api/images', [], ['file' => $file]);
@unlink($tmp);
$this->assertResponseStatusCodeSame(422);
}
public function test_upload_with_composited_and_original(): void
{
$user = $this->createUser('uploadcomp@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/api/images', [], [
'file' => $this->makeUploadedFile(),
'original' => $this->makeUploadedFile(),
]);
$this->assertResponseStatusCodeSame(201);
}
public function test_upload_with_crop_and_sticker_params(): void
{
$user = $this->createUser('uploadmeta@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/api/images', [
'cropParams' => '{"x":0,"y":0,"w":100,"h":100}',
'stickerState' => '{"stickers":[]}',
'cropOrientation' => 'portrait',
], [
'file' => $this->makeUploadedFile(),
]);
$this->assertResponseStatusCodeSame(201);
$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
{
$user = $this->createUser('orignofile@example.com');
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/images/' . $image->getId() . '/original');
$this->assertResponseStatusCodeSame(404);
}
public function test_reprocess_unknown_image_returns_404(): void
{
$user = $this->createUser('repronotfound@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/api/images/999999/reprocess', [], ['file' => $this->makeUploadedFile()]);
$this->assertResponseStatusCodeSame(404);
}
public function test_reprocess_without_file_returns_422(): void
{
$user = $this->createUser('reprocessnofile@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/api/images', [], ['file' => $this->makeUploadedFile()]);
$this->assertResponseStatusCodeSame(201);
$imageId = json_decode($client->getResponse()->getContent(), true)['id'];
$client->request('POST', '/api/images/' . $imageId . '/reprocess');
$this->assertResponseStatusCodeSame(422);
}
public function test_reprocess_with_crop_and_sticker_metadata(): void
{
$user = $this->createUser('reprocessmeta@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/api/images', [], ['file' => $this->makeUploadedFile()]);
$this->assertResponseStatusCodeSame(201);
$imageId = json_decode($client->getResponse()->getContent(), true)['id'];
$client->request('POST', '/api/images/' . $imageId . '/reprocess', [
'cropParams' => '{"x":10,"y":10}',
'stickerState' => '{"stickers":[]}',
'cropOrientation' => 'landscape',
], [
'file' => $this->makeUploadedFile(),
]);
$this->assertResponseStatusCodeSame(200);
$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
{
$user = $this->createUser('shareself@example.com');
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('POST', '/api/images/' . $image->getId() . '/share', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['recipientEmail' => 'shareself@example.com']));
$this->assertResponseStatusCodeSame(422);
}
public function test_share_idempotent_returns_204(): void
{
$sender = $this->createUser('shareidem_send@example.com');
$recipient = $this->createUser('shareidem_recip@example.com');
$image = (new Image())->setUser($sender)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
$client = $this->loginAs($sender);
$client->request('POST', '/api/images/' . $image->getId() . '/share', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['recipientEmail' => 'shareidem_recip@example.com']));
$this->assertResponseStatusCodeSame(204);
// Second call hits the idempotent ($existing) return path
$client->request('POST', '/api/images/' . $image->getId() . '/share', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['recipientEmail' => 'shareidem_recip@example.com']));
$this->assertResponseStatusCodeSame(204);
}
}