feat: Permettre aux enseignants de contourner les règles de devoirs avec justification
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Akeneo permet de configurer des règles de devoirs en mode Hard qui bloquent
totalement la création. Or certains cas légitimes (sorties scolaires, événements
exceptionnels) nécessitent de passer outre ces règles. Sans mécanisme d'exception,
l'enseignant est bloqué et doit contacter manuellement la direction.

Cette implémentation ajoute un flux complet d'exception : l'enseignant justifie
sa demande (min 20 caractères), le devoir est créé immédiatement, et la direction
est notifiée par email. Le handler vérifie côté serveur que les règles sont
réellement bloquantes avant d'accepter l'exception, empêchant toute fabrication
de fausses exceptions via l'API. La direction dispose d'un rapport filtrable
par période, enseignant et type de règle.
This commit is contained in:
2026-03-19 21:58:56 +01:00
parent d34d31976f
commit 3446cbf04b
33 changed files with 3496 additions and 10 deletions

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\CreateHomeworkWithException;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\CreateHomeworkWithException\CreateHomeworkWithExceptionCommand;
use App\Scolarite\Application\Command\CreateHomeworkWithException\CreateHomeworkWithExceptionHandler;
use App\Scolarite\Application\Port\CurrentCalendarProvider;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Application\Port\HomeworkRulesChecker;
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
use App\Scolarite\Application\Port\RuleWarning;
use App\Scolarite\Domain\Event\ExceptionDevoirDemandee;
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
use App\Scolarite\Domain\Exception\ExceptionNonNecessaireException;
use App\Scolarite\Domain\Exception\JustificationTropCourteException;
use App\Scolarite\Domain\Model\Homework\HomeworkStatus;
use App\Scolarite\Domain\Service\DueDateValidator;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRuleExceptionRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CreateHomeworkWithExceptionHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryHomeworkRepository $homeworkRepository;
private InMemoryHomeworkRuleExceptionRepository $exceptionRepository;
private Clock $clock;
protected function setUp(): void
{
$this->homeworkRepository = new InMemoryHomeworkRepository();
$this->exceptionRepository = new InMemoryHomeworkRuleExceptionRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-19 10:00:00');
}
};
}
#[Test]
public function itCreatesHomeworkAndExceptionWhenRulesAreBlocking(): void
{
$handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult());
$command = $this->createCommand();
$result = $handler($command);
self::assertSame(HomeworkStatus::PUBLISHED, $result['homework']->status);
self::assertSame('Exercices chapitre 5', $result['homework']->title);
self::assertSame('minimum_delay', $result['exception']->ruleType);
self::assertSame('Sortie scolaire prévue, devoir urgent pour les élèves.', $result['exception']->justification);
}
#[Test]
public function itPersistsBothInRepositories(): void
{
$handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult());
$command = $this->createCommand();
$result = $handler($command);
$tenantId = TenantId::fromString(self::TENANT_ID);
$savedHomework = $this->homeworkRepository->get($result['homework']->id, $tenantId);
$savedExceptions = $this->exceptionRepository->findByHomework($result['homework']->id, $tenantId);
self::assertSame('Exercices chapitre 5', $savedHomework->title);
self::assertCount(1, $savedExceptions);
self::assertTrue($savedExceptions[0]->homeworkId->equals($result['homework']->id));
}
#[Test]
public function itUsesServerSideRuleTypesNotClientProvided(): void
{
$handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult());
// Client sends ['fake_rule'] but server should use the actual rule types
$command = $this->createCommand(ruleTypes: ['fake_rule']);
$result = $handler($command);
// ruleType comes from server-side check, not from client
self::assertSame('minimum_delay', $result['exception']->ruleType);
}
#[Test]
public function itRecordsDomainEvents(): void
{
$handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult());
$result = $handler($this->createCommand());
$exceptionEvents = $result['exception']->pullDomainEvents();
self::assertCount(1, $exceptionEvents);
self::assertInstanceOf(ExceptionDevoirDemandee::class, $exceptionEvents[0]);
self::assertSame(['minimum_delay'], $exceptionEvents[0]->ruleTypes);
}
#[Test]
public function itThrowsWhenRulesAreNotBlocking(): void
{
$handler = $this->createHandler(affecte: true, rulesResult: HomeworkRulesCheckResult::ok());
$this->expectException(ExceptionNonNecessaireException::class);
$handler($this->createCommand());
}
#[Test]
public function itThrowsWhenRulesAreSoftWarningOnly(): void
{
$softResult = new HomeworkRulesCheckResult(
warnings: [new RuleWarning('minimum_delay', 'Délai trop court', ['days' => 3])],
bloquant: false,
);
$handler = $this->createHandler(affecte: true, rulesResult: $softResult);
$this->expectException(ExceptionNonNecessaireException::class);
$handler($this->createCommand());
}
#[Test]
public function itThrowsWhenTeacherNotAffected(): void
{
$handler = $this->createHandler(affecte: false, rulesResult: $this->blockingResult());
$this->expectException(EnseignantNonAffecteException::class);
$handler($this->createCommand());
}
#[Test]
public function itThrowsWhenJustificationTooShort(): void
{
$handler = $this->createHandler(affecte: true, rulesResult: $this->blockingResult());
$this->expectException(JustificationTropCourteException::class);
$handler($this->createCommand(justification: 'Trop court'));
}
#[Test]
public function itStoresMultipleRuleTypesFromServerCheck(): void
{
$multiRuleResult = new HomeworkRulesCheckResult(
warnings: [
new RuleWarning('minimum_delay', 'Délai trop court', ['days' => 7]),
new RuleWarning('no_monday_after', 'Pas de lundi', ['day' => 'friday', 'time' => '12:00']),
],
bloquant: true,
);
$handler = $this->createHandler(affecte: true, rulesResult: $multiRuleResult);
$result = $handler($this->createCommand());
self::assertSame('minimum_delay,no_monday_after', $result['exception']->ruleType);
}
private function blockingResult(): HomeworkRulesCheckResult
{
return new HomeworkRulesCheckResult(
warnings: [new RuleWarning('minimum_delay', 'Le devoir doit être créé au moins 7 jours avant.', ['days' => 7])],
bloquant: true,
suggestedDates: ['2026-03-30'],
);
}
private function createHandler(bool $affecte, HomeworkRulesCheckResult $rulesResult): CreateHomeworkWithExceptionHandler
{
$affectationChecker = new class($affecte) implements EnseignantAffectationChecker {
public function __construct(private readonly bool $affecte)
{
}
public function estAffecte(UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId): bool
{
return $this->affecte;
}
};
$calendarProvider = new class implements CurrentCalendarProvider {
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
{
return SchoolCalendar::reconstitute(
tenantId: $tenantId,
academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'),
zone: null,
entries: [],
);
}
};
$rulesChecker = new class($rulesResult) implements HomeworkRulesChecker {
public function __construct(private readonly HomeworkRulesCheckResult $result)
{
}
public function verifier(TenantId $tenantId, DateTimeImmutable $dueDate, DateTimeImmutable $creationDate): HomeworkRulesCheckResult
{
return $this->result;
}
};
$connection = $this->createMock(Connection::class);
return new CreateHomeworkWithExceptionHandler(
$this->homeworkRepository,
$this->exceptionRepository,
$affectationChecker,
$calendarProvider,
new DueDateValidator(),
$rulesChecker,
$this->clock,
$connection,
);
}
/**
* @param string[]|null $ruleTypes
*/
private function createCommand(
?string $justification = null,
?array $ruleTypes = null,
): CreateHomeworkWithExceptionCommand {
return new CreateHomeworkWithExceptionCommand(
tenantId: self::TENANT_ID,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
title: 'Exercices chapitre 5',
description: 'Faire les exercices 1 à 10',
dueDate: '2026-04-15',
justification: $justification ?? 'Sortie scolaire prévue, devoir urgent pour les élèves.',
ruleTypes: $ruleTypes ?? ['minimum_delay'],
);
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetHomeworkExceptionsReport;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
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\Repository\UserRepository;
use App\Scolarite\Application\Query\GetHomeworkExceptionsReport\GetHomeworkExceptionsReportHandler;
use App\Scolarite\Application\Query\GetHomeworkExceptionsReport\GetHomeworkExceptionsReportQuery;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\Homework\HomeworkRuleException;
use App\Scolarite\Domain\Model\Homework\HomeworkStatus;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRuleExceptionRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetHomeworkExceptionsReportHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string TEACHER2_ID = '550e8400-e29b-41d4-a716-446655440011';
private InMemoryHomeworkRepository $homeworkRepository;
private InMemoryHomeworkRuleExceptionRepository $exceptionRepository;
protected function setUp(): void
{
$this->homeworkRepository = new InMemoryHomeworkRepository();
$this->exceptionRepository = new InMemoryHomeworkRuleExceptionRepository();
}
#[Test]
public function itReturnsAllExceptionsForTenant(): void
{
$this->seedData();
$handler = $this->createHandler();
$results = $handler(new GetHomeworkExceptionsReportQuery(tenantId: self::TENANT_ID));
self::assertCount(2, $results);
self::assertSame('Exercices chapitre 5', $results[0]->homeworkTitle);
self::assertSame('Jean Dupont', $results[0]->teacherName);
}
#[Test]
public function itFiltersByDateRange(): void
{
$this->seedData();
$handler = $this->createHandler();
$results = $handler(new GetHomeworkExceptionsReportQuery(
tenantId: self::TENANT_ID,
startDate: '2026-03-20',
endDate: '2026-03-25',
));
self::assertCount(1, $results);
self::assertSame('Devoir histoire', $results[0]->homeworkTitle);
}
#[Test]
public function itFiltersByTeacher(): void
{
$this->seedData();
$handler = $this->createHandler();
$results = $handler(new GetHomeworkExceptionsReportQuery(
tenantId: self::TENANT_ID,
teacherId: self::TEACHER2_ID,
));
self::assertCount(1, $results);
self::assertSame(self::TEACHER2_ID, $results[0]->teacherId);
}
#[Test]
public function itFiltersByRuleType(): void
{
$this->seedData();
$handler = $this->createHandler();
$results = $handler(new GetHomeworkExceptionsReportQuery(
tenantId: self::TENANT_ID,
ruleType: 'no_monday_after',
));
self::assertCount(1, $results);
self::assertStringContainsString('no_monday_after', $results[0]->ruleType);
}
#[Test]
public function itReturnsEmptyWhenNoExceptions(): void
{
$handler = $this->createHandler();
$results = $handler(new GetHomeworkExceptionsReportQuery(tenantId: self::TENANT_ID));
self::assertCount(0, $results);
}
private function seedData(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$hw1 = Homework::reconstitute(
id: HomeworkId::fromString('550e8400-e29b-41d4-a716-446655440050'),
tenantId: $tenantId,
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
teacherId: UserId::fromString(self::TEACHER_ID),
title: 'Exercices chapitre 5',
description: null,
dueDate: new DateTimeImmutable('2026-04-15'),
status: HomeworkStatus::PUBLISHED,
createdAt: new DateTimeImmutable('2026-03-19 10:00:00'),
updatedAt: new DateTimeImmutable('2026-03-19 10:00:00'),
);
$this->homeworkRepository->save($hw1);
$hw2 = Homework::reconstitute(
id: HomeworkId::fromString('550e8400-e29b-41d4-a716-446655440051'),
tenantId: $tenantId,
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
teacherId: UserId::fromString(self::TEACHER2_ID),
title: 'Devoir histoire',
description: null,
dueDate: new DateTimeImmutable('2026-04-20'),
status: HomeworkStatus::PUBLISHED,
createdAt: new DateTimeImmutable('2026-03-21 10:00:00'),
updatedAt: new DateTimeImmutable('2026-03-21 10:00:00'),
);
$this->homeworkRepository->save($hw2);
$ex1 = HomeworkRuleException::demander(
tenantId: $tenantId,
homeworkId: $hw1->id,
ruleTypes: ['minimum_delay'],
justification: 'Sortie scolaire prévue, devoir urgent pour les élèves.',
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-19 10:00:00'),
);
$this->exceptionRepository->save($ex1);
$ex2 = HomeworkRuleException::demander(
tenantId: $tenantId,
homeworkId: $hw2->id,
ruleTypes: ['no_monday_after'],
justification: 'Rentrée après les vacances, devoir préparatoire.',
createdBy: UserId::fromString(self::TEACHER2_ID),
now: new DateTimeImmutable('2026-03-21 10:00:00'),
);
$this->exceptionRepository->save($ex2);
}
private function createHandler(): GetHomeworkExceptionsReportHandler
{
$userRepository = $this->createMock(UserRepository::class);
$userRepository->method('findById')
->willReturnCallback(static function (UserId $id): ?User {
$map = [
self::TEACHER_ID => ['Jean', 'Dupont'],
self::TEACHER2_ID => ['Marie', 'Martin'],
];
$data = $map[(string) $id] ?? null;
if ($data === null) {
return null;
}
return User::reconstitute(
id: $id,
email: new Email('test@test.fr'),
roles: [Role::PROF],
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Test',
statut: StatutCompte::ACTIF,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-01'),
hashedPassword: 'hashed',
activatedAt: new DateTimeImmutable('2026-01-02'),
consentementParental: null,
firstName: $data[0],
lastName: $data[1],
);
});
return new GetHomeworkExceptionsReportHandler(
$this->exceptionRepository,
$this->homeworkRepository,
$userRepository,
);
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\Homework;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\ExceptionDevoirDemandee;
use App\Scolarite\Domain\Exception\JustificationTropCourteException;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\Homework\HomeworkRuleException;
use App\Scolarite\Domain\Model\Homework\HomeworkRuleExceptionId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use function str_repeat;
final class HomeworkRuleExceptionTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string HOMEWORK_ID = '550e8400-e29b-41d4-a716-446655440050';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function demanderCreatesExceptionWithAllFields(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$homeworkId = HomeworkId::fromString(self::HOMEWORK_ID);
$teacherId = UserId::fromString(self::TEACHER_ID);
$now = new DateTimeImmutable('2026-03-19 10:00:00');
$exception = HomeworkRuleException::demander(
tenantId: $tenantId,
homeworkId: $homeworkId,
ruleTypes: ['minimum_delay'],
justification: 'Sortie scolaire prévue, les élèves doivent préparer leur dossier.',
createdBy: $teacherId,
now: $now,
);
self::assertNotEmpty((string) $exception->id);
self::assertTrue($exception->tenantId->equals($tenantId));
self::assertTrue($exception->homeworkId->equals($homeworkId));
self::assertSame('minimum_delay', $exception->ruleType);
self::assertSame(['minimum_delay'], $exception->ruleTypes());
self::assertSame('Sortie scolaire prévue, les élèves doivent préparer leur dossier.', $exception->justification);
self::assertTrue($exception->createdBy->equals($teacherId));
self::assertEquals($now, $exception->createdAt);
}
#[Test]
public function demanderStoresMultipleRuleTypesCommaSeparated(): void
{
$exception = HomeworkRuleException::demander(
tenantId: TenantId::fromString(self::TENANT_ID),
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
ruleTypes: ['minimum_delay', 'no_monday_after'],
justification: 'Sortie scolaire et lundi exceptionnel.',
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-19 10:00:00'),
);
self::assertSame('minimum_delay,no_monday_after', $exception->ruleType);
self::assertSame(['minimum_delay', 'no_monday_after'], $exception->ruleTypes());
}
#[Test]
public function demanderRecordsExceptionDevoirDemandeeEvent(): void
{
$exception = $this->createException();
$events = $exception->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(ExceptionDevoirDemandee::class, $events[0]);
self::assertSame($exception->id, $events[0]->exceptionId);
self::assertSame(['minimum_delay'], $events[0]->ruleTypes);
}
#[Test]
public function demanderThrowsWhenJustificationTooShort(): void
{
$this->expectException(JustificationTropCourteException::class);
$this->expectExceptionMessage('au moins 20 caractères');
HomeworkRuleException::demander(
tenantId: TenantId::fromString(self::TENANT_ID),
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
ruleTypes: ['minimum_delay'],
justification: 'Trop court',
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-19 10:00:00'),
);
}
#[Test]
public function demanderAcceptsExactlyMinimumLength(): void
{
$justification = str_repeat('x', 20);
$exception = HomeworkRuleException::demander(
tenantId: TenantId::fromString(self::TENANT_ID),
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
ruleTypes: ['minimum_delay'],
justification: $justification,
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-19 10:00:00'),
);
self::assertSame($justification, $exception->justification);
}
#[Test]
public function demanderRejectsNineteenCharacters(): void
{
$this->expectException(JustificationTropCourteException::class);
HomeworkRuleException::demander(
tenantId: TenantId::fromString(self::TENANT_ID),
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
ruleTypes: ['minimum_delay'],
justification: str_repeat('x', 19),
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-19 10:00:00'),
);
}
#[Test]
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
{
$id = HomeworkRuleExceptionId::generate();
$tenantId = TenantId::fromString(self::TENANT_ID);
$homeworkId = HomeworkId::fromString(self::HOMEWORK_ID);
$teacherId = UserId::fromString(self::TEACHER_ID);
$createdAt = new DateTimeImmutable('2026-03-19 10:00:00');
$exception = HomeworkRuleException::reconstitute(
id: $id,
tenantId: $tenantId,
homeworkId: $homeworkId,
ruleType: 'minimum_delay',
justification: 'Justification suffisamment longue pour passer.',
createdBy: $teacherId,
createdAt: $createdAt,
);
self::assertTrue($exception->id->equals($id));
self::assertTrue($exception->tenantId->equals($tenantId));
self::assertTrue($exception->homeworkId->equals($homeworkId));
self::assertSame('minimum_delay', $exception->ruleType);
self::assertSame('Justification suffisamment longue pour passer.', $exception->justification);
self::assertTrue($exception->createdBy->equals($teacherId));
self::assertEquals($createdAt, $exception->createdAt);
self::assertEmpty($exception->pullDomainEvents());
}
private function createException(): HomeworkRuleException
{
return HomeworkRuleException::demander(
tenantId: TenantId::fromString(self::TENANT_ID),
homeworkId: HomeworkId::fromString(self::HOMEWORK_ID),
ruleTypes: ['minimum_delay'],
justification: 'Sortie scolaire prévue, les élèves doivent préparer leur dossier.',
createdBy: UserId::fromString(self::TEACHER_ID),
now: new DateTimeImmutable('2026-03-19 10:00:00'),
);
}
}

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Infrastructure\Messaging;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
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\Repository\UserRepository;
use App\Scolarite\Domain\Event\ExceptionDevoirDemandee;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\Homework\HomeworkRuleExceptionId;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Infrastructure\Messaging\OnExceptionDevoirDemandeeHandler;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use RuntimeException;
use Symfony\Component\Mailer\MailerInterface;
use Twig\Environment;
final class OnExceptionDevoirDemandeeHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function itSendsEmailToAllDirectorsInTenant(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$homeworkRepo = $this->createMock(HomeworkRepository::class);
$userRepo = $this->createMock(UserRepository::class);
$homework = $this->createHomework();
$homeworkRepo->method('findById')->willReturn($homework);
$teacher = $this->createUser('teacher@school.fr', [Role::PROF], 'Jean', 'Dupont');
$userRepo->method('findById')->willReturn($teacher);
$userRepo->method('findAllByTenant')->willReturn([
$this->createUser('director@school.fr', [Role::ADMIN], 'Marie', 'Martin'),
$this->createUser('teacher@school.fr', [Role::PROF], 'Jean', 'Dupont'),
$this->createUser('parent@school.fr', [Role::PARENT], 'Pierre', 'Durand'),
]);
$twig->method('render')->willReturn('<html>notification</html>');
$mailer->expects(self::exactly(1))->method('send');
$handler = new OnExceptionDevoirDemandeeHandler(
$homeworkRepo,
$userRepo,
$mailer,
$twig,
new NullLogger(),
);
($handler)($this->createEvent());
}
#[Test]
public function itSkipsWhenNoDirectorsInTenant(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$homeworkRepo = $this->createMock(HomeworkRepository::class);
$userRepo = $this->createMock(UserRepository::class);
$homeworkRepo->method('findById')->willReturn($this->createHomework());
$userRepo->method('findById')->willReturn($this->createUser('teacher@school.fr', [Role::PROF]));
$userRepo->method('findAllByTenant')->willReturn([
$this->createUser('teacher@school.fr', [Role::PROF]),
]);
$mailer->expects(self::never())->method('send');
$handler = new OnExceptionDevoirDemandeeHandler(
$homeworkRepo,
$userRepo,
$mailer,
$twig,
new NullLogger(),
);
($handler)($this->createEvent());
}
#[Test]
public function itHandlesMailerFailureGracefully(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$homeworkRepo = $this->createMock(HomeworkRepository::class);
$userRepo = $this->createMock(UserRepository::class);
$homeworkRepo->method('findById')->willReturn($this->createHomework());
$userRepo->method('findById')->willReturn($this->createUser('teacher@school.fr', [Role::PROF]));
$userRepo->method('findAllByTenant')->willReturn([
$this->createUser('director@school.fr', [Role::ADMIN]),
]);
$twig->method('render')->willReturn('<html>notification</html>');
$mailer->method('send')->willThrowException(new RuntimeException('SMTP error'));
$handler = new OnExceptionDevoirDemandeeHandler(
$homeworkRepo,
$userRepo,
$mailer,
$twig,
new NullLogger(),
);
($handler)($this->createEvent());
$this->addToAssertionCount(1);
}
#[Test]
public function itPassesCorrectDataToTemplate(): void
{
$mailer = $this->createMock(MailerInterface::class);
$twig = $this->createMock(Environment::class);
$homeworkRepo = $this->createMock(HomeworkRepository::class);
$userRepo = $this->createMock(UserRepository::class);
$homeworkRepo->method('findById')->willReturn($this->createHomework());
$teacher = $this->createUser('teacher@school.fr', [Role::PROF], 'Jean', 'Dupont');
$userRepo->method('findById')->willReturn($teacher);
$userRepo->method('findAllByTenant')->willReturn([
$this->createUser('director@school.fr', [Role::ADMIN]),
]);
$twig->expects(self::once())
->method('render')
->with(
'emails/homework_exception_notification.html.twig',
self::callback(static function (array $params): bool {
return $params['teacherName'] === 'Jean Dupont'
&& $params['homeworkTitle'] === 'Exercices chapitre 5'
&& $params['ruleTypes'] === ['minimum_delay']
&& $params['justification'] === 'Sortie scolaire prévue, devoir urgent.'
&& $params['dueDate'] === '15/04/2026';
}),
)
->willReturn('<html>notification</html>');
$mailer->expects(self::once())->method('send');
$handler = new OnExceptionDevoirDemandeeHandler(
$homeworkRepo,
$userRepo,
$mailer,
$twig,
new NullLogger(),
);
($handler)($this->createEvent());
}
private function createEvent(): ExceptionDevoirDemandee
{
return new ExceptionDevoirDemandee(
exceptionId: HomeworkRuleExceptionId::generate(),
tenantId: TenantId::fromString(self::TENANT_ID),
homeworkId: HomeworkId::fromString('550e8400-e29b-41d4-a716-446655440050'),
ruleTypes: ['minimum_delay'],
justification: 'Sortie scolaire prévue, devoir urgent.',
createdBy: UserId::fromString(self::TEACHER_ID),
occurredOn: new DateTimeImmutable('2026-03-19 10:00:00'),
);
}
private function createHomework(): Homework
{
return Homework::reconstitute(
id: HomeworkId::fromString('550e8400-e29b-41d4-a716-446655440050'),
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
teacherId: UserId::fromString(self::TEACHER_ID),
title: 'Exercices chapitre 5',
description: 'Faire les exercices 1 à 10',
dueDate: new DateTimeImmutable('2026-04-15'),
status: \App\Scolarite\Domain\Model\Homework\HomeworkStatus::PUBLISHED,
createdAt: new DateTimeImmutable('2026-03-19 10:00:00'),
updatedAt: new DateTimeImmutable('2026-03-19 10:00:00'),
);
}
/**
* @param Role[] $roles
*/
private function createUser(
string $email,
array $roles,
string $firstName = 'Test',
string $lastName = 'User',
): User {
return User::reconstitute(
id: UserId::generate(),
email: new Email($email),
roles: $roles,
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Test',
statut: StatutCompte::ACTIF,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-01'),
hashedPassword: 'hashed',
activatedAt: new DateTimeImmutable('2026-01-02'),
consentementParental: null,
firstName: $firstName,
lastName: $lastName,
);
}
}