diff --git a/Makefile b/Makefile index 7991eab..084694a 100644 --- a/Makefile +++ b/Makefile @@ -125,7 +125,7 @@ setup-test-db: ## Créer et migrer la base de test PostgreSQL docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction --env=test -q .PHONY: test-php -test-php: setup-test-db ## Lancer les tests PHPUnit +test-php: ## Lancer les tests PHPUnit docker compose exec -e APP_ENV=test php composer test .PHONY: warmup diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 5ec6352..a18c503 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -264,6 +264,13 @@ services: App\Administration\Domain\Repository\SchoolBrandingRepository: alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolBrandingRepository + # Homework Rules Repository + App\Administration\Domain\Repository\HomeworkRulesRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRulesRepository + + App\Administration\Domain\Repository\HomeworkRulesHistoryRepository: + alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineHomeworkRulesHistoryRepository + App\Administration\Application\Port\LogoStorage: alias: App\Administration\Infrastructure\Storage\LocalLogoStorage diff --git a/backend/migrations/Version20260317100224.php b/backend/migrations/Version20260317100224.php new file mode 100644 index 0000000..87820d7 --- /dev/null +++ b/backend/migrations/Version20260317100224.php @@ -0,0 +1,55 @@ +addSql(<<<'SQL' + CREATE TABLE homework_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL UNIQUE, + rules JSONB NOT NULL DEFAULT '[]', + enforcement_mode VARCHAR(20) DEFAULT 'soft' NOT NULL, + enabled BOOLEAN DEFAULT true NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $this->addSql('CREATE INDEX idx_homework_rules_tenant ON homework_rules(tenant_id)'); + + $this->addSql(<<<'SQL' + CREATE TABLE homework_rules_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + previous_rules JSONB, + new_rules JSONB NOT NULL, + enforcement_mode VARCHAR(20) NOT NULL, + enabled BOOLEAN NOT NULL, + changed_by UUID REFERENCES users(id), + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + $this->addSql('CREATE INDEX idx_homework_rules_history_tenant ON homework_rules_history(tenant_id)'); + $this->addSql('CREATE INDEX idx_homework_rules_history_changed_at ON homework_rules_history(changed_at DESC)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS homework_rules_history'); + $this->addSql('DROP TABLE IF EXISTS homework_rules'); + } +} diff --git a/backend/src/Administration/Application/Command/UpdateHomeworkRules/UpdateHomeworkRulesCommand.php b/backend/src/Administration/Application/Command/UpdateHomeworkRules/UpdateHomeworkRulesCommand.php new file mode 100644 index 0000000..21c0342 --- /dev/null +++ b/backend/src/Administration/Application/Command/UpdateHomeworkRules/UpdateHomeworkRulesCommand.php @@ -0,0 +1,25 @@ +}> $rules + */ + public function __construct( + public string $tenantId, + public array $rules, + public string $enforcementMode, + public bool $enabled, + public string $changedBy, + ) { + } +} diff --git a/backend/src/Administration/Application/Command/UpdateHomeworkRules/UpdateHomeworkRulesHandler.php b/backend/src/Administration/Application/Command/UpdateHomeworkRules/UpdateHomeworkRulesHandler.php new file mode 100644 index 0000000..f54b2fa --- /dev/null +++ b/backend/src/Administration/Application/Command/UpdateHomeworkRules/UpdateHomeworkRulesHandler.php @@ -0,0 +1,110 @@ +tenantId); + $enforcementMode = EnforcementMode::from($command->enforcementMode); + $now = $this->clock->now(); + $rules = array_map(HomeworkRule::fromArray(...), $command->rules); + + $homeworkRules = $this->homeworkRulesRepository->findByTenantId($tenantId); + + // Capturer l'état précédent pour l'historique. + // null = première configuration du tenant (aucune règle avant). + $previousRules = $homeworkRules?->rules; + $previousMode = $homeworkRules?->enforcementMode; + $previousEnabled = $homeworkRules?->enabled; + + if ($homeworkRules === null) { + $homeworkRules = HomeworkRules::creer(tenantId: $tenantId, now: $now); + } + + $homeworkRules->mettreAJour( + rules: $rules, + enforcementMode: $enforcementMode, + enabled: $command->enabled, + now: $now, + ); + + $this->homeworkRulesRepository->save($homeworkRules); + + // Enregistrer l'historique uniquement si quelque chose a changé. + // On compare l'état avant/après au lieu de consommer pullDomainEvents(), + // car le Processor en a besoin pour dispatcher sur l'event bus. + $changed = $previousRules === null + || $previousMode !== $enforcementMode + || $previousEnabled !== $command->enabled + || !$this->rulesEqual($previousRules, $rules); + + if ($changed) { + $this->historyRepository->record( + tenantId: $tenantId, + previousRules: $previousRules, + newRules: $rules, + enforcementMode: $enforcementMode, + enabled: $command->enabled, + changedBy: UserId::fromString($command->changedBy), + changedAt: $now, + ); + } + + return $homeworkRules; + } + + /** + * @param HomeworkRule[] $a + * @param HomeworkRule[] $b + */ + private function rulesEqual(array $a, array $b): bool + { + if (count($a) !== count($b)) { + return false; + } + + foreach ($a as $i => $rule) { + if (!isset($b[$i]) || !$rule->equals($b[$i])) { + return false; + } + } + + return true; + } +} diff --git a/backend/src/Administration/Application/Service/HomeworkRulesValidationResult.php b/backend/src/Administration/Application/Service/HomeworkRulesValidationResult.php new file mode 100644 index 0000000..869a855 --- /dev/null +++ b/backend/src/Administration/Application/Service/HomeworkRulesValidationResult.php @@ -0,0 +1,59 @@ +violations) === 0; + } + + public function estBloquant(): bool + { + return !$this->estValide() && $this->enforcementMode->estBloquant(); + } + + public function estAvertissement(): bool + { + return !$this->estValide() && !$this->enforcementMode->estBloquant(); + } + + /** + * @return string[] + */ + public function messages(): array + { + return array_map( + static fn (RuleViolation $v): string => $v->message, + $this->violations, + ); + } + + public static function ok(EnforcementMode $mode): self + { + return new self(violations: [], enforcementMode: $mode); + } +} diff --git a/backend/src/Administration/Application/Service/HomeworkRulesValidator.php b/backend/src/Administration/Application/Service/HomeworkRulesValidator.php new file mode 100644 index 0000000..6301925 --- /dev/null +++ b/backend/src/Administration/Application/Service/HomeworkRulesValidator.php @@ -0,0 +1,166 @@ +estActif()) { + return HomeworkRulesValidationResult::ok($rules->enforcementMode); + } + + $violations = []; + + foreach ($rules->rules as $rule) { + $violation = $this->validerRegle($rule, $dueDate, $creationDate); + if ($violation !== null) { + $violations[] = $violation; + } + } + + return new HomeworkRulesValidationResult( + violations: $violations, + enforcementMode: $rules->enforcementMode, + ); + } + + private function validerRegle( + HomeworkRule $rule, + DateTimeImmutable $dueDate, + DateTimeImmutable $creationDate, + ): ?RuleViolation { + return match ($rule->type) { + RuleType::MINIMUM_DELAY => $this->validerDelaiMinimum($rule, $dueDate, $creationDate), + RuleType::NO_MONDAY_AFTER => $this->validerPasLundiApres($rule, $dueDate, $creationDate), + }; + } + + /** + * AC3 : Configuration "3 jours" → devoir pour vendredi ne peut pas être créé après mardi. + */ + private function validerDelaiMinimum( + HomeworkRule $rule, + DateTimeImmutable $dueDate, + DateTimeImmutable $creationDate, + ): ?RuleViolation { + /** @var int|string $rawDays */ + $rawDays = $rule->params['days']; + $delayDays = (int) $rawDays; + + $deadline = $dueDate->modify(sprintf('-%d days', $delayDays)); + $creationDay = $creationDate->format('Y-m-d'); + $deadlineDay = $deadline->format('Y-m-d'); + + if ($creationDay > $deadlineDay) { + return new RuleViolation( + ruleType: RuleType::MINIMUM_DELAY, + message: sprintf( + 'Le devoir doit être créé au moins %d jours avant l\'échéance. Date limite de création : %s.', + $delayDays, + $deadline->format('d/m/Y'), + ), + ); + } + + return null; + } + + /** + * AC4 : Configuration "après vendredi 12h" → devoir pour lundi ne peut pas être créé après vendredi midi. + */ + private function validerPasLundiApres( + HomeworkRule $rule, + DateTimeImmutable $dueDate, + DateTimeImmutable $creationDate, + ): ?RuleViolation { + $dueDayOfWeek = (int) $dueDate->format('N'); + + // Règle ne s'applique que si le devoir est pour lundi (jour 1) + if ($dueDayOfWeek !== 1) { + return null; + } + + /** @var string $rawDay */ + $rawDay = $rule->params['day']; + $cutoffDay = strtolower($rawDay); + /** @var string $cutoffTime */ + $cutoffTime = $rule->params['time']; + + $cutoffDayNumber = $this->dayNameToNumber($cutoffDay); + + // Trouver le jour cutoff de la semaine précédente par rapport au lundi + $daysDiff = $dueDayOfWeek - $cutoffDayNumber; + if ($daysDiff <= 0) { + $daysDiff += 7; + } + + $cutoffDate = $dueDate->modify(sprintf('-%d days', $daysDiff)); + $cutoffDateTime = new DateTimeImmutable( + $cutoffDate->format('Y-m-d') . ' ' . $cutoffTime, + ); + + if ($creationDate > $cutoffDateTime) { + return new RuleViolation( + ruleType: RuleType::NO_MONDAY_AFTER, + message: sprintf( + 'Les devoirs pour lundi ne peuvent pas être créés après %s %s.', + $this->dayLabel($cutoffDay), + $cutoffTime, + ), + ); + } + + return null; + } + + private function dayNameToNumber(string $day): int + { + return match ($day) { + 'monday' => 1, + 'tuesday' => 2, + 'wednesday' => 3, + 'thursday' => 4, + 'friday' => 5, + 'saturday' => 6, + 'sunday' => 7, + default => throw new LogicException(sprintf('Jour invalide "%s" — HomeworkRule aurait dû rejeter cette valeur.', $day)), + }; + } + + private function dayLabel(string $day): string + { + return match ($day) { + 'monday' => 'lundi', + 'tuesday' => 'mardi', + 'wednesday' => 'mercredi', + 'thursday' => 'jeudi', + 'friday' => 'vendredi', + 'saturday' => 'samedi', + 'sunday' => 'dimanche', + default => throw new LogicException(sprintf('Jour invalide "%s" — HomeworkRule aurait dû rejeter cette valeur.', $day)), + }; + } +} diff --git a/backend/src/Administration/Application/Service/RuleViolation.php b/backend/src/Administration/Application/Service/RuleViolation.php new file mode 100644 index 0000000..093b92f --- /dev/null +++ b/backend/src/Administration/Application/Service/RuleViolation.php @@ -0,0 +1,19 @@ +}> $previousRules + * @param array}> $newRules + */ + public function __construct( + public HomeworkRulesId $homeworkRulesId, + public TenantId $tenantId, + public array $previousRules, + public array $newRules, + public EnforcementMode $enforcementMode, + public bool $enabled, + private DateTimeImmutable $occurredOn, + ) { + } + + #[Override] + public function occurredOn(): DateTimeImmutable + { + return $this->occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->homeworkRulesId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/HomeworkRuleParamsInvalidException.php b/backend/src/Administration/Domain/Exception/HomeworkRuleParamsInvalidException.php new file mode 100644 index 0000000..d6781c6 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/HomeworkRuleParamsInvalidException.php @@ -0,0 +1,36 @@ +value, + implode(', ', $missing), + )); + } + + public static function invalidValue(RuleType $type, string $param, string $reason): self + { + return new self(sprintf( + 'La règle "%s", paramètre "%s" : %s.', + $type->value, + $param, + $reason, + )); + } +} diff --git a/backend/src/Administration/Domain/Exception/HomeworkRulesNotFoundException.php b/backend/src/Administration/Domain/Exception/HomeworkRulesNotFoundException.php new file mode 100644 index 0000000..a9bcaf0 --- /dev/null +++ b/backend/src/Administration/Domain/Exception/HomeworkRulesNotFoundException.php @@ -0,0 +1,21 @@ + 'Avertissement', + self::HARD => 'Blocage', + self::DISABLED => 'Désactivé', + }; + } +} diff --git a/backend/src/Administration/Domain/Model/HomeworkRules/HomeworkRule.php b/backend/src/Administration/Domain/Model/HomeworkRules/HomeworkRule.php new file mode 100644 index 0000000..43feffe --- /dev/null +++ b/backend/src/Administration/Domain/Model/HomeworkRules/HomeworkRule.php @@ -0,0 +1,125 @@ + $params + */ + public function __construct( + public RuleType $type, + public array $params, + ) { + $missing = array_diff($type->requiredParams(), array_keys($params)); + if (count($missing) > 0) { + throw HomeworkRuleParamsInvalidException::missingParams($type, $missing); + } + + self::validateValues($type, $params); + } + + private const array VALID_DAYS = [ + 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', + ]; + + /** + * @param array $params + */ + private static function validateValues(RuleType $type, array $params): void + { + match ($type) { + RuleType::MINIMUM_DELAY => self::validateMinimumDelay($params), + RuleType::NO_MONDAY_AFTER => self::validateNoMondayAfter($params), + }; + } + + /** + * @param array $params + */ + private static function validateMinimumDelay(array $params): void + { + $days = $params['days']; + + if (!is_int($days) || $days < 1 || $days > 30) { + throw HomeworkRuleParamsInvalidException::invalidValue( + RuleType::MINIMUM_DELAY, + 'days', + 'doit être un entier entre 1 et 30', + ); + } + } + + /** + * @param array $params + */ + private static function validateNoMondayAfter(array $params): void + { + $day = $params['day']; + + if (!is_string($day) || !in_array($day, self::VALID_DAYS, true)) { + throw HomeworkRuleParamsInvalidException::invalidValue( + RuleType::NO_MONDAY_AFTER, + 'day', + 'doit être un jour valide en anglais (monday, tuesday, ...)', + ); + } + + $time = $params['time']; + + if (!is_string($time) || preg_match('/^\d{2}:\d{2}$/', $time) !== 1) { + throw HomeworkRuleParamsInvalidException::invalidValue( + RuleType::NO_MONDAY_AFTER, + 'time', + 'doit être au format HH:MM', + ); + } + } + + public function equals(self $other): bool + { + return $this->type === $other->type && $this->params === $other->params; + } + + /** + * @return array{type: string, params: array} + */ + public function toArray(): array + { + return [ + 'type' => $this->type->value, + 'params' => $this->params, + ]; + } + + /** + * @param array{type: string, params: array} $data + */ + public static function fromArray(array $data): self + { + return new self( + type: RuleType::from($data['type']), + params: $data['params'], + ); + } +} diff --git a/backend/src/Administration/Domain/Model/HomeworkRules/HomeworkRules.php b/backend/src/Administration/Domain/Model/HomeworkRules/HomeworkRules.php new file mode 100644 index 0000000..0dc2026 --- /dev/null +++ b/backend/src/Administration/Domain/Model/HomeworkRules/HomeworkRules.php @@ -0,0 +1,184 @@ +updatedAt = $createdAt; + } + + /** + * Crée une configuration de règles par défaut pour un établissement. + * + * Par défaut : aucune règle active, mode soft, activé. + */ + public static function creer( + TenantId $tenantId, + DateTimeImmutable $now, + ): self { + return new self( + id: HomeworkRulesId::generate(), + tenantId: $tenantId, + rules: [], + enforcementMode: EnforcementMode::SOFT, + enabled: true, + createdAt: $now, + ); + } + + /** + * Met à jour les règles, le mode d'application et l'état d'activation. + * + * @param HomeworkRule[] $rules + */ + public function mettreAJour( + array $rules, + EnforcementMode $enforcementMode, + bool $enabled, + DateTimeImmutable $now, + ): void { + $rulesChanged = !$this->rulesEqual($rules); + $modeChanged = $this->enforcementMode !== $enforcementMode; + $enabledChanged = $this->enabled !== $enabled; + + if (!$rulesChanged && !$modeChanged && !$enabledChanged) { + return; + } + + $previousRules = $this->rules; + $this->rules = $rules; + $this->enforcementMode = $enforcementMode; + $this->enabled = $enabled; + $this->updatedAt = $now; + + $this->recordEvent(new ReglesDevoirsModifiees( + homeworkRulesId: $this->id, + tenantId: $this->tenantId, + previousRules: array_map( + static fn (HomeworkRule $rule): array => $rule->toArray(), + $previousRules, + ), + newRules: array_map( + static fn (HomeworkRule $rule): array => $rule->toArray(), + $rules, + ), + enforcementMode: $enforcementMode, + enabled: $enabled, + occurredOn: $now, + )); + } + + /** + * Désactive complètement les règles. + * + * Les enseignants peuvent créer des devoirs librement. + */ + public function desactiver(DateTimeImmutable $now): void + { + $this->mettreAJour( + rules: $this->rules, + enforcementMode: EnforcementMode::DISABLED, + enabled: false, + now: $now, + ); + } + + /** + * Réactive les règles avec le mode précédemment configuré. + */ + public function activer(EnforcementMode $mode, DateTimeImmutable $now): void + { + $this->mettreAJour( + rules: $this->rules, + enforcementMode: $mode, + enabled: true, + now: $now, + ); + } + + /** + * Vérifie si les règles sont actives et applicables. + */ + public function estActif(): bool + { + return $this->enabled && $this->enforcementMode->estActif(); + } + + /** + * @internal Pour usage Infrastructure uniquement + * + * @param HomeworkRule[] $rules + */ + public static function reconstitute( + HomeworkRulesId $id, + TenantId $tenantId, + array $rules, + EnforcementMode $enforcementMode, + bool $enabled, + DateTimeImmutable $createdAt, + DateTimeImmutable $updatedAt, + ): self { + $homeworkRules = new self( + id: $id, + tenantId: $tenantId, + rules: $rules, + enforcementMode: $enforcementMode, + enabled: $enabled, + createdAt: $createdAt, + ); + + $homeworkRules->updatedAt = $updatedAt; + + return $homeworkRules; + } + + /** + * @param HomeworkRule[] $other + */ + private function rulesEqual(array $other): bool + { + if (count($this->rules) !== count($other)) { + return false; + } + + foreach ($this->rules as $i => $rule) { + if (!isset($other[$i]) || !$rule->equals($other[$i])) { + return false; + } + } + + return true; + } +} diff --git a/backend/src/Administration/Domain/Model/HomeworkRules/HomeworkRulesId.php b/backend/src/Administration/Domain/Model/HomeworkRules/HomeworkRulesId.php new file mode 100644 index 0000000..3b7e0b2 --- /dev/null +++ b/backend/src/Administration/Domain/Model/HomeworkRules/HomeworkRulesId.php @@ -0,0 +1,11 @@ + ['days'], + self::NO_MONDAY_AFTER => ['day', 'time'], + }; + } + + /** + * Libellé utilisateur en français. + */ + public function label(): string + { + return match ($this) { + self::MINIMUM_DELAY => 'Délai minimum avant échéance', + self::NO_MONDAY_AFTER => 'Pas de devoir pour lundi après un horaire', + }; + } +} diff --git a/backend/src/Administration/Domain/Repository/HomeworkRulesHistoryRepository.php b/backend/src/Administration/Domain/Repository/HomeworkRulesHistoryRepository.php new file mode 100644 index 0000000..2030828 --- /dev/null +++ b/backend/src/Administration/Domain/Repository/HomeworkRulesHistoryRepository.php @@ -0,0 +1,41 @@ + + */ + public function findByTenant(TenantId $tenantId, int $limit = 50): array; +} diff --git a/backend/src/Administration/Domain/Repository/HomeworkRulesRepository.php b/backend/src/Administration/Domain/Repository/HomeworkRulesRepository.php new file mode 100644 index 0000000..da9a05d --- /dev/null +++ b/backend/src/Administration/Domain/Repository/HomeworkRulesRepository.php @@ -0,0 +1,19 @@ + + */ +final readonly class UpdateHomeworkRulesProcessor implements ProcessorInterface +{ + public function __construct( + private UpdateHomeworkRulesHandler $handler, + private TenantContext $tenantContext, + private MessageBusInterface $eventBus, + private Security $security, + ) { + } + + /** + * @param HomeworkRulesResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): HomeworkRulesResource + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + if (!$this->security->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedHttpException('Seuls les administrateurs peuvent modifier les règles de devoirs.'); + } + + try { + $command = new UpdateHomeworkRulesCommand( + tenantId: (string) $this->tenantContext->getCurrentTenantId(), + rules: $data->rules ?? [], + enforcementMode: $data->enforcementMode ?? 'soft', + enabled: $data->enabled ?? true, + changedBy: $user->userId(), + ); + + $homeworkRules = ($this->handler)($command); + + foreach ($homeworkRules->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + return HomeworkRulesResource::fromDomain($homeworkRules); + } catch (HomeworkRuleParamsInvalidException|ValueError $e) { + throw new BadRequestHttpException($e->getMessage()); + } + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/HomeworkRulesHistoryProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/HomeworkRulesHistoryProvider.php new file mode 100644 index 0000000..25edf1b --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/HomeworkRulesHistoryProvider.php @@ -0,0 +1,94 @@ + + */ +final readonly class HomeworkRulesHistoryProvider implements ProviderInterface +{ + public function __construct( + private HomeworkRulesHistoryRepository $historyRepository, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + ) { + } + + /** + * @return HomeworkRulesHistoryResource[] + */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedHttpException('Seuls les administrateurs peuvent voir l\'historique des règles.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $rows = $this->historyRepository->findByTenant( + $this->tenantContext->getCurrentTenantId(), + ); + + $resources = []; + foreach ($rows as $row) { + $resource = new HomeworkRulesHistoryResource(); + /** @var string $id */ + $id = $row['id']; + $resource->id = $id; + + /** @var string|null $previousRules */ + $previousRules = $row['previous_rules']; + if (is_string($previousRules)) { + /** @var array}> $decoded */ + $decoded = json_decode($previousRules, true, 512, JSON_THROW_ON_ERROR); + $resource->previousRules = $decoded; + } + + /** @var string $newRules */ + $newRules = $row['new_rules']; + /** @var array}> $decodedNew */ + $decodedNew = json_decode($newRules, true, 512, JSON_THROW_ON_ERROR); + $resource->newRules = $decodedNew; + + /** @var string $enforcementMode */ + $enforcementMode = $row['enforcement_mode']; + $resource->enforcementMode = $enforcementMode; + $resource->enabled = (bool) $row['enabled']; + + /** @var string $changedBy */ + $changedBy = $row['changed_by']; + $resource->changedBy = $changedBy; + + /** @var string $changedAt */ + $changedAt = $row['changed_at']; + $resource->changedAt = $changedAt; + + $resources[] = $resource; + } + + return $resources; + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/HomeworkRulesProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/HomeworkRulesProvider.php new file mode 100644 index 0000000..ed00146 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/HomeworkRulesProvider.php @@ -0,0 +1,58 @@ + + */ +final readonly class HomeworkRulesProvider implements ProviderInterface +{ + public function __construct( + private HomeworkRulesRepository $homeworkRulesRepository, + private TenantContext $tenantContext, + private AuthorizationCheckerInterface $authorizationChecker, + private Clock $clock, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): HomeworkRulesResource + { + if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedHttpException('Seuls les administrateurs peuvent gérer les règles de devoirs.'); + } + + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $tenantId = $this->tenantContext->getCurrentTenantId(); + $homeworkRules = $this->homeworkRulesRepository->findByTenantId($tenantId); + + if ($homeworkRules === null) { + $homeworkRules = HomeworkRules::creer( + tenantId: $tenantId, + now: $this->clock->now(), + ); + $this->homeworkRulesRepository->save($homeworkRules); + } + + return HomeworkRulesResource::fromDomain($homeworkRules); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/HomeworkRulesHistoryResource.php b/backend/src/Administration/Infrastructure/Api/Resource/HomeworkRulesHistoryResource.php new file mode 100644 index 0000000..598fd05 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/HomeworkRulesHistoryResource.php @@ -0,0 +1,43 @@ +}>|null */ + public ?array $previousRules = null; + + /** @var array}> */ + public array $newRules = []; + + public string $enforcementMode = ''; + + public bool $enabled = true; + + public string $changedBy = ''; + + public string $changedAt = ''; +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/HomeworkRulesResource.php b/backend/src/Administration/Infrastructure/Api/Resource/HomeworkRulesResource.php new file mode 100644 index 0000000..c933085 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/HomeworkRulesResource.php @@ -0,0 +1,75 @@ +}>|null */ + #[Assert\NotNull(message: 'Les règles sont obligatoires.')] + public ?array $rules = null; + + #[Assert\NotBlank(message: 'Le mode d\'application est obligatoire.')] + #[Assert\Choice(choices: ['soft', 'hard', 'disabled'], message: 'Mode invalide.')] + public ?string $enforcementMode = null; + + public ?bool $enabled = null; + + public ?DateTimeImmutable $createdAt = null; + public ?DateTimeImmutable $updatedAt = null; + + public static function fromDomain(HomeworkRules $homeworkRules): self + { + $resource = new self(); + $resource->id = (string) $homeworkRules->id; + $resource->rules = array_map( + static fn (HomeworkRule $rule): array => $rule->toArray(), + $homeworkRules->rules, + ); + $resource->enforcementMode = $homeworkRules->enforcementMode->value; + $resource->enabled = $homeworkRules->enabled; + $resource->createdAt = $homeworkRules->createdAt; + $resource->updatedAt = $homeworkRules->updatedAt; + + return $resource; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRulesHistoryRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRulesHistoryRepository.php new file mode 100644 index 0000000..834a618 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRulesHistoryRepository.php @@ -0,0 +1,83 @@ + $r->toArray(), $previousRules), + JSON_THROW_ON_ERROR, + ) + : null; + + $newJson = json_encode( + array_map(static fn (HomeworkRule $r): array => $r->toArray(), $newRules), + JSON_THROW_ON_ERROR, + ); + + $this->connection->executeStatement( + 'INSERT INTO homework_rules_history (tenant_id, previous_rules, new_rules, enforcement_mode, enabled, changed_by, changed_at) + VALUES (:tenant_id, :previous_rules, :new_rules, :enforcement_mode, :enabled, :changed_by, :changed_at)', + [ + 'tenant_id' => (string) $tenantId, + 'previous_rules' => $previousJson, + 'new_rules' => $newJson, + 'enforcement_mode' => $enforcementMode->value, + 'enabled' => $enabled ? 'true' : 'false', + 'changed_by' => (string) $changedBy, + 'changed_at' => $changedAt->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function findByTenant(TenantId $tenantId, int $limit = 50): array + { + /** @var array $rows */ + $rows = $this->connection->fetchAllAssociative( + 'SELECT id, previous_rules, new_rules, enforcement_mode, enabled, changed_by, changed_at + FROM homework_rules_history + WHERE tenant_id = :tenant_id + ORDER BY changed_at DESC + LIMIT :limit', + ['tenant_id' => (string) $tenantId, 'limit' => $limit], + ); + + return $rows; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRulesRepository.php b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRulesRepository.php new file mode 100644 index 0000000..94969f7 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRulesRepository.php @@ -0,0 +1,126 @@ + $rule->toArray(), + $homeworkRules->rules, + ), + JSON_THROW_ON_ERROR, + ); + + $this->connection->executeStatement( + 'INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) + VALUES (:id, :tenant_id, :rules, :enforcement_mode, :enabled, :created_at, :updated_at) + ON CONFLICT (tenant_id) DO UPDATE SET + rules = EXCLUDED.rules, + enforcement_mode = EXCLUDED.enforcement_mode, + enabled = EXCLUDED.enabled, + updated_at = EXCLUDED.updated_at', + [ + 'id' => (string) $homeworkRules->id, + 'tenant_id' => (string) $homeworkRules->tenantId, + 'rules' => $rulesJson, + 'enforcement_mode' => $homeworkRules->enforcementMode->value, + 'enabled' => $homeworkRules->enabled ? 'true' : 'false', + 'created_at' => $homeworkRules->createdAt->format(DateTimeImmutable::ATOM), + 'updated_at' => $homeworkRules->updatedAt->format(DateTimeImmutable::ATOM), + ], + ); + } + + #[Override] + public function get(TenantId $tenantId): HomeworkRules + { + $rules = $this->findByTenantId($tenantId); + + if ($rules === null) { + throw HomeworkRulesNotFoundException::pourTenant($tenantId); + } + + return $rules; + } + + #[Override] + public function findByTenantId(TenantId $tenantId): ?HomeworkRules + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM homework_rules WHERE tenant_id = :tenant_id', + ['tenant_id' => (string) $tenantId], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + /** + * @param array $row + */ + private function hydrate(array $row): HomeworkRules + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $rulesJson */ + $rulesJson = $row['rules']; + /** @var string $enforcementMode */ + $enforcementMode = $row['enforcement_mode']; + /** @var bool|string $enabled */ + $enabled = $row['enabled']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string $updatedAt */ + $updatedAt = $row['updated_at']; + + /** @var array}> $rulesData */ + $rulesData = json_decode($rulesJson, true, 512, JSON_THROW_ON_ERROR); + + return HomeworkRules::reconstitute( + id: HomeworkRulesId::fromString($id), + tenantId: TenantId::fromString($tenantId), + rules: array_map(HomeworkRule::fromArray(...), $rulesData), + enforcementMode: EnforcementMode::from($enforcementMode), + enabled: is_string($enabled) ? $enabled === 'true' || $enabled === 't' : (bool) $enabled, + createdAt: new DateTimeImmutable($createdAt), + updatedAt: new DateTimeImmutable($updatedAt), + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryHomeworkRulesHistoryRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryHomeworkRulesHistoryRepository.php new file mode 100644 index 0000000..825dc4c --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryHomeworkRulesHistoryRepository.php @@ -0,0 +1,70 @@ +> */ + private array $history = []; + + #[Override] + public function record( + TenantId $tenantId, + ?array $previousRules, + array $newRules, + EnforcementMode $enforcementMode, + bool $enabled, + UserId $changedBy, + DateTimeImmutable $changedAt, + ): void { + $key = (string) $tenantId; + + $previousJson = $previousRules !== null + ? json_encode( + array_map(static fn (HomeworkRule $r): array => $r->toArray(), $previousRules), + JSON_THROW_ON_ERROR, + ) + : null; + + $this->history[$key][] = [ + 'id' => Uuid::uuid7()->toString(), + 'previous_rules' => $previousJson, + 'new_rules' => json_encode( + array_map(static fn (HomeworkRule $r): array => $r->toArray(), $newRules), + JSON_THROW_ON_ERROR, + ), + 'enforcement_mode' => $enforcementMode->value, + 'enabled' => $enabled, + 'changed_by' => (string) $changedBy, + 'changed_at' => $changedAt->format(DateTimeImmutable::ATOM), + ]; + } + + #[Override] + public function findByTenant(TenantId $tenantId, int $limit = 50): array + { + $entries = $this->history[(string) $tenantId] ?? []; + + return array_slice(array_reverse($entries), 0, $limit); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryHomeworkRulesRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryHomeworkRulesRepository.php new file mode 100644 index 0000000..7ba72d2 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryHomeworkRulesRepository.php @@ -0,0 +1,41 @@ + Indexed by tenant_id */ + private array $byTenant = []; + + #[Override] + public function save(HomeworkRules $homeworkRules): void + { + $this->byTenant[(string) $homeworkRules->tenantId] = $homeworkRules; + } + + #[Override] + public function get(TenantId $tenantId): HomeworkRules + { + $rules = $this->findByTenantId($tenantId); + + if ($rules === null) { + throw HomeworkRulesNotFoundException::pourTenant($tenantId); + } + + return $rules; + } + + #[Override] + public function findByTenantId(TenantId $tenantId): ?HomeworkRules + { + return $this->byTenant[(string) $tenantId] ?? null; + } +} diff --git a/backend/tests/Functional/Administration/Api/HomeworkRulesEndpointsTest.php b/backend/tests/Functional/Administration/Api/HomeworkRulesEndpointsTest.php new file mode 100644 index 0000000..d95198e --- /dev/null +++ b/backend/tests/Functional/Administration/Api/HomeworkRulesEndpointsTest.php @@ -0,0 +1,183 @@ +request('GET', '/api/settings/homework-rules', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + // ========================================================================= + // GET /settings/homework-rules — Without authentication (with tenant) + // ========================================================================= + + #[Test] + public function getHomeworkRulesReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/settings/homework-rules', [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(401); + } + + // ========================================================================= + // PUT /settings/homework-rules — Without tenant + // ========================================================================= + + #[Test] + public function updateHomeworkRulesReturns404WithoutTenant(): void + { + $client = static::createClient(); + + $client->request('PUT', '/api/settings/homework-rules', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'rules' => [], + 'enforcementMode' => 'soft', + 'enabled' => true, + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function updateHomeworkRulesReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('PUT', 'http://ecole-alpha.classeo.local/api/settings/homework-rules', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'rules' => [], + 'enforcementMode' => 'soft', + 'enabled' => true, + ], + ]); + + self::assertResponseStatusCodeSame(401); + } + + // ========================================================================= + // GET /settings/homework-rules/history — Without tenant / auth + // ========================================================================= + + #[Test] + public function getHistoryReturns404WithoutTenant(): void + { + $client = static::createClient(); + + $client->request('GET', '/api/settings/homework-rules/history', [ + 'headers' => [ + 'Host' => 'localhost', + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + + #[Test] + public function getHistoryReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/settings/homework-rules/history', [ + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + self::assertResponseStatusCodeSame(401); + } + + // ========================================================================= + // Route existence — tenant + no auth proves route exists (401 not 404) + // ========================================================================= + + #[Test] + public function getHomeworkRulesRouteExistsWithTenant(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/settings/homework-rules', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + // 401 (no auth) not 404 — proves route is registered + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function putHomeworkRulesRouteExistsWithTenant(): void + { + $client = static::createClient(); + + $client->request('PUT', 'http://ecole-alpha.classeo.local/api/settings/homework-rules', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'rules' => [], + 'enforcementMode' => 'soft', + 'enabled' => true, + ], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function historyRouteExistsWithTenant(): void + { + $client = static::createClient(); + + $client->request('GET', 'http://ecole-alpha.classeo.local/api/settings/homework-rules/history', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } +} diff --git a/backend/tests/Functional/Administration/Application/HomeworkRulesPersistenceFunctionalTest.php b/backend/tests/Functional/Administration/Application/HomeworkRulesPersistenceFunctionalTest.php new file mode 100644 index 0000000..65addd8 --- /dev/null +++ b/backend/tests/Functional/Administration/Application/HomeworkRulesPersistenceFunctionalTest.php @@ -0,0 +1,151 @@ +repository = static::getContainer()->get(HomeworkRulesRepository::class); + } + + protected function tearDown(): void + { + /** @var Connection $connection */ + $connection = static::getContainer()->get(Connection::class); + $connection->executeStatement( + 'DELETE FROM homework_rules_history WHERE tenant_id = :t', + ['t' => self::TENANT_ID], + ); + $connection->executeStatement( + 'DELETE FROM homework_rules WHERE tenant_id = :t', + ['t' => self::TENANT_ID], + ); + + parent::tearDown(); + } + + #[Test] + public function saveAndRetrieveEmptyRules(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $rules = HomeworkRules::creer(tenantId: $tenantId, now: new DateTimeImmutable('2026-03-17 10:00:00')); + $this->repository->save($rules); + + $loaded = $this->repository->findByTenantId($tenantId); + + self::assertNotNull($loaded); + self::assertTrue($loaded->tenantId->equals($tenantId)); + self::assertSame([], $loaded->rules); + self::assertSame(EnforcementMode::SOFT, $loaded->enforcementMode); + self::assertTrue($loaded->enabled); + } + + #[Test] + public function saveAndRetrieveWithRules(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $rules = HomeworkRules::creer(tenantId: $tenantId, now: new DateTimeImmutable('2026-03-17 10:00:00')); + + $rules->mettreAJour( + rules: [ + new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]), + new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00']), + ], + enforcementMode: EnforcementMode::HARD, + enabled: true, + now: new DateTimeImmutable('2026-03-17 11:00:00'), + ); + + $this->repository->save($rules); + + $loaded = $this->repository->findByTenantId($tenantId); + + self::assertNotNull($loaded); + self::assertCount(2, $loaded->rules); + self::assertSame(RuleType::MINIMUM_DELAY, $loaded->rules[0]->type); + self::assertSame(3, $loaded->rules[0]->params['days']); + self::assertSame(RuleType::NO_MONDAY_AFTER, $loaded->rules[1]->type); + self::assertSame('friday', $loaded->rules[1]->params['day']); + self::assertSame('12:00', $loaded->rules[1]->params['time']); + self::assertSame(EnforcementMode::HARD, $loaded->enforcementMode); + } + + #[Test] + public function upsertOverwritesExistingConfig(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $rules = HomeworkRules::creer(tenantId: $tenantId, now: new DateTimeImmutable('2026-03-17 10:00:00')); + $this->repository->save($rules); + + $rules->mettreAJour( + rules: [new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 5])], + enforcementMode: EnforcementMode::HARD, + enabled: true, + now: new DateTimeImmutable('2026-03-17 12:00:00'), + ); + $this->repository->save($rules); + + $loaded = $this->repository->findByTenantId($tenantId); + + self::assertNotNull($loaded); + self::assertCount(1, $loaded->rules); + self::assertSame(5, $loaded->rules[0]->params['days']); + self::assertSame(EnforcementMode::HARD, $loaded->enforcementMode); + } + + #[Test] + public function findByTenantIdReturnsNullWhenNotFound(): void + { + $tenantId = TenantId::fromString('99999999-9999-9999-9999-999999999999'); + + self::assertNull($this->repository->findByTenantId($tenantId)); + } + + #[Test] + public function getThrowsWhenNotFound(): void + { + $this->expectException(\App\Administration\Domain\Exception\HomeworkRulesNotFoundException::class); + + $this->repository->get(TenantId::fromString('99999999-9999-9999-9999-999999999999')); + } + + #[Test] + public function disabledStatePersistedCorrectly(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $rules = HomeworkRules::creer(tenantId: $tenantId, now: new DateTimeImmutable('2026-03-17 10:00:00')); + $rules->desactiver(new DateTimeImmutable('2026-03-17 11:00:00')); + $this->repository->save($rules); + + $loaded = $this->repository->findByTenantId($tenantId); + + self::assertNotNull($loaded); + self::assertFalse($loaded->enabled); + self::assertSame(EnforcementMode::DISABLED, $loaded->enforcementMode); + self::assertFalse($loaded->estActif()); + } +} diff --git a/backend/tests/Unit/Administration/Application/Command/UpdateHomeworkRules/UpdateHomeworkRulesHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/UpdateHomeworkRules/UpdateHomeworkRulesHandlerTest.php new file mode 100644 index 0000000..eb9bab7 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/UpdateHomeworkRules/UpdateHomeworkRulesHandlerTest.php @@ -0,0 +1,163 @@ +rulesRepository = new InMemoryHomeworkRulesRepository(); + $this->historyRepository = new InMemoryHomeworkRulesHistoryRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-03-17 10:00:00'); + } + }; + } + + #[Test] + public function itCreatesRulesWhenNoneExist(): void + { + $handler = $this->handler(); + + $result = $handler($this->commandAvecDelai(3)); + + self::assertCount(1, $result->rules); + self::assertSame(RuleType::MINIMUM_DELAY, $result->rules[0]->type); + self::assertSame(EnforcementMode::SOFT, $result->enforcementMode); + self::assertTrue($result->enabled); + } + + #[Test] + public function itUpdatesExistingRules(): void + { + $this->seedRules(); + + $handler = $this->handler(); + $result = $handler($this->commandAvecDelai(5, 'hard')); + + self::assertCount(1, $result->rules); + self::assertSame(5, $result->rules[0]->params['days']); + self::assertSame(EnforcementMode::HARD, $result->enforcementMode); + } + + #[Test] + public function itRecordsHistory(): void + { + $handler = $this->handler(); + $handler($this->commandAvecDelai(3)); + + $history = $this->historyRepository->findByTenant(TenantId::fromString(self::TENANT_ID)); + + self::assertCount(1, $history); + self::assertSame('soft', $history[0]['enforcement_mode']); + } + + #[Test] + public function itSavesToRepository(): void + { + $handler = $this->handler(); + $handler($this->commandAvecDelai(3)); + + $saved = $this->rulesRepository->findByTenantId(TenantId::fromString(self::TENANT_ID)); + self::assertNotNull($saved); + self::assertCount(1, $saved->rules); + } + + #[Test] + public function itDisablesRules(): void + { + $this->seedRules(); + + $handler = $this->handler(); + $result = $handler(new UpdateHomeworkRulesCommand( + tenantId: self::TENANT_ID, + rules: [], + enforcementMode: 'disabled', + enabled: false, + changedBy: self::USER_ID, + )); + + self::assertFalse($result->enabled); + self::assertSame(EnforcementMode::DISABLED, $result->enforcementMode); + } + + #[Test] + public function itHandlesMultipleRules(): void + { + $handler = $this->handler(); + $result = $handler(new UpdateHomeworkRulesCommand( + tenantId: self::TENANT_ID, + rules: [ + ['type' => 'minimum_delay', 'params' => ['days' => 3]], + ['type' => 'no_monday_after', 'params' => ['day' => 'friday', 'time' => '12:00']], + ], + enforcementMode: 'soft', + enabled: true, + changedBy: self::USER_ID, + )); + + self::assertCount(2, $result->rules); + } + + private function handler(): UpdateHomeworkRulesHandler + { + return new UpdateHomeworkRulesHandler( + $this->rulesRepository, + $this->historyRepository, + $this->clock, + ); + } + + private function commandAvecDelai(int $days, string $mode = 'soft'): UpdateHomeworkRulesCommand + { + return new UpdateHomeworkRulesCommand( + tenantId: self::TENANT_ID, + rules: [['type' => 'minimum_delay', 'params' => ['days' => $days]]], + enforcementMode: $mode, + enabled: true, + changedBy: self::USER_ID, + ); + } + + private function seedRules(): void + { + $rules = HomeworkRules::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + now: new DateTimeImmutable('2026-03-01'), + ); + + $rules->mettreAJour( + rules: [new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3])], + enforcementMode: EnforcementMode::SOFT, + enabled: true, + now: new DateTimeImmutable('2026-03-01'), + ); + + $this->rulesRepository->save($rules); + } +} diff --git a/backend/tests/Unit/Administration/Application/Service/HomeworkRulesValidatorTest.php b/backend/tests/Unit/Administration/Application/Service/HomeworkRulesValidatorTest.php new file mode 100644 index 0000000..2ada11f --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/HomeworkRulesValidatorTest.php @@ -0,0 +1,264 @@ +validator = new HomeworkRulesValidator(); + } + + // -- Règles désactivées -- + + #[Test] + public function disabledRulesAlwaysValid(): void + { + $rules = $this->creerRulesAvecDelai(3, EnforcementMode::DISABLED, false); + + $result = $this->validator->valider( + $rules, + new DateTimeImmutable('2026-03-20'), // vendredi + new DateTimeImmutable('2026-03-19 15:00'), // jeudi (1 jour, < 3) + ); + + self::assertTrue($result->estValide()); + } + + // -- Délai minimum (AC3) -- + + #[Test] + public function delaiMinimumOkQuandRespected(): void + { + $rules = $this->creerRulesAvecDelai(3); + + // Devoir vendredi 20 mars, créé mardi 17 mars = 3 jours = OK + $result = $this->validator->valider( + $rules, + new DateTimeImmutable('2026-03-20'), + new DateTimeImmutable('2026-03-17 10:00'), + ); + + self::assertTrue($result->estValide()); + } + + #[Test] + public function delaiMinimumViolationQuandTropTard(): void + { + $rules = $this->creerRulesAvecDelai(3); + + // Devoir vendredi 20 mars, créé mercredi 18 mars = 2 jours < 3 + $result = $this->validator->valider( + $rules, + new DateTimeImmutable('2026-03-20'), + new DateTimeImmutable('2026-03-18 10:00'), + ); + + self::assertFalse($result->estValide()); + self::assertCount(1, $result->violations); + self::assertSame(RuleType::MINIMUM_DELAY, $result->violations[0]->ruleType); + } + + #[Test] + public function delaiMinimumSoftModeRetourneAvertissement(): void + { + $rules = $this->creerRulesAvecDelai(3, EnforcementMode::SOFT); + + $result = $this->validator->valider( + $rules, + new DateTimeImmutable('2026-03-20'), + new DateTimeImmutable('2026-03-18 10:00'), + ); + + self::assertTrue($result->estAvertissement()); + self::assertFalse($result->estBloquant()); + } + + #[Test] + public function delaiMinimumHardModeRetourneErreur(): void + { + $rules = $this->creerRulesAvecDelai(3, EnforcementMode::HARD); + + $result = $this->validator->valider( + $rules, + new DateTimeImmutable('2026-03-20'), + new DateTimeImmutable('2026-03-18 10:00'), + ); + + self::assertTrue($result->estBloquant()); + self::assertFalse($result->estAvertissement()); + } + + // -- Règle pas de devoir pour lundi (AC4) -- + + #[Test] + public function pasLundiApresOkQuandCreationAvantCutoff(): void + { + $rules = $this->creerRulesAvecLundi(); + + // Devoir pour lundi 23 mars, créé vendredi 20 mars à 11h (avant 12h) + $result = $this->validator->valider( + $rules, + new DateTimeImmutable('2026-03-23'), // lundi + new DateTimeImmutable('2026-03-20 11:00'), // vendredi 11h + ); + + self::assertTrue($result->estValide()); + } + + #[Test] + public function pasLundiApresViolationQuandCreationApresCutoff(): void + { + $rules = $this->creerRulesAvecLundi(); + + // Devoir pour lundi 23 mars, créé vendredi 20 mars à 14h (après 12h) + $result = $this->validator->valider( + $rules, + new DateTimeImmutable('2026-03-23'), // lundi + new DateTimeImmutable('2026-03-20 14:00'), // vendredi 14h + ); + + self::assertFalse($result->estValide()); + self::assertSame(RuleType::NO_MONDAY_AFTER, $result->violations[0]->ruleType); + } + + #[Test] + public function pasLundiApresIgnorePourJoursNonLundi(): void + { + $rules = $this->creerRulesAvecLundi(); + + // Devoir pour mardi 24 mars, créé n'importe quand + $result = $this->validator->valider( + $rules, + new DateTimeImmutable('2026-03-24'), // mardi + new DateTimeImmutable('2026-03-23 20:00'), // lundi soir + ); + + self::assertTrue($result->estValide()); + } + + #[Test] + public function pasLundiApresViolationSamediCreation(): void + { + $rules = $this->creerRulesAvecLundi(); + + // Devoir pour lundi 23 mars, créé samedi 21 mars (après vendredi 12h) + $result = $this->validator->valider( + $rules, + new DateTimeImmutable('2026-03-23'), // lundi + new DateTimeImmutable('2026-03-21 10:00'), // samedi + ); + + self::assertFalse($result->estValide()); + } + + // -- Règles combinées -- + + #[Test] + public function multipleViolationsQuandPlusieursReglesEnfreintes(): void + { + $rules = $this->creerRulesComplet(); + + // Devoir pour lundi 23 mars, créé samedi 21 mars 10h + // Violation délai minimum (3 jours: 23-3 = 20, samedi 21 > 20) + // Violation lundi (après vendredi 12h) + $result = $this->validator->valider( + $rules, + new DateTimeImmutable('2026-03-23'), // lundi + new DateTimeImmutable('2026-03-21 10:00'), // samedi + ); + + self::assertFalse($result->estValide()); + self::assertCount(2, $result->violations); + } + + #[Test] + public function messagesRetourneListeMessages(): void + { + $rules = $this->creerRulesAvecDelai(3); + + $result = $this->validator->valider( + $rules, + new DateTimeImmutable('2026-03-20'), + new DateTimeImmutable('2026-03-19 10:00'), + ); + + self::assertCount(1, $result->messages()); + self::assertStringContainsString('3 jours', $result->messages()[0]); + } + + // -- Helpers -- + + private function creerRulesAvecDelai( + int $days, + EnforcementMode $mode = EnforcementMode::SOFT, + bool $enabled = true, + ): HomeworkRules { + $rules = HomeworkRules::creer( + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'), + now: new DateTimeImmutable('2026-03-01'), + ); + + $rules->mettreAJour( + rules: [new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => $days])], + enforcementMode: $mode, + enabled: $enabled, + now: new DateTimeImmutable('2026-03-01'), + ); + + return $rules; + } + + private function creerRulesAvecLundi( + EnforcementMode $mode = EnforcementMode::SOFT, + ): HomeworkRules { + $rules = HomeworkRules::creer( + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'), + now: new DateTimeImmutable('2026-03-01'), + ); + + $rules->mettreAJour( + rules: [new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00'])], + enforcementMode: $mode, + enabled: true, + now: new DateTimeImmutable('2026-03-01'), + ); + + return $rules; + } + + private function creerRulesComplet( + EnforcementMode $mode = EnforcementMode::SOFT, + ): HomeworkRules { + $rules = HomeworkRules::creer( + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'), + now: new DateTimeImmutable('2026-03-01'), + ); + + $rules->mettreAJour( + rules: [ + new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]), + new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00']), + ], + enforcementMode: $mode, + enabled: true, + now: new DateTimeImmutable('2026-03-01'), + ); + + return $rules; + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/HomeworkRules/EnforcementModeTest.php b/backend/tests/Unit/Administration/Domain/Model/HomeworkRules/EnforcementModeTest.php new file mode 100644 index 0000000..20bdfc9 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/HomeworkRules/EnforcementModeTest.php @@ -0,0 +1,56 @@ +estActif()); + } + + #[Test] + public function hardEstActif(): void + { + self::assertTrue(EnforcementMode::HARD->estActif()); + } + + #[Test] + public function disabledEstPasActif(): void + { + self::assertFalse(EnforcementMode::DISABLED->estActif()); + } + + #[Test] + public function softEstPasBloquant(): void + { + self::assertFalse(EnforcementMode::SOFT->estBloquant()); + } + + #[Test] + public function hardEstBloquant(): void + { + self::assertTrue(EnforcementMode::HARD->estBloquant()); + } + + #[Test] + public function disabledEstPasBloquant(): void + { + self::assertFalse(EnforcementMode::DISABLED->estBloquant()); + } + + #[Test] + public function labelsAreFrench(): void + { + self::assertSame('Avertissement', EnforcementMode::SOFT->label()); + self::assertSame('Blocage', EnforcementMode::HARD->label()); + self::assertSame('Désactivé', EnforcementMode::DISABLED->label()); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/HomeworkRules/HomeworkRuleTest.php b/backend/tests/Unit/Administration/Domain/Model/HomeworkRules/HomeworkRuleTest.php new file mode 100644 index 0000000..96d893f --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/HomeworkRules/HomeworkRuleTest.php @@ -0,0 +1,163 @@ + 3]); + + self::assertSame(RuleType::MINIMUM_DELAY, $rule->type); + self::assertSame(3, $rule->params['days']); + } + + #[Test] + public function createNoMondayAfterRule(): void + { + $rule = new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00']); + + self::assertSame(RuleType::NO_MONDAY_AFTER, $rule->type); + self::assertSame('friday', $rule->params['day']); + self::assertSame('12:00', $rule->params['time']); + } + + #[Test] + public function throwsOnMissingParamsForMinimumDelay(): void + { + $this->expectException(HomeworkRuleParamsInvalidException::class); + + new HomeworkRule(RuleType::MINIMUM_DELAY, []); + } + + #[Test] + public function throwsOnMissingParamsForNoMondayAfter(): void + { + $this->expectException(HomeworkRuleParamsInvalidException::class); + + new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday']); + } + + #[Test] + public function equalsReturnsTrueForIdenticalRules(): void + { + $rule1 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]); + $rule2 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]); + + self::assertTrue($rule1->equals($rule2)); + } + + #[Test] + public function equalsReturnsFalseForDifferentParams(): void + { + $rule1 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]); + $rule2 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 5]); + + self::assertFalse($rule1->equals($rule2)); + } + + #[Test] + public function equalsReturnsFalseForDifferentTypes(): void + { + $rule1 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]); + $rule2 = new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00']); + + self::assertFalse($rule1->equals($rule2)); + } + + #[Test] + public function toArrayReturnsCorrectFormat(): void + { + $rule = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]); + $array = $rule->toArray(); + + self::assertSame('minimum_delay', $array['type']); + self::assertSame(['days' => 3], $array['params']); + } + + #[Test] + public function fromArrayCreatesRule(): void + { + $data = ['type' => 'minimum_delay', 'params' => ['days' => 3]]; + $rule = HomeworkRule::fromArray($data); + + self::assertSame(RuleType::MINIMUM_DELAY, $rule->type); + self::assertSame(3, $rule->params['days']); + } + + #[Test] + public function roundTripArrayConversion(): void + { + $original = new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00']); + $restored = HomeworkRule::fromArray($original->toArray()); + + self::assertTrue($original->equals($restored)); + } + + #[Test] + public function throwsOnZeroDays(): void + { + $this->expectException(HomeworkRuleParamsInvalidException::class); + + new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 0]); + } + + #[Test] + public function throwsOnNegativeDays(): void + { + $this->expectException(HomeworkRuleParamsInvalidException::class); + + new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => -1]); + } + + #[Test] + public function throwsOnExcessiveDays(): void + { + $this->expectException(HomeworkRuleParamsInvalidException::class); + + new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 31]); + } + + #[Test] + public function throwsOnStringDays(): void + { + $this->expectException(HomeworkRuleParamsInvalidException::class); + + new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 'three']); + } + + #[Test] + public function throwsOnInvalidDayName(): void + { + $this->expectException(HomeworkRuleParamsInvalidException::class); + + new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'firday', 'time' => '12:00']); + } + + #[Test] + public function throwsOnInvalidTimeFormat(): void + { + $this->expectException(HomeworkRuleParamsInvalidException::class); + + new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => 'banana']); + } + + #[Test] + public function acceptsBoundaryDays(): void + { + $rule1 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 1]); + $rule30 = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 30]); + + self::assertSame(1, $rule1->params['days']); + self::assertSame(30, $rule30->params['days']); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/HomeworkRules/HomeworkRulesTest.php b/backend/tests/Unit/Administration/Domain/Model/HomeworkRules/HomeworkRulesTest.php new file mode 100644 index 0000000..df0c224 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/HomeworkRules/HomeworkRulesTest.php @@ -0,0 +1,244 @@ +creerHomeworkRules(); + + self::assertTrue($rules->tenantId->equals(TenantId::fromString(self::TENANT_ID))); + self::assertSame([], $rules->rules); + self::assertSame(EnforcementMode::SOFT, $rules->enforcementMode); + self::assertTrue($rules->enabled); + } + + #[Test] + public function creerDoesNotRecordEvent(): void + { + $rules = $this->creerHomeworkRules(); + + self::assertCount(0, $rules->pullDomainEvents()); + } + + #[Test] + public function mettreAJourChangesRulesAndRecordsEvent(): void + { + $rules = $this->creerHomeworkRules(); + $now = new DateTimeImmutable('2026-03-17 10:00:00'); + $newRules = [ + new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]), + ]; + + $rules->mettreAJour( + rules: $newRules, + enforcementMode: EnforcementMode::HARD, + enabled: true, + now: $now, + ); + + self::assertCount(1, $rules->rules); + self::assertSame(EnforcementMode::HARD, $rules->enforcementMode); + self::assertSame($now, $rules->updatedAt); + + $events = $rules->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(ReglesDevoirsModifiees::class, $events[0]); + self::assertSame([], $events[0]->previousRules); + self::assertCount(1, $events[0]->newRules); + self::assertSame(EnforcementMode::HARD, $events[0]->enforcementMode); + } + + #[Test] + public function mettreAJourDoesNotRecordEventWhenNothingChanges(): void + { + $rules = $this->creerHomeworkRules(); + $now = new DateTimeImmutable('2026-03-17 10:00:00'); + $newRules = [ + new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]), + ]; + + $rules->mettreAJour( + rules: $newRules, + enforcementMode: EnforcementMode::SOFT, + enabled: true, + now: $now, + ); + $rules->pullDomainEvents(); + + $rules->mettreAJour( + rules: $newRules, + enforcementMode: EnforcementMode::SOFT, + enabled: true, + now: new DateTimeImmutable('2026-03-17 11:00:00'), + ); + + self::assertCount(0, $rules->pullDomainEvents()); + } + + #[Test] + public function mettreAJourWithMultipleRules(): void + { + $rules = $this->creerHomeworkRules(); + $now = new DateTimeImmutable('2026-03-17 10:00:00'); + $newRules = [ + new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]), + new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00']), + ]; + + $rules->mettreAJour( + rules: $newRules, + enforcementMode: EnforcementMode::SOFT, + enabled: true, + now: $now, + ); + + self::assertCount(2, $rules->rules); + self::assertSame(RuleType::MINIMUM_DELAY, $rules->rules[0]->type); + self::assertSame(RuleType::NO_MONDAY_AFTER, $rules->rules[1]->type); + } + + #[Test] + public function desactiverDisablesRulesAndRecordsEvent(): void + { + $rules = $this->creerHomeworkRules(); + $now = new DateTimeImmutable('2026-03-17 10:00:00'); + + $rules->desactiver($now); + + self::assertFalse($rules->enabled); + self::assertSame(EnforcementMode::DISABLED, $rules->enforcementMode); + + $events = $rules->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(ReglesDevoirsModifiees::class, $events[0]); + self::assertFalse($events[0]->enabled); + } + + #[Test] + public function activerReenablesRulesWithGivenMode(): void + { + $rules = $this->creerHomeworkRules(); + $rules->desactiver(new DateTimeImmutable('2026-03-17 09:00:00')); + $rules->pullDomainEvents(); + + $rules->activer(EnforcementMode::HARD, new DateTimeImmutable('2026-03-17 10:00:00')); + + self::assertTrue($rules->enabled); + self::assertSame(EnforcementMode::HARD, $rules->enforcementMode); + + $events = $rules->pullDomainEvents(); + self::assertCount(1, $events); + } + + #[Test] + public function estActifReturnsTrueWhenEnabledAndNotDisabled(): void + { + $rules = $this->creerHomeworkRules(); + + self::assertTrue($rules->estActif()); + } + + #[Test] + public function estActifReturnsFalseWhenDisabled(): void + { + $rules = $this->creerHomeworkRules(); + $rules->desactiver(new DateTimeImmutable()); + + self::assertFalse($rules->estActif()); + } + + #[Test] + public function reconstituteFromStorage(): void + { + $tenantId = TenantId::fromString(self::TENANT_ID); + $rulesList = [ + new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]), + ]; + $createdAt = new DateTimeImmutable('2026-03-01 10:00:00'); + $updatedAt = new DateTimeImmutable('2026-03-15 14:30:00'); + $id = \App\Administration\Domain\Model\HomeworkRules\HomeworkRulesId::generate(); + + $rules = HomeworkRules::reconstitute( + id: $id, + tenantId: $tenantId, + rules: $rulesList, + enforcementMode: EnforcementMode::HARD, + enabled: true, + createdAt: $createdAt, + updatedAt: $updatedAt, + ); + + self::assertTrue($rules->id->equals($id)); + self::assertTrue($rules->tenantId->equals($tenantId)); + self::assertCount(1, $rules->rules); + self::assertSame(EnforcementMode::HARD, $rules->enforcementMode); + self::assertTrue($rules->enabled); + self::assertSame($createdAt, $rules->createdAt); + self::assertSame($updatedAt, $rules->updatedAt); + self::assertCount(0, $rules->pullDomainEvents()); + } + + #[Test] + public function mettreAJourPreservesPreviousRulesInEvent(): void + { + $rules = $this->creerHomeworkRules(); + $now1 = new DateTimeImmutable('2026-03-17 10:00:00'); + $firstRules = [ + new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 3]), + ]; + + $rules->mettreAJour( + rules: $firstRules, + enforcementMode: EnforcementMode::SOFT, + enabled: true, + now: $now1, + ); + $rules->pullDomainEvents(); + + $now2 = new DateTimeImmutable('2026-03-17 11:00:00'); + $secondRules = [ + new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 5]), + ]; + + $rules->mettreAJour( + rules: $secondRules, + enforcementMode: EnforcementMode::HARD, + enabled: true, + now: $now2, + ); + + $events = $rules->pullDomainEvents(); + self::assertCount(1, $events); + /** @var ReglesDevoirsModifiees $event */ + $event = $events[0]; + self::assertSame('minimum_delay', $event->previousRules[0]['type']); + self::assertSame(3, $event->previousRules[0]['params']['days']); + self::assertSame('minimum_delay', $event->newRules[0]['type']); + self::assertSame(5, $event->newRules[0]['params']['days']); + } + + private function creerHomeworkRules(): HomeworkRules + { + return HomeworkRules::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + now: new DateTimeImmutable('2026-03-01 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/HomeworkRules/RuleTypeTest.php b/backend/tests/Unit/Administration/Domain/Model/HomeworkRules/RuleTypeTest.php new file mode 100644 index 0000000..965a738 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/HomeworkRules/RuleTypeTest.php @@ -0,0 +1,31 @@ +requiredParams()); + } + + #[Test] + public function noMondayAfterRequiresParams(): void + { + self::assertSame(['day', 'time'], RuleType::NO_MONDAY_AFTER->requiredParams()); + } + + #[Test] + public function labelsAreFrench(): void + { + self::assertSame('Délai minimum avant échéance', RuleType::MINIMUM_DELAY->label()); + self::assertSame('Pas de devoir pour lundi après un horaire', RuleType::NO_MONDAY_AFTER->label()); + } +} diff --git a/frontend/e2e/homework-rules.spec.ts b/frontend/e2e/homework-rules.spec.ts new file mode 100644 index 0000000..fffa148 --- /dev/null +++ b/frontend/e2e/homework-rules.spec.ts @@ -0,0 +1,190 @@ +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-homework-rules-admin@example.com'; +const ADMIN_PASSWORD = 'HomeworkRulesAdmin123'; +const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + +test.describe('Homework Rules Configuration', () => { + // Serial: les tests d'historique dépendent des entrées créées par les tests précédents + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + // 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' } + ); + + // Clean up homework rules from previous test runs + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM homework_rules_history WHERE tenant_id = '${TENANT_ID}'" 2>&1`, + { encoding: 'utf-8' } + ); + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM homework_rules WHERE tenant_id = '${TENANT_ID}'" 2>&1`, + { encoding: 'utf-8' } + ); + + try { + execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, + { encoding: 'utf-8' } + ); + } catch { + // Cache pool may not exist + } + }); + + 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 Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click() + ]); + } + + async function waitForPageLoaded(page: import('@playwright/test').Page) { + await expect( + page.getByRole('heading', { name: /règles de devoirs/i }) + ).toBeVisible({ timeout: 15000 }); + + await expect( + page.locator('.card').first() + ).toBeVisible({ timeout: 15000 }); + } + + // AC1: Page configuration pédagogique → section "Règles de devoirs" accessible + test('page affiche la configuration des règles de devoirs', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/homework-rules`); + await waitForPageLoaded(page); + + await expect( + page.getByRole('heading', { name: /règles de devoirs/i }) + ).toBeVisible(); + + // Mode selector visible + await expect(page.getByRole('radio', { name: /avertissement/i })).toBeVisible(); + await expect(page.getByRole('radio', { name: /blocage/i })).toBeVisible(); + await expect(page.getByRole('radio', { name: /désactivé/i })).toBeVisible(); + }); + + // AC5: Mode par défaut = "Soft" + test('mode par défaut est Avertissement (soft)', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/homework-rules`); + await waitForPageLoaded(page); + + const softRadio = page.getByRole('radio', { name: /avertissement/i }); + await expect(softRadio).toHaveAttribute('aria-checked', 'true'); + }); + + // AC2: Règle définissable : délai minimum + test('configurer la règle de délai minimum', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/homework-rules`); + await waitForPageLoaded(page); + + // Enable minimum delay rule + const delayCheckbox = page.getByRole('checkbox', { name: /délai minimum/i }); + await delayCheckbox.check(); + + // Set 3 days + const daysInput = page.locator('input[type="number"]'); + await daysInput.fill('3'); + + // Save + await page.getByRole('button', { name: /enregistrer/i }).click(); + + // Success message + await expect(page.getByText(/mises à jour avec succès/i)).toBeVisible({ timeout: 10000 }); + }); + + // AC2: Règle définissable : jours concernés (pas de devoir pour lundi) + test('configurer la règle pas de devoir pour lundi', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/homework-rules`); + await waitForPageLoaded(page); + + // Enable no-monday rule + const mondayCheckbox = page.getByRole('checkbox', { name: /pas de devoir pour lundi/i }); + await mondayCheckbox.check(); + + // Save + await page.getByRole('button', { name: /enregistrer/i }).click(); + + await expect(page.getByText(/mises à jour avec succès/i)).toBeVisible({ timeout: 10000 }); + }); + + // AC5: Choix mode Hard (blocage) + test('changer le mode en Blocage', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/homework-rules`); + await waitForPageLoaded(page); + + // Select hard mode + await page.getByRole('radio', { name: /blocage/i }).click(); + + // Save + await page.getByRole('button', { name: /enregistrer/i }).click(); + + await expect(page.getByText(/mises à jour avec succès/i)).toBeVisible({ timeout: 10000 }); + }); + + // AC7: Règles désactivables complètement + test('désactiver les règles complètement', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/homework-rules`); + await waitForPageLoaded(page); + + // Select disabled mode + await page.getByRole('radio', { name: /désactivé/i }).click(); + + // Rules section should disappear + await expect(page.getByText(/les enseignants peuvent créer des devoirs librement/i)).toBeVisible(); + + // Save + await page.getByRole('button', { name: /enregistrer/i }).click(); + + await expect(page.getByText(/mises à jour avec succès/i)).toBeVisible({ timeout: 10000 }); + }); + + // AC6: Historique des changements conservé + test('voir l\'historique des changements avec contenu', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${ALPHA_URL}/admin/homework-rules`); + await waitForPageLoaded(page); + + // Click history button + await page.getByRole('button', { name: /voir l'historique/i }).click(); + + // Modal should appear + const dialog = page.getByRole('dialog', { name: /historique des changements/i }); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Verify history is not empty (previous tests created entries) + await expect(dialog.getByText(/aucun changement/i)).not.toBeVisible(); + + // Verify at least one history entry with mode and date + const firstEntry = dialog.locator('.history-entry').first(); + await expect(firstEntry).toBeVisible(); + await expect(firstEntry.locator('.history-mode')).toBeVisible(); + await expect(firstEntry.locator('.history-date')).toBeVisible(); + }); +}); diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte index 2f9de16..7151506 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardAdmin.svelte @@ -91,6 +91,11 @@ Identité visuelle Logo et couleurs + + 📏 + Règles de devoirs + Timing et contraintes + 📤 Importer des élèves diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index da6cf6c..f05489c 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -67,7 +67,8 @@ links: [ { href: '/admin/image-rights', label: "Droit à l'image" }, { href: '/admin/pedagogy', label: 'Pédagogie' }, - { href: '/admin/branding', label: 'Identité visuelle' } + { href: '/admin/branding', label: 'Identité visuelle' }, + { href: '/admin/homework-rules', label: 'Règles de devoirs' } ] } ]; diff --git a/frontend/src/routes/admin/homework-rules/+page.svelte b/frontend/src/routes/admin/homework-rules/+page.svelte new file mode 100644 index 0000000..3a66b84 --- /dev/null +++ b/frontend/src/routes/admin/homework-rules/+page.svelte @@ -0,0 +1,868 @@ + + + + Règles de devoirs - Administration + + + + +{#if error} + +{/if} + +{#if successMessage} +
+ {successMessage} + +
+{/if} + +{#if isLoading} +
+
+

Chargement de la configuration...

+
+{:else if config} + +
+

Mode d'application

+

+ Choisissez comment les règles sont appliquées lorsqu'un enseignant crée un devoir. +

+ +
+ + + + + +
+
+ + + {#if enforcementMode !== 'disabled'} +
+

Règles actives

+

Définissez les règles de timing des devoirs.

+ + +
+ + + {#if minimumDelayEnabled} +
+ +

+ Exemple : avec {minimumDelayDays} jours, un devoir pour vendredi ne peut pas être + créé après {minimumDelayDays === 3 ? 'mardi' : `J-${minimumDelayDays}`}. +

+
+ {/if} +
+ + +
+ + + {#if noMondayAfterEnabled} +
+ + +

+ Exemple : un devoir pour lundi ne peut pas être créé après {noMondayAfterDay === 'friday' ? 'vendredi' : 'jeudi'} + {noMondayAfterTime}. +

+
+ {/if} +
+
+ {/if} + + +
+

Résumé

+
+

+ Mode : + {modeLabel} +

+ {#if enforcementMode !== 'disabled'} +

+ Règles : + {#if !minimumDelayEnabled && !noMondayAfterEnabled} + Aucune règle configurée + {:else} + {#if minimumDelayEnabled} + Délai minimum de {minimumDelayDays} jours. + {/if} + {#if noMondayAfterEnabled} + Pas de devoir pour lundi après {noMondayAfterDay === 'friday' ? 'vendredi' : 'jeudi'} + {noMondayAfterTime}. + {/if} + {/if} +

+ {:else} +

Les enseignants peuvent créer des devoirs librement.

+ {/if} +
+
+ + +
+ +
+ + +
+
+ + + {#if showHistory} + + {/if} +{/if} + +