chore: stage all in-progress work before repo split
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>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent 062c52eec7
commit 12245759ac
149 changed files with 14846 additions and 92 deletions
@@ -0,0 +1,281 @@
<?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;
class DeviceApiControllerTest extends AppWebTestCase
{
private function makeDevice(string $mac, $user): Device
{
$device = new Device();
$device->setMac($mac);
$device->setName('Frame ' . $mac);
$device->setUser($user);
$device->setModel(DeviceModel::V1);
$device->setOrientation(Orientation::Landscape);
$this->em()->persist($device);
$this->em()->flush();
return $device;
}
private function makeImage($user): Image
{
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$this->em()->persist($image);
$this->em()->flush();
return $image;
}
public function test_list_returns_own_devices(): void
{
$user = $this->createUser('list@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A1', $user);
$client = $this->loginAs($user);
$client->request('GET', '/api/devices');
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertCount(1, $data);
$this->assertSame($device->getMac(), $data[0]['mac']);
}
public function test_list_unauthenticated_returns_redirect(): void
{
$this->client->request('GET', '/api/devices');
// form_login firewall redirects unauthenticated requests to /login
$this->assertResponseRedirects('/login');
}
public function test_patch_updates_name_orientation_and_interval(): void
{
$user = $this->createUser('patch@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A2', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'name' => 'New Name',
'orientation' => 'portrait',
'rotationIntervalMinutes' => 30,
]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame('New Name', $data['name']);
$this->assertSame('portrait', $data['orientation']);
$this->assertSame(30, $data['rotationIntervalMinutes']);
}
public function test_patch_wrong_users_device_returns_404(): void
{
$owner = $this->createUser('owner@example.com');
$other = $this->createUser('other@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A3', $owner);
$client = $this->loginAs($other);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['name' => 'Hack']));
$this->assertResponseStatusCodeSame(404);
}
public function test_put_lock_sets_locked_image_id(): void
{
$user = $this->createUser('lock@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A4', $user);
$image = $this->makeImage($user);
$image->approveForDevice($device);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['imageId' => $image->getId()]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame($image->getId(), $data['lockedImageId']);
}
// A-06: PUT /lock with image not approved for the device → 422
public function test_put_lock_unapproved_image_returns_422(): void
{
$user = $this->createUser('lock422@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A8', $user);
$image = $this->makeImage($user);
// Image is owned by user but NOT approved for this device
$client = $this->loginAs($user);
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['imageId' => $image->getId()]));
$this->assertResponseStatusCodeSame(422);
}
public function test_put_lock_image_not_owned_by_user_returns_404(): void
{
$user1 = $this->createUser('lockown1@example.com');
$user2 = $this->createUser('lockown2@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A5', $user1);
$image = $this->makeImage($user2);
$client = $this->loginAs($user1);
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['imageId' => $image->getId()]));
$this->assertResponseStatusCodeSame(404);
}
public function test_delete_lock_clears_locked_image_id(): void
{
$user = $this->createUser('unlock@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A6', $user);
$image = $this->makeImage($user);
$device->setLockedImage($image);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('DELETE', '/api/devices/' . $device->getId() . '/lock');
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertNull($data['lockedImageId']);
}
public function test_put_lock_wrong_users_device_returns_404(): void
{
$owner = $this->createUser('lockwrong1@example.com');
$other = $this->createUser('lockwrong2@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:A7', $owner);
$image = $this->makeImage($other);
$client = $this->loginAs($other);
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['imageId' => $image->getId()]));
$this->assertResponseStatusCodeSame(404);
}
public function test_patch_empty_name_returns_422(): void
{
$user = $this->createUser('patchname@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B1', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['name' => '']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_invalid_orientation_returns_422(): void
{
$user = $this->createUser('patchorient@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B2', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['orientation' => 'diagonal']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_invalid_timezone_returns_422(): void
{
$user = $this->createUser('patchtzone@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B3', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['timezone' => 'Not/Real']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_sets_wake_hour(): void
{
$user = $this->createUser('patchwake@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B4', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeHour' => 8]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame(8, $data['wakeHour']);
}
public function test_patch_sets_uniqueness_window(): void
{
$user = $this->createUser('patchuniq@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:B5', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['uniquenessWindow' => 14]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame(14, $data['uniquenessWindow']);
}
public function test_patch_sets_valid_timezone(): void
{
$user = $this->createUser('patchtzvld@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:C1', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['timezone' => 'America/New_York']));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame('America/New_York', $data['timezone']);
}
public function test_lock_with_no_image_id_returns_422(): void
{
$user = $this->createUser('locknoimgid@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:C2', $user);
$client = $this->loginAs($user);
$client->request('PUT', '/api/devices/' . $device->getId() . '/lock', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([]));
$this->assertResponseStatusCodeSame(422);
}
public function test_unlock_wrong_users_device_returns_404(): void
{
$owner = $this->createUser('unlockown@example.com');
$other = $this->createUser('unlockoth@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:C3', $owner);
$client = $this->loginAs($other);
$client->request('DELETE', '/api/devices/' . $device->getId() . '/lock');
$this->assertResponseStatusCodeSame(404);
}
}
@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Entity\Device;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Tests\Functional\AppWebTestCase;
class DeviceImageControllerTest extends AppWebTestCase
{
private const MAC = 'AA:BB:CC:11:22:33';
private const BIN_PATH = 'var/storage/images/test-img/v1_landscape.bin';
private string $binAbsPath;
protected function setUp(): void
{
parent::setUp();
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
$this->binAbsPath = $projectDir . '/' . self::BIN_PATH;
$dir = dirname($this->binAbsPath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($this->binAbsPath, 'FAKEBIN');
}
protected function tearDown(): void
{
if (file_exists($this->binAbsPath)) {
unlink($this->binAbsPath);
}
parent::tearDown();
}
private function createTestSetup(bool $approveImage = true, bool $withReadyAsset = true): array
{
$user = $this->createUser('devimg@example.com');
$device = new Device();
$device->setMac(self::MAC);
$device->setName('Test Frame');
$device->setUser($user);
$device->setModel(DeviceModel::V1);
$device->setOrientation(Orientation::Landscape);
$device->setRotationIntervalMinutes(60);
$this->em()->persist($device);
$image = new Image();
$image->setUser($user)->setOriginalFilename('test.jpg')->setStoragePath('x');
if ($approveImage) {
$image->approveForDevice($device);
}
$this->em()->persist($image);
if ($withReadyAsset) {
$asset = (new RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)
->setStatus(RenderStatus::Ready)
->setFilePath(self::BIN_PATH);
$this->em()->persist($asset);
}
$this->em()->flush();
return ['device' => $device, 'image' => $image, 'user' => $user];
}
public function test_returns_404_for_unknown_mac(): void
{
$this->client->request('GET', '/api/device/FF:FF:FF:FF:FF:FF/image');
$this->assertResponseStatusCodeSame(404);
}
public function test_returns_204_when_no_images(): void
{
$user = $this->createUser('noimg@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:44:55:66');
$device->setName('No Image Device');
$device->setUser($user);
$this->em()->persist($device);
$this->em()->flush();
$this->client->request('GET', '/api/device/AA:BB:CC:44:55:66/image');
$this->assertResponseStatusCodeSame(204);
}
public function test_returns_200_with_binary_body_and_headers(): void
{
$this->createTestSetup();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(200);
$response = $this->client->getResponse();
$this->assertNotEmpty($response->headers->get('X-Image-Id'));
$this->assertNotEmpty($response->headers->get('X-Interval-Ms'));
$this->assertSame('application/octet-stream', $response->headers->get('Content-Type'));
}
public function test_returns_304_when_current_image_id_matches(): void
{
$setup = $this->createTestSetup();
$imageId = $setup['image']->getId();
// First call to advance rotation and get the image
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(200);
// Second call with matching X-Current-Image-Id
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
'HTTP_X-Current-Image-Id' => (string) $imageId,
]);
$this->assertResponseStatusCodeSame(304);
}
public function test_returns_200_when_current_image_id_is_stale(): void
{
$this->createTestSetup();
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
'HTTP_X-Current-Image-Id' => '99999',
]);
$this->assertResponseStatusCodeSame(200);
}
public function test_locked_image_served_without_rotation_advance(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
$deviceId = $device->getId();
$image = $setup['image'];
$device->setLockedImage($image);
$this->em()->flush();
$this->assertNull($device->getCurrentImage());
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(200);
// Re-fetch from DB; currentImage should still be null (advance() was never called)
$this->em()->clear();
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
$this->assertNull($device->getCurrentImage());
}
public function test_returns_304_when_locked_image_matches_current_image_id(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
$image = $setup['image'];
$imageId = $image->getId();
$device->setLockedImage($image);
$this->em()->flush();
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
'HTTP_X-Current-Image-Id' => (string) $imageId,
]);
$this->assertResponseStatusCodeSame(304);
}
public function test_poll_advances_current_image(): void
{
$setup = $this->createTestSetup();
$deviceId = $setup['device']->getId();
$imageId = $setup['image']->getId();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(200);
// Re-fetch from DB after request clears EM state
$this->em()->clear();
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
$this->assertNotNull($device->getCurrentImage());
$this->assertSame($imageId, $device->getCurrentImage()->getId());
}
public function test_x_interval_ms_equals_rotation_interval_minutes_times_60000(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$response = $this->client->getResponse();
$intervalMs = (int) $response->headers->get('X-Interval-Ms');
$this->assertSame($device->getRotationIntervalMinutes() * 60 * 1000, $intervalMs);
}
public function test_last_seen_at_updated_after_200_poll(): void
{
$setup = $this->createTestSetup();
$deviceId = $setup['device']->getId();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(200);
$this->em()->clear();
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
$this->assertNotNull($device->getLastSeenAt());
}
public function test_last_seen_at_updated_after_304_poll(): void
{
$setup = $this->createTestSetup();
$deviceId = $setup['device']->getId();
$imageId = $setup['image']->getId();
// Seed rotation first
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
'HTTP_X-Current-Image-Id' => (string) $imageId,
]);
$this->assertResponseStatusCodeSame(304);
// Re-fetch from DB since the EM may have been cleared by the request
$this->em()->clear();
$device = $this->em()->find(\App\Entity\Device::class, $deviceId);
$this->assertNotNull($device->getLastSeenAt());
}
// Returns 204 when image is approved but no Ready RenderedAsset exists
public function test_returns_204_when_no_ready_asset_for_approved_image(): void
{
$this->createTestSetup(true, false);
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(204);
}
// Returns 204 when RenderedAsset has Ready status but the file is missing from disk
public function test_returns_204_when_bin_file_missing_from_disk(): void
{
$setup = $this->createTestSetup(true, false);
$asset = (new RenderedAsset())
->setImage($setup['image'])
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)
->setStatus(RenderStatus::Ready)
->setFilePath('var/storage/images/nonexistent/missing.bin');
$this->em()->persist($asset);
$this->em()->flush();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(204);
}
// When wakeHour is set, X-Interval-Ms should be > 0 and <= 24h in ms
public function test_wake_hour_interval_used_when_set(): void
{
$setup = $this->createTestSetup();
$device = $setup['device'];
$device->setWakeHour(3)->setTimezone('UTC');
$this->em()->flush();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(200);
$intervalMs = (int) $this->client->getResponse()->headers->get('X-Interval-Ms');
$this->assertGreaterThan(0, $intervalMs);
$this->assertLessThanOrEqual(24 * 60 * 60 * 1000, $intervalMs);
}
// Returns 204 when RenderedAsset has Ready status but filePath is null (device.poll.no_asset path)
public function test_returns_204_when_ready_asset_has_null_file_path(): void
{
$setup = $this->createTestSetup(true, false);
$asset = (new RenderedAsset())
->setImage($setup['image'])
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)
->setStatus(RenderStatus::Ready)
->setFilePath(null);
$this->em()->persist($asset);
$this->em()->flush();
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
$this->assertResponseStatusCodeSame(204);
}
}
@@ -0,0 +1,536 @@
<?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);
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Tests\Functional\AppWebTestCase;
class SecurityControllerTest extends AppWebTestCase
{
// SEC-01: anonymous GET /login → 200
public function test_login_page_renders_for_anonymous(): void
{
$this->client->request('GET', '/login');
$this->assertResponseIsSuccessful();
}
// SEC-02: authenticated GET /login → redirects to spa
public function test_login_page_redirects_when_authenticated(): void
{
$user = $this->createUser('sec02@example.com');
$this->loginAs($user);
$this->client->request('GET', '/login');
$this->assertResponseRedirects();
}
// SEC-03: anonymous GET /register → 200
public function test_register_page_renders_for_anonymous(): void
{
$this->client->request('GET', '/register');
$this->assertResponseIsSuccessful();
}
// SEC-04: POST /register with valid form data → user created, redirected
public function test_register_creates_user_and_redirects(): void
{
$this->client->request('POST', '/register', [
'registration_form' => [
'email' => 'newsecuser@example.com',
'plainPassword' => [
'first' => 'securepass123',
'second' => 'securepass123',
],
],
]);
$this->assertResponseRedirects();
}
// SEC-05: authenticated GET /register → redirects
public function test_register_page_redirects_when_authenticated(): void
{
$user = $this->createUser('sec05@example.com');
$this->loginAs($user);
$this->client->request('GET', '/register');
$this->assertResponseRedirects();
}
// SEC-06: logout() method throws LogicException (the firewall intercepts real requests before this runs)
public function test_logout_method_throws_logic_exception(): void
{
$controller = new \App\Controller\SecurityController();
$this->expectException(\LogicException::class);
$controller->logout();
}
}
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Entity\Device;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Tests\Functional\AppWebTestCase;
class SetupControllerTest extends AppWebTestCase
{
private const MAC = 'AA:BB:CC:00:11:22';
// S-01: anonymous GET /setup/{mac} → 200 (shows login/register form)
public function test_setup_index_anonymous_renders(): void
{
$this->client->request('GET', '/setup/' . self::MAC);
$this->assertResponseIsSuccessful();
}
// S-02: authenticated with named device → redirects to spa (/)
public function test_setup_index_authenticated_named_device_redirects_to_spa(): void
{
$user = $this->createUser('setup02@example.com');
$device = new Device();
$device->setMac(self::MAC);
$device->setName('Living Room');
$device->setUser($user);
$this->em()->persist($device);
$this->em()->flush();
$this->loginAs($user);
$this->client->request('GET', '/setup/' . self::MAC);
$this->assertResponseRedirects('/');
}
// S-03: authenticated with no existing device (new link) → redirects to configure
public function test_setup_index_authenticated_new_device_redirects_to_configure(): void
{
$user = $this->createUser('setup03@example.com');
$this->loginAs($user);
$this->client->request('GET', '/setup/' . self::MAC);
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
}
// S-04: POST /setup/{mac}/register with valid data → creates user, links device, redirects to configure
public function test_setup_register_creates_user_and_redirects_to_configure(): void
{
$this->client->request('POST', '/setup/' . self::MAC . '/register', [
'registration_form' => [
'email' => 'setupnew@example.com',
'plainPassword' => [
'first' => 'securepass123',
'second' => 'securepass123',
],
],
]);
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
}
// S-05: POST /setup/{mac}/configure with valid name → saves device, redirects to spa
public function test_setup_configure_saves_name_and_redirects_to_spa(): void
{
$user = $this->createUser('setup05@example.com');
$device = new Device();
$device->setMac(self::MAC);
$device->setUser($user);
$this->em()->persist($device);
$this->em()->flush();
$deviceId = $device->getId();
$this->loginAs($user);
$this->client->request('POST', '/setup/' . self::MAC . '/configure', [
'name' => 'Kitchen Frame',
'orientation' => 'landscape',
'rotation_interval_minutes' => '1440',
'uniqueness_window' => '10',
]);
$this->assertResponseRedirects('/');
$this->em()->clear();
$saved = $this->em()->find(Device::class, $deviceId);
$this->assertSame('Kitchen Frame', $saved->getName());
}
// S-06: POST /setup/{mac}/login with valid credentials → redirects to configure
public function test_login_valid_credentials_redirects_to_configure(): void
{
$this->createUser('setuplogin@example.com', 'testpass');
$this->client->request('POST', '/setup/' . self::MAC . '/login', [
'_username' => 'setuplogin@example.com',
'_password' => 'testpass',
]);
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
}
// S-07: POST /setup/{mac}/login with wrong password → redirects to index
public function test_login_invalid_credentials_redirects_to_index(): void
{
$this->createUser('setupbadlogin@example.com', 'testpass');
$this->client->request('POST', '/setup/' . self::MAC . '/login', [
'_username' => 'setupbadlogin@example.com',
'_password' => 'wrongpass',
]);
$this->assertResponseRedirects('/setup/' . self::MAC);
}
// S-08: authenticated GET /setup/{mac}/configure → 200
public function test_configure_get_renders_form(): void
{
$user = $this->createUser('setupconfigget@example.com');
$device = new Device();
$device->setMac(self::MAC);
$device->setUser($user);
$this->em()->persist($device);
$this->em()->flush();
$this->loginAs($user);
$this->client->request('GET', '/setup/' . self::MAC . '/configure');
$this->assertResponseIsSuccessful();
}
// S-09: POST /setup/{mac}/configure with empty name → 200 (re-renders form with error)
public function test_configure_post_empty_name_renders_error(): void
{
$user = $this->createUser('setupconfigerr@example.com');
$device = new Device();
$device->setMac(self::MAC);
$device->setUser($user);
$this->em()->persist($device);
$this->em()->flush();
$this->loginAs($user);
$this->client->request('POST', '/setup/' . self::MAC . '/configure', [
'name' => '',
'orientation' => 'landscape',
]);
$this->assertResponseIsSuccessful();
}
// S-10: POST /setup/{mac}/register with invalid form data → re-renders registration page
public function test_setup_register_invalid_form_renders_page(): void
{
// Valid email (avoids TypeError) but mismatched passwords → form is invalid
$this->client->request('POST', '/setup/' . self::MAC . '/register', [
'registration_form' => [
'email' => 'setup-invalid@example.com',
'plainPassword' => [
'first' => 'password123',
'second' => 'different456',
],
],
]);
// Symfony 7 returns 422 for submitted-but-invalid forms
$this->assertResponseStatusCodeSame(422);
}
}
@@ -0,0 +1,277 @@
<?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());
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Tests\Functional\AppWebTestCase;
class SpaControllerTest extends AppWebTestCase
{
// SPA-01: authenticated GET / → 200 with injected user data
public function test_spa_renders_with_user_data_for_authenticated_user(): void
{
$user = $this->createUser('spatest@example.com');
$this->loginAs($user);
$this->client->request('GET', '/');
$this->assertResponseIsSuccessful();
$content = $this->client->getResponse()->getContent();
$this->assertStringContainsString('window.__PF_USER__', $content);
$this->assertStringContainsString('spatest@example.com', $content);
}
// SPA-02: unauthenticated GET / → redirect to /login
public function test_spa_redirects_unauthenticated_to_login(): void
{
$this->client->request('GET', '/');
$this->assertResponseRedirects('/login');
}
// SPA-03: authenticated GET /some/nested/path → still serves SPA
public function test_spa_serves_nested_paths(): void
{
$user = $this->createUser('spatest2@example.com');
$this->loginAs($user);
$this->client->request('GET', '/library');
$this->assertResponseIsSuccessful();
}
// SPA-04: authenticated GET / when index.html is absent → 404
public function test_spa_throws_404_when_not_built(): void
{
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
$indexFile = $projectDir . '/public/build/index.html';
$tmpFile = $indexFile . '.bak';
$user = $this->createUser('spa04@example.com');
$this->loginAs($user);
rename($indexFile, $tmpFile);
try {
$this->client->request('GET', '/');
$this->assertResponseStatusCodeSame(404);
} finally {
rename($tmpFile, $indexFile);
}
}
}
@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Entity\Device;
use App\Entity\Image;
use App\Entity\SharedImage;
use App\Entity\Token;
use App\Enum\SharedImageStatus;
use App\Enum\TokenType;
use App\Tests\Functional\AppWebTestCase;
/**
* Tests for TokenActionController — token-based share approve/decline flows.
*
* TK-01: Valid share_approve token → page renders (GET) / action performed (POST)
* TK-02: Expired/missing token → invalid page rendered
* TK-03: Already-used token → same invalid page (repo returns null for used tokens)
*/
class TokenActionControllerTest 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 issueToken(Image $image, TokenType $type, int $ttlDays = 7): Token
{
$token = new Token($type, $image, null, 'recipient@example.com', $ttlDays);
$this->em()->persist($token);
return $token;
}
/**
* TK-01a: GET /token/{uuid}/approve with a valid token renders the approve page.
*/
public function test_approve_show_valid_token_renders_page(): void
{
$sender = $this->createUser('tk01a_sender@example.com');
$recipient = $this->createUser('tk01a_recip@example.com');
$image = $this->makeImage($sender);
$token = $this->issueToken($image, TokenType::ShareApprove);
$this->em()->flush();
$client = $this->loginAs($recipient);
$client->request('GET', '/token/' . $token->getUuid() . '/approve');
$this->assertResponseIsSuccessful();
// The valid approve page shows "Someone shared a photo" — NOT the invalid page
$this->assertSelectorTextContains('h1', 'Someone shared a photo');
}
/**
* TK-01b: POST /token/{uuid}/approve with a valid token marks SharedImage as approved and consumes the token.
*/
public function test_approve_submit_valid_token_marks_approved_and_consumes(): void
{
$sender = $this->createUser('tk01b_sender@example.com');
$recipient = $this->createUser('tk01b_recip@example.com');
$image = $this->makeImage($sender);
$token = $this->issueToken($image, TokenType::ShareApprove);
// Also create a SharedImage so the controller can update its status
$shared = new SharedImage($image, $recipient, $sender);
$this->em()->persist($shared);
$this->em()->flush();
$tokenUuid = $token->getUuid();
$sharedId = $shared->getId();
$client = $this->loginAs($recipient);
$client->request('POST', '/token/' . $tokenUuid . '/approve', [
'device_ids' => [],
]);
// Controller renders approved.html.twig on success (200)
$this->assertResponseIsSuccessful();
// Token should now be marked used
$this->em()->clear();
$reloaded = $this->em()->find(Token::class, $tokenUuid);
$this->assertNotNull($reloaded->getUsedAt(), 'Token should be consumed after successful submit');
// SharedImage status should be Approved
$sharedReloaded = $this->em()->find(SharedImage::class, $sharedId);
$this->assertSame(SharedImageStatus::Approved, $sharedReloaded->getStatus());
$this->assertSame($recipient->getId(), $sharedReloaded->getRecipientUser()->getId());
}
/**
* TK-02: GET /token/{uuid}/approve with a missing UUID renders the invalid page.
*/
public function test_approve_show_missing_token_renders_invalid_page(): void
{
$user = $this->createUser('tk02@example.com');
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/token/00000000-0000-0000-0000-000000000000/approve');
$this->assertResponseIsSuccessful(); // controller returns 200 with invalid.html.twig
$this->assertSelectorTextContains('body', 'expired');
}
/**
* TK-03: GET /token/{uuid}/approve with an already-used token renders the invalid page.
*
* TokenRepository::findValidToken() filters usedAt IS NULL, so a used token
* is indistinguishable from a missing one — both return null → invalid page.
*/
public function test_approve_show_used_token_renders_invalid_page(): void
{
$sender = $this->createUser('tk03_sender@example.com');
$image = $this->makeImage($sender);
$token = $this->issueToken($image, TokenType::ShareApprove);
// Consume the token before the test
$token->consume();
$this->em()->flush();
$recipient = $this->createUser('tk03_recip@example.com');
$client = $this->loginAs($recipient);
$client->request('GET', '/token/' . $token->getUuid() . '/approve');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('body', 'expired');
}
// TK-04: GET /token/{uuid}/approve without login renders the approve page (anonymous)
public function test_approve_show_anonymous_shows_page(): void
{
$sender = $this->createUser('tk04_sender@example.com');
$image = $this->makeImage($sender);
$token = $this->issueToken($image, TokenType::ShareApprove);
$this->em()->flush();
$this->client->request('GET', '/token/' . $token->getUuid() . '/approve');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h1', 'Someone shared a photo');
}
// TK-05: POST /token/{uuid}/approve unauthenticated → redirects to /login
public function test_approve_submit_unauthenticated_redirects_to_login(): void
{
$sender = $this->createUser('tk05_sender@example.com');
$image = $this->makeImage($sender);
$token = $this->issueToken($image, TokenType::ShareApprove);
$this->em()->flush();
$this->client->request('POST', '/token/' . $token->getUuid() . '/approve', [
'device_ids' => [],
]);
$this->assertResponseRedirects('/login');
}
// TK-06: POST /token/{invalid}/approve → renders invalid page
public function test_approve_submit_invalid_token_renders_invalid(): void
{
$user = $this->createUser('tk06@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/token/00000000-0000-0000-0000-000000000000/approve', [
'device_ids' => [],
]);
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('body', 'expired');
}
// TK-07: GET /token/{uuid}/decline with valid token renders decline page
public function test_decline_show_valid_token_renders_page(): void
{
$sender = $this->createUser('tk07_sender@example.com');
$image = $this->makeImage($sender);
$token = $this->issueToken($image, TokenType::ShareDecline);
$this->em()->flush();
$this->client->request('GET', '/token/' . $token->getUuid() . '/decline');
$this->assertResponseIsSuccessful();
}
// TK-08: GET /token/{invalid}/decline → renders invalid page
public function test_decline_show_invalid_token_renders_invalid_page(): void
{
$this->client->request('GET', '/token/00000000-0000-0000-0000-000000000000/decline');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('body', 'expired');
}
// TK-09: POST /token/{uuid}/decline → sets SharedImage status to Declined
public function test_decline_submit_updates_shared_image_to_declined(): void
{
$sender = $this->createUser('tk09_sender@example.com');
$recipient = $this->createUser('tk09_recip@example.com');
$image = $this->makeImage($sender);
$token = $this->issueToken($image, TokenType::ShareDecline);
$shared = new SharedImage($image, $recipient, $sender);
$this->em()->persist($shared);
$this->em()->flush();
$sharedId = $shared->getId();
$client = $this->loginAs($recipient);
$client->request('POST', '/token/' . $token->getUuid() . '/decline');
$this->assertResponseIsSuccessful();
$this->em()->clear();
$reloaded = $this->em()->find(SharedImage::class, $sharedId);
$this->assertSame(\App\Enum\SharedImageStatus::Declined, $reloaded->getStatus());
}
// TK-10: POST /token/{uuid}/decline without a matching SharedImage → succeeds
public function test_decline_submit_without_shared_image_succeeds(): void
{
$sender = $this->createUser('tk10_sender@example.com');
$image = $this->makeImage($sender);
$token = $this->issueToken($image, TokenType::ShareDecline);
$this->em()->flush();
$user = $this->createUser('tk10_user@example.com');
$client = $this->loginAs($user);
$client->request('POST', '/token/' . $token->getUuid() . '/decline');
$this->assertResponseIsSuccessful();
}
// TK-12: POST /token/{uuid}/approve with recipient's own device → approveForDevice is called
public function test_approve_submit_with_owned_device_calls_approve_for_device(): void
{
$sender = $this->createUser('tk12_sender@example.com');
$recipient = $this->createUser('tk12_recip@example.com');
$image = $this->makeImage($sender);
$token = $this->issueToken($image, TokenType::ShareApprove);
$ownDevice = new Device();
$ownDevice->setMac('AA:BB:CC:00:55:66')->setName('My Frame');
$ownDevice->setUser($recipient);
$this->em()->persist($ownDevice);
$shared = new SharedImage($image, $recipient, $sender);
$this->em()->persist($shared);
$this->em()->flush();
$client = $this->loginAs($recipient);
$client->request('POST', '/token/' . $token->getUuid() . '/approve', [
'device_ids' => [$ownDevice->getId()],
]);
$this->assertResponseIsSuccessful();
}
// TK-11: POST /token/{uuid}/approve with a device_id not owned by the user → device skipped (continue branch)
public function test_approve_submit_with_unowned_device_id_is_skipped(): void
{
$sender = $this->createUser('tk11_sender@example.com');
$recipient = $this->createUser('tk11_recip@example.com');
$other = $this->createUser('tk11_other@example.com');
$image = $this->makeImage($sender);
$token = $this->issueToken($image, TokenType::ShareApprove);
$otherDevice = new Device();
$otherDevice->setMac('AA:BB:CC:00:44:55')->setName('Other Frame');
$otherDevice->setUser($other);
$this->em()->persist($otherDevice);
$this->em()->flush();
$client = $this->loginAs($recipient);
$client->request('POST', '/token/' . $token->getUuid() . '/approve', [
'device_ids' => [$otherDevice->getId()],
]);
$this->assertResponseIsSuccessful();
}
}
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Tests\Functional\AppWebTestCase;
class UserApiControllerTest extends AppWebTestCase
{
// US-01: search q < 2 chars → []
public function test_search_short_query_returns_empty(): void
{
$user = $this->createUser('us01@example.com');
$client = $this->loginAs($user);
$client->request('GET', '/api/user/search?q=a');
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame([], $data);
}
// US-02: search valid q → results containing matching users
public function test_search_valid_query_returns_results(): void
{
$user = $this->createUser('us02_me@example.com');
$other = $this->createUser('us02_other@example.com');
$client = $this->loginAs($user);
$client->request('GET', '/api/user/search?q=us02_other');
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertNotEmpty($data);
$emails = array_column($data, 'email');
$this->assertContains('us02_other@example.com', $emails);
// Own email should not appear
$this->assertNotContains('us02_me@example.com', $emails);
}
// US-03: updateTheme valid → 200, returns {theme}
public function test_update_theme_valid_returns_200(): void
{
$user = $this->createUser('us03@example.com');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/theme', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['theme' => 'warm-craft']));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame('warm-craft', $data['theme']);
}
// US-04: updateTheme invalid → 422
public function test_update_theme_invalid_returns_422(): void
{
$user = $this->createUser('us04@example.com');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/theme', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['theme' => 'not-a-valid-theme']));
$this->assertResponseStatusCodeSame(422);
}
// US-05: updateTimezone valid → 200, returns {timezone}
public function test_update_timezone_valid_returns_200(): void
{
$user = $this->createUser('us05@example.com');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/timezone', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['timezone' => 'America/New_York']));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame('America/New_York', $data['timezone']);
}
// US-06: updateTimezone missing/null body → 422
public function test_update_timezone_missing_returns_422(): void
{
$user = $this->createUser('us06@example.com');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/timezone', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['other' => 'value']));
$this->assertResponseStatusCodeSame(422);
}
// US-07: updateTimezone invalid string → 422
public function test_update_timezone_invalid_string_returns_422(): void
{
$user = $this->createUser('us07@example.com');
$client = $this->loginAs($user);
$client->request('PATCH', '/api/user/timezone', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['timezone' => 'Not/A/Valid/Timezone']));
$this->assertResponseStatusCodeSame(422);
}
// US-08: unauthenticated → redirects to /login
public function test_unauthenticated_redirects_to_login(): void
{
$this->client->request('GET', '/api/user/search?q=test');
$this->assertResponseRedirects('/login');
}
}