feat: Avertir l'enseignant quand un devoir ne respecte pas les règles (mode soft)
Quand un établissement configure des règles de devoirs en mode "soft", l'enseignant est maintenant averti avant la création si la date d'échéance ne respecte pas les contraintes (délai minimum, pas de lundi après un certain créneau). Il peut alors choisir de continuer (avec traçabilité) ou de modifier la date vers une date conforme. Le mode "hard" (blocage) reste protégé : acknowledgeWarning ne permet pas de contourner les règles bloquantes, préparant la story 5.5.
This commit is contained in:
26
backend/migrations/Version20260318004535.php
Normal file
26
backend/migrations/Version20260318004535.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260318004535 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Ajouter le champ rule_override sur homework pour la traçabilité des warnings contournés';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE homework ADD COLUMN rule_override JSONB DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE homework DROP COLUMN rule_override');
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ final readonly class CreateHomeworkCommand
|
||||
public string $title,
|
||||
public ?string $description,
|
||||
public string $dueDate,
|
||||
public bool $acknowledgeWarning = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException;
|
||||
use App\Scolarite\Domain\Model\Homework\Homework;
|
||||
use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||
use App\Scolarite\Domain\Service\DueDateValidator;
|
||||
@@ -26,6 +28,7 @@ final readonly class CreateHomeworkHandler
|
||||
private EnseignantAffectationChecker $affectationChecker,
|
||||
private CurrentCalendarProvider $calendarProvider,
|
||||
private DueDateValidator $dueDateValidator,
|
||||
private HomeworkRulesChecker $rulesChecker,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
@@ -46,6 +49,16 @@ final readonly class CreateHomeworkHandler
|
||||
$dueDate = new DateTimeImmutable($command->dueDate);
|
||||
$this->dueDateValidator->valider($dueDate, $now, $calendar);
|
||||
|
||||
$rulesResult = $this->rulesChecker->verifier($tenantId, $dueDate, $now);
|
||||
|
||||
if ($rulesResult->estBloquant()) {
|
||||
throw new ReglesDevoirsNonRespecteesException($rulesResult->toArray());
|
||||
}
|
||||
|
||||
if ($rulesResult->estAvertissement() && !$command->acknowledgeWarning) {
|
||||
throw new ReglesDevoirsNonRespecteesException($rulesResult->toArray());
|
||||
}
|
||||
|
||||
$homework = Homework::creer(
|
||||
tenantId: $tenantId,
|
||||
classId: $classId,
|
||||
@@ -57,6 +70,10 @@ final readonly class CreateHomeworkHandler
|
||||
now: $now,
|
||||
);
|
||||
|
||||
if ($command->acknowledgeWarning && $rulesResult->estAvertissement()) {
|
||||
$homework->acknowledgeRuleWarning($rulesResult->ruleTypes(), $now);
|
||||
}
|
||||
|
||||
$this->homeworkRepository->save($homework);
|
||||
|
||||
return $homework;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
use function array_map;
|
||||
use function count;
|
||||
|
||||
/**
|
||||
* Résultat de la vérification des règles de devoirs.
|
||||
*
|
||||
* Transporté à travers la frontière Scolarite ← Administration,
|
||||
* indépendant des types internes d'Administration.
|
||||
*/
|
||||
final readonly class HomeworkRulesCheckResult
|
||||
{
|
||||
/**
|
||||
* @param RuleWarning[] $warnings
|
||||
*/
|
||||
public function __construct(
|
||||
public array $warnings,
|
||||
public bool $bloquant,
|
||||
) {
|
||||
}
|
||||
|
||||
public function estValide(): bool
|
||||
{
|
||||
return count($this->warnings) === 0;
|
||||
}
|
||||
|
||||
public function estAvertissement(): bool
|
||||
{
|
||||
return !$this->estValide() && !$this->bloquant;
|
||||
}
|
||||
|
||||
public function estBloquant(): bool
|
||||
{
|
||||
return !$this->estValide() && $this->bloquant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (RuleWarning $w): string => $w->message,
|
||||
$this->warnings,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function ruleTypes(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (RuleWarning $w): string => $w->ruleType,
|
||||
$this->warnings,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{ruleType: string, message: string, params: array<string, mixed>}>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (RuleWarning $w): array => $w->toArray(),
|
||||
$this->warnings,
|
||||
);
|
||||
}
|
||||
|
||||
public static function ok(): self
|
||||
{
|
||||
return new self(warnings: [], bloquant: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Port pour vérifier les règles de devoirs configurées par l'établissement.
|
||||
*
|
||||
* L'implémentation consulte la configuration des règles du tenant
|
||||
* et retourne le résultat de validation.
|
||||
*/
|
||||
interface HomeworkRulesChecker
|
||||
{
|
||||
public function verifier(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesCheckResult;
|
||||
}
|
||||
33
backend/src/Scolarite/Application/Port/RuleWarning.php
Normal file
33
backend/src/Scolarite/Application/Port/RuleWarning.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
/**
|
||||
* Avertissement d'une règle de devoir non respectée.
|
||||
*/
|
||||
final readonly class RuleWarning
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $params Paramètres de la règle pour le frontend
|
||||
*/
|
||||
public function __construct(
|
||||
public string $ruleType,
|
||||
public string $message,
|
||||
public array $params = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ruleType: string, message: string, params: array<string, mixed>}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'ruleType' => $this->ruleType,
|
||||
'message' => $this->message,
|
||||
'params' => $this->params,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Domain\Exception;
|
||||
|
||||
use DomainException;
|
||||
|
||||
/**
|
||||
* Levée quand un devoir enfreint les règles configurées
|
||||
* et que l'enseignant n'a pas encore confirmé.
|
||||
*/
|
||||
final class ReglesDevoirsNonRespecteesException extends DomainException
|
||||
{
|
||||
/**
|
||||
* @param array<array{ruleType: string, message: string, params: array<string, mixed>}> $warnings
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $warnings,
|
||||
) {
|
||||
parent::__construct('Le devoir ne respecte pas les règles configurées.');
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ final class Homework extends AggregateRoot
|
||||
{
|
||||
public private(set) DateTimeImmutable $updatedAt;
|
||||
|
||||
/** @var array{warnings: string[], acknowledgedAt: string}|null */
|
||||
public private(set) ?array $ruleOverride = null;
|
||||
|
||||
private function __construct(
|
||||
public private(set) HomeworkId $id,
|
||||
public private(set) TenantId $tenantId,
|
||||
@@ -34,6 +37,17 @@ final class Homework extends AggregateRoot
|
||||
$this->updatedAt = $createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $ruleTypes Types de règles contournées (ex: ['minimum_delay'])
|
||||
*/
|
||||
public function acknowledgeRuleWarning(array $ruleTypes, DateTimeImmutable $now): void
|
||||
{
|
||||
$this->ruleOverride = [
|
||||
'warnings' => $ruleTypes,
|
||||
'acknowledgedAt' => $now->format(DateTimeImmutable::ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
public static function creer(
|
||||
TenantId $tenantId,
|
||||
ClassId $classId,
|
||||
@@ -110,6 +124,8 @@ final class Homework extends AggregateRoot
|
||||
|
||||
/**
|
||||
* @internal Pour usage Infrastructure uniquement
|
||||
*
|
||||
* @param array{warnings: string[], acknowledgedAt: string}|null $ruleOverride
|
||||
*/
|
||||
public static function reconstitute(
|
||||
HomeworkId $id,
|
||||
@@ -123,6 +139,7 @@ final class Homework extends AggregateRoot
|
||||
HomeworkStatus $status,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $updatedAt,
|
||||
?array $ruleOverride = null,
|
||||
): self {
|
||||
$homework = new self(
|
||||
id: $id,
|
||||
@@ -138,6 +155,7 @@ final class Homework extends AggregateRoot
|
||||
);
|
||||
|
||||
$homework->updatedAt = $updatedAt;
|
||||
$homework->ruleOverride = $ruleOverride;
|
||||
|
||||
return $homework;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,14 @@ use App\Scolarite\Application\Command\CreateHomework\CreateHomeworkCommand;
|
||||
use App\Scolarite\Application\Command\CreateHomework\CreateHomeworkHandler;
|
||||
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\HomeworkResource;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Ramsey\Uuid\Exception\InvalidUuidStringException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
@@ -37,7 +40,7 @@ final readonly class CreateHomeworkProcessor implements ProcessorInterface
|
||||
* @param HomeworkResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): HomeworkResource
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): HomeworkResource|JsonResponse
|
||||
{
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
@@ -58,6 +61,7 @@ final readonly class CreateHomeworkProcessor implements ProcessorInterface
|
||||
title: $data->title ?? '',
|
||||
description: $data->description,
|
||||
dueDate: $data->dueDate ?? '',
|
||||
acknowledgeWarning: $data->acknowledgeWarning ?? false,
|
||||
);
|
||||
|
||||
$homework = ($this->handler)($command);
|
||||
@@ -67,6 +71,12 @@ final readonly class CreateHomeworkProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
return HomeworkResource::fromDomain($homework);
|
||||
} catch (ReglesDevoirsNonRespecteesException $e) {
|
||||
return new JsonResponse([
|
||||
'type' => 'homework_rules_warning',
|
||||
'message' => $e->getMessage(),
|
||||
'warnings' => $e->warnings,
|
||||
], Response::HTTP_CONFLICT);
|
||||
} catch (EnseignantNonAffecteException|DateEcheanceInvalideException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
} catch (InvalidUuidStringException $e) {
|
||||
|
||||
@@ -80,6 +80,8 @@ final class HomeworkResource
|
||||
|
||||
public ?string $status = null;
|
||||
|
||||
public ?bool $acknowledgeWarning = null;
|
||||
|
||||
public ?string $className = null;
|
||||
|
||||
public ?string $subjectName = null;
|
||||
@@ -88,6 +90,8 @@ final class HomeworkResource
|
||||
|
||||
public ?DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public ?bool $hasRuleOverride = null;
|
||||
|
||||
public static function fromDomain(
|
||||
Homework $homework,
|
||||
?string $className = null,
|
||||
@@ -106,6 +110,7 @@ final class HomeworkResource
|
||||
$resource->subjectName = $subjectName;
|
||||
$resource->createdAt = $homework->createdAt;
|
||||
$resource->updatedAt = $homework->updatedAt;
|
||||
$resource->hasRuleOverride = $homework->ruleOverride !== null;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use function is_string;
|
||||
use function json_decode;
|
||||
use function json_encode;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineHomeworkRepository implements HomeworkRepository
|
||||
@@ -31,13 +38,14 @@ final readonly class DoctrineHomeworkRepository implements HomeworkRepository
|
||||
public function save(Homework $homework): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at)
|
||||
VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :title, :description, :due_date, :status, :created_at, :updated_at)
|
||||
'INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, rule_override, created_at, updated_at)
|
||||
VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :title, :description, :due_date, :status, :rule_override, :created_at, :updated_at)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
due_date = EXCLUDED.due_date,
|
||||
status = EXCLUDED.status,
|
||||
rule_override = EXCLUDED.rule_override,
|
||||
updated_at = EXCLUDED.updated_at',
|
||||
[
|
||||
'id' => (string) $homework->id,
|
||||
@@ -49,6 +57,7 @@ final readonly class DoctrineHomeworkRepository implements HomeworkRepository
|
||||
'description' => $homework->description,
|
||||
'due_date' => $homework->dueDate->format('Y-m-d'),
|
||||
'status' => $homework->status->value,
|
||||
'rule_override' => $homework->ruleOverride !== null ? json_encode($homework->ruleOverride, JSON_THROW_ON_ERROR) : null,
|
||||
'created_at' => $homework->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $homework->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
@@ -154,6 +163,13 @@ final readonly class DoctrineHomeworkRepository implements HomeworkRepository
|
||||
$createdAt = $row['created_at'];
|
||||
/** @var string $updatedAt */
|
||||
$updatedAt = $row['updated_at'];
|
||||
/** @var string|null $ruleOverrideJson */
|
||||
$ruleOverrideJson = $row['rule_override'] ?? null;
|
||||
|
||||
/** @var array{warnings: string[], acknowledgedAt: string}|null $ruleOverride */
|
||||
$ruleOverride = is_string($ruleOverrideJson)
|
||||
? json_decode($ruleOverrideJson, true, 512, JSON_THROW_ON_ERROR)
|
||||
: null;
|
||||
|
||||
return Homework::reconstitute(
|
||||
id: HomeworkId::fromString($id),
|
||||
@@ -167,6 +183,7 @@ final readonly class DoctrineHomeworkRepository implements HomeworkRepository
|
||||
status: HomeworkStatus::from($status),
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
updatedAt: new DateTimeImmutable($updatedAt),
|
||||
ruleOverride: $ruleOverride,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Administration\Application\Service\HomeworkRulesValidationResult;
|
||||
use App\Administration\Application\Service\HomeworkRulesValidator;
|
||||
use App\Administration\Application\Service\RuleViolation;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
|
||||
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
|
||||
use App\Administration\Domain\Repository\HomeworkRulesRepository;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
|
||||
use App\Scolarite\Application\Port\RuleWarning;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Adaptateur qui délègue la vérification des règles au bounded context Administration.
|
||||
*/
|
||||
final readonly class AdministrationHomeworkRulesChecker implements HomeworkRulesChecker
|
||||
{
|
||||
public function __construct(
|
||||
private HomeworkRulesRepository $rulesRepository,
|
||||
private HomeworkRulesValidator $validator,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function verifier(
|
||||
TenantId $tenantId,
|
||||
DateTimeImmutable $dueDate,
|
||||
DateTimeImmutable $creationDate,
|
||||
): HomeworkRulesCheckResult {
|
||||
$rules = $this->rulesRepository->findByTenantId($tenantId);
|
||||
|
||||
if ($rules === null) {
|
||||
return HomeworkRulesCheckResult::ok();
|
||||
}
|
||||
|
||||
$result = $this->validator->valider($rules, $dueDate, $creationDate);
|
||||
|
||||
return $this->toCheckResult($result, $rules);
|
||||
}
|
||||
|
||||
private function toCheckResult(HomeworkRulesValidationResult $result, HomeworkRules $rules): HomeworkRulesCheckResult
|
||||
{
|
||||
return new HomeworkRulesCheckResult(
|
||||
warnings: array_map(
|
||||
fn (RuleViolation $v): RuleWarning => new RuleWarning(
|
||||
ruleType: $v->ruleType->value,
|
||||
message: $v->message,
|
||||
params: $this->findRuleParams($rules, $v),
|
||||
),
|
||||
$result->violations,
|
||||
),
|
||||
bloquant: $result->estBloquant(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findRuleParams(HomeworkRules $rules, RuleViolation $violation): array
|
||||
{
|
||||
foreach ($rules->rules as $rule) {
|
||||
/** @var HomeworkRule $rule */
|
||||
if ($rule->type === $violation->ruleType) {
|
||||
return $rule->params;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,11 @@ use App\Scolarite\Domain\Repository\HomeworkRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* Tests for homework API endpoints.
|
||||
@@ -53,6 +57,7 @@ final class HomeworkEndpointsTest extends ApiTestCase
|
||||
/** @var Connection $connection */
|
||||
$connection = $container->get(Connection::class);
|
||||
$connection->executeStatement('DELETE FROM homework WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||
$connection->executeStatement('DELETE FROM homework_rules WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]);
|
||||
$connection->executeStatement('DELETE FROM school_classes WHERE id IN (:id1, :id2)', ['id1' => self::CLASS_ID, 'id2' => self::TARGET_CLASS_ID]);
|
||||
$connection->executeStatement('DELETE FROM subjects WHERE id = :id', ['id' => self::SUBJECT_ID]);
|
||||
$connection->executeStatement('DELETE FROM users WHERE id IN (:o, :t)', ['o' => self::OWNER_TEACHER_ID, 't' => self::OTHER_TEACHER_ID]);
|
||||
@@ -506,6 +511,118 @@ final class HomeworkEndpointsTest extends ApiTestCase
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5.4-FUNC-001 (P2) - GET /homework/{id} with rule override -> hasRuleOverride = true
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getHomeworkShowsHasRuleOverrideTrue(): void
|
||||
{
|
||||
$this->persistHomeworkWithRuleOverride(self::HOMEWORK_ID);
|
||||
|
||||
$client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']);
|
||||
|
||||
$client->request('GET', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID, [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertJsonContains(['hasRuleOverride' => true]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5.4-FUNC-002 (P2) - GET /homework/{id} without rule override -> hasRuleOverride = false
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function getHomeworkShowsHasRuleOverrideFalse(): void
|
||||
{
|
||||
$this->persistHomework(self::HOMEWORK_ID, HomeworkStatus::PUBLISHED);
|
||||
|
||||
$client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']);
|
||||
|
||||
$client->request('GET', 'http://ecole-alpha.classeo.local/api/homework/' . self::HOMEWORK_ID, [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertJsonContains(['hasRuleOverride' => false]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5.4-FUNC-003 (P1) - POST /homework with soft rules violated → 409 with warnings
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function createHomeworkReturns409WhenSoftRulesViolated(): void
|
||||
{
|
||||
$this->persistSoftRulesWithMinimumDelay(7);
|
||||
$this->seedTeacherAssignment();
|
||||
|
||||
$client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']);
|
||||
|
||||
// Due date tomorrow = violates 7-day minimum_delay
|
||||
$tomorrow = (new DateTimeImmutable('+1 weekday'))->format('Y-m-d');
|
||||
|
||||
$client->request('POST', 'http://ecole-alpha.classeo.local/api/homework', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'classId' => self::CLASS_ID,
|
||||
'subjectId' => self::SUBJECT_ID,
|
||||
'title' => 'Devoir test 409',
|
||||
'dueDate' => $tomorrow,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
$json = $client->getResponse()->toArray(false);
|
||||
self::assertSame('homework_rules_warning', $json['type']);
|
||||
self::assertNotEmpty($json['warnings']);
|
||||
self::assertSame('minimum_delay', $json['warnings'][0]['ruleType']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5.4-FUNC-004 (P1) - POST /homework with acknowledgeWarning → 201 created
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function createHomeworkReturns201WhenSoftRulesAcknowledged(): void
|
||||
{
|
||||
$this->persistSoftRulesWithMinimumDelay(7);
|
||||
$this->seedTeacherAssignment();
|
||||
|
||||
$client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']);
|
||||
|
||||
$tomorrow = (new DateTimeImmutable('+1 weekday'))->format('Y-m-d');
|
||||
|
||||
$client->request('POST', 'http://ecole-alpha.classeo.local/api/homework', [
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'classId' => self::CLASS_ID,
|
||||
'subjectId' => self::SUBJECT_ID,
|
||||
'title' => 'Devoir acknowledge',
|
||||
'dueDate' => $tomorrow,
|
||||
'acknowledgeWarning' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertJsonContains([
|
||||
'title' => 'Devoir acknowledge',
|
||||
'hasRuleOverride' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
@@ -527,6 +644,71 @@ final class HomeworkEndpointsTest extends ApiTestCase
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function persistSoftRulesWithMinimumDelay(int $days): void
|
||||
{
|
||||
/** @var Connection $connection */
|
||||
$connection = static::getContainer()->get(Connection::class);
|
||||
$rulesJson = json_encode([['type' => 'minimum_delay', 'params' => ['days' => $days]]], JSON_THROW_ON_ERROR);
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), :tid, :rules::jsonb, 'soft', true, NOW(), NOW())
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET rules = :rules::jsonb, enforcement_mode = 'soft', enabled = true, updated_at = NOW()",
|
||||
['tid' => self::TENANT_ID, 'rules' => $rulesJson],
|
||||
);
|
||||
}
|
||||
|
||||
private function seedTeacherAssignment(): void
|
||||
{
|
||||
/** @var Connection $connection */
|
||||
$connection = static::getContainer()->get(Connection::class);
|
||||
|
||||
// Compute the current academic year UUID the same way CurrentAcademicYearResolver does
|
||||
$month = (int) date('n');
|
||||
$year = (int) date('Y');
|
||||
$startYear = $month >= 9 ? $year : $year - 1;
|
||||
$academicYearId = Uuid::uuid5(
|
||||
'6ba7b814-9dad-11d1-80b4-00c04fd430c8',
|
||||
self::TENANT_ID . ':' . $startYear . '-' . ($startYear + 1),
|
||||
)->toString();
|
||||
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), :tid, :teacher, :class, :subject, :ayid, 'active', NOW(), NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING",
|
||||
[
|
||||
'tid' => self::TENANT_ID,
|
||||
'teacher' => self::OWNER_TEACHER_ID,
|
||||
'class' => self::CLASS_ID,
|
||||
'subject' => self::SUBJECT_ID,
|
||||
'ayid' => $academicYearId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function persistHomeworkWithRuleOverride(string $homeworkId): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
|
||||
$homework = Homework::reconstitute(
|
||||
id: HomeworkId::fromString($homeworkId),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString(self::OWNER_TEACHER_ID),
|
||||
title: 'Devoir existant',
|
||||
description: null,
|
||||
dueDate: new DateTimeImmutable('2026-06-15'),
|
||||
status: HomeworkStatus::PUBLISHED,
|
||||
createdAt: $now,
|
||||
updatedAt: $now,
|
||||
ruleOverride: ['warnings' => ['minimum_delay'], 'acknowledgedAt' => '2026-03-18T10:00:00+00:00'],
|
||||
);
|
||||
|
||||
/** @var HomeworkRepository $repository */
|
||||
$repository = static::getContainer()->get(HomeworkRepository::class);
|
||||
$repository->save($homework);
|
||||
}
|
||||
|
||||
private function persistHomework(string $homeworkId, HomeworkStatus $status): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
|
||||
@@ -13,8 +13,12 @@ use App\Scolarite\Application\Command\CreateHomework\CreateHomeworkCommand;
|
||||
use App\Scolarite\Application\Command\CreateHomework\CreateHomeworkHandler;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesChecker;
|
||||
use App\Scolarite\Application\Port\HomeworkRulesCheckResult;
|
||||
use App\Scolarite\Application\Port\RuleWarning;
|
||||
use App\Scolarite\Domain\Exception\DateEcheanceInvalideException;
|
||||
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
|
||||
use App\Scolarite\Domain\Exception\ReglesDevoirsNonRespecteesException;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkId;
|
||||
use App\Scolarite\Domain\Model\Homework\HomeworkStatus;
|
||||
use App\Scolarite\Domain\Service\DueDateValidator;
|
||||
@@ -106,7 +110,103 @@ final class CreateHomeworkHandlerTest extends TestCase
|
||||
self::assertNull($homework->description);
|
||||
}
|
||||
|
||||
private function createHandler(bool $affecte): CreateHomeworkHandler
|
||||
#[Test]
|
||||
public function itThrowsWhenSoftRulesViolatedAndNotAcknowledged(): void
|
||||
{
|
||||
$warning = new RuleWarning(
|
||||
ruleType: 'minimum_delay',
|
||||
message: 'Le devoir doit être créé au moins 3 jours avant.',
|
||||
params: ['days' => 3],
|
||||
);
|
||||
$rulesResult = new HomeworkRulesCheckResult(warnings: [$warning], bloquant: false);
|
||||
$handler = $this->createHandler(affecte: true, rulesResult: $rulesResult);
|
||||
|
||||
$this->expectException(ReglesDevoirsNonRespecteesException::class);
|
||||
|
||||
$handler($this->createCommand(acknowledgeWarning: false));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenHardRulesViolatedAndNotAcknowledged(): void
|
||||
{
|
||||
$warning = new RuleWarning(
|
||||
ruleType: 'minimum_delay',
|
||||
message: 'Le devoir doit être créé au moins 3 jours avant.',
|
||||
params: ['days' => 3],
|
||||
);
|
||||
$rulesResult = new HomeworkRulesCheckResult(warnings: [$warning], bloquant: true);
|
||||
$handler = $this->createHandler(affecte: true, rulesResult: $rulesResult);
|
||||
|
||||
$this->expectException(ReglesDevoirsNonRespecteesException::class);
|
||||
|
||||
$handler($this->createCommand(acknowledgeWarning: false));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenHardRulesViolatedEvenWhenAcknowledged(): void
|
||||
{
|
||||
$warning = new RuleWarning(
|
||||
ruleType: 'minimum_delay',
|
||||
message: 'Le devoir doit être créé au moins 3 jours avant.',
|
||||
params: ['days' => 3],
|
||||
);
|
||||
$rulesResult = new HomeworkRulesCheckResult(warnings: [$warning], bloquant: true);
|
||||
$handler = $this->createHandler(affecte: true, rulesResult: $rulesResult);
|
||||
|
||||
$this->expectException(ReglesDevoirsNonRespecteesException::class);
|
||||
|
||||
$handler($this->createCommand(acknowledgeWarning: true));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesHomeworkWhenSoftRulesViolatedButAcknowledged(): void
|
||||
{
|
||||
$warning = new RuleWarning(
|
||||
ruleType: 'minimum_delay',
|
||||
message: 'Le devoir doit être créé au moins 3 jours avant.',
|
||||
params: ['days' => 3],
|
||||
);
|
||||
$rulesResult = new HomeworkRulesCheckResult(warnings: [$warning], bloquant: false);
|
||||
$handler = $this->createHandler(affecte: true, rulesResult: $rulesResult);
|
||||
|
||||
$homework = $handler($this->createCommand(acknowledgeWarning: true));
|
||||
|
||||
self::assertNotEmpty((string) $homework->id);
|
||||
self::assertSame(HomeworkStatus::PUBLISHED, $homework->status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRecordsRuleOverrideWhenAcknowledged(): void
|
||||
{
|
||||
$warning = new RuleWarning(
|
||||
ruleType: 'minimum_delay',
|
||||
message: 'Le devoir doit être créé au moins 3 jours avant.',
|
||||
params: ['days' => 3],
|
||||
);
|
||||
$rulesResult = new HomeworkRulesCheckResult(warnings: [$warning], bloquant: false);
|
||||
$handler = $this->createHandler(affecte: true, rulesResult: $rulesResult);
|
||||
|
||||
$homework = $handler($this->createCommand(acknowledgeWarning: true));
|
||||
|
||||
self::assertNotNull($homework->ruleOverride);
|
||||
self::assertArrayHasKey('warnings', $homework->ruleOverride);
|
||||
self::assertArrayHasKey('acknowledgedAt', $homework->ruleOverride);
|
||||
self::assertSame(['minimum_delay'], $homework->ruleOverride['warnings']);
|
||||
self::assertSame('2026-03-12T10:00:00+00:00', $homework->ruleOverride['acknowledgedAt']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesHomeworkWithNoOverrideWhenRulesPass(): void
|
||||
{
|
||||
$rulesResult = HomeworkRulesCheckResult::ok();
|
||||
$handler = $this->createHandler(affecte: true, rulesResult: $rulesResult);
|
||||
|
||||
$homework = $handler($this->createCommand());
|
||||
|
||||
self::assertNull($homework->ruleOverride);
|
||||
}
|
||||
|
||||
private function createHandler(bool $affecte, ?HomeworkRulesCheckResult $rulesResult = null): CreateHomeworkHandler
|
||||
{
|
||||
$affectationChecker = new class($affecte) implements EnseignantAffectationChecker {
|
||||
public function __construct(private readonly bool $affecte)
|
||||
@@ -131,18 +231,31 @@ final class CreateHomeworkHandlerTest extends TestCase
|
||||
}
|
||||
};
|
||||
|
||||
$rulesChecker = new class($rulesResult ?? HomeworkRulesCheckResult::ok()) implements HomeworkRulesChecker {
|
||||
public function __construct(private readonly HomeworkRulesCheckResult $result)
|
||||
{
|
||||
}
|
||||
|
||||
public function verifier(TenantId $tenantId, DateTimeImmutable $dueDate, DateTimeImmutable $creationDate): HomeworkRulesCheckResult
|
||||
{
|
||||
return $this->result;
|
||||
}
|
||||
};
|
||||
|
||||
return new CreateHomeworkHandler(
|
||||
$this->homeworkRepository,
|
||||
$affectationChecker,
|
||||
$calendarProvider,
|
||||
new DueDateValidator(),
|
||||
$rulesChecker,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createCommand(
|
||||
?string $dueDate = null,
|
||||
mixed $description = 'Faire les exercices 1 à 10',
|
||||
?string $description = 'Faire les exercices 1 à 10',
|
||||
bool $acknowledgeWarning = false,
|
||||
): CreateHomeworkCommand {
|
||||
return new CreateHomeworkCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
@@ -152,6 +265,7 @@ final class CreateHomeworkHandlerTest extends TestCase
|
||||
title: 'Exercices chapitre 5',
|
||||
description: $description,
|
||||
dueDate: $dueDate ?? '2026-04-15',
|
||||
acknowledgeWarning: $acknowledgeWarning,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +207,42 @@ final class HomeworkTest extends TestCase
|
||||
self::assertEmpty($homework->pullDomainEvents());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function acknowledgeRuleWarningRecordsOverrideData(): void
|
||||
{
|
||||
$homework = $this->createHomework();
|
||||
|
||||
$now = new DateTimeImmutable('2026-03-18 10:00:00');
|
||||
$homework->acknowledgeRuleWarning(['minimum_delay', 'max_per_day'], $now);
|
||||
|
||||
self::assertNotNull($homework->ruleOverride);
|
||||
self::assertSame(['minimum_delay', 'max_per_day'], $homework->ruleOverride['warnings']);
|
||||
self::assertSame('2026-03-18T10:00:00+00:00', $homework->ruleOverride['acknowledgedAt']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reconstitutePreservesRuleOverride(): void
|
||||
{
|
||||
$overrideData = ['warnings' => ['minimum_delay'], 'acknowledgedAt' => '2026-03-18T10:00:00+00:00'];
|
||||
|
||||
$homework = Homework::reconstitute(
|
||||
id: HomeworkId::generate(),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
title: 'Exercices chapitre 5',
|
||||
description: 'Faire les exercices 1 à 10',
|
||||
dueDate: new DateTimeImmutable('2026-04-15'),
|
||||
status: HomeworkStatus::PUBLISHED,
|
||||
createdAt: new DateTimeImmutable('2026-03-12 10:00:00'),
|
||||
updatedAt: new DateTimeImmutable('2026-03-13 14:00:00'),
|
||||
ruleOverride: $overrideData,
|
||||
);
|
||||
|
||||
self::assertSame($overrideData, $homework->ruleOverride);
|
||||
}
|
||||
|
||||
private function createHomework(): Homework
|
||||
{
|
||||
return Homework::creer(
|
||||
|
||||
Reference in New Issue
Block a user