Files
pictureFrame/tests/Functional/Controller/SharedImageApiControllerTest.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

278 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Entity\Device;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Entity\SharedImage;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Enum\SharedImageStatus;
use App\Service\RotationService;
use App\Tests\Functional\AppWebTestCase;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
class SharedImageApiControllerTest extends AppWebTestCase
{
private function makeImage($user): Image
{
$image = (new Image())->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());
}
}