feat: Permettre à l'enseignant de créer et gérer ses évaluations
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 enseignants avaient besoin de définir les critères de notation
(barème, coefficient) avant de pouvoir saisir des notes. Sans cette
brique, le module Notes & Évaluations (Epic 6) ne pouvait pas démarrer.

L'évaluation est un agrégat du bounded context Scolarité avec deux
Value Objects (GradeScale 1-100, Coefficient 0.1-10). Le barème est
verrouillé dès qu'une note existe pour éviter les incohérences.
Un port EvaluationGradesChecker (stub pour l'instant) sera branché
sur le repository de notes dans la story 6.2.
This commit is contained in:
2026-03-23 23:56:37 +01:00
parent 8d950b0f3c
commit 93baeb1eaa
43 changed files with 4312 additions and 0 deletions

View File

@@ -242,6 +242,12 @@ services:
App\Scolarite\Application\Port\EnseignantAffectationChecker: App\Scolarite\Application\Port\EnseignantAffectationChecker:
alias: App\Scolarite\Infrastructure\Service\CurrentYearEnseignantAffectationChecker alias: App\Scolarite\Infrastructure\Service\CurrentYearEnseignantAffectationChecker
App\Scolarite\Domain\Repository\EvaluationRepository:
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineEvaluationRepository
App\Scolarite\Application\Port\EvaluationGradesChecker:
alias: App\Scolarite\Infrastructure\Service\NoGradesEvaluationGradesChecker
# Super Admin Repositories (Story 2.10 - Multi-établissements) # Super Admin Repositories (Story 2.10 - Multi-établissements)
App\SuperAdmin\Domain\Repository\SuperAdminRepository: App\SuperAdmin\Domain\Repository\SuperAdminRepository:
alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineSuperAdminRepository alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineSuperAdminRepository

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260323114411 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create evaluations table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE evaluations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
class_id UUID NOT NULL REFERENCES school_classes(id),
subject_id UUID NOT NULL REFERENCES subjects(id),
teacher_id UUID NOT NULL REFERENCES users(id),
title VARCHAR(255) NOT NULL,
description TEXT,
evaluation_date DATE NOT NULL,
grade_scale SMALLINT NOT NULL DEFAULT 20,
coefficient DECIMAL(3,1) NOT NULL DEFAULT 1.0,
status VARCHAR(20) NOT NULL DEFAULT \'published\',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)');
$this->addSql('CREATE INDEX idx_evaluations_tenant ON evaluations(tenant_id)');
$this->addSql('CREATE INDEX idx_evaluations_class ON evaluations(class_id)');
$this->addSql('CREATE INDEX idx_evaluations_teacher ON evaluations(teacher_id)');
$this->addSql('CREATE INDEX idx_evaluations_date ON evaluations(evaluation_date)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS evaluations');
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\CreateEvaluation;
final readonly class CreateEvaluationCommand
{
public function __construct(
public string $tenantId,
public string $classId,
public string $subjectId,
public string $teacherId,
public string $title,
public ?string $description,
public string $evaluationDate,
public int $gradeScale = 20,
public float $coefficient = 1.0,
) {
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\CreateEvaluation;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class CreateEvaluationHandler
{
public function __construct(
private EvaluationRepository $evaluationRepository,
private EnseignantAffectationChecker $affectationChecker,
private Clock $clock,
) {
}
public function __invoke(CreateEvaluationCommand $command): Evaluation
{
$tenantId = TenantId::fromString($command->tenantId);
$classId = ClassId::fromString($command->classId);
$subjectId = SubjectId::fromString($command->subjectId);
$teacherId = UserId::fromString($command->teacherId);
$now = $this->clock->now();
if (!$this->affectationChecker->estAffecte($teacherId, $classId, $subjectId, $tenantId)) {
throw EnseignantNonAffecteException::pourClasseEtMatiere($teacherId, $classId, $subjectId);
}
$evaluation = Evaluation::creer(
tenantId: $tenantId,
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
title: $command->title,
description: $command->description,
evaluationDate: new DateTimeImmutable($command->evaluationDate),
gradeScale: new GradeScale($command->gradeScale),
coefficient: new Coefficient($command->coefficient),
now: $now,
);
$this->evaluationRepository->save($evaluation);
return $evaluation;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\DeleteEvaluation;
final readonly class DeleteEvaluationCommand
{
public function __construct(
public string $tenantId,
public string $evaluationId,
public string $teacherId,
) {
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\DeleteEvaluation;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class DeleteEvaluationHandler
{
public function __construct(
private EvaluationRepository $evaluationRepository,
private Clock $clock,
) {
}
public function __invoke(DeleteEvaluationCommand $command): Evaluation
{
$tenantId = TenantId::fromString($command->tenantId);
$evaluationId = EvaluationId::fromString($command->evaluationId);
$evaluation = $this->evaluationRepository->get($evaluationId, $tenantId);
$teacherId = UserId::fromString($command->teacherId);
if ((string) $evaluation->teacherId !== (string) $teacherId) {
throw NonProprietaireDeLEvaluationException::withId($evaluationId);
}
$evaluation->supprimer($this->clock->now());
$this->evaluationRepository->save($evaluation);
return $evaluation;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\UpdateEvaluation;
final readonly class UpdateEvaluationCommand
{
public function __construct(
public string $tenantId,
public string $evaluationId,
public string $teacherId,
public string $title,
public ?string $description,
public string $evaluationDate,
public float $coefficient = 1.0,
public ?int $gradeScale = null,
) {
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Command\UpdateEvaluation;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\EvaluationGradesChecker;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UpdateEvaluationHandler
{
public function __construct(
private EvaluationRepository $evaluationRepository,
private EvaluationGradesChecker $gradesChecker,
private Clock $clock,
) {
}
public function __invoke(UpdateEvaluationCommand $command): Evaluation
{
$tenantId = TenantId::fromString($command->tenantId);
$evaluationId = EvaluationId::fromString($command->evaluationId);
$now = $this->clock->now();
$evaluation = $this->evaluationRepository->get($evaluationId, $tenantId);
$teacherId = UserId::fromString($command->teacherId);
if ((string) $evaluation->teacherId !== (string) $teacherId) {
throw NonProprietaireDeLEvaluationException::withId($evaluationId);
}
$hasGrades = $this->gradesChecker->hasGrades($evaluationId, $tenantId);
$evaluation->modifier(
title: $command->title,
description: $command->description,
coefficient: new Coefficient($command->coefficient),
evaluationDate: new DateTimeImmutable($command->evaluationDate),
gradeScale: $command->gradeScale !== null ? new GradeScale($command->gradeScale) : null,
hasGrades: $hasGrades,
now: $now,
);
$this->evaluationRepository->save($evaluation);
return $evaluation;
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Port;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Shared\Domain\Tenant\TenantId;
interface EvaluationGradesChecker
{
public function hasGrades(EvaluationId $evaluationId, TenantId $tenantId): bool;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class EvaluationCreee implements DomainEvent
{
public function __construct(
public EvaluationId $evaluationId,
public string $classId,
public string $subjectId,
public string $teacherId,
public string $title,
public DateTimeImmutable $evaluationDate,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->evaluationId->value;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class EvaluationModifiee implements DomainEvent
{
public function __construct(
public EvaluationId $evaluationId,
public string $title,
public DateTimeImmutable $evaluationDate,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->evaluationId->value;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class EvaluationSupprimee implements DomainEvent
{
public function __construct(
public EvaluationId $evaluationId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->evaluationId->value;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DomainException;
use function sprintf;
final class BaremeInvalideException extends DomainException
{
public static function avecValeur(int $maxValue): self
{
return new self(sprintf(
'Le barème doit être compris entre 1 et 100, %d donné.',
$maxValue,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use DomainException;
use function sprintf;
final class BaremeNonModifiableException extends DomainException
{
public static function carNotesExistantes(EvaluationId $id): self
{
return new self(sprintf(
'Le barème de l\'évaluation "%s" ne peut pas être modifié car des notes ont déjà été saisies.',
$id,
));
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DomainException;
use function sprintf;
final class CoefficientInvalideException extends DomainException
{
public static function avecValeur(float $value): self
{
return new self(sprintf(
'Le coefficient doit être compris entre 0.1 et 10, %s donné.',
$value,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use DomainException;
use function sprintf;
final class EvaluationDejaSupprimeeException extends DomainException
{
public static function withId(EvaluationId $id): self
{
return new self(sprintf(
'L\'évaluation avec l\'ID "%s" est déjà supprimée.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use DomainException;
use function sprintf;
final class EvaluationNotFoundException extends DomainException
{
public static function withId(EvaluationId $id): self
{
return new self(sprintf(
'L\'évaluation avec l\'ID "%s" n\'a pas été trouvée.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use DomainException;
use function sprintf;
final class NonProprietaireDeLEvaluationException extends DomainException
{
public static function withId(EvaluationId $id): self
{
return new self(sprintf(
'Vous n\'êtes pas le propriétaire de l\'évaluation "%s".',
$id,
));
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Evaluation;
use App\Scolarite\Domain\Exception\CoefficientInvalideException;
final readonly class Coefficient
{
public function __construct(
public float $value,
) {
if ($value < 0.1 || $value > 10) {
throw CoefficientInvalideException::avecValeur($value);
}
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Evaluation;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\EvaluationCreee;
use App\Scolarite\Domain\Event\EvaluationModifiee;
use App\Scolarite\Domain\Event\EvaluationSupprimee;
use App\Scolarite\Domain\Exception\BaremeNonModifiableException;
use App\Scolarite\Domain\Exception\EvaluationDejaSupprimeeException;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
final class Evaluation extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
private function __construct(
public private(set) EvaluationId $id,
public private(set) TenantId $tenantId,
public private(set) ClassId $classId,
public private(set) SubjectId $subjectId,
public private(set) UserId $teacherId,
public private(set) string $title,
public private(set) ?string $description,
public private(set) DateTimeImmutable $evaluationDate,
public private(set) GradeScale $gradeScale,
public private(set) Coefficient $coefficient,
public private(set) EvaluationStatus $status,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
}
public static function creer(
TenantId $tenantId,
ClassId $classId,
SubjectId $subjectId,
UserId $teacherId,
string $title,
?string $description,
DateTimeImmutable $evaluationDate,
GradeScale $gradeScale,
Coefficient $coefficient,
DateTimeImmutable $now,
): self {
$evaluation = new self(
id: EvaluationId::generate(),
tenantId: $tenantId,
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
title: $title,
description: $description,
evaluationDate: $evaluationDate,
gradeScale: $gradeScale,
coefficient: $coefficient,
status: EvaluationStatus::PUBLISHED,
createdAt: $now,
);
$evaluation->recordEvent(new EvaluationCreee(
evaluationId: $evaluation->id,
classId: (string) $classId,
subjectId: (string) $subjectId,
teacherId: (string) $teacherId,
title: $title,
evaluationDate: $evaluationDate,
occurredOn: $now,
));
return $evaluation;
}
public function modifier(
string $title,
?string $description,
Coefficient $coefficient,
DateTimeImmutable $evaluationDate,
?GradeScale $gradeScale,
bool $hasGrades,
DateTimeImmutable $now,
): void {
if ($this->status === EvaluationStatus::DELETED) {
throw EvaluationDejaSupprimeeException::withId($this->id);
}
if ($gradeScale !== null && !$this->gradeScale->equals($gradeScale) && $hasGrades) {
throw BaremeNonModifiableException::carNotesExistantes($this->id);
}
$this->title = $title;
$this->description = $description;
$this->coefficient = $coefficient;
$this->evaluationDate = $evaluationDate;
if ($gradeScale !== null && !$hasGrades) {
$this->gradeScale = $gradeScale;
}
$this->updatedAt = $now;
$this->recordEvent(new EvaluationModifiee(
evaluationId: $this->id,
title: $title,
evaluationDate: $evaluationDate,
occurredOn: $now,
));
}
public function supprimer(DateTimeImmutable $now): void
{
if ($this->status === EvaluationStatus::DELETED) {
throw EvaluationDejaSupprimeeException::withId($this->id);
}
$this->status = EvaluationStatus::DELETED;
$this->updatedAt = $now;
$this->recordEvent(new EvaluationSupprimee(
evaluationId: $this->id,
occurredOn: $now,
));
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
EvaluationId $id,
TenantId $tenantId,
ClassId $classId,
SubjectId $subjectId,
UserId $teacherId,
string $title,
?string $description,
DateTimeImmutable $evaluationDate,
GradeScale $gradeScale,
Coefficient $coefficient,
EvaluationStatus $status,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
): self {
$evaluation = new self(
id: $id,
tenantId: $tenantId,
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
title: $title,
description: $description,
evaluationDate: $evaluationDate,
gradeScale: $gradeScale,
coefficient: $coefficient,
status: $status,
createdAt: $createdAt,
);
$evaluation->updatedAt = $updatedAt;
return $evaluation;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Evaluation;
use App\Shared\Domain\EntityId;
final readonly class EvaluationId extends EntityId
{
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Evaluation;
enum EvaluationStatus: string
{
case PUBLISHED = 'published';
case DELETED = 'deleted';
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Evaluation;
use App\Scolarite\Domain\Exception\BaremeInvalideException;
use function round;
final readonly class GradeScale
{
public function __construct(
public int $maxValue,
) {
if ($maxValue < 1 || $maxValue > 100) {
throw BaremeInvalideException::avecValeur($maxValue);
}
}
public function convertTo20(float $grade): float
{
return round(($grade / $this->maxValue) * 20, 2);
}
public function equals(self $other): bool
{
return $this->maxValue === $other->maxValue;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\EvaluationNotFoundException;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Shared\Domain\Tenant\TenantId;
interface EvaluationRepository
{
public function save(Evaluation $evaluation): void;
/** @throws EvaluationNotFoundException */
public function get(EvaluationId $id, TenantId $tenantId): Evaluation;
public function findById(EvaluationId $id, TenantId $tenantId): ?Evaluation;
/** @return array<Evaluation> */
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array;
/** @return array<Evaluation> */
public function findByTeacherAndClass(UserId $teacherId, ClassId $classId, TenantId $tenantId): array;
/** @return array<Evaluation> */
public function findByClass(ClassId $classId, TenantId $tenantId): array;
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\CreateEvaluation\CreateEvaluationCommand;
use App\Scolarite\Application\Command\CreateEvaluation\CreateEvaluationHandler;
use App\Scolarite\Domain\Exception\BaremeInvalideException;
use App\Scolarite\Domain\Exception\CoefficientInvalideException;
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
use App\Scolarite\Infrastructure\Api\Resource\EvaluationResource;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @implements ProcessorInterface<EvaluationResource, EvaluationResource>
*/
final readonly class CreateEvaluationProcessor implements ProcessorInterface
{
public function __construct(
private CreateEvaluationHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private Security $security,
) {
}
/**
* @param EvaluationResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EvaluationResource
{
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.');
}
try {
$command = new CreateEvaluationCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
classId: $data->classId ?? '',
subjectId: $data->subjectId ?? '',
teacherId: $user->userId(),
title: $data->title ?? '',
description: $data->description,
evaluationDate: $data->evaluationDate ?? '',
gradeScale: $data->gradeScale ?? 20,
coefficient: $data->coefficient ?? 1.0,
);
$evaluation = ($this->handler)($command);
foreach ($evaluation->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return EvaluationResource::fromDomain($evaluation);
} catch (EnseignantNonAffecteException|BaremeInvalideException|CoefficientInvalideException $e) {
throw new BadRequestHttpException($e->getMessage());
} catch (InvalidUuidStringException $e) {
throw new BadRequestHttpException('UUID invalide : ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\DeleteEvaluation\DeleteEvaluationCommand;
use App\Scolarite\Application\Command\DeleteEvaluation\DeleteEvaluationHandler;
use App\Scolarite\Domain\Exception\EvaluationDejaSupprimeeException;
use App\Scolarite\Domain\Exception\EvaluationNotFoundException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Infrastructure\Api\Resource\EvaluationResource;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @implements ProcessorInterface<EvaluationResource, EvaluationResource>
*/
final readonly class DeleteEvaluationProcessor implements ProcessorInterface
{
public function __construct(
private DeleteEvaluationHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private Security $security,
) {
}
/**
* @param EvaluationResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EvaluationResource
{
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.');
}
/** @var string $id */
$id = $uriVariables['id'];
try {
$command = new DeleteEvaluationCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
evaluationId: $id,
teacherId: $user->userId(),
);
$evaluation = ($this->handler)($command);
foreach ($evaluation->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return EvaluationResource::fromDomain($evaluation);
} catch (EvaluationNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (NonProprietaireDeLEvaluationException $e) {
throw new AccessDeniedHttpException($e->getMessage());
} catch (EvaluationDejaSupprimeeException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Command\UpdateEvaluation\UpdateEvaluationCommand;
use App\Scolarite\Application\Command\UpdateEvaluation\UpdateEvaluationHandler;
use App\Scolarite\Domain\Exception\BaremeInvalideException;
use App\Scolarite\Domain\Exception\BaremeNonModifiableException;
use App\Scolarite\Domain\Exception\CoefficientInvalideException;
use App\Scolarite\Domain\Exception\EvaluationDejaSupprimeeException;
use App\Scolarite\Domain\Exception\EvaluationNotFoundException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Infrastructure\Api\Resource\EvaluationResource;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @implements ProcessorInterface<EvaluationResource, EvaluationResource>
*/
final readonly class UpdateEvaluationProcessor implements ProcessorInterface
{
public function __construct(
private UpdateEvaluationHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private Security $security,
) {
}
/**
* @param EvaluationResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EvaluationResource
{
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.');
}
/** @var string $id */
$id = $uriVariables['id'];
try {
$command = new UpdateEvaluationCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
evaluationId: $id,
teacherId: $user->userId(),
title: $data->title ?? '',
description: $data->description,
evaluationDate: $data->evaluationDate ?? '',
coefficient: $data->coefficient ?? 1.0,
gradeScale: $data->gradeScale,
);
$evaluation = ($this->handler)($command);
foreach ($evaluation->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return EvaluationResource::fromDomain($evaluation);
} catch (EvaluationNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
} catch (NonProprietaireDeLEvaluationException $e) {
throw new AccessDeniedHttpException($e->getMessage());
} catch (EvaluationDejaSupprimeeException|BaremeNonModifiableException|BaremeInvalideException|CoefficientInvalideException $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\EvaluationStatus;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Infrastructure\Api\Resource\EvaluationResource;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use function is_string;
use Override;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* @implements ProviderInterface<EvaluationResource>
*/
final readonly class EvaluationCollectionProvider implements ProviderInterface
{
public function __construct(
private Connection $connection,
private TenantContext $tenantContext,
private Security $security,
) {
}
/** @return array<EvaluationResource> */
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
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.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$teacherId = $user->userId();
/** @var array<string, mixed> $filters */
$filters = $context['filters'] ?? [];
$classIdFilter = $filters['classId'] ?? null;
$search = $filters['search'] ?? null;
$sql = 'SELECT e.*, c.name AS class_name, s.name AS subject_name
FROM evaluations e
LEFT JOIN school_classes c ON c.id = e.class_id
LEFT JOIN subjects s ON s.id = e.subject_id
WHERE e.teacher_id = :teacher_id
AND e.tenant_id = :tenant_id
AND e.status != :deleted';
$params = [
'teacher_id' => $teacherId,
'tenant_id' => $tenantId,
'deleted' => EvaluationStatus::DELETED->value,
];
if (is_string($classIdFilter) && $classIdFilter !== '') {
try {
ClassId::fromString($classIdFilter);
} catch (InvalidUuidStringException $e) {
throw new BadRequestHttpException('UUID de classe invalide : ' . $e->getMessage());
}
$sql .= ' AND e.class_id = :class_id';
$params['class_id'] = $classIdFilter;
}
if (is_string($search) && $search !== '') {
$sql .= ' AND e.title ILIKE :search';
$params['search'] = '%' . $search . '%';
}
$sql .= ' ORDER BY e.evaluation_date DESC';
$rows = $this->connection->fetchAllAssociative($sql, $params);
return array_map(static function (array $row): EvaluationResource {
/** @var string $className */
$className = $row['class_name'] ?? null;
/** @var string $subjectName */
$subjectName = $row['subject_name'] ?? null;
$evaluation = self::hydrateEvaluation($row);
return EvaluationResource::fromDomain($evaluation, $className, $subjectName);
}, $rows);
}
/** @param array<string, mixed> $row */
private static function hydrateEvaluation(array $row): Evaluation
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $classId */
$classId = $row['class_id'];
/** @var string $subjectId */
$subjectId = $row['subject_id'];
/** @var string $teacherId */
$teacherId = $row['teacher_id'];
/** @var string $title */
$title = $row['title'];
/** @var string|null $description */
$description = $row['description'];
/** @var string $evaluationDate */
$evaluationDate = $row['evaluation_date'];
/** @var string|int $gradeScaleRaw */
$gradeScaleRaw = $row['grade_scale'];
/** @var string|float $coefficientRaw */
$coefficientRaw = $row['coefficient'];
/** @var string $status */
$status = $row['status'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
return Evaluation::reconstitute(
id: EvaluationId::fromString($id),
tenantId: TenantId::fromString($tenantId),
classId: ClassId::fromString($classId),
subjectId: SubjectId::fromString($subjectId),
teacherId: UserId::fromString($teacherId),
title: $title,
description: $description,
evaluationDate: new DateTimeImmutable($evaluationDate),
gradeScale: new GradeScale((int) $gradeScaleRaw),
coefficient: new Coefficient((float) $coefficientRaw),
status: EvaluationStatus::from($status),
createdAt: new DateTimeImmutable($createdAt),
updatedAt: new DateTimeImmutable($updatedAt),
);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Scolarite\Infrastructure\Api\Resource\EvaluationResource;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* @implements ProviderInterface<EvaluationResource>
*/
final readonly class EvaluationItemProvider implements ProviderInterface
{
public function __construct(
private EvaluationRepository $evaluationRepository,
private TenantContext $tenantContext,
private Security $security,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EvaluationResource
{
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.');
}
/** @var string $id */
$id = $uriVariables['id'];
$evaluation = $this->evaluationRepository->findById(
EvaluationId::fromString($id),
$this->tenantContext->getCurrentTenantId(),
);
if ($evaluation === null) {
throw new NotFoundHttpException('Évaluation non trouvée.');
}
if ((string) $evaluation->teacherId !== $user->userId()) {
throw new AccessDeniedHttpException('Vous n\'êtes pas le propriétaire de cette évaluation.');
}
$class = $this->classRepository->findById($evaluation->classId);
$subject = $this->subjectRepository->findById($evaluation->subjectId);
return EvaluationResource::fromDomain(
$evaluation,
$class !== null ? (string) $class->name : null,
$subject !== null ? (string) $subject->name : null,
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Provider;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
final readonly class EvaluationWithNames
{
public function __construct(
public Evaluation $evaluation,
public ?string $className,
public ?string $subjectName,
) {
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Infrastructure\Api\Processor\CreateEvaluationProcessor;
use App\Scolarite\Infrastructure\Api\Processor\DeleteEvaluationProcessor;
use App\Scolarite\Infrastructure\Api\Processor\UpdateEvaluationProcessor;
use App\Scolarite\Infrastructure\Api\Provider\EvaluationCollectionProvider;
use App\Scolarite\Infrastructure\Api\Provider\EvaluationItemProvider;
use DateTimeImmutable;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'Evaluation',
operations: [
new GetCollection(
uriTemplate: '/evaluations',
provider: EvaluationCollectionProvider::class,
name: 'get_evaluation_list',
),
new Get(
uriTemplate: '/evaluations/{id}',
provider: EvaluationItemProvider::class,
name: 'get_evaluation',
),
new Post(
uriTemplate: '/evaluations',
processor: CreateEvaluationProcessor::class,
validationContext: ['groups' => ['Default', 'create']],
name: 'create_evaluation',
),
new Patch(
uriTemplate: '/evaluations/{id}',
provider: EvaluationItemProvider::class,
processor: UpdateEvaluationProcessor::class,
validationContext: ['groups' => ['Default', 'update']],
name: 'update_evaluation',
),
new Delete(
uriTemplate: '/evaluations/{id}',
provider: EvaluationItemProvider::class,
processor: DeleteEvaluationProcessor::class,
name: 'delete_evaluation',
),
],
)]
final class EvaluationResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
#[Assert\NotBlank(message: 'La classe est requise.', groups: ['create'])]
#[Assert\Uuid(message: 'L\'identifiant de la classe doit être un UUID valide.', groups: ['create'])]
public ?string $classId = null;
#[Assert\NotBlank(message: 'La matière est requise.', groups: ['create'])]
#[Assert\Uuid(message: 'L\'identifiant de la matière doit être un UUID valide.', groups: ['create'])]
public ?string $subjectId = null;
public ?string $teacherId = null;
#[Assert\NotBlank(message: 'Le titre est requis.', groups: ['create', 'update'])]
#[Assert\Length(max: 255, maxMessage: 'Le titre ne peut pas dépasser 255 caractères.')]
public ?string $title = null;
public ?string $description = null;
#[Assert\NotBlank(message: 'La date d\'évaluation est requise.', groups: ['create', 'update'])]
public ?string $evaluationDate = null;
#[Assert\Range(min: 1, max: 100, notInRangeMessage: 'Le barème doit être compris entre 1 et 100.')]
public ?int $gradeScale = null;
#[Assert\Range(min: 0.1, max: 10, notInRangeMessage: 'Le coefficient doit être compris entre 0.1 et 10.')]
public ?float $coefficient = null;
public ?string $status = null;
public ?string $className = null;
public ?string $subjectName = null;
public ?DateTimeImmutable $createdAt = null;
public ?DateTimeImmutable $updatedAt = null;
public static function fromDomain(
Evaluation $evaluation,
?string $className = null,
?string $subjectName = null,
): self {
$resource = new self();
$resource->id = (string) $evaluation->id;
$resource->classId = (string) $evaluation->classId;
$resource->subjectId = (string) $evaluation->subjectId;
$resource->teacherId = (string) $evaluation->teacherId;
$resource->title = $evaluation->title;
$resource->description = $evaluation->description;
$resource->evaluationDate = $evaluation->evaluationDate->format('Y-m-d');
$resource->gradeScale = $evaluation->gradeScale->maxValue;
$resource->coefficient = $evaluation->coefficient->value;
$resource->status = $evaluation->status->value;
$resource->className = $className;
$resource->subjectName = $subjectName;
$resource->createdAt = $evaluation->createdAt;
$resource->updatedAt = $evaluation->updatedAt;
return $resource;
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\EvaluationNotFoundException;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\EvaluationStatus;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Override;
final readonly class DoctrineEvaluationRepository implements EvaluationRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(Evaluation $evaluation): void
{
$this->connection->executeStatement(
'INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, description, evaluation_date, grade_scale, coefficient, status, created_at, updated_at)
VALUES (:id, :tenant_id, :class_id, :subject_id, :teacher_id, :title, :description, :evaluation_date, :grade_scale, :coefficient, :status, :created_at, :updated_at)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
evaluation_date = EXCLUDED.evaluation_date,
grade_scale = EXCLUDED.grade_scale,
coefficient = EXCLUDED.coefficient,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at',
[
'id' => (string) $evaluation->id,
'tenant_id' => (string) $evaluation->tenantId,
'class_id' => (string) $evaluation->classId,
'subject_id' => (string) $evaluation->subjectId,
'teacher_id' => (string) $evaluation->teacherId,
'title' => $evaluation->title,
'description' => $evaluation->description,
'evaluation_date' => $evaluation->evaluationDate->format('Y-m-d'),
'grade_scale' => $evaluation->gradeScale->maxValue,
'coefficient' => $evaluation->coefficient->value,
'status' => $evaluation->status->value,
'created_at' => $evaluation->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $evaluation->updatedAt->format(DateTimeImmutable::ATOM),
],
);
}
#[Override]
public function get(EvaluationId $id, TenantId $tenantId): Evaluation
{
$evaluation = $this->findById($id, $tenantId);
if ($evaluation === null) {
throw EvaluationNotFoundException::withId($id);
}
return $evaluation;
}
#[Override]
public function findById(EvaluationId $id, TenantId $tenantId): ?Evaluation
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM evaluations WHERE id = :id AND tenant_id = :tenant_id',
['id' => (string) $id, 'tenant_id' => (string) $tenantId],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
#[Override]
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT e.*, c.name AS class_name, s.name AS subject_name
FROM evaluations e
LEFT JOIN school_classes c ON c.id = e.class_id
LEFT JOIN subjects s ON s.id = e.subject_id
WHERE e.teacher_id = :teacher_id
AND e.tenant_id = :tenant_id
AND e.status != :deleted
ORDER BY e.evaluation_date DESC',
[
'teacher_id' => (string) $teacherId,
'tenant_id' => (string) $tenantId,
'deleted' => EvaluationStatus::DELETED->value,
],
);
return array_map($this->hydrate(...), $rows);
}
#[Override]
public function findByTeacherAndClass(UserId $teacherId, ClassId $classId, TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT e.*, c.name AS class_name, s.name AS subject_name
FROM evaluations e
LEFT JOIN school_classes c ON c.id = e.class_id
LEFT JOIN subjects s ON s.id = e.subject_id
WHERE e.teacher_id = :teacher_id
AND e.class_id = :class_id
AND e.tenant_id = :tenant_id
AND e.status != :deleted
ORDER BY e.evaluation_date DESC',
[
'teacher_id' => (string) $teacherId,
'class_id' => (string) $classId,
'tenant_id' => (string) $tenantId,
'deleted' => EvaluationStatus::DELETED->value,
],
);
return array_map($this->hydrate(...), $rows);
}
#[Override]
public function findByClass(ClassId $classId, TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT e.*, c.name AS class_name, s.name AS subject_name
FROM evaluations e
LEFT JOIN school_classes c ON c.id = e.class_id
LEFT JOIN subjects s ON s.id = e.subject_id
WHERE e.class_id = :class_id
AND e.tenant_id = :tenant_id
AND e.status != :deleted
ORDER BY e.evaluation_date DESC',
[
'class_id' => (string) $classId,
'tenant_id' => (string) $tenantId,
'deleted' => EvaluationStatus::DELETED->value,
],
);
return array_map($this->hydrate(...), $rows);
}
/** @param array<string, mixed> $row */
private function hydrate(array $row): Evaluation
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $classId */
$classId = $row['class_id'];
/** @var string $subjectId */
$subjectId = $row['subject_id'];
/** @var string $teacherId */
$teacherId = $row['teacher_id'];
/** @var string $title */
$title = $row['title'];
/** @var string|null $description */
$description = $row['description'];
/** @var string $evaluationDate */
$evaluationDate = $row['evaluation_date'];
/** @var string|int $gradeScaleRaw */
$gradeScaleRaw = $row['grade_scale'];
$gradeScale = (int) $gradeScaleRaw;
/** @var string|float $coefficientRaw */
$coefficientRaw = $row['coefficient'];
$coefficient = (float) $coefficientRaw;
/** @var string $status */
$status = $row['status'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
return Evaluation::reconstitute(
id: EvaluationId::fromString($id),
tenantId: TenantId::fromString($tenantId),
classId: ClassId::fromString($classId),
subjectId: SubjectId::fromString($subjectId),
teacherId: UserId::fromString($teacherId),
title: $title,
description: $description,
evaluationDate: new DateTimeImmutable($evaluationDate),
gradeScale: new GradeScale($gradeScale),
coefficient: new Coefficient($coefficient),
status: EvaluationStatus::from($status),
createdAt: new DateTimeImmutable($createdAt),
updatedAt: new DateTimeImmutable($updatedAt),
);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Exception\EvaluationNotFoundException;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\EvaluationStatus;
use App\Scolarite\Domain\Repository\EvaluationRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_values;
use Override;
final class InMemoryEvaluationRepository implements EvaluationRepository
{
/** @var array<string, Evaluation> */
private array $byId = [];
#[Override]
public function save(Evaluation $evaluation): void
{
$this->byId[(string) $evaluation->id] = $evaluation;
}
#[Override]
public function get(EvaluationId $id, TenantId $tenantId): Evaluation
{
$evaluation = $this->findById($id, $tenantId);
if ($evaluation === null) {
throw EvaluationNotFoundException::withId($id);
}
return $evaluation;
}
#[Override]
public function findById(EvaluationId $id, TenantId $tenantId): ?Evaluation
{
$evaluation = $this->byId[(string) $id] ?? null;
if ($evaluation === null || !$evaluation->tenantId->equals($tenantId)) {
return null;
}
return $evaluation;
}
#[Override]
public function findByTeacher(UserId $teacherId, TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (Evaluation $e): bool => $e->teacherId->equals($teacherId)
&& $e->tenantId->equals($tenantId)
&& $e->status !== EvaluationStatus::DELETED,
));
}
#[Override]
public function findByClass(ClassId $classId, TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (Evaluation $e): bool => $e->classId->equals($classId)
&& $e->tenantId->equals($tenantId)
&& $e->status !== EvaluationStatus::DELETED,
));
}
#[Override]
public function findByTeacherAndClass(UserId $teacherId, ClassId $classId, TenantId $tenantId): array
{
return array_values(array_filter(
$this->byId,
static fn (Evaluation $e): bool => $e->teacherId->equals($teacherId)
&& $e->classId->equals($classId)
&& $e->tenantId->equals($tenantId)
&& $e->status !== EvaluationStatus::DELETED,
));
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Service;
use App\Scolarite\Application\Port\EvaluationGradesChecker;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final readonly class NoGradesEvaluationGradesChecker implements EvaluationGradesChecker
{
#[Override]
public function hasGrades(EvaluationId $evaluationId, TenantId $tenantId): bool
{
return false;
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\CreateEvaluation;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\CreateEvaluation\CreateEvaluationCommand;
use App\Scolarite\Application\Command\CreateEvaluation\CreateEvaluationHandler;
use App\Scolarite\Application\Port\EnseignantAffectationChecker;
use App\Scolarite\Domain\Exception\BaremeInvalideException;
use App\Scolarite\Domain\Exception\CoefficientInvalideException;
use App\Scolarite\Domain\Exception\EnseignantNonAffecteException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\EvaluationStatus;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CreateEvaluationHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryEvaluationRepository $evaluationRepository;
private Clock $clock;
protected function setUp(): void
{
$this->evaluationRepository = new InMemoryEvaluationRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-12 10:00:00');
}
};
}
#[Test]
public function itCreatesEvaluationSuccessfully(): void
{
$handler = $this->createHandler(affecte: true);
$command = $this->createCommand();
$evaluation = $handler($command);
self::assertNotEmpty((string) $evaluation->id);
self::assertSame(EvaluationStatus::PUBLISHED, $evaluation->status);
self::assertSame('Contrôle chapitre 5', $evaluation->title);
self::assertSame(20, $evaluation->gradeScale->maxValue);
self::assertSame(1.0, $evaluation->coefficient->value);
}
#[Test]
public function itPersistsEvaluationInRepository(): void
{
$handler = $this->createHandler(affecte: true);
$command = $this->createCommand();
$created = $handler($command);
$evaluation = $this->evaluationRepository->get(
EvaluationId::fromString((string) $created->id),
TenantId::fromString(self::TENANT_ID),
);
self::assertSame('Contrôle chapitre 5', $evaluation->title);
}
#[Test]
public function itThrowsWhenTeacherNotAffected(): void
{
$handler = $this->createHandler(affecte: false);
$this->expectException(EnseignantNonAffecteException::class);
$handler($this->createCommand());
}
#[Test]
public function itCreatesEvaluationWithCustomGradeScale(): void
{
$handler = $this->createHandler(affecte: true);
$command = $this->createCommand(gradeScale: 10);
$evaluation = $handler($command);
self::assertSame(10, $evaluation->gradeScale->maxValue);
}
#[Test]
public function itCreatesEvaluationWithCustomCoefficient(): void
{
$handler = $this->createHandler(affecte: true);
$command = $this->createCommand(coefficient: 2.5);
$evaluation = $handler($command);
self::assertSame(2.5, $evaluation->coefficient->value);
}
#[Test]
public function itThrowsWhenGradeScaleIsInvalid(): void
{
$handler = $this->createHandler(affecte: true);
$this->expectException(BaremeInvalideException::class);
$handler($this->createCommand(gradeScale: 0));
}
#[Test]
public function itThrowsWhenCoefficientIsInvalid(): void
{
$handler = $this->createHandler(affecte: true);
$this->expectException(CoefficientInvalideException::class);
$handler($this->createCommand(coefficient: 0.0));
}
#[Test]
public function itAllowsNullDescription(): void
{
$handler = $this->createHandler(affecte: true);
$command = $this->createCommand(description: null);
$evaluation = $handler($command);
self::assertNull($evaluation->description);
}
private function createHandler(bool $affecte): CreateEvaluationHandler
{
$affectationChecker = new class($affecte) implements EnseignantAffectationChecker {
public function __construct(private readonly bool $affecte)
{
}
public function estAffecte(UserId $teacherId, ClassId $classId, SubjectId $subjectId, TenantId $tenantId): bool
{
return $this->affecte;
}
};
return new CreateEvaluationHandler(
$this->evaluationRepository,
$affectationChecker,
$this->clock,
);
}
private function createCommand(
?string $description = 'Évaluation sur les fonctions',
int $gradeScale = 20,
float $coefficient = 1.0,
): CreateEvaluationCommand {
return new CreateEvaluationCommand(
tenantId: self::TENANT_ID,
classId: self::CLASS_ID,
subjectId: self::SUBJECT_ID,
teacherId: self::TEACHER_ID,
title: 'Contrôle chapitre 5',
description: $description,
evaluationDate: '2026-04-15',
gradeScale: $gradeScale,
coefficient: $coefficient,
);
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\DeleteEvaluation;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\DeleteEvaluation\DeleteEvaluationCommand;
use App\Scolarite\Application\Command\DeleteEvaluation\DeleteEvaluationHandler;
use App\Scolarite\Domain\Exception\EvaluationDejaSupprimeeException;
use App\Scolarite\Domain\Exception\EvaluationNotFoundException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationStatus;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class DeleteEvaluationHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string OTHER_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440099';
private InMemoryEvaluationRepository $evaluationRepository;
private Clock $clock;
protected function setUp(): void
{
$this->evaluationRepository = new InMemoryEvaluationRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-14 08:00:00');
}
};
}
#[Test]
public function itSoftDeletesEvaluation(): void
{
$evaluation = $this->createAndSaveEvaluation();
$handler = $this->createHandler();
$result = $handler(new DeleteEvaluationCommand(
tenantId: self::TENANT_ID,
evaluationId: (string) $evaluation->id,
teacherId: self::TEACHER_ID,
));
self::assertSame(EvaluationStatus::DELETED, $result->status);
}
#[Test]
public function itThrowsWhenTeacherIsNotOwner(): void
{
$evaluation = $this->createAndSaveEvaluation();
$handler = $this->createHandler();
$this->expectException(NonProprietaireDeLEvaluationException::class);
$handler(new DeleteEvaluationCommand(
tenantId: self::TENANT_ID,
evaluationId: (string) $evaluation->id,
teacherId: self::OTHER_TEACHER_ID,
));
}
#[Test]
public function itThrowsWhenEvaluationNotFound(): void
{
$handler = $this->createHandler();
$this->expectException(EvaluationNotFoundException::class);
$handler(new DeleteEvaluationCommand(
tenantId: self::TENANT_ID,
evaluationId: '550e8400-e29b-41d4-a716-446655449999',
teacherId: self::TEACHER_ID,
));
}
#[Test]
public function itThrowsWhenAlreadyDeleted(): void
{
$evaluation = $this->createAndSaveEvaluation();
$evaluation->supprimer(new DateTimeImmutable('2026-03-13'));
$this->evaluationRepository->save($evaluation);
$handler = $this->createHandler();
$this->expectException(EvaluationDejaSupprimeeException::class);
$handler(new DeleteEvaluationCommand(
tenantId: self::TENANT_ID,
evaluationId: (string) $evaluation->id,
teacherId: self::TEACHER_ID,
));
}
private function createHandler(): DeleteEvaluationHandler
{
return new DeleteEvaluationHandler(
$this->evaluationRepository,
$this->clock,
);
}
private function createAndSaveEvaluation(): Evaluation
{
$evaluation = Evaluation::creer(
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: 'Contrôle chapitre 5',
description: 'Évaluation sur les fonctions',
evaluationDate: new DateTimeImmutable('2026-04-15'),
gradeScale: new GradeScale(20),
coefficient: new Coefficient(1.0),
now: new DateTimeImmutable('2026-03-12 10:00:00'),
);
$this->evaluationRepository->save($evaluation);
return $evaluation;
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Command\UpdateEvaluation;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Command\UpdateEvaluation\UpdateEvaluationCommand;
use App\Scolarite\Application\Command\UpdateEvaluation\UpdateEvaluationHandler;
use App\Scolarite\Application\Port\EvaluationGradesChecker;
use App\Scolarite\Domain\Exception\BaremeNonModifiableException;
use App\Scolarite\Domain\Exception\EvaluationDejaSupprimeeException;
use App\Scolarite\Domain\Exception\EvaluationNotFoundException;
use App\Scolarite\Domain\Exception\NonProprietaireDeLEvaluationException;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryEvaluationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class UpdateEvaluationHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private const string OTHER_TEACHER_ID = '550e8400-e29b-41d4-a716-446655440099';
private InMemoryEvaluationRepository $evaluationRepository;
private Clock $clock;
protected function setUp(): void
{
$this->evaluationRepository = new InMemoryEvaluationRepository();
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-03-13 14:00:00');
}
};
}
#[Test]
public function itUpdatesEvaluationSuccessfully(): void
{
$evaluation = $this->createAndSaveEvaluation();
$handler = $this->createHandler(hasGrades: false);
$command = new UpdateEvaluationCommand(
tenantId: self::TENANT_ID,
evaluationId: (string) $evaluation->id,
teacherId: self::TEACHER_ID,
title: 'Titre modifié',
description: 'Nouvelle description',
evaluationDate: '2026-04-20',
coefficient: 2.0,
);
$updated = $handler($command);
self::assertSame('Titre modifié', $updated->title);
self::assertSame('Nouvelle description', $updated->description);
self::assertSame(2.0, $updated->coefficient->value);
}
#[Test]
public function itAllowsGradeScaleChangeWhenNoGrades(): void
{
$evaluation = $this->createAndSaveEvaluation();
$handler = $this->createHandler(hasGrades: false);
$command = new UpdateEvaluationCommand(
tenantId: self::TENANT_ID,
evaluationId: (string) $evaluation->id,
teacherId: self::TEACHER_ID,
title: $evaluation->title,
description: $evaluation->description,
evaluationDate: '2026-04-15',
gradeScale: 10,
);
$updated = $handler($command);
self::assertSame(10, $updated->gradeScale->maxValue);
}
#[Test]
public function itBlocksGradeScaleChangeWhenGradesExist(): void
{
$evaluation = $this->createAndSaveEvaluation();
$handler = $this->createHandler(hasGrades: true);
$this->expectException(BaremeNonModifiableException::class);
$handler(new UpdateEvaluationCommand(
tenantId: self::TENANT_ID,
evaluationId: (string) $evaluation->id,
teacherId: self::TEACHER_ID,
title: $evaluation->title,
description: $evaluation->description,
evaluationDate: '2026-04-15',
gradeScale: 10,
));
}
#[Test]
public function itThrowsWhenTeacherIsNotOwner(): void
{
$evaluation = $this->createAndSaveEvaluation();
$handler = $this->createHandler(hasGrades: false);
$this->expectException(NonProprietaireDeLEvaluationException::class);
$handler(new UpdateEvaluationCommand(
tenantId: self::TENANT_ID,
evaluationId: (string) $evaluation->id,
teacherId: self::OTHER_TEACHER_ID,
title: 'Titre',
description: null,
evaluationDate: '2026-04-15',
));
}
#[Test]
public function itThrowsWhenEvaluationNotFound(): void
{
$handler = $this->createHandler(hasGrades: false);
$this->expectException(EvaluationNotFoundException::class);
$handler(new UpdateEvaluationCommand(
tenantId: self::TENANT_ID,
evaluationId: '550e8400-e29b-41d4-a716-446655449999',
teacherId: self::TEACHER_ID,
title: 'Titre',
description: null,
evaluationDate: '2026-04-15',
));
}
#[Test]
public function itThrowsWhenEvaluationIsDeleted(): void
{
$evaluation = $this->createAndSaveEvaluation();
$evaluation->supprimer(new DateTimeImmutable('2026-03-13'));
$this->evaluationRepository->save($evaluation);
$handler = $this->createHandler(hasGrades: false);
$this->expectException(EvaluationDejaSupprimeeException::class);
$handler(new UpdateEvaluationCommand(
tenantId: self::TENANT_ID,
evaluationId: (string) $evaluation->id,
teacherId: self::TEACHER_ID,
title: 'Titre',
description: null,
evaluationDate: '2026-04-15',
));
}
private function createHandler(bool $hasGrades): UpdateEvaluationHandler
{
$gradesChecker = new class($hasGrades) implements EvaluationGradesChecker {
public function __construct(private readonly bool $hasGrades)
{
}
public function hasGrades(EvaluationId $evaluationId, TenantId $tenantId): bool
{
return $this->hasGrades;
}
};
return new UpdateEvaluationHandler(
$this->evaluationRepository,
$gradesChecker,
$this->clock,
);
}
private function createAndSaveEvaluation(): Evaluation
{
$evaluation = Evaluation::creer(
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: 'Contrôle chapitre 5',
description: 'Évaluation sur les fonctions',
evaluationDate: new DateTimeImmutable('2026-04-15'),
gradeScale: new GradeScale(20),
coefficient: new Coefficient(1.0),
now: new DateTimeImmutable('2026-03-12 10:00:00'),
);
$this->evaluationRepository->save($evaluation);
return $evaluation;
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\Evaluation;
use App\Scolarite\Domain\Exception\CoefficientInvalideException;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CoefficientTest extends TestCase
{
#[Test]
public function acceptsValidValues(): void
{
self::assertSame(0.1, (new Coefficient(0.1))->value);
self::assertSame(0.5, (new Coefficient(0.5))->value);
self::assertSame(1.0, (new Coefficient(1.0))->value);
self::assertSame(1.5, (new Coefficient(1.5))->value);
self::assertSame(2.0, (new Coefficient(2.0))->value);
self::assertSame(10.0, (new Coefficient(10.0))->value);
}
#[Test]
public function rejectsTooSmall(): void
{
$this->expectException(CoefficientInvalideException::class);
new Coefficient(0.0);
}
#[Test]
public function rejectsTooLarge(): void
{
$this->expectException(CoefficientInvalideException::class);
new Coefficient(10.1);
}
#[Test]
public function rejectsNegative(): void
{
$this->expectException(CoefficientInvalideException::class);
new Coefficient(-1.0);
}
#[Test]
public function equalsComparesValue(): void
{
$a = new Coefficient(1.5);
$b = new Coefficient(1.5);
$c = new Coefficient(2.0);
self::assertTrue($a->equals($b));
self::assertFalse($a->equals($c));
}
}

View File

@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\Evaluation;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\EvaluationCreee;
use App\Scolarite\Domain\Event\EvaluationModifiee;
use App\Scolarite\Domain\Event\EvaluationSupprimee;
use App\Scolarite\Domain\Exception\BaremeNonModifiableException;
use App\Scolarite\Domain\Exception\EvaluationDejaSupprimeeException;
use App\Scolarite\Domain\Model\Evaluation\Coefficient;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\EvaluationStatus;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class EvaluationTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
#[Test]
public function creerCreatesPublishedEvaluation(): void
{
$evaluation = $this->createEvaluation();
self::assertSame(EvaluationStatus::PUBLISHED, $evaluation->status);
}
#[Test]
public function creerRecordsEvaluationCreeeEvent(): void
{
$evaluation = $this->createEvaluation();
$events = $evaluation->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(EvaluationCreee::class, $events[0]);
self::assertSame($evaluation->id, $events[0]->evaluationId);
}
#[Test]
public function creerSetsAllProperties(): void
{
$tenantId = TenantId::fromString(self::TENANT_ID);
$classId = ClassId::fromString(self::CLASS_ID);
$subjectId = SubjectId::fromString(self::SUBJECT_ID);
$teacherId = UserId::fromString(self::TEACHER_ID);
$evaluationDate = new DateTimeImmutable('2026-04-15');
$now = new DateTimeImmutable('2026-03-12 10:00:00');
$gradeScale = new GradeScale(20);
$coefficient = new Coefficient(1.5);
$evaluation = Evaluation::creer(
tenantId: $tenantId,
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
title: 'Contrôle chapitre 5',
description: 'Évaluation sur les fonctions',
evaluationDate: $evaluationDate,
gradeScale: $gradeScale,
coefficient: $coefficient,
now: $now,
);
self::assertTrue($evaluation->tenantId->equals($tenantId));
self::assertTrue($evaluation->classId->equals($classId));
self::assertTrue($evaluation->subjectId->equals($subjectId));
self::assertTrue($evaluation->teacherId->equals($teacherId));
self::assertSame('Contrôle chapitre 5', $evaluation->title);
self::assertSame('Évaluation sur les fonctions', $evaluation->description);
self::assertEquals($evaluationDate, $evaluation->evaluationDate);
self::assertSame(20, $evaluation->gradeScale->maxValue);
self::assertSame(1.5, $evaluation->coefficient->value);
self::assertSame(EvaluationStatus::PUBLISHED, $evaluation->status);
self::assertEquals($now, $evaluation->createdAt);
self::assertEquals($now, $evaluation->updatedAt);
}
#[Test]
public function creerAllowsNullDescription(): void
{
$evaluation = Evaluation::creer(
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: 'Évaluation sans description',
description: null,
evaluationDate: new DateTimeImmutable('2026-04-15'),
gradeScale: new GradeScale(20),
coefficient: new Coefficient(1.0),
now: new DateTimeImmutable('2026-03-12 10:00:00'),
);
self::assertNull($evaluation->description);
}
#[Test]
public function modifierUpdatesFieldsAndRecordsEvent(): void
{
$evaluation = $this->createEvaluation();
$evaluation->pullDomainEvents();
$modifiedAt = new DateTimeImmutable('2026-03-13 14:00:00');
$newDate = new DateTimeImmutable('2026-04-20');
$newCoefficient = new Coefficient(2.0);
$evaluation->modifier(
title: 'Titre modifié',
description: 'Nouvelle description',
coefficient: $newCoefficient,
evaluationDate: $newDate,
gradeScale: null,
hasGrades: false,
now: $modifiedAt,
);
self::assertSame('Titre modifié', $evaluation->title);
self::assertSame('Nouvelle description', $evaluation->description);
self::assertSame(2.0, $evaluation->coefficient->value);
self::assertEquals($newDate, $evaluation->evaluationDate);
self::assertEquals($modifiedAt, $evaluation->updatedAt);
$events = $evaluation->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(EvaluationModifiee::class, $events[0]);
self::assertSame($evaluation->id, $events[0]->evaluationId);
}
#[Test]
public function modifierAllowsGradeScaleChangeWhenNoGrades(): void
{
$evaluation = $this->createEvaluation();
$evaluation->pullDomainEvents();
$newGradeScale = new GradeScale(10);
$evaluation->modifier(
title: $evaluation->title,
description: $evaluation->description,
coefficient: $evaluation->coefficient,
evaluationDate: $evaluation->evaluationDate,
gradeScale: $newGradeScale,
hasGrades: false,
now: new DateTimeImmutable('2026-03-13 14:00:00'),
);
self::assertSame(10, $evaluation->gradeScale->maxValue);
}
#[Test]
public function modifierBlocksGradeScaleChangeWhenGradesExist(): void
{
$evaluation = $this->createEvaluation();
$this->expectException(BaremeNonModifiableException::class);
$evaluation->modifier(
title: $evaluation->title,
description: $evaluation->description,
coefficient: $evaluation->coefficient,
evaluationDate: $evaluation->evaluationDate,
gradeScale: new GradeScale(10),
hasGrades: true,
now: new DateTimeImmutable('2026-03-13 14:00:00'),
);
}
#[Test]
public function modifierThrowsWhenDeleted(): void
{
$evaluation = $this->createEvaluation();
$evaluation->supprimer(new DateTimeImmutable('2026-03-13'));
$this->expectException(EvaluationDejaSupprimeeException::class);
$evaluation->modifier(
title: 'Titre',
description: null,
coefficient: new Coefficient(1.0),
evaluationDate: new DateTimeImmutable('2026-04-20'),
gradeScale: null,
hasGrades: false,
now: new DateTimeImmutable('2026-03-14'),
);
}
#[Test]
public function supprimerChangesStatusAndRecordsEvent(): void
{
$evaluation = $this->createEvaluation();
$evaluation->pullDomainEvents();
$deletedAt = new DateTimeImmutable('2026-03-14 08:00:00');
$evaluation->supprimer($deletedAt);
self::assertSame(EvaluationStatus::DELETED, $evaluation->status);
self::assertEquals($deletedAt, $evaluation->updatedAt);
$events = $evaluation->pullDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(EvaluationSupprimee::class, $events[0]);
self::assertSame($evaluation->id, $events[0]->evaluationId);
}
#[Test]
public function supprimerThrowsWhenAlreadyDeleted(): void
{
$evaluation = $this->createEvaluation();
$evaluation->supprimer(new DateTimeImmutable('2026-03-14'));
$this->expectException(EvaluationDejaSupprimeeException::class);
$evaluation->supprimer(new DateTimeImmutable('2026-03-15'));
}
#[Test]
public function reconstituteRestoresAllPropertiesWithoutEvents(): void
{
$id = EvaluationId::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);
$evaluationDate = new DateTimeImmutable('2026-04-15');
$createdAt = new DateTimeImmutable('2026-03-12 10:00:00');
$updatedAt = new DateTimeImmutable('2026-03-13 14:00:00');
$gradeScale = new GradeScale(20);
$coefficient = new Coefficient(1.5);
$evaluation = Evaluation::reconstitute(
id: $id,
tenantId: $tenantId,
classId: $classId,
subjectId: $subjectId,
teacherId: $teacherId,
title: 'Contrôle chapitre 5',
description: 'Évaluation sur les fonctions',
evaluationDate: $evaluationDate,
gradeScale: $gradeScale,
coefficient: $coefficient,
status: EvaluationStatus::PUBLISHED,
createdAt: $createdAt,
updatedAt: $updatedAt,
);
self::assertTrue($evaluation->id->equals($id));
self::assertTrue($evaluation->tenantId->equals($tenantId));
self::assertTrue($evaluation->classId->equals($classId));
self::assertTrue($evaluation->subjectId->equals($subjectId));
self::assertTrue($evaluation->teacherId->equals($teacherId));
self::assertSame('Contrôle chapitre 5', $evaluation->title);
self::assertSame('Évaluation sur les fonctions', $evaluation->description);
self::assertEquals($evaluationDate, $evaluation->evaluationDate);
self::assertSame(20, $evaluation->gradeScale->maxValue);
self::assertSame(1.5, $evaluation->coefficient->value);
self::assertSame(EvaluationStatus::PUBLISHED, $evaluation->status);
self::assertEquals($createdAt, $evaluation->createdAt);
self::assertEquals($updatedAt, $evaluation->updatedAt);
self::assertEmpty($evaluation->pullDomainEvents());
}
private function createEvaluation(): Evaluation
{
return Evaluation::creer(
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: 'Contrôle chapitre 5',
description: 'Évaluation sur les fonctions',
evaluationDate: new DateTimeImmutable('2026-04-15'),
gradeScale: new GradeScale(20),
coefficient: new Coefficient(1.0),
now: new DateTimeImmutable('2026-03-12 10:00:00'),
);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Domain\Model\Evaluation;
use App\Scolarite\Domain\Exception\BaremeInvalideException;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GradeScaleTest extends TestCase
{
#[Test]
public function acceptsValidValues(): void
{
self::assertSame(1, (new GradeScale(1))->maxValue);
self::assertSame(10, (new GradeScale(10))->maxValue);
self::assertSame(20, (new GradeScale(20))->maxValue);
self::assertSame(100, (new GradeScale(100))->maxValue);
}
#[Test]
public function rejectsZero(): void
{
$this->expectException(BaremeInvalideException::class);
new GradeScale(0);
}
#[Test]
public function rejectsNegative(): void
{
$this->expectException(BaremeInvalideException::class);
new GradeScale(-1);
}
#[Test]
public function rejectsAbove100(): void
{
$this->expectException(BaremeInvalideException::class);
new GradeScale(101);
}
#[Test]
public function convertTo20ConvertsCorrectly(): void
{
$scale10 = new GradeScale(10);
self::assertSame(14.0, $scale10->convertTo20(7));
$scale20 = new GradeScale(20);
self::assertSame(15.0, $scale20->convertTo20(15));
$scale100 = new GradeScale(100);
self::assertSame(17.0, $scale100->convertTo20(85));
}
#[Test]
public function equalsComparesMaxValue(): void
{
$a = new GradeScale(20);
$b = new GradeScale(20);
$c = new GradeScale(10);
self::assertTrue($a->equals($b));
self::assertFalse($a->equals($c));
}
}

View File

@@ -0,0 +1,481 @@
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 TEACHER_EMAIL = 'e2e-eval-teacher@example.com';
const TEACHER_PASSWORD = 'EvalTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
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
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function navigateToEvaluations(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/dashboard/teacher/evaluations`);
await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible({ timeout: 15000 });
}
async function selectClassAndSubject(page: import('@playwright/test').Page) {
const classSelect = page.locator('#ev-class');
await expect(classSelect).toBeVisible();
await classSelect.selectOption({ index: 1 });
// Wait for subject options to appear after class selection
const subjectSelect = page.locator('#ev-subject');
await expect(subjectSelect).toBeEnabled({ timeout: 5000 });
await expect(subjectSelect.locator('option')).not.toHaveCount(1, { timeout: 10000 });
await subjectSelect.selectOption({ index: 1 });
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// Table may not exist
}
}
test.describe('Evaluation Management (Story 6.1)', () => {
test.beforeAll(async () => {
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
// Ensure classes and subject exist
const { schoolId, academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-EVAL-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-EVAL-Maths', 'E2EVALM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
test.beforeEach(async () => {
// Clean up evaluation data
try {
runSql(`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
} catch {
// Table may not exist
}
const { schoolId, academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-EVAL-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-EVAL-Maths', 'E2EVALM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
// ============================================================================
// Navigation
// ============================================================================
test.describe('Navigation', () => {
test('evaluations link appears in teacher navigation', async ({ page }) => {
await loginAsTeacher(page);
const nav = page.locator('.desktop-nav');
await expect(nav.getByRole('link', { name: /évaluations/i })).toBeVisible({ timeout: 15000 });
});
test('can navigate to evaluations page', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
await expect(page.getByRole('heading', { name: /mes évaluations/i })).toBeVisible();
});
});
// ============================================================================
// Empty State
// ============================================================================
test.describe('Empty State', () => {
test('shows empty state when no evaluations exist', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
await expect(page.getByText(/aucune évaluation/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC1-AC5: Create Evaluation
// ============================================================================
test.describe('AC1-AC5: Create Evaluation', () => {
test('can create a new evaluation with default grade scale and coefficient', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
// Open create modal
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Select class and subject
await selectClassAndSubject(page);
// Fill title
await page.locator('#ev-title').fill('Contrôle chapitre 5');
// Fill date
await page.locator('#ev-date').fill('2026-06-15');
// Submit
await page.getByRole('button', { name: 'Créer', exact: true }).click();
// Wait for modal to close (creation succeeded)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 });
// Verify evaluation appears in list
await expect(page.getByText('Contrôle chapitre 5')).toBeVisible({ timeout: 10000 });
// Verify default grade scale and coefficient badges
await expect(page.getByText('/20')).toBeVisible();
await expect(page.getByText('x1')).toBeVisible();
});
test('can create evaluation with custom grade scale and coefficient', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Select class and subject
await selectClassAndSubject(page);
// Fill form
await page.locator('#ev-title').fill('QCM rapide');
await page.locator('#ev-date').fill('2026-06-20');
// Set custom grade scale /10
await page.locator('#ev-scale').fill('10');
// Set coefficient to 0.5
await page.locator('#ev-coeff').fill('0.5');
// Submit
await page.getByRole('button', { name: 'Créer', exact: true }).click();
// Verify evaluation with custom values
await expect(page.getByText('QCM rapide')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('/10')).toBeVisible();
await expect(page.getByText('x0.5')).toBeVisible();
});
});
// ============================================================================
// AC6: Edit Evaluation
// ============================================================================
test.describe('AC6: Edit Evaluation', () => {
test('can modify title, description, and coefficient', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
// Create an evaluation first
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await selectClassAndSubject(page);
await page.locator('#ev-title').fill('Évaluation originale');
await page.locator('#ev-date').fill('2026-06-15');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByText('Évaluation originale')).toBeVisible({ timeout: 10000 });
// Open edit modal
await page.getByRole('button', { name: /modifier/i }).first().click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
// Modify title
await page.locator('#edit-title').fill('Évaluation modifiée');
// Modify coefficient
await page.locator('#edit-coeff').fill('2');
// Submit
await page.getByRole('button', { name: /enregistrer/i }).click();
// Verify changes
await expect(page.getByText('Évaluation modifiée')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('x2')).toBeVisible();
});
});
// ============================================================================
// Delete Evaluation
// ============================================================================
test.describe('Delete Evaluation', () => {
test('can delete an evaluation', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
// Create an evaluation first
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await selectClassAndSubject(page);
await page.locator('#ev-title').fill('Évaluation à supprimer');
await page.locator('#ev-date').fill('2026-06-15');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByText('Évaluation à supprimer')).toBeVisible({ timeout: 10000 });
// Open delete modal
await page.getByRole('button', { name: /supprimer/i }).first().click();
await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 10000 });
// Confirm deletion
await page.getByRole('alertdialog').getByRole('button', { name: /supprimer/i }).click();
// Verify evaluation is removed
await expect(page.getByText(/aucune évaluation/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// T1: Search evaluations by title (P2)
// ============================================================================
test.describe('Search evaluations', () => {
test('filters evaluations when searching by title', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
// Create first evaluation: "Contrôle géométrie"
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await selectClassAndSubject(page);
await page.locator('#ev-title').fill('Contrôle géométrie');
await page.locator('#ev-date').fill('2026-06-15');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 });
await expect(page.getByText('Contrôle géométrie')).toBeVisible({ timeout: 10000 });
// Create second evaluation: "QCM algèbre"
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await selectClassAndSubject(page);
await page.locator('#ev-title').fill('QCM algèbre');
await page.locator('#ev-date').fill('2026-06-20');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 });
await expect(page.getByText('QCM algèbre')).toBeVisible({ timeout: 10000 });
// Both evaluations should be visible
await expect(page.getByText('Contrôle géométrie')).toBeVisible();
await expect(page.getByText('QCM algèbre')).toBeVisible();
// Search for "géométrie"
const searchInput = page.getByRole('searchbox', { name: /rechercher par titre/i });
await searchInput.fill('géométrie');
// Wait for debounced search to trigger and results to update
await expect(page.getByText('Contrôle géométrie')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('QCM algèbre')).not.toBeVisible({ timeout: 10000 });
// Clear search and verify both reappear
await page.getByRole('button', { name: /effacer la recherche/i }).click();
await expect(page.getByText('Contrôle géométrie')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('QCM algèbre')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// T2: Filter evaluations by class (P2)
// ============================================================================
test.describe('Filter by class', () => {
test('class filter dropdown filters the evaluation list', async ({ page }) => {
// Seed a second class and assignment for this test
const { schoolId, academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-EVAL-5B', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-EVAL-5B' AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
clearCache();
await loginAsTeacher(page);
await navigateToEvaluations(page);
// Create evaluation in first class (E2E-EVAL-6A)
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const classSelect = page.locator('#ev-class');
await expect(classSelect).toBeVisible();
await classSelect.selectOption({ label: 'E2E-EVAL-6A' });
const subjectSelect = page.locator('#ev-subject');
await expect(subjectSelect).toBeEnabled({ timeout: 5000 });
await expect(subjectSelect.locator('option')).not.toHaveCount(1, { timeout: 10000 });
await subjectSelect.selectOption({ index: 1 });
await page.locator('#ev-title').fill('Eval classe 6A');
await page.locator('#ev-date').fill('2026-06-15');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 });
await expect(page.getByText('Eval classe 6A')).toBeVisible({ timeout: 10000 });
// Create evaluation in second class (E2E-EVAL-5B)
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
const classSelect2 = page.locator('#ev-class');
await classSelect2.selectOption({ label: 'E2E-EVAL-5B' });
const subjectSelect2 = page.locator('#ev-subject');
await expect(subjectSelect2).toBeEnabled({ timeout: 5000 });
await expect(subjectSelect2.locator('option')).not.toHaveCount(1, { timeout: 10000 });
await subjectSelect2.selectOption({ index: 1 });
await page.locator('#ev-title').fill('Eval classe 5B');
await page.locator('#ev-date').fill('2026-06-20');
await page.getByRole('button', { name: 'Créer', exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15000 });
await expect(page.getByText('Eval classe 5B')).toBeVisible({ timeout: 10000 });
// Both evaluations visible initially
await expect(page.getByText('Eval classe 6A')).toBeVisible();
await expect(page.getByText('Eval classe 5B')).toBeVisible();
// Filter by E2E-EVAL-6A
const filterSelect = page.getByRole('combobox', { name: /filtrer par classe/i });
await expect(filterSelect).toBeVisible();
await filterSelect.selectOption({ label: 'E2E-EVAL-6A' });
// Wait for filtered results
await expect(page.getByText('Eval classe 6A')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Eval classe 5B')).not.toBeVisible({ timeout: 10000 });
// Reset filter to "Toutes les classes"
await filterSelect.selectOption({ label: 'Toutes les classes' });
// Both should reappear
await expect(page.getByText('Eval classe 6A')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Eval classe 5B')).toBeVisible({ timeout: 10000 });
// Cleanup: remove the second class data
try {
runSql(`DELETE FROM evaluations WHERE teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM school_classes WHERE name = 'E2E-EVAL-5B' AND tenant_id = '${TENANT_ID}'`);
} catch {
// Cleanup is best-effort
}
});
});
// ============================================================================
// T3: Grade scale equivalence preview (P2)
// ============================================================================
test.describe('Grade scale preview', () => {
test('shows equivalence preview when barème is not 20', async ({ page }) => {
await loginAsTeacher(page);
await navigateToEvaluations(page);
// Open create modal
await page.getByRole('button', { name: /nouvelle évaluation/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Default barème is 20 - no preview should appear
const scaleInput = dialog.locator('#ev-scale');
await expect(scaleInput).toHaveValue('20');
const previewHint = dialog.locator('#ev-scale ~ .form-hint');
await expect(previewHint).not.toBeVisible();
// Change barème to 10
await scaleInput.fill('10');
// Verify equivalence preview appears: (10/10 = 20.0/20)
await expect(previewHint).toBeVisible({ timeout: 5000 });
await expect(previewHint).toHaveText('(10/10 = 20.0/20)');
// Change barème to 5 -> (10/5 = 40.0/20)
await scaleInput.fill('5');
await expect(previewHint).toHaveText('(10/5 = 40.0/20)');
// Change back to 20 -> preview should disappear
await scaleInput.fill('20');
await expect(previewHint).not.toBeVisible({ timeout: 5000 });
});
});
});

View File

@@ -105,6 +105,7 @@
<a href="/dashboard" class="nav-link" class:active={pathname === '/dashboard'}>Tableau de bord</a> <a href="/dashboard" class="nav-link" class:active={pathname === '/dashboard'}>Tableau de bord</a>
{#if isProf} {#if isProf}
<a href="/dashboard/teacher/homework" class="nav-link" class:active={pathname === '/dashboard/teacher/homework'}>Devoirs</a> <a href="/dashboard/teacher/homework" class="nav-link" class:active={pathname === '/dashboard/teacher/homework'}>Devoirs</a>
<a href="/dashboard/teacher/evaluations" class="nav-link" class:active={pathname === '/dashboard/teacher/evaluations'}>Évaluations</a>
{/if} {/if}
{#if isEleve} {#if isEleve}
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a> <a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
@@ -154,6 +155,9 @@
<a href="/dashboard/teacher/homework" class="mobile-nav-link" class:active={pathname === '/dashboard/teacher/homework'}> <a href="/dashboard/teacher/homework" class="mobile-nav-link" class:active={pathname === '/dashboard/teacher/homework'}>
Devoirs Devoirs
</a> </a>
<a href="/dashboard/teacher/evaluations" class="mobile-nav-link" class:active={pathname === '/dashboard/teacher/evaluations'}>
Évaluations
</a>
{/if} {/if}
{#if isEleve} {#if isEleve}
<a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}> <a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}>

File diff suppressed because it is too large Load Diff