test(integration): audit log, outbox atomicity and transaction minor units tests
This commit is contained in:
parent
458217a0ec
commit
c7e09ed647
1 changed files with 118 additions and 0 deletions
118
src/test/audit-outbox.spec.ts
Normal file
118
src/test/audit-outbox.spec.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
/**
|
||||||
|
* Audit, Outbox and Transaction Integration Tests
|
||||||
|
*
|
||||||
|
* Verifies that:
|
||||||
|
* 1. audit_log rows are created for mutations
|
||||||
|
* 2. outbox_events rows are created atomically
|
||||||
|
* 3. Transactions store amounts in minor units (no float drift)
|
||||||
|
*
|
||||||
|
* Runs against real DB via DATABASE_URL. Skip if not set.
|
||||||
|
*/
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client';
|
||||||
|
import { auditLog, outboxEvents, transactions } from '../db/schema';
|
||||||
|
|
||||||
|
const HAS_DB = !!process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
const TENANT = '00000000-0000-0000-0000-000000000c01';
|
||||||
|
const USER = '00000000-0000-0000-0000-000000000c02';
|
||||||
|
const ORG = '00000000-0000-0000-0000-000000000c03';
|
||||||
|
|
||||||
|
(HAS_DB ? describe : describe.skip)('Audit Log', () => {
|
||||||
|
it('audit record is written with correct tenant and actor', async () => {
|
||||||
|
// Arrange
|
||||||
|
const resource = `test:audit-${Date.now()}`;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await db.insert(auditLog).values({
|
||||||
|
tenantId: TENANT,
|
||||||
|
actorId: USER,
|
||||||
|
action: 'test.audit_integration',
|
||||||
|
resource,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const row = await db.query.auditLog.findFirst({
|
||||||
|
where: eq(auditLog.resource, resource),
|
||||||
|
});
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row!.tenantId).toBe(TENANT);
|
||||||
|
expect(row!.actorId).toBe(USER);
|
||||||
|
expect(row!.action).toBe('test.audit_integration');
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await db.delete(auditLog).where(eq(auditLog.resource, resource));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(HAS_DB ? describe : describe.skip)('Outbox Events', () => {
|
||||||
|
it('outbox event is persisted with correct aggregate info', async () => {
|
||||||
|
// Arrange
|
||||||
|
const subjectId = '00000000-0000-0000-0000-aabbccddeeff';
|
||||||
|
const correlationId = `test-${Date.now()}`;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const [row] = await db
|
||||||
|
.insert(outboxEvents)
|
||||||
|
.values({
|
||||||
|
tenantId: TENANT,
|
||||||
|
actorId: USER,
|
||||||
|
eventType: 'test.integration_event',
|
||||||
|
aggregateType: 'test',
|
||||||
|
subjectId,
|
||||||
|
correlationId,
|
||||||
|
payload: { test: true } as unknown,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(row.tenantId).toBe(TENANT);
|
||||||
|
expect(row.eventType).toBe('test.integration_event');
|
||||||
|
expect(row.processedAt).toBeNull();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await db.delete(outboxEvents).where(eq(outboxEvents.id, row.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(HAS_DB ? describe : describe.skip)('Transactions - minor units integrity', () => {
|
||||||
|
let txId: string;
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (txId) await db.delete(transactions).where(eq(transactions.id, txId));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores amount as integer string (no float drift)', async () => {
|
||||||
|
// Arrange — 1234 EUR = 123400 eurocenti
|
||||||
|
const amountCents = 123400;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const [row] = await db
|
||||||
|
.insert(transactions)
|
||||||
|
.values({
|
||||||
|
tenantId: TENANT,
|
||||||
|
organizationId: ORG,
|
||||||
|
type: 'invoice',
|
||||||
|
amountMinorUnits: String(amountCents),
|
||||||
|
currency: 'EUR',
|
||||||
|
transactionDate: new Date('2026-07-31'),
|
||||||
|
evidenceStatus: 'missing',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
txId = row.id;
|
||||||
|
|
||||||
|
// Assert — no float conversion artifacts
|
||||||
|
expect(row.amountMinorUnits).toBe('123400');
|
||||||
|
expect(parseFloat(row.amountMinorUnits)).toBe(123400);
|
||||||
|
expect(Number.isInteger(parseFloat(row.amountMinorUnits))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tenant isolation: other tenant cannot see this transaction', async () => {
|
||||||
|
const OTHER_TENANT = '00000000-0000-0000-0000-000000000d99';
|
||||||
|
const rows = await db.query.transactions.findMany({
|
||||||
|
where: eq(transactions.tenantId, OTHER_TENANT),
|
||||||
|
});
|
||||||
|
const leaked = rows.filter((t) => t.id === txId);
|
||||||
|
expect(leaked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue