feat: Provisionner automatiquement un nouvel établissement
Lorsqu'un super-admin crée un établissement via l'interface, le système doit automatiquement créer la base tenant, exécuter les migrations, créer le premier utilisateur admin et envoyer l'invitation — le tout de manière asynchrone pour ne pas bloquer la réponse HTTP. Ce mécanisme rend chaque établissement opérationnel dès sa création sans intervention manuelle sur l'infrastructure.
This commit is contained in:
@@ -10,8 +10,11 @@ use App\Administration\Domain\Model\SchoolCalendar\CalendarEntryType;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemorySchoolCalendarRepository;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesHandler;
|
||||
use App\Scolarite\Application\Query\GetBlockedDates\GetBlockedDatesQuery;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
@@ -28,7 +31,29 @@ final class GetBlockedDatesHandlerTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->calendarRepository = new InMemorySchoolCalendarRepository();
|
||||
$this->handler = new GetBlockedDatesHandler($this->calendarRepository);
|
||||
|
||||
$rulesChecker = new class implements HomeworkRulesChecker {
|
||||
public function verifier(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesCheckResult {
|
||||
return HomeworkRulesCheckResult::ok();
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-01 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->handler = new GetBlockedDatesHandler(
|
||||
$this->calendarRepository,
|
||||
$rulesChecker,
|
||||
$clock,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -110,6 +135,93 @@ final class GetBlockedDatesHandlerTest extends TestCase
|
||||
self::assertCount(5, $vacations);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsRuleHardBlockedDates(): void
|
||||
{
|
||||
$rulesChecker = new class implements HomeworkRulesChecker {
|
||||
public function verifier(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesCheckResult {
|
||||
// Block Tuesday March 3
|
||||
if ($dueDate->format('Y-m-d') === '2026-03-03') {
|
||||
return new HomeworkRulesCheckResult(
|
||||
warnings: [new \App\Scolarite\Application\Port\RuleWarning('minimum_delay', 'Délai minimum non respecté')],
|
||||
bloquant: true,
|
||||
);
|
||||
}
|
||||
|
||||
return HomeworkRulesCheckResult::ok();
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-01 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$handler = new GetBlockedDatesHandler($this->calendarRepository, $rulesChecker, $clock);
|
||||
|
||||
$result = ($handler)(new GetBlockedDatesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
startDate: '2026-03-02',
|
||||
endDate: '2026-03-06',
|
||||
));
|
||||
|
||||
$ruleBlocked = array_filter($result, static fn ($d) => $d->type === 'rule_hard');
|
||||
self::assertCount(1, $ruleBlocked);
|
||||
$blocked = array_values($ruleBlocked)[0];
|
||||
self::assertSame('2026-03-03', $blocked->date);
|
||||
self::assertSame('Délai minimum non respecté', $blocked->reason);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsRuleSoftWarningDates(): void
|
||||
{
|
||||
$rulesChecker = new class implements HomeworkRulesChecker {
|
||||
public function verifier(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesCheckResult {
|
||||
if ($dueDate->format('Y-m-d') === '2026-03-04') {
|
||||
return new HomeworkRulesCheckResult(
|
||||
warnings: [new \App\Scolarite\Application\Port\RuleWarning('no_monday_after', 'Devoirs pour lundi déconseillés')],
|
||||
bloquant: false,
|
||||
);
|
||||
}
|
||||
|
||||
return HomeworkRulesCheckResult::ok();
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-03-01 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$handler = new GetBlockedDatesHandler($this->calendarRepository, $rulesChecker, $clock);
|
||||
|
||||
$result = ($handler)(new GetBlockedDatesQuery(
|
||||
tenantId: self::TENANT_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
startDate: '2026-03-02',
|
||||
endDate: '2026-03-06',
|
||||
));
|
||||
|
||||
$ruleSoft = array_filter($result, static fn ($d) => $d->type === 'rule_soft');
|
||||
self::assertCount(1, $ruleSoft);
|
||||
$soft = array_values($ruleSoft)[0];
|
||||
self::assertSame('2026-03-04', $soft->date);
|
||||
self::assertSame('rule_soft', $soft->type);
|
||||
}
|
||||
|
||||
private function createCalendarWithHoliday(DateTimeImmutable $date, string $label): SchoolCalendar
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetClassStatisticsDetail;
|
||||
|
||||
use App\Scolarite\Application\Port\PeriodFinder;
|
||||
use App\Scolarite\Application\Port\PeriodInfo;
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\GetClassStatisticsDetailHandler;
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\GetClassStatisticsDetailQuery;
|
||||
use App\Scolarite\Domain\Service\AverageCalculator;
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
use App\Scolarite\Infrastructure\ReadModel\InMemoryTeacherStatisticsReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetClassStatisticsDetailHandlerTest extends TestCase
|
||||
{
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
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 InMemoryTeacherStatisticsReader $reader;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reader = new InMemoryTeacherStatisticsReader();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyWhenNoPeriodFound(): void
|
||||
{
|
||||
$handler = $this->createHandler(periodInfo: null);
|
||||
|
||||
$result = $handler($this->query());
|
||||
|
||||
self::assertNull($result->average);
|
||||
self::assertSame(0.0, $result->successRate);
|
||||
self::assertSame([], $result->students);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itComputesClassStatisticsFromGrades(): void
|
||||
{
|
||||
$this->reader->feedClassGrades([8.0, 10.0, 12.0, 14.0, 16.0]);
|
||||
$this->reader->feedMonthlyAverages([
|
||||
['month' => '2026-01', 'average' => 11.0],
|
||||
['month' => '2026-02', 'average' => 12.5],
|
||||
]);
|
||||
$this->reader->feedStudentAverages([
|
||||
['studentId' => 's1', 'studentName' => 'Alice Dupont', 'average' => 14.0],
|
||||
['studentId' => 's2', 'studentName' => 'Bob Martin', 'average' => 7.0],
|
||||
]);
|
||||
|
||||
$handler = $this->createHandler(periodInfo: $this->currentPeriod());
|
||||
$result = $handler($this->query());
|
||||
|
||||
self::assertSame(12.0, $result->average);
|
||||
self::assertSame(80.0, $result->successRate); // 4/5 >= 10
|
||||
self::assertSame([0, 0, 0, 1, 2, 1, 1, 0], $result->distribution);
|
||||
self::assertCount(2, $result->evolution);
|
||||
self::assertCount(2, $result->students);
|
||||
self::assertFalse($result->students[0]->inDifficulty); // Alice 14.0 >= 8.0
|
||||
self::assertTrue($result->students[1]->inDifficulty); // Bob 7.0 < 8.0
|
||||
}
|
||||
|
||||
private function query(): GetClassStatisticsDetailQuery
|
||||
{
|
||||
return new GetClassStatisticsDetailQuery(
|
||||
teacherId: self::TEACHER_ID,
|
||||
classId: self::CLASS_ID,
|
||||
subjectId: self::SUBJECT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
);
|
||||
}
|
||||
|
||||
private function createHandler(?PeriodInfo $periodInfo): GetClassStatisticsDetailHandler
|
||||
{
|
||||
$periodFinder = new class($periodInfo) implements PeriodFinder {
|
||||
public function __construct(private readonly ?PeriodInfo $info)
|
||||
{
|
||||
}
|
||||
|
||||
public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo
|
||||
{
|
||||
return $this->info;
|
||||
}
|
||||
};
|
||||
|
||||
return new GetClassStatisticsDetailHandler(
|
||||
$this->reader,
|
||||
$periodFinder,
|
||||
new TeacherStatisticsCalculator(),
|
||||
new AverageCalculator(),
|
||||
);
|
||||
}
|
||||
|
||||
private function currentPeriod(): PeriodInfo
|
||||
{
|
||||
return new PeriodInfo(
|
||||
periodId: 'period-1',
|
||||
startDate: new DateTimeImmutable('2026-01-05'),
|
||||
endDate: new DateTimeImmutable('2026-03-31'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetEvaluationDifficulty;
|
||||
|
||||
use App\Scolarite\Application\Query\GetEvaluationDifficulty\GetEvaluationDifficultyHandler;
|
||||
use App\Scolarite\Application\Query\GetEvaluationDifficulty\GetEvaluationDifficultyQuery;
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
use App\Scolarite\Infrastructure\ReadModel\InMemoryTeacherStatisticsReader;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetEvaluationDifficultyHandlerTest extends TestCase
|
||||
{
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private InMemoryTeacherStatisticsReader $reader;
|
||||
private GetEvaluationDifficultyHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reader = new InMemoryTeacherStatisticsReader();
|
||||
$this->handler = new GetEvaluationDifficultyHandler(
|
||||
$this->reader,
|
||||
new TeacherStatisticsCalculator(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyWhenNoEvaluations(): void
|
||||
{
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEvaluationDifficultyWithComparison(): void
|
||||
{
|
||||
$this->reader->feedEvaluationDifficulties([
|
||||
[
|
||||
'evaluationId' => 'eval-1',
|
||||
'title' => 'Contrôle chapitre 5',
|
||||
'classId' => 'class-1',
|
||||
'className' => '6ème A',
|
||||
'subjectId' => 'subject-1',
|
||||
'subjectName' => 'Mathématiques',
|
||||
'date' => '2026-03-15',
|
||||
'average' => 12.0,
|
||||
'gradedCount' => 25,
|
||||
],
|
||||
]);
|
||||
|
||||
// Other teachers' averages for same subject
|
||||
$this->reader->feedOtherTeachersAverages([10.0, 11.0, 13.0]);
|
||||
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('Contrôle chapitre 5', $result[0]->title);
|
||||
self::assertSame(12.0, $result[0]->average);
|
||||
self::assertEqualsWithDelta(11.33, $result[0]->subjectAverage, 0.01);
|
||||
self::assertNotNull($result[0]->percentile);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesNoOtherTeachersForComparison(): void
|
||||
{
|
||||
$this->reader->feedEvaluationDifficulties([
|
||||
[
|
||||
'evaluationId' => 'eval-1',
|
||||
'title' => 'Test unique',
|
||||
'classId' => 'class-1',
|
||||
'className' => '6ème A',
|
||||
'subjectId' => 'subject-1',
|
||||
'subjectName' => 'Musique',
|
||||
'date' => '2026-03-15',
|
||||
'average' => 14.0,
|
||||
'gradedCount' => 20,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->reader->feedOtherTeachersAverages([]);
|
||||
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertNull($result[0]->subjectAverage);
|
||||
self::assertNull($result[0]->percentile);
|
||||
}
|
||||
|
||||
private function query(): GetEvaluationDifficultyQuery
|
||||
{
|
||||
return new GetEvaluationDifficultyQuery(
|
||||
teacherId: self::TEACHER_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetStudentProgression;
|
||||
|
||||
use App\Scolarite\Application\Query\GetStudentProgression\GetStudentProgressionHandler;
|
||||
use App\Scolarite\Application\Query\GetStudentProgression\GetStudentProgressionQuery;
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
use App\Scolarite\Infrastructure\ReadModel\InMemoryTeacherStatisticsReader;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetStudentProgressionHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryTeacherStatisticsReader $reader;
|
||||
private GetStudentProgressionHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reader = new InMemoryTeacherStatisticsReader();
|
||||
$this->handler = new GetStudentProgressionHandler(
|
||||
$this->reader,
|
||||
new TeacherStatisticsCalculator(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyProgressionWhenNoGrades(): void
|
||||
{
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertSame([], $result->grades);
|
||||
self::assertNull($result->trendLine);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsSingleGradeWithNoTrendLine(): void
|
||||
{
|
||||
$this->reader->feedGradeHistory([
|
||||
['date' => '2026-01-15', 'value' => 12.0, 'evaluationTitle' => 'Contrôle 1'],
|
||||
]);
|
||||
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertCount(1, $result->grades);
|
||||
self::assertSame('2026-01-15', $result->grades[0]->date);
|
||||
self::assertSame(12.0, $result->grades[0]->value);
|
||||
self::assertNull($result->trendLine);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itComputesTrendLineFromMultipleGrades(): void
|
||||
{
|
||||
$this->reader->feedGradeHistory([
|
||||
['date' => '2026-01-15', 'value' => 10.0, 'evaluationTitle' => 'Contrôle 1'],
|
||||
['date' => '2026-02-10', 'value' => 12.0, 'evaluationTitle' => 'Contrôle 2'],
|
||||
['date' => '2026-03-05', 'value' => 14.0, 'evaluationTitle' => 'Contrôle 3'],
|
||||
]);
|
||||
|
||||
$result = ($this->handler)($this->query());
|
||||
|
||||
self::assertCount(3, $result->grades);
|
||||
self::assertNotNull($result->trendLine);
|
||||
self::assertGreaterThan(0, $result->trendLine->slope); // Positive trend
|
||||
}
|
||||
|
||||
private function query(): GetStudentProgressionQuery
|
||||
{
|
||||
return new GetStudentProgressionQuery(
|
||||
studentId: '550e8400-e29b-41d4-a716-446655440050',
|
||||
subjectId: '550e8400-e29b-41d4-a716-446655440030',
|
||||
classId: '550e8400-e29b-41d4-a716-446655440020',
|
||||
teacherId: '550e8400-e29b-41d4-a716-446655440010',
|
||||
tenantId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetTeacherStatisticsOverview;
|
||||
|
||||
use App\Scolarite\Application\Port\PeriodFinder;
|
||||
use App\Scolarite\Application\Port\PeriodInfo;
|
||||
use App\Scolarite\Application\Query\GetTeacherStatisticsOverview\GetTeacherStatisticsOverviewHandler;
|
||||
use App\Scolarite\Application\Query\GetTeacherStatisticsOverview\GetTeacherStatisticsOverviewQuery;
|
||||
use App\Scolarite\Infrastructure\ReadModel\InMemoryTeacherStatisticsReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetTeacherStatisticsOverviewHandlerTest extends TestCase
|
||||
{
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private InMemoryTeacherStatisticsReader $reader;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->reader = new InMemoryTeacherStatisticsReader();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyWhenNoPeriodFound(): void
|
||||
{
|
||||
$handler = $this->createHandler(periodInfo: null);
|
||||
|
||||
$result = $handler(new GetTeacherStatisticsOverviewQuery(
|
||||
teacherId: self::TEACHER_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsClassOverviewDtos(): void
|
||||
{
|
||||
$this->reader->feedClassesSummary([
|
||||
[
|
||||
'classId' => 'class-1',
|
||||
'className' => '6ème A',
|
||||
'subjectId' => 'subject-1',
|
||||
'subjectName' => 'Mathématiques',
|
||||
'evaluationCount' => 3,
|
||||
'studentCount' => 25,
|
||||
'average' => 12.5,
|
||||
'successRate' => 72.0,
|
||||
],
|
||||
[
|
||||
'classId' => 'class-2',
|
||||
'className' => '5ème B',
|
||||
'subjectId' => 'subject-1',
|
||||
'subjectName' => 'Mathématiques',
|
||||
'evaluationCount' => 2,
|
||||
'studentCount' => 28,
|
||||
'average' => 10.8,
|
||||
'successRate' => 57.0,
|
||||
],
|
||||
]);
|
||||
|
||||
$handler = $this->createHandler(periodInfo: $this->currentPeriod());
|
||||
|
||||
$result = $handler(new GetTeacherStatisticsOverviewQuery(
|
||||
teacherId: self::TEACHER_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
));
|
||||
|
||||
self::assertCount(2, $result);
|
||||
self::assertSame('6ème A', $result[0]->className);
|
||||
self::assertSame(12.5, $result[0]->average);
|
||||
self::assertSame(72.0, $result[0]->successRate);
|
||||
self::assertSame('5ème B', $result[1]->className);
|
||||
}
|
||||
|
||||
private function createHandler(?PeriodInfo $periodInfo): GetTeacherStatisticsOverviewHandler
|
||||
{
|
||||
$periodFinder = new class($periodInfo) implements PeriodFinder {
|
||||
public function __construct(private readonly ?PeriodInfo $info)
|
||||
{
|
||||
}
|
||||
|
||||
public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo
|
||||
{
|
||||
return $this->info;
|
||||
}
|
||||
};
|
||||
|
||||
return new GetTeacherStatisticsOverviewHandler($this->reader, $periodFinder);
|
||||
}
|
||||
|
||||
private function currentPeriod(): PeriodInfo
|
||||
{
|
||||
return new PeriodInfo(
|
||||
periodId: 'period-1',
|
||||
startDate: new DateTimeImmutable('2026-01-05'),
|
||||
endDate: new DateTimeImmutable('2026-03-31'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user