From 5f3c5c2d71f25337aba6145696b91481d20632fd Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Tue, 17 Mar 2026 21:27:06 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Permettre=20aux=20administrateurs=20de?= =?UTF-8?q?=20configurer=20les=20r=C3=A8gles=20de=20devoirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les établissements ont besoin de protéger les élèves et familles des devoirs de dernière minute. Cette configuration au niveau tenant permet de définir des règles de timing (délai minimum, pas de devoir pour lundi après une heure limite) et un mode d'application (avertissement, blocage ou désactivé). Le service de validation est prêt pour être branché dans le flux de création de devoirs (Stories 5.4/5.5). L'historique des changements assure la traçabilité des modifications de configuration. --- Makefile | 2 +- backend/config/services.yaml | 7 + backend/migrations/Version20260317100224.php | 55 ++ .../UpdateHomeworkRulesCommand.php | 25 + .../UpdateHomeworkRulesHandler.php | 110 +++ .../Service/HomeworkRulesValidationResult.php | 59 ++ .../Service/HomeworkRulesValidator.php | 166 ++++ .../Application/Service/RuleViolation.php | 19 + .../Domain/Event/ReglesDevoirsModifiees.php | 48 + .../HomeworkRuleParamsInvalidException.php | 36 + .../HomeworkRulesNotFoundException.php | 21 + .../Model/HomeworkRules/EnforcementMode.php | 49 + .../Model/HomeworkRules/HomeworkRule.php | 125 +++ .../Model/HomeworkRules/HomeworkRules.php | 184 ++++ .../Model/HomeworkRules/HomeworkRulesId.php | 11 + .../Domain/Model/HomeworkRules/RuleType.php | 43 + .../HomeworkRulesHistoryRepository.php | 41 + .../Repository/HomeworkRulesRepository.php | 19 + .../UpdateHomeworkRulesProcessor.php | 78 ++ .../Provider/HomeworkRulesHistoryProvider.php | 94 ++ .../Api/Provider/HomeworkRulesProvider.php | 58 ++ .../Resource/HomeworkRulesHistoryResource.php | 43 + .../Api/Resource/HomeworkRulesResource.php | 75 ++ ...DoctrineHomeworkRulesHistoryRepository.php | 83 ++ .../DoctrineHomeworkRulesRepository.php | 126 +++ ...InMemoryHomeworkRulesHistoryRepository.php | 70 ++ .../InMemoryHomeworkRulesRepository.php | 41 + .../Api/HomeworkRulesEndpointsTest.php | 183 ++++ ...HomeworkRulesPersistenceFunctionalTest.php | 151 +++ .../UpdateHomeworkRulesHandlerTest.php | 163 ++++ .../Service/HomeworkRulesValidatorTest.php | 264 ++++++ .../HomeworkRules/EnforcementModeTest.php | 56 ++ .../Model/HomeworkRules/HomeworkRuleTest.php | 163 ++++ .../Model/HomeworkRules/HomeworkRulesTest.php | 244 +++++ .../Model/HomeworkRules/RuleTypeTest.php | 31 + frontend/e2e/homework-rules.spec.ts | 190 ++++ .../organisms/Dashboard/DashboardAdmin.svelte | 5 + frontend/src/routes/admin/+layout.svelte | 3 +- .../routes/admin/homework-rules/+page.svelte | 868 ++++++++++++++++++ 39 files changed, 4007 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/Version20260317100224.php create mode 100644 backend/src/Administration/Application/Command/UpdateHomeworkRules/UpdateHomeworkRulesCommand.php create mode 100644 backend/src/Administration/Application/Command/UpdateHomeworkRules/UpdateHomeworkRulesHandler.php create mode 100644 backend/src/Administration/Application/Service/HomeworkRulesValidationResult.php create mode 100644 backend/src/Administration/Application/Service/HomeworkRulesValidator.php create mode 100644 backend/src/Administration/Application/Service/RuleViolation.php create mode 100644 backend/src/Administration/Domain/Event/ReglesDevoirsModifiees.php create mode 100644 backend/src/Administration/Domain/Exception/HomeworkRuleParamsInvalidException.php create mode 100644 backend/src/Administration/Domain/Exception/HomeworkRulesNotFoundException.php create mode 100644 backend/src/Administration/Domain/Model/HomeworkRules/EnforcementMode.php create mode 100644 backend/src/Administration/Domain/Model/HomeworkRules/HomeworkRule.php create mode 100644 backend/src/Administration/Domain/Model/HomeworkRules/HomeworkRules.php create mode 100644 backend/src/Administration/Domain/Model/HomeworkRules/HomeworkRulesId.php create mode 100644 backend/src/Administration/Domain/Model/HomeworkRules/RuleType.php create mode 100644 backend/src/Administration/Domain/Repository/HomeworkRulesHistoryRepository.php create mode 100644 backend/src/Administration/Domain/Repository/HomeworkRulesRepository.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/UpdateHomeworkRulesProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/HomeworkRulesHistoryProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/HomeworkRulesProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/HomeworkRulesHistoryResource.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/HomeworkRulesResource.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRulesHistoryRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Doctrine/DoctrineHomeworkRulesRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryHomeworkRulesHistoryRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryHomeworkRulesRepository.php create mode 100644 backend/tests/Functional/Administration/Api/HomeworkRulesEndpointsTest.php create mode 100644 backend/tests/Functional/Administration/Application/HomeworkRulesPersistenceFunctionalTest.php create mode 100644 backend/tests/Unit/Administration/Application/Command/UpdateHomeworkRules/UpdateHomeworkRulesHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Application/Service/HomeworkRulesValidatorTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/HomeworkRules/EnforcementModeTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/HomeworkRules/HomeworkRuleTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/HomeworkRules/HomeworkRulesTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/HomeworkRules/RuleTypeTest.php create mode 100644 frontend/e2e/homework-rules.spec.ts create mode 100644 frontend/src/routes/admin/homework-rules/+page.svelte 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} + +