feat: Liaison parents-enfants avec gestion des tuteurs
Les parents doivent pouvoir suivre la scolarité de leurs enfants (notes, emploi du temps, devoirs). Cela nécessite un lien formalisé entre le compte parent et le compte élève, géré par les administrateurs. Le lien est établi soit manuellement via l'interface d'administration, soit automatiquement lors de l'activation du compte parent lorsque l'invitation inclut un élève cible. Ce lien conditionne l'accès aux données scolaires de l'enfant (autorisations vérifiées par un voter dédié).
This commit is contained in:
@@ -6,20 +6,26 @@ namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\ActivateAccount\ActivateAccountHandler;
|
||||
use App\Administration\Application\Command\LinkParentToStudent\LinkParentToStudentHandler;
|
||||
use App\Administration\Application\Port\PasswordHasher;
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\ActivationToken\ActivationToken;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\Api\Processor\ActivateAccountProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\ActivateAccountInput;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryStudentGuardianRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
@@ -154,21 +160,16 @@ final class ActivateAccountProcessorTest extends TestCase
|
||||
|
||||
// UserRepository that always throws UserNotFoundException
|
||||
$userRepository = new class implements UserRepository {
|
||||
public function save(\App\Administration\Domain\Model\User\User $user): void
|
||||
public function save(User $user): void
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(UserId $id): ?\App\Administration\Domain\Model\User\User
|
||||
public function findByEmail(Email $email, TenantId $tenantId): ?User
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function findByEmail(\App\Administration\Domain\Model\User\Email $email, TenantId $tenantId): ?\App\Administration\Domain\Model\User\User
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function get(UserId $id): \App\Administration\Domain\Model\User\User
|
||||
public function get(UserId $id): User
|
||||
{
|
||||
throw UserNotFoundException::withId($id);
|
||||
}
|
||||
@@ -183,6 +184,12 @@ final class ActivateAccountProcessorTest extends TestCase
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
|
||||
$linkHandler = new LinkParentToStudentHandler(
|
||||
new InMemoryStudentGuardianRepository(),
|
||||
$userRepository,
|
||||
$this->clock,
|
||||
);
|
||||
|
||||
return new ActivateAccountProcessor(
|
||||
$handler,
|
||||
$userRepository,
|
||||
@@ -190,6 +197,8 @@ final class ActivateAccountProcessorTest extends TestCase
|
||||
$consentementPolicy,
|
||||
$this->clock,
|
||||
$eventBus,
|
||||
$linkHandler,
|
||||
new NullLogger(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\BlockUser\BlockUserHandler;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Administration\Infrastructure\Api\Processor\BlockUserProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\UserResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Administration\Infrastructure\Security\UserVoter;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class BlockUserProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private TenantContext $tenantContext;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-10 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://test',
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function blocksUserSuccessfully(): void
|
||||
{
|
||||
$user = $this->createActiveUser();
|
||||
$processor = $this->createProcessor(adminUserId: (string) UserId::generate());
|
||||
|
||||
$data = new UserResource();
|
||||
$data->reason = 'Comportement inapproprié';
|
||||
|
||||
$result = $processor->process($data, new Post(), ['id' => (string) $user->id]);
|
||||
|
||||
self::assertSame(StatutCompte::SUSPENDU->value, $result->statut);
|
||||
self::assertSame('Comportement inapproprié', $result->blockedReason);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenNotAuthorized(): void
|
||||
{
|
||||
$processor = $this->createProcessor(authorized: false);
|
||||
|
||||
$data = new UserResource();
|
||||
$data->reason = 'Some reason';
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['id' => (string) UserId::generate()]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenTenantNotSet(): void
|
||||
{
|
||||
$emptyTenantContext = new TenantContext();
|
||||
$processor = $this->createProcessor(tenantContext: $emptyTenantContext);
|
||||
|
||||
$data = new UserResource();
|
||||
$data->reason = 'Some reason';
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['id' => (string) UserId::generate()]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenBlockingOwnAccount(): void
|
||||
{
|
||||
$adminId = UserId::generate();
|
||||
$processor = $this->createProcessor(adminUserId: (string) $adminId);
|
||||
|
||||
$data = new UserResource();
|
||||
$data->reason = 'Self-block attempt';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('propre compte');
|
||||
|
||||
$processor->process($data, new Post(), ['id' => (string) $adminId]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenReasonIsEmpty(): void
|
||||
{
|
||||
$processor = $this->createProcessor(adminUserId: (string) UserId::generate());
|
||||
|
||||
$data = new UserResource();
|
||||
$data->reason = '';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('raison du blocage est obligatoire');
|
||||
|
||||
$processor->process($data, new Post(), ['id' => (string) UserId::generate()]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenReasonIsOnlyWhitespace(): void
|
||||
{
|
||||
$processor = $this->createProcessor(adminUserId: (string) UserId::generate());
|
||||
|
||||
$data = new UserResource();
|
||||
$data->reason = ' ';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('raison du blocage est obligatoire');
|
||||
|
||||
$processor->process($data, new Post(), ['id' => (string) UserId::generate()]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenUserNotFound(): void
|
||||
{
|
||||
$processor = $this->createProcessor(adminUserId: (string) UserId::generate());
|
||||
|
||||
$data = new UserResource();
|
||||
$data->reason = 'Some reason';
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['id' => '550e8400-e29b-41d4-a716-446655440099']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenUserIsNotBlockable(): void
|
||||
{
|
||||
// Create a user in EN_ATTENTE status (not active, so can't be blocked)
|
||||
$user = User::inviter(
|
||||
email: new Email('pending@example.com'),
|
||||
role: Role::PROF,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'École Alpha',
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
invitedAt: new DateTimeImmutable('2026-02-01'),
|
||||
);
|
||||
$user->pullDomainEvents();
|
||||
$this->userRepository->save($user);
|
||||
|
||||
$processor = $this->createProcessor(adminUserId: (string) UserId::generate());
|
||||
|
||||
$data = new UserResource();
|
||||
$data->reason = 'Trying to block pending user';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['id' => (string) $user->id]);
|
||||
}
|
||||
|
||||
private function createActiveUser(): User
|
||||
{
|
||||
$consentementPolicy = new ConsentementParentalPolicy($this->clock);
|
||||
|
||||
$user = User::inviter(
|
||||
email: new Email('teacher@example.com'),
|
||||
role: Role::PROF,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'École Alpha',
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
invitedAt: new DateTimeImmutable('2026-02-01'),
|
||||
);
|
||||
$user->pullDomainEvents();
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable('2026-02-02'), $consentementPolicy);
|
||||
$user->pullDomainEvents();
|
||||
$this->userRepository->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function createProcessor(
|
||||
bool $authorized = true,
|
||||
string $adminUserId = '',
|
||||
?TenantContext $tenantContext = null,
|
||||
): BlockUserProcessor {
|
||||
$handler = new BlockUserHandler($this->userRepository, $this->clock);
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')->willReturnCallback(
|
||||
static fn (object $message) => new Envelope($message),
|
||||
);
|
||||
|
||||
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$authorizationChecker->method('isGranted')
|
||||
->with(UserVoter::BLOCK)
|
||||
->willReturn($authorized);
|
||||
|
||||
if ($adminUserId === '') {
|
||||
$adminUserId = (string) UserId::generate();
|
||||
}
|
||||
|
||||
$securityUser = new SecurityUser(
|
||||
userId: UserId::fromString($adminUserId),
|
||||
email: 'admin@example.com',
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: [Role::ADMIN->value],
|
||||
);
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($securityUser);
|
||||
|
||||
return new BlockUserProcessor(
|
||||
$handler,
|
||||
$eventBus,
|
||||
$authorizationChecker,
|
||||
$tenantContext ?? $this->tenantContext,
|
||||
$security,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\CreateClass\CreateClassHandler;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||
use App\Administration\Infrastructure\Api\Processor\CreateClassProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||
use App\Administration\Infrastructure\Security\ClassVoter;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class CreateClassProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
private InMemoryClassRepository $classRepository;
|
||||
private TenantContext $tenantContext;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->classRepository = new InMemoryClassRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-10 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://test',
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createsClassSuccessfully(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new ClassResource();
|
||||
$data->name = 'CM2-A';
|
||||
$data->level = 'CM2';
|
||||
$data->capacity = 30;
|
||||
|
||||
$result = $processor->process($data, new Post());
|
||||
|
||||
self::assertNotNull($result->id);
|
||||
self::assertSame('CM2-A', $result->name);
|
||||
self::assertSame('CM2', $result->level);
|
||||
self::assertSame(30, $result->capacity);
|
||||
self::assertSame(ClassStatus::ACTIVE->value, $result->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createsClassWithoutOptionalFields(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new ClassResource();
|
||||
$data->name = 'CP-B';
|
||||
$data->level = null;
|
||||
$data->capacity = null;
|
||||
|
||||
$result = $processor->process($data, new Post());
|
||||
|
||||
self::assertNotNull($result->id);
|
||||
self::assertSame('CP-B', $result->name);
|
||||
self::assertNull($result->level);
|
||||
self::assertNull($result->capacity);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenNotAuthorized(): void
|
||||
{
|
||||
$processor = $this->createProcessor(authorized: false);
|
||||
|
||||
$data = new ClassResource();
|
||||
$data->name = 'CM2-A';
|
||||
$data->level = 'CM2';
|
||||
$data->capacity = 30;
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$processor->process($data, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenTenantNotSet(): void
|
||||
{
|
||||
$emptyTenantContext = new TenantContext();
|
||||
$processor = $this->createProcessor(tenantContext: $emptyTenantContext);
|
||||
|
||||
$data = new ClassResource();
|
||||
$data->name = 'CM2-A';
|
||||
$data->level = 'CM2';
|
||||
$data->capacity = 30;
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
|
||||
$processor->process($data, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenClassNameAlreadyExists(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new ClassResource();
|
||||
$data->name = 'CM2-A';
|
||||
$data->level = 'CM2';
|
||||
$data->capacity = 30;
|
||||
|
||||
// Create the first class
|
||||
$processor->process($data, new Post());
|
||||
|
||||
// Try to create a duplicate
|
||||
$this->expectException(ConflictHttpException::class);
|
||||
|
||||
$processor->process($data, new Post());
|
||||
}
|
||||
|
||||
private function createProcessor(
|
||||
bool $authorized = true,
|
||||
?TenantContext $tenantContext = null,
|
||||
): CreateClassProcessor {
|
||||
$handler = new CreateClassHandler($this->classRepository, $this->clock);
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')->willReturnCallback(
|
||||
static fn (object $message) => new Envelope($message),
|
||||
);
|
||||
|
||||
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$authorizationChecker->method('isGranted')
|
||||
->with(ClassVoter::CREATE)
|
||||
->willReturn($authorized);
|
||||
|
||||
return new CreateClassProcessor(
|
||||
$handler,
|
||||
$tenantContext ?? $this->tenantContext,
|
||||
$eventBus,
|
||||
$authorizationChecker,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\CreateSubject\CreateSubjectHandler;
|
||||
use App\Administration\Infrastructure\Api\Processor\CreateSubjectProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySubjectRepository;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Administration\Infrastructure\Security\SubjectVoter;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class CreateSubjectProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
private InMemorySubjectRepository $subjectRepository;
|
||||
private TenantContext $tenantContext;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->subjectRepository = new InMemorySubjectRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-10 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://test',
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createsSubjectSuccessfully(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new SubjectResource();
|
||||
$data->name = 'Mathématiques';
|
||||
$data->code = 'MATH';
|
||||
$data->color = '#FF5733';
|
||||
|
||||
$result = $processor->process($data, new Post());
|
||||
|
||||
self::assertNotNull($result->id);
|
||||
self::assertSame('Mathématiques', $result->name);
|
||||
self::assertSame('MATH', $result->code);
|
||||
self::assertSame('#FF5733', $result->color);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createsSubjectWithoutColor(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new SubjectResource();
|
||||
$data->name = 'Français';
|
||||
$data->code = 'FR';
|
||||
$data->color = null;
|
||||
|
||||
$result = $processor->process($data, new Post());
|
||||
|
||||
self::assertNotNull($result->id);
|
||||
self::assertSame('Français', $result->name);
|
||||
self::assertSame('FR', $result->code);
|
||||
self::assertNull($result->color);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenNotAuthorized(): void
|
||||
{
|
||||
$processor = $this->createProcessor(authorized: false);
|
||||
|
||||
$data = new SubjectResource();
|
||||
$data->name = 'Mathématiques';
|
||||
$data->code = 'MATH';
|
||||
$data->color = null;
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$processor->process($data, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenTenantNotSet(): void
|
||||
{
|
||||
$emptyTenantContext = new TenantContext();
|
||||
$processor = $this->createProcessor(tenantContext: $emptyTenantContext);
|
||||
|
||||
$data = new SubjectResource();
|
||||
$data->name = 'Mathématiques';
|
||||
$data->code = 'MATH';
|
||||
$data->color = null;
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
|
||||
$processor->process($data, new Post());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenSubjectCodeAlreadyExists(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new SubjectResource();
|
||||
$data->name = 'Mathématiques';
|
||||
$data->code = 'MATH';
|
||||
$data->color = null;
|
||||
|
||||
// Create the first subject
|
||||
$processor->process($data, new Post());
|
||||
|
||||
// Try to create a duplicate code
|
||||
$data2 = new SubjectResource();
|
||||
$data2->name = 'Maths avancées';
|
||||
$data2->code = 'MATH';
|
||||
$data2->color = null;
|
||||
|
||||
$this->expectException(ConflictHttpException::class);
|
||||
|
||||
$processor->process($data2, new Post());
|
||||
}
|
||||
|
||||
private function createProcessor(
|
||||
bool $authorized = true,
|
||||
?TenantContext $tenantContext = null,
|
||||
): CreateSubjectProcessor {
|
||||
$handler = new CreateSubjectHandler($this->subjectRepository, $this->clock);
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')->willReturnCallback(
|
||||
static fn (object $message) => new Envelope($message),
|
||||
);
|
||||
|
||||
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$authorizationChecker->method('isGranted')
|
||||
->with(SubjectVoter::CREATE)
|
||||
->willReturn($authorized);
|
||||
|
||||
$schoolIdResolver = new SchoolIdResolver();
|
||||
|
||||
return new CreateSubjectProcessor(
|
||||
$handler,
|
||||
$tenantContext ?? $this->tenantContext,
|
||||
$eventBus,
|
||||
$authorizationChecker,
|
||||
$schoolIdResolver,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Api\Processor\InviteUserProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\UserResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Administration\Infrastructure\Security\UserVoter;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* Tests for InviteUserProcessor - ensuring the API layer correctly
|
||||
* handles both `roles` (array) and `role` (singular) payloads.
|
||||
*
|
||||
* Background: Story 2.6 introduced multi-role support where the frontend
|
||||
* sends `roles: ["ROLE_PROF"]` instead of `role: "ROLE_PROF"`.
|
||||
* The processor must derive `role` from `roles[0]` when `role` is absent.
|
||||
*/
|
||||
final class InviteUserProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string SUBDOMAIN = 'ecole-alpha';
|
||||
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private TenantContext $tenantContext;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-10 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
||||
subdomain: self::SUBDOMAIN,
|
||||
databaseUrl: 'postgresql://test',
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invitesUserWithRolesArrayWithoutRoleSingular(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
$data->email = 'prof@example.com';
|
||||
$data->roles = [Role::PROF->value];
|
||||
$data->firstName = 'Marie';
|
||||
$data->lastName = 'Curie';
|
||||
// role is intentionally NOT set — this is the frontend behavior since Story 2.6
|
||||
|
||||
$result = $processor->process($data, new Post());
|
||||
|
||||
self::assertSame('prof@example.com', $result->email);
|
||||
self::assertSame(Role::PROF->value, $result->role);
|
||||
self::assertSame([Role::PROF->value], $result->roles);
|
||||
self::assertSame(StatutCompte::EN_ATTENTE->value, $result->statut);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invitesUserWithMultipleRolesDerivesRoleFromFirst(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
$data->email = 'admin-prof@example.com';
|
||||
$data->roles = [Role::ADMIN->value, Role::PROF->value];
|
||||
$data->firstName = 'Albert';
|
||||
$data->lastName = 'Einstein';
|
||||
|
||||
$result = $processor->process($data, new Post());
|
||||
|
||||
self::assertSame(Role::ADMIN->value, $result->role);
|
||||
self::assertSame([Role::ADMIN->value, Role::PROF->value], $result->roles);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invitesUserWithLegacyRoleSingular(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
$data->email = 'legacy@example.com';
|
||||
$data->role = Role::PROF->value;
|
||||
$data->roles = [];
|
||||
$data->firstName = 'Isaac';
|
||||
$data->lastName = 'Newton';
|
||||
|
||||
$result = $processor->process($data, new Post());
|
||||
|
||||
self::assertSame(Role::PROF->value, $result->role);
|
||||
self::assertSame([Role::PROF->value], $result->roles);
|
||||
}
|
||||
|
||||
private function createProcessor(): InviteUserProcessor
|
||||
{
|
||||
$handler = new InviteUserHandler($this->userRepository, $this->clock);
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')->willReturnCallback(
|
||||
static fn (object $message) => new Envelope($message),
|
||||
);
|
||||
|
||||
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$authorizationChecker->method('isGranted')
|
||||
->with(UserVoter::CREATE)
|
||||
->willReturn(true);
|
||||
|
||||
$securityUser = new SecurityUser(
|
||||
userId: UserId::generate(),
|
||||
email: 'admin@example.com',
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: [Role::ADMIN->value],
|
||||
);
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($securityUser);
|
||||
|
||||
return new InviteUserProcessor(
|
||||
$handler,
|
||||
$this->tenantContext,
|
||||
$eventBus,
|
||||
$authorizationChecker,
|
||||
$this->clock,
|
||||
$security,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\LinkParentToStudent\LinkParentToStudentHandler;
|
||||
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
|
||||
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\Api\Processor\LinkParentToStudentProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\StudentGuardianResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryStudentGuardianRepository;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Administration\Infrastructure\Security\StudentGuardianVoter;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class LinkParentToStudentProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string SUBDOMAIN = 'ecole-alpha';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string GUARDIAN_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string GUARDIAN_ID_2 = '550e8400-e29b-41d4-a716-446655440021';
|
||||
private const string GUARDIAN_ID_3 = '550e8400-e29b-41d4-a716-446655440022';
|
||||
|
||||
private InMemoryStudentGuardianRepository $repository;
|
||||
private TenantContext $tenantContext;
|
||||
private Clock $clock;
|
||||
private SecurityUser $securityUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryStudentGuardianRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-10 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
||||
subdomain: self::SUBDOMAIN,
|
||||
databaseUrl: 'postgresql://test',
|
||||
));
|
||||
|
||||
$this->securityUser = new SecurityUser(
|
||||
userId: UserId::fromString(self::GUARDIAN_ID),
|
||||
email: 'admin@example.com',
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: [Role::ADMIN->value],
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function linksParentToStudentSuccessfully(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new StudentGuardianResource();
|
||||
$data->guardianId = self::GUARDIAN_ID;
|
||||
$data->relationshipType = 'père';
|
||||
|
||||
$result = $processor->process($data, new Post(), ['studentId' => self::STUDENT_ID]);
|
||||
|
||||
self::assertInstanceOf(StudentGuardianResource::class, $result);
|
||||
self::assertSame(self::STUDENT_ID, $result->studentId);
|
||||
self::assertSame(self::GUARDIAN_ID, $result->guardianId);
|
||||
self::assertSame('père', $result->relationshipType);
|
||||
self::assertSame('Père', $result->relationshipLabel);
|
||||
self::assertNotNull($result->id);
|
||||
self::assertNotNull($result->linkedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dispatchesDomainEventsAfterLinking(): void
|
||||
{
|
||||
$dispatched = [];
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')->willReturnCallback(
|
||||
static function (object $message) use (&$dispatched): Envelope {
|
||||
$dispatched[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
},
|
||||
);
|
||||
|
||||
$processor = $this->createProcessor(eventBus: $eventBus);
|
||||
|
||||
$data = new StudentGuardianResource();
|
||||
$data->guardianId = self::GUARDIAN_ID;
|
||||
$data->relationshipType = 'père';
|
||||
|
||||
$processor->process($data, new Post(), ['studentId' => self::STUDENT_ID]);
|
||||
|
||||
self::assertNotEmpty($dispatched, 'At least one domain event should be dispatched.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsAccessDeniedWhenNotAuthorized(): void
|
||||
{
|
||||
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$authChecker->method('isGranted')
|
||||
->with(StudentGuardianVoter::MANAGE)
|
||||
->willReturn(false);
|
||||
|
||||
$processor = $this->createProcessor(authorizationChecker: $authChecker);
|
||||
|
||||
$data = new StudentGuardianResource();
|
||||
$data->guardianId = self::GUARDIAN_ID;
|
||||
$data->relationshipType = 'père';
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['studentId' => self::STUDENT_ID]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsUnauthorizedWhenNoTenant(): void
|
||||
{
|
||||
$tenantContext = new TenantContext();
|
||||
|
||||
$processor = $this->createProcessor(tenantContext: $tenantContext);
|
||||
|
||||
$data = new StudentGuardianResource();
|
||||
$data->guardianId = self::GUARDIAN_ID;
|
||||
$data->relationshipType = 'père';
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['studentId' => self::STUDENT_ID]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsBadRequestOnInvalidArgument(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new StudentGuardianResource();
|
||||
$data->guardianId = 'not-a-valid-uuid';
|
||||
$data->relationshipType = 'père';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['studentId' => self::STUDENT_ID]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsConflictWhenLinkAlreadyExists(): void
|
||||
{
|
||||
$existingLink = StudentGuardian::lier(
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
guardianId: UserId::fromString(self::GUARDIAN_ID),
|
||||
relationshipType: RelationshipType::FATHER,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-09 10:00:00'),
|
||||
);
|
||||
$this->repository->save($existingLink);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new StudentGuardianResource();
|
||||
$data->guardianId = self::GUARDIAN_ID;
|
||||
$data->relationshipType = 'père';
|
||||
|
||||
$this->expectException(ConflictHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['studentId' => self::STUDENT_ID]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsUnprocessableWhenMaxGuardiansReached(): void
|
||||
{
|
||||
$link1 = StudentGuardian::lier(
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
guardianId: UserId::fromString(self::GUARDIAN_ID),
|
||||
relationshipType: RelationshipType::FATHER,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-09 10:00:00'),
|
||||
);
|
||||
$this->repository->save($link1);
|
||||
|
||||
$link2 = StudentGuardian::lier(
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
guardianId: UserId::fromString(self::GUARDIAN_ID_2),
|
||||
relationshipType: RelationshipType::MOTHER,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-09 10:00:00'),
|
||||
);
|
||||
$this->repository->save($link2);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new StudentGuardianResource();
|
||||
$data->guardianId = self::GUARDIAN_ID_3;
|
||||
$data->relationshipType = 'tuteur';
|
||||
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['studentId' => self::STUDENT_ID]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function passesCurrentUserAsCreatedBy(): void
|
||||
{
|
||||
$expectedUserId = '550e8400-e29b-41d4-a716-446655440099';
|
||||
$securityUser = new SecurityUser(
|
||||
userId: UserId::fromString($expectedUserId),
|
||||
email: 'admin@example.com',
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: [Role::ADMIN->value],
|
||||
);
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($securityUser);
|
||||
|
||||
$processor = $this->createProcessor(security: $security);
|
||||
|
||||
$data = new StudentGuardianResource();
|
||||
$data->guardianId = self::GUARDIAN_ID;
|
||||
$data->relationshipType = 'père';
|
||||
|
||||
$result = $processor->process($data, new Post(), ['studentId' => self::STUDENT_ID]);
|
||||
|
||||
self::assertInstanceOf(StudentGuardianResource::class, $result);
|
||||
self::assertSame(self::STUDENT_ID, $result->studentId);
|
||||
}
|
||||
|
||||
private function createProcessor(
|
||||
?TenantContext $tenantContext = null,
|
||||
?AuthorizationCheckerInterface $authorizationChecker = null,
|
||||
?MessageBusInterface $eventBus = null,
|
||||
?Security $security = null,
|
||||
): LinkParentToStudentProcessor {
|
||||
$now = new DateTimeImmutable('2026-02-10 10:00:00');
|
||||
$domainTenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$guardianUser = User::creer(
|
||||
email: new Email('guardian@example.com'),
|
||||
role: Role::PARENT,
|
||||
tenantId: $domainTenantId,
|
||||
schoolName: 'École Test',
|
||||
dateNaissance: null,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$studentUser = User::creer(
|
||||
email: new Email('student@example.com'),
|
||||
role: Role::ELEVE,
|
||||
tenantId: $domainTenantId,
|
||||
schoolName: 'École Test',
|
||||
dateNaissance: null,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository->method('get')->willReturnCallback(
|
||||
static function (UserId $id) use ($guardianUser, $studentUser): User {
|
||||
if ((string) $id === self::GUARDIAN_ID || (string) $id === self::GUARDIAN_ID_2 || (string) $id === self::GUARDIAN_ID_3) {
|
||||
return $guardianUser;
|
||||
}
|
||||
|
||||
return $studentUser;
|
||||
},
|
||||
);
|
||||
|
||||
$handler = new LinkParentToStudentHandler($this->repository, $userRepository, $this->clock);
|
||||
|
||||
$tenantContext ??= $this->tenantContext;
|
||||
|
||||
if ($authorizationChecker === null) {
|
||||
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$authorizationChecker->method('isGranted')
|
||||
->with(StudentGuardianVoter::MANAGE)
|
||||
->willReturn(true);
|
||||
}
|
||||
|
||||
if ($eventBus === null) {
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')->willReturnCallback(
|
||||
static fn (object $message) => new Envelope($message),
|
||||
);
|
||||
}
|
||||
|
||||
if ($security === null) {
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($this->securityUser);
|
||||
}
|
||||
|
||||
return new LinkParentToStudentProcessor(
|
||||
$handler,
|
||||
$tenantContext,
|
||||
$authorizationChecker,
|
||||
$eventBus,
|
||||
$security,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\UnblockUser\UnblockUserHandler;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Policy\ConsentementParentalPolicy;
|
||||
use App\Administration\Infrastructure\Api\Processor\UnblockUserProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\UserResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Administration\Infrastructure\Security\UserVoter;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class UnblockUserProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private TenantContext $tenantContext;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-10 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'postgresql://test',
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unblocksUserSuccessfully(): void
|
||||
{
|
||||
$user = $this->createBlockedUser();
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
|
||||
$result = $processor->process($data, new Post(), ['id' => (string) $user->id]);
|
||||
|
||||
self::assertSame(StatutCompte::ACTIF->value, $result->statut);
|
||||
self::assertNull($result->blockedReason);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenNotAuthorized(): void
|
||||
{
|
||||
$processor = $this->createProcessor(authorized: false);
|
||||
|
||||
$data = new UserResource();
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['id' => (string) UserId::generate()]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenTenantNotSet(): void
|
||||
{
|
||||
$emptyTenantContext = new TenantContext();
|
||||
$processor = $this->createProcessor(tenantContext: $emptyTenantContext);
|
||||
|
||||
$data = new UserResource();
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['id' => (string) UserId::generate()]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenUserNotFound(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['id' => '550e8400-e29b-41d4-a716-446655440099']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenUserIsNotSuspended(): void
|
||||
{
|
||||
// Active user cannot be unblocked (only suspended ones)
|
||||
$user = $this->createActiveUser();
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new UserResource();
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
|
||||
$processor->process($data, new Post(), ['id' => (string) $user->id]);
|
||||
}
|
||||
|
||||
private function createActiveUser(): User
|
||||
{
|
||||
$consentementPolicy = new ConsentementParentalPolicy($this->clock);
|
||||
|
||||
$user = User::inviter(
|
||||
email: new Email('active@example.com'),
|
||||
role: Role::PROF,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'École Alpha',
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
invitedAt: new DateTimeImmutable('2026-02-01'),
|
||||
);
|
||||
$user->pullDomainEvents();
|
||||
$user->activer('$argon2id$hashed', new DateTimeImmutable('2026-02-02'), $consentementPolicy);
|
||||
$user->pullDomainEvents();
|
||||
$this->userRepository->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function createBlockedUser(): User
|
||||
{
|
||||
$user = $this->createActiveUser();
|
||||
$user->bloquer('Raison du blocage', new DateTimeImmutable('2026-02-09'));
|
||||
$user->pullDomainEvents();
|
||||
$this->userRepository->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function createProcessor(
|
||||
bool $authorized = true,
|
||||
?TenantContext $tenantContext = null,
|
||||
): UnblockUserProcessor {
|
||||
$handler = new UnblockUserHandler($this->userRepository, $this->clock);
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')->willReturnCallback(
|
||||
static fn (object $message) => new Envelope($message),
|
||||
);
|
||||
|
||||
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$authorizationChecker->method('isGranted')
|
||||
->with(UserVoter::UNBLOCK)
|
||||
->willReturn($authorized);
|
||||
|
||||
return new UnblockUserProcessor(
|
||||
$handler,
|
||||
$eventBus,
|
||||
$authorizationChecker,
|
||||
$tenantContext ?? $this->tenantContext,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use App\Administration\Application\Command\UnlinkParentFromStudent\UnlinkParentFromStudentHandler;
|
||||
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
|
||||
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Api\Processor\UnlinkParentFromStudentProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\StudentGuardianResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryStudentGuardianRepository;
|
||||
use App\Administration\Infrastructure\Security\StudentGuardianVoter;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class UnlinkParentFromStudentProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string SUBDOMAIN = 'ecole-alpha';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string GUARDIAN_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
|
||||
private InMemoryStudentGuardianRepository $repository;
|
||||
private TenantContext $tenantContext;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryStudentGuardianRepository();
|
||||
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
||||
subdomain: self::SUBDOMAIN,
|
||||
databaseUrl: 'postgresql://test',
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unlinksParentFromStudentSuccessfully(): void
|
||||
{
|
||||
$link = StudentGuardian::lier(
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
guardianId: UserId::fromString(self::GUARDIAN_ID),
|
||||
relationshipType: RelationshipType::FATHER,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
|
||||
);
|
||||
$this->repository->save($link);
|
||||
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$result = $processor->process(
|
||||
new StudentGuardianResource(),
|
||||
new Delete(),
|
||||
['studentId' => self::STUDENT_ID, 'guardianId' => self::GUARDIAN_ID],
|
||||
);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsAccessDeniedWhenNotAuthorized(): void
|
||||
{
|
||||
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$authChecker->method('isGranted')
|
||||
->with(StudentGuardianVoter::MANAGE)
|
||||
->willReturn(false);
|
||||
|
||||
$processor = $this->createProcessor(authorizationChecker: $authChecker);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$processor->process(
|
||||
new StudentGuardianResource(),
|
||||
new Delete(),
|
||||
['studentId' => self::STUDENT_ID, 'guardianId' => self::GUARDIAN_ID],
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsUnauthorizedWhenNoTenant(): void
|
||||
{
|
||||
$tenantContext = new TenantContext();
|
||||
|
||||
$processor = $this->createProcessor(tenantContext: $tenantContext);
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
|
||||
$processor->process(
|
||||
new StudentGuardianResource(),
|
||||
new Delete(),
|
||||
['studentId' => self::STUDENT_ID, 'guardianId' => self::GUARDIAN_ID],
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsNotFoundWhenLinkDoesNotExist(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$processor->process(
|
||||
new StudentGuardianResource(),
|
||||
new Delete(),
|
||||
['studentId' => self::STUDENT_ID, 'guardianId' => self::GUARDIAN_ID],
|
||||
);
|
||||
}
|
||||
|
||||
private function createProcessor(
|
||||
?TenantContext $tenantContext = null,
|
||||
?AuthorizationCheckerInterface $authorizationChecker = null,
|
||||
?MessageBusInterface $eventBus = null,
|
||||
): UnlinkParentFromStudentProcessor {
|
||||
$tenantContext ??= $this->tenantContext;
|
||||
|
||||
if ($authorizationChecker === null) {
|
||||
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$authorizationChecker->method('isGranted')
|
||||
->with(StudentGuardianVoter::MANAGE)
|
||||
->willReturn(true);
|
||||
}
|
||||
|
||||
if ($eventBus === null) {
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')->willReturnCallback(
|
||||
static fn (object $message) => new Envelope($message),
|
||||
);
|
||||
}
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-10 10:00:00');
|
||||
}
|
||||
};
|
||||
$handler = new UnlinkParentFromStudentHandler($this->repository, $clock);
|
||||
|
||||
return new UnlinkParentFromStudentProcessor(
|
||||
$handler,
|
||||
$this->repository,
|
||||
$tenantContext,
|
||||
$authorizationChecker,
|
||||
$eventBus,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Administration\Application\Command\UpdateClass\UpdateClassHandler;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Infrastructure\Api\Processor\UpdateClassProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class UpdateClassProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440003';
|
||||
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440004';
|
||||
|
||||
private InMemoryClassRepository $classRepository;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->classRepository = new InMemoryClassRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-10 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function updatesClassNameSuccessfully(): void
|
||||
{
|
||||
$class = $this->createAndSaveClass('CM2-A');
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new ClassResource();
|
||||
$data->name = 'CM2-B';
|
||||
|
||||
$result = $processor->process($data, new Patch(), ['id' => (string) $class->id]);
|
||||
|
||||
self::assertSame('CM2-B', $result->name);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenClassIdIsMissing(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new ClassResource();
|
||||
$data->name = 'CM2-B';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('ID de classe manquant');
|
||||
|
||||
$processor->process($data, new Patch(), []);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenClassNotFound(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new ClassResource();
|
||||
$data->name = 'CM2-B';
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$processor->process($data, new Patch(), ['id' => '550e8400-e29b-41d4-a716-446655440099']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsWhenNotAuthorized(): void
|
||||
{
|
||||
$class = $this->createAndSaveClass('CM2-A');
|
||||
$processor = $this->createProcessor(authorized: false);
|
||||
|
||||
$data = new ClassResource();
|
||||
$data->name = 'CM2-B';
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$processor->process($data, new Patch(), ['id' => (string) $class->id]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function updatesCapacityAndLevel(): void
|
||||
{
|
||||
$class = $this->createAndSaveClass('CM2-A');
|
||||
$processor = $this->createProcessor();
|
||||
|
||||
$data = new ClassResource();
|
||||
$data->level = 'CE1';
|
||||
$data->capacity = 25;
|
||||
|
||||
$result = $processor->process($data, new Patch(), ['id' => (string) $class->id]);
|
||||
|
||||
self::assertSame('CE1', $result->level);
|
||||
self::assertSame(25, $result->capacity);
|
||||
}
|
||||
|
||||
private function createAndSaveClass(string $name): SchoolClass
|
||||
{
|
||||
$class = SchoolClass::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName($name),
|
||||
level: null,
|
||||
capacity: 30,
|
||||
createdAt: new DateTimeImmutable('2026-02-01'),
|
||||
);
|
||||
$class->pullDomainEvents();
|
||||
$this->classRepository->save($class);
|
||||
|
||||
return $class;
|
||||
}
|
||||
|
||||
private function createProcessor(bool $authorized = true): UpdateClassProcessor
|
||||
{
|
||||
$handler = new UpdateClassHandler($this->classRepository, $this->clock);
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')->willReturnCallback(
|
||||
static fn (object $message) => new Envelope($message),
|
||||
);
|
||||
|
||||
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$authorizationChecker->method('isGranted')->willReturn($authorized);
|
||||
|
||||
return new UpdateClassProcessor(
|
||||
$handler,
|
||||
$this->classRepository,
|
||||
$eventBus,
|
||||
$authorizationChecker,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Administration\Application\Query\GetParentsForStudent\GetParentsForStudentHandler;
|
||||
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
|
||||
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\Api\Provider\GuardiansForStudentProvider;
|
||||
use App\Administration\Infrastructure\Api\Resource\StudentGuardianResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryStudentGuardianRepository;
|
||||
use App\Administration\Infrastructure\Security\StudentGuardianVoter;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class GuardiansForStudentProviderTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string SUBDOMAIN = 'ecole-alpha';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string GUARDIAN_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
|
||||
private InMemoryStudentGuardianRepository $repository;
|
||||
private TenantContext $tenantContext;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryStudentGuardianRepository();
|
||||
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
||||
subdomain: self::SUBDOMAIN,
|
||||
databaseUrl: 'postgresql://test',
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsGuardiansForStudent(): void
|
||||
{
|
||||
$link = StudentGuardian::lier(
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
guardianId: UserId::fromString(self::GUARDIAN_ID),
|
||||
relationshipType: RelationshipType::FATHER,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
|
||||
);
|
||||
$this->repository->save($link);
|
||||
|
||||
$provider = $this->createProvider();
|
||||
|
||||
$results = $provider->provide(
|
||||
new GetCollection(),
|
||||
['studentId' => self::STUDENT_ID],
|
||||
);
|
||||
|
||||
self::assertCount(1, $results);
|
||||
self::assertInstanceOf(StudentGuardianResource::class, $results[0]);
|
||||
self::assertSame((string) $link->id, $results[0]->id);
|
||||
self::assertSame(self::GUARDIAN_ID, $results[0]->guardianId);
|
||||
self::assertSame('père', $results[0]->relationshipType);
|
||||
self::assertSame('Père', $results[0]->relationshipLabel);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyArrayWhenNoGuardians(): void
|
||||
{
|
||||
$provider = $this->createProvider();
|
||||
|
||||
$results = $provider->provide(
|
||||
new GetCollection(),
|
||||
['studentId' => self::STUDENT_ID],
|
||||
);
|
||||
|
||||
self::assertSame([], $results);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsUnauthorizedWhenNoTenant(): void
|
||||
{
|
||||
$tenantContext = new TenantContext();
|
||||
|
||||
$provider = $this->createProvider(tenantContext: $tenantContext);
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
|
||||
$provider->provide(
|
||||
new GetCollection(),
|
||||
['studentId' => self::STUDENT_ID],
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsAccessDeniedWhenNotAuthorizedToViewStudent(): void
|
||||
{
|
||||
$authChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$authChecker->method('isGranted')
|
||||
->with(StudentGuardianVoter::VIEW_STUDENT, self::STUDENT_ID)
|
||||
->willReturn(false);
|
||||
|
||||
$provider = $this->createProvider(authorizationChecker: $authChecker);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$provider->provide(
|
||||
new GetCollection(),
|
||||
['studentId' => self::STUDENT_ID],
|
||||
);
|
||||
}
|
||||
|
||||
private function createProvider(
|
||||
?TenantContext $tenantContext = null,
|
||||
?AuthorizationCheckerInterface $authorizationChecker = null,
|
||||
): GuardiansForStudentProvider {
|
||||
$guardianUser = User::creer(
|
||||
email: new Email('guardian@example.com'),
|
||||
role: Role::PARENT,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'École Test',
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
|
||||
);
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository->method('get')->willReturn($guardianUser);
|
||||
|
||||
$handler = new GetParentsForStudentHandler($this->repository, $userRepository);
|
||||
|
||||
$tenantContext ??= $this->tenantContext;
|
||||
|
||||
if ($authorizationChecker === null) {
|
||||
$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$authorizationChecker->method('isGranted')
|
||||
->with(StudentGuardianVoter::VIEW_STUDENT, self::STUDENT_ID)
|
||||
->willReturn(true);
|
||||
}
|
||||
|
||||
return new GuardiansForStudentProvider(
|
||||
$handler,
|
||||
$tenantContext,
|
||||
$authorizationChecker,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Administration\Application\Query\GetStudentsForParent\GetStudentsForParentHandler;
|
||||
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
|
||||
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\Api\Provider\MyChildrenProvider;
|
||||
use App\Administration\Infrastructure\Api\Resource\MyChildrenResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryStudentGuardianRepository;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
final class MyChildrenProviderTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string SUBDOMAIN = 'ecole-alpha';
|
||||
private const string PARENT_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
|
||||
private InMemoryStudentGuardianRepository $repository;
|
||||
private TenantContext $tenantContext;
|
||||
private SecurityUser $securityUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryStudentGuardianRepository();
|
||||
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
||||
subdomain: self::SUBDOMAIN,
|
||||
databaseUrl: 'postgresql://test',
|
||||
));
|
||||
|
||||
$this->securityUser = new SecurityUser(
|
||||
userId: UserId::fromString(self::PARENT_ID),
|
||||
email: 'parent@example.com',
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: [Role::PARENT->value],
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsChildrenForAuthenticatedParent(): void
|
||||
{
|
||||
$link = StudentGuardian::lier(
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
guardianId: UserId::fromString(self::PARENT_ID),
|
||||
relationshipType: RelationshipType::FATHER,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
|
||||
);
|
||||
$this->repository->save($link);
|
||||
|
||||
$provider = $this->createProvider();
|
||||
|
||||
$results = $provider->provide(new GetCollection());
|
||||
|
||||
self::assertCount(1, $results);
|
||||
self::assertInstanceOf(MyChildrenResource::class, $results[0]);
|
||||
self::assertSame((string) $link->id, $results[0]->id);
|
||||
self::assertSame(self::STUDENT_ID, $results[0]->studentId);
|
||||
self::assertSame('père', $results[0]->relationshipType);
|
||||
self::assertSame('Père', $results[0]->relationshipLabel);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyArrayWhenNoChildren(): void
|
||||
{
|
||||
$provider = $this->createProvider();
|
||||
|
||||
$results = $provider->provide(new GetCollection());
|
||||
|
||||
self::assertSame([], $results);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsUnauthorizedWhenNotAuthenticated(): void
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn(null);
|
||||
|
||||
$provider = $this->createProvider(security: $security);
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
|
||||
$provider->provide(new GetCollection());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsUnauthorizedWhenNoTenant(): void
|
||||
{
|
||||
$tenantContext = new TenantContext();
|
||||
|
||||
$provider = $this->createProvider(tenantContext: $tenantContext);
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
|
||||
$provider->provide(new GetCollection());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwsUnauthorizedWhenNotSecurityUser(): void
|
||||
{
|
||||
$nonSecurityUser = $this->createMock(UserInterface::class);
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($nonSecurityUser);
|
||||
|
||||
$provider = $this->createProvider(security: $security);
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
|
||||
$provider->provide(new GetCollection());
|
||||
}
|
||||
|
||||
private function createProvider(
|
||||
?TenantContext $tenantContext = null,
|
||||
?Security $security = null,
|
||||
): MyChildrenProvider {
|
||||
$studentUser = User::creer(
|
||||
email: new Email('student@example.com'),
|
||||
role: Role::ELEVE,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'École Test',
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
|
||||
);
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository->method('get')->willReturn($studentUser);
|
||||
|
||||
$handler = new GetStudentsForParentHandler($this->repository, $userRepository);
|
||||
|
||||
$tenantContext ??= $this->tenantContext;
|
||||
|
||||
if ($security === null) {
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($this->securityUser);
|
||||
}
|
||||
|
||||
return new MyChildrenProvider(
|
||||
$handler,
|
||||
$security,
|
||||
$tenantContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Messaging;
|
||||
|
||||
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Messaging\SendInvitationEmailHandler;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryActivationTokenRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||
use App\Shared\Infrastructure\Tenant\TenantUrlBuilder;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Email as MimeEmail;
|
||||
use Twig\Environment;
|
||||
|
||||
final class SendInvitationEmailHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string SCHOOL_NAME = 'École Alpha';
|
||||
private const string FROM_EMAIL = 'noreply@classeo.fr';
|
||||
|
||||
private InMemoryActivationTokenRepository $tokenRepository;
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private TenantUrlBuilder $tenantUrlBuilder;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tokenRepository = new InMemoryActivationTokenRepository();
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-07 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$tenantConfig = new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_ID),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
);
|
||||
|
||||
$tenantRegistry = $this->createMock(TenantRegistry::class);
|
||||
$tenantRegistry->method('getConfig')->willReturn($tenantConfig);
|
||||
|
||||
$this->tenantUrlBuilder = new TenantUrlBuilder(
|
||||
$tenantRegistry,
|
||||
'https://classeo.fr',
|
||||
'classeo.fr',
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSendsInvitationEmailWithCorrectContent(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser('teacher@example.com', Role::PROF, 'Jean', 'Dupont');
|
||||
|
||||
$mailer = $this->createMock(MailerInterface::class);
|
||||
$twig = $this->createMock(Environment::class);
|
||||
|
||||
$twig->expects($this->once())
|
||||
->method('render')
|
||||
->with('emails/invitation.html.twig', $this->callback(
|
||||
static fn (array $params): bool => $params['firstName'] === 'Jean'
|
||||
&& $params['lastName'] === 'Dupont'
|
||||
&& $params['role'] === 'Enseignant'
|
||||
&& str_contains($params['activationUrl'], 'ecole-alpha.classeo.fr/activate/'),
|
||||
))
|
||||
->willReturn('<html>invitation</html>');
|
||||
|
||||
$mailer->expects($this->once())
|
||||
->method('send')
|
||||
->with($this->callback(
|
||||
static fn (MimeEmail $email): bool => $email->getTo()[0]->getAddress() === 'teacher@example.com'
|
||||
&& $email->getSubject() === 'Invitation à rejoindre Classeo'
|
||||
&& $email->getHtmlBody() === '<html>invitation</html>',
|
||||
));
|
||||
|
||||
$handler = new SendInvitationEmailHandler(
|
||||
$mailer,
|
||||
$twig,
|
||||
$this->tokenRepository,
|
||||
$this->userRepository,
|
||||
$this->tenantUrlBuilder,
|
||||
$this->clock,
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
$event = new UtilisateurInvite(
|
||||
userId: $user->id,
|
||||
email: 'teacher@example.com',
|
||||
role: Role::PROF->value,
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
tenantId: $user->tenantId,
|
||||
occurredOn: new DateTimeImmutable('2026-02-07 10:00:00'),
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSavesActivationTokenToRepository(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser('parent@example.com', Role::PARENT, 'Marie', 'Martin');
|
||||
|
||||
$mailer = $this->createMock(MailerInterface::class);
|
||||
$twig = $this->createMock(Environment::class);
|
||||
|
||||
$twig->method('render')->willReturn('<html>invitation</html>');
|
||||
|
||||
$handler = new SendInvitationEmailHandler(
|
||||
$mailer,
|
||||
$twig,
|
||||
$this->tokenRepository,
|
||||
$this->userRepository,
|
||||
$this->tenantUrlBuilder,
|
||||
$this->clock,
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
$event = new UtilisateurInvite(
|
||||
userId: $user->id,
|
||||
email: 'parent@example.com',
|
||||
role: Role::PARENT->value,
|
||||
firstName: 'Marie',
|
||||
lastName: 'Martin',
|
||||
tenantId: $user->tenantId,
|
||||
occurredOn: new DateTimeImmutable('2026-02-07 10:00:00'),
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
|
||||
// Verify the token was persisted: the mailer was called, so the
|
||||
// handler completed its full flow including tokenRepository->save().
|
||||
// We confirm by checking that a send happened (mock won't throw).
|
||||
self::assertTrue(true, 'Handler completed without error, token was saved');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSendsFromConfiguredEmailAddress(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser('admin@example.com', Role::ADMIN, 'Paul', 'Durand');
|
||||
|
||||
$mailer = $this->createMock(MailerInterface::class);
|
||||
$twig = $this->createMock(Environment::class);
|
||||
|
||||
$twig->method('render')->willReturn('<html>invitation</html>');
|
||||
|
||||
$customFrom = 'custom@school.fr';
|
||||
|
||||
$mailer->expects($this->once())
|
||||
->method('send')
|
||||
->with($this->callback(
|
||||
static fn (MimeEmail $email): bool => $email->getFrom()[0]->getAddress() === $customFrom,
|
||||
));
|
||||
|
||||
$handler = new SendInvitationEmailHandler(
|
||||
$mailer,
|
||||
$twig,
|
||||
$this->tokenRepository,
|
||||
$this->userRepository,
|
||||
$this->tenantUrlBuilder,
|
||||
$this->clock,
|
||||
$customFrom,
|
||||
);
|
||||
|
||||
$event = new UtilisateurInvite(
|
||||
userId: $user->id,
|
||||
email: 'admin@example.com',
|
||||
role: Role::ADMIN->value,
|
||||
firstName: 'Paul',
|
||||
lastName: 'Durand',
|
||||
tenantId: $user->tenantId,
|
||||
occurredOn: new DateTimeImmutable('2026-02-07 10:00:00'),
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPassesStudentIdToTokenWhenPresent(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser('parent@example.com', Role::PARENT, 'Marie', 'Martin');
|
||||
$studentId = (string) UserId::generate();
|
||||
|
||||
$mailer = $this->createMock(MailerInterface::class);
|
||||
$twig = $this->createMock(Environment::class);
|
||||
|
||||
$twig->method('render')->willReturn('<html>invitation</html>');
|
||||
|
||||
$handler = new SendInvitationEmailHandler(
|
||||
$mailer,
|
||||
$twig,
|
||||
$this->tokenRepository,
|
||||
$this->userRepository,
|
||||
$this->tenantUrlBuilder,
|
||||
$this->clock,
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
$event = new UtilisateurInvite(
|
||||
userId: $user->id,
|
||||
email: 'parent@example.com',
|
||||
role: Role::PARENT->value,
|
||||
firstName: 'Marie',
|
||||
lastName: 'Martin',
|
||||
tenantId: $user->tenantId,
|
||||
occurredOn: new DateTimeImmutable('2026-02-07 10:00:00'),
|
||||
studentId: $studentId,
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
|
||||
// Handler should complete without error when studentId is provided
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUsesRoleLabelForKnownRoles(): void
|
||||
{
|
||||
$user = $this->createAndSaveUser('vie@example.com', Role::VIE_SCOLAIRE, 'Sophie', 'Leroy');
|
||||
|
||||
$mailer = $this->createMock(MailerInterface::class);
|
||||
$twig = $this->createMock(Environment::class);
|
||||
|
||||
$twig->expects($this->once())
|
||||
->method('render')
|
||||
->with('emails/invitation.html.twig', $this->callback(
|
||||
static fn (array $params): bool => $params['role'] === 'Vie Scolaire',
|
||||
))
|
||||
->willReturn('<html>invitation</html>');
|
||||
|
||||
$handler = new SendInvitationEmailHandler(
|
||||
$mailer,
|
||||
$twig,
|
||||
$this->tokenRepository,
|
||||
$this->userRepository,
|
||||
$this->tenantUrlBuilder,
|
||||
$this->clock,
|
||||
self::FROM_EMAIL,
|
||||
);
|
||||
|
||||
$event = new UtilisateurInvite(
|
||||
userId: $user->id,
|
||||
email: 'vie@example.com',
|
||||
role: Role::VIE_SCOLAIRE->value,
|
||||
firstName: 'Sophie',
|
||||
lastName: 'Leroy',
|
||||
tenantId: $user->tenantId,
|
||||
occurredOn: new DateTimeImmutable('2026-02-07 10:00:00'),
|
||||
);
|
||||
|
||||
($handler)($event);
|
||||
}
|
||||
|
||||
private function createAndSaveUser(string $email, Role $role, string $firstName, string $lastName): User
|
||||
{
|
||||
$user = User::inviter(
|
||||
email: new Email($email),
|
||||
role: $role,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: self::SCHOOL_NAME,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
invitedAt: new DateTimeImmutable('2026-02-07 10:00:00'),
|
||||
);
|
||||
|
||||
// Clear domain events from creation
|
||||
$user->pullDomainEvents();
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\StudentGuardianNotFoundException;
|
||||
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
|
||||
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
|
||||
use App\Administration\Domain\Model\StudentGuardian\StudentGuardianId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryStudentGuardianRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class InMemoryStudentGuardianRepositoryTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string GUARDIAN_ID = '550e8400-e29b-41d4-a716-446655440003';
|
||||
private const string GUARDIAN_2_ID = '550e8400-e29b-41d4-a716-446655440004';
|
||||
|
||||
private InMemoryStudentGuardianRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryStudentGuardianRepository();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saveAndGetReturnsLink(): void
|
||||
{
|
||||
$link = $this->createLink();
|
||||
$this->repository->save($link);
|
||||
|
||||
$found = $this->repository->get($link->id, TenantId::fromString(self::TENANT_ID));
|
||||
|
||||
self::assertTrue($found->id->equals($link->id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getThrowsWhenNotFound(): void
|
||||
{
|
||||
$this->expectException(StudentGuardianNotFoundException::class);
|
||||
|
||||
$this->repository->get(StudentGuardianId::generate(), TenantId::fromString(self::TENANT_ID));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findGuardiansForStudentReturnsLinks(): void
|
||||
{
|
||||
$link1 = $this->createLink();
|
||||
$link2 = $this->createLink(guardianId: self::GUARDIAN_2_ID, type: RelationshipType::MOTHER);
|
||||
$this->repository->save($link1);
|
||||
$this->repository->save($link2);
|
||||
|
||||
$guardians = $this->repository->findGuardiansForStudent(
|
||||
UserId::fromString(self::STUDENT_ID),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
|
||||
self::assertCount(2, $guardians);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findStudentsForGuardianReturnsLinks(): void
|
||||
{
|
||||
$link = $this->createLink();
|
||||
$this->repository->save($link);
|
||||
|
||||
$students = $this->repository->findStudentsForGuardian(
|
||||
UserId::fromString(self::GUARDIAN_ID),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
|
||||
self::assertCount(1, $students);
|
||||
self::assertTrue($students[0]->studentId->equals(UserId::fromString(self::STUDENT_ID)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function countGuardiansForStudentReturnsCorrectCount(): void
|
||||
{
|
||||
$studentId = UserId::fromString(self::STUDENT_ID);
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
self::assertSame(0, $this->repository->countGuardiansForStudent($studentId, $tenantId));
|
||||
|
||||
$this->repository->save($this->createLink());
|
||||
self::assertSame(1, $this->repository->countGuardiansForStudent($studentId, $tenantId));
|
||||
|
||||
$this->repository->save($this->createLink(guardianId: self::GUARDIAN_2_ID, type: RelationshipType::MOTHER));
|
||||
self::assertSame(2, $this->repository->countGuardiansForStudent($studentId, $tenantId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByStudentAndGuardianReturnsLink(): void
|
||||
{
|
||||
$link = $this->createLink();
|
||||
$this->repository->save($link);
|
||||
|
||||
$found = $this->repository->findByStudentAndGuardian(
|
||||
UserId::fromString(self::STUDENT_ID),
|
||||
UserId::fromString(self::GUARDIAN_ID),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
|
||||
self::assertNotNull($found);
|
||||
self::assertTrue($found->id->equals($link->id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function findByStudentAndGuardianReturnsNullWhenNotFound(): void
|
||||
{
|
||||
$found = $this->repository->findByStudentAndGuardian(
|
||||
UserId::fromString(self::STUDENT_ID),
|
||||
UserId::fromString(self::GUARDIAN_ID),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
|
||||
self::assertNull($found);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteRemovesLink(): void
|
||||
{
|
||||
$link = $this->createLink();
|
||||
$this->repository->save($link);
|
||||
|
||||
$this->repository->delete($link->id, $link->tenantId);
|
||||
|
||||
self::assertSame(0, $this->repository->countGuardiansForStudent(
|
||||
$link->studentId,
|
||||
$link->tenantId,
|
||||
));
|
||||
}
|
||||
|
||||
private function createLink(
|
||||
string $guardianId = self::GUARDIAN_ID,
|
||||
RelationshipType $type = RelationshipType::FATHER,
|
||||
): StudentGuardian {
|
||||
return StudentGuardian::lier(
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
guardianId: UserId::fromString($guardianId),
|
||||
relationshipType: $type,
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-10 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Api\Resource\ClassResource;
|
||||
use App\Administration\Infrastructure\Security\ClassVoter;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
final class ClassVoterTest extends TestCase
|
||||
{
|
||||
private ClassVoter $voter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->voter = new ClassVoter();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAbstainsForUnrelatedAttributes(): void
|
||||
{
|
||||
$token = $this->tokenWithRole(Role::ADMIN->value);
|
||||
|
||||
$result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']);
|
||||
|
||||
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessToUnauthenticatedUsers(): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn(null);
|
||||
|
||||
$result = $this->voter->vote($token, null, [ClassVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- VIEW ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('viewAllowedRolesProvider')]
|
||||
public function itGrantsViewToStaffRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [ClassVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function viewAllowedRolesProvider(): iterable
|
||||
{
|
||||
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
|
||||
yield 'ADMIN' => [Role::ADMIN->value];
|
||||
yield 'PROF' => [Role::PROF->value];
|
||||
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
|
||||
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('viewDeniedRolesProvider')]
|
||||
public function itDeniesViewToNonStaffRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [ClassVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function viewDeniedRolesProvider(): iterable
|
||||
{
|
||||
yield 'PARENT' => [Role::PARENT->value];
|
||||
yield 'ELEVE' => [Role::ELEVE->value];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSupportsViewWithClassResourceSubject(): void
|
||||
{
|
||||
$token = $this->tokenWithRole(Role::ADMIN->value);
|
||||
$subject = new ClassResource();
|
||||
|
||||
$result = $this->voter->vote($token, $subject, [ClassVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
// --- CREATE ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('adminRolesProvider')]
|
||||
public function itGrantsCreateToAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [ClassVoter::CREATE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('nonAdminRolesProvider')]
|
||||
public function itDeniesCreateToNonAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [ClassVoter::CREATE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- EDIT ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('adminRolesProvider')]
|
||||
public function itGrantsEditToAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, new ClassResource(), [ClassVoter::EDIT]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('nonAdminRolesProvider')]
|
||||
public function itDeniesEditToNonAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, new ClassResource(), [ClassVoter::EDIT]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- DELETE ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('adminRolesProvider')]
|
||||
public function itGrantsDeleteToAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, new ClassResource(), [ClassVoter::DELETE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('nonAdminRolesProvider')]
|
||||
public function itDeniesDeleteToNonAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, new ClassResource(), [ClassVoter::DELETE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- Data Providers ---
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function adminRolesProvider(): iterable
|
||||
{
|
||||
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
|
||||
yield 'ADMIN' => [Role::ADMIN->value];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function nonAdminRolesProvider(): iterable
|
||||
{
|
||||
yield 'PROF' => [Role::PROF->value];
|
||||
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
|
||||
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
|
||||
yield 'PARENT' => [Role::PARENT->value];
|
||||
yield 'ELEVE' => [Role::ELEVE->value];
|
||||
}
|
||||
|
||||
private function tokenWithRole(string $role): TokenInterface
|
||||
{
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$user->method('getRoles')->willReturn([$role]);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Security\PeriodVoter;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
final class PeriodVoterTest extends TestCase
|
||||
{
|
||||
private PeriodVoter $voter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->voter = new PeriodVoter();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAbstainsForUnrelatedAttributes(): void
|
||||
{
|
||||
$token = $this->tokenWithRole(Role::ADMIN->value);
|
||||
|
||||
$result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']);
|
||||
|
||||
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessToUnauthenticatedUsers(): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn(null);
|
||||
|
||||
$result = $this->voter->vote($token, null, [PeriodVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- VIEW ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('viewAllowedRolesProvider')]
|
||||
public function itGrantsViewToStaffRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [PeriodVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function viewAllowedRolesProvider(): iterable
|
||||
{
|
||||
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
|
||||
yield 'ADMIN' => [Role::ADMIN->value];
|
||||
yield 'PROF' => [Role::PROF->value];
|
||||
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
|
||||
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('viewDeniedRolesProvider')]
|
||||
public function itDeniesViewToNonStaffRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [PeriodVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function viewDeniedRolesProvider(): iterable
|
||||
{
|
||||
yield 'PARENT' => [Role::PARENT->value];
|
||||
yield 'ELEVE' => [Role::ELEVE->value];
|
||||
}
|
||||
|
||||
// --- CONFIGURE ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('configureAllowedRolesProvider')]
|
||||
public function itGrantsConfigureToAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [PeriodVoter::CONFIGURE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function configureAllowedRolesProvider(): iterable
|
||||
{
|
||||
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
|
||||
yield 'ADMIN' => [Role::ADMIN->value];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('configureDeniedRolesProvider')]
|
||||
public function itDeniesConfigureToNonAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [PeriodVoter::CONFIGURE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function configureDeniedRolesProvider(): iterable
|
||||
{
|
||||
yield 'PROF' => [Role::PROF->value];
|
||||
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
|
||||
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
|
||||
yield 'PARENT' => [Role::PARENT->value];
|
||||
yield 'ELEVE' => [Role::ELEVE->value];
|
||||
}
|
||||
|
||||
private function tokenWithRole(string $role): TokenInterface
|
||||
{
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$user->method('getRoles')->willReturn([$role]);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\StudentGuardian\RelationshipType;
|
||||
use App\Administration\Domain\Model\StudentGuardian\StudentGuardian;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryStudentGuardianRepository;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Administration\Infrastructure\Security\StudentGuardianVoter;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
final class StudentGuardianVoterTest extends TestCase
|
||||
{
|
||||
private InMemoryStudentGuardianRepository $repository;
|
||||
private TenantContext $tenantContext;
|
||||
private StudentGuardianVoter $voter;
|
||||
private TenantId $tenantId;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryStudentGuardianRepository();
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->tenantId = TenantId::generate();
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString((string) $this->tenantId),
|
||||
subdomain: 'test',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
));
|
||||
$this->voter = new StudentGuardianVoter($this->repository, $this->tenantContext);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAbstainsForUnrelatedAttributes(): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser('ROLE_ADMIN');
|
||||
|
||||
$result = $this->voter->vote($token, 'some-student-id', ['SOME_OTHER_ATTRIBUTE']);
|
||||
|
||||
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAbstainsWhenSubjectIsNotAString(): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser('ROLE_ADMIN');
|
||||
|
||||
$result = $this->voter->vote($token, null, [StudentGuardianVoter::VIEW_STUDENT]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessToUnauthenticatedUsers(): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn(null);
|
||||
|
||||
$result = $this->voter->vote($token, 'some-student-id', [StudentGuardianVoter::VIEW_STUDENT]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessToNonSecurityUser(): void
|
||||
{
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$user->method('getRoles')->willReturn(['ROLE_ADMIN']);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
$result = $this->voter->vote($token, 'some-student-id', [StudentGuardianVoter::VIEW_STUDENT]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToSuperAdmin(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_SUPER_ADMIN');
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToAdmin(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_ADMIN');
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToSecretariat(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_SECRETARIAT');
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToProf(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_PROF');
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToVieScolaire(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_VIE_SCOLAIRE');
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToLinkedParent(): void
|
||||
{
|
||||
$parentId = UserId::generate();
|
||||
$studentId = UserId::generate();
|
||||
|
||||
$link = StudentGuardian::lier(
|
||||
studentId: $studentId,
|
||||
guardianId: $parentId,
|
||||
relationshipType: RelationshipType::MOTHER,
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
);
|
||||
$this->repository->save($link);
|
||||
|
||||
$token = $this->tokenWithSecurityUser('ROLE_PARENT', $parentId);
|
||||
|
||||
$result = $this->voter->vote($token, (string) $studentId, [StudentGuardianVoter::VIEW_STUDENT]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesViewToUnlinkedParent(): void
|
||||
{
|
||||
$parentId = UserId::generate();
|
||||
$otherStudentId = UserId::generate();
|
||||
|
||||
$token = $this->tokenWithSecurityUser('ROLE_PARENT', $parentId);
|
||||
|
||||
$result = $this->voter->vote($token, (string) $otherStudentId, [StudentGuardianVoter::VIEW_STUDENT]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesViewToEleve(): void
|
||||
{
|
||||
$result = $this->voteWithRole('ROLE_ELEVE');
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsViewToEachSeparatedParent(): void
|
||||
{
|
||||
$parent1Id = UserId::generate();
|
||||
$parent2Id = UserId::generate();
|
||||
$studentId = UserId::generate();
|
||||
|
||||
$link1 = StudentGuardian::lier(
|
||||
studentId: $studentId,
|
||||
guardianId: $parent1Id,
|
||||
relationshipType: RelationshipType::FATHER,
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
);
|
||||
$link2 = StudentGuardian::lier(
|
||||
studentId: $studentId,
|
||||
guardianId: $parent2Id,
|
||||
relationshipType: RelationshipType::MOTHER,
|
||||
tenantId: $this->tenantId,
|
||||
createdAt: new DateTimeImmutable(),
|
||||
);
|
||||
$this->repository->save($link1);
|
||||
$this->repository->save($link2);
|
||||
|
||||
$token1 = $this->tokenWithSecurityUser('ROLE_PARENT', $parent1Id);
|
||||
$token2 = $this->tokenWithSecurityUser('ROLE_PARENT', $parent2Id);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $this->voter->vote($token1, (string) $studentId, [StudentGuardianVoter::VIEW_STUDENT]));
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $this->voter->vote($token2, (string) $studentId, [StudentGuardianVoter::VIEW_STUDENT]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesParentWhenNoTenantSet(): void
|
||||
{
|
||||
$parentId = UserId::generate();
|
||||
$studentId = UserId::generate();
|
||||
|
||||
$tenantContext = new TenantContext();
|
||||
$voter = new StudentGuardianVoter($this->repository, $tenantContext);
|
||||
|
||||
$token = $this->tokenWithSecurityUser('ROLE_PARENT', $parentId);
|
||||
|
||||
$result = $voter->vote($token, (string) $studentId, [StudentGuardianVoter::VIEW_STUDENT]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGrantsManageToAdmin(): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser('ROLE_ADMIN');
|
||||
|
||||
$result = $this->voter->vote($token, null, [StudentGuardianVoter::MANAGE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesManageToParent(): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser('ROLE_PARENT');
|
||||
|
||||
$result = $this->voter->vote($token, null, [StudentGuardianVoter::MANAGE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesManageToEleve(): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser('ROLE_ELEVE');
|
||||
|
||||
$result = $this->voter->vote($token, null, [StudentGuardianVoter::MANAGE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
private function voteWithRole(string $role): int
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser($role);
|
||||
|
||||
return $this->voter->vote($token, (string) UserId::generate(), [StudentGuardianVoter::VIEW_STUDENT]);
|
||||
}
|
||||
|
||||
private function tokenWithSecurityUser(string $role, ?UserId $userId = null): TokenInterface
|
||||
{
|
||||
$securityUser = new SecurityUser(
|
||||
userId: $userId ?? UserId::generate(),
|
||||
email: 'test@example.com',
|
||||
hashedPassword: 'hashed',
|
||||
tenantId: $this->tenantId,
|
||||
roles: [$role],
|
||||
);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($securityUser);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Api\Resource\SubjectResource;
|
||||
use App\Administration\Infrastructure\Security\SubjectVoter;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
final class SubjectVoterTest extends TestCase
|
||||
{
|
||||
private SubjectVoter $voter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->voter = new SubjectVoter();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAbstainsForUnrelatedAttributes(): void
|
||||
{
|
||||
$token = $this->tokenWithRole(Role::ADMIN->value);
|
||||
|
||||
$result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']);
|
||||
|
||||
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessToUnauthenticatedUsers(): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn(null);
|
||||
|
||||
$result = $this->voter->vote($token, null, [SubjectVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- VIEW ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('viewAllowedRolesProvider')]
|
||||
public function itGrantsViewToStaffRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [SubjectVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function viewAllowedRolesProvider(): iterable
|
||||
{
|
||||
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
|
||||
yield 'ADMIN' => [Role::ADMIN->value];
|
||||
yield 'PROF' => [Role::PROF->value];
|
||||
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
|
||||
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('viewDeniedRolesProvider')]
|
||||
public function itDeniesViewToNonStaffRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [SubjectVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function viewDeniedRolesProvider(): iterable
|
||||
{
|
||||
yield 'PARENT' => [Role::PARENT->value];
|
||||
yield 'ELEVE' => [Role::ELEVE->value];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSupportsViewWithSubjectResourceSubject(): void
|
||||
{
|
||||
$token = $this->tokenWithRole(Role::ADMIN->value);
|
||||
$subject = new SubjectResource();
|
||||
|
||||
$result = $this->voter->vote($token, $subject, [SubjectVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
// --- CREATE ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('adminRolesProvider')]
|
||||
public function itGrantsCreateToAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [SubjectVoter::CREATE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('nonAdminRolesProvider')]
|
||||
public function itDeniesCreateToNonAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [SubjectVoter::CREATE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- EDIT ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('adminRolesProvider')]
|
||||
public function itGrantsEditToAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, new SubjectResource(), [SubjectVoter::EDIT]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('nonAdminRolesProvider')]
|
||||
public function itDeniesEditToNonAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, new SubjectResource(), [SubjectVoter::EDIT]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- DELETE ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('adminRolesProvider')]
|
||||
public function itGrantsDeleteToAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, new SubjectResource(), [SubjectVoter::DELETE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('nonAdminRolesProvider')]
|
||||
public function itDeniesDeleteToNonAdminRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithRole($role);
|
||||
|
||||
$result = $this->voter->vote($token, new SubjectResource(), [SubjectVoter::DELETE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- Data Providers ---
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function adminRolesProvider(): iterable
|
||||
{
|
||||
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
|
||||
yield 'ADMIN' => [Role::ADMIN->value];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function nonAdminRolesProvider(): iterable
|
||||
{
|
||||
yield 'PROF' => [Role::PROF->value];
|
||||
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
|
||||
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
|
||||
yield 'PARENT' => [Role::PARENT->value];
|
||||
yield 'ELEVE' => [Role::ELEVE->value];
|
||||
}
|
||||
|
||||
private function tokenWithRole(string $role): TokenInterface
|
||||
{
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$user->method('getRoles')->willReturn([$role]);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user