averageRepository = new InMemoryStudentAverageRepository(); $this->tenantContext = new TenantContext(); } // ========================================================================= // Auth & Tenant Guards // ========================================================================= #[Test] public function itRejects401WhenNoTenant(): void { $provider = $this->createProvider( user: $this->studentUser(), periodForDate: null, ); $this->expectException(UnauthorizedHttpException::class); $provider->provide(new Get()); } #[Test] public function itRejects401WhenNoUser(): void { $this->setTenant(); $provider = $this->createProvider( user: null, periodForDate: null, ); $this->expectException(UnauthorizedHttpException::class); $provider->provide(new Get()); } #[Test] public function itRejects403ForTeacher(): void { $this->setTenant(); $provider = $this->createProvider( user: $this->teacherUser(), periodForDate: null, ); $this->expectException(AccessDeniedHttpException::class); $provider->provide(new Get()); } #[Test] public function itRejects403ForParent(): void { $this->setTenant(); $provider = $this->createProvider( user: $this->parentUser(), periodForDate: null, ); $this->expectException(AccessDeniedHttpException::class); $provider->provide(new Get()); } #[Test] public function itRejects403ForAdmin(): void { $this->setTenant(); $provider = $this->createProvider( user: $this->adminUser(), periodForDate: null, ); $this->expectException(AccessDeniedHttpException::class); $provider->provide(new Get()); } // ========================================================================= // Period auto-detection // ========================================================================= #[Test] public function itAutoDetectsCurrentPeriodWhenNoPeriodIdInFilters(): void { $this->setTenant(); $this->seedAverages(); $provider = $this->createProvider( user: $this->studentUser(), periodForDate: new PeriodInfo(self::PERIOD_ID, new DateTimeImmutable('2026-01-01'), new DateTimeImmutable('2026-03-31')), ); $result = $provider->provide(new Get()); self::assertInstanceOf(StudentMyAveragesResource::class, $result); self::assertSame(self::PERIOD_ID, $result->periodId); self::assertNotEmpty($result->subjectAverages); self::assertSame(16.0, $result->generalAverage); } #[Test] public function itReturnsEmptyResourceWhenNoPeriodDetected(): void { $this->setTenant(); $this->seedAverages(); $provider = $this->createProvider( user: $this->studentUser(), periodForDate: null, ); $result = $provider->provide(new Get()); self::assertInstanceOf(StudentMyAveragesResource::class, $result); self::assertNull($result->periodId); self::assertEmpty($result->subjectAverages); self::assertNull($result->generalAverage); } // ========================================================================= // Explicit periodId from filters // ========================================================================= #[Test] public function itUsesExplicitPeriodIdFromFilters(): void { $this->setTenant(); $this->seedAverages(); $provider = $this->createProvider( user: $this->studentUser(), periodForDate: null, ); $result = $provider->provide(new Get(), [], [ 'filters' => ['periodId' => self::PERIOD_ID], ]); self::assertInstanceOf(StudentMyAveragesResource::class, $result); self::assertSame(self::PERIOD_ID, $result->periodId); self::assertNotEmpty($result->subjectAverages); } #[Test] public function itReturnsEmptySubjectAveragesForUnknownPeriod(): void { $this->setTenant(); $this->seedAverages(); $unknownPeriod = '99999999-9999-9999-9999-999999999999'; $provider = $this->createProvider( user: $this->studentUser(), periodForDate: null, ); $result = $provider->provide(new Get(), [], [ 'filters' => ['periodId' => $unknownPeriod], ]); self::assertInstanceOf(StudentMyAveragesResource::class, $result); self::assertSame($unknownPeriod, $result->periodId); self::assertEmpty($result->subjectAverages); self::assertNull($result->generalAverage); } // ========================================================================= // Response shape // ========================================================================= #[Test] public function itReturnsStudentIdInResource(): void { $this->setTenant(); $provider = $this->createProvider( user: $this->studentUser(), periodForDate: null, ); $result = $provider->provide(new Get(), [], [ 'filters' => ['periodId' => self::PERIOD_ID], ]); self::assertInstanceOf(StudentMyAveragesResource::class, $result); self::assertSame(self::STUDENT_UUID, $result->studentId); } #[Test] public function itReturnsSubjectAverageShape(): void { $this->setTenant(); $this->seedAverages(); $provider = $this->createProvider( user: $this->studentUser(), periodForDate: null, ); $result = $provider->provide(new Get(), [], [ 'filters' => ['periodId' => self::PERIOD_ID], ]); self::assertInstanceOf(StudentMyAveragesResource::class, $result); self::assertCount(1, $result->subjectAverages); $avg = $result->subjectAverages[0]; self::assertSame(self::SUBJECT_UUID, $avg['subjectId']); self::assertSame(16.0, $avg['average']); self::assertSame(1, $avg['gradeCount']); } // ========================================================================= // Helpers // ========================================================================= private function setTenant(): void { $this->tenantContext->setCurrentTenant(new TenantConfig( tenantId: InfraTenantId::fromString(self::TENANT_UUID), subdomain: 'ecole-alpha', databaseUrl: 'sqlite:///:memory:', )); } private function seedAverages(): void { $tenantId = TenantId::fromString(self::TENANT_UUID); $studentId = UserId::fromString(self::STUDENT_UUID); $this->averageRepository->saveSubjectAverage( $tenantId, $studentId, SubjectId::fromString(self::SUBJECT_UUID), self::PERIOD_ID, 16.0, 1, ); $this->averageRepository->saveGeneralAverage( $tenantId, $studentId, self::PERIOD_ID, 16.0, ); } private function createProvider(?SecurityUser $user, ?PeriodInfo $periodForDate): StudentMyAveragesProvider { $security = $this->createMock(Security::class); $security->method('getUser')->willReturn($user); $periodFinder = new class($periodForDate) implements PeriodFinder { public function __construct(private readonly ?PeriodInfo $info) { } public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo { return $this->info; } }; return new StudentMyAveragesProvider( $this->averageRepository, $periodFinder, $this->tenantContext, $security, ); } private function studentUser(): SecurityUser { return new SecurityUser( userId: UserId::fromString(self::STUDENT_UUID), email: 'student@test.local', hashedPassword: '', tenantId: InfraTenantId::fromString(self::TENANT_UUID), roles: ['ROLE_ELEVE'], ); } private function teacherUser(): SecurityUser { return new SecurityUser( userId: UserId::fromString('44444444-4444-4444-4444-444444444444'), email: 'teacher@test.local', hashedPassword: '', tenantId: InfraTenantId::fromString(self::TENANT_UUID), roles: ['ROLE_PROF'], ); } private function parentUser(): SecurityUser { return new SecurityUser( userId: UserId::fromString('88888888-8888-8888-8888-888888888888'), email: 'parent@test.local', hashedPassword: '', tenantId: InfraTenantId::fromString(self::TENANT_UUID), roles: ['ROLE_PARENT'], ); } private function adminUser(): SecurityUser { return new SecurityUser( userId: UserId::fromString('33333333-3333-3333-3333-333333333333'), email: 'admin@test.local', hashedPassword: '', tenantId: InfraTenantId::fromString(self::TENANT_UUID), roles: ['ROLE_ADMIN'], ); } }