feat: Permettre la création manuelle d'élèves et leur affectation aux classes
Les administrateurs et secrétaires avaient besoin de pouvoir inscrire un élève en cours d'année sans passer par un import CSV. Cette fonctionnalité pose aussi les fondations du modèle élève↔classe (ClassAssignment) qui sera réutilisé par l'import CSV en masse (Story 3.1). L'email est désormais optionnel pour les élèves : si fourni, une invitation est envoyée (User::inviter) ; sinon l'élève est créé avec le statut INSCRIT sans accès compte (User::inscrire). La création de l'utilisateur et l'affectation à la classe sont atomiques (transaction DBAL). Côté frontend, la page /admin/students offre liste paginée, recherche, filtrage par classe, création via modale (avec détection de doublons côté serveur), et changement de classe avec optimistic update.
This commit is contained in:
@@ -158,6 +158,10 @@ services:
|
||||
App\Administration\Domain\Repository\GradingConfigurationRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineGradingConfigurationRepository
|
||||
|
||||
# Class Assignment (Story 3.0 - Affectation élèves aux classes)
|
||||
App\Administration\Domain\Repository\ClassAssignmentRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineClassAssignmentRepository
|
||||
|
||||
# Teacher Assignment (Story 2.8 - Affectation enseignants)
|
||||
App\Administration\Domain\Repository\TeacherAssignmentRepository:
|
||||
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineTeacherAssignmentRepository
|
||||
|
||||
49
backend/migrations/Version20260221093719.php
Normal file
49
backend/migrations/Version20260221093719.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260221093719 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create class_assignments table and extend users for student enrollment';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE class_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
school_class_id UUID NOT NULL,
|
||||
academic_year_id UUID NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, academic_year_id),
|
||||
CONSTRAINT fk_class_assignments_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
CONSTRAINT fk_class_assignments_class FOREIGN KEY (school_class_id) REFERENCES school_classes(id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_class_assignments_class ON class_assignments(school_class_id)');
|
||||
$this->addSql('CREATE INDEX idx_class_assignments_tenant ON class_assignments(tenant_id)');
|
||||
|
||||
$this->addSql('ALTER TABLE users ALTER COLUMN email DROP NOT NULL');
|
||||
$this->addSql('ALTER TABLE users ADD COLUMN student_number VARCHAR(11)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE class_assignments');
|
||||
$this->addSql('ALTER TABLE users DROP COLUMN student_number');
|
||||
$this->addSql("UPDATE users SET email = 'removed-' || id || '@placeholder.local' WHERE email IS NULL");
|
||||
$this->addSql('ALTER TABLE users ALTER COLUMN email SET NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\AssignStudentToClass;
|
||||
|
||||
final readonly class AssignStudentToClassCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $studentId,
|
||||
public string $classId,
|
||||
public string $academicYearId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\AssignStudentToClass;
|
||||
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Exception\EleveDejaAffecteException;
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class AssignStudentToClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private UserRepository $userRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(AssignStudentToClassCommand $command): ClassAssignment
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$studentId = UserId::fromString($command->studentId);
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
|
||||
// Valider l'existence des entités référencées et leur tenant
|
||||
$student = $this->userRepository->get($studentId);
|
||||
|
||||
if (!$student->tenantId->equals($tenantId)) {
|
||||
throw UserNotFoundException::withId($studentId);
|
||||
}
|
||||
|
||||
$class = $this->classRepository->get($classId);
|
||||
|
||||
if (!$class->tenantId->equals($tenantId)) {
|
||||
throw ClasseNotFoundException::withId($classId);
|
||||
}
|
||||
|
||||
if (!$class->status->peutRecevoirEleves()) {
|
||||
throw ClasseNotFoundException::withId($classId);
|
||||
}
|
||||
|
||||
// Vérifier qu'il n'y a pas déjà une affectation pour cette année scolaire
|
||||
$existing = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
|
||||
|
||||
if ($existing !== null) {
|
||||
throw EleveDejaAffecteException::pourAnneeScolaire($studentId);
|
||||
}
|
||||
|
||||
$assignment = ClassAssignment::affecter(
|
||||
tenantId: $tenantId,
|
||||
studentId: $studentId,
|
||||
classId: $classId,
|
||||
academicYearId: $academicYearId,
|
||||
assignedAt: $this->clock->now(),
|
||||
);
|
||||
|
||||
$this->classAssignmentRepository->save($assignment);
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ChangeStudentClass;
|
||||
|
||||
final readonly class ChangeStudentClassCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $studentId,
|
||||
public string $newClassId,
|
||||
public string $academicYearId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ChangeStudentClass;
|
||||
|
||||
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ChangeStudentClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ChangeStudentClassCommand $command): ClassAssignment
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$studentId = UserId::fromString($command->studentId);
|
||||
$newClassId = ClassId::fromString($command->newClassId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
|
||||
// Valider l'existence de la nouvelle classe, son tenant et son statut
|
||||
$class = $this->classRepository->get($newClassId);
|
||||
|
||||
if (!$class->tenantId->equals($tenantId)) {
|
||||
throw ClasseNotFoundException::withId($newClassId);
|
||||
}
|
||||
|
||||
if (!$class->status->peutRecevoirEleves()) {
|
||||
throw ClasseNotFoundException::withId($newClassId);
|
||||
}
|
||||
|
||||
// Trouver l'affectation existante
|
||||
$assignment = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
|
||||
|
||||
if ($assignment === null) {
|
||||
throw AffectationEleveNonTrouveeException::pourEleve($studentId);
|
||||
}
|
||||
|
||||
$assignment->changerClasse($newClassId, $this->clock->now());
|
||||
|
||||
$this->classAssignmentRepository->save($assignment);
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\CreateStudent;
|
||||
|
||||
final readonly class CreateStudentCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolName,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public string $classId,
|
||||
public string $academicYearId,
|
||||
public ?string $email = null,
|
||||
public ?string $dateNaissance = null,
|
||||
public ?string $studentNumber = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\CreateStudent;
|
||||
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Throwable;
|
||||
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class CreateStudentHandler
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository,
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private Connection $connection,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(CreateStudentCommand $command): User
|
||||
{
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$classId = ClassId::fromString($command->classId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
|
||||
// Valider l'existence de la classe, son tenant et son statut
|
||||
$class = $this->classRepository->get($classId);
|
||||
|
||||
if (!$class->tenantId->equals($tenantId)) {
|
||||
throw ClasseNotFoundException::withId($classId);
|
||||
}
|
||||
|
||||
if (!$class->status->peutRecevoirEleves()) {
|
||||
throw ClasseNotFoundException::withId($classId);
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
|
||||
// Vérifier l'unicité de l'email si fourni
|
||||
if ($command->email !== null) {
|
||||
$email = new Email($command->email);
|
||||
$existingUser = $this->userRepository->findByEmail($email, $tenantId);
|
||||
|
||||
if ($existingUser !== null) {
|
||||
throw EmailDejaUtiliseeException::dansTenant($email, $tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
// Créer l'utilisateur
|
||||
$user = $command->email !== null
|
||||
? User::inviter(
|
||||
email: new Email($command->email),
|
||||
role: Role::ELEVE,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $command->firstName,
|
||||
lastName: $command->lastName,
|
||||
invitedAt: $now,
|
||||
dateNaissance: $command->dateNaissance !== null
|
||||
? new DateTimeImmutable($command->dateNaissance)
|
||||
: null,
|
||||
studentNumber: $command->studentNumber,
|
||||
)
|
||||
: User::inscrire(
|
||||
role: Role::ELEVE,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $command->firstName,
|
||||
lastName: $command->lastName,
|
||||
inscritAt: $now,
|
||||
dateNaissance: $command->dateNaissance !== null
|
||||
? new DateTimeImmutable($command->dateNaissance)
|
||||
: null,
|
||||
studentNumber: $command->studentNumber,
|
||||
);
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
// Affecter à la classe
|
||||
$assignment = ClassAssignment::affecter(
|
||||
tenantId: $tenantId,
|
||||
studentId: $user->id,
|
||||
classId: $classId,
|
||||
academicYearId: $academicYearId,
|
||||
assignedAt: $now,
|
||||
);
|
||||
|
||||
$this->classAssignmentRepository->save($assignment);
|
||||
|
||||
$this->connection->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetStudentsWithClass;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetStudentsWithClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PaginatedResult<StudentWithClassDto>
|
||||
*/
|
||||
public function __invoke(GetStudentsWithClassQuery $query): PaginatedResult
|
||||
{
|
||||
$params = [
|
||||
'tenant_id' => $query->tenantId,
|
||||
'academic_year_id' => $query->academicYearId,
|
||||
'role' => json_encode([Role::ELEVE->value]),
|
||||
];
|
||||
|
||||
$whereClause = 'u.tenant_id = :tenant_id AND u.roles::jsonb @> :role::jsonb';
|
||||
|
||||
if ($query->classId !== null) {
|
||||
$whereClause .= ' AND ca.school_class_id = :class_id';
|
||||
$params['class_id'] = $query->classId;
|
||||
}
|
||||
|
||||
if ($query->search !== null && $query->search !== '') {
|
||||
$whereClause .= ' AND (LOWER(u.last_name) LIKE :search OR LOWER(u.first_name) LIKE :search)';
|
||||
$params['search'] = '%' . mb_strtolower($query->search) . '%';
|
||||
}
|
||||
|
||||
// Count total
|
||||
$countSql = <<<SQL
|
||||
SELECT COUNT(*)
|
||||
FROM users u
|
||||
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
|
||||
WHERE {$whereClause}
|
||||
SQL;
|
||||
|
||||
/** @var int|string|false $totalRaw */
|
||||
$totalRaw = $this->connection->fetchOne($countSql, $params);
|
||||
$total = (int) $totalRaw;
|
||||
|
||||
// Fetch paginated results
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
|
||||
$selectSql = <<<SQL
|
||||
SELECT
|
||||
u.id,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email,
|
||||
u.statut,
|
||||
u.student_number,
|
||||
u.date_naissance,
|
||||
ca.school_class_id AS class_id,
|
||||
sc.name AS class_name,
|
||||
sc.level AS class_level
|
||||
FROM users u
|
||||
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
|
||||
LEFT JOIN school_classes sc ON sc.id = ca.school_class_id
|
||||
WHERE {$whereClause}
|
||||
ORDER BY u.last_name ASC, u.first_name ASC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SQL;
|
||||
|
||||
$params['limit'] = $query->limit;
|
||||
$params['offset'] = $offset;
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative($selectSql, $params);
|
||||
|
||||
$items = array_map(static function (array $row): StudentWithClassDto {
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $firstName */
|
||||
$firstName = $row['first_name'];
|
||||
/** @var string $lastName */
|
||||
$lastName = $row['last_name'];
|
||||
/** @var string|null $email */
|
||||
$email = $row['email'];
|
||||
/** @var string $statut */
|
||||
$statut = $row['statut'];
|
||||
/** @var string|null $studentNumber */
|
||||
$studentNumber = $row['student_number'];
|
||||
/** @var string|null $dateNaissance */
|
||||
$dateNaissance = $row['date_naissance'];
|
||||
/** @var string|null $classId */
|
||||
$classId = $row['class_id'];
|
||||
/** @var string|null $className */
|
||||
$className = $row['class_name'];
|
||||
/** @var string|null $classLevel */
|
||||
$classLevel = $row['class_level'];
|
||||
|
||||
return new StudentWithClassDto(
|
||||
id: $id,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
email: $email,
|
||||
statut: $statut,
|
||||
studentNumber: $studentNumber,
|
||||
dateNaissance: $dateNaissance,
|
||||
classId: $classId,
|
||||
className: $className,
|
||||
classLevel: $classLevel,
|
||||
);
|
||||
}, $rows);
|
||||
|
||||
return new PaginatedResult(
|
||||
items: $items,
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetStudentsWithClass;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
|
||||
final readonly class GetStudentsWithClassQuery
|
||||
{
|
||||
public int $page;
|
||||
public int $limit;
|
||||
public ?string $search;
|
||||
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
public ?string $classId = null,
|
||||
int $page = PaginatedResult::DEFAULT_PAGE,
|
||||
int $limit = PaginatedResult::DEFAULT_LIMIT,
|
||||
?string $search = null,
|
||||
) {
|
||||
$this->page = max(1, $page);
|
||||
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
|
||||
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetStudentsWithClass;
|
||||
|
||||
final readonly class StudentWithClassDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public ?string $email,
|
||||
public string $statut,
|
||||
public ?string $studentNumber,
|
||||
public ?string $dateNaissance,
|
||||
public ?string $classId,
|
||||
public ?string $className,
|
||||
public ?string $classLevel,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\HasStudentsInClass;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour vérifier si des élèves sont affectés à une classe.
|
||||
*
|
||||
* Note: L'implémentation complète sera ajoutée quand le module Élèves sera disponible.
|
||||
* Pour l'instant, retourne toujours 0 (aucun élève).
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class HasStudentsInClassHandler
|
||||
{
|
||||
public function __construct(
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(HasStudentsInClassQuery $query): int
|
||||
{
|
||||
// TODO: Implémenter la vérification réelle quand le module Élèves sera disponible
|
||||
// Pour l'instant, retourne 0 (permet l'archivage)
|
||||
return 0;
|
||||
return $this->classAssignmentRepository->countByClass(
|
||||
ClassId::fromString($query->classId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignmentId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class EleveAffecteAClasse implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ClassAssignmentId $assignmentId,
|
||||
public UserId $studentId,
|
||||
public ClassId $classId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->assignmentId->value;
|
||||
}
|
||||
}
|
||||
36
backend/src/Administration/Domain/Event/EleveInscrit.php
Normal file
36
backend/src/Administration/Domain/Event/EleveInscrit.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final readonly class EleveInscrit implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public UserId $userId,
|
||||
public string $firstName,
|
||||
public string $lastName,
|
||||
public TenantId $tenantId,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->userId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class AffectationEleveNonTrouveeException extends DomainException
|
||||
{
|
||||
public static function pourEleve(UserId $studentId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Aucune affectation trouvée pour l\'élève "%s" cette année scolaire.',
|
||||
$studentId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class EleveDejaAffecteException extends DomainException
|
||||
{
|
||||
public static function pourAnneeScolaire(UserId $studentId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'L\'élève "%s" est déjà affecté à une classe pour cette année scolaire.',
|
||||
$studentId,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\ClassAssignment;
|
||||
|
||||
use App\Administration\Domain\Event\EleveAffecteAClasse;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Aggregate Root représentant l'affectation d'un élève à une classe.
|
||||
*
|
||||
* Un élève ne peut être affecté qu'à une seule classe par année scolaire.
|
||||
* Contrainte : UNIQUE(user_id, academic_year_id).
|
||||
*
|
||||
* @see FR76: Affectation élève↔classe
|
||||
*/
|
||||
final class ClassAssignment extends AggregateRoot
|
||||
{
|
||||
public private(set) DateTimeImmutable $updatedAt;
|
||||
|
||||
private function __construct(
|
||||
public private(set) ClassAssignmentId $id,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) UserId $studentId,
|
||||
public private(set) ClassId $classId,
|
||||
public private(set) AcademicYearId $academicYearId,
|
||||
public private(set) DateTimeImmutable $assignedAt,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
$this->updatedAt = $createdAt;
|
||||
}
|
||||
|
||||
public static function affecter(
|
||||
TenantId $tenantId,
|
||||
UserId $studentId,
|
||||
ClassId $classId,
|
||||
AcademicYearId $academicYearId,
|
||||
DateTimeImmutable $assignedAt,
|
||||
): self {
|
||||
$assignment = new self(
|
||||
id: ClassAssignmentId::generate(),
|
||||
tenantId: $tenantId,
|
||||
studentId: $studentId,
|
||||
classId: $classId,
|
||||
academicYearId: $academicYearId,
|
||||
assignedAt: $assignedAt,
|
||||
createdAt: $assignedAt,
|
||||
);
|
||||
|
||||
$assignment->recordEvent(new EleveAffecteAClasse(
|
||||
assignmentId: $assignment->id,
|
||||
studentId: $assignment->studentId,
|
||||
classId: $assignment->classId,
|
||||
occurredOn: $assignedAt,
|
||||
));
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
|
||||
public function changerClasse(ClassId $newClassId, DateTimeImmutable $changedAt): void
|
||||
{
|
||||
$this->classId = $newClassId;
|
||||
$this->updatedAt = $changedAt;
|
||||
|
||||
$this->recordEvent(new EleveAffecteAClasse(
|
||||
assignmentId: $this->id,
|
||||
studentId: $this->studentId,
|
||||
classId: $newClassId,
|
||||
occurredOn: $changedAt,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstitue une ClassAssignment depuis le stockage.
|
||||
*
|
||||
* @internal Pour usage Infrastructure uniquement
|
||||
*/
|
||||
public static function reconstitute(
|
||||
ClassAssignmentId $id,
|
||||
TenantId $tenantId,
|
||||
UserId $studentId,
|
||||
ClassId $classId,
|
||||
AcademicYearId $academicYearId,
|
||||
DateTimeImmutable $assignedAt,
|
||||
DateTimeImmutable $createdAt,
|
||||
DateTimeImmutable $updatedAt,
|
||||
): self {
|
||||
$assignment = new self(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
studentId: $studentId,
|
||||
classId: $classId,
|
||||
academicYearId: $academicYearId,
|
||||
assignedAt: $assignedAt,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$assignment->updatedAt = $updatedAt;
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\ClassAssignment;
|
||||
|
||||
use App\Shared\Domain\EntityId;
|
||||
|
||||
final readonly class ClassAssignmentId extends EntityId
|
||||
{
|
||||
}
|
||||
@@ -13,6 +13,7 @@ enum StatutCompte: string
|
||||
case CONSENTEMENT_REQUIS = 'consent'; // Minor < 15 years, awaiting parental consent
|
||||
case ACTIF = 'active'; // Account activated and usable
|
||||
case SUSPENDU = 'suspended'; // Account temporarily disabled
|
||||
case INSCRIT = 'inscrit'; // Student enrolled without email, no account access
|
||||
case ARCHIVE = 'archived'; // Account archived (end of schooling)
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Administration\Domain\Model\User;
|
||||
use App\Administration\Domain\Event\CompteActive;
|
||||
use App\Administration\Domain\Event\CompteCreated;
|
||||
use App\Administration\Domain\Event\DroitImageModifie;
|
||||
use App\Administration\Domain\Event\EleveInscrit;
|
||||
use App\Administration\Domain\Event\InvitationRenvoyee;
|
||||
use App\Administration\Domain\Event\MotDePasseChange;
|
||||
use App\Administration\Domain\Event\RoleAttribue;
|
||||
@@ -57,12 +58,14 @@ final class User extends AggregateRoot
|
||||
/** @var Role[] */
|
||||
public private(set) array $roles;
|
||||
|
||||
public private(set) ?string $studentNumber = null;
|
||||
|
||||
/**
|
||||
* @param Role[] $roles
|
||||
*/
|
||||
private function __construct(
|
||||
public private(set) UserId $id,
|
||||
public private(set) Email $email,
|
||||
public private(set) ?Email $email,
|
||||
array $roles,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) string $schoolName,
|
||||
@@ -263,6 +266,7 @@ final class User extends AggregateRoot
|
||||
?DateTimeImmutable $dateNaissance = null,
|
||||
?string $studentId = null,
|
||||
?string $relationshipType = null,
|
||||
?string $studentNumber = null,
|
||||
): self {
|
||||
$user = new self(
|
||||
id: UserId::generate(),
|
||||
@@ -278,6 +282,7 @@ final class User extends AggregateRoot
|
||||
);
|
||||
|
||||
$user->invitedAt = $invitedAt;
|
||||
$user->studentNumber = $studentNumber;
|
||||
|
||||
$user->recordEvent(new UtilisateurInvite(
|
||||
userId: $user->id,
|
||||
@@ -294,6 +299,49 @@ final class User extends AggregateRoot
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrolls a student without email (no invitation sent).
|
||||
*
|
||||
* Used when an admin creates a student who does not have an email address.
|
||||
* The student is enrolled with status INSCRIT and cannot log in until
|
||||
* an email is provided later.
|
||||
*/
|
||||
public static function inscrire(
|
||||
Role $role,
|
||||
TenantId $tenantId,
|
||||
string $schoolName,
|
||||
string $firstName,
|
||||
string $lastName,
|
||||
DateTimeImmutable $inscritAt,
|
||||
?DateTimeImmutable $dateNaissance = null,
|
||||
?string $studentNumber = null,
|
||||
): self {
|
||||
$user = new self(
|
||||
id: UserId::generate(),
|
||||
email: null,
|
||||
roles: [$role],
|
||||
tenantId: $tenantId,
|
||||
schoolName: $schoolName,
|
||||
statut: StatutCompte::INSCRIT,
|
||||
dateNaissance: $dateNaissance,
|
||||
createdAt: $inscritAt,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
);
|
||||
|
||||
$user->studentNumber = $studentNumber;
|
||||
|
||||
$user->recordEvent(new EleveInscrit(
|
||||
userId: $user->id,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
tenantId: $user->tenantId,
|
||||
occurredOn: $inscritAt,
|
||||
));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resends the invitation for a user still awaiting activation.
|
||||
*
|
||||
@@ -426,7 +474,7 @@ final class User extends AggregateRoot
|
||||
*/
|
||||
public static function reconstitute(
|
||||
UserId $id,
|
||||
Email $email,
|
||||
?Email $email,
|
||||
array $roles,
|
||||
TenantId $tenantId,
|
||||
string $schoolName,
|
||||
@@ -444,6 +492,7 @@ final class User extends AggregateRoot
|
||||
ImageRightsStatus $imageRightsStatus = ImageRightsStatus::NOT_SPECIFIED,
|
||||
?DateTimeImmutable $imageRightsUpdatedAt = null,
|
||||
?UserId $imageRightsUpdatedBy = null,
|
||||
?string $studentNumber = null,
|
||||
): self {
|
||||
if ($roles === []) {
|
||||
throw new InvalidArgumentException('Un utilisateur doit avoir au moins un rôle.');
|
||||
@@ -471,6 +520,7 @@ final class User extends AggregateRoot
|
||||
$user->imageRightsStatus = $imageRightsStatus;
|
||||
$user->imageRightsUpdatedAt = $imageRightsUpdatedAt;
|
||||
$user->imageRightsUpdatedBy = $imageRightsUpdatedBy;
|
||||
$user->studentNumber = $studentNumber;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignmentId;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
interface ClassAssignmentRepository
|
||||
{
|
||||
public function save(ClassAssignment $assignment): void;
|
||||
|
||||
public function findById(ClassAssignmentId $id, TenantId $tenantId): ?ClassAssignment;
|
||||
|
||||
public function findByStudent(UserId $studentId, AcademicYearId $academicYearId, TenantId $tenantId): ?ClassAssignment;
|
||||
|
||||
public function countByClass(ClassId $classId): int;
|
||||
|
||||
/** @return ClassAssignment[] */
|
||||
public function findByClass(ClassId $classId, TenantId $tenantId): array;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassCommand;
|
||||
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
|
||||
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\StudentResource;
|
||||
use App\Administration\Infrastructure\Security\StudentVoter;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
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;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<StudentResource, StudentResource>
|
||||
*/
|
||||
final readonly class ChangeStudentClassProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ChangeStudentClassHandler $handler,
|
||||
private ClassRepository $classRepository,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param StudentResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): StudentResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(StudentVoter::MANAGE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à changer la classe d\'un élève.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
/** @var string $studentId */
|
||||
$studentId = $uriVariables['id'] ?? '';
|
||||
$academicYearId = $this->academicYearResolver->resolve('current')
|
||||
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
|
||||
|
||||
try {
|
||||
$command = new ChangeStudentClassCommand(
|
||||
tenantId: $tenantId,
|
||||
studentId: $studentId,
|
||||
newClassId: $data->classId ?? '',
|
||||
academicYearId: $academicYearId,
|
||||
);
|
||||
|
||||
$assignment = ($this->handler)($command);
|
||||
|
||||
// Dispatch domain events
|
||||
foreach ($assignment->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
// Update the resource with new class info
|
||||
$newClass = $this->classRepository->get($assignment->classId);
|
||||
$data->classId = (string) $assignment->classId;
|
||||
$data->className = (string) $newClass->name;
|
||||
$data->classLevel = $newClass->level?->value;
|
||||
|
||||
return $data;
|
||||
} catch (AffectationEleveNonTrouveeException) {
|
||||
throw new NotFoundHttpException('Élève non trouvé.');
|
||||
} catch (ClasseNotFoundException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Administration\Application\Command\CreateStudent\CreateStudentCommand;
|
||||
use App\Administration\Application\Command\CreateStudent\CreateStudentHandler;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\StudentResource;
|
||||
use App\Administration\Infrastructure\Security\StudentVoter;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<StudentResource, StudentResource>
|
||||
*/
|
||||
final readonly class CreateStudentProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private CreateStudentHandler $handler,
|
||||
private ClassRepository $classRepository,
|
||||
private TenantContext $tenantContext,
|
||||
private MessageBusInterface $eventBus,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param StudentResource $data
|
||||
*/
|
||||
#[Override]
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): StudentResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(StudentVoter::CREATE)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à créer un élève.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$tenantConfig = $this->tenantContext->getCurrentTenantConfig();
|
||||
$academicYearId = $this->academicYearResolver->resolve('current')
|
||||
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
|
||||
|
||||
try {
|
||||
$command = new CreateStudentCommand(
|
||||
tenantId: $tenantId,
|
||||
schoolName: $tenantConfig->subdomain,
|
||||
firstName: $data->firstName ?? '',
|
||||
lastName: $data->lastName ?? '',
|
||||
classId: $data->classId ?? '',
|
||||
academicYearId: $academicYearId,
|
||||
email: $data->email,
|
||||
dateNaissance: $data->dateNaissance,
|
||||
studentNumber: $data->studentNumber,
|
||||
);
|
||||
|
||||
$user = ($this->handler)($command);
|
||||
|
||||
// Dispatch domain events
|
||||
foreach ($user->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
|
||||
// Build the response from the created user and class info
|
||||
$class = $this->classRepository->get(ClassId::fromString($data->classId ?? ''));
|
||||
|
||||
$resource = new StudentResource();
|
||||
$resource->id = (string) $user->id;
|
||||
$resource->firstName = $user->firstName;
|
||||
$resource->lastName = $user->lastName;
|
||||
$resource->email = $user->email !== null ? (string) $user->email : null;
|
||||
$resource->statut = $user->statut->value;
|
||||
$resource->classId = $data->classId;
|
||||
$resource->className = (string) $class->name;
|
||||
$resource->classLevel = $class->level?->value;
|
||||
$resource->studentNumber = $user->studentNumber;
|
||||
$resource->dateNaissance = $user->dateNaissance?->format('Y-m-d');
|
||||
|
||||
return $resource;
|
||||
} catch (EmailDejaUtiliseeException $e) {
|
||||
throw new ConflictHttpException($e->getMessage());
|
||||
} catch (ClasseNotFoundException $e) {
|
||||
throw new NotFoundHttpException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\TraversablePaginator;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Application\Query\GetStudentsWithClass\GetStudentsWithClassHandler;
|
||||
use App\Administration\Application\Query\GetStudentsWithClass\GetStudentsWithClassQuery;
|
||||
use App\Administration\Infrastructure\Api\Resource\StudentResource;
|
||||
use App\Administration\Infrastructure\Security\StudentVoter;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use ArrayIterator;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<StudentResource>
|
||||
*/
|
||||
final readonly class StudentCollectionProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetStudentsWithClassHandler $handler,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(StudentVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à consulter les élèves.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
|
||||
$academicYearId = $this->academicYearResolver->resolve('current')
|
||||
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
|
||||
|
||||
/** @var array<string, mixed> $filters */
|
||||
$filters = $context['filters'] ?? [];
|
||||
/** @var int|string $rawPage */
|
||||
$rawPage = $filters['page'] ?? 1;
|
||||
$page = (int) $rawPage;
|
||||
/** @var int|string $rawItemsPerPage */
|
||||
$rawItemsPerPage = $filters['itemsPerPage'] ?? 30;
|
||||
$itemsPerPage = (int) $rawItemsPerPage;
|
||||
/** @var string|null $search */
|
||||
$search = isset($filters['search']) ? $filters['search'] : null;
|
||||
/** @var string|null $classId */
|
||||
$classId = isset($filters['classId']) ? $filters['classId'] : null;
|
||||
|
||||
$result = ($this->handler)(new GetStudentsWithClassQuery(
|
||||
tenantId: $tenantId,
|
||||
academicYearId: $academicYearId,
|
||||
classId: $classId,
|
||||
page: $page,
|
||||
limit: $itemsPerPage,
|
||||
search: $search,
|
||||
));
|
||||
|
||||
$resources = array_map(StudentResource::fromDto(...), $result->items);
|
||||
|
||||
return new TraversablePaginator(
|
||||
new ArrayIterator($resources),
|
||||
$page,
|
||||
$itemsPerPage,
|
||||
$result->total,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\Api\Resource\StudentResource;
|
||||
use App\Administration\Infrastructure\Security\StudentVoter;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<StudentResource>
|
||||
*/
|
||||
final readonly class StudentItemProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository,
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private TenantContext $tenantContext,
|
||||
private AuthorizationCheckerInterface $authorizationChecker,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): StudentResource
|
||||
{
|
||||
if (!$this->authorizationChecker->isGranted(StudentVoter::VIEW)) {
|
||||
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à consulter les élèves.');
|
||||
}
|
||||
|
||||
if (!$this->tenantContext->hasTenant()) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
|
||||
}
|
||||
|
||||
/** @var string $studentId */
|
||||
$studentId = $uriVariables['id'] ?? '';
|
||||
$tenantId = $this->tenantContext->getCurrentTenantId();
|
||||
|
||||
$user = $this->userRepository->findById(UserId::fromString($studentId));
|
||||
|
||||
if ($user === null || (string) $user->tenantId !== (string) $tenantId || !$user->aLeRole(Role::ELEVE)) {
|
||||
throw new NotFoundHttpException('Élève non trouvé.');
|
||||
}
|
||||
|
||||
$resource = new StudentResource();
|
||||
$resource->id = (string) $user->id;
|
||||
$resource->firstName = $user->firstName;
|
||||
$resource->lastName = $user->lastName;
|
||||
$resource->email = $user->email !== null ? (string) $user->email : null;
|
||||
$resource->statut = $user->statut->value;
|
||||
$resource->studentNumber = $user->studentNumber;
|
||||
$resource->dateNaissance = $user->dateNaissance?->format('Y-m-d');
|
||||
|
||||
// Look up class assignment
|
||||
$academicYearId = $this->academicYearResolver->resolve('current');
|
||||
|
||||
if ($academicYearId !== null) {
|
||||
$assignment = $this->classAssignmentRepository->findByStudent(
|
||||
$user->id,
|
||||
AcademicYearId::fromString($academicYearId),
|
||||
TenantId::fromString((string) $tenantId),
|
||||
);
|
||||
|
||||
if ($assignment !== null) {
|
||||
$resource->classId = (string) $assignment->classId;
|
||||
|
||||
$class = $this->classRepository->findById($assignment->classId);
|
||||
|
||||
if ($class !== null) {
|
||||
$resource->className = (string) $class->name;
|
||||
$resource->classLevel = $class->level?->value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Administration\Application\Query\GetStudentsWithClass\StudentWithClassDto;
|
||||
use App\Administration\Infrastructure\Api\Processor\ChangeStudentClassProcessor;
|
||||
use App\Administration\Infrastructure\Api\Processor\CreateStudentProcessor;
|
||||
use App\Administration\Infrastructure\Api\Provider\StudentCollectionProvider;
|
||||
use App\Administration\Infrastructure\Api\Provider\StudentItemProvider;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'Student',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/students',
|
||||
provider: StudentCollectionProvider::class,
|
||||
name: 'get_students',
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/students/{id}',
|
||||
provider: StudentItemProvider::class,
|
||||
name: 'get_student',
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/students',
|
||||
processor: CreateStudentProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'create']],
|
||||
name: 'create_student',
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/students/{id}/class',
|
||||
provider: StudentItemProvider::class,
|
||||
processor: ChangeStudentClassProcessor::class,
|
||||
validationContext: ['groups' => ['Default', 'change_class']],
|
||||
name: 'change_student_class',
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class StudentResource
|
||||
{
|
||||
#[ApiProperty(identifier: true)]
|
||||
public ?string $id = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Le prénom est requis.', groups: ['create'])]
|
||||
#[Assert\Length(max: 100, maxMessage: 'Le prénom ne doit pas dépasser {{ limit }} caractères.')]
|
||||
public ?string $firstName = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Le nom est requis.', groups: ['create'])]
|
||||
#[Assert\Length(max: 100, maxMessage: 'Le nom ne doit pas dépasser {{ limit }} caractères.')]
|
||||
public ?string $lastName = null;
|
||||
|
||||
#[Assert\Email(message: 'L\'email n\'est pas valide.')]
|
||||
public ?string $email = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'La classe est requise.', groups: ['create', 'change_class'])]
|
||||
public ?string $classId = null;
|
||||
|
||||
public ?string $className = null;
|
||||
public ?string $classLevel = null;
|
||||
public ?string $statut = null;
|
||||
public ?string $dateNaissance = null;
|
||||
|
||||
#[Assert\Regex(pattern: '/^[A-Za-z0-9]{11}$/', message: 'L\'INE doit contenir exactement 11 caractères alphanumériques.')]
|
||||
public ?string $studentNumber = null;
|
||||
|
||||
public static function fromDto(StudentWithClassDto $dto): self
|
||||
{
|
||||
$resource = new self();
|
||||
$resource->id = $dto->id;
|
||||
$resource->firstName = $dto->firstName;
|
||||
$resource->lastName = $dto->lastName;
|
||||
$resource->email = $dto->email;
|
||||
$resource->statut = $dto->statut;
|
||||
$resource->studentNumber = $dto->studentNumber;
|
||||
$resource->dateNaissance = $dto->dateNaissance;
|
||||
$resource->classId = $dto->classId;
|
||||
$resource->className = $dto->className;
|
||||
$resource->classLevel = $dto->classLevel;
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,12 @@ final readonly class CacheUserRepository implements UserRepository
|
||||
$this->usersCache->save($item);
|
||||
|
||||
// Save email index for lookup (scoped to tenant)
|
||||
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
|
||||
$emailItem = $this->usersCache->getItem($emailKey);
|
||||
$emailItem->set((string) $user->id);
|
||||
$this->usersCache->save($emailItem);
|
||||
if ($user->email !== null) {
|
||||
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
|
||||
$emailItem = $this->usersCache->getItem($emailKey);
|
||||
$emailItem->set((string) $user->id);
|
||||
$this->usersCache->save($emailItem);
|
||||
}
|
||||
|
||||
// Save tenant index for listing users
|
||||
$tenantKey = self::TENANT_INDEX_PREFIX . $user->tenantId;
|
||||
@@ -77,7 +79,7 @@ final readonly class CacheUserRepository implements UserRepository
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array{id: string, email: string, roles?: string[], role?: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, first_name?: string, last_name?: string, invited_at?: string|null, blocked_at?: string|null, blocked_reason?: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */
|
||||
/** @var array{id: string, email: string|null, roles?: string[], role?: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, first_name?: string, last_name?: string, invited_at?: string|null, blocked_at?: string|null, blocked_reason?: string|null, student_number?: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */
|
||||
$data = $item->get();
|
||||
|
||||
return $this->deserialize($data);
|
||||
@@ -150,7 +152,7 @@ final readonly class CacheUserRepository implements UserRepository
|
||||
|
||||
return [
|
||||
'id' => (string) $user->id,
|
||||
'email' => (string) $user->email,
|
||||
'email' => $user->email !== null ? (string) $user->email : null,
|
||||
'roles' => array_map(static fn (Role $r) => $r->value, $user->roles),
|
||||
'tenant_id' => (string) $user->tenantId,
|
||||
'school_name' => $user->schoolName,
|
||||
@@ -167,6 +169,7 @@ final readonly class CacheUserRepository implements UserRepository
|
||||
'image_rights_status' => $user->imageRightsStatus->value,
|
||||
'image_rights_updated_at' => $user->imageRightsUpdatedAt?->format('c'),
|
||||
'image_rights_updated_by' => $user->imageRightsUpdatedBy !== null ? (string) $user->imageRightsUpdatedBy : null,
|
||||
'student_number' => $user->studentNumber,
|
||||
'consentement_parental' => $consentement !== null ? [
|
||||
'parent_id' => $consentement->parentId,
|
||||
'eleve_id' => $consentement->eleveId,
|
||||
@@ -179,7 +182,7 @@ final readonly class CacheUserRepository implements UserRepository
|
||||
/**
|
||||
* @param array{
|
||||
* id: string,
|
||||
* email: string,
|
||||
* email: string|null,
|
||||
* roles?: string[],
|
||||
* role?: string,
|
||||
* tenant_id: string,
|
||||
@@ -227,7 +230,7 @@ final readonly class CacheUserRepository implements UserRepository
|
||||
|
||||
return User::reconstitute(
|
||||
id: UserId::fromString($data['id']),
|
||||
email: new Email($data['email']),
|
||||
email: $data['email'] !== null ? new Email($data['email']) : null,
|
||||
roles: $roles,
|
||||
tenantId: TenantId::fromString($data['tenant_id']),
|
||||
schoolName: $data['school_name'],
|
||||
@@ -245,6 +248,7 @@ final readonly class CacheUserRepository implements UserRepository
|
||||
imageRightsStatus: isset($data['image_rights_status']) ? ImageRightsStatus::from($data['image_rights_status']) : ImageRightsStatus::NOT_SPECIFIED,
|
||||
imageRightsUpdatedAt: ($data['image_rights_updated_at'] ?? null) !== null ? new DateTimeImmutable($data['image_rights_updated_at']) : null,
|
||||
imageRightsUpdatedBy: ($data['image_rights_updated_by'] ?? null) !== null ? UserId::fromString($data['image_rights_updated_by']) : null,
|
||||
studentNumber: $data['student_number'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function is_string;
|
||||
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Throwable;
|
||||
@@ -51,8 +54,10 @@ final readonly class CachedUserRepository implements UserRepository
|
||||
/** @var array<string, mixed> $oldData */
|
||||
$oldData = $existingItem->get();
|
||||
/** @var string $oldEmail */
|
||||
$oldEmail = $oldData['email'] ?? '';
|
||||
if ($oldEmail !== '' && $oldEmail !== (string) $user->email) {
|
||||
/** @var string|null $oldEmail */
|
||||
$oldEmail = $oldData['email'] ?? null;
|
||||
$currentEmail = $user->email !== null ? (string) $user->email : null;
|
||||
if ($oldEmail !== null && $oldEmail !== '' && $oldEmail !== $currentEmail) {
|
||||
/** @var string $oldTenantId */
|
||||
$oldTenantId = $oldData['tenant_id'] ?? (string) $user->tenantId;
|
||||
$oldEmailKey = $this->emailIndexKey(
|
||||
@@ -67,10 +72,12 @@ final readonly class CachedUserRepository implements UserRepository
|
||||
$this->usersCache->save($existingItem);
|
||||
|
||||
// Email index
|
||||
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
|
||||
$emailItem = $this->usersCache->getItem($emailKey);
|
||||
$emailItem->set((string) $user->id);
|
||||
$this->usersCache->save($emailItem);
|
||||
if ($user->email !== null) {
|
||||
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
|
||||
$emailItem = $this->usersCache->getItem($emailKey);
|
||||
$emailItem->set((string) $user->id);
|
||||
$this->usersCache->save($emailItem);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Redis unavailable — PostgreSQL write succeeded, data is safe
|
||||
}
|
||||
@@ -172,10 +179,12 @@ final readonly class CachedUserRepository implements UserRepository
|
||||
$this->usersCache->save($item);
|
||||
|
||||
// Email index
|
||||
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
|
||||
$emailItem = $this->usersCache->getItem($emailKey);
|
||||
$emailItem->set((string) $user->id);
|
||||
$this->usersCache->save($emailItem);
|
||||
if ($user->email !== null) {
|
||||
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
|
||||
$emailItem = $this->usersCache->getItem($emailKey);
|
||||
$emailItem->set((string) $user->id);
|
||||
$this->usersCache->save($emailItem);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Redis unavailable
|
||||
}
|
||||
@@ -190,7 +199,7 @@ final readonly class CachedUserRepository implements UserRepository
|
||||
|
||||
return [
|
||||
'id' => (string) $user->id,
|
||||
'email' => (string) $user->email,
|
||||
'email' => $user->email !== null ? (string) $user->email : null,
|
||||
'roles' => array_map(static fn (Role $r) => $r->value, $user->roles),
|
||||
'tenant_id' => (string) $user->tenantId,
|
||||
'school_name' => $user->schoolName,
|
||||
@@ -207,6 +216,7 @@ final readonly class CachedUserRepository implements UserRepository
|
||||
'image_rights_status' => $user->imageRightsStatus->value,
|
||||
'image_rights_updated_at' => $user->imageRightsUpdatedAt?->format('c'),
|
||||
'image_rights_updated_by' => $user->imageRightsUpdatedBy !== null ? (string) $user->imageRightsUpdatedBy : null,
|
||||
'student_number' => $user->studentNumber,
|
||||
'consentement_parental' => $consentement !== null ? [
|
||||
'parent_id' => $consentement->parentId,
|
||||
'eleve_id' => $consentement->eleveId,
|
||||
@@ -223,7 +233,7 @@ final readonly class CachedUserRepository implements UserRepository
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $data['id'];
|
||||
/** @var string $email */
|
||||
/** @var string|null $email */
|
||||
$email = $data['email'];
|
||||
// Support both legacy single role ('role') and multi-role ('roles') format
|
||||
/** @var string[] $roleStrings */
|
||||
@@ -275,7 +285,7 @@ final readonly class CachedUserRepository implements UserRepository
|
||||
|
||||
return User::reconstitute(
|
||||
id: UserId::fromString($id),
|
||||
email: new Email($email),
|
||||
email: $email !== null ? new Email($email) : null,
|
||||
roles: $roles,
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
schoolName: $schoolName,
|
||||
@@ -293,6 +303,7 @@ final readonly class CachedUserRepository implements UserRepository
|
||||
imageRightsStatus: $imageRightsStatusValue !== null ? ImageRightsStatus::from($imageRightsStatusValue) : ImageRightsStatus::NOT_SPECIFIED,
|
||||
imageRightsUpdatedAt: $imageRightsUpdatedAt !== null ? new DateTimeImmutable($imageRightsUpdatedAt) : null,
|
||||
imageRightsUpdatedBy: $imageRightsUpdatedBy !== null ? UserId::fromString($imageRightsUpdatedBy) : null,
|
||||
studentNumber: isset($data['student_number']) && is_string($data['student_number']) ? $data['student_number'] : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Exception\EleveDejaAffecteException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignmentId;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineClassAssignmentRepository implements ClassAssignmentRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(ClassAssignment $assignment): void
|
||||
{
|
||||
try {
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at)
|
||||
VALUES (:id, :tenant_id, :user_id, :school_class_id, :academic_year_id, :assigned_at, :created_at, :updated_at)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
school_class_id = EXCLUDED.school_class_id,
|
||||
updated_at = EXCLUDED.updated_at',
|
||||
[
|
||||
'id' => (string) $assignment->id,
|
||||
'tenant_id' => (string) $assignment->tenantId,
|
||||
'user_id' => (string) $assignment->studentId,
|
||||
'school_class_id' => (string) $assignment->classId,
|
||||
'academic_year_id' => (string) $assignment->academicYearId,
|
||||
'assigned_at' => $assignment->assignedAt->format(DateTimeImmutable::ATOM),
|
||||
'created_at' => $assignment->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'updated_at' => $assignment->updatedAt->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
throw EleveDejaAffecteException::pourAnneeScolaire(
|
||||
$assignment->studentId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ClassAssignmentId $id, TenantId $tenantId): ?ClassAssignment
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM class_assignments 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 findByStudent(UserId $studentId, AcademicYearId $academicYearId, TenantId $tenantId): ?ClassAssignment
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM class_assignments
|
||||
WHERE user_id = :user_id
|
||||
AND academic_year_id = :academic_year_id
|
||||
AND tenant_id = :tenant_id',
|
||||
[
|
||||
'user_id' => (string) $studentId,
|
||||
'academic_year_id' => (string) $academicYearId,
|
||||
'tenant_id' => (string) $tenantId,
|
||||
],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function countByClass(ClassId $classId): int
|
||||
{
|
||||
/** @var int|string|false $count */
|
||||
$count = $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM class_assignments WHERE school_class_id = :school_class_id',
|
||||
['school_class_id' => (string) $classId],
|
||||
);
|
||||
|
||||
return (int) $count;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByClass(ClassId $classId, TenantId $tenantId): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM class_assignments
|
||||
WHERE school_class_id = :school_class_id
|
||||
AND tenant_id = :tenant_id
|
||||
ORDER BY created_at ASC',
|
||||
[
|
||||
'school_class_id' => (string) $classId,
|
||||
'tenant_id' => (string) $tenantId,
|
||||
],
|
||||
);
|
||||
|
||||
return array_map(fn ($row) => $this->hydrate($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function hydrate(array $row): ClassAssignment
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string $studentId */
|
||||
$studentId = $row['user_id'];
|
||||
/** @var string $classId */
|
||||
$classId = $row['school_class_id'];
|
||||
/** @var string $academicYearId */
|
||||
$academicYearId = $row['academic_year_id'];
|
||||
/** @var string $assignedAt */
|
||||
$assignedAt = $row['assigned_at'];
|
||||
/** @var string $createdAt */
|
||||
$createdAt = $row['created_at'];
|
||||
/** @var string $updatedAt */
|
||||
$updatedAt = $row['updated_at'];
|
||||
|
||||
return ClassAssignment::reconstitute(
|
||||
id: ClassAssignmentId::fromString($id),
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
studentId: UserId::fromString($studentId),
|
||||
classId: ClassId::fromString($classId),
|
||||
academicYearId: AcademicYearId::fromString($academicYearId),
|
||||
assignedAt: new DateTimeImmutable($assignedAt),
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
updatedAt: new DateTimeImmutable($updatedAt),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ final readonly class DoctrineUserRepository implements UserRepository
|
||||
created_at, activated_at, invited_at, blocked_at, blocked_reason,
|
||||
consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip,
|
||||
image_rights_status, image_rights_updated_at, image_rights_updated_by,
|
||||
updated_at
|
||||
student_number, updated_at
|
||||
)
|
||||
VALUES (
|
||||
:id, :tenant_id, :email, :first_name, :last_name, :roles,
|
||||
@@ -52,7 +52,7 @@ final readonly class DoctrineUserRepository implements UserRepository
|
||||
:created_at, :activated_at, :invited_at, :blocked_at, :blocked_reason,
|
||||
:consentement_parent_id, :consentement_eleve_id, :consentement_date, :consentement_ip,
|
||||
:image_rights_status, :image_rights_updated_at, :image_rights_updated_by,
|
||||
NOW()
|
||||
:student_number, NOW()
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
@@ -74,12 +74,13 @@ final readonly class DoctrineUserRepository implements UserRepository
|
||||
image_rights_status = EXCLUDED.image_rights_status,
|
||||
image_rights_updated_at = EXCLUDED.image_rights_updated_at,
|
||||
image_rights_updated_by = EXCLUDED.image_rights_updated_by,
|
||||
student_number = EXCLUDED.student_number,
|
||||
updated_at = NOW()
|
||||
SQL,
|
||||
[
|
||||
'id' => (string) $user->id,
|
||||
'tenant_id' => (string) $user->tenantId,
|
||||
'email' => (string) $user->email,
|
||||
'email' => $user->email !== null ? (string) $user->email : null,
|
||||
'first_name' => $user->firstName,
|
||||
'last_name' => $user->lastName,
|
||||
'roles' => json_encode(array_map(static fn (Role $r) => $r->value, $user->roles)),
|
||||
@@ -99,6 +100,7 @@ final readonly class DoctrineUserRepository implements UserRepository
|
||||
'image_rights_status' => $user->imageRightsStatus->value,
|
||||
'image_rights_updated_at' => $user->imageRightsUpdatedAt?->format(DateTimeImmutable::ATOM),
|
||||
'image_rights_updated_by' => $user->imageRightsUpdatedBy !== null ? (string) $user->imageRightsUpdatedBy : null,
|
||||
'student_number' => $user->studentNumber,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -182,7 +184,7 @@ final readonly class DoctrineUserRepository implements UserRepository
|
||||
$id = $row['id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string $email */
|
||||
/** @var string|null $email */
|
||||
$email = $row['email'];
|
||||
/** @var string $firstName */
|
||||
$firstName = $row['first_name'];
|
||||
@@ -222,6 +224,8 @@ final readonly class DoctrineUserRepository implements UserRepository
|
||||
$imageRightsUpdatedAtValue = $row['image_rights_updated_at'] ?? null;
|
||||
/** @var string|null $imageRightsUpdatedByValue */
|
||||
$imageRightsUpdatedByValue = $row['image_rights_updated_by'] ?? null;
|
||||
/** @var string|null $studentNumber */
|
||||
$studentNumber = $row['student_number'] ?? null;
|
||||
|
||||
/** @var string[]|null $roleValues */
|
||||
$roleValues = json_decode($rolesJson, true);
|
||||
@@ -244,7 +248,7 @@ final readonly class DoctrineUserRepository implements UserRepository
|
||||
|
||||
return User::reconstitute(
|
||||
id: UserId::fromString($id),
|
||||
email: new Email($email),
|
||||
email: $email !== null ? new Email($email) : null,
|
||||
roles: $roles,
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
schoolName: $schoolName,
|
||||
@@ -262,6 +266,7 @@ final readonly class DoctrineUserRepository implements UserRepository
|
||||
imageRightsStatus: $imageRightsStatusValue !== null ? ImageRightsStatus::from($imageRightsStatusValue) : ImageRightsStatus::NOT_SPECIFIED,
|
||||
imageRightsUpdatedAt: $imageRightsUpdatedAtValue !== null ? new DateTimeImmutable($imageRightsUpdatedAtValue) : null,
|
||||
imageRightsUpdatedBy: $imageRightsUpdatedByValue !== null ? UserId::fromString($imageRightsUpdatedByValue) : null,
|
||||
studentNumber: $studentNumber,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignmentId;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
final class InMemoryClassAssignmentRepository implements ClassAssignmentRepository
|
||||
{
|
||||
/** @var array<string, ClassAssignment> */
|
||||
private array $byId = [];
|
||||
|
||||
#[Override]
|
||||
public function save(ClassAssignment $assignment): void
|
||||
{
|
||||
$this->byId[(string) $assignment->id] = $assignment;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ClassAssignmentId $id, TenantId $tenantId): ?ClassAssignment
|
||||
{
|
||||
$assignment = $this->byId[(string) $id] ?? null;
|
||||
|
||||
if ($assignment !== null && !$assignment->tenantId->equals($tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByStudent(UserId $studentId, AcademicYearId $academicYearId, TenantId $tenantId): ?ClassAssignment
|
||||
{
|
||||
foreach ($this->byId as $assignment) {
|
||||
if (
|
||||
$assignment->studentId->equals($studentId)
|
||||
&& $assignment->academicYearId->equals($academicYearId)
|
||||
&& $assignment->tenantId->equals($tenantId)
|
||||
) {
|
||||
return $assignment;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function countByClass(ClassId $classId): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach ($this->byId as $assignment) {
|
||||
if ($assignment->classId->equals($classId)) {
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByClass(ClassId $classId, TenantId $tenantId): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($this->byId as $assignment) {
|
||||
if ($assignment->classId->equals($classId) && $assignment->tenantId->equals($tenantId)) {
|
||||
$result[] = $assignment;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,10 @@ final class InMemoryUserRepository implements UserRepository
|
||||
public function save(User $user): void
|
||||
{
|
||||
$this->byId[(string) $user->id] = $user;
|
||||
$this->byTenantEmail[$this->emailKey($user->email, $user->tenantId)] = $user;
|
||||
|
||||
if ($user->email !== null) {
|
||||
$this->byTenantEmail[$this->emailKey($user->email, $user->tenantId)] = $user;
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User as DomainUser;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* Factory pour créer des SecurityUser depuis des Domain Users.
|
||||
@@ -18,9 +19,16 @@ final readonly class SecurityUserFactory
|
||||
{
|
||||
public function fromDomainUser(DomainUser $domainUser): SecurityUser
|
||||
{
|
||||
if ($domainUser->email === null) {
|
||||
throw new LogicException('Cannot create SecurityUser from a domain user without email.');
|
||||
}
|
||||
|
||||
/** @var non-empty-string $email */
|
||||
$email = (string) $domainUser->email;
|
||||
|
||||
return new SecurityUser(
|
||||
userId: $domainUser->id,
|
||||
email: (string) $domainUser->email,
|
||||
email: $email,
|
||||
hashedPassword: $domainUser->hashedPassword ?? '',
|
||||
tenantId: $domainUser->tenantId,
|
||||
roles: array_values(array_map(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* Voter pour les autorisations sur la gestion des élèves.
|
||||
*
|
||||
* Règles d'accès :
|
||||
* - ADMIN, SUPER_ADMIN, SECRETARIAT : accès complet (CRUD)
|
||||
* - Autres rôles : pas d'accès
|
||||
*
|
||||
* @extends Voter<string, null>
|
||||
*/
|
||||
final class StudentVoter extends Voter
|
||||
{
|
||||
public const string VIEW = 'STUDENT_VIEW';
|
||||
public const string CREATE = 'STUDENT_CREATE';
|
||||
public const string MANAGE = 'STUDENT_MANAGE';
|
||||
|
||||
private const array SUPPORTED_ATTRIBUTES = [
|
||||
self::VIEW,
|
||||
self::CREATE,
|
||||
self::MANAGE,
|
||||
];
|
||||
|
||||
#[Override]
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return in_array($attribute, self::SUPPORTED_ATTRIBUTES, true) && $subject === null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->hasAnyRole($user->getRoles(), [
|
||||
Role::SUPER_ADMIN->value,
|
||||
Role::ADMIN->value,
|
||||
Role::SECRETARIAT->value,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $userRoles
|
||||
* @param string[] $allowedRoles
|
||||
*/
|
||||
private function hasAnyRole(array $userRoles, array $allowedRoles): bool
|
||||
{
|
||||
foreach ($userRoles as $role) {
|
||||
if (in_array($role, $allowedRoles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\AssignStudentToClass;
|
||||
|
||||
use App\Administration\Application\Command\AssignStudentToClass\AssignStudentToClassCommand;
|
||||
use App\Administration\Application\Command\AssignStudentToClass\AssignStudentToClassHandler;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Exception\EleveDejaAffecteException;
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AssignStudentToClassHandlerTest 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 ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
|
||||
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440060';
|
||||
|
||||
private InMemoryClassAssignmentRepository $classAssignmentRepository;
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private InMemoryClassRepository $classRepository;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->classAssignmentRepository = new InMemoryClassAssignmentRepository();
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->classRepository = new InMemoryClassRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-21 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->seedTestData();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAssignsStudentToClass(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand();
|
||||
|
||||
$assignment = $handler($command);
|
||||
|
||||
self::assertTrue($assignment->classId->equals(ClassId::fromString(self::CLASS_ID)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenStudentDoesNotExist(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(studentId: '550e8400-e29b-41d4-a716-446655440099');
|
||||
|
||||
$this->expectException(UserNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenClassDoesNotExist(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(classId: '550e8400-e29b-41d4-a716-446655440099');
|
||||
|
||||
$this->expectException(ClasseNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenStudentAlreadyAssigned(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand();
|
||||
|
||||
// First assignment succeeds
|
||||
$handler($command);
|
||||
|
||||
$this->expectException(EleveDejaAffecteException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenClassBelongsToAnotherTenant(): void
|
||||
{
|
||||
$otherTenantClassId = '550e8400-e29b-41d4-a716-446655440030';
|
||||
|
||||
$classDifferentTenant = SchoolClass::reconstitute(
|
||||
id: ClassId::fromString($otherTenantClassId),
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('6ème B'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ACTIVE,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
);
|
||||
$this->classRepository->save($classDifferentTenant);
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(classId: $otherTenantClassId);
|
||||
|
||||
$this->expectException(ClasseNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenStudentBelongsToAnotherTenant(): void
|
||||
{
|
||||
$otherTenantStudentId = '550e8400-e29b-41d4-a716-446655440070';
|
||||
|
||||
$otherTenantStudent = User::reconstitute(
|
||||
id: UserId::fromString($otherTenantStudentId),
|
||||
email: null,
|
||||
roles: [Role::ELEVE],
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
schoolName: 'Autre École',
|
||||
statut: StatutCompte::INSCRIT,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
hashedPassword: null,
|
||||
activatedAt: null,
|
||||
consentementParental: null,
|
||||
);
|
||||
$this->userRepository->save($otherTenantStudent);
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(studentId: $otherTenantStudentId);
|
||||
|
||||
$this->expectException(UserNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenClassIsArchived(): void
|
||||
{
|
||||
$archivedClassId = '550e8400-e29b-41d4-a716-446655440031';
|
||||
|
||||
$archivedClass = SchoolClass::reconstitute(
|
||||
id: ClassId::fromString($archivedClassId),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('6ème C'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ARCHIVED,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
);
|
||||
$this->classRepository->save($archivedClass);
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(classId: $archivedClassId);
|
||||
|
||||
$this->expectException(ClasseNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
private function seedTestData(): void
|
||||
{
|
||||
$class = SchoolClass::reconstitute(
|
||||
id: ClassId::fromString(self::CLASS_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('6ème A'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ACTIVE,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
);
|
||||
$this->classRepository->save($class);
|
||||
|
||||
$student = User::reconstitute(
|
||||
id: UserId::fromString(self::STUDENT_ID),
|
||||
email: null,
|
||||
roles: [Role::ELEVE],
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'École Test',
|
||||
statut: StatutCompte::INSCRIT,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
hashedPassword: null,
|
||||
activatedAt: null,
|
||||
consentementParental: null,
|
||||
);
|
||||
$this->userRepository->save($student);
|
||||
}
|
||||
|
||||
private function createHandler(): AssignStudentToClassHandler
|
||||
{
|
||||
return new AssignStudentToClassHandler(
|
||||
$this->classAssignmentRepository,
|
||||
$this->userRepository,
|
||||
$this->classRepository,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createCommand(
|
||||
?string $studentId = null,
|
||||
?string $classId = null,
|
||||
): AssignStudentToClassCommand {
|
||||
return new AssignStudentToClassCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
studentId: $studentId ?? self::STUDENT_ID,
|
||||
classId: $classId ?? self::CLASS_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\ChangeStudentClass;
|
||||
|
||||
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassCommand;
|
||||
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
|
||||
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ChangeStudentClassHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string OLD_CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string NEW_CLASS_ID = '550e8400-e29b-41d4-a716-446655440021';
|
||||
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
|
||||
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440060';
|
||||
|
||||
private InMemoryClassAssignmentRepository $classAssignmentRepository;
|
||||
private InMemoryClassRepository $classRepository;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->classAssignmentRepository = new InMemoryClassAssignmentRepository();
|
||||
$this->classRepository = new InMemoryClassRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-21 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->seedTestData();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itChangesStudentClass(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand();
|
||||
|
||||
$assignment = $handler($command);
|
||||
|
||||
self::assertTrue($assignment->classId->equals(ClassId::fromString(self::NEW_CLASS_ID)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenNewClassDoesNotExist(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(newClassId: '550e8400-e29b-41d4-a716-446655440099');
|
||||
|
||||
$this->expectException(ClasseNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenAssignmentNotFound(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(studentId: '550e8400-e29b-41d4-a716-446655440070');
|
||||
|
||||
$this->expectException(AffectationEleveNonTrouveeException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenClassBelongsToAnotherTenant(): void
|
||||
{
|
||||
$crossTenantClassId = '550e8400-e29b-41d4-a716-446655440030';
|
||||
$this->classRepository->save(SchoolClass::reconstitute(
|
||||
id: ClassId::fromString($crossTenantClassId),
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('5ème B'),
|
||||
level: SchoolLevel::CINQUIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ACTIVE,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
));
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(newClassId: $crossTenantClassId);
|
||||
|
||||
$this->expectException(ClasseNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenClassIsArchived(): void
|
||||
{
|
||||
$archivedClassId = '550e8400-e29b-41d4-a716-446655440031';
|
||||
$this->classRepository->save(SchoolClass::reconstitute(
|
||||
id: ClassId::fromString($archivedClassId),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('6ème C'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ARCHIVED,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
));
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(newClassId: $archivedClassId);
|
||||
|
||||
$this->expectException(ClasseNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
private function seedTestData(): void
|
||||
{
|
||||
$oldClass = SchoolClass::reconstitute(
|
||||
id: ClassId::fromString(self::OLD_CLASS_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('6ème A'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ACTIVE,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
);
|
||||
$this->classRepository->save($oldClass);
|
||||
|
||||
$newClass = SchoolClass::reconstitute(
|
||||
id: ClassId::fromString(self::NEW_CLASS_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('6ème B'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ACTIVE,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
);
|
||||
$this->classRepository->save($newClass);
|
||||
|
||||
$assignment = ClassAssignment::affecter(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
classId: ClassId::fromString(self::OLD_CLASS_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
assignedAt: new DateTimeImmutable('2026-01-20'),
|
||||
);
|
||||
$this->classAssignmentRepository->save($assignment);
|
||||
}
|
||||
|
||||
private function createHandler(): ChangeStudentClassHandler
|
||||
{
|
||||
return new ChangeStudentClassHandler(
|
||||
$this->classAssignmentRepository,
|
||||
$this->classRepository,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createCommand(
|
||||
?string $newClassId = null,
|
||||
?string $studentId = null,
|
||||
): ChangeStudentClassCommand {
|
||||
return new ChangeStudentClassCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
studentId: $studentId ?? self::STUDENT_ID,
|
||||
newClassId: $newClassId ?? self::NEW_CLASS_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\CreateStudent;
|
||||
|
||||
use App\Administration\Application\Command\CreateStudent\CreateStudentCommand;
|
||||
use App\Administration\Application\Command\CreateStudent\CreateStudentHandler;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class CreateStudentHandlerTest 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 ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
|
||||
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private InMemoryClassAssignmentRepository $classAssignmentRepository;
|
||||
private InMemoryClassRepository $classRepository;
|
||||
private Connection $connection;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->classAssignmentRepository = new InMemoryClassAssignmentRepository();
|
||||
$this->classRepository = new InMemoryClassRepository();
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->connection->method('beginTransaction');
|
||||
$this->connection->method('commit');
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-21 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->seedTestData();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesStudentWithEmail(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(email: 'eleve@example.com');
|
||||
|
||||
$user = $handler($command);
|
||||
|
||||
self::assertSame('Marie', $user->firstName);
|
||||
self::assertSame('Dupont', $user->lastName);
|
||||
self::assertSame(StatutCompte::EN_ATTENTE, $user->statut);
|
||||
self::assertSame('eleve@example.com', (string) $user->email);
|
||||
self::assertTrue($user->aLeRole(Role::ELEVE));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesStudentWithoutEmail(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(email: null);
|
||||
|
||||
$user = $handler($command);
|
||||
|
||||
self::assertSame('Marie', $user->firstName);
|
||||
self::assertSame('Dupont', $user->lastName);
|
||||
self::assertSame(StatutCompte::INSCRIT, $user->statut);
|
||||
self::assertNull($user->email);
|
||||
self::assertTrue($user->aLeRole(Role::ELEVE));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAssignsStudentToClass(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand();
|
||||
|
||||
$user = $handler($command);
|
||||
|
||||
$assignment = $this->classAssignmentRepository->findByStudent(
|
||||
$user->id,
|
||||
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
);
|
||||
|
||||
self::assertNotNull($assignment);
|
||||
self::assertTrue($assignment->classId->equals(ClassId::fromString(self::CLASS_ID)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSetsStudentNumber(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(studentNumber: '12345678901');
|
||||
|
||||
$user = $handler($command);
|
||||
|
||||
self::assertSame('12345678901', $user->studentNumber);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSetsDateNaissance(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(dateNaissance: '2015-06-15');
|
||||
|
||||
$user = $handler($command);
|
||||
|
||||
self::assertNotNull($user->dateNaissance);
|
||||
self::assertSame('2015-06-15', $user->dateNaissance->format('Y-m-d'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenClassDoesNotExist(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(classId: '550e8400-e29b-41d4-a716-446655440099');
|
||||
|
||||
$this->expectException(ClasseNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenEmailAlreadyUsedInTenant(): void
|
||||
{
|
||||
// Pre-populate with a user having the same email
|
||||
$existing = User::reconstitute(
|
||||
id: UserId::fromString('550e8400-e29b-41d4-a716-446655440060'),
|
||||
email: new Email('existing@example.com'),
|
||||
roles: [Role::ELEVE],
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'École Test',
|
||||
statut: StatutCompte::EN_ATTENTE,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
hashedPassword: null,
|
||||
activatedAt: null,
|
||||
consentementParental: null,
|
||||
);
|
||||
$this->userRepository->save($existing);
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(email: 'existing@example.com');
|
||||
|
||||
$this->expectException(EmailDejaUtiliseeException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRollsBackTransactionOnFailure(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->expects(self::once())->method('beginTransaction');
|
||||
$connection->expects(self::once())->method('rollBack');
|
||||
$connection->expects(self::never())->method('commit');
|
||||
|
||||
// Use a mock UserRepository that throws during save (inside the transaction)
|
||||
$failingUserRepo = $this->createMock(UserRepository::class);
|
||||
$failingUserRepo->method('findByEmail')->willReturn(null);
|
||||
$failingUserRepo->method('save')->willThrowException(new RuntimeException('DB write failed'));
|
||||
|
||||
$handler = new CreateStudentHandler(
|
||||
$failingUserRepo,
|
||||
$this->classAssignmentRepository,
|
||||
$this->classRepository,
|
||||
$connection,
|
||||
$this->clock,
|
||||
);
|
||||
|
||||
$command = $this->createCommand();
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenClassBelongsToAnotherTenant(): void
|
||||
{
|
||||
$otherTenantClassId = '550e8400-e29b-41d4-a716-446655440080';
|
||||
$otherClass = SchoolClass::reconstitute(
|
||||
id: ClassId::fromString($otherTenantClassId),
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('Autre tenant'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ACTIVE,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
);
|
||||
$this->classRepository->save($otherClass);
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(classId: $otherTenantClassId);
|
||||
|
||||
$this->expectException(ClasseNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenClassIsArchived(): void
|
||||
{
|
||||
$archivedClassId = '550e8400-e29b-41d4-a716-446655440081';
|
||||
$archivedClass = SchoolClass::reconstitute(
|
||||
id: ClassId::fromString($archivedClassId),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('Archivée'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ARCHIVED,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
);
|
||||
$this->classRepository->save($archivedClass);
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(classId: $archivedClassId);
|
||||
|
||||
$this->expectException(ClasseNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
private function seedTestData(): void
|
||||
{
|
||||
$class = SchoolClass::reconstitute(
|
||||
id: ClassId::fromString(self::CLASS_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('6ème A'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ACTIVE,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
);
|
||||
$this->classRepository->save($class);
|
||||
}
|
||||
|
||||
private function createHandler(): CreateStudentHandler
|
||||
{
|
||||
return new CreateStudentHandler(
|
||||
$this->userRepository,
|
||||
$this->classAssignmentRepository,
|
||||
$this->classRepository,
|
||||
$this->connection,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createCommand(
|
||||
?string $email = 'eleve@example.com',
|
||||
?string $classId = null,
|
||||
?string $dateNaissance = null,
|
||||
?string $studentNumber = null,
|
||||
): CreateStudentCommand {
|
||||
return new CreateStudentCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
schoolName: 'École Test',
|
||||
firstName: 'Marie',
|
||||
lastName: 'Dupont',
|
||||
classId: $classId ?? self::CLASS_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
email: $email,
|
||||
dateNaissance: $dateNaissance,
|
||||
studentNumber: $studentNumber,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,25 +6,36 @@ namespace App\Tests\Unit\Administration\Application\Query\HasStudentsInClass;
|
||||
|
||||
use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassHandler;
|
||||
use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassQuery;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for HasStudentsInClassHandler.
|
||||
*
|
||||
* Currently returns 0 (stub) until the student module is available.
|
||||
* These tests document the expected behavior for when the implementation
|
||||
* is completed.
|
||||
*/
|
||||
final class HasStudentsInClassHandlerTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function returnsZeroForAnyClass(): void
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
|
||||
|
||||
private InMemoryClassAssignmentRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$handler = new HasStudentsInClassHandler();
|
||||
$this->repository = new InMemoryClassAssignmentRepository();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsZeroWhenNoStudentsAssigned(): void
|
||||
{
|
||||
$handler = new HasStudentsInClassHandler($this->repository);
|
||||
|
||||
$query = new HasStudentsInClassQuery(
|
||||
classId: '550e8400-e29b-41d4-a716-446655440020',
|
||||
classId: self::CLASS_ID,
|
||||
);
|
||||
|
||||
$result = ($handler)($query);
|
||||
@@ -33,28 +44,41 @@ final class HasStudentsInClassHandlerTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsIntegerType(): void
|
||||
public function returnsCountOfAssignedStudents(): void
|
||||
{
|
||||
$handler = new HasStudentsInClassHandler();
|
||||
$this->addAssignment('550e8400-e29b-41d4-a716-446655440010');
|
||||
$this->addAssignment('550e8400-e29b-41d4-a716-446655440011');
|
||||
|
||||
$query = new HasStudentsInClassQuery(
|
||||
classId: '550e8400-e29b-41d4-a716-446655440021',
|
||||
);
|
||||
$handler = new HasStudentsInClassHandler($this->repository);
|
||||
|
||||
$result = ($handler)($query);
|
||||
$result = ($handler)(new HasStudentsInClassQuery(classId: self::CLASS_ID));
|
||||
|
||||
self::assertIsInt($result);
|
||||
self::assertSame(2, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function isConsistentAcrossMultipleCalls(): void
|
||||
{
|
||||
$handler = new HasStudentsInClassHandler();
|
||||
$classId = '550e8400-e29b-41d4-a716-446655440022';
|
||||
$this->addAssignment('550e8400-e29b-41d4-a716-446655440010');
|
||||
|
||||
$result1 = ($handler)(new HasStudentsInClassQuery(classId: $classId));
|
||||
$result2 = ($handler)(new HasStudentsInClassQuery(classId: $classId));
|
||||
$handler = new HasStudentsInClassHandler($this->repository);
|
||||
|
||||
$result1 = ($handler)(new HasStudentsInClassQuery(classId: self::CLASS_ID));
|
||||
$result2 = ($handler)(new HasStudentsInClassQuery(classId: self::CLASS_ID));
|
||||
|
||||
self::assertSame($result1, $result2);
|
||||
self::assertSame(1, $result1);
|
||||
}
|
||||
|
||||
private function addAssignment(string $studentId): void
|
||||
{
|
||||
$assignment = ClassAssignment::affecter(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
studentId: UserId::fromString($studentId),
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
assignedAt: new DateTimeImmutable('2026-02-21 10:00:00'),
|
||||
);
|
||||
$this->repository->save($assignment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Domain\Model\ClassAssignment;
|
||||
|
||||
use App\Administration\Domain\Event\EleveAffecteAClasse;
|
||||
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ClassAssignmentTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
|
||||
|
||||
#[Test]
|
||||
public function affecterCreeAffectationAvecIdUnique(): void
|
||||
{
|
||||
$assignment = $this->createAssignment();
|
||||
|
||||
self::assertNotEmpty((string) $assignment->id);
|
||||
self::assertTrue($assignment->tenantId->equals(TenantId::fromString(self::TENANT_ID)));
|
||||
self::assertTrue($assignment->studentId->equals(UserId::fromString(self::STUDENT_ID)));
|
||||
self::assertTrue($assignment->classId->equals(ClassId::fromString(self::CLASS_ID)));
|
||||
self::assertTrue($assignment->academicYearId->equals(AcademicYearId::fromString(self::ACADEMIC_YEAR_ID)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function affecterEnregistreEvenementEleveAffecteAClasse(): void
|
||||
{
|
||||
$assignment = $this->createAssignment();
|
||||
|
||||
$events = $assignment->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(EleveAffecteAClasse::class, $events[0]);
|
||||
self::assertTrue($events[0]->studentId->equals(UserId::fromString(self::STUDENT_ID)));
|
||||
self::assertTrue($events[0]->classId->equals(ClassId::fromString(self::CLASS_ID)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function affecterInitialiseUpdatedAtAvecCreatedAt(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-21 10:00:00');
|
||||
|
||||
$assignment = ClassAssignment::affecter(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
assignedAt: $now,
|
||||
);
|
||||
|
||||
self::assertSame($now, $assignment->createdAt);
|
||||
self::assertSame($now, $assignment->updatedAt);
|
||||
self::assertSame($now, $assignment->assignedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function changerClasseModifieLaClasseEtUpdatedAt(): void
|
||||
{
|
||||
$assignment = $this->createAssignment();
|
||||
$assignment->pullDomainEvents(); // Clear creation event
|
||||
|
||||
$newClassId = ClassId::fromString('550e8400-e29b-41d4-a716-446655440099');
|
||||
$changedAt = new DateTimeImmutable('2026-03-15 14:00:00');
|
||||
|
||||
$assignment->changerClasse($newClassId, $changedAt);
|
||||
|
||||
self::assertTrue($assignment->classId->equals($newClassId));
|
||||
self::assertSame($changedAt, $assignment->updatedAt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function changerClasseEnregistreEvenementEleveAffecteAClasse(): void
|
||||
{
|
||||
$assignment = $this->createAssignment();
|
||||
$assignment->pullDomainEvents(); // Clear creation event
|
||||
|
||||
$newClassId = ClassId::fromString('550e8400-e29b-41d4-a716-446655440099');
|
||||
$changedAt = new DateTimeImmutable('2026-03-15 14:00:00');
|
||||
|
||||
$assignment->changerClasse($newClassId, $changedAt);
|
||||
|
||||
$events = $assignment->pullDomainEvents();
|
||||
|
||||
self::assertCount(1, $events);
|
||||
self::assertInstanceOf(EleveAffecteAClasse::class, $events[0]);
|
||||
self::assertTrue($events[0]->classId->equals($newClassId));
|
||||
self::assertSame($changedAt, $events[0]->occurredOn());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reconstituteNeGenereAucunEvenement(): void
|
||||
{
|
||||
$assignment = ClassAssignment::reconstitute(
|
||||
id: \App\Administration\Domain\Model\ClassAssignment\ClassAssignmentId::fromString('550e8400-e29b-41d4-a716-446655440050'),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
assignedAt: new DateTimeImmutable('2026-02-21 10:00:00'),
|
||||
createdAt: new DateTimeImmutable('2026-02-21 10:00:00'),
|
||||
updatedAt: new DateTimeImmutable('2026-02-21 12:00:00'),
|
||||
);
|
||||
|
||||
self::assertEmpty($assignment->pullDomainEvents());
|
||||
}
|
||||
|
||||
private function createAssignment(): ClassAssignment
|
||||
{
|
||||
return ClassAssignment::affecter(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
studentId: UserId::fromString(self::STUDENT_ID),
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
assignedAt: new DateTimeImmutable('2026-02-21 10:00:00'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Administration\Infrastructure\Security\StudentVoter;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
final class StudentVoterTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
private StudentVoter $voter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->voter = new StudentVoter();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAbstainsForUnrelatedAttributes(): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser(Role::ADMIN->value);
|
||||
|
||||
$result = $this->voter->vote($token, null, ['SOME_OTHER_ATTRIBUTE']);
|
||||
|
||||
self::assertSame(Voter::ACCESS_ABSTAIN, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessToUnauthenticatedUsers(): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn(null);
|
||||
|
||||
$result = $this->voter->vote($token, null, [StudentVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeniesAccessToNonSecurityUserInstances(): void
|
||||
{
|
||||
$user = $this->createMock(UserInterface::class);
|
||||
$user->method('getRoles')->willReturn([Role::ADMIN->value]);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($user);
|
||||
|
||||
$result = $this->voter->vote($token, null, [StudentVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- VIEW ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('allowedRolesProvider')]
|
||||
public function itGrantsViewToAllowedRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [StudentVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('deniedRolesProvider')]
|
||||
public function itDeniesViewToOtherRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [StudentVoter::VIEW]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- CREATE ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('allowedRolesProvider')]
|
||||
public function itGrantsCreateToAllowedRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [StudentVoter::CREATE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('deniedRolesProvider')]
|
||||
public function itDeniesCreateToOtherRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [StudentVoter::CREATE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- MANAGE ---
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('allowedRolesProvider')]
|
||||
public function itGrantsManageToAllowedRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [StudentVoter::MANAGE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_GRANTED, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('deniedRolesProvider')]
|
||||
public function itDeniesManageToOtherRoles(string $role): void
|
||||
{
|
||||
$token = $this->tokenWithSecurityUser($role);
|
||||
|
||||
$result = $this->voter->vote($token, null, [StudentVoter::MANAGE]);
|
||||
|
||||
self::assertSame(Voter::ACCESS_DENIED, $result);
|
||||
}
|
||||
|
||||
// --- Data Providers ---
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function allowedRolesProvider(): iterable
|
||||
{
|
||||
yield 'SUPER_ADMIN' => [Role::SUPER_ADMIN->value];
|
||||
yield 'ADMIN' => [Role::ADMIN->value];
|
||||
yield 'SECRETARIAT' => [Role::SECRETARIAT->value];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function deniedRolesProvider(): iterable
|
||||
{
|
||||
yield 'PROF' => [Role::PROF->value];
|
||||
yield 'VIE_SCOLAIRE' => [Role::VIE_SCOLAIRE->value];
|
||||
yield 'PARENT' => [Role::PARENT->value];
|
||||
yield 'ELEVE' => [Role::ELEVE->value];
|
||||
}
|
||||
|
||||
private function tokenWithSecurityUser(string $role): TokenInterface
|
||||
{
|
||||
$securityUser = new SecurityUser(
|
||||
UserId::fromString('550e8400-e29b-41d4-a716-446655440010'),
|
||||
'test@example.com',
|
||||
'hashed_password',
|
||||
TenantId::fromString(self::TENANT_ID),
|
||||
[$role],
|
||||
);
|
||||
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn($securityUser);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,10 @@ test.describe('Classes Management (Story 2.1)', () => {
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM class_assignments WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
@@ -78,6 +82,10 @@ test.describe('Classes Management (Story 2.1)', () => {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM class_assignments WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
|
||||
642
frontend/e2e/student-creation.spec.ts
Normal file
642
frontend/e2e/student-creation.spec.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
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 TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const ADMIN_EMAIL = 'e2e-student-creation-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'StudentCreationTest123';
|
||||
|
||||
const UNIQUE_SUFFIX = Date.now();
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
function runCommand(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
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, academicYearId };
|
||||
}
|
||||
|
||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function waitForStudentsPage(page: import('@playwright/test').Page) {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /gestion des élèves/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
page.locator('.empty-state, .students-table, .alert-error')
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
let testClassId: string;
|
||||
let testClassId2: string;
|
||||
|
||||
function createBulkStudents(count: number, tenantId: string, classId: string, academicYearId: string) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const suffix = UNIQUE_SUFFIX.toString().slice(-8);
|
||||
const paddedI = String(i).padStart(4, '0');
|
||||
const userId = `00000000-e2e0-4000-8000-${suffix}${paddedI}`;
|
||||
const assignmentId = `00000001-e2e0-4000-8000-${suffix}${paddedI}`;
|
||||
try {
|
||||
runCommand(
|
||||
`INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, hashed_password, statut, school_name, date_naissance, created_at, activated_at, invited_at, blocked_at, blocked_reason, consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip, image_rights_status, image_rights_updated_at, image_rights_updated_by, student_number, updated_at) VALUES ('${userId}', '${tenantId}', NULL, 'Pagination${i}', 'Student-${UNIQUE_SUFFIX}', '[\\"ROLE_ELEVE\\"]', NULL, 'inscrit', 'E2E Test School', NULL, NOW(), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'not_specified', NULL, NULL, NULL, NOW()) ON CONFLICT (id) DO NOTHING`
|
||||
);
|
||||
runCommand(
|
||||
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) VALUES ('${assignmentId}', '${tenantId}', '${userId}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// Student may already exist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Student Creation & Management (Story 3.0)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create admin user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
// Create a test class for student assignment
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
testClassId = `e2e-class-${UNIQUE_SUFFIX}`.substring(0, 36).padEnd(36, '0');
|
||||
// Use a valid UUID format
|
||||
testClassId = `00000000-0000-0000-0000-${UNIQUE_SUFFIX.toString().padStart(12, '0')}`;
|
||||
|
||||
try {
|
||||
runCommand(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${testClassId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Test Class ${UNIQUE_SUFFIX}', 'CM2', 30, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// Class may already exist
|
||||
}
|
||||
|
||||
// Create a second test class for change-class tests
|
||||
testClassId2 = `00000001-0000-0000-0000-${UNIQUE_SUFFIX.toString().padStart(12, '0')}`;
|
||||
try {
|
||||
runCommand(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${testClassId2}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Test Class 2 ${UNIQUE_SUFFIX}', 'CE2', 30, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// Class may already exist
|
||||
}
|
||||
|
||||
// Create 31 students for pagination tests (itemsPerPage = 30)
|
||||
createBulkStudents(31, TENANT_ID, testClassId, academicYearId);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Navigation
|
||||
// ============================================================================
|
||||
test.describe('Navigation', () => {
|
||||
test('students page is accessible from admin nav', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /gestion des élèves/i })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
});
|
||||
|
||||
test('page title is set correctly', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
|
||||
await expect(page).toHaveTitle(/gestion des élèves/i);
|
||||
});
|
||||
|
||||
test('nav menu shows Élèves link', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
|
||||
// The nav should have an active "Élèves" link
|
||||
await expect(page.locator('nav a', { hasText: /élèves/i })).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC1: Create Student Form
|
||||
// ============================================================================
|
||||
test.describe('AC1 - Create Student Modal', () => {
|
||||
test('can open create student modal', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouvel élève/i }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: /nouvel élève/i })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('modal has all required fields', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouvel élève/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Required fields
|
||||
await expect(dialog.locator('#student-lastname')).toBeVisible();
|
||||
await expect(dialog.locator('#student-firstname')).toBeVisible();
|
||||
await expect(dialog.locator('#student-class')).toBeVisible();
|
||||
|
||||
// Optional fields
|
||||
await expect(dialog.locator('#student-email')).toBeVisible();
|
||||
await expect(dialog.locator('#student-dob')).toBeVisible();
|
||||
await expect(dialog.locator('#student-ine')).toBeVisible();
|
||||
});
|
||||
|
||||
test('class dropdown uses optgroup by level', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouvel élève/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Check optgroups exist in the class select
|
||||
const optgroups = dialog.locator('#student-class optgroup');
|
||||
const count = await optgroups.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('can close modal with cancel button', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouvel élève/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await dialog.getByRole('button', { name: /annuler/i }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('can close modal with Escape key', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouvel élève/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Focus the modal so the keydown handler receives the event
|
||||
await dialog.focus();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC2: Create student with class assignment
|
||||
// ============================================================================
|
||||
test.describe('AC2 - Student Creation', () => {
|
||||
test('can create a student without email', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouvel élève/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Fill required fields
|
||||
await dialog.locator('#student-lastname').fill(`Dupont-${UNIQUE_SUFFIX}`);
|
||||
await dialog.locator('#student-firstname').fill('Marie');
|
||||
|
||||
// Select first available class
|
||||
await dialog.locator('#student-class').selectOption({ index: 1 });
|
||||
|
||||
// Submit
|
||||
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
|
||||
|
||||
// Success message should appear
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.alert-success')).toContainText(/inscrit/i);
|
||||
|
||||
// Student should appear in the list
|
||||
await expect(page.locator('.students-table')).toBeVisible({ timeout: 5000 });
|
||||
await expect(
|
||||
page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('can create a student with email', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouvel élève/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await dialog.locator('#student-lastname').fill(`Martin-${UNIQUE_SUFFIX}`);
|
||||
await dialog.locator('#student-firstname').fill('Jean');
|
||||
await dialog.locator('#student-email').fill(`jean.martin.${UNIQUE_SUFFIX}@example.com`);
|
||||
await dialog.locator('#student-class').selectOption({ index: 1 });
|
||||
|
||||
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
|
||||
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.alert-success')).toContainText(/invitation/i);
|
||||
});
|
||||
|
||||
test('"Créer un autre" keeps modal open', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouvel élève/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Check "Créer un autre élève"
|
||||
await dialog.locator('input[type="checkbox"]').check();
|
||||
|
||||
await dialog.locator('#student-lastname').fill(`Bernard-${UNIQUE_SUFFIX}`);
|
||||
await dialog.locator('#student-firstname').fill('Luc');
|
||||
await dialog.locator('#student-class').selectOption({ index: 1 });
|
||||
|
||||
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
|
||||
|
||||
// Success should appear but modal stays open
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Form fields should be cleared
|
||||
await expect(dialog.locator('#student-lastname')).toHaveValue('');
|
||||
await expect(dialog.locator('#student-firstname')).toHaveValue('');
|
||||
|
||||
// Close the modal
|
||||
await dialog.getByRole('button', { name: /annuler/i }).click();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC3: Data validation
|
||||
// ============================================================================
|
||||
test.describe('AC3 - Validation', () => {
|
||||
test('[P0] INE validation shows error for invalid format', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouvel élève/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Fill required fields
|
||||
await dialog.locator('#student-firstname').fill('Test');
|
||||
await dialog.locator('#student-lastname').fill('INEValidation');
|
||||
await dialog.locator('#student-class').selectOption({ index: 1 });
|
||||
|
||||
// Enter invalid INE (too short)
|
||||
await dialog.locator('#student-ine').fill('ABC');
|
||||
|
||||
// Error message should appear
|
||||
await expect(dialog.locator('.field-error')).toBeVisible();
|
||||
await expect(dialog.locator('.field-error')).toContainText(/11 caractères/i);
|
||||
|
||||
// Submit button should be disabled
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: /créer l'élève/i })
|
||||
).toBeDisabled();
|
||||
|
||||
// Fix INE to valid format (11 alphanumeric chars)
|
||||
await dialog.locator('#student-ine').fill('12345678901');
|
||||
|
||||
// Error should disappear
|
||||
await expect(dialog.locator('.field-error')).not.toBeVisible();
|
||||
|
||||
// Submit button should be enabled
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: /créer l'élève/i })
|
||||
).toBeEnabled();
|
||||
|
||||
// Close modal without creating
|
||||
await dialog.getByRole('button', { name: /annuler/i }).click();
|
||||
});
|
||||
|
||||
test('[P0] shows error when email is already used', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouvel élève/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Use the same email as the student created in AC2
|
||||
await dialog.locator('#student-firstname').fill('Doublon');
|
||||
await dialog.locator('#student-lastname').fill('Email');
|
||||
await dialog
|
||||
.locator('#student-email')
|
||||
.fill(`jean.martin.${UNIQUE_SUFFIX}@example.com`);
|
||||
await dialog.locator('#student-class').selectOption({ index: 1 });
|
||||
|
||||
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
|
||||
|
||||
// Error should appear (from API)
|
||||
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.alert-error')).toContainText(/email/i);
|
||||
|
||||
// Close modal
|
||||
await dialog.getByRole('button', { name: /annuler/i }).click();
|
||||
});
|
||||
|
||||
test('[P0] shows duplicate warning for same name in same class', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouvel élève/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Use same name as student created in AC2
|
||||
await dialog.locator('#student-firstname').fill('Marie');
|
||||
await dialog.locator('#student-lastname').fill(`Dupont-${UNIQUE_SUFFIX}`);
|
||||
await dialog.locator('#student-class').selectOption({ index: 1 });
|
||||
|
||||
// Submit — should trigger duplicate check
|
||||
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
|
||||
|
||||
// Duplicate warning should appear
|
||||
await expect(dialog.locator('.duplicate-warning')).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
await expect(dialog.locator('.duplicate-warning')).toContainText(/existe déjà/i);
|
||||
|
||||
// Click "Annuler" — warning disappears
|
||||
await dialog
|
||||
.locator('.duplicate-warning')
|
||||
.getByRole('button', { name: /annuler/i })
|
||||
.click();
|
||||
await expect(dialog.locator('.duplicate-warning')).not.toBeVisible();
|
||||
|
||||
// Submit again — warning reappears
|
||||
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
|
||||
await expect(dialog.locator('.duplicate-warning')).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
// Click "Continuer" — creation succeeds despite duplicate
|
||||
await dialog
|
||||
.locator('.duplicate-warning')
|
||||
.getByRole('button', { name: /continuer/i })
|
||||
.click();
|
||||
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC4: Students listing page
|
||||
// ============================================================================
|
||||
test.describe('AC4 - Students List', () => {
|
||||
test('displays students in a table', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
// Table should have headers
|
||||
await expect(page.locator('.students-table th', { hasText: /nom/i })).toBeVisible();
|
||||
await expect(page.locator('.students-table th', { hasText: /classe/i })).toBeVisible();
|
||||
await expect(page.locator('.students-table th', { hasText: /statut/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('search filters students by name', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
// Search for a student created earlier
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`);
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should find the student (use .first() because AC3 duplicate test creates a second one)
|
||||
await expect(
|
||||
page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first()
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('rows are clickable and navigate to student detail', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
// Search for specific student
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`);
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click on the row (use .first() because AC3 duplicate test creates a second one)
|
||||
const row = page.locator('.clickable-row', {
|
||||
hasText: `Dupont-${UNIQUE_SUFFIX}`
|
||||
}).first();
|
||||
await row.click();
|
||||
|
||||
// Should navigate to student detail page
|
||||
await expect(page).toHaveURL(/\/admin\/students\/[a-f0-9-]+/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /fiche élève/i })
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('[P1] pagination appears when more than 30 students and navigation works', async ({
|
||||
page
|
||||
}) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
// Pagination nav should be visible (31 students created in beforeAll)
|
||||
const paginationNav = page.locator('nav[aria-label="Pagination"]');
|
||||
await expect(paginationNav).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// "Précédent" button should be disabled on page 1
|
||||
await expect(
|
||||
paginationNav.getByRole('button', { name: /précédent/i })
|
||||
).toBeDisabled();
|
||||
|
||||
// Page 1 button should be active
|
||||
await expect(
|
||||
paginationNav.getByRole('button', { name: 'Page 1', exact: true })
|
||||
).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
// Click "Suivant" to go to page 2
|
||||
await paginationNav.getByRole('button', { name: /suivant/i }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// URL should contain page=2
|
||||
await expect(page).toHaveURL(/page=2/);
|
||||
|
||||
// Page 2 button should now be active
|
||||
await expect(
|
||||
paginationNav.getByRole('button', { name: 'Page 2', exact: true })
|
||||
).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
// "Précédent" should now be enabled
|
||||
await expect(
|
||||
paginationNav.getByRole('button', { name: /précédent/i })
|
||||
).toBeEnabled();
|
||||
|
||||
// Table or content should still be visible (not error)
|
||||
await expect(
|
||||
page.locator('.students-table, .empty-state')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC5: Change student class
|
||||
// ============================================================================
|
||||
test.describe('AC5 - Change Student Class', () => {
|
||||
test('[P1] can change class via modal with confirmation and optimistic update', async ({
|
||||
page
|
||||
}) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
// Search for a student created earlier
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`);
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Find the student row and click "Changer de classe" (use .first() because AC3 duplicate test creates a second one)
|
||||
const row = page.locator('.clickable-row', {
|
||||
hasText: `Dupont-${UNIQUE_SUFFIX}`
|
||||
}).first();
|
||||
await expect(row).toBeVisible({ timeout: 10000 });
|
||||
await row.locator('button', { hasText: /changer de classe/i }).click();
|
||||
|
||||
// Change class modal should open
|
||||
const dialog = page.locator('[role="alertdialog"]');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: /changer de classe/i })
|
||||
).toBeVisible();
|
||||
|
||||
// Description should mention the student name
|
||||
await expect(dialog.locator('#change-class-description')).toContainText(
|
||||
`Dupont-${UNIQUE_SUFFIX}`
|
||||
);
|
||||
|
||||
// Select a different class
|
||||
await dialog.locator('#change-class-select').selectOption({ index: 1 });
|
||||
|
||||
// Confirmation text should appear
|
||||
await expect(dialog.locator('.change-confirm-info')).toBeVisible();
|
||||
await expect(dialog.locator('.change-confirm-info')).toContainText(/transférer/i);
|
||||
|
||||
// Click "Confirmer le transfert"
|
||||
await dialog.getByRole('button', { name: /confirmer le transfert/i }).click();
|
||||
|
||||
// Success message should appear
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.alert-success')).toContainText(/transféré/i);
|
||||
|
||||
// Modal should close
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC6: Filter by class
|
||||
// ============================================================================
|
||||
test.describe('AC6 - Class Filter', () => {
|
||||
test('class filter dropdown exists with optgroups', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
const filterSelect = page.locator('#filter-class');
|
||||
await expect(filterSelect).toBeVisible();
|
||||
|
||||
// Should have optgroups
|
||||
const optgroups = filterSelect.locator('optgroup');
|
||||
const count = await optgroups.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('[P2] selecting a class filters the student list', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||
await waitForStudentsPage(page);
|
||||
|
||||
// Select a class in the filter
|
||||
const filterSelect = page.locator('#filter-class');
|
||||
await filterSelect.selectOption({ index: 1 });
|
||||
|
||||
// Wait for the list to reload
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// URL should contain classId parameter
|
||||
await expect(page).toHaveURL(/classId=/);
|
||||
|
||||
// The page should still show the table or empty state (not an error)
|
||||
await expect(
|
||||
page.locator('.students-table, .empty-state')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Reset filter
|
||||
await filterSelect.selectOption({ value: '' });
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// classId should be removed from URL (polling assertion for reliability)
|
||||
await expect(page).not.toHaveURL(/classId=/, { timeout: 10000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -126,7 +126,7 @@ test.describe('Student Management', () => {
|
||||
await expect(page).toHaveTitle(/fiche élève/i);
|
||||
});
|
||||
|
||||
test('back link navigates to users page', async ({ page }) => {
|
||||
test('back link navigates to students page', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
|
||||
|
||||
@@ -136,8 +136,8 @@ test.describe('Student Management', () => {
|
||||
// Click the back link
|
||||
await page.locator('.back-link').click();
|
||||
|
||||
// Should navigate to users page
|
||||
await expect(page).toHaveURL(/\/admin\/users/);
|
||||
// Should navigate to students page
|
||||
await expect(page).toHaveURL(/\/admin\/students/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
<span class="action-label">Configurer les classes</span>
|
||||
<span class="action-hint">Créer et gérer</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/students">
|
||||
<span class="action-icon">🎒</span>
|
||||
<span class="action-label">Gérer les élèves</span>
|
||||
<span class="action-hint">Inscrire et affecter</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/subjects">
|
||||
<span class="action-icon">📚</span>
|
||||
<span class="action-label">Gérer les matières</span>
|
||||
|
||||
150
frontend/src/lib/features/students/api/students.ts
Normal file
150
frontend/src/lib/features/students/api/students.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { getApiBaseUrl } from '$lib/api';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
|
||||
export interface Student {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string | null;
|
||||
classId: string | null;
|
||||
className: string | null;
|
||||
classLevel: string | null;
|
||||
statut: string;
|
||||
studentNumber: string | null;
|
||||
dateNaissance: string | null;
|
||||
}
|
||||
|
||||
export interface SchoolClass {
|
||||
id: string;
|
||||
name: string;
|
||||
level: string | null;
|
||||
}
|
||||
|
||||
export interface FetchStudentsParams {
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
search?: string | undefined;
|
||||
classId?: string | undefined;
|
||||
signal?: AbortSignal | undefined;
|
||||
}
|
||||
|
||||
export interface FetchStudentsResult {
|
||||
members: Student[];
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export interface CreateStudentData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
classId: string;
|
||||
email?: string | undefined;
|
||||
dateNaissance?: string | undefined;
|
||||
studentNumber?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la liste paginée des élèves.
|
||||
*/
|
||||
export async function fetchStudents(params: FetchStudentsParams): Promise<FetchStudentsResult> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('page', String(params.page));
|
||||
searchParams.set('itemsPerPage', String(params.itemsPerPage));
|
||||
if (params.search) searchParams.set('search', params.search);
|
||||
if (params.classId) searchParams.set('classId', params.classId);
|
||||
|
||||
const options: RequestInit = {};
|
||||
if (params.signal) options.signal = params.signal;
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
`${apiUrl}/students?${searchParams.toString()}`,
|
||||
options
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement des élèves');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const members: Student[] =
|
||||
data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
|
||||
const totalItems: number = data['hydra:totalItems'] ?? data['totalItems'] ?? members.length;
|
||||
|
||||
return { members, totalItems };
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la liste des classes disponibles.
|
||||
*/
|
||||
export async function fetchClasses(): Promise<SchoolClass[]> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/classes?itemsPerPage=200`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement des classes');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouvel élève.
|
||||
*/
|
||||
export async function createStudent(studentData: CreateStudentData): Promise<Student> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const body: Record<string, string> = {
|
||||
firstName: studentData.firstName,
|
||||
lastName: studentData.lastName,
|
||||
classId: studentData.classId
|
||||
};
|
||||
if (studentData.email) body['email'] = studentData.email;
|
||||
if (studentData.dateNaissance) body['dateNaissance'] = studentData.dateNaissance;
|
||||
if (studentData.studentNumber) body['studentNumber'] = studentData.studentNumber;
|
||||
|
||||
const response = await authenticatedFetch(`${apiUrl}/students`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Erreur lors de la création (${response.status})`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData['hydra:description']) errorMessage = errorData['hydra:description'];
|
||||
else if (errorData.message) errorMessage = errorData.message;
|
||||
else if (errorData.detail) errorMessage = errorData.detail;
|
||||
} catch {
|
||||
// JSON parsing failed
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change la classe d'un élève.
|
||||
*/
|
||||
export async function changeStudentClass(studentId: string, newClassId: string): Promise<void> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/students/${studentId}/class`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/merge-patch+json' },
|
||||
body: JSON.stringify({ classId: newClassId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Erreur lors du changement de classe (${response.status})`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData['hydra:description']) errorMessage = errorData['hydra:description'];
|
||||
else if (errorData.message) errorMessage = errorData.message;
|
||||
else if (errorData.detail) errorMessage = errorData.detail;
|
||||
} catch {
|
||||
// JSON parsing failed
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
const navLinks = [
|
||||
{ href: '/dashboard', label: 'Tableau de bord', isActive: () => false },
|
||||
{ href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive },
|
||||
{ href: '/admin/students', label: 'Élèves', isActive: () => isStudentsActive },
|
||||
{ href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive },
|
||||
{ href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive },
|
||||
{ href: '/admin/assignments', label: 'Affectations', isActive: () => isAssignmentsActive },
|
||||
@@ -81,6 +82,7 @@
|
||||
|
||||
// Determine which admin section is active
|
||||
const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users'));
|
||||
const isStudentsActive = $derived(page.url.pathname.startsWith('/admin/students'));
|
||||
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
|
||||
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
|
||||
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));
|
||||
|
||||
1292
frontend/src/routes/admin/students/+page.svelte
Normal file
1292
frontend/src/routes/admin/students/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@
|
||||
|
||||
<div class="student-detail">
|
||||
<header class="page-header">
|
||||
<a href="/admin/users" class="back-link">← Retour</a>
|
||||
<a href="/admin/students" class="back-link">← Retour</a>
|
||||
<h1>Fiche élève</h1>
|
||||
</header>
|
||||
|
||||
|
||||
300
frontend/tests/unit/lib/features/students/api/students.test.ts
Normal file
300
frontend/tests/unit/lib/features/students/api/students.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api', () => ({
|
||||
getApiBaseUrl: () => 'http://test.classeo.local:18000/api'
|
||||
}));
|
||||
|
||||
const mockAuthenticatedFetch = vi.fn();
|
||||
vi.mock('$lib/auth', () => ({
|
||||
authenticatedFetch: (...args: unknown[]) => mockAuthenticatedFetch(...args)
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchStudents,
|
||||
fetchClasses,
|
||||
createStudent,
|
||||
changeStudentClass
|
||||
} from '$lib/features/students/api/students';
|
||||
|
||||
describe('students API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// fetchStudents
|
||||
// ==========================================================================
|
||||
describe('fetchStudents', () => {
|
||||
it('should return members and totalItems on success', async () => {
|
||||
const mockStudents = [
|
||||
{
|
||||
id: 'student-1',
|
||||
firstName: 'Marie',
|
||||
lastName: 'Dupont',
|
||||
email: null,
|
||||
classId: 'class-1',
|
||||
className: '6ème A',
|
||||
classLevel: 'sixieme',
|
||||
statut: 'inscrit',
|
||||
studentNumber: null,
|
||||
dateNaissance: null
|
||||
}
|
||||
];
|
||||
|
||||
mockAuthenticatedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
'hydra:member': mockStudents,
|
||||
'hydra:totalItems': 1
|
||||
})
|
||||
});
|
||||
|
||||
const result = await fetchStudents({ page: 1, itemsPerPage: 30 });
|
||||
|
||||
expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
|
||||
'http://test.classeo.local:18000/api/students?page=1&itemsPerPage=30',
|
||||
{}
|
||||
);
|
||||
expect(result.members).toHaveLength(1);
|
||||
expect(result.members[0]!.firstName).toBe('Marie');
|
||||
expect(result.totalItems).toBe(1);
|
||||
});
|
||||
|
||||
it('should pass search and classId params when provided', async () => {
|
||||
mockAuthenticatedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
'hydra:member': [],
|
||||
'hydra:totalItems': 0
|
||||
})
|
||||
});
|
||||
|
||||
await fetchStudents({
|
||||
page: 2,
|
||||
itemsPerPage: 30,
|
||||
search: 'Dupont',
|
||||
classId: 'class-1'
|
||||
});
|
||||
|
||||
expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
|
||||
'http://test.classeo.local:18000/api/students?page=2&itemsPerPage=30&search=Dupont&classId=class-1',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when API response is not ok', async () => {
|
||||
mockAuthenticatedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500
|
||||
});
|
||||
|
||||
await expect(fetchStudents({ page: 1, itemsPerPage: 30 })).rejects.toThrow(
|
||||
'Erreur lors du chargement des élèves'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// fetchClasses
|
||||
// ==========================================================================
|
||||
describe('fetchClasses', () => {
|
||||
it('should return classes array on success', async () => {
|
||||
const mockClasses = [
|
||||
{ id: 'class-1', name: '6ème A', level: 'sixieme' },
|
||||
{ id: 'class-2', name: '5ème B', level: 'cinquieme' }
|
||||
];
|
||||
|
||||
mockAuthenticatedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ 'hydra:member': mockClasses })
|
||||
});
|
||||
|
||||
const result = await fetchClasses();
|
||||
|
||||
expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
|
||||
'http://test.classeo.local:18000/api/classes?itemsPerPage=200'
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.name).toBe('6ème A');
|
||||
});
|
||||
|
||||
it('should throw when API response is not ok', async () => {
|
||||
mockAuthenticatedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500
|
||||
});
|
||||
|
||||
await expect(fetchClasses()).rejects.toThrow('Erreur lors du chargement des classes');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// createStudent
|
||||
// ==========================================================================
|
||||
describe('createStudent', () => {
|
||||
it('should return created student on success', async () => {
|
||||
const created = {
|
||||
id: 'new-student-id',
|
||||
firstName: 'Marie',
|
||||
lastName: 'Dupont',
|
||||
email: null,
|
||||
classId: 'class-1',
|
||||
className: '6ème A',
|
||||
classLevel: 'sixieme',
|
||||
statut: 'inscrit',
|
||||
studentNumber: null,
|
||||
dateNaissance: null
|
||||
};
|
||||
|
||||
mockAuthenticatedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(created)
|
||||
});
|
||||
|
||||
const result = await createStudent({
|
||||
firstName: 'Marie',
|
||||
lastName: 'Dupont',
|
||||
classId: 'class-1'
|
||||
});
|
||||
|
||||
expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
|
||||
'http://test.classeo.local:18000/api/students',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: 'Marie',
|
||||
lastName: 'Dupont',
|
||||
classId: 'class-1'
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(result.id).toBe('new-student-id');
|
||||
expect(result.firstName).toBe('Marie');
|
||||
});
|
||||
|
||||
it('should include optional fields when provided', async () => {
|
||||
mockAuthenticatedFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'new-id',
|
||||
firstName: 'Marie',
|
||||
lastName: 'Dupont',
|
||||
email: 'marie@example.com',
|
||||
classId: 'class-1',
|
||||
className: '6ème A',
|
||||
classLevel: 'sixieme',
|
||||
statut: 'pending',
|
||||
studentNumber: '12345',
|
||||
dateNaissance: '2015-06-15'
|
||||
})
|
||||
});
|
||||
|
||||
await createStudent({
|
||||
firstName: 'Marie',
|
||||
lastName: 'Dupont',
|
||||
classId: 'class-1',
|
||||
email: 'marie@example.com',
|
||||
dateNaissance: '2015-06-15',
|
||||
studentNumber: '12345'
|
||||
});
|
||||
|
||||
expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
|
||||
'http://test.classeo.local:18000/api/students',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
firstName: 'Marie',
|
||||
lastName: 'Dupont',
|
||||
classId: 'class-1',
|
||||
email: 'marie@example.com',
|
||||
dateNaissance: '2015-06-15',
|
||||
studentNumber: '12345'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw with hydra:description on error', async () => {
|
||||
mockAuthenticatedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
'hydra:description': 'Cet email est déjà utilisé.'
|
||||
})
|
||||
});
|
||||
|
||||
await expect(
|
||||
createStudent({ firstName: 'Marie', lastName: 'Dupont', classId: 'class-1' })
|
||||
).rejects.toThrow('Cet email est déjà utilisé.');
|
||||
});
|
||||
|
||||
it('should throw generic message when error body is not valid JSON', async () => {
|
||||
mockAuthenticatedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.reject(new Error('Unexpected token'))
|
||||
});
|
||||
|
||||
await expect(
|
||||
createStudent({ firstName: 'Marie', lastName: 'Dupont', classId: 'class-1' })
|
||||
).rejects.toThrow('Erreur lors de la création (500)');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// changeStudentClass
|
||||
// ==========================================================================
|
||||
describe('changeStudentClass', () => {
|
||||
it('should call PATCH endpoint with correct body', async () => {
|
||||
mockAuthenticatedFetch.mockResolvedValueOnce({
|
||||
ok: true
|
||||
});
|
||||
|
||||
await changeStudentClass('student-1', 'class-2');
|
||||
|
||||
expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
|
||||
'http://test.classeo.local:18000/api/students/student-1/class',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/merge-patch+json' },
|
||||
body: JSON.stringify({ classId: 'class-2' })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw with hydra:description on error', async () => {
|
||||
mockAuthenticatedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
'hydra:description': 'Élève non trouvé.'
|
||||
})
|
||||
});
|
||||
|
||||
await expect(changeStudentClass('student-1', 'class-2')).rejects.toThrow(
|
||||
'Élève non trouvé.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw generic message when error body is not valid JSON', async () => {
|
||||
mockAuthenticatedFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.reject(new Error('Unexpected token'))
|
||||
});
|
||||
|
||||
await expect(changeStudentClass('student-1', 'class-2')).rejects.toThrow(
|
||||
'Erreur lors du changement de classe (500)'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user