test(technique) : couvrir RG-3.x PHPUnit + capturer le contrat JSON (ProviderSerializationContractTest, ProviderAuditTest, fixtures démo) (ERP-139)

Ajoute provider:read:accounting sur les réfs comptables partagées (TvaMode/PaymentDelay/PaymentType/Bank) pour embarquer {id,code,label} au lieu d un IRI nu (réplique fix ERP-92). Helper seedCompleteProvider, anti-N+1 + pagination=false + filtre typeCode, restauration conflit 409, fixtures démo idempotentes. Captures JSON réelles collées dans spec § 4.0.bis.
This commit is contained in:
Matthieu
2026-06-12 15:29:41 +02:00
parent 17f6c2f28f
commit d6ed4f5faf
11 changed files with 1299 additions and 67 deletions
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use Doctrine\DBAL\Connection;
/**
* Tests Audit du repertoire prestataires (M3, spec § 6). Jumeau du
* {@see \App\Tests\Module\Commercial\Api\SupplierAuditTest} (M2). Couvre :
* - POST / PATCH / archivage -> ligne audit_log entity_type='technique.Provider'
* avec l'action et le diff attendus ;
* - RIB : `#[Auditable]` SANS `#[AuditIgnore]` sur iban/bic -> ces champs sensibles
* DOIVENT apparaitre dans le diff audite (decision § 2.7, miroir M1/M2) ;
* - M2M `sites` : un PATCH du formulaire principal qui modifie les sites trace la
* relation many-to-many (audit M2M automatique, § 2.7).
*
* @internal
*/
final class ProviderAuditTest extends AbstractProviderApiTestCase
{
private const string PROVIDER_TYPE = 'technique.Provider';
private const string RIB_TYPE = 'technique.ProviderRib';
private ?Connection $auditConnection = null;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
$this->auditConnection = $conn;
}
protected function tearDown(): void
{
if (null !== $this->auditConnection) {
$this->auditConnection->close();
}
parent::tearDown();
}
public function testPostProviderIsAudited(): void
{
$admin = $this->createAdminClient();
$payload = $this->validMainPayload('Audit Created Co', [self::SITE_86]);
$created = $admin->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $payload,
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertGreaterThanOrEqual(
1,
$this->countAudit(self::PROVIDER_TYPE, (string) $created['id'], 'create'),
'Un audit_log "create" doit etre genere pour le prestataire.',
);
}
public function testPatchProviderIsAudited(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedProvider('Audit Patch Co', [self::SITE_86]);
$admin->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Audit Patch Renamed'],
]);
self::assertResponseStatusCodeSame(200);
self::assertGreaterThanOrEqual(
1,
$this->countAudit(self::PROVIDER_TYPE, (string) $seed->getId(), 'update'),
'Un audit_log "update" doit etre genere pour le PATCH.',
);
}
public function testArchiveProviderIsAudited(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedProvider('Audit Archive Co', [self::SITE_86]);
$admin->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(200);
$changes = $this->latestChanges(self::PROVIDER_TYPE, (string) $seed->getId(), 'update');
self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived.');
}
public function testPatchSitesIsAuditedAsManyToMany(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedProvider('Audit Sites Co', [self::SITE_86]);
// PATCH du formulaire principal : on passe a 2 sites (86 + 17). L'audit M2M
// automatique (§ 2.7) doit tracer la relation `sites` dans le diff.
$admin->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['sites' => [
'/api/sites/'.$this->site(self::SITE_86)->getId(),
'/api/sites/'.$this->site(self::SITE_17)->getId(),
]],
]);
self::assertResponseStatusCodeSame(200);
$changes = $this->latestChanges(self::PROVIDER_TYPE, (string) $seed->getId(), 'update');
self::assertArrayHasKey('sites', $changes, 'La modification M2M des sites doit etre tracee.');
}
public function testRibCreateAuditIncludesIbanAndBic(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedProvider('Rib Audit Host', [self::SITE_86]);
$rib = $admin->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Compte audite',
'bic' => self::VALID_BIC,
'iban' => self::VALID_IBAN,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
$changes = $this->latestChanges(self::RIB_TYPE, (string) $rib['id'], 'create');
self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertSame(self::VALID_IBAN, $changes['iban']);
self::assertSame(self::VALID_BIC, $changes['bic']);
}
/**
* Decode le `changes` (diff) de la derniere ligne audit_log correspondante.
*
* @return array<string, mixed>
*/
private function latestChanges(string $type, string $id, string $action): array
{
$rows = $this->auditConnection->fetchAllAssociative(
'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
['type' => $type, 'id' => $id, 'action' => $action],
);
self::assertGreaterThanOrEqual(1, count($rows), sprintf('Un audit_log "%s" doit exister pour %s#%s.', $action, $type, $id));
return json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
}
private function countAudit(string $type, string $id, string $action): int
{
return (int) $this->auditConnection->fetchOne(
'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action',
['type' => $type, 'id' => $id, 'action' => $action],
);
}
}