Files
api/services/nginx/app/tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php
T
Jeppe B 0b304a2203 Gate invoice tree activation on audit schema (#339)
## Summary
- gate object-tree v2 on exact, schema-backed supersession audit columns
- serialize checked additive DDL and fail closed without disrupting
legacy invoicing
- preserve pre-schema supersession markers when structured columns are
still null
- add a superuser-only, self-scoped canary endpoint with locked
legacy-to-canonical allowlist migration
- verify effective activation, roll back failed readiness, and audit
enable/disable/failure

## Verification
- focused invoicing safety: 16 tests passed (99 assertions)
- full unit suite: 1,237 passed (9,003 assertions), 2 skipped, existing
warnings only
- PHP syntax and `git diff --check` clean
- independent architecture, security, and reviewer gates: GO

## Activation
Deploy with global database/environment enablement off. POST the
self-canary endpoint for one authenticated superuser, require
`configured_enabled=true` and `effective_enabled=true`, then verify the
exact period and tree GET routes. Roll back with the same endpoint using
`enabled=false`.
2026-08-03 13:03:55 +02:00

200 lines
10 KiB
PHP

<?php
app_require('classes/invoice_collection_bulk_action_service.php');
use classes\invoice_collection_bulk_action_service;
it('rejects stale bulk-action previews with a content digest and conflict response', function (): void {
$service = (string)file_get_contents(app_path('classes/invoice_collection_bulk_action_service.php'));
$route = (string)file_get_contents(app_path('routes/orderInvoicesRoute.php'));
expect($service)
->toContain('$preview[\'content_digest\'] = $this->previewContentDigest($preview);')
->toContain('hash_equals((string)($cached[\'preview\'][\'content_digest\'] ?? \'\'), $freshDigest)')
->toContain('throw new invoice_collection_bulk_action_conflict(')
->and($route)
->toContain('catch (invoice_collection_bulk_action_conflict $e)')
->toContain('$response->error($e->getMessage(), 409);');
});
it('binds invoice-period tree bulk previews to the actor and cached preview action', function (): void {
$service = (string)file_get_contents(app_path('classes/invoice_collection_bulk_action_service.php'));
$route = (string)file_get_contents(app_path('routes/orderInvoicesRoute.php'));
expect($service)
->toContain("'actor_user_id' => \$actorUserId")
->toContain('validateSnapshotBinding(')
->toContain('public function cachedPreviewAction(string $previewId): string')
->toContain('Preview belongs to another user. Create a new preview.')
->and($route)
->toContain("\$service->cachedPreviewAction((string)self::getParameter('preview_id'))")
->toContain("'customer_number'")
->toContain("'snapshot_revision'");
});
it('previews whole-tree cleanup and merge supersession semantics', function (): void {
$service = (string)file_get_contents(app_path('classes/invoice_collection_bulk_action_service.php'));
$object = (string)file_get_contents(app_path('objects/collected_order_invoices_o.php'));
expect($service)
->toContain('$this->orderItemRows((int)$collection->id, true)')
->toContain('private function expandCleanupRows(array $rows, array $violationsByItemId): array')
->toContain('foreach ($this->allCollectionOrderRows((int)$collection->id) as $orderIdRow)')
->toContain("'supersession' => array_map")
->toContain('$source->markSupersededBy($targetId, $actorUserId)')
->and($object)
->toContain('public function markSupersededBy(int $targetInvoiceCollectionId, int $actorUserId): void')
->toContain('SUPERSESSION_MARKER_PATTERN')
->toContain('public function getSupersessionMetadata(): ?array')
->toContain('Collections superseded before the additive columns existed')
->toMatch('/Collections superseded before the additive columns existed.*?}\s*if \(!preg_match\(self::SUPERSESSION_MARKER_PATTERN/s')
->toContain('The source invoice collection has already been superseded.')
->toContain('The target invoice collection has already been superseded.')
->toContain('if ($this->getSupersessionMetadata() !== null)');
});
it('expands hidden cleanup descendants recursively and remains cycle safe', function (): void {
$service = (new ReflectionClass(invoice_collection_bulk_action_service::class))
->newInstanceWithoutConstructor();
$method = (new ReflectionClass(invoice_collection_bulk_action_service::class))
->getMethod('expandCleanupRows');
$rows = [
['order_item_id' => 10, 'order_id' => 1, 'related_item_id' => 13],
['order_item_id' => 11, 'order_id' => 1, 'related_item_id' => 10, 'include_in_invoice' => 0],
['order_item_id' => 12, 'order_id' => 1, 'related_item_id' => 11, 'include_in_invoice' => 0],
['order_item_id' => 13, 'order_id' => 1, 'related_item_id' => 12, 'include_in_invoice' => 0],
['order_item_id' => 14, 'order_id' => 2, 'related_item_id' => 10, 'include_in_invoice' => 0],
['order_item_id' => 99, 'order_id' => 1, 'related_item_id' => null],
];
$expanded = $method->invoke($service, $rows, [10 => 'customer_product_restricted']);
expect(array_column(array_column($expanded, 'row'), 'order_item_id'))->toBe([10, 11, 12, 13])
->and(array_column($expanded, 'rule'))->toBe([
'customer_product_restricted',
'related_to_removed_item',
'related_to_removed_item',
'related_to_removed_item',
]);
});
it('keeps legacy full-payload apply and exposes cached tree-action aliases', function (): void {
$route = (string)file_get_contents(app_path('routes/orderInvoicesRoute.php'));
expect($route)
->toMatch("/\\/collected-invoices\\/bulk-actions\\/apply'.*?requireParameters\\(\\['preview_id', 'action', 'invoice_collection_ids', 'confirmation_text'\\]\\)/s")
->toContain("\$this->post('/superuser/invoicing/period/tree-actions/preview'")
->toContain("\$this->post('/superuser/invoicing/period/tree-actions/apply'")
->toContain('InvoicingPeriodRoute::isInvoicePeriodObjectTreeV2Enabled((int)$user->id)')
->toContain('$service->applyCachedPreview(')
->toContain('$response->error($e->getMessage(), 409);');
});
it('changes the stale-preview digest when an export-relevant line changes', function (): void {
global $db;
$previousDb = $GLOBALS['db'] ?? null;
$hadDb = array_key_exists('db', $GLOBALS);
$db = new class {
public array $rows = [];
public function query(string $sql): object
{
expect($sql)
->toContain('oi.price')
->toContain('oi.quantity')
->toContain('p.economic_product_id')
->toContain('o.reference AS order_reference');
return new class($this->rows) {
private int $index = 0;
public function __construct(private readonly array $rows)
{
}
public function fetch_assoc(): ?array
{
return $this->rows[$this->index++] ?? null;
}
};
}
};
try {
$service = (new ReflectionClass(invoice_collection_bulk_action_service::class))->newInstanceWithoutConstructor();
$method = (new ReflectionClass(invoice_collection_bulk_action_service::class))
->getMethod('collectionExportContentDigest');
$db->rows = [[
'order_id' => 91,
'order_item_id' => 191,
'product_id' => 7,
'price' => 100,
'quantity' => 1,
'product_name' => 'Wash',
'economic_product_id' => 700,
]];
$before = $method->invoke($service, 41);
$db->rows[0]['price'] = 125;
$after = $method->invoke($service, 41);
expect($before)->toMatch('/^[a-f0-9]{64}$/')
->and($after)->toMatch('/^[a-f0-9]{64}$/')
->and($after)->not->toBe($before);
} finally {
if ($hadDb) {
$db = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('enqueues bulk E-conomic transfers durably and deduplicates active jobs by global target', function (): void {
$service = (string)file_get_contents(app_path('classes/invoice_collection_bulk_action_service.php'));
$queue = (string)file_get_contents(app_path('classes/economic_transfer_queue.php'));
expect($service)
->toContain('self::ACTION_QUEUE_ECONOMIC => $this->applyQueueEconomic(')
->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT')
->toContain("'queue_job_ids'")
->toContain('public static function assertCollectionCanQueueEconomic(')
->toContain('(new economic())->assertCustomerNumberIsNotDraft(')
->and($queue)
->toContain('Active work is unique by transfer type and business target across all requesting users.')
->toContain('CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, \'$json_path\')) AS UNSIGNED) = ?')
->not->toContain("CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$" . "json_path')) AS UNSIGNED) = ?\n AND created_by = ?");
});
it('serializes collected-invoice enqueue and worker export with collection mutations', function (): void {
$queue = (string)file_get_contents(app_path('classes/economic_transfer_queue.php'));
$route = (string)file_get_contents(app_path('routes/orderInvoicesRoute.php'));
$service = (string)file_get_contents(app_path('classes/invoice_collection_bulk_action_service.php'));
$enqueueStart = strpos($queue, 'public function enqueue(');
$activeJobCheck = strpos($queue, '$active_job = $this->findActiveJobByTarget', (int)$enqueueStart);
$enqueueLock = strpos($queue, '$this->acquireCollectedInvoiceExportLock($transfer_type, $payload)', (int)$enqueueStart);
$workerStart = strpos($queue, 'private function executeCollectedInvoiceExport(');
$workerLock = strpos($queue, '$this->acquireCollectedInvoiceExportLock(', (int)$workerStart);
$workerRevalidation = strpos($queue, '$this->assertCollectedInvoiceExportIsStillEligible($payload)', (int)$workerStart);
$workerExport = strpos($queue, '$this->executor->exportCollectedInvoice(', (int)$workerStart);
$routeStart = strpos($route, "\$this->post('/collected-invoices/economic'");
$routeEnd = strpos($route, "\$this->get('/collected-invoices/economic/queue'", (int)$routeStart);
$routeBlock = substr($route, (int)$routeStart, (int)$routeEnd - (int)$routeStart);
$routeLock = strpos($routeBlock, '$collection_export_lock = order_payment_lock::tryAcquireInvoiceCollection(');
$routeEligibility = strpos($routeBlock, 'invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic(');
expect($enqueueStart)->not->toBeFalse()
->and($enqueueLock)->not->toBeFalse()->toBeLessThan($activeJobCheck)
->and($workerStart)->not->toBeFalse()
->and($workerLock)->not->toBeFalse()->toBeLessThan($workerRevalidation)
->and($workerRevalidation)->not->toBeFalse()->toBeLessThan($workerExport)
->and($queue)->toContain('order_payment_lock::tryAcquireInvoiceCollection($collected_invoice_id)')
->toContain('invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collection)')
->and($route)->toContain('use classes\\order_payment_lock;')
->and($routeStart)->not->toBeFalse()
->and($routeEnd)->not->toBeFalse()
->and($routeLock)->not->toBeFalse()->toBeLessThan($routeEligibility)
->and($service)->toContain("Invoice collection has been superseded and cannot be exported.");
});