Files
pictureFrame/tests/Functional/Controller/ImageApiControllerTest.php
T
football2801 4002ff9fbf
CI / test (push) Has been cancelled
chore: stage all in-progress work before repo split
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>
2026-05-06 12:11:31 -04:00

537 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":[]}',
], [
'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);
}
}