*/ private array $affectationResults = []; protected function setUp(): void { $this->tenantId = TenantId::generate(); $this->classId = ClassId::generate(); $this->subjectId = SubjectId::generate(); $this->replacementRepository = new InMemoryTeacherReplacementRepository(); $this->tenantContext = new TenantContext(); $this->now = new DateTimeImmutable('2026-04-13 10:00:00'); $this->tenantContext->setCurrentTenant(new TenantConfig( tenantId: InfraTenantId::fromString((string) $this->tenantId), subdomain: 'test', databaseUrl: 'sqlite:///:memory:', )); $this->affectationResults = []; $test = $this; $affectationChecker = new class($test) implements EnseignantAffectationChecker { public function __construct(private readonly GradeVoterTest $test) { } public function estAffecte( UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId, ): bool { return $this->test->getAffectationResult((string) $teacherId); } }; $autorisationChecker = new AutorisationSaisieNotesChecker( $affectationChecker, $this->replacementRepository, ); $clock = $this->createMock(Clock::class); $clock->method('now')->willReturn($this->now); $this->voter = new GradeVoter( $autorisationChecker, $this->tenantContext, $clock, ); } public function getAffectationResult(string $teacherId): bool { return $this->affectationResults[$teacherId] ?? false; } private function setTeacherAffecte(UserId $teacherId): void { $this->affectationResults[(string) $teacherId] = true; } #[Test] public function itAbstainsForUnrelatedAttributes(): void { $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::PROF->value); $result = $this->voter->vote($token, $evaluation, ['SOME_OTHER_ATTRIBUTE']); self::assertSame(Voter::ACCESS_ABSTAIN, $result); } #[Test] public function itAbstainsWhenSubjectIsNotAnEvaluation(): void { $token = $this->tokenWithSecurityUser(Role::PROF->value); $result = $this->voter->vote($token, null, [GradeVoter::VIEW]); self::assertSame(Voter::ACCESS_ABSTAIN, $result); } #[Test] public function itDeniesAccessToUnauthenticatedUsers(): void { $evaluation = $this->createEvaluation(); $token = $this->createMock(TokenInterface::class); $token->method('getUser')->willReturn(null); $result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]); self::assertSame(Voter::ACCESS_DENIED, $result); } #[Test] public function itDeniesAccessToNonSecurityUser(): void { $evaluation = $this->createEvaluation(); $user = $this->createMock(UserInterface::class); $user->method('getRoles')->willReturn([Role::PROF->value]); $token = $this->createMock(TokenInterface::class); $token->method('getUser')->willReturn($user); $result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]); self::assertSame(Voter::ACCESS_DENIED, $result); } #[Test] public function itGrantsViewToAdmin(): void { $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::ADMIN->value); $result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]); self::assertSame(Voter::ACCESS_GRANTED, $result); } #[Test] public function itDeniesEditToAdmin(): void { $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::ADMIN->value); $result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]); self::assertSame(Voter::ACCESS_DENIED, $result); } #[Test] public function itGrantsViewToSuperAdmin(): void { $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::SUPER_ADMIN->value); $result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]); self::assertSame(Voter::ACCESS_GRANTED, $result); } #[Test] public function itGrantsViewToAssignedTeacher(): void { $teacherId = UserId::generate(); $this->setTeacherAffecte($teacherId); $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId); $result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]); self::assertSame(Voter::ACCESS_GRANTED, $result); } #[Test] public function itGrantsEditToAssignedTeacher(): void { $teacherId = UserId::generate(); $this->setTeacherAffecte($teacherId); $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId); $result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]); self::assertSame(Voter::ACCESS_GRANTED, $result); } #[Test] public function itDeniesEditToUnassignedTeacher(): void { $teacherId = UserId::generate(); // No assignment set $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId); $result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]); self::assertSame(Voter::ACCESS_DENIED, $result); } #[Test] public function itGrantsViewToEvaluationOwnerWithoutAssignment(): void { $teacherId = UserId::generate(); // Teacher owns the evaluation but is no longer assigned $evaluation = $this->createEvaluation(teacherId: $teacherId); $token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId); $result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]); self::assertSame(Voter::ACCESS_GRANTED, $result); } #[Test] public function itDeniesEditToEvaluationOwnerWithoutAssignment(): void { $teacherId = UserId::generate(); // Teacher owns the evaluation but is no longer assigned $evaluation = $this->createEvaluation(teacherId: $teacherId); $token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId); $result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]); self::assertSame(Voter::ACCESS_DENIED, $result); } #[Test] public function itGrantsViewToActiveReplacement(): void { $replacementTeacherId = UserId::generate(); $this->createActiveReplacement($replacementTeacherId); $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId); $result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]); self::assertSame(Voter::ACCESS_GRANTED, $result); } #[Test] public function itGrantsEditToActiveReplacement(): void { $replacementTeacherId = UserId::generate(); $this->createActiveReplacement($replacementTeacherId); $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId); $result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]); self::assertSame(Voter::ACCESS_GRANTED, $result); } #[Test] public function itDeniesEditToExpiredReplacement(): void { $replacementTeacherId = UserId::generate(); $this->createExpiredReplacement($replacementTeacherId); $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId); $result = $this->voter->vote($token, $evaluation, [GradeVoter::EDIT]); self::assertSame(Voter::ACCESS_DENIED, $result); } #[Test] public function itDeniesViewToExpiredReplacementWhoIsNotOwner(): void { $replacementTeacherId = UserId::generate(); $this->createExpiredReplacement($replacementTeacherId); $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId); $result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]); self::assertSame(Voter::ACCESS_DENIED, $result); } #[Test] public function itDeniesViewToReplacementOnDifferentClassSubject(): void { $replacementTeacherId = UserId::generate(); // Remplacement actif mais sur une AUTRE classe/matière $otherClassId = ClassId::generate(); $otherSubjectId = SubjectId::generate(); $replacement = TeacherReplacement::designer( tenantId: $this->tenantId, replacedTeacherId: UserId::generate(), replacementTeacherId: $replacementTeacherId, startDate: $this->now->modify('-1 day'), endDate: $this->now->modify('+7 days'), classes: [new ClassSubjectPair($otherClassId, $otherSubjectId)], reason: 'Maladie', createdBy: UserId::generate(), now: $this->now->modify('-1 day'), ); $this->replacementRepository->save($replacement); $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::PROF->value, $replacementTeacherId); $result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]); self::assertSame(Voter::ACCESS_DENIED, $result); } #[Test] public function itDeniesViewToNonTeacherNonAdminRoles(): void { $evaluation = $this->createEvaluation(); foreach ([Role::ELEVE->value, Role::PARENT->value, Role::SECRETARIAT->value, Role::VIE_SCOLAIRE->value] as $role) { $token = $this->tokenWithSecurityUser($role); $result = $this->voter->vote($token, $evaluation, [GradeVoter::VIEW]); self::assertSame(Voter::ACCESS_DENIED, $result, "Role {$role} should be denied VIEW"); } } #[Test] public function itDeniesWhenNoTenantIsSet(): void { $teacherId = UserId::generate(); $this->setTeacherAffecte($teacherId); $tenantContext = new TenantContext(); $clock = $this->createMock(Clock::class); $clock->method('now')->willReturn($this->now); $test = $this; $affectationChecker = new class($test) implements EnseignantAffectationChecker { public function __construct(private readonly GradeVoterTest $test) { } public function estAffecte( UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId, ): bool { return $this->test->getAffectationResult((string) $teacherId); } }; $autorisationChecker = new AutorisationSaisieNotesChecker( $affectationChecker, $this->replacementRepository, ); $voter = new GradeVoter( $autorisationChecker, $tenantContext, $clock, ); $evaluation = $this->createEvaluation(); $token = $this->tokenWithSecurityUser(Role::PROF->value, $teacherId); $result = $voter->vote($token, $evaluation, [GradeVoter::VIEW]); self::assertSame(Voter::ACCESS_DENIED, $result); } private function createEvaluation(?UserId $teacherId = null): Evaluation { return Evaluation::creer( tenantId: $this->tenantId, classId: $this->classId, subjectId: $this->subjectId, teacherId: $teacherId ?? UserId::generate(), title: 'Contrôle de maths', description: null, evaluationDate: $this->now, gradeScale: new GradeScale(20), coefficient: new Coefficient(1.0), now: $this->now, ); } private function createActiveReplacement(UserId $replacementTeacherId): void { $replacement = TeacherReplacement::designer( tenantId: $this->tenantId, replacedTeacherId: UserId::generate(), replacementTeacherId: $replacementTeacherId, startDate: $this->now->modify('-1 day'), endDate: $this->now->modify('+7 days'), classes: [new ClassSubjectPair($this->classId, $this->subjectId)], reason: 'Maladie', createdBy: UserId::generate(), now: $this->now->modify('-1 day'), ); $this->replacementRepository->save($replacement); } private function createExpiredReplacement(UserId $replacementTeacherId): void { $replacement = TeacherReplacement::designer( tenantId: $this->tenantId, replacedTeacherId: UserId::generate(), replacementTeacherId: $replacementTeacherId, startDate: $this->now->modify('-14 days'), endDate: $this->now->modify('-1 day'), classes: [new ClassSubjectPair($this->classId, $this->subjectId)], reason: 'Maladie', createdBy: UserId::generate(), now: $this->now->modify('-14 days'), ); $this->replacementRepository->save($replacement); } private function tokenWithSecurityUser(string $role, ?UserId $userId = null): TokenInterface { $securityUser = new SecurityUser( userId: $userId ?? UserId::generate(), email: 'test@example.com', hashedPassword: 'hashed', tenantId: $this->tenantId, roles: [$role], ); $token = $this->createMock(TokenInterface::class); $token->method('getUser')->willReturn($securityUser); return $token; } }