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); } }