feat(reporting) : add transverse Reporting module (DBAL read-only, back)

LST-59 (3.1) backend. New native reporting module that aggregates across
TimeTracking/ProjectManagement/Absence with ZERO direct inter-module imports —
coupled only to the physical SQL schema via read-only DBAL (AuditLog provider
pattern).

- 4 read-only reports (ApiResource + DBAL provider + readonly DTO,
  paginationEnabled false, security reporting.view): /api/reports/
  {time-per-project, time-per-user, tasks-by-status, absences-by-type}.
  All filters bound-param, dates validated YYYY-MM-DD (default = current month),
  int filters validated by regex (cs-fixer-stable).
- No Doctrine entity, no migration. ReportFilterTrait centralises validation.
  Absence status compared by literal 'approved' to avoid importing the enum.
- ReportingModule registered (id reporting, reporting.view/export perms);
  sidebar /reporting item gated by module + permission (ROLE_ADMIN section).

169 tests green (163 + 6), 4 routes exposed, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-21 00:08:43 +02:00
parent 0761bbd8c1
commit b3b29fd753
17 changed files with 747 additions and 0 deletions
+2
View File
@@ -13,6 +13,7 @@ use App\Module\Directory\DirectoryModule;
use App\Module\Integration\IntegrationModule; use App\Module\Integration\IntegrationModule;
use App\Module\Mail\MailModule; use App\Module\Mail\MailModule;
use App\Module\ProjectManagement\ProjectManagementModule; use App\Module\ProjectManagement\ProjectManagementModule;
use App\Module\Reporting\ReportingModule;
use App\Module\TimeTracking\TimeTrackingModule; use App\Module\TimeTracking\TimeTrackingModule;
return [ return [
@@ -23,4 +24,5 @@ return [
DirectoryModule::class, DirectoryModule::class,
MailModule::class, MailModule::class,
IntegrationModule::class, IntegrationModule::class,
ReportingModule::class,
]; ];
+1
View File
@@ -38,6 +38,7 @@ return [
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'], ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:contact-multiple-outline', 'module' => 'directory'], ['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:contact-multiple-outline', 'module' => 'directory'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'], ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
], ],
], ],
]; ];
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Application\DTO;
/**
* Absences approuvees agregees par type sur une periode.
*/
final readonly class AbsencesByTypeOutput
{
public function __construct(
public string $type,
public int $count,
public float $totalDays,
) {}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Application\DTO;
/**
* Nombre de taches non archivees regroupees par statut.
*
* `statusId` peut etre null pour les taches sans statut (LEFT JOIN).
*/
final readonly class TasksByStatusOutput
{
public function __construct(
public ?int $statusId,
public string $statusLabel,
public int $count,
) {}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Application\DTO;
/**
* Heures travaillees agregees par projet sur une periode.
*/
final readonly class TimePerProjectOutput
{
public function __construct(
public int $projectId,
public string $projectCode,
public string $projectName,
public float $hours,
public int $entryCount,
) {}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Application\DTO;
/**
* Heures travaillees agregees par utilisateur sur une periode.
*/
final readonly class TimePerUserOutput
{
public function __construct(
public int $userId,
public string $username,
public string $fullName,
public float $hours,
public int $entryCount,
) {}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Reporting\Application\DTO\AbsencesByTypeOutput;
use App\Module\Reporting\Infrastructure\ApiPlatform\State\AbsencesByTypeProvider;
/**
* Rapport en lecture seule : absences approuvees par type sur une periode.
*
* Filtres query-param : ?from=YYYY-MM-DD&to=YYYY-MM-DD&userId=
* (bornes facultatives, sans defaut — un filtre absent n'applique pas la clause).
*
* Aucune entite Doctrine sous-jacente : le provider lit via DBAL et retourne
* directement une liste de AbsencesByTypeOutput. Pagination desactivee.
*/
#[ApiResource(
shortName: 'ReportAbsencesByType',
operations: [
new GetCollection(
uriTemplate: '/reports/absences-by-type',
paginationEnabled: false,
security: "is_granted('reporting.view')",
provider: AbsencesByTypeProvider::class,
),
],
output: AbsencesByTypeOutput::class,
)]
final class AbsencesByTypeResource {}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Reporting\Application\DTO\TasksByStatusOutput;
use App\Module\Reporting\Infrastructure\ApiPlatform\State\TasksByStatusProvider;
/**
* Rapport en lecture seule : nombre de taches non archivees par statut.
*
* Filtre query-param : ?projectId=
*
* Aucune entite Doctrine sous-jacente : le provider lit via DBAL et retourne
* directement une liste de TasksByStatusOutput. Pagination desactivee.
*/
#[ApiResource(
shortName: 'ReportTasksByStatus',
operations: [
new GetCollection(
uriTemplate: '/reports/tasks-by-status',
paginationEnabled: false,
security: "is_granted('reporting.view')",
provider: TasksByStatusProvider::class,
),
],
output: TasksByStatusOutput::class,
)]
final class TasksByStatusResource {}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Reporting\Application\DTO\TimePerProjectOutput;
use App\Module\Reporting\Infrastructure\ApiPlatform\State\TimePerProjectProvider;
/**
* Rapport en lecture seule : heures travaillees par projet sur une periode.
*
* Filtres query-param : ?from=YYYY-MM-DD&to=YYYY-MM-DD&userId=&projectId=
* (from/to par defaut = mois courant si absents).
*
* Aucune entite Doctrine sous-jacente : le provider lit via DBAL et retourne
* directement une liste de TimePerProjectOutput. Pagination desactivee
* (volume borne par le nombre de projets).
*/
#[ApiResource(
shortName: 'ReportTimePerProject',
operations: [
new GetCollection(
uriTemplate: '/reports/time-per-project',
paginationEnabled: false,
security: "is_granted('reporting.view')",
provider: TimePerProjectProvider::class,
),
],
output: TimePerProjectOutput::class,
)]
final class TimePerProjectResource {}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Reporting\Application\DTO\TimePerUserOutput;
use App\Module\Reporting\Infrastructure\ApiPlatform\State\TimePerUserProvider;
/**
* Rapport en lecture seule : heures travaillees par utilisateur sur une periode.
*
* Filtres query-param : ?from=YYYY-MM-DD&to=YYYY-MM-DD&projectId=
* (from/to par defaut = mois courant si absents).
*
* Aucune entite Doctrine sous-jacente : le provider lit via DBAL et retourne
* directement une liste de TimePerUserOutput. Pagination desactivee.
*/
#[ApiResource(
shortName: 'ReportTimePerUser',
operations: [
new GetCollection(
uriTemplate: '/reports/time-per-user',
paginationEnabled: false,
security: "is_granted('reporting.view')",
provider: TimePerUserProvider::class,
),
],
output: TimePerUserOutput::class,
)]
final class TimePerUserResource {}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Reporting\Application\DTO\AbsencesByTypeOutput;
use Doctrine\DBAL\Connection;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider DBAL (lecture seule) du rapport absences-par-type.
*
* N'importe AUCUNE entite : couplage au seul schema SQL physique. La valeur du
* statut filtre ('approved') correspond au backing-value de
* App\Module\Absence\Domain\Enum\AbsenceStatus::Approved, mais est ecrite ici
* en litteral pour eviter tout import inter-module. Tous les filtres sont
* passes en bound params. Les bornes de date sont facultatives et sans defaut.
*
* @implements ProviderInterface<AbsencesByTypeOutput>
*/
final readonly class AbsencesByTypeProvider implements ProviderInterface
{
use ReportFilterTrait;
/**
* Backing-value de AbsenceStatus::Approved (litteral pour rester decouple).
*/
private const string STATUS_APPROVED = 'approved';
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
) {}
/**
* @return list<AbsencesByTypeOutput>
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
/** @var array<string, mixed> $filters */
$filters = $context['filters'] ?? [];
$from = $this->validateDate($filters['from'] ?? null, 'from');
$to = $this->validateDate($filters['to'] ?? null, 'to');
$userId = $this->validatePositiveInt($filters['userId'] ?? null, 'userId');
$qb = $this->connection->createQueryBuilder()
->select(
'ar.type AS type',
'COUNT(ar.id) AS cnt',
'COALESCE(SUM(ar.counted_days), 0) AS total_days',
)
->from('absence_request', 'ar')
->where('ar.status = :status')
->setParameter('status', self::STATUS_APPROVED)
->groupBy('ar.type')
->orderBy('total_days', 'DESC')
;
if (null !== $from) {
$qb->andWhere('ar.start_date >= :from')->setParameter('from', $from);
}
if (null !== $to) {
$qb->andWhere('ar.end_date < :to')->setParameter('to', $to);
}
if (null !== $userId) {
$qb->andWhere('ar.user_id = :userId')->setParameter('userId', $userId);
}
/** @var list<array<string, mixed>> $rows */
$rows = $qb->executeQuery()->fetchAllAssociative();
return array_map(static fn (array $row): AbsencesByTypeOutput => new AbsencesByTypeOutput(
type: (string) $row['type'],
count: (int) $row['cnt'],
totalDays: round((float) $row['total_days'], 2),
), $rows);
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Infrastructure\ApiPlatform\State;
use DateTimeImmutable;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Validation stricte des filtres query-param partages par les providers de reporting.
*
* Toutes les valeurs validees ici sont destinees a etre passees en bound params
* (jamais interpolees dans le SQL). On rejette en 400 explicite tout input
* malforme plutot que de laisser PostgreSQL lever une 500.
*/
trait ReportFilterTrait
{
/**
* Valide une date au format strict YYYY-MM-DD.
*
* @param mixed $value valeur brute issue des query-params
*
* @return null|string la date validee, ou null si absente/vide
*/
private function validateDate(mixed $value, string $paramName): ?string
{
if (null === $value || '' === $value) {
return null;
}
if (!is_string($value)) {
throw new BadRequestHttpException(sprintf('Filtre "%s" invalide : date YYYY-MM-DD attendue.', $paramName));
}
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
if (false === $date || $date->format('Y-m-d') !== $value) {
throw new BadRequestHttpException(sprintf('Filtre "%s" invalide : date YYYY-MM-DD attendue (recu: "%s").', $paramName, $value));
}
return $value;
}
/**
* Valide un identifiant entier strictement positif.
*
* @param mixed $value valeur brute issue des query-params
*
* @return null|int l'identifiant valide, ou null si absent/vide
*/
private function validatePositiveInt(mixed $value, string $paramName): ?int
{
if (null === $value || '' === $value) {
return null;
}
// Les query-params arrivent sous forme de chaine : on valide donc le
// format decimal positif via regex (rejette "1.5", "abc", "-3", "01"...)
// plutot qu'une comparaison de type juggling sensible au cs-fixer.
if ((!is_string($value) && !is_int($value)) || 1 !== preg_match('/^[1-9][0-9]*$/', (string) $value)) {
throw new BadRequestHttpException(sprintf('Filtre "%s" invalide : entier positif attendu.', $paramName));
}
return (int) $value;
}
/**
* Resout la plage [from, to[ avec defaut = mois courant si les deux bornes sont absentes.
*
* @param array<string, mixed> $filters
*
* @return array{0: string, 1: string} [from inclus, to exclus] au format YYYY-MM-DD
*/
private function resolveDateRange(array $filters): array
{
$from = $this->validateDate($filters['from'] ?? null, 'from');
$to = $this->validateDate($filters['to'] ?? null, 'to');
if (null === $from) {
$from = new DateTimeImmutable('first day of this month')->format('Y-m-d');
}
if (null === $to) {
$to = new DateTimeImmutable('first day of next month')->format('Y-m-d');
}
return [$from, $to];
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Reporting\Application\DTO\TasksByStatusOutput;
use Doctrine\DBAL\Connection;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider DBAL (lecture seule) du rapport taches-par-statut.
*
* N'importe AUCUNE entite : couplage au seul schema SQL physique. Le filtre
* projectId est passe en bound param.
*
* @implements ProviderInterface<TasksByStatusOutput>
*/
final readonly class TasksByStatusProvider implements ProviderInterface
{
use ReportFilterTrait;
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
) {}
/**
* @return list<TasksByStatusOutput>
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
/** @var array<string, mixed> $filters */
$filters = $context['filters'] ?? [];
$projectId = $this->validatePositiveInt($filters['projectId'] ?? null, 'projectId');
$qb = $this->connection->createQueryBuilder()
->select(
's.id AS status_id',
's.label AS status_label',
'COUNT(t.id) AS cnt',
)
->from('task', 't')
->leftJoin('t', 'task_status', 's', 't.status_id = s.id')
->where('t.archived = false')
->groupBy('s.id')
->addGroupBy('s.label')
->orderBy('cnt', 'DESC')
;
if (null !== $projectId) {
$qb->andWhere('t.project_id = :projectId')->setParameter('projectId', $projectId);
}
/** @var list<array<string, mixed>> $rows */
$rows = $qb->executeQuery()->fetchAllAssociative();
return array_map(static fn (array $row): TasksByStatusOutput => new TasksByStatusOutput(
statusId: null !== $row['status_id'] ? (int) $row['status_id'] : null,
statusLabel: null !== $row['status_label'] ? (string) $row['status_label'] : 'Sans statut',
count: (int) $row['cnt'],
), $rows);
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Reporting\Application\DTO\TimePerProjectOutput;
use Doctrine\DBAL\Connection;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider DBAL (lecture seule) du rapport heures-par-projet.
*
* N'importe AUCUNE entite : couplage au seul schema SQL physique. Tous les
* filtres sont passes en bound params (jamais interpoles).
*
* @implements ProviderInterface<TimePerProjectOutput>
*/
final readonly class TimePerProjectProvider implements ProviderInterface
{
use ReportFilterTrait;
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
) {}
/**
* @return list<TimePerProjectOutput>
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
/** @var array<string, mixed> $filters */
$filters = $context['filters'] ?? [];
[$from, $to] = $this->resolveDateRange($filters);
$userId = $this->validatePositiveInt($filters['userId'] ?? null, 'userId');
$projectId = $this->validatePositiveInt($filters['projectId'] ?? null, 'projectId');
$qb = $this->connection->createQueryBuilder()
->select(
'p.id AS project_id',
'p.code AS project_code',
'p.name AS project_name',
'COALESCE(SUM(EXTRACT(EPOCH FROM (te.stopped_at - te.started_at)) / 3600), 0) AS hours',
'COUNT(te.id) AS entry_count',
)
->from('time_entry', 'te')
->innerJoin('te', 'project', 'p', 'te.project_id = p.id')
->where('te.stopped_at IS NOT NULL')
->andWhere('te.started_at >= :from')
->andWhere('te.started_at < :to')
->setParameter('from', $from)
->setParameter('to', $to)
->groupBy('p.id')
->addGroupBy('p.code')
->addGroupBy('p.name')
->orderBy('hours', 'DESC')
;
if (null !== $userId) {
$qb->andWhere('te.user_id = :userId')->setParameter('userId', $userId);
}
if (null !== $projectId) {
$qb->andWhere('te.project_id = :projectId')->setParameter('projectId', $projectId);
}
/** @var list<array<string, mixed>> $rows */
$rows = $qb->executeQuery()->fetchAllAssociative();
return array_map(static fn (array $row): TimePerProjectOutput => new TimePerProjectOutput(
projectId: (int) $row['project_id'],
projectCode: (string) $row['project_code'],
projectName: (string) $row['project_name'],
hours: round((float) $row['hours'], 2),
entryCount: (int) $row['entry_count'],
), $rows);
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Reporting\Application\DTO\TimePerUserOutput;
use Doctrine\DBAL\Connection;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider DBAL (lecture seule) du rapport heures-par-utilisateur.
*
* N'importe AUCUNE entite : couplage au seul schema SQL physique. La table
* "user" est un mot reserve PostgreSQL et reste systematiquement quotee. Tous
* les filtres sont passes en bound params.
*
* @implements ProviderInterface<TimePerUserOutput>
*/
final readonly class TimePerUserProvider implements ProviderInterface
{
use ReportFilterTrait;
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
) {}
/**
* @return list<TimePerUserOutput>
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
/** @var array<string, mixed> $filters */
$filters = $context['filters'] ?? [];
[$from, $to] = $this->resolveDateRange($filters);
$projectId = $this->validatePositiveInt($filters['projectId'] ?? null, 'projectId');
$qb = $this->connection->createQueryBuilder()
->select(
'u.id AS user_id',
'u.username AS username',
"TRIM(COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) AS full_name",
'COALESCE(SUM(EXTRACT(EPOCH FROM (te.stopped_at - te.started_at)) / 3600), 0) AS hours',
'COUNT(te.id) AS entry_count',
)
->from('time_entry', 'te')
->innerJoin('te', '"user"', 'u', 'te.user_id = u.id')
->where('te.stopped_at IS NOT NULL')
->andWhere('te.started_at >= :from')
->andWhere('te.started_at < :to')
->setParameter('from', $from)
->setParameter('to', $to)
->groupBy('u.id')
->addGroupBy('u.username')
->addGroupBy('u.first_name')
->addGroupBy('u.last_name')
->orderBy('hours', 'DESC')
;
if (null !== $projectId) {
$qb->andWhere('te.project_id = :projectId')->setParameter('projectId', $projectId);
}
/** @var list<array<string, mixed>> $rows */
$rows = $qb->executeQuery()->fetchAllAssociative();
return array_map(static fn (array $row): TimePerUserOutput => new TimePerUserOutput(
userId: (int) $row['user_id'],
username: (string) $row['username'],
fullName: (string) $row['full_name'],
hours: round((float) $row['hours'], 2),
entryCount: (int) $row['entry_count'],
), $rows);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Module\Reporting;
use App\Shared\Domain\Module\ModuleInterface;
/**
* Module de reporting transverse en lecture seule.
*
* Ce module n'introduit AUCUNE entite Doctrine ni migration : il agrege les
* donnees existantes (time_entry, project, task, absence_request, "user") via
* des requetes DBAL en lecture seule. Il n'importe deliberement aucune entite
* d'un autre module — couplage uniquement au schema SQL physique partage.
*/
final class ReportingModule implements ModuleInterface
{
public static function id(): string
{
return 'reporting';
}
public static function label(): string
{
return 'Rapports';
}
public static function isRequired(): bool
{
return false;
}
/**
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'reporting.view', 'label' => 'Consulter les rapports'],
['code' => 'reporting.export', 'label' => 'Exporter les rapports'],
];
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Reporting;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class ReportingApiTest extends WebTestCase
{
public function testGetCollectionRequiresAuthentication(): void
{
$client = self::createClient();
$client->request('GET', '/api/reports/time-per-project?from=2026-01-01&to=2027-01-01');
self::assertResponseStatusCodeSame(401);
}
public function testAdminCanListTimePerProject(): void
{
$client = self::createClient();
$this->loginUser($client, 'admin');
$client->request('GET', '/api/reports/time-per-project?from=2026-01-01&to=2027-01-01');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('member', $data);
// Le rapport est non pagine : la structure hydra expose tout de meme un
// tableau `member`. Si des projets ont du temps logge, chaque membre
// porte au minimum projectId et hours.
foreach ($data['member'] as $row) {
self::assertArrayHasKey('projectId', $row);
self::assertArrayHasKey('hours', $row);
self::assertIsInt($row['projectId']);
}
}
public function testValidProjectIdFilterIsAccepted(): void
{
$client = self::createClient();
$this->loginUser($client, 'admin');
// projectId arrive en chaine ("1") : la validation doit l'accepter (200),
// garde-fou contre une regression de type-juggling sur le filtre entier.
$client->request('GET', '/api/reports/time-per-project?from=2026-01-01&to=2027-01-01&projectId=1');
self::assertResponseIsSuccessful();
}
public function testInvalidProjectIdFilterIsRejected(): void
{
$client = self::createClient();
$this->loginUser($client, 'admin');
$client->request('GET', '/api/reports/time-per-project?projectId=abc');
self::assertResponseStatusCodeSame(400);
}
public function testInvalidDateFilterIsRejected(): void
{
$client = self::createClient();
$this->loginUser($client, 'admin');
$client->request('GET', '/api/reports/time-per-project?from=not-a-date');
self::assertResponseStatusCodeSame(400);
}
public function testUserWithoutPermissionIsForbidden(): void
{
$client = self::createClient();
$this->loginUser($client, 'alice');
$client->request('GET', '/api/reports/time-per-project?from=2026-01-01&to=2027-01-01');
self::assertResponseStatusCodeSame(403);
}
private function loginUser(KernelBrowser $client, string $username): void
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
self::assertInstanceOf(User::class, $user);
$client->loginUser($user);
}
}