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,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Entity;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\DeviceImageHistory;
|
||||
use App\Entity\Image;
|
||||
use App\Entity\RenderedAsset;
|
||||
use App\Entity\SharedImage;
|
||||
use App\Entity\Token;
|
||||
use App\Entity\User;
|
||||
use App\Enum\Orientation;
|
||||
use App\Enum\TokenType;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class EntityAccessorTest extends TestCase
|
||||
{
|
||||
public function test_device_image_history_accessors(): void
|
||||
{
|
||||
$user = (new User())->setEmail('u@x.com');
|
||||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||||
$device = (new Device())->setMac('AA:BB:CC:DD:EE:FF')->setName('Frame');
|
||||
|
||||
$history = new DeviceImageHistory($device, $image);
|
||||
|
||||
$this->assertNull($history->getId());
|
||||
$this->assertSame($device, $history->getDevice());
|
||||
$this->assertSame($image, $history->getImage());
|
||||
$this->assertInstanceOf(\DateTimeImmutable::class, $history->getServedAt());
|
||||
}
|
||||
|
||||
public function test_rendered_asset_getId_setGetImage_setGetRenderedAt(): void
|
||||
{
|
||||
$asset = new RenderedAsset();
|
||||
$this->assertNull($asset->getId());
|
||||
|
||||
$user = (new User())->setEmail('u@x.com');
|
||||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||||
$asset->setImage($image);
|
||||
$this->assertSame($image, $asset->getImage());
|
||||
|
||||
$this->assertNull($asset->getRenderedAt());
|
||||
$dt = new \DateTimeImmutable('2025-01-01');
|
||||
$asset->setRenderedAt($dt);
|
||||
$this->assertSame($dt, $asset->getRenderedAt());
|
||||
}
|
||||
|
||||
public function test_shared_image_getSharedBy_getSharedAt(): void
|
||||
{
|
||||
$sender = (new User())->setEmail('sender@x.com');
|
||||
$recipient = (new User())->setEmail('recip@x.com');
|
||||
$image = (new Image())->setUser($sender)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||||
$shared = new SharedImage($image, $recipient, $sender);
|
||||
|
||||
$this->assertSame($sender, $shared->getSharedBy());
|
||||
$this->assertInstanceOf(\DateTimeImmutable::class, $shared->getSharedAt());
|
||||
}
|
||||
|
||||
public function test_token_getRecipientUser_getRecipientEmail_getExpiresAt_getUsedAt(): void
|
||||
{
|
||||
$user = (new User())->setEmail('u@x.com');
|
||||
$image = (new Image())->setUser($user)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||||
$token = new Token(TokenType::ShareApprove, $image, $user, 'recip@x.com', 7);
|
||||
|
||||
$this->assertSame($user, $token->getRecipientUser());
|
||||
$this->assertSame('recip@x.com', $token->getRecipientEmail());
|
||||
$this->assertInstanceOf(\DateTimeImmutable::class, $token->getExpiresAt());
|
||||
$this->assertNull($token->getUsedAt());
|
||||
$this->assertTrue($token->isValid());
|
||||
}
|
||||
|
||||
public function test_user_setRoles_getRoles_getTheme_getTimezone_eraseCredentials_collections(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
$this->assertCount(0, $user->getDevices());
|
||||
$this->assertCount(0, $user->getImages());
|
||||
|
||||
$roles = $user->getRoles();
|
||||
$this->assertContains('ROLE_USER', $roles);
|
||||
|
||||
$user->setRoles(['ROLE_ADMIN']);
|
||||
$this->assertContains('ROLE_ADMIN', $user->getRoles());
|
||||
$this->assertContains('ROLE_USER', $user->getRoles());
|
||||
|
||||
$this->assertNull($user->getTheme());
|
||||
$this->assertSame('UTC', $user->getTimezone());
|
||||
|
||||
$user->setPassword('secret');
|
||||
$user->eraseCredentials();
|
||||
$this->assertSame('secret', $user->getPassword());
|
||||
}
|
||||
|
||||
public function test_orientation_isPortrait(): void
|
||||
{
|
||||
$this->assertTrue(Orientation::Portrait->isPortrait());
|
||||
$this->assertFalse(Orientation::Landscape->isPortrait());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Repository;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Tests\AppKernelTestCase;
|
||||
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
|
||||
class UserRepositoryTest extends AppKernelTestCase
|
||||
{
|
||||
private UserRepository $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->repo = static::getContainer()->get(UserRepository::class);
|
||||
}
|
||||
|
||||
// UR-01: valid User → password updated in DB
|
||||
public function test_upgrade_password_updates_password_for_valid_user(): void
|
||||
{
|
||||
$user = $this->createUser('ur01@example.com', 'oldpassword');
|
||||
$userId = $user->getId();
|
||||
|
||||
$this->repo->upgradePassword($user, 'newhashed_password');
|
||||
|
||||
$this->em()->clear();
|
||||
$reloaded = $this->em()->find(User::class, $userId);
|
||||
$this->assertSame('newhashed_password', $reloaded->getPassword());
|
||||
}
|
||||
|
||||
// UR-02: non-User PasswordAuthenticatedUserInterface → throws UnsupportedUserException
|
||||
public function test_upgrade_password_throws_for_non_user(): void
|
||||
{
|
||||
$fakeUser = new class implements PasswordAuthenticatedUserInterface {
|
||||
public function getPassword(): ?string { return null; }
|
||||
public function getUserIdentifier(): string { return 'fake'; }
|
||||
public function getRoles(): array { return []; }
|
||||
public function eraseCredentials(): void {}
|
||||
};
|
||||
|
||||
$this->expectException(UnsupportedUserException::class);
|
||||
$this->repo->upgradePassword($fakeUser, 'newpassword');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\DeviceImageHistory;
|
||||
use App\Entity\Image;
|
||||
use App\Entity\RenderedAsset;
|
||||
use App\Enum\DeviceModel;
|
||||
use App\Enum\Orientation;
|
||||
use App\Enum\RenderStatus;
|
||||
use App\Service\DeviceService;
|
||||
use App\Tests\AppKernelTestCase;
|
||||
|
||||
class DeviceServiceTest extends AppKernelTestCase
|
||||
{
|
||||
private DeviceService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = static::getContainer()->get(DeviceService::class);
|
||||
}
|
||||
|
||||
public function test_link_creates_new_device_with_correct_mac_and_user(): void
|
||||
{
|
||||
$user = $this->createUser('link1@example.com');
|
||||
$device = $this->service->linkToUser('aa:bb:cc:dd:ee:01', $user);
|
||||
|
||||
$this->assertNotNull($device->getId());
|
||||
$this->assertSame('AA:BB:CC:DD:EE:01', $device->getMac());
|
||||
$this->assertSame($user->getId(), $device->getUser()->getId());
|
||||
}
|
||||
|
||||
public function test_link_is_idempotent_for_same_user(): void
|
||||
{
|
||||
$user = $this->createUser('link2@example.com');
|
||||
|
||||
$d1 = $this->service->linkToUser('aa:bb:cc:dd:ee:02', $user);
|
||||
$d2 = $this->service->linkToUser('aa:bb:cc:dd:ee:02', $user);
|
||||
|
||||
$this->assertSame($d1->getId(), $d2->getId());
|
||||
$this->assertSame($user->getId(), $d2->getUser()->getId());
|
||||
}
|
||||
|
||||
public function test_link_purges_history_on_ownership_transfer(): void
|
||||
{
|
||||
$user1 = $this->createUser('owner1@example.com');
|
||||
$user2 = $this->createUser('owner2@example.com');
|
||||
|
||||
// Create device owned by user1 with an image and history
|
||||
$device = $this->service->linkToUser('aa:bb:cc:dd:ee:03', $user1);
|
||||
|
||||
$image = (new Image())->setUser($user1)->setOriginalFilename('x.jpg')->setStoragePath('x');
|
||||
$image->approveForDevice($device);
|
||||
$this->em()->persist($image);
|
||||
|
||||
$history = new DeviceImageHistory($device, $image);
|
||||
$this->em()->persist($history);
|
||||
$this->em()->flush();
|
||||
|
||||
// Transfer to user2
|
||||
$this->service->linkToUser('aa:bb:cc:dd:ee:03', $user2);
|
||||
|
||||
// History should be gone
|
||||
$count = $this->em()->createQueryBuilder()
|
||||
->select('COUNT(h.id)')
|
||||
->from(DeviceImageHistory::class, 'h')
|
||||
->where('h.device = :device')
|
||||
->setParameter('device', $device)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$this->assertSame(0, (int) $count);
|
||||
|
||||
// Approval should be revoked
|
||||
$this->em()->refresh($image);
|
||||
$this->assertFalse($image->isApprovedForDevice($device));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\DeviceImageHistory;
|
||||
use App\Entity\Image;
|
||||
use App\Entity\RenderedAsset;
|
||||
use App\Enum\DeviceModel;
|
||||
use App\Enum\Orientation;
|
||||
use App\Enum\RenderStatus;
|
||||
use App\Service\RotationService;
|
||||
use App\Tests\AppKernelTestCase;
|
||||
|
||||
/**
|
||||
* Integration tests for RotationService (requires real DB + dama rollback).
|
||||
*/
|
||||
class RotationServiceTest extends AppKernelTestCase
|
||||
{
|
||||
private RotationService $rotation;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->rotation = static::getContainer()->get(RotationService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a User+Device approved for a ready Image with V1/Landscape asset.
|
||||
* Returns [Device, Image].
|
||||
*/
|
||||
private function createDeviceWithReadyImage(
|
||||
string $email = 'test@example.com',
|
||||
RenderStatus $status = RenderStatus::Ready,
|
||||
bool $approveForDevice = true,
|
||||
?\DateTimeImmutable $uploadedAt = null,
|
||||
): array {
|
||||
$user = $this->createUser($email);
|
||||
|
||||
$device = new Device();
|
||||
$device->setMac(strtoupper(bin2hex(random_bytes(3)) . ':' . bin2hex(random_bytes(3)) . ':AA'));
|
||||
$device->setName('Test Device');
|
||||
$device->setUser($user);
|
||||
$device->setModel(DeviceModel::V1);
|
||||
$device->setOrientation(Orientation::Landscape);
|
||||
self::em()->persist($device);
|
||||
|
||||
$image = new Image();
|
||||
$image->setUser($user);
|
||||
$image->setOriginalFilename('test.jpg');
|
||||
$image->setStoragePath('var/storage/images/test/original.jpg');
|
||||
if ($uploadedAt !== null) {
|
||||
// Use reflection to set uploadedAt since it's set in constructor
|
||||
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($image, $uploadedAt);
|
||||
}
|
||||
if ($approveForDevice) {
|
||||
$image->approveForDevice($device);
|
||||
}
|
||||
self::em()->persist($image);
|
||||
|
||||
$asset = (new RenderedAsset())
|
||||
->setImage($image)
|
||||
->setDeviceModel(DeviceModel::V1)
|
||||
->setOrientation(Orientation::Landscape)
|
||||
->setStatus($status)
|
||||
->setFilePath('var/storage/images/test/v1_landscape.bin');
|
||||
self::em()->persist($asset);
|
||||
|
||||
self::em()->flush();
|
||||
|
||||
return [$device, $image];
|
||||
}
|
||||
|
||||
public function test_advance_returns_null_when_pool_empty(): void
|
||||
{
|
||||
$user = $this->createUser('empty@example.com');
|
||||
$device = new Device();
|
||||
$device->setMac('AA:BB:CC:DD:EE:FF');
|
||||
$device->setName('Empty Device');
|
||||
$device->setUser($user);
|
||||
self::em()->persist($device);
|
||||
self::em()->flush();
|
||||
|
||||
$result = $this->rotation->advance($device);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function test_advance_picks_oldest_image(): void
|
||||
{
|
||||
$user = $this->createUser('oldest@example.com');
|
||||
|
||||
$device = new Device();
|
||||
$device->setMac('AA:BB:CC:DD:EE:01');
|
||||
$device->setName('Device');
|
||||
$device->setUser($user);
|
||||
self::em()->persist($device);
|
||||
|
||||
$older = new Image();
|
||||
$older->setUser($user)->setOriginalFilename('old.jpg')->setStoragePath('x');
|
||||
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($older, new \DateTimeImmutable('2024-01-01'));
|
||||
$older->approveForDevice($device);
|
||||
self::em()->persist($older);
|
||||
|
||||
$assetOld = (new RenderedAsset())->setImage($older)->setDeviceModel(DeviceModel::V1)
|
||||
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('x/old.bin');
|
||||
self::em()->persist($assetOld);
|
||||
|
||||
$newer = new Image();
|
||||
$newer->setUser($user)->setOriginalFilename('new.jpg')->setStoragePath('y');
|
||||
$ref->setValue($newer, new \DateTimeImmutable('2024-06-01'));
|
||||
$newer->approveForDevice($device);
|
||||
self::em()->persist($newer);
|
||||
|
||||
$assetNew = (new RenderedAsset())->setImage($newer)->setDeviceModel(DeviceModel::V1)
|
||||
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('y/new.bin');
|
||||
self::em()->persist($assetNew);
|
||||
|
||||
self::em()->flush();
|
||||
|
||||
$result = $this->rotation->advance($device);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame($older->getId(), $result->getId());
|
||||
}
|
||||
|
||||
public function test_advance_skips_recently_shown(): void
|
||||
{
|
||||
[$device, $imageA] = $this->createDeviceWithReadyImage('skip@example.com');
|
||||
|
||||
$imageB = new Image();
|
||||
$imageB->setUser($device->getUser())->setOriginalFilename('b.jpg')->setStoragePath('b');
|
||||
$imageB->approveForDevice($device);
|
||||
self::em()->persist($imageB);
|
||||
|
||||
$assetB = (new RenderedAsset())->setImage($imageB)->setDeviceModel(DeviceModel::V1)
|
||||
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('b.bin');
|
||||
self::em()->persist($assetB);
|
||||
|
||||
// Put imageA in history
|
||||
$history = new DeviceImageHistory($device, $imageA);
|
||||
self::em()->persist($history);
|
||||
|
||||
$device->setUniquenessWindow(2);
|
||||
self::em()->flush();
|
||||
|
||||
$result = $this->rotation->advance($device);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertSame($imageB->getId(), $result->getId());
|
||||
}
|
||||
|
||||
public function test_advance_resets_when_all_in_window(): void
|
||||
{
|
||||
[$device, $imageA] = $this->createDeviceWithReadyImage('reset@example.com');
|
||||
|
||||
$imageB = new Image();
|
||||
$imageB->setUser($device->getUser())->setOriginalFilename('b.jpg')->setStoragePath('b');
|
||||
$imageB->approveForDevice($device);
|
||||
self::em()->persist($imageB);
|
||||
|
||||
$assetB = (new RenderedAsset())->setImage($imageB)->setDeviceModel(DeviceModel::V1)
|
||||
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('b.bin');
|
||||
self::em()->persist($assetB);
|
||||
|
||||
$histA = new DeviceImageHistory($device, $imageA);
|
||||
$histB = new DeviceImageHistory($device, $imageB);
|
||||
self::em()->persist($histA);
|
||||
self::em()->persist($histB);
|
||||
|
||||
$device->setUniquenessWindow(2);
|
||||
self::em()->flush();
|
||||
|
||||
$result = $this->rotation->advance($device);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
}
|
||||
|
||||
public function test_advance_respects_uniqueness_window(): void
|
||||
{
|
||||
$user = $this->createUser('window@example.com');
|
||||
|
||||
$device = new Device();
|
||||
$device->setMac('AA:BB:CC:DD:EE:02');
|
||||
$device->setName('Window Device');
|
||||
$device->setUser($user);
|
||||
$device->setUniquenessWindow(1);
|
||||
self::em()->persist($device);
|
||||
|
||||
$images = [];
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$img = new Image();
|
||||
$img->setUser($user)->setOriginalFilename("img{$i}.jpg")->setStoragePath("p{$i}");
|
||||
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($img, new \DateTimeImmutable("2024-01-0" . ($i + 1)));
|
||||
$img->approveForDevice($device);
|
||||
self::em()->persist($img);
|
||||
$asset = (new RenderedAsset())->setImage($img)->setDeviceModel(DeviceModel::V1)
|
||||
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath("p{$i}.bin");
|
||||
self::em()->persist($asset);
|
||||
$images[] = $img;
|
||||
}
|
||||
|
||||
// Put image[0] in history (most recent)
|
||||
$hist = new DeviceImageHistory($device, $images[0]);
|
||||
self::em()->persist($hist);
|
||||
self::em()->flush();
|
||||
|
||||
$result = $this->rotation->advance($device);
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertNotSame($images[0]->getId(), $result->getId());
|
||||
}
|
||||
|
||||
public function test_advance_persists_history(): void
|
||||
{
|
||||
[$device] = $this->createDeviceWithReadyImage('history@example.com');
|
||||
|
||||
$this->rotation->advance($device);
|
||||
|
||||
$count = self::em()->createQueryBuilder()
|
||||
->select('COUNT(h.id)')
|
||||
->from(DeviceImageHistory::class, 'h')
|
||||
->where('h.device = :device')
|
||||
->setParameter('device', $device)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$this->assertSame(1, (int) $count);
|
||||
}
|
||||
|
||||
public function test_advance_updates_current_image(): void
|
||||
{
|
||||
[$device, $image] = $this->createDeviceWithReadyImage('current@example.com');
|
||||
|
||||
$this->rotation->advance($device);
|
||||
|
||||
self::em()->refresh($device);
|
||||
|
||||
$this->assertNotNull($device->getCurrentImage());
|
||||
$this->assertSame($image->getId(), $device->getCurrentImage()->getId());
|
||||
}
|
||||
|
||||
public function test_advance_excludes_pending_asset(): void
|
||||
{
|
||||
[$device, ] = $this->createDeviceWithReadyImage('pending@example.com', RenderStatus::Pending);
|
||||
|
||||
$result = $this->rotation->advance($device);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function test_advance_excludes_unapproved_image(): void
|
||||
{
|
||||
[$device, ] = $this->createDeviceWithReadyImage('unapproved@example.com', RenderStatus::Ready, false);
|
||||
|
||||
$result = $this->rotation->advance($device);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Entity\Image;
|
||||
use App\Entity\Token;
|
||||
use App\Entity\User;
|
||||
use App\Enum\TokenType;
|
||||
use App\Repository\TokenRepository;
|
||||
use App\Service\TokenService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class TokenServiceTest extends TestCase
|
||||
{
|
||||
private function makeService(): array
|
||||
{
|
||||
$repo = $this->createStub(TokenRepository::class);
|
||||
$em = $this->createStub(EntityManagerInterface::class);
|
||||
$service = new TokenService($repo, $em);
|
||||
return [$service, $repo, $em];
|
||||
}
|
||||
|
||||
private function makeServiceWithMockEm(): array
|
||||
{
|
||||
$repo = $this->createStub(TokenRepository::class);
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$service = new TokenService($repo, $em);
|
||||
return [$service, $repo, $em];
|
||||
}
|
||||
|
||||
private function makeImage(): Image
|
||||
{
|
||||
$user = new User();
|
||||
$image = new Image();
|
||||
$image->setUser($user)->setOriginalFilename('test.jpg')->setStoragePath('x');
|
||||
return $image;
|
||||
}
|
||||
|
||||
public function test_issue_returns_token_with_correct_type(): void
|
||||
{
|
||||
[$service, , $em] = $this->makeServiceWithMockEm();
|
||||
$em->expects($this->once())->method('persist');
|
||||
|
||||
$token = $service->issue(TokenType::ShareApprove, $this->makeImage(), null, 'a@b.com', 7);
|
||||
|
||||
$this->assertSame(TokenType::ShareApprove, $token->getType());
|
||||
}
|
||||
|
||||
public function test_issue_expiry_is_in_the_future(): void
|
||||
{
|
||||
[$service] = $this->makeService();
|
||||
|
||||
$token = $service->issue(TokenType::ShareApprove, $this->makeImage(), null, null, 7);
|
||||
|
||||
$this->assertGreaterThan(new \DateTimeImmutable(), $token->getExpiresAt());
|
||||
}
|
||||
|
||||
public function test_issue_calls_em_persist(): void
|
||||
{
|
||||
[$service, , $em] = $this->makeServiceWithMockEm();
|
||||
$em->expects($this->once())->method('persist')->with($this->isInstanceOf(Token::class));
|
||||
|
||||
$service->issue(TokenType::ShareApprove, $this->makeImage(), null, 'a@b.com', 7);
|
||||
}
|
||||
|
||||
public function test_consume_calls_token_consume(): void
|
||||
{
|
||||
$repo = $this->createStub(TokenRepository::class);
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$service = new TokenService($repo, $em);
|
||||
|
||||
/** @var Token&MockObject $token */
|
||||
$token = $this->createMock(Token::class);
|
||||
$token->expects($this->once())->method('consume');
|
||||
$em->expects($this->once())->method('flush');
|
||||
|
||||
$repo->method('findValidToken')->willReturn($token);
|
||||
|
||||
$service->consume('some-uuid', TokenType::ShareApprove);
|
||||
}
|
||||
|
||||
public function test_consume_calls_em_flush(): void
|
||||
{
|
||||
$repo = $this->createStub(TokenRepository::class);
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$service = new TokenService($repo, $em);
|
||||
|
||||
$token = $this->createStub(Token::class);
|
||||
$em->expects($this->once())->method('flush');
|
||||
|
||||
$repo->method('findValidToken')->willReturn($token);
|
||||
|
||||
$service->consume('some-uuid', TokenType::ShareApprove);
|
||||
}
|
||||
|
||||
public function test_consume_returns_the_token(): void
|
||||
{
|
||||
$repo = $this->createStub(TokenRepository::class);
|
||||
$em = $this->createStub(EntityManagerInterface::class);
|
||||
$service = new TokenService($repo, $em);
|
||||
|
||||
$token = $this->createStub(Token::class);
|
||||
$repo->method('findValidToken')->willReturn($token);
|
||||
|
||||
$result = $service->consume('some-uuid', TokenType::ShareApprove);
|
||||
|
||||
$this->assertSame($token, $result);
|
||||
}
|
||||
|
||||
public function test_consume_throws_when_token_not_found(): void
|
||||
{
|
||||
[$service, $repo] = $this->makeService();
|
||||
$repo->method('findValidToken')->willReturn(null);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$service->consume('invalid-uuid', TokenType::ShareApprove);
|
||||
}
|
||||
|
||||
/** T-05: expired token — repo already excludes it, returns null → RuntimeException */
|
||||
public function test_consume_throws_for_expired_token(): void
|
||||
{
|
||||
[$service, $repo] = $this->makeService();
|
||||
$repo->method('findValidToken')->willReturn(null);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$service->consume('expired-uuid', TokenType::ShareApprove);
|
||||
}
|
||||
|
||||
/** T-06: already-used token — repo excludes usedAt IS NOT NULL, returns null → RuntimeException */
|
||||
public function test_consume_throws_for_already_used_token(): void
|
||||
{
|
||||
[$service, $repo] = $this->makeService();
|
||||
$repo->method('findValidToken')->willReturn(null);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$service->consume('used-uuid', TokenType::ShareApprove);
|
||||
}
|
||||
|
||||
/** T-07: type mismatch — repo WHERE clause filters type, returns null → RuntimeException */
|
||||
public function test_consume_throws_for_type_mismatch(): void
|
||||
{
|
||||
[$service, $repo] = $this->makeService();
|
||||
$repo->method('findValidToken')->willReturn(null);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
// UUID issued as ShareDecline but consumed as ShareApprove
|
||||
$service->consume('some-uuid', TokenType::ShareApprove);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user