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:
@@ -112,6 +112,14 @@ final class UploadSubmissionAttachmentHandlerTest extends TestCase
|
||||
public function delete(string $path): void
|
||||
{
|
||||
}
|
||||
|
||||
public function readStream(string $path): mixed
|
||||
{
|
||||
/** @var resource $stream */
|
||||
$stream = fopen('php://memory', 'r+');
|
||||
|
||||
return $stream;
|
||||
}
|
||||
};
|
||||
|
||||
$clock = new class implements Clock {
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Service;
|
||||
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\ClassStatisticsDetailDto;
|
||||
use App\Scolarite\Application\Query\GetClassStatisticsDetail\StudentAverageDto;
|
||||
use App\Scolarite\Application\Service\StatisticsExporter;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use function str_contains;
|
||||
|
||||
final class StatisticsExporterTest extends TestCase
|
||||
{
|
||||
private StatisticsExporter $exporter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->exporter = new StatisticsExporter();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itExportsClassStatisticsToCsv(): void
|
||||
{
|
||||
$stats = new ClassStatisticsDetailDto(
|
||||
average: 12.5,
|
||||
successRate: 72.0,
|
||||
distribution: [0, 0, 1, 2, 3, 2, 1, 0],
|
||||
evolution: [],
|
||||
students: [
|
||||
new StudentAverageDto(
|
||||
studentId: 's1',
|
||||
studentName: 'Alice Dupont',
|
||||
average: 14.0,
|
||||
inDifficulty: false,
|
||||
trend: 'improving',
|
||||
),
|
||||
new StudentAverageDto(
|
||||
studentId: 's2',
|
||||
studentName: 'Bob Martin',
|
||||
average: 7.0,
|
||||
inDifficulty: true,
|
||||
trend: 'declining',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
$csv = $this->exporter->exportClassToCsv($stats, '6ème A', 'Mathématiques');
|
||||
|
||||
self::assertNotSame('', $csv);
|
||||
self::assertTrue(str_contains($csv, '6ème A'));
|
||||
self::assertTrue(str_contains($csv, 'Mathématiques'));
|
||||
self::assertTrue(str_contains($csv, '12.5'));
|
||||
self::assertTrue(str_contains($csv, '72%'));
|
||||
self::assertTrue(str_contains($csv, 'Alice Dupont'));
|
||||
self::assertTrue(str_contains($csv, '14'));
|
||||
self::assertTrue(str_contains($csv, 'Progression'));
|
||||
self::assertTrue(str_contains($csv, 'Bob Martin'));
|
||||
self::assertTrue(str_contains($csv, 'Oui'));
|
||||
self::assertTrue(str_contains($csv, 'Régression'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesEmptyStudentList(): void
|
||||
{
|
||||
$stats = new ClassStatisticsDetailDto(
|
||||
average: null,
|
||||
successRate: 0.0,
|
||||
distribution: [0, 0, 0, 0, 0, 0, 0, 0],
|
||||
evolution: [],
|
||||
students: [],
|
||||
);
|
||||
|
||||
$csv = $this->exporter->exportClassToCsv($stats, '5ème B', 'Français');
|
||||
|
||||
self::assertNotSame('', $csv);
|
||||
self::assertTrue(str_contains($csv, '5ème B'));
|
||||
self::assertTrue(str_contains($csv, 'N/A'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Domain\Service;
|
||||
|
||||
use App\Scolarite\Domain\Service\TeacherStatisticsCalculator;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TeacherStatisticsCalculatorTest extends TestCase
|
||||
{
|
||||
private TeacherStatisticsCalculator $calculator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->calculator = new TeacherStatisticsCalculator();
|
||||
}
|
||||
|
||||
// --- Distribution ---
|
||||
|
||||
#[Test]
|
||||
public function distributionReturnsEmptyBinsWhenNoValues(): void
|
||||
{
|
||||
$bins = $this->calculator->calculateDistribution([]);
|
||||
|
||||
self::assertSame([0, 0, 0, 0, 0, 0, 0, 0], $bins);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function distributionPlacesValuesInCorrectBins(): void
|
||||
{
|
||||
// Bins: [0-2.5[, [2.5-5[, [5-7.5[, [7.5-10[, [10-12.5[, [12.5-15[, [15-17.5[, [17.5-20]
|
||||
$values = [1.0, 3.0, 6.0, 9.0, 11.0, 14.0, 16.0, 19.0];
|
||||
$bins = $this->calculator->calculateDistribution($values);
|
||||
|
||||
self::assertSame([1, 1, 1, 1, 1, 1, 1, 1], $bins);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function distributionHandlesMaxValue20InLastBin(): void
|
||||
{
|
||||
$bins = $this->calculator->calculateDistribution([20.0]);
|
||||
|
||||
self::assertSame([0, 0, 0, 0, 0, 0, 0, 1], $bins);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function distributionHandlesMultipleValuesInSameBin(): void
|
||||
{
|
||||
$values = [10.0, 10.5, 11.0, 12.0];
|
||||
$bins = $this->calculator->calculateDistribution($values);
|
||||
|
||||
// All in bin [10-12.5[
|
||||
self::assertSame([0, 0, 0, 0, 4, 0, 0, 0], $bins);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function distributionHandlesBoundaryValues(): void
|
||||
{
|
||||
$values = [0.0, 2.5, 5.0, 7.5, 10.0, 12.5, 15.0, 17.5];
|
||||
$bins = $this->calculator->calculateDistribution($values);
|
||||
|
||||
// 0.0 → bin 0, 2.5 → bin 1, 5.0 → bin 2, 7.5 → bin 3
|
||||
// 10.0 → bin 4, 12.5 → bin 5, 15.0 → bin 6, 17.5 → bin 7
|
||||
self::assertSame([1, 1, 1, 1, 1, 1, 1, 1], $bins);
|
||||
}
|
||||
|
||||
// --- Success Rate ---
|
||||
|
||||
#[Test]
|
||||
public function successRateReturnsZeroWhenNoValues(): void
|
||||
{
|
||||
self::assertSame(0.0, $this->calculator->calculateSuccessRate([]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successRateCountsValuesAboveThreshold(): void
|
||||
{
|
||||
// Threshold defaults to 10.0
|
||||
$values = [8.0, 10.0, 12.0, 14.0, 6.0];
|
||||
|
||||
// 10.0, 12.0, 14.0 are >= 10 → 3/5 = 60%
|
||||
self::assertSame(60.0, $this->calculator->calculateSuccessRate($values));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successRateWithCustomThreshold(): void
|
||||
{
|
||||
$values = [8.0, 10.0, 12.0, 14.0, 6.0];
|
||||
|
||||
// >= 12: 12.0, 14.0 → 2/5 = 40%
|
||||
self::assertSame(40.0, $this->calculator->calculateSuccessRate($values, 12.0));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successRateWithAllAbove(): void
|
||||
{
|
||||
$values = [15.0, 18.0, 12.0];
|
||||
|
||||
self::assertSame(100.0, $this->calculator->calculateSuccessRate($values));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function successRateWithNoneAbove(): void
|
||||
{
|
||||
$values = [4.0, 6.0, 8.0];
|
||||
|
||||
self::assertSame(0.0, $this->calculator->calculateSuccessRate($values));
|
||||
}
|
||||
|
||||
// --- Trend Line ---
|
||||
|
||||
#[Test]
|
||||
public function trendLineReturnsNullWhenLessThanTwoPoints(): void
|
||||
{
|
||||
self::assertNull($this->calculator->calculateTrendLine([]));
|
||||
self::assertNull($this->calculator->calculateTrendLine([[1, 10.0]]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function trendLineCalculatesLinearRegression(): void
|
||||
{
|
||||
// Perfectly increasing line: y = 2x + 8
|
||||
$points = [[1, 10.0], [2, 12.0], [3, 14.0]];
|
||||
|
||||
$result = $this->calculator->calculateTrendLine($points);
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertEqualsWithDelta(2.0, $result->slope, 0.01);
|
||||
self::assertEqualsWithDelta(8.0, $result->intercept, 0.01);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function trendLineWithFlatData(): void
|
||||
{
|
||||
$points = [[1, 12.0], [2, 12.0], [3, 12.0]];
|
||||
|
||||
$result = $this->calculator->calculateTrendLine($points);
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertEqualsWithDelta(0.0, $result->slope, 0.01);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function trendLineWithDecreasingData(): void
|
||||
{
|
||||
$points = [[1, 16.0], [2, 14.0], [3, 12.0]];
|
||||
|
||||
$result = $this->calculator->calculateTrendLine($points);
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertLessThan(0, $result->slope);
|
||||
}
|
||||
|
||||
// --- Detect Trend ---
|
||||
|
||||
#[Test]
|
||||
public function detectTrendReturnsStableWhenLessThanTwoAverages(): void
|
||||
{
|
||||
self::assertSame('stable', $this->calculator->detectTrend([]));
|
||||
self::assertSame('stable', $this->calculator->detectTrend([12.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectTrendReturnsImprovingWhenLastIsHigherByThreshold(): void
|
||||
{
|
||||
// Last - first > 1.0 (default threshold)
|
||||
self::assertSame('improving', $this->calculator->detectTrend([10.0, 11.5, 13.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectTrendReturnsDecliningWhenLastIsLowerByThreshold(): void
|
||||
{
|
||||
self::assertSame('declining', $this->calculator->detectTrend([14.0, 12.0, 10.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectTrendReturnsStableWhenDifferenceIsBelowThreshold(): void
|
||||
{
|
||||
self::assertSame('stable', $this->calculator->detectTrend([12.0, 12.5, 12.8]));
|
||||
}
|
||||
|
||||
// --- Percentile ---
|
||||
|
||||
#[Test]
|
||||
public function percentileReturnsHundredWhenNoOtherValues(): void
|
||||
{
|
||||
self::assertSame(100.0, $this->calculator->calculatePercentile(12.0, []));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function percentileCalculatesCorrectly(): void
|
||||
{
|
||||
// My value: 14.0, others: [10, 12, 16, 18]
|
||||
// 2 out of 4 are below → 50th percentile
|
||||
self::assertSame(50.0, $this->calculator->calculatePercentile(14.0, [10.0, 12.0, 16.0, 18.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function percentileAtBottom(): void
|
||||
{
|
||||
self::assertSame(0.0, $this->calculator->calculatePercentile(5.0, [10.0, 12.0, 14.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function percentileAtTop(): void
|
||||
{
|
||||
self::assertSame(100.0, $this->calculator->calculatePercentile(20.0, [10.0, 12.0, 14.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function percentileWithDuplicateValues(): void
|
||||
{
|
||||
// My value: 12.0, others: [12.0, 12.0, 12.0]
|
||||
// 0 strictly below → 0th percentile
|
||||
self::assertSame(0.0, $this->calculator->calculatePercentile(12.0, [12.0, 12.0, 12.0]));
|
||||
}
|
||||
|
||||
// --- Detect Trend edge cases ---
|
||||
|
||||
#[Test]
|
||||
public function detectTrendAtExactThresholdReturnsStable(): void
|
||||
{
|
||||
// Difference is exactly 1.0 → should be stable (not strictly above threshold)
|
||||
self::assertSame('stable', $this->calculator->detectTrend([10.0, 11.0]));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function detectTrendJustAboveThresholdReturnsImproving(): void
|
||||
{
|
||||
// Difference is 1.01 → should be improving
|
||||
self::assertSame('improving', $this->calculator->detectTrend([10.0, 11.01]));
|
||||
}
|
||||
|
||||
// --- Distribution edge cases ---
|
||||
|
||||
#[Test]
|
||||
public function distributionWithSingleValue(): void
|
||||
{
|
||||
$bins = $this->calculator->calculateDistribution([10.0]);
|
||||
|
||||
self::assertSame([0, 0, 0, 0, 1, 0, 0, 0], $bins);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function distributionWithAllSameValues(): void
|
||||
{
|
||||
$values = [12.0, 12.0, 12.0, 12.0, 12.0];
|
||||
$bins = $this->calculator->calculateDistribution($values);
|
||||
|
||||
// All in bin [10-12.5[
|
||||
self::assertSame([0, 0, 0, 0, 5, 0, 0, 0], $bins);
|
||||
}
|
||||
|
||||
// --- Trend Line edge cases ---
|
||||
|
||||
#[Test]
|
||||
public function trendLineWithTwoPointsExact(): void
|
||||
{
|
||||
$points = [[1, 10.0], [2, 14.0]];
|
||||
|
||||
$result = $this->calculator->calculateTrendLine($points);
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertEqualsWithDelta(4.0, $result->slope, 0.01);
|
||||
self::assertEqualsWithDelta(6.0, $result->intercept, 0.01);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function trendLineWithNoisyData(): void
|
||||
{
|
||||
// Noisy but overall increasing
|
||||
$points = [[1, 8.0], [2, 12.0], [3, 10.0], [4, 14.0], [5, 13.0]];
|
||||
|
||||
$result = $this->calculator->calculateTrendLine($points);
|
||||
|
||||
self::assertNotNull($result);
|
||||
self::assertGreaterThan(0, $result->slope);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Command\UploadHomeworkAttachment\UploadHomeworkAttachmentHandler;
|
||||
use App\Scolarite\Application\Port\FileStorage;
|
||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId;
|
||||
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||
use App\Scolarite\Infrastructure\Api\Controller\HomeworkAttachmentController;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkAttachmentRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Tests\Unit\Scolarite\Infrastructure\Storage\InMemoryFileStorage;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class HomeworkAttachmentControllerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string OTHER_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440099';
|
||||
|
||||
private InMemoryHomeworkRepository $homeworkRepository;
|
||||
private InMemoryHomeworkAttachmentRepository $attachmentRepository;
|
||||
private InMemoryFileStorage $fileStorage;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->homeworkRepository = new InMemoryHomeworkRepository();
|
||||
$this->attachmentRepository = new InMemoryHomeworkAttachmentRepository();
|
||||
$this->fileStorage = new InMemoryFileStorage();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadReturnsStreamedResponseForExistingAttachment(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
$this->fileStorage->upload('homework/files/exercices.pdf', 'PDF content here', 'application/pdf');
|
||||
|
||||
$controller = $this->createController(self::TEACHER_ID);
|
||||
|
||||
$response = $controller->download((string) $homework->id, (string) $attachment->id);
|
||||
|
||||
self::assertInstanceOf(StreamedResponse::class, $response);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('application/pdf', $response->headers->get('Content-Type'));
|
||||
self::assertStringContainsString('exercices.pdf', $response->headers->get('Content-Disposition') ?? '');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadReturns404ForNonExistentAttachment(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$controller = $this->createController(self::TEACHER_ID);
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$controller->download((string) $homework->id, 'non-existent-attachment-id');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadReturns404WhenFileNotFoundInStorage(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$attachment = $this->createAttachment('missing.pdf', 'homework/files/missing.pdf');
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
// File NOT uploaded to storage — simulates a missing blob
|
||||
|
||||
$controller = $this->createController(self::TEACHER_ID);
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
|
||||
$controller->download((string) $homework->id, (string) $attachment->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadDeniesAccessToNonOwnerTeacher(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
|
||||
$controller = $this->createController(self::OTHER_TEACHER_ID);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$controller->download((string) $homework->id, (string) $attachment->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function listDeniesAccessToNonOwnerTeacher(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$controller = $this->createController(self::OTHER_TEACHER_ID);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$controller->list((string) $homework->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteDeniesAccessToNonOwnerTeacher(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
|
||||
$controller = $this->createController(self::OTHER_TEACHER_ID);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$controller->delete((string) $homework->id, (string) $attachment->id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function downloadDeniesAccessToUnauthenticatedUser(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$controller = $this->createControllerWithoutUser();
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$controller->download((string) $homework->id, 'any-attachment-id');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function listReturnsAttachmentsForOwner(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
|
||||
$controller = $this->createController(self::TEACHER_ID);
|
||||
|
||||
$response = $controller->list((string) $homework->id);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
/** @var array<array{id: string, filename: string}> $data */
|
||||
$data = json_decode((string) $response->getContent(), true);
|
||||
self::assertCount(1, $data);
|
||||
self::assertSame('exercices.pdf', $data[0]['filename']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteRemovesAttachmentAndFile(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
$attachment = $this->createAttachment('exercices.pdf', 'homework/files/exercices.pdf');
|
||||
$this->attachmentRepository->save($homework->id, $attachment);
|
||||
$this->fileStorage->upload('homework/files/exercices.pdf', 'content', 'application/pdf');
|
||||
|
||||
$controller = $this->createController(self::TEACHER_ID);
|
||||
$response = $controller->delete((string) $homework->id, (string) $attachment->id);
|
||||
|
||||
self::assertSame(204, $response->getStatusCode());
|
||||
self::assertEmpty($this->attachmentRepository->findByHomeworkId($homework->id));
|
||||
self::assertFalse($this->fileStorage->has('homework/files/exercices.pdf'));
|
||||
}
|
||||
|
||||
private function createHomework(): Homework
|
||||
{
|
||||
return Homework::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString('550e8400-e29b-41d4-a716-446655440020'),
|
||||
subjectId: SubjectId::fromString('550e8400-e29b-41d4-a716-446655440030'),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: 'Devoir test',
|
||||
description: 'Description',
|
||||
dueDate: new DateTimeImmutable('2026-05-01'),
|
||||
now: new DateTimeImmutable('2026-04-09'),
|
||||
);
|
||||
}
|
||||
|
||||
private function createAttachment(string $filename, string $filePath): HomeworkAttachment
|
||||
{
|
||||
return new HomeworkAttachment(
|
||||
id: HomeworkAttachmentId::generate(),
|
||||
filename: $filename,
|
||||
filePath: $filePath,
|
||||
fileSize: 5000,
|
||||
mimeType: 'application/pdf',
|
||||
uploadedAt: new DateTimeImmutable('2026-04-09'),
|
||||
);
|
||||
}
|
||||
|
||||
private function createController(string $teacherId): HomeworkAttachmentController
|
||||
{
|
||||
$securityUser = new SecurityUser(
|
||||
userId: UserId::fromString($teacherId),
|
||||
email: 'teacher@example.com',
|
||||
hashedPassword: 'hashed',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: ['ROLE_PROF'],
|
||||
);
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($securityUser);
|
||||
|
||||
$uploadHandler = $this->createUploadHandler($this->homeworkRepository, $this->fileStorage);
|
||||
|
||||
return new HomeworkAttachmentController(
|
||||
security: $security,
|
||||
homeworkRepository: $this->homeworkRepository,
|
||||
attachmentRepository: $this->attachmentRepository,
|
||||
uploadHandler: $uploadHandler,
|
||||
fileStorage: $this->fileStorage,
|
||||
);
|
||||
}
|
||||
|
||||
private function createControllerWithoutUser(): HomeworkAttachmentController
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn(null);
|
||||
|
||||
$uploadHandler = $this->createUploadHandler($this->homeworkRepository, $this->fileStorage);
|
||||
|
||||
return new HomeworkAttachmentController(
|
||||
security: $security,
|
||||
homeworkRepository: $this->homeworkRepository,
|
||||
attachmentRepository: $this->attachmentRepository,
|
||||
uploadHandler: $uploadHandler,
|
||||
fileStorage: $this->fileStorage,
|
||||
);
|
||||
}
|
||||
|
||||
private function createUploadHandler(HomeworkRepository $homeworkRepository, FileStorage $fileStorage): UploadHomeworkAttachmentHandler
|
||||
{
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-04-09 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
return new UploadHomeworkAttachmentHandler($homeworkRepository, $fileStorage, $clock);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,16 @@ namespace App\Tests\Unit\Scolarite\Infrastructure\Storage;
|
||||
|
||||
use App\Scolarite\Application\Port\FileStorage;
|
||||
|
||||
use function fopen;
|
||||
use function fwrite;
|
||||
use function is_string;
|
||||
|
||||
use Override;
|
||||
|
||||
use function rewind;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class InMemoryFileStorage implements FileStorage
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
@@ -29,6 +35,21 @@ final class InMemoryFileStorage implements FileStorage
|
||||
unset($this->files[$path]);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function readStream(string $path): mixed
|
||||
{
|
||||
if (!isset($this->files[$path])) {
|
||||
throw new RuntimeException("File not found: {$path}");
|
||||
}
|
||||
|
||||
/** @var resource $stream */
|
||||
$stream = fopen('php://memory', 'r+');
|
||||
fwrite($stream, $this->files[$path]);
|
||||
rewind($stream);
|
||||
|
||||
return $stream;
|
||||
}
|
||||
|
||||
public function has(string $path): bool
|
||||
{
|
||||
return isset($this->files[$path]);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Infrastructure\Storage;
|
||||
|
||||
use App\Scolarite\Infrastructure\Storage\S3FileStorage;
|
||||
|
||||
use function fopen;
|
||||
|
||||
use League\Flysystem\Filesystem;
|
||||
use League\Flysystem\UnableToDeleteFile;
|
||||
use League\Flysystem\UnableToReadFile;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
use RuntimeException;
|
||||
|
||||
final class S3FileStorageTest extends TestCase
|
||||
{
|
||||
private Filesystem $filesystem;
|
||||
private LoggerInterface $logger;
|
||||
private S3FileStorage $storage;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->filesystem = $this->createMock(Filesystem::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->storage = $this->createStorageWithMockedFilesystem($this->filesystem, $this->logger);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadWritesStringContentToFilesystem(): void
|
||||
{
|
||||
$this->filesystem->expects(self::once())
|
||||
->method('write')
|
||||
->with('homework/abc/file.pdf', 'fake content', ['ContentType' => 'application/pdf']);
|
||||
|
||||
$result = $this->storage->upload('homework/abc/file.pdf', 'fake content', 'application/pdf');
|
||||
|
||||
self::assertSame('homework/abc/file.pdf', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadWritesStreamContentToFilesystem(): void
|
||||
{
|
||||
/** @var resource $stream */
|
||||
$stream = fopen('php://memory', 'r+');
|
||||
|
||||
$this->filesystem->expects(self::once())
|
||||
->method('writeStream')
|
||||
->with('homework/abc/file.pdf', $stream, ['ContentType' => 'application/pdf']);
|
||||
|
||||
$result = $this->storage->upload('homework/abc/file.pdf', $stream, 'application/pdf');
|
||||
|
||||
self::assertSame('homework/abc/file.pdf', $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteRemovesFileFromFilesystem(): void
|
||||
{
|
||||
$this->filesystem->expects(self::once())
|
||||
->method('delete')
|
||||
->with('homework/abc/file.pdf');
|
||||
|
||||
$this->logger->expects(self::never())
|
||||
->method('warning');
|
||||
|
||||
$this->storage->delete('homework/abc/file.pdf');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteLogsWarningOnFailure(): void
|
||||
{
|
||||
$this->filesystem->expects(self::once())
|
||||
->method('delete')
|
||||
->willThrowException(UnableToDeleteFile::atLocation('homework/abc/file.pdf'));
|
||||
|
||||
$this->logger->expects(self::once())
|
||||
->method('warning')
|
||||
->with(
|
||||
'S3 delete failed, possible orphan blob: {path}',
|
||||
self::callback(static fn (array $context): bool => $context['path'] === 'homework/abc/file.pdf'),
|
||||
);
|
||||
|
||||
$this->storage->delete('homework/abc/file.pdf');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function readStreamReturnsResourceFromFilesystem(): void
|
||||
{
|
||||
/** @var resource $expectedStream */
|
||||
$expectedStream = fopen('php://memory', 'r+');
|
||||
|
||||
$this->filesystem->expects(self::once())
|
||||
->method('readStream')
|
||||
->with('homework/abc/file.pdf')
|
||||
->willReturn($expectedStream);
|
||||
|
||||
$result = $this->storage->readStream('homework/abc/file.pdf');
|
||||
|
||||
self::assertSame($expectedStream, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function readStreamThrowsRuntimeExceptionOnMissingFile(): void
|
||||
{
|
||||
$this->filesystem->expects(self::once())
|
||||
->method('readStream')
|
||||
->with('homework/abc/missing.pdf')
|
||||
->willThrowException(UnableToReadFile::fromLocation('homework/abc/missing.pdf'));
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Impossible de lire le fichier : homework/abc/missing.pdf');
|
||||
|
||||
$this->storage->readStream('homework/abc/missing.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an S3FileStorage instance with a mocked Filesystem injected via reflection.
|
||||
*
|
||||
* S3FileStorage is `final readonly` and its constructor creates a real S3Client,
|
||||
* so we bypass it with newInstanceWithoutConstructor() and inject mocks directly.
|
||||
* If the class gains new properties, this method must be updated.
|
||||
*/
|
||||
private function createStorageWithMockedFilesystem(Filesystem $filesystem, LoggerInterface $logger): S3FileStorage
|
||||
{
|
||||
$reflection = new ReflectionClass(S3FileStorage::class);
|
||||
$storage = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$fsProp = $reflection->getProperty('filesystem');
|
||||
$fsProp->setValue($storage, $filesystem);
|
||||
|
||||
$loggerProp = $reflection->getProperty('logger');
|
||||
$loggerProp->setValue($storage, $logger);
|
||||
|
||||
return $storage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Tenant;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\DoctrineTenantRegistry;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantNotFoundException;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(DoctrineTenantRegistry::class)]
|
||||
final class DoctrineTenantRegistryTest extends TestCase
|
||||
{
|
||||
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master';
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string SUBDOMAIN = 'ecole-alpha';
|
||||
private const string DB_NAME = 'classeo_tenant_a1b2c3d4e5f67890abcdef1234567890';
|
||||
|
||||
#[Test]
|
||||
public function itResolvesConfigBySubdomain(): void
|
||||
{
|
||||
$registry = $this->registryWith([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
]);
|
||||
|
||||
$config = $registry->getBySubdomain(self::SUBDOMAIN);
|
||||
|
||||
self::assertSame(self::SUBDOMAIN, $config->subdomain);
|
||||
self::assertSame(self::TENANT_ID, (string) $config->tenantId);
|
||||
self::assertSame('postgresql://classeo:secret@db:5432/' . self::DB_NAME, $config->databaseUrl);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResolvesConfigByTenantId(): void
|
||||
{
|
||||
$registry = $this->registryWith([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
]);
|
||||
|
||||
$config = $registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||
|
||||
self::assertSame(self::SUBDOMAIN, $config->subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsForUnknownSubdomain(): void
|
||||
{
|
||||
$registry = $this->registryWith([]);
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
$registry->getBySubdomain('inexistant');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsForUnknownTenantId(): void
|
||||
{
|
||||
$registry = $this->registryWith([]);
|
||||
|
||||
$this->expectException(TenantNotFoundException::class);
|
||||
$registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itChecksExistence(): void
|
||||
{
|
||||
$registry = $this->registryWith([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
]);
|
||||
|
||||
self::assertTrue($registry->exists(self::SUBDOMAIN));
|
||||
self::assertFalse($registry->exists('inexistant'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsAllConfigs(): void
|
||||
{
|
||||
$registry = $this->registryWith([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
['tenant_id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'subdomain' => 'ecole-beta', 'database_name' => 'classeo_tenant_beta'],
|
||||
]);
|
||||
|
||||
$configs = $registry->getAllConfigs();
|
||||
|
||||
self::assertCount(2, $configs);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itQueriesDatabaseOnlyOnce(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->expects(self::once())
|
||||
->method('fetchAllAssociative')
|
||||
->willReturn([
|
||||
['tenant_id' => self::TENANT_ID, 'subdomain' => self::SUBDOMAIN, 'database_name' => self::DB_NAME],
|
||||
]);
|
||||
|
||||
$registry = new DoctrineTenantRegistry($connection, self::MASTER_URL);
|
||||
|
||||
$registry->getBySubdomain(self::SUBDOMAIN);
|
||||
$registry->getConfig(TenantId::fromString(self::TENANT_ID));
|
||||
$registry->exists(self::SUBDOMAIN);
|
||||
$registry->getAllConfigs();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array{tenant_id: string, subdomain: string, database_name: string}> $rows
|
||||
*/
|
||||
private function registryWith(array $rows): DoctrineTenantRegistry
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchAllAssociative')->willReturn($rows);
|
||||
|
||||
return new DoctrineTenantRegistry($connection, self::MASTER_URL);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createsEstablishmentAndReturnsResult(): void
|
||||
public function createsEstablishmentAndReturnsIt(): void
|
||||
{
|
||||
$command = new CreateEstablishmentCommand(
|
||||
name: 'École Alpha',
|
||||
@@ -46,13 +46,13 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
||||
superAdminId: self::SUPER_ADMIN_ID,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
$establishment = ($this->handler)($command);
|
||||
|
||||
self::assertNotEmpty($result->establishmentId);
|
||||
self::assertNotEmpty($result->tenantId);
|
||||
self::assertSame('École Alpha', $result->name);
|
||||
self::assertSame('ecole-alpha', $result->subdomain);
|
||||
self::assertStringStartsWith('classeo_tenant_', $result->databaseName);
|
||||
self::assertNotEmpty((string) $establishment->id);
|
||||
self::assertNotEmpty((string) $establishment->tenantId);
|
||||
self::assertSame('École Alpha', $establishment->name);
|
||||
self::assertSame('ecole-alpha', $establishment->subdomain);
|
||||
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -65,10 +65,10 @@ final class CreateEstablishmentHandlerTest extends TestCase
|
||||
superAdminId: self::SUPER_ADMIN_ID,
|
||||
);
|
||||
|
||||
$result = ($this->handler)($command);
|
||||
$establishment = ($this->handler)($command);
|
||||
|
||||
$establishments = $this->repository->findAll();
|
||||
self::assertCount(1, $establishments);
|
||||
self::assertSame($result->establishmentId, (string) $establishments[0]->id);
|
||||
self::assertSame((string) $establishment->id, (string) $establishments[0]->id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
||||
$this->repository->save(Establishment::creer(
|
||||
name: 'École Alpha',
|
||||
subdomain: 'ecole-alpha',
|
||||
adminEmail: 'admin@ecole-alpha.fr',
|
||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||
));
|
||||
@@ -47,6 +48,7 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
||||
$this->repository->save(Establishment::creer(
|
||||
name: 'École Beta',
|
||||
subdomain: 'ecole-beta',
|
||||
adminEmail: 'admin@ecole-beta.fr',
|
||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-16 11:00:00'),
|
||||
));
|
||||
@@ -56,6 +58,6 @@ final class GetEstablishmentsHandlerTest extends TestCase
|
||||
self::assertCount(2, $result);
|
||||
self::assertSame('École Alpha', $result[0]->name);
|
||||
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
||||
self::assertSame('active', $result[0]->status);
|
||||
self::assertSame('provisioning', $result[0]->status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,11 @@ final class EstablishmentTest extends TestCase
|
||||
private const string SUBDOMAIN = 'ecole-alpha';
|
||||
|
||||
#[Test]
|
||||
public function creerCreatesActiveEstablishment(): void
|
||||
public function creerCreatesProvisioningEstablishment(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
|
||||
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
||||
self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status);
|
||||
self::assertSame(self::ESTABLISHMENT_NAME, $establishment->name);
|
||||
self::assertSame(self::SUBDOMAIN, $establishment->subdomain);
|
||||
self::assertNull($establishment->lastActivityAt);
|
||||
@@ -59,10 +59,21 @@ final class EstablishmentTest extends TestCase
|
||||
self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activerChangesStatusToActif(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
|
||||
self::assertSame(EstablishmentStatus::PROVISIONING, $establishment->status);
|
||||
$establishment->activer();
|
||||
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function desactiverChangesStatusToInactif(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
$establishment->activer();
|
||||
|
||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||
|
||||
@@ -73,6 +84,7 @@ final class EstablishmentTest extends TestCase
|
||||
public function desactiverRecordsEtablissementDesactiveEvent(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
$establishment->activer();
|
||||
$establishment->pullDomainEvents(); // Clear creation event
|
||||
|
||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||
@@ -86,6 +98,7 @@ final class EstablishmentTest extends TestCase
|
||||
public function desactiverThrowsWhenAlreadyInactive(): void
|
||||
{
|
||||
$establishment = $this->createEstablishment();
|
||||
$establishment->activer();
|
||||
$establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00'));
|
||||
|
||||
$this->expectException(EstablishmentDejaInactifException::class);
|
||||
@@ -141,6 +154,7 @@ final class EstablishmentTest extends TestCase
|
||||
return Establishment::creer(
|
||||
name: self::ESTABLISHMENT_NAME,
|
||||
subdomain: self::SUBDOMAIN,
|
||||
adminEmail: 'admin@ecole-alpha.fr',
|
||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Tests\Unit\SuperAdmin\Infrastructure\Api\Processor;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
||||
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||
@@ -16,13 +17,15 @@ use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
final class CreateEstablishmentProcessorTest extends TestCase
|
||||
{
|
||||
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
#[Test]
|
||||
public function processCreatesEstablishmentAndReturnsResource(): void
|
||||
public function processCreatesEstablishmentAndDispatchesProvisioning(): void
|
||||
{
|
||||
$repository = new InMemoryEstablishmentRepository();
|
||||
$clock = new class implements Clock {
|
||||
@@ -42,7 +45,16 @@ final class CreateEstablishmentProcessorTest extends TestCase
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($securityUser);
|
||||
|
||||
$processor = new CreateEstablishmentProcessor($handler, $security);
|
||||
$dispatched = [];
|
||||
$commandBus = $this->createMock(MessageBusInterface::class);
|
||||
$commandBus->method('dispatch')
|
||||
->willReturnCallback(static function (object $message) use (&$dispatched): Envelope {
|
||||
$dispatched[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$processor = new CreateEstablishmentProcessor($handler, $security, $commandBus);
|
||||
|
||||
$input = new EstablishmentResource();
|
||||
$input->name = 'École Gamma';
|
||||
@@ -55,6 +67,12 @@ final class CreateEstablishmentProcessorTest extends TestCase
|
||||
self::assertNotNull($result->tenantId);
|
||||
self::assertSame('École Gamma', $result->name);
|
||||
self::assertSame('ecole-gamma', $result->subdomain);
|
||||
self::assertSame('active', $result->status);
|
||||
self::assertSame('provisioning', $result->status);
|
||||
|
||||
self::assertCount(1, $dispatched);
|
||||
self::assertInstanceOf(ProvisionEstablishmentCommand::class, $dispatched[0]);
|
||||
self::assertSame('admin@ecole-gamma.fr', $dispatched[0]->adminEmail);
|
||||
self::assertSame('ecole-gamma', $dispatched[0]->subdomain);
|
||||
self::assertSame('École Gamma', $dispatched[0]->establishmentName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ final class EstablishmentCollectionProviderTest extends TestCase
|
||||
$repository->save(Establishment::creer(
|
||||
name: 'École Alpha',
|
||||
subdomain: 'ecole-alpha',
|
||||
adminEmail: 'admin@ecole-alpha.fr',
|
||||
createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
createdAt: new DateTimeImmutable('2026-02-16 10:00:00'),
|
||||
));
|
||||
@@ -49,6 +50,6 @@ final class EstablishmentCollectionProviderTest extends TestCase
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('École Alpha', $result[0]->name);
|
||||
self::assertSame('ecole-alpha', $result[0]->subdomain);
|
||||
self::assertSame('active', $result[0]->status);
|
||||
self::assertSame('provisioning', $result[0]->status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\DatabaseTenantProvisioner;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\TenantDatabaseCreator;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\TenantMigrator;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use RuntimeException;
|
||||
|
||||
final class DatabaseTenantProvisionerTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itCallsCreatorThenMigratorInOrder(): void
|
||||
{
|
||||
$steps = [];
|
||||
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchOne')->willReturn(false);
|
||||
$connection->method('executeStatement')->willReturnCallback(
|
||||
static function () use (&$steps): int {
|
||||
$steps[] = 'create';
|
||||
|
||||
return 1;
|
||||
},
|
||||
);
|
||||
|
||||
$creator = new TenantDatabaseCreator($connection, new NullLogger());
|
||||
|
||||
// TenantMigrator is final — we wrap via the TenantProvisioner interface
|
||||
// to verify the creator is called. Migration subprocess cannot be tested unitarily.
|
||||
$provisioner = new class($creator, $steps) implements TenantProvisioner {
|
||||
/** @param string[] $steps */
|
||||
public function __construct(
|
||||
private readonly TenantDatabaseCreator $creator,
|
||||
private array &$steps,
|
||||
) {
|
||||
}
|
||||
|
||||
public function provision(string $databaseName): void
|
||||
{
|
||||
$this->creator->create($databaseName);
|
||||
$this->steps[] = 'migrate';
|
||||
}
|
||||
};
|
||||
|
||||
$provisioner->provision('classeo_tenant_test');
|
||||
|
||||
self::assertSame(['create', 'migrate'], $steps);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPropagatesCreationFailure(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchOne')->willThrowException(new RuntimeException('Connection refused'));
|
||||
|
||||
$creator = new TenantDatabaseCreator($connection, new NullLogger());
|
||||
$migrator = new TenantMigrator('/tmp', 'postgresql://u:p@h/db', new NullLogger());
|
||||
|
||||
$provisioner = new DatabaseTenantProvisioner($creator, $migrator);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$provisioner->provision('classeo_tenant_test');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||
use App\Administration\Domain\Event\InvitationRenvoyee;
|
||||
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||
use App\SuperAdmin\Domain\Model\Establishment\Establishment;
|
||||
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentStatus;
|
||||
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
final class ProvisionEstablishmentHandlerTest extends TestCase
|
||||
{
|
||||
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master?serverVersion=18';
|
||||
private const string ESTABLISHMENT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
#[Test]
|
||||
public function itProvisionsTenantDatabase(): void
|
||||
{
|
||||
$provisioner = $this->createMock(TenantProvisioner::class);
|
||||
$provisioner->expects(self::once())
|
||||
->method('provision')
|
||||
->with('classeo_tenant_abc123');
|
||||
|
||||
$handler = $this->buildHandler(provisioner: $provisioner);
|
||||
$handler($this->command());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesAdminUser(): void
|
||||
{
|
||||
$userRepository = new InMemoryUserRepository();
|
||||
|
||||
$handler = $this->buildHandler(userRepository: $userRepository);
|
||||
$handler($this->command());
|
||||
|
||||
$users = $userRepository->findAllByTenant(TenantId::fromString(self::TENANT_ID));
|
||||
self::assertCount(1, $users);
|
||||
self::assertSame('admin@ecole-gamma.fr', (string) $users[0]->email);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDispatchesInvitationEvent(): void
|
||||
{
|
||||
$dispatched = [];
|
||||
$eventBus = $this->spyEventBus($dispatched);
|
||||
|
||||
$handler = $this->buildHandler(eventBus: $eventBus);
|
||||
$handler($this->command());
|
||||
|
||||
self::assertNotEmpty($dispatched);
|
||||
self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itActivatesEstablishmentAfterProvisioning(): void
|
||||
{
|
||||
$establishmentRepo = $this->establishmentRepoWithProvisioningEstablishment();
|
||||
|
||||
$handler = $this->buildHandler(establishmentRepository: $establishmentRepo);
|
||||
$handler($this->command());
|
||||
|
||||
$establishment = $establishmentRepo->get(
|
||||
EstablishmentId::fromString(self::ESTABLISHMENT_ID),
|
||||
);
|
||||
self::assertSame(EstablishmentStatus::ACTIF, $establishment->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIsIdempotentWhenAdminAlreadyExists(): void
|
||||
{
|
||||
$userRepository = new InMemoryUserRepository();
|
||||
$dispatched = [];
|
||||
$eventBus = $this->spyEventBus($dispatched);
|
||||
|
||||
$handler = $this->buildHandler(userRepository: $userRepository, eventBus: $eventBus);
|
||||
|
||||
// First call creates the admin
|
||||
$handler($this->command());
|
||||
self::assertCount(1, $dispatched);
|
||||
self::assertInstanceOf(UtilisateurInvite::class, $dispatched[0]);
|
||||
|
||||
// Second call is idempotent — re-sends invitation
|
||||
$dispatched = [];
|
||||
$handler($this->command());
|
||||
self::assertCount(1, $dispatched);
|
||||
self::assertInstanceOf(InvitationRenvoyee::class, $dispatched[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSwitchesDatabaseAndRestores(): void
|
||||
{
|
||||
$switcher = new SpyDatabaseSwitcher();
|
||||
|
||||
$handler = $this->buildHandler(databaseSwitcher: $switcher);
|
||||
$handler($this->command());
|
||||
|
||||
self::assertCount(1, $switcher->switchedTo);
|
||||
self::assertStringContainsString('classeo_tenant_abc123', $switcher->switchedTo[0]);
|
||||
self::assertTrue($switcher->restoredToDefault);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPreservesQueryParametersInDatabaseUrl(): void
|
||||
{
|
||||
$switcher = new SpyDatabaseSwitcher();
|
||||
|
||||
$handler = $this->buildHandler(databaseSwitcher: $switcher);
|
||||
$handler($this->command());
|
||||
|
||||
self::assertStringContainsString('?serverVersion=18', $switcher->switchedTo[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRestoresDatabaseEvenOnFailure(): void
|
||||
{
|
||||
$switcher = new SpyDatabaseSwitcher();
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')
|
||||
->willThrowException(new RuntimeException('Event bus failure'));
|
||||
|
||||
$handler = $this->buildHandler(databaseSwitcher: $switcher, eventBus: $eventBus);
|
||||
|
||||
try {
|
||||
$handler($this->command());
|
||||
} catch (RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
self::assertTrue($switcher->restoredToDefault);
|
||||
}
|
||||
|
||||
private function command(): ProvisionEstablishmentCommand
|
||||
{
|
||||
return new ProvisionEstablishmentCommand(
|
||||
establishmentId: self::ESTABLISHMENT_ID,
|
||||
establishmentTenantId: self::TENANT_ID,
|
||||
databaseName: 'classeo_tenant_abc123',
|
||||
subdomain: 'ecole-gamma',
|
||||
adminEmail: 'admin@ecole-gamma.fr',
|
||||
establishmentName: 'École Gamma',
|
||||
);
|
||||
}
|
||||
|
||||
private function establishmentRepoWithProvisioningEstablishment(): InMemoryEstablishmentRepository
|
||||
{
|
||||
$repo = new InMemoryEstablishmentRepository();
|
||||
$establishment = Establishment::reconstitute(
|
||||
id: EstablishmentId::fromString(self::ESTABLISHMENT_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
name: 'École Gamma',
|
||||
subdomain: 'ecole-gamma',
|
||||
databaseName: 'classeo_tenant_abc123',
|
||||
status: EstablishmentStatus::PROVISIONING,
|
||||
createdAt: new DateTimeImmutable('2026-04-07 10:00:00'),
|
||||
createdBy: SuperAdminId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
);
|
||||
$repo->save($establishment);
|
||||
|
||||
return $repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object[] $dispatched
|
||||
*/
|
||||
private function spyEventBus(array &$dispatched): MessageBusInterface
|
||||
{
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')
|
||||
->willReturnCallback(static function (object $message) use (&$dispatched): Envelope {
|
||||
$dispatched[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
return $eventBus;
|
||||
}
|
||||
|
||||
private function buildHandler(
|
||||
?TenantProvisioner $provisioner = null,
|
||||
?InMemoryUserRepository $userRepository = null,
|
||||
?SpyDatabaseSwitcher $databaseSwitcher = null,
|
||||
?InMemoryEstablishmentRepository $establishmentRepository = null,
|
||||
?MessageBusInterface $eventBus = null,
|
||||
): ProvisionEstablishmentHandler {
|
||||
$provisioner ??= $this->createMock(TenantProvisioner::class);
|
||||
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-04-07 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$userRepository ??= new InMemoryUserRepository();
|
||||
|
||||
$databaseSwitcher ??= new SpyDatabaseSwitcher();
|
||||
|
||||
$establishmentRepository ??= $this->establishmentRepoWithProvisioningEstablishment();
|
||||
|
||||
$eventBus ??= $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')
|
||||
->willReturnCallback(static fn (object $m): Envelope => new Envelope($m));
|
||||
|
||||
return new ProvisionEstablishmentHandler(
|
||||
tenantProvisioner: $provisioner,
|
||||
inviteUserHandler: new InviteUserHandler($userRepository, $clock),
|
||||
userRepository: $userRepository,
|
||||
clock: $clock,
|
||||
databaseSwitcher: $databaseSwitcher,
|
||||
establishmentRepository: $establishmentRepository,
|
||||
eventBus: $eventBus,
|
||||
logger: new NullLogger(),
|
||||
masterDatabaseUrl: self::MASTER_URL,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
|
||||
use App\Administration\Domain\Event\UtilisateurInvite;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
|
||||
use App\SuperAdmin\Application\Port\TenantProvisioner;
|
||||
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentStatus;
|
||||
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
||||
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||
use App\SuperAdmin\Infrastructure\Provisioning\ProvisionEstablishmentHandler;
|
||||
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Integration tests: verify the full provisioning flow from API request
|
||||
* through establishment creation to async provisioning and admin user creation.
|
||||
*
|
||||
* Split into focused tests that each verify one aspect of the flow.
|
||||
*/
|
||||
final class ProvisioningIntegrationTest extends TestCase
|
||||
{
|
||||
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string MASTER_URL = 'postgresql://classeo:secret@db:5432/classeo_master';
|
||||
|
||||
private InMemoryEstablishmentRepository $establishmentRepository;
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private ?ProvisionEstablishmentCommand $provisionCommand;
|
||||
/** @var object[] */
|
||||
private array $dispatchedEvents;
|
||||
|
||||
private function runFullFlow(): void
|
||||
{
|
||||
$clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-04-07 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 1: API processor creates establishment
|
||||
$this->establishmentRepository = new InMemoryEstablishmentRepository();
|
||||
$createHandler = new CreateEstablishmentHandler($this->establishmentRepository, $clock);
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn(new SecuritySuperAdmin(
|
||||
SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||
'superadmin@classeo.fr',
|
||||
'hashed',
|
||||
));
|
||||
|
||||
$this->provisionCommand = null;
|
||||
$commandBus = $this->createMock(MessageBusInterface::class);
|
||||
$commandBus->method('dispatch')
|
||||
->willReturnCallback(function (object $message): Envelope {
|
||||
if ($message instanceof ProvisionEstablishmentCommand) {
|
||||
$this->provisionCommand = $message;
|
||||
}
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$processor = new CreateEstablishmentProcessor($createHandler, $security, $commandBus);
|
||||
|
||||
$input = new EstablishmentResource();
|
||||
$input->name = 'École Test';
|
||||
$input->subdomain = 'ecole-test';
|
||||
$input->adminEmail = 'admin@ecole-test.fr';
|
||||
|
||||
$processor->process($input, new Post());
|
||||
|
||||
// Phase 2: Provisioning handler processes the command
|
||||
self::assertNotNull($this->provisionCommand);
|
||||
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->dispatchedEvents = [];
|
||||
|
||||
$eventBus = $this->createMock(MessageBusInterface::class);
|
||||
$eventBus->method('dispatch')
|
||||
->willReturnCallback(function (object $message): Envelope {
|
||||
$this->dispatchedEvents[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
});
|
||||
|
||||
$provisioner = $this->createMock(TenantProvisioner::class);
|
||||
|
||||
$switcher = new SpyDatabaseSwitcher();
|
||||
|
||||
$provisionHandler = new ProvisionEstablishmentHandler(
|
||||
tenantProvisioner: $provisioner,
|
||||
inviteUserHandler: new InviteUserHandler($this->userRepository, $clock),
|
||||
userRepository: $this->userRepository,
|
||||
clock: $clock,
|
||||
databaseSwitcher: $switcher,
|
||||
establishmentRepository: $this->establishmentRepository,
|
||||
eventBus: $eventBus,
|
||||
logger: new NullLogger(),
|
||||
masterDatabaseUrl: self::MASTER_URL,
|
||||
);
|
||||
|
||||
$provisionHandler($this->provisionCommand);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function processorCreatesEstablishmentInProvisioningStatus(): void
|
||||
{
|
||||
$this->runFullFlow();
|
||||
|
||||
$establishments = $this->establishmentRepository->findAll();
|
||||
self::assertCount(1, $establishments);
|
||||
self::assertSame('École Test', $establishments[0]->name);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function processorDispatchesProvisioningCommandWithAdminEmail(): void
|
||||
{
|
||||
$this->runFullFlow();
|
||||
|
||||
self::assertNotNull($this->provisionCommand);
|
||||
self::assertSame('admin@ecole-test.fr', $this->provisionCommand->adminEmail);
|
||||
self::assertSame('ecole-test', $this->provisionCommand->subdomain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function provisioningCreatesAdminUserWithCorrectRole(): void
|
||||
{
|
||||
$this->runFullFlow();
|
||||
|
||||
$users = $this->userRepository->findAllByTenant(
|
||||
TenantId::fromString($this->provisionCommand->establishmentTenantId),
|
||||
);
|
||||
self::assertCount(1, $users);
|
||||
self::assertSame('admin@ecole-test.fr', (string) $users[0]->email);
|
||||
self::assertSame(Role::ADMIN, $users[0]->role);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function provisioningActivatesEstablishmentAndDispatchesEvent(): void
|
||||
{
|
||||
$this->runFullFlow();
|
||||
|
||||
$establishments = $this->establishmentRepository->findAll();
|
||||
self::assertSame(EstablishmentStatus::ACTIF, $establishments[0]->status);
|
||||
|
||||
self::assertCount(1, $this->dispatchedEvents);
|
||||
self::assertInstanceOf(UtilisateurInvite::class, $this->dispatchedEvents[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Provisioning;
|
||||
|
||||
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
|
||||
|
||||
/**
|
||||
* Test double that records database switching operations.
|
||||
*/
|
||||
final class SpyDatabaseSwitcher implements TenantDatabaseSwitcher
|
||||
{
|
||||
/** @var string[] */
|
||||
public array $switchedTo = [];
|
||||
public bool $restoredToDefault = false;
|
||||
|
||||
public function useTenantDatabase(string $databaseUrl): void
|
||||
{
|
||||
$this->switchedTo[] = $databaseUrl;
|
||||
}
|
||||
|
||||
public function useDefaultDatabase(): void
|
||||
{
|
||||
$this->restoredToDefault = true;
|
||||
}
|
||||
|
||||
public function currentDatabaseUrl(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user