fix(hierarchy): include transfer snapshots in replay content comparison
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Review round 1 blocker: sameEvent omitted transferFrom/transferTo, so a retried transfer re-routed to a different destination would silently replay instead of conflicting. Transfer source/destination are semantic event content per §5.2. Adds a witness varying the transfer destination on a duplicate idempotency key; also fixes a stale cross-reference in the db witness header.
This commit is contained in:
@@ -143,6 +143,27 @@ describe('hierarchy audit repository integration', (): void => {
|
||||
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
|
||||
});
|
||||
|
||||
it('throws on a duplicate idempotency key whose transfer destination differs', async () => {
|
||||
const from = { kind: 'company' as const, id: randomUUID(), slug: 'src-co' };
|
||||
const to = { kind: 'company' as const, id: randomUUID(), slug: 'dst-co' };
|
||||
const first = input({
|
||||
verb: 'transfer',
|
||||
targetKind: 'estate',
|
||||
transferFrom: from,
|
||||
transferTo: to,
|
||||
});
|
||||
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(original.replayed).toBe(false);
|
||||
// Identical retry replays; a retry re-routed to a different destination must conflict.
|
||||
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
|
||||
expect(replay.replayed).toBe(true);
|
||||
await expect(
|
||||
handle.db.transaction(async (tx) =>
|
||||
repo.append(tx, { ...first, transferTo: { ...to, id: randomUUID() } }),
|
||||
),
|
||||
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
|
||||
});
|
||||
|
||||
it('builds root-first parent chains and rejects unknown nodes', async () => {
|
||||
const companyId = randomUUID();
|
||||
const estateId = randomUUID();
|
||||
|
||||
@@ -165,7 +165,11 @@ function sameEvent(row: HierarchyAuditEventRow, input: AppendHierarchyEventInput
|
||||
row.targetId === input.targetId &&
|
||||
row.correlationId === input.correlationId &&
|
||||
(row.causationId ?? null) === (input.causationId ?? null) &&
|
||||
canonicalJson(row.targetSnapshot) === canonicalJson(input.targetSnapshot)
|
||||
canonicalJson(row.targetSnapshot) === canonicalJson(input.targetSnapshot) &&
|
||||
// Transfer source/destination are semantic content (§5.2): a retry with a
|
||||
// different destination must conflict, never silently replay.
|
||||
canonicalJson(row.transferFrom ?? null) === canonicalJson(input.transferFrom ?? null) &&
|
||||
canonicalJson(row.transferTo ?? null) === canonicalJson(input.transferTo ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* table — events survive the deletion of their target), the causation
|
||||
* self-FK, and the outbox's FK/uniqueness/status shape. The repository-level
|
||||
* half of §6.4 (same-transaction atomicity, rollback, replay) is witnessed in
|
||||
* apps/gateway/src/hierarchy/hierarchy-audit.witness.test.ts.
|
||||
* apps/gateway/src/hierarchy/hierarchy-audit.integration.test.ts.
|
||||
*
|
||||
* Two legs run the same witness body:
|
||||
* - PGlite (WASM Postgres): always runs.
|
||||
|
||||
Reference in New Issue
Block a user