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
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
abstract class AppKernelTestCase extends KernelTestCase
{
private ?EntityManagerInterface $em = null;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
$this->em = null;
}
protected function em(): EntityManagerInterface
{
if ($this->em === null) {
$this->em = static::getContainer()->get(EntityManagerInterface::class);
}
return $this->em;
}
protected function createUser(string $email, string $password = 'password'): User
{
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
$user = new User();
$user->setEmail($email);
$user->setPassword($hasher->hashPassword($user, $password));
$this->em()->persist($user);
$this->em()->flush();
return $user;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
abstract class AppWebTestCase extends WebTestCase
{
protected KernelBrowser $client;
private ?EntityManagerInterface $em = null;
protected function setUp(): void
{
parent::setUp();
// Create the client first — this boots the kernel and makes getContainer() available.
$this->client = static::createClient();
$this->em = null;
}
protected function em(): EntityManagerInterface
{
if ($this->em === null) {
$this->em = static::getContainer()->get(EntityManagerInterface::class);
}
return $this->em;
}
protected function createUser(string $email, string $password = 'password'): User
{
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
$user = new User();
$user->setEmail($email);
$user->setPassword($hasher->hashPassword($user, $password));
$this->em()->persist($user);
$this->em()->flush();
return $user;
}
protected function loginAs(User $user): KernelBrowser
{
$this->client->loginUser($user);
return $this->client;
}
}
@@ -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');
}
}
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\MessageHandler;
use App\Entity\Device;
use App\Entity\DeviceImageHistory;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Message\AdvanceRotationMessage;
use App\MessageHandler\AdvanceRotationMessageHandler;
use App\Repository\DeviceImageHistoryRepository;
use App\Tests\AppKernelTestCase;
class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
{
private int $deviceSeq = 0;
private function invokeHandler(): void
{
$handler = static::getContainer()->get(AdvanceRotationMessageHandler::class);
$handler(new AdvanceRotationMessage());
}
private function makeDevice(int $intervalMinutes = 60): Device
{
$seq = ++$this->deviceSeq;
$mac = sprintf('AA:BB:CC:%02X:%02X:%02X', 10, 0, $seq);
$user = $this->createUser('ar' . $seq . '@example.com');
$device = new Device();
$device->setMac($mac)->setName('Frame ' . $seq)->setUser($user)
->setRotationIntervalMinutes($intervalMinutes);
$this->em()->persist($device);
return $device;
}
private function makeReadyImage(Device $device): Image
{
$image = (new Image())->setUser($device->getUser())->setOriginalFilename('x.jpg')->setStoragePath('x');
$image->approveForDevice($device);
$this->em()->persist($image);
$asset = (new RenderedAsset())
->setImage($image)
->setDeviceModel($device->getModel() ?? DeviceModel::V1)
->setOrientation($device->getOrientation() ?? Orientation::Landscape)
->setStatus(RenderStatus::Ready)
->setFilePath('var/storage/dummy.bin');
$this->em()->persist($asset);
return $image;
}
// AR-01: due device (no history) with no ready images → advance returns null
public function test_ar01_due_with_no_images_does_not_rotate(): void
{
$device = $this->makeDevice();
$this->em()->flush();
// Ensure repository constructor is covered
$this->em()->getRepository(DeviceImageHistory::class);
$this->invokeHandler();
$this->em()->clear();
$reloaded = $this->em()->find(Device::class, $device->getId());
$this->assertNull($reloaded->getCurrentImage());
}
// AR-02: interval-based device with recent history → not due → rotation skipped
public function test_ar02_recent_history_is_not_due(): void
{
$device = $this->makeDevice(60);
$image = $this->makeReadyImage($device);
$this->em()->flush();
$history = new DeviceImageHistory($device, $image);
$this->em()->persist($history);
$device->setCurrentImage($image);
$this->em()->flush();
$this->invokeHandler();
$this->em()->clear();
$reloaded = $this->em()->find(Device::class, $device->getId());
// currentImage was set before handler; rotation should not have occurred again
$this->assertNotNull($reloaded->getCurrentImage());
$this->assertSame($image->getId(), $reloaded->getCurrentImage()->getId());
}
// AR-03: interval-based device with old history → due → rotation occurs
public function test_ar03_old_history_triggers_rotation(): void
{
$device = $this->makeDevice(1); // 1-minute interval
$image = $this->makeReadyImage($device);
$this->em()->flush();
$deviceId = $device->getId();
$history = new DeviceImageHistory($device, $image);
$this->em()->persist($history);
$this->em()->flush();
// Backdate history to 2 minutes ago (older than 1-minute interval).
// Clear the identity map afterward — DQL bulk UPDATE bypasses the entity cache.
$this->em()->createQuery(
'UPDATE App\Entity\DeviceImageHistory h SET h.servedAt = :old WHERE h.device = :dev'
)->setParameters(['old' => new \DateTimeImmutable('-2 minutes'), 'dev' => $device])->execute();
$this->em()->clear();
$this->invokeHandler();
$this->em()->clear();
$reloaded = $this->em()->find(Device::class, $deviceId);
$this->assertNotNull($reloaded->getCurrentImage());
}
// AR-04: wakeHour=0 (midnight, always past) + no history today → rotation occurs
public function test_ar04_wake_hour_past_no_history_rotates(): void
{
$device = $this->makeDevice();
$device->setWakeHour(0)->setTimezone('UTC');
$image = $this->makeReadyImage($device);
$this->em()->flush();
$deviceId = $device->getId();
$imageId = $image->getId();
$this->invokeHandler();
$this->em()->clear();
$reloaded = $this->em()->find(Device::class, $deviceId);
$this->assertNotNull($reloaded->getCurrentImage());
$this->assertSame($imageId, $reloaded->getCurrentImage()->getId());
}
// AR-05: wakeHour=0 (midnight) + history exists since midnight → already served today → not due
public function test_ar05_wake_hour_already_served_today_is_skipped(): void
{
$device = $this->makeDevice();
$device->setWakeHour(0)->setTimezone('UTC');
$image = $this->makeReadyImage($device);
$this->em()->flush();
// History entry timestamped just now (after midnight UTC → considered "today's wake")
$history = new DeviceImageHistory($device, $image);
$this->em()->persist($history);
$device->setCurrentImage($image);
$this->em()->flush();
$deviceId = $device->getId();
$imageId = $image->getId();
$this->invokeHandler();
$this->em()->clear();
$reloaded = $this->em()->find(Device::class, $deviceId);
// currentImage set before; rotation should not have happened again
$this->assertSame($imageId, $reloaded->getCurrentImage()?->getId());
}
// AR-06: wakeHour in future → isDue returns false → no rotation
// Uses 'Etc/GMT+11' (UTC-11) so local time is always before wakeHour=22
// except during UTC 09:00-10:59; test is skipped then.
public function test_ar06_wake_hour_in_future_is_not_due(): void
{
$utcHour = (int)(new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('G');
if ($utcHour >= 9 && $utcHour <= 10) {
$this->markTestSkipped('Time-dependent test skipped during UTC 09:xx-10:xx boundary hour');
}
$device = $this->makeDevice();
// UTC-11: local time is at most 12:59 when UTC is 23:59 → wakeHour=23 is always future
$device->setWakeHour(23)->setTimezone('Etc/GMT+11');
$image = $this->makeReadyImage($device);
$this->em()->flush();
$this->invokeHandler();
$this->em()->clear();
$reloaded = $this->em()->find(Device::class, $device->getId());
$this->assertNull($reloaded->getCurrentImage(), 'Rotation must not happen when wakeHour is still in the future');
}
}
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\MessageHandler;
use App\Entity\Image;
use App\Enum\RenderStatus;
use App\Message\RenderImageMessage;
use App\MessageHandler\RenderImageMessageHandler;
use App\Repository\RenderedAssetRepository;
use App\Tests\AppKernelTestCase;
class RenderImageMessageHandlerTest extends AppKernelTestCase
{
private string $projectDir;
private string $fixtureJpeg;
private array $createdDirs = [];
protected function setUp(): void
{
parent::setUp();
$this->projectDir = static::getContainer()->getParameter('kernel.project_dir');
$this->createdDirs = [];
$storageDir = $this->projectDir . '/var/storage/images';
if (!is_dir($storageDir)) {
mkdir($storageDir, 0755, true);
}
$this->fixtureJpeg = $this->projectDir . '/var/storage/images/_render_fixture.jpg';
$imagick = new \Imagick();
$imagick->newImage(20, 20, new \ImagickPixel('white'));
$imagick->setImageFormat('jpeg');
$imagick->writeImage($this->fixtureJpeg);
$imagick->destroy();
}
protected function tearDown(): void
{
foreach ($this->createdDirs as $dir) {
if (is_dir($dir)) {
$this->deleteDir($dir);
}
}
if (file_exists($this->fixtureJpeg)) {
unlink($this->fixtureJpeg);
}
parent::tearDown();
}
private function deleteDir(string $dir): void
{
foreach (new \FilesystemIterator($dir) as $item) {
$item->isDir() ? $this->deleteDir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($dir);
}
private function invokeHandler(int $imageId, string $model = 'v1', string $orientation = 'landscape'): void
{
$handler = static::getContainer()->get(RenderImageMessageHandler::class);
$handler(new RenderImageMessage($imageId, $model, $orientation));
}
// MH-01: happy path — bin written, RenderedAsset status = Ready
public function test_mh01_renders_image_to_bin_and_marks_ready(): void
{
$user = $this->createUser('mh01@example.com');
$image = (new Image())->setUser($user)
->setOriginalFilename('test.jpg')
->setStoragePath('var/storage/images/_render_fixture.jpg');
$this->em()->persist($image);
$this->em()->flush();
$imageId = $image->getId();
$imageDir = $this->projectDir . '/var/storage/images/' . $imageId;
mkdir($imageDir, 0755, true);
$this->createdDirs[] = $imageDir;
$this->invokeHandler($imageId);
/** @var RenderedAssetRepository $assetRepo */
$assetRepo = $this->em()->getRepository(\App\Entity\RenderedAsset::class);
$asset = $assetRepo->findOneBy(['image' => $image]);
$this->assertNotNull($asset);
$this->assertSame(RenderStatus::Ready, $asset->getStatus());
$this->assertFileExists($this->projectDir . '/' . $asset->getFilePath());
}
// MH-01b: composited.jpg is preferred over the raw original when present
public function test_mh01b_composited_jpg_preferred_over_original(): void
{
$user = $this->createUser('mh01b@example.com');
$image = (new Image())->setUser($user)
->setOriginalFilename('test.jpg')
->setStoragePath('var/storage/images/_render_fixture.jpg');
$this->em()->persist($image);
$this->em()->flush();
$imageId = $image->getId();
$imageDir = $this->projectDir . '/var/storage/images/' . $imageId;
mkdir($imageDir, 0755, true);
$this->createdDirs[] = $imageDir;
copy($this->fixtureJpeg, $imageDir . '/composited.jpg');
$this->invokeHandler($imageId);
$assetRepo = $this->em()->getRepository(\App\Entity\RenderedAsset::class);
$asset = $assetRepo->findOneBy(['image' => $image]);
$this->assertNotNull($asset);
$this->assertSame(RenderStatus::Ready, $asset->getStatus());
}
// MH-02: non-existent imageId → handler returns early, no RenderedAsset created
public function test_mh02_nonexistent_image_returns_early(): void
{
$this->invokeHandler(999999999);
$assetRepo = $this->em()->getRepository(\App\Entity\RenderedAsset::class);
$assets = $assetRepo->findAll();
$this->assertCount(0, $assets);
}
// MH-03: corrupt/non-image file → RenderedAsset status = Failed
public function test_mh03_imagick_failure_marks_asset_as_failed(): void
{
$badFile = $this->projectDir . '/var/storage/images/_render_bad.txt';
file_put_contents($badFile, 'not-an-image');
$user = $this->createUser('mh03@example.com');
$image = (new Image())->setUser($user)
->setOriginalFilename('bad.jpg')
->setStoragePath('var/storage/images/_render_bad.txt');
$this->em()->persist($image);
$this->em()->flush();
$imageId = $image->getId();
$imageDir = $this->projectDir . '/var/storage/images/' . $imageId;
mkdir($imageDir, 0755, true);
$this->createdDirs[] = $imageDir;
$this->invokeHandler($imageId);
$assetRepo = $this->em()->getRepository(\App\Entity\RenderedAsset::class);
$asset = $assetRepo->findOneBy(['image' => $image]);
$this->assertNotNull($asset);
$this->assertSame(RenderStatus::Failed, $asset->getStatus());
if (file_exists($badFile)) {
unlink($badFile);
}
}
}
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\MessageHandler;
use App\Entity\Device;
use App\Entity\Image;
use App\Message\RunImageCleanupMessage;
use App\MessageHandler\RunImageCleanupMessageHandler;
use App\Repository\ImageRepository;
use App\Tests\AppKernelTestCase;
use Doctrine\ORM\EntityManagerInterface;
class RunImageCleanupMessageHandlerTest extends AppKernelTestCase
{
private string $projectDir;
private array $createdDirs = [];
protected function setUp(): void
{
parent::setUp();
$this->projectDir = static::getContainer()->getParameter('kernel.project_dir');
$this->createdDirs = [];
}
protected function tearDown(): void
{
foreach ($this->createdDirs as $dir) {
if (is_dir($dir)) {
$this->deleteDir($dir);
}
}
parent::tearDown();
}
private function deleteDir(string $dir): void
{
foreach (new \FilesystemIterator($dir) as $item) {
$item->isDir() ? $this->deleteDir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($dir);
}
private function invokeHandler(): void
{
$handler = static::getContainer()->get(RunImageCleanupMessageHandler::class);
$handler(new RunImageCleanupMessage());
}
private function makeImage(string $email): Image
{
$user = $this->createUser($email);
$image = (new Image())->setUser($user)
->setOriginalFilename('del.jpg')
->setStoragePath('var/storage/images/_cleanup_test.jpg');
$this->em()->persist($image);
$this->em()->flush();
return $image;
}
// CL-01: soft-deleted image with no approvals → hard-deleted from DB and filesystem
public function test_cl01_soft_deleted_no_approvals_is_hard_deleted(): void
{
$image = $this->makeImage('cl01@example.com');
$image->setDeletedAt(new \DateTimeImmutable('-1 day'));
$this->em()->flush();
$imageId = $image->getId();
$dir = $this->projectDir . '/var/storage/images/' . $imageId;
mkdir($dir, 0755, true);
$this->createdDirs[] = $dir;
// Add a file and a subdirectory to cover unlink + recursive deleteDirectory branches
file_put_contents($dir . '/v1_landscape.bin', 'FAKEBIN');
$subdir = $dir . '/subdir';
mkdir($subdir, 0755, true);
file_put_contents($subdir . '/nested.bin', 'NESTEDBIN');
$this->invokeHandler();
$this->em()->clear();
$found = $this->em()->find(Image::class, $imageId);
$this->assertNull($found, 'Image should be hard-deleted from the database');
$this->assertFalse(is_dir($dir), 'Image directory should be removed from filesystem');
$this->createdDirs = array_filter($this->createdDirs, fn($d) => $d !== $dir);
}
// CL-02: soft-deleted image with approved device → skipped, stays in DB
public function test_cl02_soft_deleted_with_approval_is_skipped(): void
{
$image = $this->makeImage('cl02@example.com');
$device = new Device();
$device->setMac('CC:DD:EE:FF:00:01');
$device->setName('Frame');
$device->setUser($image->getUser());
$this->em()->persist($device);
$image->approveForDevice($device);
$image->setDeletedAt(new \DateTimeImmutable('-1 day'));
$this->em()->flush();
$imageId = $image->getId();
$this->invokeHandler();
$this->em()->clear();
$found = $this->em()->find(Image::class, $imageId);
$this->assertNotNull($found, 'Image with active approvals should not be hard-deleted');
}
// CL-03: non-deleted image → not touched
public function test_cl03_non_deleted_image_is_not_touched(): void
{
$image = $this->makeImage('cl03@example.com');
$imageId = $image->getId();
$this->invokeHandler();
$this->em()->clear();
$found = $this->em()->find(Image::class, $imageId);
$this->assertNotNull($found, 'Non-deleted image should not be affected');
}
// CL-04: error during cleanup is caught and logged; no exception propagates
public function test_cl04_error_during_cleanup_is_caught(): void
{
$image = $this->makeImage('cl04@example.com');
$image->setDeletedAt(new \DateTimeImmutable('-1 day'));
$this->em()->flush();
$mockRepo = $this->createStub(ImageRepository::class);
$mockRepo->method('findSoftDeleted')->willReturn([$image]);
$mockEm = $this->createStub(EntityManagerInterface::class);
$mockEm->method('remove')->willThrowException(new \RuntimeException('simulated error'));
$logger = static::getContainer()->get(\Psr\Log\LoggerInterface::class);
$handler = new RunImageCleanupMessageHandler(
$mockRepo,
$mockEm,
$logger,
$this->projectDir,
);
$handler(new RunImageCleanupMessage());
$this->assertTrue(true, 'catch block executed — no exception propagated');
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use App\Schedule;
use App\Tests\AppKernelTestCase;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
class ScheduleTest extends AppKernelTestCase
{
public function test_schedule_returns_schedule_with_recurring_message(): void
{
$cache = static::getContainer()->get('cache.app');
$schedule = new Schedule($cache);
$result = $schedule->getSchedule();
$this->assertInstanceOf(SymfonySchedule::class, $result);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Entity;
use App\Entity\Device;
use App\Entity\DeviceImageHistory;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Entity\SharedImage;
use App\Entity\Token;
use App\Entity\User;
use App\Enum\Orientation;
use App\Enum\TokenType;
use PHPUnit\Framework\TestCase;
class EntityAccessorTest extends TestCase
{
public function test_device_image_history_accessors(): void
{
$user = (new User())->setEmail('u@x.com');
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$device = (new Device())->setMac('AA:BB:CC:DD:EE:FF')->setName('Frame');
$history = new DeviceImageHistory($device, $image);
$this->assertNull($history->getId());
$this->assertSame($device, $history->getDevice());
$this->assertSame($image, $history->getImage());
$this->assertInstanceOf(\DateTimeImmutable::class, $history->getServedAt());
}
public function test_rendered_asset_getId_setGetImage_setGetRenderedAt(): void
{
$asset = new RenderedAsset();
$this->assertNull($asset->getId());
$user = (new User())->setEmail('u@x.com');
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$asset->setImage($image);
$this->assertSame($image, $asset->getImage());
$this->assertNull($asset->getRenderedAt());
$dt = new \DateTimeImmutable('2025-01-01');
$asset->setRenderedAt($dt);
$this->assertSame($dt, $asset->getRenderedAt());
}
public function test_shared_image_getSharedBy_getSharedAt(): void
{
$sender = (new User())->setEmail('sender@x.com');
$recipient = (new User())->setEmail('recip@x.com');
$image = (new Image())->setUser($sender)->setOriginalFilename('x.jpg')->setStoragePath('x');
$shared = new SharedImage($image, $recipient, $sender);
$this->assertSame($sender, $shared->getSharedBy());
$this->assertInstanceOf(\DateTimeImmutable::class, $shared->getSharedAt());
}
public function test_token_getRecipientUser_getRecipientEmail_getExpiresAt_getUsedAt(): void
{
$user = (new User())->setEmail('u@x.com');
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
$token = new Token(TokenType::ShareApprove, $image, $user, 'recip@x.com', 7);
$this->assertSame($user, $token->getRecipientUser());
$this->assertSame('recip@x.com', $token->getRecipientEmail());
$this->assertInstanceOf(\DateTimeImmutable::class, $token->getExpiresAt());
$this->assertNull($token->getUsedAt());
$this->assertTrue($token->isValid());
}
public function test_user_setRoles_getRoles_getTheme_getTimezone_eraseCredentials_collections(): void
{
$user = new User();
$this->assertCount(0, $user->getDevices());
$this->assertCount(0, $user->getImages());
$roles = $user->getRoles();
$this->assertContains('ROLE_USER', $roles);
$user->setRoles(['ROLE_ADMIN']);
$this->assertContains('ROLE_ADMIN', $user->getRoles());
$this->assertContains('ROLE_USER', $user->getRoles());
$this->assertNull($user->getTheme());
$this->assertSame('UTC', $user->getTimezone());
$user->setPassword('secret');
$user->eraseCredentials();
$this->assertSame('secret', $user->getPassword());
}
public function test_orientation_isPortrait(): void
{
$this->assertTrue(Orientation::Portrait->isPortrait());
$this->assertFalse(Orientation::Landscape->isPortrait());
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Repository;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Tests\AppKernelTestCase;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
class UserRepositoryTest extends AppKernelTestCase
{
private UserRepository $repo;
protected function setUp(): void
{
parent::setUp();
$this->repo = static::getContainer()->get(UserRepository::class);
}
// UR-01: valid User → password updated in DB
public function test_upgrade_password_updates_password_for_valid_user(): void
{
$user = $this->createUser('ur01@example.com', 'oldpassword');
$userId = $user->getId();
$this->repo->upgradePassword($user, 'newhashed_password');
$this->em()->clear();
$reloaded = $this->em()->find(User::class, $userId);
$this->assertSame('newhashed_password', $reloaded->getPassword());
}
// UR-02: non-User PasswordAuthenticatedUserInterface → throws UnsupportedUserException
public function test_upgrade_password_throws_for_non_user(): void
{
$fakeUser = new class implements PasswordAuthenticatedUserInterface {
public function getPassword(): ?string { return null; }
public function getUserIdentifier(): string { return 'fake'; }
public function getRoles(): array { return []; }
public function eraseCredentials(): void {}
};
$this->expectException(UnsupportedUserException::class);
$this->repo->upgradePassword($fakeUser, 'newpassword');
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Entity\Device;
use App\Entity\DeviceImageHistory;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Service\DeviceService;
use App\Tests\AppKernelTestCase;
class DeviceServiceTest extends AppKernelTestCase
{
private DeviceService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = static::getContainer()->get(DeviceService::class);
}
public function test_link_creates_new_device_with_correct_mac_and_user(): void
{
$user = $this->createUser('link1@example.com');
$device = $this->service->linkToUser('aa:bb:cc:dd:ee:01', $user);
$this->assertNotNull($device->getId());
$this->assertSame('AA:BB:CC:DD:EE:01', $device->getMac());
$this->assertSame($user->getId(), $device->getUser()->getId());
}
public function test_link_is_idempotent_for_same_user(): void
{
$user = $this->createUser('link2@example.com');
$d1 = $this->service->linkToUser('aa:bb:cc:dd:ee:02', $user);
$d2 = $this->service->linkToUser('aa:bb:cc:dd:ee:02', $user);
$this->assertSame($d1->getId(), $d2->getId());
$this->assertSame($user->getId(), $d2->getUser()->getId());
}
public function test_link_purges_history_on_ownership_transfer(): void
{
$user1 = $this->createUser('owner1@example.com');
$user2 = $this->createUser('owner2@example.com');
// Create device owned by user1 with an image and history
$device = $this->service->linkToUser('aa:bb:cc:dd:ee:03', $user1);
$image = (new Image())->setUser($user1)->setOriginalFilename('x.jpg')->setStoragePath('x');
$image->approveForDevice($device);
$this->em()->persist($image);
$history = new DeviceImageHistory($device, $image);
$this->em()->persist($history);
$this->em()->flush();
// Transfer to user2
$this->service->linkToUser('aa:bb:cc:dd:ee:03', $user2);
// History should be gone
$count = $this->em()->createQueryBuilder()
->select('COUNT(h.id)')
->from(DeviceImageHistory::class, 'h')
->where('h.device = :device')
->setParameter('device', $device)
->getQuery()
->getSingleScalarResult();
$this->assertSame(0, (int) $count);
// Approval should be revoked
$this->em()->refresh($image);
$this->assertFalse($image->isApprovedForDevice($device));
}
}
+268
View File
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Entity\Device;
use App\Entity\DeviceImageHistory;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Service\RotationService;
use App\Tests\AppKernelTestCase;
/**
* Integration tests for RotationService (requires real DB + dama rollback).
*/
class RotationServiceTest extends AppKernelTestCase
{
private RotationService $rotation;
protected function setUp(): void
{
parent::setUp();
$this->rotation = static::getContainer()->get(RotationService::class);
}
/**
* Creates a User+Device approved for a ready Image with V1/Landscape asset.
* Returns [Device, Image].
*/
private function createDeviceWithReadyImage(
string $email = 'test@example.com',
RenderStatus $status = RenderStatus::Ready,
bool $approveForDevice = true,
?\DateTimeImmutable $uploadedAt = null,
): array {
$user = $this->createUser($email);
$device = new Device();
$device->setMac(strtoupper(bin2hex(random_bytes(3)) . ':' . bin2hex(random_bytes(3)) . ':AA'));
$device->setName('Test Device');
$device->setUser($user);
$device->setModel(DeviceModel::V1);
$device->setOrientation(Orientation::Landscape);
self::em()->persist($device);
$image = new Image();
$image->setUser($user);
$image->setOriginalFilename('test.jpg');
$image->setStoragePath('var/storage/images/test/original.jpg');
if ($uploadedAt !== null) {
// Use reflection to set uploadedAt since it's set in constructor
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
$ref->setAccessible(true);
$ref->setValue($image, $uploadedAt);
}
if ($approveForDevice) {
$image->approveForDevice($device);
}
self::em()->persist($image);
$asset = (new RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)
->setStatus($status)
->setFilePath('var/storage/images/test/v1_landscape.bin');
self::em()->persist($asset);
self::em()->flush();
return [$device, $image];
}
public function test_advance_returns_null_when_pool_empty(): void
{
$user = $this->createUser('empty@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:DD:EE:FF');
$device->setName('Empty Device');
$device->setUser($user);
self::em()->persist($device);
self::em()->flush();
$result = $this->rotation->advance($device);
$this->assertNull($result);
}
public function test_advance_picks_oldest_image(): void
{
$user = $this->createUser('oldest@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:DD:EE:01');
$device->setName('Device');
$device->setUser($user);
self::em()->persist($device);
$older = new Image();
$older->setUser($user)->setOriginalFilename('old.jpg')->setStoragePath('x');
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
$ref->setAccessible(true);
$ref->setValue($older, new \DateTimeImmutable('2024-01-01'));
$older->approveForDevice($device);
self::em()->persist($older);
$assetOld = (new RenderedAsset())->setImage($older)->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('x/old.bin');
self::em()->persist($assetOld);
$newer = new Image();
$newer->setUser($user)->setOriginalFilename('new.jpg')->setStoragePath('y');
$ref->setValue($newer, new \DateTimeImmutable('2024-06-01'));
$newer->approveForDevice($device);
self::em()->persist($newer);
$assetNew = (new RenderedAsset())->setImage($newer)->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('y/new.bin');
self::em()->persist($assetNew);
self::em()->flush();
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame($older->getId(), $result->getId());
}
public function test_advance_skips_recently_shown(): void
{
[$device, $imageA] = $this->createDeviceWithReadyImage('skip@example.com');
$imageB = new Image();
$imageB->setUser($device->getUser())->setOriginalFilename('b.jpg')->setStoragePath('b');
$imageB->approveForDevice($device);
self::em()->persist($imageB);
$assetB = (new RenderedAsset())->setImage($imageB)->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('b.bin');
self::em()->persist($assetB);
// Put imageA in history
$history = new DeviceImageHistory($device, $imageA);
self::em()->persist($history);
$device->setUniquenessWindow(2);
self::em()->flush();
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame($imageB->getId(), $result->getId());
}
public function test_advance_resets_when_all_in_window(): void
{
[$device, $imageA] = $this->createDeviceWithReadyImage('reset@example.com');
$imageB = new Image();
$imageB->setUser($device->getUser())->setOriginalFilename('b.jpg')->setStoragePath('b');
$imageB->approveForDevice($device);
self::em()->persist($imageB);
$assetB = (new RenderedAsset())->setImage($imageB)->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('b.bin');
self::em()->persist($assetB);
$histA = new DeviceImageHistory($device, $imageA);
$histB = new DeviceImageHistory($device, $imageB);
self::em()->persist($histA);
self::em()->persist($histB);
$device->setUniquenessWindow(2);
self::em()->flush();
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
}
public function test_advance_respects_uniqueness_window(): void
{
$user = $this->createUser('window@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:DD:EE:02');
$device->setName('Window Device');
$device->setUser($user);
$device->setUniquenessWindow(1);
self::em()->persist($device);
$images = [];
for ($i = 0; $i < 3; $i++) {
$img = new Image();
$img->setUser($user)->setOriginalFilename("img{$i}.jpg")->setStoragePath("p{$i}");
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
$ref->setAccessible(true);
$ref->setValue($img, new \DateTimeImmutable("2024-01-0" . ($i + 1)));
$img->approveForDevice($device);
self::em()->persist($img);
$asset = (new RenderedAsset())->setImage($img)->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath("p{$i}.bin");
self::em()->persist($asset);
$images[] = $img;
}
// Put image[0] in history (most recent)
$hist = new DeviceImageHistory($device, $images[0]);
self::em()->persist($hist);
self::em()->flush();
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertNotSame($images[0]->getId(), $result->getId());
}
public function test_advance_persists_history(): void
{
[$device] = $this->createDeviceWithReadyImage('history@example.com');
$this->rotation->advance($device);
$count = self::em()->createQueryBuilder()
->select('COUNT(h.id)')
->from(DeviceImageHistory::class, 'h')
->where('h.device = :device')
->setParameter('device', $device)
->getQuery()
->getSingleScalarResult();
$this->assertSame(1, (int) $count);
}
public function test_advance_updates_current_image(): void
{
[$device, $image] = $this->createDeviceWithReadyImage('current@example.com');
$this->rotation->advance($device);
self::em()->refresh($device);
$this->assertNotNull($device->getCurrentImage());
$this->assertSame($image->getId(), $device->getCurrentImage()->getId());
}
public function test_advance_excludes_pending_asset(): void
{
[$device, ] = $this->createDeviceWithReadyImage('pending@example.com', RenderStatus::Pending);
$result = $this->rotation->advance($device);
$this->assertNull($result);
}
public function test_advance_excludes_unapproved_image(): void
{
[$device, ] = $this->createDeviceWithReadyImage('unapproved@example.com', RenderStatus::Ready, false);
$result = $this->rotation->advance($device);
$this->assertNull($result);
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Entity\Image;
use App\Entity\Token;
use App\Entity\User;
use App\Enum\TokenType;
use App\Repository\TokenRepository;
use App\Service\TokenService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class TokenServiceTest extends TestCase
{
private function makeService(): array
{
$repo = $this->createStub(TokenRepository::class);
$em = $this->createStub(EntityManagerInterface::class);
$service = new TokenService($repo, $em);
return [$service, $repo, $em];
}
private function makeServiceWithMockEm(): array
{
$repo = $this->createStub(TokenRepository::class);
$em = $this->createMock(EntityManagerInterface::class);
$service = new TokenService($repo, $em);
return [$service, $repo, $em];
}
private function makeImage(): Image
{
$user = new User();
$image = new Image();
$image->setUser($user)->setOriginalFilename('test.jpg')->setStoragePath('x');
return $image;
}
public function test_issue_returns_token_with_correct_type(): void
{
[$service, , $em] = $this->makeServiceWithMockEm();
$em->expects($this->once())->method('persist');
$token = $service->issue(TokenType::ShareApprove, $this->makeImage(), null, 'a@b.com', 7);
$this->assertSame(TokenType::ShareApprove, $token->getType());
}
public function test_issue_expiry_is_in_the_future(): void
{
[$service] = $this->makeService();
$token = $service->issue(TokenType::ShareApprove, $this->makeImage(), null, null, 7);
$this->assertGreaterThan(new \DateTimeImmutable(), $token->getExpiresAt());
}
public function test_issue_calls_em_persist(): void
{
[$service, , $em] = $this->makeServiceWithMockEm();
$em->expects($this->once())->method('persist')->with($this->isInstanceOf(Token::class));
$service->issue(TokenType::ShareApprove, $this->makeImage(), null, 'a@b.com', 7);
}
public function test_consume_calls_token_consume(): void
{
$repo = $this->createStub(TokenRepository::class);
$em = $this->createMock(EntityManagerInterface::class);
$service = new TokenService($repo, $em);
/** @var Token&MockObject $token */
$token = $this->createMock(Token::class);
$token->expects($this->once())->method('consume');
$em->expects($this->once())->method('flush');
$repo->method('findValidToken')->willReturn($token);
$service->consume('some-uuid', TokenType::ShareApprove);
}
public function test_consume_calls_em_flush(): void
{
$repo = $this->createStub(TokenRepository::class);
$em = $this->createMock(EntityManagerInterface::class);
$service = new TokenService($repo, $em);
$token = $this->createStub(Token::class);
$em->expects($this->once())->method('flush');
$repo->method('findValidToken')->willReturn($token);
$service->consume('some-uuid', TokenType::ShareApprove);
}
public function test_consume_returns_the_token(): void
{
$repo = $this->createStub(TokenRepository::class);
$em = $this->createStub(EntityManagerInterface::class);
$service = new TokenService($repo, $em);
$token = $this->createStub(Token::class);
$repo->method('findValidToken')->willReturn($token);
$result = $service->consume('some-uuid', TokenType::ShareApprove);
$this->assertSame($token, $result);
}
public function test_consume_throws_when_token_not_found(): void
{
[$service, $repo] = $this->makeService();
$repo->method('findValidToken')->willReturn(null);
$this->expectException(\RuntimeException::class);
$service->consume('invalid-uuid', TokenType::ShareApprove);
}
/** T-05: expired token — repo already excludes it, returns null → RuntimeException */
public function test_consume_throws_for_expired_token(): void
{
[$service, $repo] = $this->makeService();
$repo->method('findValidToken')->willReturn(null);
$this->expectException(\RuntimeException::class);
$service->consume('expired-uuid', TokenType::ShareApprove);
}
/** T-06: already-used token — repo excludes usedAt IS NOT NULL, returns null → RuntimeException */
public function test_consume_throws_for_already_used_token(): void
{
[$service, $repo] = $this->makeService();
$repo->method('findValidToken')->willReturn(null);
$this->expectException(\RuntimeException::class);
$service->consume('used-uuid', TokenType::ShareApprove);
}
/** T-07: type mismatch — repo WHERE clause filters type, returns null → RuntimeException */
public function test_consume_throws_for_type_mismatch(): void
{
[$service, $repo] = $this->makeService();
$repo->method('findValidToken')->willReturn(null);
$this->expectException(\RuntimeException::class);
// UUID issued as ShareDecline but consumed as ShareApprove
$service->consume('some-uuid', TokenType::ShareApprove);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B