diff --git a/src/test/audit-outbox.spec.ts b/src/test/audit-outbox.spec.ts new file mode 100644 index 0000000..7915761 --- /dev/null +++ b/src/test/audit-outbox.spec.ts @@ -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); + }); +});