12245759ac
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>
537 lines
20 KiB
PHP
537 lines
20 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;
|
||
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);
|
||
}
|
||
}
|