Files
Classeo/backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php
Mathias STRASSER ff18850a43 feat: Configuration du mode de notation par établissement
Les établissements scolaires utilisent des systèmes d'évaluation variés
(notes /20, /10, lettres, compétences, sans notes). Jusqu'ici l'application
imposait implicitement le mode notes /20, ce qui ne correspondait pas
à la réalité pédagogique de nombreuses écoles.

Cette configuration permet à chaque établissement de choisir son mode
de notation par année scolaire, avec verrouillage automatique dès que
des notes ont été saisies pour éviter les incohérences. Le Score Sérénité
adapte ses pondérations selon le mode choisi (les compétences sont
converties via un mapping, le mode sans notes exclut la composante notes).
2026-02-07 02:32:20 +01:00

191 lines
6.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\UpdatePeriod;
use App\Administration\Application\Command\UpdatePeriod\UpdatePeriodCommand;
use App\Administration\Application\Command\UpdatePeriod\UpdatePeriodHandler;
use App\Administration\Application\Port\GradeExistenceChecker;
use App\Administration\Domain\Exception\PeriodeAvecNotesException;
use App\Administration\Domain\Exception\PeriodeNonTrouveeException;
use App\Administration\Domain\Exception\PeriodesNonConfigureesException;
use App\Administration\Domain\Exception\PeriodsOverlapException;
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
use App\Administration\Domain\Model\AcademicYear\PeriodType;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
use App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
final class UpdatePeriodHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440002';
private InMemoryPeriodConfigurationRepository $repository;
private UpdatePeriodHandler $handler;
private Clock $clock;
private MessageBusInterface $eventBus;
protected function setUp(): void
{
$this->repository = new InMemoryPeriodConfigurationRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2025-10-15 10:00:00');
}
};
$this->eventBus = new class implements MessageBusInterface {
public function dispatch(object $message, array $stamps = []): Envelope
{
return new Envelope($message);
}
};
$this->handler = new UpdatePeriodHandler($this->repository, new NoOpGradeExistenceChecker(), $this->clock, $this->eventBus);
}
#[Test]
public function itRejectsOverlappingPeriodDates(): void
{
$this->seedTrimesterConfig();
$command = new UpdatePeriodCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
sequence: 1,
startDate: '2025-09-01',
endDate: '2025-12-15',
);
$this->expectException(PeriodsOverlapException::class);
($this->handler)($command);
}
#[Test]
public function itUpdatesValidPeriodDates(): void
{
$this->seedTrimesterConfig();
$command = new UpdatePeriodCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
sequence: 1,
startDate: '2025-09-02',
endDate: '2025-11-30',
);
$config = ($this->handler)($command);
self::assertSame('2025-09-02', $config->periods[0]->startDate->format('Y-m-d'));
self::assertSame('2025-11-30', $config->periods[0]->endDate->format('Y-m-d'));
}
#[Test]
public function itRejectsWhenNoConfigurationExists(): void
{
$this->expectException(PeriodesNonConfigureesException::class);
($this->handler)(new UpdatePeriodCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
sequence: 1,
startDate: '2025-09-01',
endDate: '2025-11-30',
));
}
#[Test]
public function itRejectsUnknownSequence(): void
{
$this->seedTrimesterConfig();
$this->expectException(PeriodeNonTrouveeException::class);
($this->handler)(new UpdatePeriodCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
sequence: 99,
startDate: '2025-09-01',
endDate: '2025-11-30',
));
}
#[Test]
public function itRejectsUpdateWhenPeriodHasGradesWithoutConfirmation(): void
{
$this->seedTrimesterConfig();
$gradeChecker = $this->createGradeCheckerWithGrades();
$handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus);
$this->expectException(PeriodeAvecNotesException::class);
$handler(new UpdatePeriodCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
sequence: 1,
startDate: '2025-09-02',
endDate: '2025-11-30',
));
}
#[Test]
public function itAllowsUpdateWhenPeriodHasGradesWithConfirmation(): void
{
$this->seedTrimesterConfig();
$gradeChecker = $this->createGradeCheckerWithGrades();
$handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus);
$config = $handler(new UpdatePeriodCommand(
tenantId: self::TENANT_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
sequence: 1,
startDate: '2025-09-02',
endDate: '2025-11-30',
confirmImpact: true,
));
self::assertSame('2025-09-02', $config->periods[0]->startDate->format('Y-m-d'));
}
private function createGradeCheckerWithGrades(): GradeExistenceChecker
{
return new class implements GradeExistenceChecker {
#[Override]
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
{
return true;
}
#[Override]
public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool
{
return true;
}
};
}
private function seedTrimesterConfig(): void
{
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
$this->repository->save(
TenantId::fromString(self::TENANT_ID),
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
$config,
);
}
}