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,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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user