feat: Permettre aux administrateurs de configurer les règles de devoirs
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

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.
This commit is contained in:
2026-03-17 21:27:06 +01:00
parent a708af3a8f
commit 5f3c5c2d71
39 changed files with 4007 additions and 2 deletions

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\HomeworkRules;
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class EnforcementModeTest extends TestCase
{
#[Test]
public function softEstActif(): void
{
self::assertTrue(EnforcementMode::SOFT->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());
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\HomeworkRules;
use App\Administration\Domain\Exception\HomeworkRuleParamsInvalidException;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
use App\Administration\Domain\Model\HomeworkRules\RuleType;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class HomeworkRuleTest extends TestCase
{
#[Test]
public function createMinimumDelayRule(): void
{
$rule = new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => 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']);
}
}

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\HomeworkRules;
use App\Administration\Domain\Event\ReglesDevoirsModifiees;
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
use App\Administration\Domain\Model\HomeworkRules\RuleType;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class HomeworkRulesTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
#[Test]
public function creerWithDefaultValues(): void
{
$rules = $this->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'),
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Domain\Model\HomeworkRules;
use App\Administration\Domain\Model\HomeworkRules\RuleType;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class RuleTypeTest extends TestCase
{
#[Test]
public function minimumDelayRequiresParams(): void
{
self::assertSame(['days'], RuleType::MINIMUM_DELAY->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());
}
}