diff --git a/backend/Dockerfile b/backend/Dockerfile index 697fbe0..c768efa 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -21,7 +21,7 @@ RUN apk add --no-cache \ $PHPIZE_DEPS # Install PHP extensions (opcache is pre-installed in FrankenPHP) -RUN docker-php-ext-install intl pdo_pgsql zip sockets +RUN docker-php-ext-install intl pcntl pdo_pgsql zip sockets # Install AMQP extension for RabbitMQ RUN pecl install amqp && docker-php-ext-enable amqp diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 3e2a3fc..88c7faf 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -138,6 +138,10 @@ services: App\Administration\Domain\Repository\PeriodConfigurationRepository: alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrinePeriodConfigurationRepository + # Grading Configuration Repository (Story 2.4 - Mode de notation) + App\Administration\Domain\Repository\GradingConfigurationRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineGradingConfigurationRepository + # GradeExistenceChecker (stub until Notes module exists) App\Administration\Application\Port\GradeExistenceChecker: alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker diff --git a/backend/migrations/Version20260206100000.php b/backend/migrations/Version20260206100000.php new file mode 100644 index 0000000..4337bd4 --- /dev/null +++ b/backend/migrations/Version20260206100000.php @@ -0,0 +1,47 @@ +addSql(<<<'SQL' + CREATE TABLE school_grading_configurations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + school_id UUID NOT NULL, + academic_year_id UUID NOT NULL, + grading_mode VARCHAR(20) NOT NULL DEFAULT 'numeric_20' CHECK (grading_mode IN ('numeric_20', 'numeric_10', 'letters', 'competencies', 'no_grades')), + configured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $this->addSql('CREATE INDEX idx_grading_config_tenant_id ON school_grading_configurations(tenant_id)'); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX idx_grading_config_unique + ON school_grading_configurations (tenant_id, school_id, academic_year_id) + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS school_grading_configurations'); + } +} diff --git a/backend/src/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeCommand.php b/backend/src/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeCommand.php new file mode 100644 index 0000000..9f3194a --- /dev/null +++ b/backend/src/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeCommand.php @@ -0,0 +1,16 @@ +tenantId); + $schoolId = SchoolId::fromString($command->schoolId); + $academicYearId = AcademicYearId::fromString($command->academicYearId); + $mode = GradingMode::from($command->gradingMode); + $now = $this->clock->now(); + + $existing = $this->repository->findBySchoolAndYear($tenantId, $schoolId, $academicYearId); + $hasGrades = $this->gradeExistenceChecker->hasGradesForYear($tenantId, $schoolId, $academicYearId); + + if ($existing === null) { + $configuration = SchoolGradingConfiguration::configurer( + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + mode: $mode, + hasExistingGrades: $hasGrades, + configuredAt: $now, + ); + } else { + $existing->changerMode( + nouveauMode: $mode, + hasExistingGrades: $hasGrades, + at: $now, + ); + + $configuration = $existing; + } + + $this->repository->save($configuration); + + return $configuration; + } +} diff --git a/backend/src/Administration/Application/Port/GradeExistenceChecker.php b/backend/src/Administration/Application/Port/GradeExistenceChecker.php index 773eb08..57370d1 100644 --- a/backend/src/Administration/Application/Port/GradeExistenceChecker.php +++ b/backend/src/Administration/Application/Port/GradeExistenceChecker.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Administration\Application\Port; use App\Administration\Domain\Model\SchoolClass\AcademicYearId; +use App\Administration\Domain\Model\SchoolClass\SchoolId; use App\Shared\Domain\Tenant\TenantId; /** @@ -20,4 +21,16 @@ interface GradeExistenceChecker AcademicYearId $academicYearId, int $periodSequence, ): bool; + + /** + * Vérifie si des notes existent pour une année scolaire entière. + * + * Utilisé pour bloquer le changement de mode de notation quand + * des évaluations ont déjà été saisies. + */ + public function hasGradesForYear( + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + ): bool; } diff --git a/backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandler.php b/backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandler.php new file mode 100644 index 0000000..ddb9e48 --- /dev/null +++ b/backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandler.php @@ -0,0 +1,35 @@ +gradeExistenceChecker->hasGradesForYear( + TenantId::fromString($query->tenantId), + SchoolId::fromString($query->schoolId), + AcademicYearId::fromString($query->academicYearId), + ); + } +} diff --git a/backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearQuery.php b/backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearQuery.php new file mode 100644 index 0000000..7a4c371 --- /dev/null +++ b/backend/src/Administration/Application/Query/HasGradesForYear/HasGradesForYearQuery.php @@ -0,0 +1,21 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->configurationId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/CannotChangeGradingModeWithExistingGradesException.php b/backend/src/Administration/Domain/Exception/CannotChangeGradingModeWithExistingGradesException.php new file mode 100644 index 0000000..fac5a35 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/CannotChangeGradingModeWithExistingGradesException.php @@ -0,0 +1,17 @@ +mode->scaleMax(); + } + + public function estNumerique(): bool + { + return $this->mode->estNumerique(); + } + + public function calculeMoyenne(): bool + { + return $this->mode->calculeMoyenne(); + } + + public function equals(self $other): bool + { + return $this->mode === $other->mode; + } +} diff --git a/backend/src/Administration/Domain/Model/GradingConfiguration/GradingMode.php b/backend/src/Administration/Domain/Model/GradingConfiguration/GradingMode.php new file mode 100644 index 0000000..d2ddf9f --- /dev/null +++ b/backend/src/Administration/Domain/Model/GradingConfiguration/GradingMode.php @@ -0,0 +1,73 @@ + 20, + self::NUMERIC_10 => 10, + self::LETTERS, self::COMPETENCIES, self::NO_GRADES => null, + }; + } + + /** + * Détermine si le mode utilise une notation numérique. + */ + public function estNumerique(): bool + { + return match ($this) { + self::NUMERIC_20, self::NUMERIC_10 => true, + self::LETTERS, self::COMPETENCIES, self::NO_GRADES => false, + }; + } + + /** + * Détermine si le mode nécessite un calcul de moyenne. + * + * Les compétences et le mode sans notes ne calculent pas de moyenne. + */ + public function calculeMoyenne(): bool + { + return match ($this) { + self::NUMERIC_20, self::NUMERIC_10, self::LETTERS => true, + self::COMPETENCIES, self::NO_GRADES => false, + }; + } + + /** + * Libellé utilisateur en français. + */ + public function label(): string + { + return match ($this) { + self::NUMERIC_20 => 'Notes /20', + self::NUMERIC_10 => 'Notes /10', + self::LETTERS => 'Lettres (A-E)', + self::COMPETENCIES => 'Compétences', + self::NO_GRADES => 'Sans notes', + }; + } +} diff --git a/backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfiguration.php b/backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfiguration.php new file mode 100644 index 0000000..c1c3f64 --- /dev/null +++ b/backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfiguration.php @@ -0,0 +1,145 @@ +updatedAt = $configuredAt; + } + + public static function generateId(): SchoolGradingConfigurationId + { + return SchoolGradingConfigurationId::generate(); + } + + /** + * Configure le mode de notation pour un établissement et une année scolaire. + * + * @param bool $hasExistingGrades Résultat de la vérification par l'Application Layer + */ + public static function configurer( + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + GradingMode $mode, + bool $hasExistingGrades, + DateTimeImmutable $configuredAt, + ): self { + // Le mode par défaut (NUMERIC_20) est autorisé même avec des notes existantes : + // c'est le mode implicite avant toute configuration, donc le créer explicitement + // est une opération idempotente. En revanche, changerMode() bloque tout changement + // dès qu'il y a des notes, quel que soit le mode cible. + if ($hasExistingGrades && $mode !== self::DEFAULT_MODE) { + throw new CannotChangeGradingModeWithExistingGradesException(); + } + + $config = new self( + id: SchoolGradingConfigurationId::generate(), + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + gradingConfiguration: new GradingConfiguration($mode), + configuredAt: $configuredAt, + ); + + $config->recordEvent(new ModeNotationConfigure( + configurationId: $config->id, + tenantId: $config->tenantId, + schoolId: $config->schoolId, + academicYearId: $config->academicYearId, + mode: $mode, + occurredOn: $configuredAt, + )); + + return $config; + } + + /** + * Change le mode de notation. + * + * @param bool $hasExistingGrades Résultat de la vérification par l'Application Layer + */ + public function changerMode( + GradingMode $nouveauMode, + bool $hasExistingGrades, + DateTimeImmutable $at, + ): void { + if ($this->gradingConfiguration->mode === $nouveauMode) { + return; + } + + if ($hasExistingGrades) { + throw new CannotChangeGradingModeWithExistingGradesException(); + } + + $this->gradingConfiguration = new GradingConfiguration($nouveauMode); + $this->updatedAt = $at; + + $this->recordEvent(new ModeNotationConfigure( + configurationId: $this->id, + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: $nouveauMode, + occurredOn: $at, + )); + } + + /** + * @internal Pour usage Infrastructure uniquement + */ + public static function reconstitute( + SchoolGradingConfigurationId $id, + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + GradingConfiguration $gradingConfiguration, + DateTimeImmutable $configuredAt, + DateTimeImmutable $updatedAt, + ): self { + $config = new self( + id: $id, + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + gradingConfiguration: $gradingConfiguration, + configuredAt: $configuredAt, + ); + + $config->updatedAt = $updatedAt; + + return $config; + } +} diff --git a/backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationId.php b/backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationId.php new file mode 100644 index 0000000..190e422 --- /dev/null +++ b/backend/src/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationId.php @@ -0,0 +1,11 @@ + + */ +final readonly class ConfigureGradingModeProcessor implements ProcessorInterface +{ + public function __construct( + private ConfigureGradingModeHandler $handler, + private GradeExistenceChecker $gradeExistenceChecker, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + private SchoolIdResolver $schoolIdResolver, + ) { + } + + /** + * @param GradingModeResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GradingModeResource + { + if (!$this->authorizationChecker->isGranted(GradingModeVoter::CONFIGURE)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à configurer le mode de notation.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + /** @var string $rawAcademicYearId */ + $rawAcademicYearId = $uriVariables['academicYearId']; + + $academicYearId = $this->academicYearResolver->resolve($rawAcademicYearId); + if ($academicYearId === null) { + throw new NotFoundHttpException('Année scolaire non trouvée.'); + } + + $schoolId = $this->schoolIdResolver->resolveForTenant($tenantId); + + try { + $command = new ConfigureGradingModeCommand( + tenantId: $tenantId, + schoolId: $schoolId, + academicYearId: $academicYearId, + gradingMode: $data->mode ?? '', + ); + + $configuration = ($this->handler)($command); + + foreach ($configuration->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + $resource = GradingModeResource::fromDomain($configuration); + $resource->hasExistingGrades = $this->gradeExistenceChecker->hasGradesForYear( + TenantId::fromString($tenantId), + SchoolId::fromString($schoolId), + AcademicYearId::fromString($academicYearId), + ); + $resource->availableModes = GradingModeResource::allAvailableModes(); + + return $resource; + } catch (CannotChangeGradingModeWithExistingGradesException $e) { + throw new ConflictHttpException($e->getMessage()); + } catch (ValueError $e) { + throw new BadRequestHttpException('Mode de notation invalide : ' . $e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessor.php b/backend/src/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessor.php index f44135f..62f5a58 100644 --- a/backend/src/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessor.php +++ b/backend/src/Administration/Infrastructure/Api/Processor/UpdatePeriodProcessor.php @@ -19,6 +19,7 @@ use App\Administration\Infrastructure\Api\Resource\PeriodResource; use App\Administration\Infrastructure\Security\PeriodVoter; use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver; use App\Shared\Infrastructure\Tenant\TenantContext; +use DateMalformedStringException; use Override; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -108,6 +109,8 @@ final readonly class UpdatePeriodProcessor implements ProcessorInterface throw new ConflictHttpException($e->getMessage()); } catch (InvalidPeriodDatesException|PeriodsOverlapException|PeriodsCoverageGapException $e) { throw new BadRequestHttpException($e->getMessage()); + } catch (DateMalformedStringException $e) { + throw new BadRequestHttpException('Format de date invalide : ' . $e->getMessage()); } } } diff --git a/backend/src/Administration/Infrastructure/Api/Provider/GradingModeProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/GradingModeProvider.php new file mode 100644 index 0000000..01cfbba --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/GradingModeProvider.php @@ -0,0 +1,87 @@ + + */ +final readonly class GradingModeProvider implements ProviderInterface +{ + public function __construct( + private GradingConfigurationRepository $repository, + private GradeExistenceChecker $gradeExistenceChecker, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + private CurrentAcademicYearResolver $academicYearResolver, + private SchoolIdResolver $schoolIdResolver, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): GradingModeResource + { + if (!$this->authorizationChecker->isGranted(GradingModeVoter::VIEW)) { + throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir la configuration de notation.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantIdStr = (string) $this->tenantContext->getCurrentTenantId(); + $tenantId = TenantId::fromString($tenantIdStr); + /** @var string $rawAcademicYearId */ + $rawAcademicYearId = $uriVariables['academicYearId']; + + $resolvedYearId = $this->academicYearResolver->resolve($rawAcademicYearId); + if ($resolvedYearId === null) { + throw new NotFoundHttpException('Année scolaire non trouvée.'); + } + + $academicYearId = AcademicYearId::fromString($resolvedYearId); + $schoolId = SchoolId::fromString($this->schoolIdResolver->resolveForTenant($tenantIdStr)); + + $config = $this->repository->findBySchoolAndYear( + $tenantId, + $schoolId, + $academicYearId, + ); + + if ($config === null) { + $resource = GradingModeResource::defaultForYear($resolvedYearId); + } else { + $resource = GradingModeResource::fromDomain($config); + } + + $resource->hasExistingGrades = $this->gradeExistenceChecker->hasGradesForYear( + $tenantId, + $schoolId, + $academicYearId, + ); + $resource->availableModes = GradingModeResource::allAvailableModes(); + + return $resource; + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/GradingModeResource.php b/backend/src/Administration/Infrastructure/Api/Resource/GradingModeResource.php new file mode 100644 index 0000000..a511f8d --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/GradingModeResource.php @@ -0,0 +1,109 @@ +|null */ + #[ApiProperty(readable: true, writable: false)] + public ?array $availableModes = null; + + public static function fromDomain(SchoolGradingConfiguration $config): self + { + $resource = new self(); + $resource->academicYearId = (string) $config->academicYearId; + $resource->mode = $config->gradingConfiguration->mode->value; + $resource->label = $config->gradingConfiguration->mode->label(); + $resource->scaleMax = $config->gradingConfiguration->scaleMax(); + $resource->isNumeric = $config->gradingConfiguration->estNumerique(); + $resource->calculatesAverage = $config->gradingConfiguration->calculeMoyenne(); + + return $resource; + } + + public static function defaultForYear(string $academicYearId): self + { + $resource = new self(); + $resource->academicYearId = $academicYearId; + $resource->mode = SchoolGradingConfiguration::DEFAULT_MODE->value; + $resource->label = SchoolGradingConfiguration::DEFAULT_MODE->label(); + $resource->scaleMax = SchoolGradingConfiguration::DEFAULT_MODE->scaleMax(); + $resource->isNumeric = SchoolGradingConfiguration::DEFAULT_MODE->estNumerique(); + $resource->calculatesAverage = SchoolGradingConfiguration::DEFAULT_MODE->calculeMoyenne(); + $resource->hasExistingGrades = false; + + return $resource; + } + + /** + * @return array + */ + public static function allAvailableModes(): array + { + return array_map( + static fn (GradingMode $mode) => [ + 'value' => $mode->value, + 'label' => $mode->label(), + ], + GradingMode::cases(), + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php b/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php index 19ecfc4..2e98d57 100644 --- a/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php +++ b/backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php @@ -108,6 +108,11 @@ final class CreateTestActivationTokenCommand extends Command $baseUrlOption = $input->getOption('base-url'); $baseUrl = rtrim($baseUrlOption, '/'); + // In interactive mode, replace localhost with tenant subdomain + if ($input->isInteractive() && $usingDefaults) { + $baseUrl = (string) preg_replace('#//localhost([:/])#', "//{$tenantSubdomain}.classeo.local\$1", $baseUrl); + } + // Convert short role name to full Symfony role format $roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput; diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineGradingConfigurationRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineGradingConfigurationRepository.php new file mode 100644 index 0000000..8f073be --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineGradingConfigurationRepository.php @@ -0,0 +1,121 @@ +connection->executeStatement( + 'INSERT INTO school_grading_configurations (id, tenant_id, school_id, academic_year_id, grading_mode, configured_at, updated_at) + VALUES (:id, :tenant_id, :school_id, :academic_year_id, :grading_mode, :configured_at, :updated_at) + ON CONFLICT (tenant_id, school_id, academic_year_id) DO UPDATE SET + grading_mode = EXCLUDED.grading_mode, + updated_at = EXCLUDED.updated_at', + [ + 'id' => (string) $configuration->id, + 'tenant_id' => (string) $configuration->tenantId, + 'school_id' => (string) $configuration->schoolId, + 'academic_year_id' => (string) $configuration->academicYearId, + 'grading_mode' => $configuration->gradingConfiguration->mode->value, + 'configured_at' => $configuration->configuredAt->format(DateTimeImmutable::ATOM), + 'updated_at' => $configuration->updatedAt->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function findBySchoolAndYear( + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + ): ?SchoolGradingConfiguration { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM school_grading_configurations + WHERE tenant_id = :tenant_id + AND school_id = :school_id + AND academic_year_id = :academic_year_id', + [ + 'tenant_id' => (string) $tenantId, + 'school_id' => (string) $schoolId, + 'academic_year_id' => (string) $academicYearId, + ], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + #[Override] + public function get(SchoolGradingConfigurationId $id, TenantId $tenantId): SchoolGradingConfiguration + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM school_grading_configurations WHERE id = :id AND tenant_id = :tenant_id', + [ + 'id' => (string) $id, + 'tenant_id' => (string) $tenantId, + ], + ); + + if ($row === false) { + throw GradingConfigurationNotFoundException::withId($id); + } + + return $this->hydrate($row); + } + + /** + * @param array $row + */ + private function hydrate(array $row): SchoolGradingConfiguration + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $schoolId */ + $schoolId = $row['school_id']; + /** @var string $academicYearId */ + $academicYearId = $row['academic_year_id']; + /** @var string $gradingMode */ + $gradingMode = $row['grading_mode']; + /** @var string $configuredAt */ + $configuredAt = $row['configured_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + + return SchoolGradingConfiguration::reconstitute( + id: SchoolGradingConfigurationId::fromString($id), + tenantId: TenantId::fromString($tenantId), + schoolId: SchoolId::fromString($schoolId), + academicYearId: AcademicYearId::fromString($academicYearId), + gradingConfiguration: new GradingConfiguration(GradingMode::from($gradingMode)), + configuredAt: new DateTimeImmutable($configuredAt), + updatedAt: new DateTimeImmutable($updatedAt), + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryGradingConfigurationRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryGradingConfigurationRepository.php new file mode 100644 index 0000000..21c0fbb --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryGradingConfigurationRepository.php @@ -0,0 +1,59 @@ + Indexed by ID */ + private array $byId = []; + + /** @var array Indexed by tenant:school:year */ + private array $byTenantSchoolYear = []; + + #[Override] + public function save(SchoolGradingConfiguration $configuration): void + { + $idStr = (string) $configuration->id; + $key = $this->compositeKey($configuration->tenantId, $configuration->schoolId, $configuration->academicYearId); + + $this->byId[$idStr] = $configuration; + $this->byTenantSchoolYear[$key] = $configuration; + } + + #[Override] + public function findBySchoolAndYear( + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + ): ?SchoolGradingConfiguration { + return $this->byTenantSchoolYear[$this->compositeKey($tenantId, $schoolId, $academicYearId)] ?? null; + } + + #[Override] + public function get(SchoolGradingConfigurationId $id, TenantId $tenantId): SchoolGradingConfiguration + { + $config = $this->byId[(string) $id] ?? throw GradingConfigurationNotFoundException::withId($id); + + if (!$config->tenantId->equals($tenantId)) { + throw GradingConfigurationNotFoundException::withId($id); + } + + return $config; + } + + private function compositeKey(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): string + { + return $tenantId . ':' . $schoolId . ':' . $academicYearId; + } +} diff --git a/backend/src/Administration/Infrastructure/Security/GradingModeVoter.php b/backend/src/Administration/Infrastructure/Security/GradingModeVoter.php new file mode 100644 index 0000000..1a781fe --- /dev/null +++ b/backend/src/Administration/Infrastructure/Security/GradingModeVoter.php @@ -0,0 +1,100 @@ + + */ +final class GradingModeVoter extends Voter +{ + public const string VIEW = 'GRADING_MODE_VIEW'; + public const string CONFIGURE = 'GRADING_MODE_CONFIGURE'; + + private const array SUPPORTED_ATTRIBUTES = [ + self::VIEW, + self::CONFIGURE, + ]; + + #[Override] + protected function supports(string $attribute, mixed $subject): bool + { + return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true); + } + + #[Override] + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $user = $token->getUser(); + + if (!$user instanceof UserInterface) { + return false; + } + + $roles = $user->getRoles(); + + return match ($attribute) { + self::VIEW => $this->canView($roles), + self::CONFIGURE => $this->canConfigure($roles), + default => false, + }; + } + + /** + * @param string[] $roles + */ + private function canView(array $roles): bool + { + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + Role::PROF->value, + Role::VIE_SCOLAIRE->value, + Role::SECRETARIAT->value, + ]); + } + + /** + * @param string[] $roles + */ + private function canConfigure(array $roles): bool + { + return $this->hasAnyRole($roles, [ + Role::SUPER_ADMIN->value, + Role::ADMIN->value, + ]); + } + + /** + * @param string[] $userRoles + * @param string[] $allowedRoles + */ + private function hasAnyRole(array $userRoles, array $allowedRoles): bool + { + foreach ($userRoles as $role) { + if (in_array($role, $allowedRoles, true)) { + return true; + } + } + + return false; + } +} diff --git a/backend/src/Administration/Infrastructure/Service/NoOpGradeExistenceChecker.php b/backend/src/Administration/Infrastructure/Service/NoOpGradeExistenceChecker.php index c32563d..997af35 100644 --- a/backend/src/Administration/Infrastructure/Service/NoOpGradeExistenceChecker.php +++ b/backend/src/Administration/Infrastructure/Service/NoOpGradeExistenceChecker.php @@ -6,6 +6,7 @@ namespace App\Administration\Infrastructure\Service; use App\Administration\Application\Port\GradeExistenceChecker; use App\Administration\Domain\Model\SchoolClass\AcademicYearId; +use App\Administration\Domain\Model\SchoolClass\SchoolId; use App\Shared\Domain\Tenant\TenantId; use Override; @@ -24,4 +25,13 @@ final class NoOpGradeExistenceChecker implements GradeExistenceChecker ): bool { return false; } + + #[Override] + public function hasGradesForYear( + TenantId $tenantId, + SchoolId $schoolId, + AcademicYearId $academicYearId, + ): bool { + return false; + } } diff --git a/backend/src/Scolarite/Domain/Model/GradingMode.php b/backend/src/Scolarite/Domain/Model/GradingMode.php new file mode 100644 index 0000000..e8264b0 --- /dev/null +++ b/backend/src/Scolarite/Domain/Model/GradingMode.php @@ -0,0 +1,20 @@ + 100, + 'in_progress' => 50, + 'not_acquired' => 0, + ]; + + private function __construct( + public float $notesWeight, + public float $absencesWeight, + public float $devoirsWeight, + private bool $usesCompetencyMapping, + ) { + } + + public static function forMode(GradingMode $mode): self + { + return match ($mode) { + GradingMode::NO_GRADES => new self( + notesWeight: 0.0, + absencesWeight: 0.5, + devoirsWeight: 0.5, + usesCompetencyMapping: false, + ), + GradingMode::COMPETENCIES => new self( + notesWeight: 0.4, + absencesWeight: 0.3, + devoirsWeight: 0.3, + usesCompetencyMapping: true, + ), + default => new self( + notesWeight: 0.4, + absencesWeight: 0.3, + devoirsWeight: 0.3, + usesCompetencyMapping: false, + ), + }; + } + + /** + * Convertit un niveau de compétence en score numérique (0-100). + * + * @return int|null Score numérique, ou null si le mode n'utilise pas le mapping compétences + */ + public function competencyToScore(string $competencyLevel): ?int + { + if (!$this->usesCompetencyMapping) { + return null; + } + + return self::COMPETENCY_MAPPING[$competencyLevel] + ?? throw new InvalidArgumentException("Niveau de compétence inconnu : '{$competencyLevel}'"); + } +} diff --git a/backend/tests/Functional/Administration/Api/GradingModeEndpointsTest.php b/backend/tests/Functional/Administration/Api/GradingModeEndpointsTest.php new file mode 100644 index 0000000..3101af0 --- /dev/null +++ b/backend/tests/Functional/Administration/Api/GradingModeEndpointsTest.php @@ -0,0 +1,151 @@ +request('GET', '/api/academic-years/current/grading-mode', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function configureGradingModeReturns404WithoutTenant(): void + { + $client = static::createClient(); + + $client->request('PUT', '/api/academic-years/current/grading-mode', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => ['mode' => 'numeric_20'], + ]); + + self::assertResponseStatusCodeSame(404); + } + + // ========================================================================= + // Security - Without authentication (with tenant) + // ========================================================================= + + #[Test] + public function getGradingModeReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/grading-mode', [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function configureGradingModeReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('PUT', 'http://ecole-alpha.classeo.local/api/academic-years/current/grading-mode', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => ['mode' => 'numeric_20'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + // ========================================================================= + // Validation - Invalid mode + // ========================================================================= + + #[Test] + public function configureGradingModeRejectsInvalidModeWithoutTenant(): void + { + $client = static::createClient(); + + $client->request('PUT', '/api/academic-years/current/grading-mode', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => ['mode' => 'invalid_mode'], + ]); + + // Without tenant, returns 404 before validation kicks in + self::assertResponseStatusCodeSame(404); + } + + // ========================================================================= + // Special identifiers - 'current', 'next', 'previous' + // ========================================================================= + + #[Test] + public function getGradingModeAcceptsCurrentIdentifier(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/current/grading-mode', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + // 401 (no auth) not 404 (invalid id) — proves 'current' is accepted + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getGradingModeAcceptsNextIdentifier(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/next/grading-mode', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getGradingModeAcceptsPreviousIdentifier(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/academic-years/previous/grading-mode', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeHandlerTest.php new file mode 100644 index 0000000..4b96659 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ConfigureGradingMode/ConfigureGradingModeHandlerTest.php @@ -0,0 +1,226 @@ +repository = new InMemoryGradingConfigurationRepository(); + $this->clock = new class implements Clock { + #[Override] + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-01 10:00:00'); + } + }; + } + + #[Test] + public function itCreatesNewConfigurationWhenNoneExists(): void + { + $handler = $this->createHandler(hasGrades: false); + + $result = $handler(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'numeric_20', + )); + + self::assertSame(GradingMode::NUMERIC_20, $result->gradingConfiguration->mode); + } + + #[Test] + public function itPersistsConfigurationInRepository(): void + { + $handler = $this->createHandler(hasGrades: false); + + $handler(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'competencies', + )); + + $found = $this->repository->findBySchoolAndYear( + TenantId::fromString(self::TENANT_ID), + SchoolId::fromString(self::SCHOOL_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + ); + + self::assertNotNull($found); + self::assertSame(GradingMode::COMPETENCIES, $found->gradingConfiguration->mode); + } + + #[Test] + public function itChangesExistingModeWhenNoGradesExist(): void + { + $handler = $this->createHandler(hasGrades: false); + + $handler(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'numeric_20', + )); + + $result = $handler(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'letters', + )); + + self::assertSame(GradingMode::LETTERS, $result->gradingConfiguration->mode); + } + + #[Test] + public function itBlocksChangeWhenGradesExist(): void + { + $handlerNoGrades = $this->createHandler(hasGrades: false); + $handlerNoGrades(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'numeric_20', + )); + + $handlerWithGrades = $this->createHandler(hasGrades: true); + + $this->expectException(CannotChangeGradingModeWithExistingGradesException::class); + + $handlerWithGrades(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'competencies', + )); + } + + #[Test] + public function itAllowsSameModeEvenWithGradesExisting(): void + { + $handlerNoGrades = $this->createHandler(hasGrades: false); + $handlerNoGrades(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'numeric_20', + )); + + $handlerWithGrades = $this->createHandler(hasGrades: true); + $result = $handlerWithGrades(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'numeric_20', + )); + + self::assertSame(GradingMode::NUMERIC_20, $result->gradingConfiguration->mode); + } + + #[Test] + public function itBlocksInitialNonDefaultModeWhenGradesExist(): void + { + $handler = $this->createHandler(hasGrades: true); + + $this->expectException(CannotChangeGradingModeWithExistingGradesException::class); + + $handler(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'competencies', + )); + } + + #[Test] + public function itIsolatesConfigurationByTenant(): void + { + $handler = $this->createHandler(hasGrades: false); + + $handler(new ConfigureGradingModeCommand( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'numeric_20', + )); + + $otherTenantId = '550e8400-e29b-41d4-a716-446655440099'; + $handler(new ConfigureGradingModeCommand( + tenantId: $otherTenantId, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + gradingMode: 'competencies', + )); + + $config1 = $this->repository->findBySchoolAndYear( + TenantId::fromString(self::TENANT_ID), + SchoolId::fromString(self::SCHOOL_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + ); + + $config2 = $this->repository->findBySchoolAndYear( + TenantId::fromString($otherTenantId), + SchoolId::fromString(self::SCHOOL_ID), + AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), + ); + + self::assertNotNull($config1); + self::assertNotNull($config2); + self::assertSame(GradingMode::NUMERIC_20, $config1->gradingConfiguration->mode); + self::assertSame(GradingMode::COMPETENCIES, $config2->gradingConfiguration->mode); + } + + private function createHandler(bool $hasGrades): ConfigureGradingModeHandler + { + $gradeChecker = new class($hasGrades) implements GradeExistenceChecker { + public function __construct(private bool $hasGrades) + { + } + + #[Override] + public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool + { + return $this->hasGrades; + } + + #[Override] + public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool + { + return $this->hasGrades; + } + }; + + return new ConfigureGradingModeHandler( + $this->repository, + $gradeChecker, + $this->clock, + ); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php index 02c9532..a66a17d 100644 --- a/backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php +++ b/backend/tests/Unit/Administration/Application/Command/UpdatePeriod/UpdatePeriodHandlerTest.php @@ -14,6 +14,7 @@ use App\Administration\Domain\Exception\PeriodsOverlapException; use App\Administration\Domain\Model\AcademicYear\DefaultPeriods; use App\Administration\Domain\Model\AcademicYear\PeriodType; use App\Administration\Domain\Model\SchoolClass\AcademicYearId; +use App\Administration\Domain\Model\SchoolClass\SchoolId; use App\Administration\Infrastructure\Persistence\InMemory\InMemoryPeriodConfigurationRepository; use App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker; use App\Shared\Domain\Clock; @@ -54,7 +55,7 @@ final class UpdatePeriodHandlerTest extends TestCase } #[Test] - public function itUpdatesPeriodDates(): void + public function itRejectsOverlappingPeriodDates(): void { $this->seedTrimesterConfig(); @@ -124,13 +125,7 @@ final class UpdatePeriodHandlerTest extends TestCase { $this->seedTrimesterConfig(); - $gradeChecker = new class implements GradeExistenceChecker { - #[Override] - public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool - { - return true; - } - }; + $gradeChecker = $this->createGradeCheckerWithGrades(); $handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus); @@ -150,13 +145,7 @@ final class UpdatePeriodHandlerTest extends TestCase { $this->seedTrimesterConfig(); - $gradeChecker = new class implements GradeExistenceChecker { - #[Override] - public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool - { - return true; - } - }; + $gradeChecker = $this->createGradeCheckerWithGrades(); $handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus); @@ -172,6 +161,23 @@ final class UpdatePeriodHandlerTest extends TestCase self::assertSame('2025-09-02', $config->periods[0]->startDate->format('Y-m-d')); } + private function createGradeCheckerWithGrades(): GradeExistenceChecker + { + return new class implements GradeExistenceChecker { + #[Override] + public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool + { + return true; + } + + #[Override] + public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool + { + return true; + } + }; + } + private function seedTrimesterConfig(): void { $config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025); diff --git a/backend/tests/Unit/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandlerTest.php b/backend/tests/Unit/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandlerTest.php new file mode 100644 index 0000000..614583c --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Query/HasGradesForYear/HasGradesForYearHandlerTest.php @@ -0,0 +1,71 @@ +createChecker(hasGrades: false)); + + $result = $handler(new HasGradesForYearQuery( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + self::assertFalse($result); + } + + #[Test] + public function itReturnsTrueWhenGradesExist(): void + { + $handler = new HasGradesForYearHandler($this->createChecker(hasGrades: true)); + + $result = $handler(new HasGradesForYearQuery( + tenantId: self::TENANT_ID, + schoolId: self::SCHOOL_ID, + academicYearId: self::ACADEMIC_YEAR_ID, + )); + + self::assertTrue($result); + } + + private function createChecker(bool $hasGrades): GradeExistenceChecker + { + return new class($hasGrades) implements GradeExistenceChecker { + public function __construct(private bool $hasGrades) + { + } + + #[Override] + public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool + { + return $this->hasGrades; + } + + #[Override] + public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool + { + return $this->hasGrades; + } + }; + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingConfigurationTest.php b/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingConfigurationTest.php new file mode 100644 index 0000000..f178024 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingConfigurationTest.php @@ -0,0 +1,79 @@ +mode); + } + + #[Test] + public function itCreatesCompetenciesConfiguration(): void + { + $config = new GradingConfiguration(GradingMode::COMPETENCIES); + + self::assertSame(GradingMode::COMPETENCIES, $config->mode); + } + + #[Test] + public function itCreatesNoGradesConfiguration(): void + { + $config = new GradingConfiguration(GradingMode::NO_GRADES); + + self::assertSame(GradingMode::NO_GRADES, $config->mode); + } + + #[Test] + public function equalConfigurationsAreEqual(): void + { + $config1 = new GradingConfiguration(GradingMode::NUMERIC_20); + $config2 = new GradingConfiguration(GradingMode::NUMERIC_20); + + self::assertTrue($config1->equals($config2)); + } + + #[Test] + public function differentConfigurationsAreNotEqual(): void + { + $config1 = new GradingConfiguration(GradingMode::NUMERIC_20); + $config2 = new GradingConfiguration(GradingMode::COMPETENCIES); + + self::assertFalse($config1->equals($config2)); + } + + #[Test] + public function itDelegatesToModeForScaleMax(): void + { + $config = new GradingConfiguration(GradingMode::NUMERIC_20); + self::assertSame(20, $config->scaleMax()); + + $config = new GradingConfiguration(GradingMode::COMPETENCIES); + self::assertNull($config->scaleMax()); + } + + #[Test] + public function itDelegatesToModeForEstNumerique(): void + { + self::assertTrue((new GradingConfiguration(GradingMode::NUMERIC_20))->estNumerique()); + self::assertFalse((new GradingConfiguration(GradingMode::NO_GRADES))->estNumerique()); + } + + #[Test] + public function itDelegatesToModeForCalculeMoyenne(): void + { + self::assertTrue((new GradingConfiguration(GradingMode::NUMERIC_20))->calculeMoyenne()); + self::assertFalse((new GradingConfiguration(GradingMode::COMPETENCIES))->calculeMoyenne()); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingModeTest.php b/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingModeTest.php new file mode 100644 index 0000000..d1a4cd4 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/GradingModeTest.php @@ -0,0 +1,97 @@ +value); + self::assertSame('numeric_10', GradingMode::NUMERIC_10->value); + self::assertSame('letters', GradingMode::LETTERS->value); + self::assertSame('competencies', GradingMode::COMPETENCIES->value); + self::assertSame('no_grades', GradingMode::NO_GRADES->value); + } + + #[Test] + public function numeric20HasCorrectScale(): void + { + self::assertSame(20, GradingMode::NUMERIC_20->scaleMax()); + } + + #[Test] + public function numeric10HasCorrectScale(): void + { + self::assertSame(10, GradingMode::NUMERIC_10->scaleMax()); + } + + #[Test] + public function nonNumericModesHaveNullScale(): void + { + self::assertNull(GradingMode::LETTERS->scaleMax()); + self::assertNull(GradingMode::COMPETENCIES->scaleMax()); + self::assertNull(GradingMode::NO_GRADES->scaleMax()); + } + + #[Test] + public function numericModesUseNumericGrading(): void + { + self::assertTrue(GradingMode::NUMERIC_20->estNumerique()); + self::assertTrue(GradingMode::NUMERIC_10->estNumerique()); + } + + #[Test] + public function nonNumericModesDoNotUseNumericGrading(): void + { + self::assertFalse(GradingMode::LETTERS->estNumerique()); + self::assertFalse(GradingMode::COMPETENCIES->estNumerique()); + self::assertFalse(GradingMode::NO_GRADES->estNumerique()); + } + + #[Test] + public function modesRequiringAverageCalculation(): void + { + self::assertTrue(GradingMode::NUMERIC_20->calculeMoyenne()); + self::assertTrue(GradingMode::NUMERIC_10->calculeMoyenne()); + self::assertTrue(GradingMode::LETTERS->calculeMoyenne()); + self::assertFalse(GradingMode::COMPETENCIES->calculeMoyenne()); + self::assertFalse(GradingMode::NO_GRADES->calculeMoyenne()); + } + + #[Test] + public function labelsAreInFrench(): void + { + self::assertSame('Notes /20', GradingMode::NUMERIC_20->label()); + self::assertSame('Notes /10', GradingMode::NUMERIC_10->label()); + self::assertSame('Lettres (A-E)', GradingMode::LETTERS->label()); + self::assertSame('Compétences', GradingMode::COMPETENCIES->label()); + self::assertSame('Sans notes', GradingMode::NO_GRADES->label()); + } + + #[Test] + public function administrationAndScolariteEnumsAreInSync(): void + { + $adminValues = array_map( + static fn (GradingMode $m) => $m->value, + GradingMode::cases(), + ); + $scolariteValues = array_map( + static fn (ScolariteGradingMode $m) => $m->value, + ScolariteGradingMode::cases(), + ); + + self::assertSame( + $adminValues, + $scolariteValues, + 'Les enums GradingMode des contextes Administration et Scolarité doivent rester synchronisées.', + ); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationTest.php b/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationTest.php new file mode 100644 index 0000000..3ee1c25 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/GradingConfiguration/SchoolGradingConfigurationTest.php @@ -0,0 +1,186 @@ +tenantId = TenantId::generate(); + $this->schoolId = SchoolId::generate(); + $this->academicYearId = AcademicYearId::generate(); + } + + #[Test] + public function itConfiguresGradingMode(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::NUMERIC_20, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + + self::assertSame(GradingMode::NUMERIC_20, $config->gradingConfiguration->mode); + self::assertSame($this->tenantId, $config->tenantId); + self::assertSame($this->schoolId, $config->schoolId); + self::assertSame($this->academicYearId, $config->academicYearId); + } + + #[Test] + public function itEmitsEventOnCreation(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::COMPETENCIES, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + + $events = $config->pullDomainEvents(); + self::assertCount(1, $events); + } + + #[Test] + public function itChangesGradingModeWhenNoGradesExist(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::NUMERIC_20, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + $config->pullDomainEvents(); + + $config->changerMode( + nouveauMode: GradingMode::COMPETENCIES, + hasExistingGrades: false, + at: new DateTimeImmutable('2026-02-02'), + ); + + self::assertSame(GradingMode::COMPETENCIES, $config->gradingConfiguration->mode); + $events = $config->pullDomainEvents(); + self::assertCount(1, $events); + } + + #[Test] + public function itBlocksChangeWhenGradesExist(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::NUMERIC_20, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + + $this->expectException(CannotChangeGradingModeWithExistingGradesException::class); + + $config->changerMode( + nouveauMode: GradingMode::COMPETENCIES, + hasExistingGrades: true, + at: new DateTimeImmutable('2026-02-02'), + ); + } + + #[Test] + public function itDoesNotEmitEventWhenModeIsUnchanged(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::NUMERIC_20, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + $config->pullDomainEvents(); + + $config->changerMode( + nouveauMode: GradingMode::NUMERIC_20, + hasExistingGrades: false, + at: new DateTimeImmutable('2026-02-02'), + ); + + self::assertEmpty($config->pullDomainEvents()); + } + + #[Test] + public function itReconstitutesFromStorage(): void + { + $id = SchoolGradingConfiguration::generateId(); + + $config = SchoolGradingConfiguration::reconstitute( + id: $id, + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + gradingConfiguration: new GradingConfiguration(GradingMode::LETTERS), + configuredAt: new DateTimeImmutable('2026-02-01'), + updatedAt: new DateTimeImmutable('2026-02-02'), + ); + + self::assertSame(GradingMode::LETTERS, $config->gradingConfiguration->mode); + self::assertEmpty($config->pullDomainEvents()); + } + + #[Test] + public function itBlocksInitialNonDefaultModeWhenGradesExist(): void + { + $this->expectException(CannotChangeGradingModeWithExistingGradesException::class); + + SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::COMPETENCIES, + hasExistingGrades: true, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + } + + #[Test] + public function itAllowsInitialDefaultModeWhenGradesExist(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: $this->tenantId, + schoolId: $this->schoolId, + academicYearId: $this->academicYearId, + mode: GradingMode::NUMERIC_20, + hasExistingGrades: true, + configuredAt: new DateTimeImmutable('2026-02-01'), + ); + + self::assertSame(GradingMode::NUMERIC_20, $config->gradingConfiguration->mode); + } + + #[Test] + public function defaultModeIsNumeric20(): void + { + self::assertSame(GradingMode::NUMERIC_20, SchoolGradingConfiguration::DEFAULT_MODE); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ConfigureGradingModeProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ConfigureGradingModeProcessorTest.php new file mode 100644 index 0000000..64c108f --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ConfigureGradingModeProcessorTest.php @@ -0,0 +1,254 @@ +repository = new InMemoryGradingConfigurationRepository(); + $this->tenantContext = new TenantContext(); + $this->clock = new class implements Clock { + #[Override] + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2025-10-15 10:00:00'); + } + }; + } + + #[Test] + public function itRejectsUnauthorizedAccess(): void + { + $processor = $this->createProcessor(granted: false); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'numeric_20'; + + $this->expectException(AccessDeniedHttpException::class); + $processor->process($data, new Put(), ['academicYearId' => 'current']); + } + + #[Test] + public function itRejectsRequestWithoutTenant(): void + { + $processor = $this->createProcessor(granted: true); + + $data = new GradingModeResource(); + $data->mode = 'numeric_20'; + + $this->expectException(UnauthorizedHttpException::class); + $processor->process($data, new Put(), ['academicYearId' => 'current']); + } + + #[Test] + public function itRejectsInvalidAcademicYearId(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'numeric_20'; + + $this->expectException(NotFoundHttpException::class); + $processor->process($data, new Put(), ['academicYearId' => 'invalid']); + } + + #[Test] + public function itConfiguresGradingMode(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'competencies'; + + $result = $processor->process($data, new Put(), ['academicYearId' => 'current']); + + self::assertInstanceOf(GradingModeResource::class, $result); + self::assertSame('competencies', $result->mode); + self::assertSame('Compétences', $result->label); + self::assertFalse($result->hasExistingGrades); + } + + #[Test] + public function itSetsAvailableModesOnResult(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'numeric_20'; + + $result = $processor->process($data, new Put(), ['academicYearId' => 'current']); + + self::assertNotNull($result->availableModes); + self::assertCount(5, $result->availableModes); + } + + #[Test] + public function itThrowsConflictWhenGradesExistAndModeChanges(): void + { + // First configure with numeric_20 (no grades) + $processor = $this->createProcessor(granted: true, hasGrades: false); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'numeric_20'; + $processor->process($data, new Put(), ['academicYearId' => 'current']); + + // Now try to change mode with grades existing + $processorWithGrades = $this->createProcessor(granted: true, hasGrades: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'competencies'; + + $this->expectException(ConflictHttpException::class); + $processorWithGrades->process($data, new Put(), ['academicYearId' => 'current']); + } + + #[Test] + public function itChangesExistingModeWhenNoGradesExist(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'numeric_20'; + $processor->process($data, new Put(), ['academicYearId' => 'current']); + + $data = new GradingModeResource(); + $data->mode = 'letters'; + $result = $processor->process($data, new Put(), ['academicYearId' => 'current']); + + self::assertSame('letters', $result->mode); + } + + #[Test] + public function itResolvesNextAcademicYear(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'no_grades'; + + $result = $processor->process($data, new Put(), ['academicYearId' => 'next']); + + self::assertSame('no_grades', $result->mode); + } + + #[Test] + public function itRejectsInvalidGradingModeWithBadRequest(): void + { + $processor = $this->createProcessor(granted: true); + $this->setTenant(); + + $data = new GradingModeResource(); + $data->mode = 'invalid_mode'; + + $this->expectException(BadRequestHttpException::class); + $processor->process($data, new Put(), ['academicYearId' => 'current']); + } + + private function setTenant(): void + { + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_UUID), + subdomain: 'test', + databaseUrl: 'sqlite:///:memory:', + )); + } + + private function createProcessor(bool $granted, bool $hasGrades = false): ConfigureGradingModeProcessor + { + $authChecker = new class($granted) implements AuthorizationCheckerInterface { + public function __construct(private readonly bool $granted) + { + } + + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + return $this->granted; + } + }; + + $eventBus = new class implements MessageBusInterface { + public function dispatch(object $message, array $stamps = []): Envelope + { + return new Envelope($message); + } + }; + + $gradeChecker = new class($hasGrades) implements GradeExistenceChecker { + public function __construct(private readonly bool $hasGrades) + { + } + + #[Override] + public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool + { + return $this->hasGrades; + } + + #[Override] + public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool + { + return $this->hasGrades; + } + }; + + $handler = new ConfigureGradingModeHandler($this->repository, $gradeChecker, $this->clock); + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + $schoolIdResolver = new SchoolIdResolver(); + + return new ConfigureGradingModeProcessor( + $handler, + $gradeChecker, + $this->tenantContext, + $eventBus, + $authChecker, + $resolver, + $schoolIdResolver, + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Provider/GradingModeProviderTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/GradingModeProviderTest.php new file mode 100644 index 0000000..0be22ef --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/GradingModeProviderTest.php @@ -0,0 +1,226 @@ +repository = new InMemoryGradingConfigurationRepository(); + $this->tenantContext = new TenantContext(); + $this->clock = new class implements Clock { + #[Override] + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2025-10-15 10:00:00'); + } + }; + } + + #[Test] + public function itRejectsUnauthorizedAccess(): void + { + $provider = $this->createProvider(granted: false); + $this->setTenant(); + + $this->expectException(AccessDeniedHttpException::class); + $provider->provide(new Get(), ['academicYearId' => 'current']); + } + + #[Test] + public function itRejectsRequestWithoutTenant(): void + { + $provider = $this->createProvider(granted: true); + + $this->expectException(UnauthorizedHttpException::class); + $provider->provide(new Get(), ['academicYearId' => 'current']); + } + + #[Test] + public function itRejectsInvalidAcademicYearId(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $this->expectException(NotFoundHttpException::class); + $provider->provide(new Get(), ['academicYearId' => 'invalid']); + } + + #[Test] + public function itReturnsDefaultModeWhenNoConfigurationExists(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'current']); + + self::assertInstanceOf(GradingModeResource::class, $result); + self::assertSame('numeric_20', $result->mode); + self::assertSame('Notes /20', $result->label); + self::assertFalse($result->hasExistingGrades); + } + + #[Test] + public function itReturnsExistingConfigurationWhenPresent(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + $academicYearId = $resolver->resolve('current'); + self::assertNotNull($academicYearId); + + $schoolIdResolver = new SchoolIdResolver(); + $schoolId = $schoolIdResolver->resolveForTenant(self::TENANT_UUID); + + $config = SchoolGradingConfiguration::configurer( + tenantId: TenantId::fromString(self::TENANT_UUID), + schoolId: SchoolId::fromString($schoolId), + academicYearId: AcademicYearId::fromString($academicYearId), + mode: GradingMode::COMPETENCIES, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable(), + ); + $this->repository->save($config); + + $result = $provider->provide(new Get(), ['academicYearId' => 'current']); + + self::assertInstanceOf(GradingModeResource::class, $result); + self::assertSame('competencies', $result->mode); + self::assertSame('Compétences', $result->label); + } + + #[Test] + public function itSetsAvailableModesOnResult(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'current']); + + self::assertNotNull($result->availableModes); + self::assertCount(5, $result->availableModes); + } + + #[Test] + public function itSetsHasExistingGradesFlag(): void + { + $provider = $this->createProvider(granted: true, hasGrades: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'current']); + + self::assertTrue($result->hasExistingGrades); + } + + #[Test] + public function itResolvesNextAcademicYear(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'next']); + + self::assertInstanceOf(GradingModeResource::class, $result); + self::assertSame('numeric_20', $result->mode); + } + + #[Test] + public function itResolvesPreviousAcademicYear(): void + { + $provider = $this->createProvider(granted: true); + $this->setTenant(); + + $result = $provider->provide(new Get(), ['academicYearId' => 'previous']); + + self::assertInstanceOf(GradingModeResource::class, $result); + self::assertSame('numeric_20', $result->mode); + } + + private function setTenant(): void + { + $this->tenantContext->setCurrentTenant(new TenantConfig( + tenantId: InfraTenantId::fromString(self::TENANT_UUID), + subdomain: 'test', + databaseUrl: 'sqlite:///:memory:', + )); + } + + private function createProvider(bool $granted, bool $hasGrades = false): GradingModeProvider + { + $authChecker = new class($granted) implements AuthorizationCheckerInterface { + public function __construct(private readonly bool $granted) + { + } + + public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool + { + return $this->granted; + } + }; + + $gradeChecker = new class($hasGrades) implements GradeExistenceChecker { + public function __construct(private readonly bool $hasGrades) + { + } + + #[Override] + public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool + { + return $this->hasGrades; + } + + #[Override] + public function hasGradesForYear(TenantId $tenantId, SchoolId $schoolId, AcademicYearId $academicYearId): bool + { + return $this->hasGrades; + } + }; + + $resolver = new CurrentAcademicYearResolver($this->tenantContext, $this->clock); + $schoolIdResolver = new SchoolIdResolver(); + + return new GradingModeProvider( + $this->repository, + $gradeChecker, + $this->tenantContext, + $authChecker, + $resolver, + $schoolIdResolver, + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Resource/GradingModeResourceTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Resource/GradingModeResourceTest.php new file mode 100644 index 0000000..aedd994 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Resource/GradingModeResourceTest.php @@ -0,0 +1,124 @@ +mode); + self::assertSame('Notes /20', $resource->label); + self::assertSame(20, $resource->scaleMax); + self::assertTrue($resource->isNumeric); + self::assertTrue($resource->calculatesAverage); + self::assertSame((string) $config->academicYearId, $resource->academicYearId); + } + + #[Test] + public function fromDomainMapsCompetenciesMode(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: TenantId::generate(), + schoolId: SchoolId::generate(), + academicYearId: AcademicYearId::generate(), + mode: GradingMode::COMPETENCIES, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable(), + ); + + $resource = GradingModeResource::fromDomain($config); + + self::assertSame('competencies', $resource->mode); + self::assertSame('Compétences', $resource->label); + self::assertNull($resource->scaleMax); + self::assertFalse($resource->isNumeric); + self::assertFalse($resource->calculatesAverage); + } + + #[Test] + public function fromDomainMapsNoGradesMode(): void + { + $config = SchoolGradingConfiguration::configurer( + tenantId: TenantId::generate(), + schoolId: SchoolId::generate(), + academicYearId: AcademicYearId::generate(), + mode: GradingMode::NO_GRADES, + hasExistingGrades: false, + configuredAt: new DateTimeImmutable(), + ); + + $resource = GradingModeResource::fromDomain($config); + + self::assertSame('no_grades', $resource->mode); + self::assertSame('Sans notes', $resource->label); + self::assertNull($resource->scaleMax); + self::assertFalse($resource->isNumeric); + self::assertFalse($resource->calculatesAverage); + } + + #[Test] + public function defaultForYearUsesNumeric20(): void + { + $yearId = 'some-year-id'; + $resource = GradingModeResource::defaultForYear($yearId); + + self::assertSame($yearId, $resource->academicYearId); + self::assertSame('numeric_20', $resource->mode); + self::assertSame('Notes /20', $resource->label); + self::assertSame(20, $resource->scaleMax); + self::assertTrue($resource->isNumeric); + self::assertTrue($resource->calculatesAverage); + self::assertFalse($resource->hasExistingGrades); + } + + #[Test] + public function allAvailableModesReturnsAllFiveModes(): void + { + $modes = GradingModeResource::allAvailableModes(); + + self::assertCount(5, $modes); + + $values = array_column($modes, 'value'); + self::assertContains('numeric_20', $values); + self::assertContains('numeric_10', $values); + self::assertContains('letters', $values); + self::assertContains('competencies', $values); + self::assertContains('no_grades', $values); + } + + #[Test] + public function allAvailableModesContainsLabels(): void + { + $modes = GradingModeResource::allAvailableModes(); + + foreach ($modes as $mode) { + self::assertArrayHasKey('value', $mode); + self::assertArrayHasKey('label', $mode); + self::assertNotEmpty($mode['label']); + } + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/GradingModeVoterTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/GradingModeVoterTest.php new file mode 100644 index 0000000..cb49543 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Security/GradingModeVoterTest.php @@ -0,0 +1,141 @@ +voter = new GradingModeVoter(); + } + + #[Test] + public function itAbstainsForUnrelatedAttributes(): void + { + $user = $this->createMock(UserInterface::class); + $user->method('getRoles')->willReturn(['ROLE_ADMIN']); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + $result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']); + + self::assertSame(GradingModeVoter::ACCESS_ABSTAIN, $result); + } + + #[Test] + public function itDeniesAccessToUnauthenticatedUsers(): void + { + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn(null); + + $result = $this->voter->vote($token, null, [GradingModeVoter::VIEW]); + + self::assertSame(GradingModeVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itGrantsViewToSuperAdmin(): void + { + $result = $this->voteWithRole('ROLE_SUPER_ADMIN', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsViewToAdmin(): void + { + $result = $this->voteWithRole('ROLE_ADMIN', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsViewToProf(): void + { + $result = $this->voteWithRole('ROLE_PROF', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsViewToVieScolaire(): void + { + $result = $this->voteWithRole('ROLE_VIE_SCOLAIRE', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsViewToSecretariat(): void + { + $result = $this->voteWithRole('ROLE_SECRETARIAT', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itDeniesViewToParent(): void + { + $result = $this->voteWithRole('ROLE_PARENT', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itDeniesViewToEleve(): void + { + $result = $this->voteWithRole('ROLE_ELEVE', GradingModeVoter::VIEW); + self::assertSame(GradingModeVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itGrantsConfigureToSuperAdmin(): void + { + $result = $this->voteWithRole('ROLE_SUPER_ADMIN', GradingModeVoter::CONFIGURE); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itGrantsConfigureToAdmin(): void + { + $result = $this->voteWithRole('ROLE_ADMIN', GradingModeVoter::CONFIGURE); + self::assertSame(GradingModeVoter::ACCESS_GRANTED, $result); + } + + #[Test] + public function itDeniesConfigureToProf(): void + { + $result = $this->voteWithRole('ROLE_PROF', GradingModeVoter::CONFIGURE); + self::assertSame(GradingModeVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itDeniesConfigureToVieScolaire(): void + { + $result = $this->voteWithRole('ROLE_VIE_SCOLAIRE', GradingModeVoter::CONFIGURE); + self::assertSame(GradingModeVoter::ACCESS_DENIED, $result); + } + + #[Test] + public function itDeniesConfigureToSecretariat(): void + { + $result = $this->voteWithRole('ROLE_SECRETARIAT', GradingModeVoter::CONFIGURE); + self::assertSame(GradingModeVoter::ACCESS_DENIED, $result); + } + + private function voteWithRole(string $role, string $attribute): int + { + $user = $this->createMock(UserInterface::class); + $user->method('getRoles')->willReturn([$role]); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + return $this->voter->vote($token, null, [$attribute]); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Service/SerenityScoreWeightsTest.php b/backend/tests/Unit/Scolarite/Domain/Service/SerenityScoreWeightsTest.php new file mode 100644 index 0000000..544b579 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Service/SerenityScoreWeightsTest.php @@ -0,0 +1,93 @@ +notesWeight); + self::assertSame(0.3, $weights->absencesWeight); + self::assertSame(0.3, $weights->devoirsWeight); + } + + #[Test] + public function competencyModeUsesAdaptedWeights(): void + { + $weights = SerenityScoreWeights::forMode(GradingMode::COMPETENCIES); + + self::assertSame(0.4, $weights->notesWeight); + self::assertSame(0.3, $weights->absencesWeight); + self::assertSame(0.3, $weights->devoirsWeight); + } + + #[Test] + public function noGradesModeExcludesNotesComponent(): void + { + $weights = SerenityScoreWeights::forMode(GradingMode::NO_GRADES); + + self::assertSame(0.0, $weights->notesWeight); + self::assertSame(0.5, $weights->absencesWeight); + self::assertSame(0.5, $weights->devoirsWeight); + } + + #[Test] + public function lettersModeUsesStandardWeights(): void + { + $weights = SerenityScoreWeights::forMode(GradingMode::LETTERS); + + self::assertSame(0.4, $weights->notesWeight); + self::assertSame(0.3, $weights->absencesWeight); + self::assertSame(0.3, $weights->devoirsWeight); + } + + #[Test] + public function weightsAlwaysSumToOne(): void + { + foreach (GradingMode::cases() as $mode) { + $weights = SerenityScoreWeights::forMode($mode); + $sum = $weights->notesWeight + $weights->absencesWeight + $weights->devoirsWeight; + self::assertEqualsWithDelta(1.0, $sum, 0.001, "Weights for {$mode->value} must sum to 1.0"); + } + } + + #[Test] + public function competencyMappingConvertsToPercentage(): void + { + $weights = SerenityScoreWeights::forMode(GradingMode::COMPETENCIES); + + self::assertSame(100, $weights->competencyToScore('acquired')); + self::assertSame(50, $weights->competencyToScore('in_progress')); + self::assertSame(0, $weights->competencyToScore('not_acquired')); + } + + #[Test] + public function nonCompetencyModesReturnNullForMapping(): void + { + $weights = SerenityScoreWeights::forMode(GradingMode::NUMERIC_20); + + self::assertNull($weights->competencyToScore('acquired')); + } + + #[Test] + public function competencyMappingThrowsOnUnknownLevel(): void + { + $weights = SerenityScoreWeights::forMode(GradingMode::COMPETENCIES); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Niveau de compétence inconnu : 'unknown_level'"); + + $weights->competencyToScore('unknown_level'); + } +} diff --git a/frontend/e2e/classes.spec.ts b/frontend/e2e/classes.spec.ts index b61b5e0..895e272 100644 --- a/frontend/e2e/classes.spec.ts +++ b/frontend/e2e/classes.spec.ts @@ -25,23 +25,15 @@ test.describe('Classes Management (Story 2.1)', () => { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); - try { - // Create admin user - execSync( - `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, - { encoding: 'utf-8' } - ); - console.log('Classes E2E test admin user created'); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); - // Clean up all classes for this tenant to ensure Empty State test works - execSync( - `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, - { encoding: 'utf-8' } - ); - console.log('Classes cleaned up for E2E tests'); - } catch (error) { - console.error('Setup error:', error); - } + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, + { encoding: 'utf-8' } + ); }); // Helper to login as admin diff --git a/frontend/e2e/pedagogy.spec.ts b/frontend/e2e/pedagogy.spec.ts new file mode 100644 index 0000000..3e8105f --- /dev/null +++ b/frontend/e2e/pedagogy.spec.ts @@ -0,0 +1,284 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const ADMIN_EMAIL = 'e2e-pedagogy-admin@example.com'; +const ADMIN_PASSWORD = 'PedagogyTest123'; + +test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => { + test.beforeAll(async () => { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); + + // Reset grading mode to default (numeric_20) to ensure clean state + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php -r " + require 'vendor/autoload.php'; + \\$kernel = new App\\Kernel('dev', true); + \\$kernel->boot(); + \\$conn = \\$kernel->getContainer()->get('doctrine')->getConnection(); + \\$conn->executeStatement('DELETE FROM school_grading_configurations'); + " 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Table might not exist yet, that's OK — default mode applies + } + }); + + async function loginAsAdmin(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(ADMIN_EMAIL); + await page.locator('#password').fill(ADMIN_PASSWORD); + await page.getByRole('button', { name: /se connecter/i }).click(); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); + } + + // ========================================================================= + // Navigation + // ========================================================================= + + test('pedagogy link is visible in admin navigation', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + const pedagogyLink = page.getByRole('link', { name: /pédagogie/i }); + await expect(pedagogyLink).toBeVisible(); + }); + + test('pedagogy link navigates to pedagogy page', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/classes`); + + await page.getByRole('link', { name: /pédagogie/i }).click(); + await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 }); + }); + + test('pedagogy card is visible on admin dashboard', async ({ page, browserName }) => { + // Svelte 5 delegated onclick is not triggered by Playwright click on webkit + test.skip(browserName === 'webkit', 'Demo role switcher click not supported on webkit'); + + await loginAsAdmin(page); + + // Switch to admin view in demo dashboard + await page.goto(`${ALPHA_URL}/dashboard`); + const adminButton = page.getByRole('button', { name: /admin/i }); + await adminButton.click(); + + await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ + timeout: 10000 + }); + + const pedagogyCard = page.getByRole('link', { name: /pédagogie/i }); + await expect(pedagogyCard).toBeVisible(); + }); + + // ========================================================================= + // AC1: Display grading mode options + // ========================================================================= + + test('shows page title and subtitle', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await expect(page.getByRole('heading', { name: /mode de notation/i })).toBeVisible(); + await expect(page.getByText(/système d'évaluation/i)).toBeVisible(); + }); + + test('shows current mode banner', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + // Wait for loading to finish + await page.waitForLoadState('networkidle'); + + // Should show "Mode actuel" banner + await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 }); + }); + + test('displays all five grading modes', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + + // All 5 mode cards should be visible (scope to .mode-card to avoid ambiguous matches) + const modeCards = page.locator('.mode-card'); + await expect(modeCards).toHaveCount(5, { timeout: 10000 }); + await expect(modeCards.filter({ hasText: /notes \/20/i })).toBeVisible(); + await expect(modeCards.filter({ hasText: /notes \/10/i })).toBeVisible(); + await expect(modeCards.filter({ hasText: /lettres/i })).toBeVisible(); + await expect(modeCards.filter({ hasText: /compétences/i })).toBeVisible(); + await expect(modeCards.filter({ hasText: /sans notes/i })).toBeVisible(); + }); + + test('default mode is Notes sur 20', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + + // The "Notes /20" mode card should be selected (has mode-selected class) + const selectedCard = page.locator('.mode-card.mode-selected'); + await expect(selectedCard).toBeVisible({ timeout: 10000 }); + await expect(selectedCard).toContainText(/notes \/20/i); + }); + + // ========================================================================= + // AC1: Year selector + // ========================================================================= + + test('can switch between year tabs', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + const tabs = page.getByRole('tab'); + + // Wait for page to load + await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 }); + await page.waitForLoadState('networkidle'); + + // Click next year tab + await tabs.nth(2).click(); + await expect(tabs.nth(2)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 }); + + // Click previous year tab + await tabs.nth(0).click(); + await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 }); + }); + + // ========================================================================= + // AC2-4: Mode selection and preview + // ========================================================================= + + test('selecting a different mode shows save button', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + + // Save button should not be visible initially + await expect(page.getByRole('button', { name: /enregistrer/i })).not.toBeVisible(); + + // Click a different mode + const competenciesCard = page.locator('.mode-card').filter({ hasText: /compétences/i }); + await competenciesCard.click(); + + // Save and cancel buttons should appear + await expect(page.getByRole('button', { name: /enregistrer/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /annuler/i })).toBeVisible(); + }); + + test('cancel button reverts mode selection', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + + // Select a different mode + const lettersCard = page.locator('.mode-card').filter({ hasText: /lettres/i }); + await lettersCard.click(); + + // Click cancel + await page.getByRole('button', { name: /annuler/i }).click(); + + // Save button should disappear + await expect(page.getByRole('button', { name: /enregistrer/i })).not.toBeVisible(); + }); + + test('selecting competencies mode shows competency-specific description', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 }); + + const competenciesCard = page.locator('.mode-card').filter({ hasText: /compétences/i }); + await competenciesCard.click(); + + // Check description inside the card (scoped to avoid matching bulletin impact text) + await expect(competenciesCard.locator('.mode-description')).toContainText( + /acquis.*en cours.*non acquis/i + ); + }); + + test('selecting sans notes mode shows no-grades description', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 }); + + const noGradesCard = page.locator('.mode-card').filter({ hasText: /sans notes/i }); + await noGradesCard.click(); + + // Check description inside the card (scoped to avoid matching bulletin impact text) + await expect(noGradesCard.locator('.mode-description')).toContainText( + /appréciations textuelles/i + ); + }); + + test('shows bulletin impact preview when mode is selected', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + + // Impact section should be visible + await expect(page.getByText(/impact sur les bulletins/i)).toBeVisible({ timeout: 10000 }); + }); + + // ========================================================================= + // AC2: Can change to a different mode + // ========================================================================= + + test('can save a new grading mode', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/pedagogy`); + + await page.waitForLoadState('networkidle'); + await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 }); + + // Select "Notes sur 10" + const numeric10Card = page.locator('.mode-card').filter({ hasText: /notes \/10/i }); + await numeric10Card.click(); + + // Save + await page.getByRole('button', { name: /enregistrer/i }).click(); + + // Success message should appear + await expect(page.getByText(/mis à jour avec succès/i)).toBeVisible({ timeout: 10000 }); + + // The selected mode should now be "Notes /10" + const selectedCard = page.locator('.mode-card.mode-selected'); + await expect(selectedCard).toContainText(/notes \/10/i); + + // Reload the page to verify server-side persistence + await page.reload(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.mode-card.mode-selected')).toContainText(/notes \/10/i, { + timeout: 10000 + }); + + // Restore default mode for other tests + const numeric20Card = page.locator('.mode-card').filter({ hasText: /notes \/20/i }); + await numeric20Card.click(); + await page.getByRole('button', { name: /enregistrer/i }).click(); + await expect(page.getByText(/mis à jour avec succès/i)).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/frontend/e2e/periods.spec.ts b/frontend/e2e/periods.spec.ts index 9fa1469..6c95c41 100644 --- a/frontend/e2e/periods.spec.ts +++ b/frontend/e2e/periods.spec.ts @@ -23,23 +23,15 @@ test.describe('Periods Management (Story 2.3)', () => { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); - try { - // Create admin user - execSync( - `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, - { encoding: 'utf-8' } - ); - console.log('Periods E2E test admin user created'); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); - // Clean up all periods for this tenant - execSync( - `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`, - { encoding: 'utf-8' } - ); - console.log('Periods cleaned up for E2E tests'); - } catch (error) { - console.error('Setup error:', error); - } + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`, + { encoding: 'utf-8' } + ); }); async function loginAsAdmin(page: import('@playwright/test').Page) { diff --git a/frontend/e2e/subjects.spec.ts b/frontend/e2e/subjects.spec.ts index 2e43376..5a7f037 100644 --- a/frontend/e2e/subjects.spec.ts +++ b/frontend/e2e/subjects.spec.ts @@ -25,23 +25,15 @@ test.describe('Subjects Management (Story 2.2)', () => { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); - try { - // Create admin user - execSync( - `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, - { encoding: 'utf-8' } - ); - console.log('Subjects E2E test admin user created'); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`, + { encoding: 'utf-8' } + ); - // Clean up all subjects for this tenant to ensure Empty State test works - execSync( - `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM subjects WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, - { encoding: 'utf-8' } - ); - console.log('Subjects cleaned up for E2E tests'); - } catch (error) { - console.error('Setup error:', error); - } + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM subjects WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, + { encoding: 'utf-8' } + ); }); // Helper to login as admin diff --git a/frontend/src/lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte b/frontend/src/lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte index 8cfca2c..58bd440 100644 --- a/frontend/src/lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte +++ b/frontend/src/lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte @@ -2,6 +2,11 @@ import type { SerenityScore } from '$types'; import { getSerenityEmoji, getSerenityLabel } from '$lib/features/dashboard/serenity-score'; + // TODO: Adapter la formule et les poids affichés selon le mode de notation + // (no_grades: 0/50/50, competencies: renommer Notes→Compétences + note mapping). + // À traiter quand le Score Sérénité sera connecté aux vraies données. + // Voir backend SerenityScoreWeights::forMode() pour la logique de pondération. + let { score, isEnabled = false, @@ -16,10 +21,9 @@ onToggleOptIn?: ((enabled: boolean) => void) | undefined; } = $props(); - let localEnabled = $state(isEnabled); + let localEnabled = $state(false); - // Sync local state with parent prop changes - $effect(() => { + $effect.pre(() => { localEnabled = isEnabled; }); @@ -43,8 +47,8 @@ - -