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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user