chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled

Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent dd0970ed7c
commit 4002ff9fbf
156 changed files with 27333 additions and 92 deletions
@@ -0,0 +1,536 @@
<?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":[]}',
], [
'file' => $this->makeUploadedFile(),
]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertNotNull($data['cropParams']);
$this->assertNotNull($data['stickerState']);
}
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":[]}',
], [
'file' => $this->makeUploadedFile(),
]);
$this->assertResponseStatusCodeSame(200);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertNotNull($data['cropParams']);
$this->assertNotNull($data['stickerState']);
}
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);
}
}