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