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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 332 B |
Reference in New Issue
Block a user