feat: Gestion des périodes scolaires
L'administration d'un établissement nécessite de découper l'année scolaire en trimestres ou semestres avant de pouvoir saisir les notes et générer les bulletins. Ce module permet de configurer les périodes par année scolaire (current/previous/next résolus en UUID v5 déterministes), de modifier les dates individuelles avec validation anti-chevauchement, et de consulter la période en cours avec le décompte des jours restants. Les dates par défaut de février s'adaptent aux années bissextiles. Le repository utilise UPSERT transactionnel pour garantir l'intégrité lors du changement de mode (trimestres ↔ semestres). Les domain events de Subject sont étendus pour couvrir toutes les mutations (code, couleur, description) en plus du renommage.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\ConfigurePeriods;
|
||||
|
||||
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsCommand;
|
||||
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsHandler;
|
||||
use App\Administration\Domain\Exception\PeriodesDejaConfigureesException;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
final class ConfigurePeriodsHandlerTest 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 ConfigurePeriodsHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryPeriodConfigurationRepository();
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-01-31 10:00:00');
|
||||
}
|
||||
};
|
||||
$eventBus = new class implements MessageBusInterface {
|
||||
public function dispatch(object $message, array $stamps = []): Envelope
|
||||
{
|
||||
return new Envelope($message);
|
||||
}
|
||||
};
|
||||
$this->handler = new ConfigurePeriodsHandler($this->repository, $clock, $eventBus);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itConfiguresTrimesterPeriods(): void
|
||||
{
|
||||
$command = new ConfigurePeriodsCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
periodType: 'trimester',
|
||||
startYear: 2025,
|
||||
);
|
||||
|
||||
$config = ($this->handler)($command);
|
||||
|
||||
self::assertSame(PeriodType::TRIMESTER, $config->type);
|
||||
self::assertCount(3, $config->periods);
|
||||
self::assertSame('T1', $config->periods[0]->label);
|
||||
self::assertSame('T2', $config->periods[1]->label);
|
||||
self::assertSame('T3', $config->periods[2]->label);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itConfiguresSemesterPeriods(): void
|
||||
{
|
||||
$command = new ConfigurePeriodsCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
periodType: 'semester',
|
||||
startYear: 2025,
|
||||
);
|
||||
|
||||
$config = ($this->handler)($command);
|
||||
|
||||
self::assertSame(PeriodType::SEMESTER, $config->type);
|
||||
self::assertCount(2, $config->periods);
|
||||
self::assertSame('S1', $config->periods[0]->label);
|
||||
self::assertSame('S2', $config->periods[1]->label);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPersistsConfiguration(): void
|
||||
{
|
||||
$command = new ConfigurePeriodsCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
periodType: 'trimester',
|
||||
startYear: 2025,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
$saved = $this->repository->findByAcademicYear(
|
||||
\App\Shared\Domain\Tenant\TenantId::fromString(self::TENANT_ID),
|
||||
\App\Administration\Domain\Model\SchoolClass\AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
);
|
||||
self::assertNotNull($saved);
|
||||
self::assertSame(PeriodType::TRIMESTER, $saved->type);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsDoubleConfiguration(): void
|
||||
{
|
||||
$command = new ConfigurePeriodsCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
periodType: 'trimester',
|
||||
startYear: 2025,
|
||||
);
|
||||
|
||||
($this->handler)($command);
|
||||
|
||||
$this->expectException(PeriodesDejaConfigureesException::class);
|
||||
($this->handler)($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAllowsDifferentTenantsToConfigureSameYear(): void
|
||||
{
|
||||
$otherTenantId = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
($this->handler)(new ConfigurePeriodsCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
periodType: 'trimester',
|
||||
startYear: 2025,
|
||||
));
|
||||
|
||||
$config = ($this->handler)(new ConfigurePeriodsCommand(
|
||||
tenantId: $otherTenantId,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
periodType: 'semester',
|
||||
startYear: 2025,
|
||||
));
|
||||
|
||||
self::assertSame(PeriodType::SEMESTER, $config->type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?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\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 itUpdatesPeriodDates(): 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 = new class implements GradeExistenceChecker {
|
||||
#[Override]
|
||||
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
$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 = new class implements GradeExistenceChecker {
|
||||
#[Override]
|
||||
public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
$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 seedTrimesterConfig(): void
|
||||
{
|
||||
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
|
||||
$this->repository->save(
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
$config,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Query\GetPeriods;
|
||||
|
||||
use App\Administration\Application\Query\GetPeriods\GetPeriodsHandler;
|
||||
use App\Administration\Application\Query\GetPeriods\GetPeriodsQuery;
|
||||
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetPeriodsHandlerTest 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;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryPeriodConfigurationRepository();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullWhenNoPeriodsConfigured(): void
|
||||
{
|
||||
$handler = $this->createHandler('2025-10-15');
|
||||
|
||||
$result = $handler(new GetPeriodsQuery(self::TENANT_ID, self::ACADEMIC_YEAR_ID));
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsPeriodsWithCurrentPeriodInfo(): void
|
||||
{
|
||||
$this->seedTrimesterConfig();
|
||||
$handler = $this->createHandler('2025-10-15');
|
||||
|
||||
$result = $handler(new GetPeriodsQuery(self::TENANT_ID, self::ACADEMIC_YEAR_ID));
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertSame('trimester', $result->type);
|
||||
self::assertCount(3, $result->periods);
|
||||
|
||||
// T1 is current
|
||||
self::assertNotNull($result->currentPeriod);
|
||||
self::assertSame('T1', $result->currentPeriod->label);
|
||||
self::assertTrue($result->currentPeriod->isCurrent);
|
||||
self::assertSame(46, $result->currentPeriod->daysRemaining);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullCurrentPeriodWhenOutOfRange(): void
|
||||
{
|
||||
$this->seedTrimesterConfig();
|
||||
$handler = $this->createHandler('2025-08-15');
|
||||
|
||||
$result = $handler(new GetPeriodsQuery(self::TENANT_ID, self::ACADEMIC_YEAR_ID));
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertNull($result->currentPeriod);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itMarksPastPeriods(): void
|
||||
{
|
||||
$this->seedTrimesterConfig();
|
||||
$handler = $this->createHandler('2026-04-15');
|
||||
|
||||
$result = $handler(new GetPeriodsQuery(self::TENANT_ID, self::ACADEMIC_YEAR_ID));
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertTrue($result->periods[0]->isPast);
|
||||
self::assertTrue($result->periods[1]->isPast);
|
||||
self::assertFalse($result->periods[2]->isPast);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
private function createHandler(string $dateString): GetPeriodsHandler
|
||||
{
|
||||
$clock = new class($dateString) implements Clock {
|
||||
public function __construct(private readonly string $dateString)
|
||||
{
|
||||
}
|
||||
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable($this->dateString);
|
||||
}
|
||||
};
|
||||
|
||||
return new GetPeriodsHandler($this->repository, $clock);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\AcademicYear;
|
||||
|
||||
use App\Administration\Domain\Exception\InvalidPeriodDatesException;
|
||||
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AcademicPeriodTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCreatesValidPeriod(): void
|
||||
{
|
||||
$period = new AcademicPeriod(
|
||||
sequence: 1,
|
||||
label: 'T1',
|
||||
startDate: new DateTimeImmutable('2025-09-01'),
|
||||
endDate: new DateTimeImmutable('2025-11-30'),
|
||||
);
|
||||
|
||||
self::assertSame(1, $period->sequence);
|
||||
self::assertSame('T1', $period->label);
|
||||
self::assertSame('2025-09-01', $period->startDate->format('Y-m-d'));
|
||||
self::assertSame('2025-11-30', $period->endDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsEndDateBeforeStartDate(): void
|
||||
{
|
||||
$this->expectException(InvalidPeriodDatesException::class);
|
||||
|
||||
new AcademicPeriod(
|
||||
sequence: 1,
|
||||
label: 'T1',
|
||||
startDate: new DateTimeImmutable('2025-11-30'),
|
||||
endDate: new DateTimeImmutable('2025-09-01'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsEqualDates(): void
|
||||
{
|
||||
$this->expectException(InvalidPeriodDatesException::class);
|
||||
|
||||
new AcademicPeriod(
|
||||
sequence: 1,
|
||||
label: 'T1',
|
||||
startDate: new DateTimeImmutable('2025-09-01'),
|
||||
endDate: new DateTimeImmutable('2025-09-01'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDetectsDateWithinPeriod(): void
|
||||
{
|
||||
$period = new AcademicPeriod(
|
||||
sequence: 1,
|
||||
label: 'T1',
|
||||
startDate: new DateTimeImmutable('2025-09-01'),
|
||||
endDate: new DateTimeImmutable('2025-11-30'),
|
||||
);
|
||||
|
||||
self::assertTrue($period->containsDate(new DateTimeImmutable('2025-10-15')));
|
||||
self::assertTrue($period->containsDate(new DateTimeImmutable('2025-09-01')));
|
||||
self::assertTrue($period->containsDate(new DateTimeImmutable('2025-11-30')));
|
||||
self::assertFalse($period->containsDate(new DateTimeImmutable('2025-08-31')));
|
||||
self::assertFalse($period->containsDate(new DateTimeImmutable('2025-12-01')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIncludesLastDayRegardlessOfTime(): void
|
||||
{
|
||||
$period = new AcademicPeriod(
|
||||
sequence: 1,
|
||||
label: 'T1',
|
||||
startDate: new DateTimeImmutable('2025-09-01'),
|
||||
endDate: new DateTimeImmutable('2025-11-30'),
|
||||
);
|
||||
|
||||
// Last day at 15:00 must still be "within" the period
|
||||
self::assertTrue($period->containsDate(new DateTimeImmutable('2025-11-30 15:00:00')));
|
||||
self::assertFalse($period->isPast(new DateTimeImmutable('2025-11-30 23:59:59')));
|
||||
self::assertTrue($period->isPast(new DateTimeImmutable('2025-12-01 00:00:01')));
|
||||
|
||||
// daysRemaining on last day should be 0 (same day)
|
||||
self::assertSame(0, $period->daysRemaining(new DateTimeImmutable('2025-11-30 15:00:00')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCalculatesDaysRemaining(): void
|
||||
{
|
||||
$period = new AcademicPeriod(
|
||||
sequence: 1,
|
||||
label: 'T1',
|
||||
startDate: new DateTimeImmutable('2025-09-01'),
|
||||
endDate: new DateTimeImmutable('2025-11-30'),
|
||||
);
|
||||
|
||||
// During the period
|
||||
self::assertSame(30, $period->daysRemaining(new DateTimeImmutable('2025-10-31')));
|
||||
|
||||
// After the period
|
||||
self::assertSame(0, $period->daysRemaining(new DateTimeImmutable('2025-12-01')));
|
||||
|
||||
// Before the period: returns total period length
|
||||
self::assertSame(90, $period->daysRemaining(new DateTimeImmutable('2025-08-01')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDetectsPastPeriod(): void
|
||||
{
|
||||
$period = new AcademicPeriod(
|
||||
sequence: 1,
|
||||
label: 'T1',
|
||||
startDate: new DateTimeImmutable('2025-09-01'),
|
||||
endDate: new DateTimeImmutable('2025-11-30'),
|
||||
);
|
||||
|
||||
self::assertTrue($period->isPast(new DateTimeImmutable('2025-12-01')));
|
||||
self::assertFalse($period->isPast(new DateTimeImmutable('2025-11-30')));
|
||||
self::assertFalse($period->isPast(new DateTimeImmutable('2025-10-15')));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\AcademicYear;
|
||||
|
||||
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DefaultPeriodsTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itGeneratesDefaultTremesters(): void
|
||||
{
|
||||
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
|
||||
|
||||
self::assertSame(PeriodType::TRIMESTER, $config->type);
|
||||
self::assertCount(3, $config->periods);
|
||||
|
||||
self::assertSame('T1', $config->periods[0]->label);
|
||||
self::assertSame('2025-09-01', $config->periods[0]->startDate->format('Y-m-d'));
|
||||
self::assertSame('2025-11-30', $config->periods[0]->endDate->format('Y-m-d'));
|
||||
|
||||
self::assertSame('T2', $config->periods[1]->label);
|
||||
self::assertSame('2025-12-01', $config->periods[1]->startDate->format('Y-m-d'));
|
||||
self::assertSame('2026-02-28', $config->periods[1]->endDate->format('Y-m-d'));
|
||||
|
||||
self::assertSame('T3', $config->periods[2]->label);
|
||||
self::assertSame('2026-03-01', $config->periods[2]->startDate->format('Y-m-d'));
|
||||
self::assertSame('2026-06-30', $config->periods[2]->endDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesLeapYearForTrimesters(): void
|
||||
{
|
||||
// 2023-2024 : 2024 is a leap year, Feb has 29 days
|
||||
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2023);
|
||||
|
||||
self::assertSame('2024-02-29', $config->periods[1]->endDate->format('Y-m-d'));
|
||||
self::assertSame('2024-03-01', $config->periods[2]->startDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesNonLeapYearForTrimesters(): void
|
||||
{
|
||||
// 2024-2025 : 2025 is not a leap year
|
||||
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2024);
|
||||
|
||||
self::assertSame('2025-02-28', $config->periods[1]->endDate->format('Y-m-d'));
|
||||
self::assertSame('2025-03-01', $config->periods[2]->startDate->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGeneratesDefaultSemesters(): void
|
||||
{
|
||||
$config = DefaultPeriods::forType(PeriodType::SEMESTER, 2025);
|
||||
|
||||
self::assertSame(PeriodType::SEMESTER, $config->type);
|
||||
self::assertCount(2, $config->periods);
|
||||
|
||||
self::assertSame('S1', $config->periods[0]->label);
|
||||
self::assertSame('2025-09-01', $config->periods[0]->startDate->format('Y-m-d'));
|
||||
self::assertSame('2026-01-31', $config->periods[0]->endDate->format('Y-m-d'));
|
||||
|
||||
self::assertSame('S2', $config->periods[1]->label);
|
||||
self::assertSame('2026-02-01', $config->periods[1]->startDate->format('Y-m-d'));
|
||||
self::assertSame('2026-06-30', $config->periods[1]->endDate->format('Y-m-d'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\AcademicYear;
|
||||
|
||||
use App\Administration\Domain\Exception\InvalidPeriodCountException;
|
||||
use App\Administration\Domain\Exception\PeriodsCoverageGapException;
|
||||
use App\Administration\Domain\Exception\PeriodsOverlapException;
|
||||
use App\Administration\Domain\Model\AcademicYear\AcademicPeriod;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodConfiguration;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PeriodConfigurationTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCreatesValidTrimesterConfiguration(): void
|
||||
{
|
||||
$config = $this->validTrimesterConfig();
|
||||
|
||||
self::assertSame(PeriodType::TRIMESTER, $config->type);
|
||||
self::assertCount(3, $config->periods);
|
||||
self::assertSame('2025-09-01', $config->startDate()->format('Y-m-d'));
|
||||
self::assertSame('2026-06-30', $config->endDate()->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesValidSemesterConfiguration(): void
|
||||
{
|
||||
$config = new PeriodConfiguration(PeriodType::SEMESTER, [
|
||||
new AcademicPeriod(1, 'S1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2026-01-31')),
|
||||
new AcademicPeriod(2, 'S2', new DateTimeImmutable('2026-02-01'), new DateTimeImmutable('2026-06-30')),
|
||||
]);
|
||||
|
||||
self::assertSame(PeriodType::SEMESTER, $config->type);
|
||||
self::assertCount(2, $config->periods);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsWrongPeriodCountForTrimester(): void
|
||||
{
|
||||
$this->expectException(InvalidPeriodCountException::class);
|
||||
|
||||
new PeriodConfiguration(PeriodType::TRIMESTER, [
|
||||
new AcademicPeriod(1, 'S1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2026-01-31')),
|
||||
new AcademicPeriod(2, 'S2', new DateTimeImmutable('2026-02-01'), new DateTimeImmutable('2026-06-30')),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsWrongPeriodCountForSemester(): void
|
||||
{
|
||||
$this->expectException(InvalidPeriodCountException::class);
|
||||
|
||||
new PeriodConfiguration(PeriodType::SEMESTER, [
|
||||
new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-11-30')),
|
||||
new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-12-01'), new DateTimeImmutable('2026-02-28')),
|
||||
new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsOverlappingPeriods(): void
|
||||
{
|
||||
$this->expectException(PeriodsOverlapException::class);
|
||||
|
||||
new PeriodConfiguration(PeriodType::TRIMESTER, [
|
||||
new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-12-01')),
|
||||
new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-11-30'), new DateTimeImmutable('2026-02-28')),
|
||||
new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsCoverageGap(): void
|
||||
{
|
||||
$this->expectException(PeriodsCoverageGapException::class);
|
||||
|
||||
new PeriodConfiguration(PeriodType::TRIMESTER, [
|
||||
new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-11-28')),
|
||||
new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-12-01'), new DateTimeImmutable('2026-02-28')),
|
||||
new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSortsPeriodsByStartDate(): void
|
||||
{
|
||||
$config = new PeriodConfiguration(PeriodType::TRIMESTER, [
|
||||
new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')),
|
||||
new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-11-30')),
|
||||
new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-12-01'), new DateTimeImmutable('2026-02-28')),
|
||||
]);
|
||||
|
||||
self::assertSame('T1', $config->periods[0]->label);
|
||||
self::assertSame('T2', $config->periods[1]->label);
|
||||
self::assertSame('T3', $config->periods[2]->label);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFindsCurrentPeriod(): void
|
||||
{
|
||||
$config = $this->validTrimesterConfig();
|
||||
|
||||
$current = $config->currentPeriod(new DateTimeImmutable('2025-10-15'));
|
||||
self::assertNotNull($current);
|
||||
self::assertSame('T1', $current->label);
|
||||
|
||||
$current = $config->currentPeriod(new DateTimeImmutable('2026-01-15'));
|
||||
self::assertNotNull($current);
|
||||
self::assertSame('T2', $current->label);
|
||||
|
||||
$current = $config->currentPeriod(new DateTimeImmutable('2026-05-01'));
|
||||
self::assertNotNull($current);
|
||||
self::assertSame('T3', $current->label);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullWhenNoCurrentPeriod(): void
|
||||
{
|
||||
$config = $this->validTrimesterConfig();
|
||||
|
||||
self::assertNull($config->currentPeriod(new DateTimeImmutable('2025-08-01')));
|
||||
self::assertNull($config->currentPeriod(new DateTimeImmutable('2026-07-01')));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFindsPeriodBySequence(): void
|
||||
{
|
||||
$config = $this->validTrimesterConfig();
|
||||
|
||||
$period = $config->periodBySequence(2);
|
||||
self::assertNotNull($period);
|
||||
self::assertSame('T2', $period->label);
|
||||
|
||||
self::assertNull($config->periodBySequence(4));
|
||||
}
|
||||
|
||||
private function validTrimesterConfig(): PeriodConfiguration
|
||||
{
|
||||
return new PeriodConfiguration(PeriodType::TRIMESTER, [
|
||||
new AcademicPeriod(1, 'T1', new DateTimeImmutable('2025-09-01'), new DateTimeImmutable('2025-11-30')),
|
||||
new AcademicPeriod(2, 'T2', new DateTimeImmutable('2025-12-01'), new DateTimeImmutable('2026-02-28')),
|
||||
new AcademicPeriod(3, 'T3', new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-06-30')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\AcademicYear;
|
||||
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PeriodTypeTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function trimesterExpectsThreePeriods(): void
|
||||
{
|
||||
self::assertSame(3, PeriodType::TRIMESTER->expectedCount());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function semesterExpectsTwoPeriods(): void
|
||||
{
|
||||
self::assertSame(2, PeriodType::SEMESTER->expectedCount());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHasCorrectValues(): void
|
||||
{
|
||||
self::assertSame('trimester', PeriodType::TRIMESTER->value);
|
||||
self::assertSame('semester', PeriodType::SEMESTER->value);
|
||||
}
|
||||
}
|
||||
@@ -112,8 +112,9 @@ final class SubjectTest extends TestCase
|
||||
$events = $subject->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(MatiereModifiee::class, $events[0]);
|
||||
self::assertTrue($events[0]->ancienNom->equals($ancienNom));
|
||||
self::assertTrue($events[0]->nouveauNom->equals($nouveauNom));
|
||||
self::assertSame('nom', $events[0]->champ);
|
||||
self::assertSame((string) $ancienNom, $events[0]->ancienneValeur);
|
||||
self::assertSame((string) $nouveauNom, $events[0]->nouvelleValeur);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -130,9 +131,10 @@ final class SubjectTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function changerCodeUpdatesCode(): void
|
||||
public function changerCodeUpdatesCodeAndRecordsEvent(): void
|
||||
{
|
||||
$subject = $this->createSubject();
|
||||
$subject->pullDomainEvents();
|
||||
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||
$nouveauCode = new SubjectCode('MATHS');
|
||||
|
||||
@@ -140,23 +142,33 @@ final class SubjectTest extends TestCase
|
||||
|
||||
self::assertTrue($subject->code->equals($nouveauCode));
|
||||
self::assertEquals($at, $subject->updatedAt);
|
||||
|
||||
$events = $subject->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(MatiereModifiee::class, $events[0]);
|
||||
self::assertSame('code', $events[0]->champ);
|
||||
self::assertSame('MATH', $events[0]->ancienneValeur);
|
||||
self::assertSame('MATHS', $events[0]->nouvelleValeur);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function changerCodeWithSameCodeDoesNothing(): void
|
||||
{
|
||||
$subject = $this->createSubject();
|
||||
$subject->pullDomainEvents();
|
||||
$originalUpdatedAt = $subject->updatedAt;
|
||||
|
||||
$subject->changerCode(new SubjectCode('MATH'), new DateTimeImmutable('2026-02-01 10:00:00'));
|
||||
|
||||
self::assertEquals($originalUpdatedAt, $subject->updatedAt);
|
||||
self::assertEmpty($subject->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function changerCouleurUpdatesColor(): void
|
||||
public function changerCouleurUpdatesColorAndRecordsEvent(): void
|
||||
{
|
||||
$subject = $this->createSubject();
|
||||
$subject->pullDomainEvents();
|
||||
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||
$nouvelleCouleur = new SubjectColor('#EF4444');
|
||||
|
||||
@@ -165,41 +177,66 @@ final class SubjectTest extends TestCase
|
||||
self::assertNotNull($subject->color);
|
||||
self::assertTrue($subject->color->equals($nouvelleCouleur));
|
||||
self::assertEquals($at, $subject->updatedAt);
|
||||
|
||||
$events = $subject->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(MatiereModifiee::class, $events[0]);
|
||||
self::assertSame('couleur', $events[0]->champ);
|
||||
self::assertSame('#3B82F6', $events[0]->ancienneValeur);
|
||||
self::assertSame('#EF4444', $events[0]->nouvelleValeur);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function changerCouleurToNullRemovesColor(): void
|
||||
public function changerCouleurToNullRemovesColorAndRecordsEvent(): void
|
||||
{
|
||||
$subject = $this->createSubject();
|
||||
$subject->pullDomainEvents();
|
||||
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||
|
||||
$subject->changerCouleur(null, $at);
|
||||
|
||||
self::assertNull($subject->color);
|
||||
self::assertEquals($at, $subject->updatedAt);
|
||||
|
||||
$events = $subject->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(MatiereModifiee::class, $events[0]);
|
||||
self::assertSame('couleur', $events[0]->champ);
|
||||
self::assertSame('#3B82F6', $events[0]->ancienneValeur);
|
||||
self::assertNull($events[0]->nouvelleValeur);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function changerCouleurWithSameColorDoesNothing(): void
|
||||
{
|
||||
$subject = $this->createSubject();
|
||||
$subject->pullDomainEvents();
|
||||
$originalUpdatedAt = $subject->updatedAt;
|
||||
|
||||
$subject->changerCouleur(new SubjectColor('#3B82F6'), new DateTimeImmutable('2026-02-01 10:00:00'));
|
||||
|
||||
self::assertEquals($originalUpdatedAt, $subject->updatedAt);
|
||||
self::assertEmpty($subject->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function decrireUpdatesDescription(): void
|
||||
public function decrireUpdatesDescriptionAndRecordsEvent(): void
|
||||
{
|
||||
$subject = $this->createSubject();
|
||||
$subject->pullDomainEvents();
|
||||
$at = new DateTimeImmutable('2026-02-01 10:00:00');
|
||||
|
||||
$subject->decrire('Cours de mathématiques généralistes', $at);
|
||||
|
||||
self::assertSame('Cours de mathématiques généralistes', $subject->description);
|
||||
self::assertEquals($at, $subject->updatedAt);
|
||||
|
||||
$events = $subject->pullDomainEvents();
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(MatiereModifiee::class, $events[0]);
|
||||
self::assertSame('description', $events[0]->champ);
|
||||
self::assertNull($events[0]->ancienneValeur);
|
||||
self::assertSame('Cours de mathématiques généralistes', $events[0]->nouvelleValeur);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Administration\Application\Command\ConfigurePeriods\ConfigurePeriodsHandler;
|
||||
use App\Administration\Infrastructure\Api\Processor\ConfigurePeriodsProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
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\AccessDecision;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class ConfigurePeriodsProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_UUID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private InMemoryPeriodConfigurationRepository $repository;
|
||||
private TenantContext $tenantContext;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryPeriodConfigurationRepository();
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2025-10-15 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsUnauthorizedAccess(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: false);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->periodType = 'trimester';
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsRequestWithoutTenant(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->periodType = 'trimester';
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
$processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsInvalidAcademicYearId(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->periodType = 'trimester';
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
$processor->process($data, new Put(), ['academicYearId' => 'invalid']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itConfiguresTrimesters(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->periodType = 'trimester';
|
||||
$data->startYear = 2025;
|
||||
|
||||
$result = $processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
|
||||
self::assertInstanceOf(PeriodResource::class, $result);
|
||||
self::assertSame('trimester', $result->type);
|
||||
self::assertCount(3, $result->periods);
|
||||
self::assertContainsOnlyInstancesOf(PeriodItem::class, $result->periods);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itConfiguresSemesters(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->periodType = 'semester';
|
||||
$data->startYear = 2025;
|
||||
|
||||
$result = $processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
|
||||
self::assertSame('semester', $result->type);
|
||||
self::assertCount(2, $result->periods);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesCurrentAndReturnsCorrectAcademicYearId(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
|
||||
$expectedUuid = $resolver->resolve('current');
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->periodType = 'trimester';
|
||||
$data->startYear = 2025;
|
||||
|
||||
$result = $processor->process($data, new Put(), ['academicYearId' => 'current']);
|
||||
|
||||
self::assertSame($expectedUuid, $result->academicYearId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itConfiguresNextYear(): void
|
||||
{
|
||||
$processor = $this->createProcessor(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->periodType = 'trimester';
|
||||
$data->startYear = 2026;
|
||||
|
||||
$result = $processor->process($data, new Put(), ['academicYearId' => 'next']);
|
||||
|
||||
self::assertSame('trimester', $result->type);
|
||||
self::assertCount(3, $result->periods);
|
||||
}
|
||||
|
||||
private function setTenant(): void
|
||||
{
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: TenantId::fromString(self::TENANT_UUID),
|
||||
subdomain: 'test',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
));
|
||||
}
|
||||
|
||||
private function createProcessor(bool $granted): ConfigurePeriodsProcessor
|
||||
{
|
||||
$authChecker = new class($granted) implements AuthorizationCheckerInterface {
|
||||
public function __construct(private readonly bool $granted)
|
||||
{
|
||||
}
|
||||
|
||||
public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
|
||||
{
|
||||
return $this->granted;
|
||||
}
|
||||
};
|
||||
|
||||
$eventBus = new class implements MessageBusInterface {
|
||||
public function dispatch(object $message, array $stamps = []): Envelope
|
||||
{
|
||||
return new Envelope($message);
|
||||
}
|
||||
};
|
||||
$handler = new ConfigurePeriodsHandler($this->repository, $this->clock, $eventBus);
|
||||
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
|
||||
|
||||
return new ConfigurePeriodsProcessor($handler, $this->tenantContext, $authChecker, $resolver, $this->clock);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Administration\Application\Command\UpdatePeriod\UpdatePeriodHandler;
|
||||
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Infrastructure\Api\Processor\UpdatePeriodProcessor;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker;
|
||||
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\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AccessDecision;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class UpdatePeriodProcessorTest extends TestCase
|
||||
{
|
||||
private const string TENANT_UUID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private InMemoryPeriodConfigurationRepository $repository;
|
||||
private TenantContext $tenantContext;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryPeriodConfigurationRepository();
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2025-10-15 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsInvalidAcademicYearId(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
$this->setTenant();
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->startDate = '2025-09-02';
|
||||
$data->endDate = '2025-11-30';
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
$processor->process($data, new Patch(), ['academicYearId' => 'invalid', 'sequence' => 1]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsWhenNoPeriodsConfigured(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
$this->setTenant();
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->startDate = '2025-09-02';
|
||||
$data->endDate = '2025-11-30';
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
$processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itUpdatesPeriodDates(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
$this->setTenant();
|
||||
$this->seedPeriods();
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->startDate = '2025-09-02';
|
||||
$data->endDate = '2025-11-30';
|
||||
|
||||
$result = $processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]);
|
||||
|
||||
self::assertInstanceOf(PeriodResource::class, $result);
|
||||
self::assertSame('trimester', $result->type);
|
||||
self::assertCount(3, $result->periods);
|
||||
self::assertContainsOnlyInstancesOf(PeriodItem::class, $result->periods);
|
||||
|
||||
// First period has updated start date
|
||||
self::assertSame('2025-09-02', $result->periods[0]->startDate);
|
||||
self::assertSame('2025-11-30', $result->periods[0]->endDate);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesCurrentAcademicYearId(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
$this->setTenant();
|
||||
$this->seedPeriods();
|
||||
|
||||
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
|
||||
$expectedUuid = $resolver->resolve('current');
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->startDate = '2025-09-02';
|
||||
$data->endDate = '2025-11-30';
|
||||
|
||||
$result = $processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]);
|
||||
|
||||
self::assertSame($expectedUuid, $result->academicYearId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsMissingStartDate(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
$this->setTenant();
|
||||
$this->seedPeriods();
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->endDate = '2025-11-30';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('obligatoires');
|
||||
$processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsMissingEndDate(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
$this->setTenant();
|
||||
$this->seedPeriods();
|
||||
|
||||
$data = new PeriodResource();
|
||||
$data->startDate = '2025-09-01';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('obligatoires');
|
||||
$processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsOverlappingDates(): void
|
||||
{
|
||||
$processor = $this->createProcessor();
|
||||
$this->setTenant();
|
||||
$this->seedPeriods();
|
||||
|
||||
$data = new PeriodResource();
|
||||
// T1 end date overlaps with T2 start date (Dec 1)
|
||||
$data->startDate = '2025-09-01';
|
||||
$data->endDate = '2025-12-15';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$processor->process($data, new Patch(), ['academicYearId' => 'current', 'sequence' => 1]);
|
||||
}
|
||||
|
||||
private function setTenant(): void
|
||||
{
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
subdomain: 'test',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
));
|
||||
}
|
||||
|
||||
private function seedPeriods(): void
|
||||
{
|
||||
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
|
||||
$academicYearId = $resolver->resolve('current');
|
||||
self::assertNotNull($academicYearId);
|
||||
|
||||
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
|
||||
$this->repository->save(
|
||||
TenantId::fromString(self::TENANT_UUID),
|
||||
AcademicYearId::fromString($academicYearId),
|
||||
$config,
|
||||
);
|
||||
}
|
||||
|
||||
private function createProcessor(): UpdatePeriodProcessor
|
||||
{
|
||||
$authChecker = new class implements AuthorizationCheckerInterface {
|
||||
public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
$eventBus = new class implements MessageBusInterface {
|
||||
public function dispatch(object $message, array $stamps = []): Envelope
|
||||
{
|
||||
return new Envelope($message);
|
||||
}
|
||||
};
|
||||
$handler = new UpdatePeriodHandler($this->repository, new NoOpGradeExistenceChecker(), $this->clock, $eventBus);
|
||||
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
|
||||
|
||||
return new UpdatePeriodProcessor($handler, $this->tenantContext, $authChecker, $resolver);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Administration\Application\Query\GetPeriods\GetPeriodsHandler;
|
||||
use App\Administration\Domain\Model\AcademicYear\DefaultPeriods;
|
||||
use App\Administration\Domain\Model\AcademicYear\PeriodType;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Infrastructure\Api\Provider\PeriodsProvider;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodItem;
|
||||
use App\Administration\Infrastructure\Api\Resource\PeriodResource;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
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\Security\Core\Authorization\AccessDecision;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
final class PeriodsProviderTest extends TestCase
|
||||
{
|
||||
private const string TENANT_UUID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private InMemoryPeriodConfigurationRepository $repository;
|
||||
private TenantContext $tenantContext;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryPeriodConfigurationRepository();
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2025-10-15 10:00:00');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsUnauthorizedAccess(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: false);
|
||||
$this->setTenant();
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$provider->provide(new Get(), ['academicYearId' => 'current']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsRequestWithoutTenant(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
$provider->provide(new Get(), ['academicYearId' => 'current']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsInvalidAcademicYearId(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
$provider->provide(new Get(), ['academicYearId' => 'invalid']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullWhenNoPeriodsConfigured(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$result = $provider->provide(new Get(), ['academicYearId' => 'current']);
|
||||
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesCurrentToValidUuidAndReturnsPeriods(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
// Seed periods using the same resolved UUID
|
||||
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
|
||||
$academicYearId = $resolver->resolve('current');
|
||||
self::assertNotNull($academicYearId);
|
||||
|
||||
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
|
||||
$this->repository->save(
|
||||
TenantId::fromString(self::TENANT_UUID),
|
||||
AcademicYearId::fromString($academicYearId),
|
||||
$config,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get(), ['academicYearId' => 'current']);
|
||||
|
||||
self::assertInstanceOf(PeriodResource::class, $result);
|
||||
self::assertSame($academicYearId, $result->academicYearId);
|
||||
self::assertSame('trimester', $result->type);
|
||||
self::assertCount(3, $result->periods);
|
||||
self::assertContainsOnlyInstancesOf(PeriodItem::class, $result->periods);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesNextAcademicYear(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$result = $provider->provide(new Get(), ['academicYearId' => 'next']);
|
||||
|
||||
// No periods for next year → null
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesPreviousAcademicYear(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$result = $provider->provide(new Get(), ['academicYearId' => 'previous']);
|
||||
|
||||
// No periods for previous year → null
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsPeriodItemsWithCorrectFields(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
|
||||
$academicYearId = $resolver->resolve('current');
|
||||
self::assertNotNull($academicYearId);
|
||||
|
||||
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
|
||||
$this->repository->save(
|
||||
TenantId::fromString(self::TENANT_UUID),
|
||||
AcademicYearId::fromString($academicYearId),
|
||||
$config,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get(), ['academicYearId' => 'current']);
|
||||
self::assertNotNull($result);
|
||||
|
||||
$firstPeriod = $result->periods[0];
|
||||
self::assertInstanceOf(PeriodItem::class, $firstPeriod);
|
||||
self::assertSame(1, $firstPeriod->sequence);
|
||||
self::assertSame('T1', $firstPeriod->label);
|
||||
self::assertSame('2025-09-01', $firstPeriod->startDate);
|
||||
self::assertSame('2025-11-30', $firstPeriod->endDate);
|
||||
self::assertTrue($firstPeriod->isCurrent);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsCurrentPeriodBanner(): void
|
||||
{
|
||||
$provider = $this->createProvider(granted: true);
|
||||
$this->setTenant();
|
||||
|
||||
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
|
||||
$academicYearId = $resolver->resolve('current');
|
||||
self::assertNotNull($academicYearId);
|
||||
|
||||
$config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025);
|
||||
$this->repository->save(
|
||||
TenantId::fromString(self::TENANT_UUID),
|
||||
AcademicYearId::fromString($academicYearId),
|
||||
$config,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get(), ['academicYearId' => 'current']);
|
||||
self::assertNotNull($result);
|
||||
|
||||
self::assertInstanceOf(PeriodItem::class, $result->currentPeriod);
|
||||
self::assertSame('T1', $result->currentPeriod->label);
|
||||
self::assertTrue($result->currentPeriod->isCurrent);
|
||||
}
|
||||
|
||||
private function setTenant(): void
|
||||
{
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
subdomain: 'test',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
));
|
||||
}
|
||||
|
||||
private function createProvider(bool $granted): PeriodsProvider
|
||||
{
|
||||
$authChecker = new class($granted) implements AuthorizationCheckerInterface {
|
||||
public function __construct(private readonly bool $granted)
|
||||
{
|
||||
}
|
||||
|
||||
public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
|
||||
{
|
||||
return $this->granted;
|
||||
}
|
||||
};
|
||||
|
||||
$handler = new GetPeriodsHandler($this->repository, $this->clock);
|
||||
$resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock);
|
||||
|
||||
return new PeriodsProvider($handler, $this->tenantContext, $authChecker, $resolver);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Service;
|
||||
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CurrentAcademicYearResolverTest extends TestCase
|
||||
{
|
||||
private const string TENANT_UUID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
#[Test]
|
||||
public function itPassesThroughValidUuid(): void
|
||||
{
|
||||
$resolver = $this->createResolver('2026-02-05 10:00:00');
|
||||
$uuid = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
self::assertSame($uuid, $resolver->resolve($uuid));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullForInvalidIdentifier(): void
|
||||
{
|
||||
$resolver = $this->createResolver('2026-02-05 10:00:00');
|
||||
|
||||
self::assertNull($resolver->resolve('invalid'));
|
||||
self::assertNull($resolver->resolve(''));
|
||||
self::assertNull($resolver->resolve('past'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesCurrentBeforeSeptember(): void
|
||||
{
|
||||
// February 2026 → school year 2025-2026
|
||||
$resolver = $this->createResolver('2026-02-05 10:00:00');
|
||||
$result = $resolver->resolve('current');
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertTrue(\Ramsey\Uuid\Uuid::isValid($result));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesCurrentAfterSeptember(): void
|
||||
{
|
||||
// October 2025 → school year 2025-2026
|
||||
$resolver = $this->createResolver('2025-10-15 10:00:00');
|
||||
$result = $resolver->resolve('current');
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertTrue(\Ramsey\Uuid\Uuid::isValid($result));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesSameUuidForSameSchoolYear(): void
|
||||
{
|
||||
// Both dates are in school year 2025-2026
|
||||
$resolverOct = $this->createResolver('2025-10-15 10:00:00');
|
||||
$resolverFeb = $this->createResolver('2026-02-05 10:00:00');
|
||||
|
||||
self::assertSame(
|
||||
$resolverOct->resolve('current'),
|
||||
$resolverFeb->resolve('current'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesDifferentUuidForDifferentSchoolYears(): void
|
||||
{
|
||||
// October 2025 → 2025-2026, October 2026 → 2026-2027
|
||||
$resolver2025 = $this->createResolver('2025-10-15 10:00:00');
|
||||
$resolver2026 = $this->createResolver('2026-10-15 10:00:00');
|
||||
|
||||
self::assertNotSame(
|
||||
$resolver2025->resolve('current'),
|
||||
$resolver2026->resolve('current'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesNextYear(): void
|
||||
{
|
||||
// February 2026, current = 2025-2026, next = 2026-2027
|
||||
$resolver = $this->createResolver('2026-02-05 10:00:00');
|
||||
|
||||
$current = $resolver->resolve('current');
|
||||
$next = $resolver->resolve('next');
|
||||
|
||||
self::assertNotNull($next);
|
||||
self::assertTrue(\Ramsey\Uuid\Uuid::isValid($next));
|
||||
self::assertNotSame($current, $next);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesPreviousYear(): void
|
||||
{
|
||||
// February 2026, current = 2025-2026, previous = 2024-2025
|
||||
$resolver = $this->createResolver('2026-02-05 10:00:00');
|
||||
|
||||
$current = $resolver->resolve('current');
|
||||
$previous = $resolver->resolve('previous');
|
||||
|
||||
self::assertNotNull($previous);
|
||||
self::assertTrue(\Ramsey\Uuid\Uuid::isValid($previous));
|
||||
self::assertNotSame($current, $previous);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function nextOfCurrentYearMatchesCurrentOfNextYear(): void
|
||||
{
|
||||
// "next" from Feb 2026 should equal "current" from Oct 2026
|
||||
$resolverFeb2026 = $this->createResolver('2026-02-05 10:00:00');
|
||||
$resolverOct2026 = $this->createResolver('2026-10-15 10:00:00');
|
||||
|
||||
self::assertSame(
|
||||
$resolverFeb2026->resolve('next'),
|
||||
$resolverOct2026->resolve('current'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function previousOfCurrentYearMatchesCurrentOfPreviousYear(): void
|
||||
{
|
||||
// "previous" from Feb 2026 (2024-2025) should equal "current" from Oct 2024
|
||||
$resolverFeb2026 = $this->createResolver('2026-02-05 10:00:00');
|
||||
$resolverOct2024 = $this->createResolver('2024-10-15 10:00:00');
|
||||
|
||||
self::assertSame(
|
||||
$resolverFeb2026->resolve('previous'),
|
||||
$resolverOct2024->resolve('current'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesDifferentUuidsForDifferentTenants(): void
|
||||
{
|
||||
$otherTenantUuid = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
$resolver1 = $this->createResolver('2026-02-05 10:00:00', self::TENANT_UUID);
|
||||
$resolver2 = $this->createResolver('2026-02-05 10:00:00', $otherTenantUuid);
|
||||
|
||||
self::assertNotSame(
|
||||
$resolver1->resolve('current'),
|
||||
$resolver2->resolve('current'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIsDeterministic(): void
|
||||
{
|
||||
$resolver = $this->createResolver('2026-02-05 10:00:00');
|
||||
|
||||
self::assertSame(
|
||||
$resolver->resolve('current'),
|
||||
$resolver->resolve('current'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function septemberBelongsToNewSchoolYear(): void
|
||||
{
|
||||
// September 1st 2026 should be in school year 2026-2027
|
||||
$resolverSept = $this->createResolver('2026-09-01 08:00:00');
|
||||
$resolverOct = $this->createResolver('2026-10-15 10:00:00');
|
||||
|
||||
self::assertSame(
|
||||
$resolverSept->resolve('current'),
|
||||
$resolverOct->resolve('current'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function augustBelongsToPreviousSchoolYear(): void
|
||||
{
|
||||
// August 31st 2026 should still be in school year 2025-2026
|
||||
$resolverAug = $this->createResolver('2026-08-31 23:59:59');
|
||||
$resolverFeb = $this->createResolver('2026-02-05 10:00:00');
|
||||
|
||||
self::assertSame(
|
||||
$resolverAug->resolve('current'),
|
||||
$resolverFeb->resolve('current'),
|
||||
);
|
||||
}
|
||||
|
||||
private function createResolver(string $dateTime, string $tenantUuid = self::TENANT_UUID): CurrentAcademicYearResolver
|
||||
{
|
||||
$tenantContext = new TenantContext();
|
||||
$tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: TenantId::fromString($tenantUuid),
|
||||
subdomain: 'test',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
));
|
||||
|
||||
$clock = new class($dateTime) implements Clock {
|
||||
public function __construct(private readonly string $dateTime)
|
||||
{
|
||||
}
|
||||
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable($this->dateTime);
|
||||
}
|
||||
};
|
||||
|
||||
return new CurrentAcademicYearResolver($tenantContext, $clock);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user