feat: Permettre à l'élève de consulter ses notes et moyennes
L'élève avait accès à ses compétences mais pas à ses notes numériques. Cette fonctionnalité lui donne une vue complète de sa progression scolaire avec moyennes par matière, détail par évaluation, statistiques de classe, et un mode "découverte" pour révéler ses notes à son rythme (FR14, FR15). Les notes ne sont visibles qu'après publication par l'enseignant, ce qui garantit que l'élève les découvre avant ses parents (délai 24h story 6.7).
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Infrastructure\Api\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Port\PeriodFinder;
|
||||
use App\Scolarite\Application\Port\PeriodInfo;
|
||||
use App\Scolarite\Infrastructure\Api\Provider\StudentMyAveragesProvider;
|
||||
use App\Scolarite\Infrastructure\Api\Resource\StudentMyAveragesResource;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryStudentAverageRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfraTenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
|
||||
final class StudentMyAveragesProviderTest extends TestCase
|
||||
{
|
||||
private const string TENANT_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
private const string STUDENT_UUID = '22222222-2222-2222-2222-222222222222';
|
||||
private const string SUBJECT_UUID = '66666666-6666-6666-6666-666666666666';
|
||||
private const string PERIOD_ID = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
private InMemoryStudentAverageRepository $averageRepository;
|
||||
private TenantContext $tenantContext;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->averageRepository = new InMemoryStudentAverageRepository();
|
||||
$this->tenantContext = new TenantContext();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Auth & Tenant Guards
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function itRejects401WhenNoTenant(): void
|
||||
{
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
$provider->provide(new Get());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejects401WhenNoUser(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$provider = $this->createProvider(
|
||||
user: null,
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$this->expectException(UnauthorizedHttpException::class);
|
||||
$provider->provide(new Get());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejects403ForTeacher(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$provider = $this->createProvider(
|
||||
user: $this->teacherUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$provider->provide(new Get());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejects403ForParent(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$provider = $this->createProvider(
|
||||
user: $this->parentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$provider->provide(new Get());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejects403ForAdmin(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$provider = $this->createProvider(
|
||||
user: $this->adminUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$provider->provide(new Get());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Period auto-detection
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function itAutoDetectsCurrentPeriodWhenNoPeriodIdInFilters(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$this->seedAverages();
|
||||
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: new PeriodInfo(self::PERIOD_ID, new DateTimeImmutable('2026-01-01'), new DateTimeImmutable('2026-03-31')),
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get());
|
||||
|
||||
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||
self::assertSame(self::PERIOD_ID, $result->periodId);
|
||||
self::assertNotEmpty($result->subjectAverages);
|
||||
self::assertSame(16.0, $result->generalAverage);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptyResourceWhenNoPeriodDetected(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$this->seedAverages();
|
||||
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get());
|
||||
|
||||
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||
self::assertNull($result->periodId);
|
||||
self::assertEmpty($result->subjectAverages);
|
||||
self::assertNull($result->generalAverage);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Explicit periodId from filters
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function itUsesExplicitPeriodIdFromFilters(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$this->seedAverages();
|
||||
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get(), [], [
|
||||
'filters' => ['periodId' => self::PERIOD_ID],
|
||||
]);
|
||||
|
||||
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||
self::assertSame(self::PERIOD_ID, $result->periodId);
|
||||
self::assertNotEmpty($result->subjectAverages);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsEmptySubjectAveragesForUnknownPeriod(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$this->seedAverages();
|
||||
|
||||
$unknownPeriod = '99999999-9999-9999-9999-999999999999';
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get(), [], [
|
||||
'filters' => ['periodId' => $unknownPeriod],
|
||||
]);
|
||||
|
||||
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||
self::assertSame($unknownPeriod, $result->periodId);
|
||||
self::assertEmpty($result->subjectAverages);
|
||||
self::assertNull($result->generalAverage);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Response shape
|
||||
// =========================================================================
|
||||
|
||||
#[Test]
|
||||
public function itReturnsStudentIdInResource(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get(), [], [
|
||||
'filters' => ['periodId' => self::PERIOD_ID],
|
||||
]);
|
||||
|
||||
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||
self::assertSame(self::STUDENT_UUID, $result->studentId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsSubjectAverageShape(): void
|
||||
{
|
||||
$this->setTenant();
|
||||
$this->seedAverages();
|
||||
|
||||
$provider = $this->createProvider(
|
||||
user: $this->studentUser(),
|
||||
periodForDate: null,
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get(), [], [
|
||||
'filters' => ['periodId' => self::PERIOD_ID],
|
||||
]);
|
||||
|
||||
self::assertInstanceOf(StudentMyAveragesResource::class, $result);
|
||||
self::assertCount(1, $result->subjectAverages);
|
||||
|
||||
$avg = $result->subjectAverages[0];
|
||||
self::assertSame(self::SUBJECT_UUID, $avg['subjectId']);
|
||||
self::assertSame(16.0, $avg['average']);
|
||||
self::assertSame(1, $avg['gradeCount']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private function setTenant(): void
|
||||
{
|
||||
$this->tenantContext->setCurrentTenant(new TenantConfig(
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
subdomain: 'ecole-alpha',
|
||||
databaseUrl: 'sqlite:///:memory:',
|
||||
));
|
||||
}
|
||||
|
||||
private function seedAverages(): void
|
||||
{
|
||||
$tenantId = TenantId::fromString(self::TENANT_UUID);
|
||||
$studentId = UserId::fromString(self::STUDENT_UUID);
|
||||
|
||||
$this->averageRepository->saveSubjectAverage(
|
||||
$tenantId,
|
||||
$studentId,
|
||||
SubjectId::fromString(self::SUBJECT_UUID),
|
||||
self::PERIOD_ID,
|
||||
16.0,
|
||||
1,
|
||||
);
|
||||
|
||||
$this->averageRepository->saveGeneralAverage(
|
||||
$tenantId,
|
||||
$studentId,
|
||||
self::PERIOD_ID,
|
||||
16.0,
|
||||
);
|
||||
}
|
||||
|
||||
private function createProvider(?SecurityUser $user, ?PeriodInfo $periodForDate): StudentMyAveragesProvider
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($user);
|
||||
|
||||
$periodFinder = new class($periodForDate) implements PeriodFinder {
|
||||
public function __construct(private readonly ?PeriodInfo $info)
|
||||
{
|
||||
}
|
||||
|
||||
public function findForDate(DateTimeImmutable $date, TenantId $tenantId): ?PeriodInfo
|
||||
{
|
||||
return $this->info;
|
||||
}
|
||||
};
|
||||
|
||||
return new StudentMyAveragesProvider(
|
||||
$this->averageRepository,
|
||||
$periodFinder,
|
||||
$this->tenantContext,
|
||||
$security,
|
||||
);
|
||||
}
|
||||
|
||||
private function studentUser(): SecurityUser
|
||||
{
|
||||
return new SecurityUser(
|
||||
userId: UserId::fromString(self::STUDENT_UUID),
|
||||
email: 'student@test.local',
|
||||
hashedPassword: '',
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
roles: ['ROLE_ELEVE'],
|
||||
);
|
||||
}
|
||||
|
||||
private function teacherUser(): SecurityUser
|
||||
{
|
||||
return new SecurityUser(
|
||||
userId: UserId::fromString('44444444-4444-4444-4444-444444444444'),
|
||||
email: 'teacher@test.local',
|
||||
hashedPassword: '',
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
roles: ['ROLE_PROF'],
|
||||
);
|
||||
}
|
||||
|
||||
private function parentUser(): SecurityUser
|
||||
{
|
||||
return new SecurityUser(
|
||||
userId: UserId::fromString('88888888-8888-8888-8888-888888888888'),
|
||||
email: 'parent@test.local',
|
||||
hashedPassword: '',
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
roles: ['ROLE_PARENT'],
|
||||
);
|
||||
}
|
||||
|
||||
private function adminUser(): SecurityUser
|
||||
{
|
||||
return new SecurityUser(
|
||||
userId: UserId::fromString('33333333-3333-3333-3333-333333333333'),
|
||||
email: 'admin@test.local',
|
||||
hashedPassword: '',
|
||||
tenantId: InfraTenantId::fromString(self::TENANT_UUID),
|
||||
roles: ['ROLE_ADMIN'],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user