feat(versioning) : add entity versioning with numbered versions and restore
Backend: - Migration: version column on audit_logs and machines - AuditLog, Machine, Composant, Piece, Product: version + skipAudit properties - AbstractAuditSubscriber: auto-increment version, skip on restore, fix decimal diff - Enriched snapshots with slots, custom fields and version number - AuditLogRepository: findVersionHistory, findByVersion - EntityVersionService: list, preview, restore with skeleton/integrity checks - EntityVersionController: REST endpoints for all 4 entity types - 11 tests covering list, preview, restore, auth Frontend: update submodule pointer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
174
tests/Api/Controller/EntityVersionTest.php
Normal file
174
tests/Api/Controller/EntityVersionTest.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Controller;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class EntityVersionTest extends AbstractApiTestCase
|
||||
{
|
||||
// ── Versions list ───────────────────────────────────────────────
|
||||
|
||||
public function testMachineVersionsAfterCreateAndUpdate(): void
|
||||
{
|
||||
$machine = $this->createMachine('Machine V');
|
||||
|
||||
$gClient = $this->createGestionnaireClient();
|
||||
$gClient->request('PATCH', self::iri('machines', $machine->getId()), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => 'Machine V Updated'],
|
||||
]);
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$vClient = $this->createViewerClient();
|
||||
$vClient->request('GET', sprintf('/api/machines/%s/versions', $machine->getId()));
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$data = $vClient->getResponse()->toArray();
|
||||
$this->assertArrayHasKey('items', $data);
|
||||
$this->assertArrayHasKey('total', $data);
|
||||
$this->assertGreaterThanOrEqual(1, $data['total']);
|
||||
|
||||
$firstItem = $data['items'][0];
|
||||
$this->assertArrayHasKey('version', $firstItem);
|
||||
$this->assertArrayHasKey('action', $firstItem);
|
||||
$this->assertArrayHasKey('createdAt', $firstItem);
|
||||
}
|
||||
|
||||
public function testComposantVersionsList(): void
|
||||
{
|
||||
$composant = $this->createComposant('Composant V');
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('GET', sprintf('/api/composants/%s/versions', $composant->getId()));
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$data = $client->getResponse()->toArray();
|
||||
$this->assertArrayHasKey('items', $data);
|
||||
}
|
||||
|
||||
public function testPieceVersionsList(): void
|
||||
{
|
||||
$piece = $this->createPiece('Piece V');
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('GET', sprintf('/api/pieces/%s/versions', $piece->getId()));
|
||||
$this->assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testProductVersionsList(): void
|
||||
{
|
||||
$product = $this->createProduct('Product V');
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('GET', sprintf('/api/products/%s/versions', $product->getId()));
|
||||
$this->assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testVersionsNotFound(): void
|
||||
{
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('GET', '/api/machines/nonexistent-id/versions');
|
||||
$this->assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testVersionsUnauthenticated(): void
|
||||
{
|
||||
$machine = $this->createMachine('Machine V');
|
||||
|
||||
$client = $this->createUnauthenticatedClient();
|
||||
$client->request('GET', sprintf('/api/machines/%s/versions', $machine->getId()));
|
||||
$this->assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────
|
||||
|
||||
public function testPreviewRequiresGestionnaire(): void
|
||||
{
|
||||
$machine = $this->createMachine('Machine P');
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('GET', sprintf('/api/machines/%s/versions/1/preview', $machine->getId()));
|
||||
$this->assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testPreviewReturnsRestoreInfo(): void
|
||||
{
|
||||
$composant = $this->createComposant('Composant P');
|
||||
|
||||
// Update to create version 2
|
||||
$gClient = $this->createGestionnaireClient();
|
||||
$gClient->request('PATCH', self::iri('composants', $composant->getId()), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => 'Composant P Updated'],
|
||||
]);
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
// Preview restore to version 1
|
||||
$gClient2 = $this->createGestionnaireClient();
|
||||
$gClient2->request('GET', sprintf('/api/composants/%s/versions/1/preview', $composant->getId()));
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$data = $gClient2->getResponse()->toArray();
|
||||
$this->assertArrayHasKey('version', $data);
|
||||
$this->assertArrayHasKey('restoreMode', $data);
|
||||
$this->assertArrayHasKey('diff', $data);
|
||||
$this->assertArrayHasKey('warnings', $data);
|
||||
$this->assertEquals(1, $data['version']);
|
||||
$this->assertEquals('full', $data['restoreMode']);
|
||||
}
|
||||
|
||||
// ── Restore ─────────────────────────────────────────────────────
|
||||
|
||||
public function testRestoreRequiresGestionnaire(): void
|
||||
{
|
||||
$machine = $this->createMachine('Machine R');
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('POST', sprintf('/api/machines/%s/versions/1/restore', $machine->getId()));
|
||||
$this->assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testRestoreCreatesNewVersion(): void
|
||||
{
|
||||
$composant = $this->createComposant('Original Name');
|
||||
|
||||
// Update
|
||||
$gClient = $this->createGestionnaireClient();
|
||||
$gClient->request('PATCH', self::iri('composants', $composant->getId()), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => 'Updated Name'],
|
||||
]);
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
// Restore to version 1
|
||||
$gClient2 = $this->createGestionnaireClient();
|
||||
$gClient2->request('POST', sprintf('/api/composants/%s/versions/1/restore', $composant->getId()));
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$data = $gClient2->getResponse()->toArray();
|
||||
$this->assertTrue($data['success']);
|
||||
$this->assertEquals(1, $data['restoredFromVersion']);
|
||||
$this->assertGreaterThan(2, $data['newVersion']);
|
||||
|
||||
// Verify the name was restored
|
||||
$vClient = $this->createViewerClient();
|
||||
$vClient->request('GET', self::iri('composants', $composant->getId()));
|
||||
$this->assertResponseIsSuccessful();
|
||||
$entityData = $vClient->getResponse()->toArray();
|
||||
$this->assertEquals('Original Name', $entityData['name']);
|
||||
}
|
||||
|
||||
public function testRestoreVersionNotFound(): void
|
||||
{
|
||||
$composant = $this->createComposant('Composant NF');
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', sprintf('/api/composants/%s/versions/999/restore', $composant->getId()));
|
||||
$this->assertResponseStatusCodeSame(404);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user