setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x'); $this->em()->persist($image); return $image; } private function makeShared(Image $image, $recipient, $sharedBy, SharedImageStatus $status = SharedImageStatus::Pending): SharedImage { $shared = new SharedImage($image, $recipient, $sharedBy); $shared->setStatus($status); $this->em()->persist($shared); return $shared; } public function test_list_returns_paginated_result_for_recipient(): void { $sender = $this->createUser('sender@example.com'); $recipient = $this->createUser('recip@example.com'); $image = $this->makeImage($sender); $this->makeShared($image, $recipient, $sender); $this->em()->flush(); $client = $this->loginAs($recipient); $client->request('GET', '/api/shared-images'); $this->assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); $this->assertArrayHasKey('items', $data); $this->assertArrayHasKey('total', $data); $this->assertCount(1, $data['items']); $this->assertSame($image->getId(), $data['items'][0]['imageId']); } public function test_list_filters_by_status_pending(): void { $sender = $this->createUser('senderf@example.com'); $recipient = $this->createUser('recipf@example.com'); $img1 = $this->makeImage($sender); $img2 = $this->makeImage($sender); $this->makeShared($img1, $recipient, $sender, SharedImageStatus::Pending); $this->makeShared($img2, $recipient, $sender, SharedImageStatus::Approved); $this->em()->flush(); $client = $this->loginAs($recipient); $client->request('GET', '/api/shared-images?status=pending'); $this->assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); $this->assertCount(1, $data['items']); $this->assertSame('pending', $data['items'][0]['status']); } public function test_approve_sets_status_approved(): void { $sender = $this->createUser('sendera@example.com'); $recipient = $this->createUser('recipa@example.com'); $image = $this->makeImage($sender); $shared = $this->makeShared($image, $recipient, $sender); $this->em()->flush(); $client = $this->loginAs($recipient); $client->request('POST', '/api/shared-images/' . $shared->getId() . '/approve', [], [], [ 'CONTENT_TYPE' => 'application/json', ], json_encode(['deviceIds' => []])); $this->assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); $this->assertSame('approved', $data['status']); $sharedId = $shared->getId(); $this->em()->clear(); $shared = $this->em()->find(SharedImage::class, $sharedId); $this->assertSame(SharedImageStatus::Approved, $shared->getStatus()); // SH-03: verify that render messages were dispatched /** @var \Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport $transport */ $transport = static::getContainer()->get('messenger.transport.image_processing'); $this->assertGreaterThan(0, count($transport->get()), 'Expected RenderImageMessage(s) to be dispatched after approve'); } public function test_decline_sets_status_declined(): void { $sender = $this->createUser('senderd@example.com'); $recipient = $this->createUser('recipd@example.com'); $image = $this->makeImage($sender); $shared = $this->makeShared($image, $recipient, $sender); $this->em()->flush(); $client = $this->loginAs($recipient); $client->request('POST', '/api/shared-images/' . $shared->getId() . '/decline'); $this->assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); $this->assertSame('declined', $data['status']); $sharedId = $shared->getId(); $this->em()->clear(); $shared = $this->em()->find(SharedImage::class, $sharedId); $this->assertSame(SharedImageStatus::Declined, $shared->getStatus()); } public function test_approve_on_another_users_shared_returns_404(): void { $sender = $this->createUser('senderx@example.com'); $recipient = $this->createUser('recipx@example.com'); $attacker = $this->createUser('attack@example.com'); $image = $this->makeImage($sender); $shared = $this->makeShared($image, $recipient, $sender); $this->em()->flush(); $client = $this->loginAs($attacker); $client->request('POST', '/api/shared-images/' . $shared->getId() . '/approve', [], [], [ 'CONTENT_TYPE' => 'application/json', ], json_encode(['deviceIds' => []])); $this->assertResponseStatusCodeSame(404); } public function test_pending_count_returns_count(): void { $sender = $this->createUser('pcountsend@example.com'); $recipient = $this->createUser('pcountrecip@example.com'); $img1 = $this->makeImage($sender); $img2 = $this->makeImage($sender); $this->makeShared($img1, $recipient, $sender, SharedImageStatus::Pending); $this->makeShared($img2, $recipient, $sender, SharedImageStatus::Approved); $this->em()->flush(); $client = $this->loginAs($recipient); $client->request('GET', '/api/shared-images/pending-count'); $this->assertResponseIsSuccessful(); $data = json_decode($client->getResponse()->getContent(), true); $this->assertSame(1, $data['count']); } public function test_decline_previously_approved_revokes_device_approvals(): void { $sender = $this->createUser('declrevoke_send@example.com'); $recipient = $this->createUser('declrevoke_recip@example.com'); $device = new Device(); $device->setMac('AA:BB:CC:DD:99:01'); $device->setName('Revoke Frame'); $device->setUser($recipient); $this->em()->persist($device); $image = $this->makeImage($sender); $image->approveForDevice($device); $shared = $this->makeShared($image, $recipient, $sender, SharedImageStatus::Approved); $this->em()->flush(); $imageId = $image->getId(); $deviceId = $device->getId(); $sharedId = $shared->getId(); $client = $this->loginAs($recipient); $client->request('POST', '/api/shared-images/' . $sharedId . '/decline'); $this->assertResponseIsSuccessful(); $this->em()->clear(); $imageReloaded = $this->em()->find(\App\Entity\Image::class, $imageId); $deviceReloaded = $this->em()->find(Device::class, $deviceId); $this->assertFalse($imageReloaded->isApprovedForDevice($deviceReloaded)); } public function test_approve_with_unowned_device_id_skips_it(): void { $sender = $this->createUser('senderunown@example.com'); $recipient = $this->createUser('recipunown@example.com'); $other = $this->createUser('otherunown@example.com'); $image = $this->makeImage($sender); $shared = $this->makeShared($image, $recipient, $sender); $otherDevice = new Device(); $otherDevice->setMac('AA:BB:CC:DD:99:88'); $otherDevice->setName('Other Frame'); $otherDevice->setUser($other); $this->em()->persist($otherDevice); $this->em()->flush(); $client = $this->loginAs($recipient); $client->request('POST', '/api/shared-images/' . $shared->getId() . '/approve', [], [], [ 'CONTENT_TYPE' => 'application/json', ], json_encode(['deviceIds' => [$otherDevice->getId()]])); $this->assertResponseIsSuccessful(); $this->em()->clear(); $reloaded = $this->em()->find(SharedImage::class, $shared->getId()); $this->assertSame(SharedImageStatus::Approved, $reloaded->getStatus()); } public function test_decline_not_found_returns_404(): void { $user = $this->createUser('declnotfound@example.com'); $client = $this->loginAs($user); $client->request('POST', '/api/shared-images/99999/decline'); $this->assertResponseStatusCodeSame(404); } /** * SH-06: After approve + pre-existing ready RenderedAsset, image appears in rotation pool. * * The approve endpoint dispatches RenderImageMessage but does NOT create RenderedAssets itself. * We pre-create a Ready RenderedAsset before the approve call, then verify that after approval * the image is approved for the device and RotationService::advance() returns it. */ public function test_approved_image_with_ready_asset_appears_in_rotation(): void { $sender = $this->createUser('sendersh6@example.com'); $recipient = $this->createUser('recipsh6@example.com'); // Create a device owned by the recipient $device = new Device(); $device->setMac('AA:BB:CC:DD:EE:06')->setName('Test Frame'); $device->setUser($recipient); $this->em()->persist($device); // Create the shared image $image = $this->makeImage($sender); $shared = $this->makeShared($image, $recipient, $sender); // Pre-create a Ready RenderedAsset for the device's model+orientation $asset = (new RenderedAsset()) ->setImage($image) ->setDeviceModel($device->getModel()) ->setOrientation($device->getOrientation()) ->setStatus(RenderStatus::Ready); $this->em()->persist($asset); $this->em()->flush(); // Approve with the device ID so the image is approved for this device $client = $this->loginAs($recipient); $client->request('POST', '/api/shared-images/' . $shared->getId() . '/approve', [], [], [ 'CONTENT_TYPE' => 'application/json', ], json_encode(['deviceIds' => [$device->getId()]])); $this->assertResponseIsSuccessful(); // Reload image and assert device approval $this->em()->clear(); $imageReloaded = $this->em()->find(Image::class, $image->getId()); $deviceReloaded = $this->em()->find(Device::class, $device->getId()); $this->assertTrue($imageReloaded->isApprovedForDevice($deviceReloaded), 'Image should be approved for device'); // Assert image appears in the rotation pool via RotationService::advance() $rotationService = static::getContainer()->get(RotationService::class); $next = $rotationService->advance($deviceReloaded); $this->assertNotNull($next, 'RotationService::advance() should return the approved image'); $this->assertSame($imageReloaded->getId(), $next->getId()); } }