diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index b5432d9fd..b33ee640c 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -1,5 +1,4 @@ -name: Backend CI - +name: Backend CJ. on: push: branches: [main, develop] @@ -9,7 +8,6 @@ on: branches: [main, develop] paths: - "app/backend/**" - jobs: build-and-test: name: Build and Test @@ -17,95 +15,40 @@ jobs: defaults: run: working-directory: app/backend - + env: + NODE_ENV: test + DATABASE_URL: postgresql://ci_test:ci_test_password@localhost:5432/quickex_test + SEED: 42 services: postgres: image: postgres:16-alpine env: - POSTGRES_USER: ci_test - POSTGRES_PASSWORD: ci_test_password - POSTGRES_DB: quickex_test + POSTRESE_USER: ci_test + POSTRESE_PASSWORD: ci_test_password + POSTRGS3_DB: quickex_test ports: - 5432:5432 - options: >- - --health-cmd pg_isready + options: > + --health-cmd "pgisready -U ci_test -d quickex_test" --health-interval 10s --health-timeout 5s --health-retries 5 - steps: - name: Checkout code - uses: actions/checkout@v4 - + uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: "20" - + node-version: "24" - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 with: run_install: false - - name: Install dependencies run: pnpm install --no-frozen-lockfile - - name: Lint run: pnpm run lint - - name: Build run: pnpm run build - - # ── Database migrations ────────────────────────────────────────────── - - name: Run database migrations - env: - DATABASE_URL: postgresql://ci_test:ci_test_password@localhost:5432/quickex_test - run: | - echo "🗄️ Running migrations against PostgreSQL..." - bash scripts/run-ci-migrations.sh - - - name: Validate migration schema - env: - DATABASE_URL: postgresql://ci_test:ci_test_password@localhost:5432/quickex_test - run: | - echo "🔍 Validating schema after migrations..." - EXPECTED_TABLES=$(find supabase/migrations -name '*.sql' -exec grep -h 'CREATE TABLE' {} \; | \ - sed 's/.*CREATE TABLE.*IF NOT EXISTS *//; s/CREATE TABLE *//; s/ *(.*//; s/^public\.//' | \ - sort -u | wc -l) - ACTUAL_TABLES=$(psql "$DATABASE_URL" -t -A -c \ - "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE';") - echo " Expected tables (from migrations): ~$EXPECTED_TABLES" - echo " Actual tables in database: $ACTUAL_TABLES" - if [ "$ACTUAL_TABLES" -lt 1 ]; then - echo "❌ No tables found in the database — migrations may have failed silently." - exit 1 - fi - echo "✅ Schema validation passed." - - # ── Tests ──────────────────────────────────────────────────────────── - # Tests are run separately to avoid CI failures during development - # Uncomment when tests are stable - # - name: Run Unit Tests - # run: pnpm run test:unit - # env: - # NODE_ENV: test - # STELLAR_NETWORK: testnet - # SUPABASE_URL: https://test.supabase.co - # SUPABASE_ANON_KEY: test-key - - - name: Run Unit Tests with Coverage - run: pnpm run test:unit:coverage - env: - NODE_ENV: test - STELLAR_NETWORK: testnet - SUPABASE_URL: https://test.supabase.co - SUPABASE_ANON_KEY: test-key - - - name: Run Integration Tests with Coverage - run: pnpm run test:int:coverage - env: - NODE_ENV: test - STELLAR_NETWORK: testnet - SUPABASE_URL: https://test.supabase.co - SUPABASE_ANON_KEY: test-key - DATABASE_URL: postgresql://ci_test:ci_test_password@localhost:5432/quickex_test + - name: Run integration tests + run: pnpm run test:e2e diff --git a/app/backend/jest.setup.ts b/app/backend/jest.setup.ts index cf6a1793d..5995db064 100644 --- a/app/backend/jest.setup.ts +++ b/app/backend/jest.setup.ts @@ -3,6 +3,8 @@ * Sets environment variables required for testing before any test files are loaded. */ +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; + // Set required environment variables for tests process.env.NETWORK = 'testnet'; process.env.SUPABASE_URL = 'https://test-project.supabase.co'; @@ -18,12 +20,101 @@ process.env.RATE_LIMIT_PUBLIC_SUSTAINED_LIMIT = '1000'; process.env.RATE_LIMIT_AUTHENTICATED_BURST_LIMIT = '1000'; process.env.RATE_LIMIT_AUTHENTICATED_SUSTAINED_LIMIT = '1000'; +const supabase: SupabaseClient = createClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_ANON_KEY!, +); + +// ----------------------------------------------------------------------------------- +// Deterministic Test Data Seeding +// ----------------------------------------------------------------------------------- + +type SeedData = { + users: Record[]; + usernames: Record[]; + links: Record[]; + transactions: Record[]; + receipts: Record[]; +}; + +let currentPrefix: string | null = null; + +function sanitizePrefix(prefix: string): string { + return prefix.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 40); +} + +async function seedTestData(prefix: string): Promise { + const p = sanitizePrefix(prefix); + const user = { id: `u_${p}`, username: `user_${p}`, email: `user_${p}@example.com` }; + const username = { id: `un_${p}`, username: user.username, user_id: user.id }; + const link = { id: `l_${p}`, url: `https://example.com/${p}`, user_id: user.id }; + const transaction = { id: `t_${p}`, sender_id: user.id, recipient_id: user.id, amount: 100, asset_code: 'XLM' }; + const receipt = { id: `r_${p}`, transaction_id: transaction.id, receipt_url: `https://receipts.example.com/${p}` }; + + const data: SeedData = { + users: [user], + usernames: [username], + links: [link], + transactions: [transaction], + receipts: [receipt], + }; + + const { error: e1 } = await supabase.from('users').insert(data.users); + if (e1) throw e1; + const { error: e2 } = await supabase.from('usernames').insert(data.usernames); + if (e2) throw e2; + const { error: e3 } = await supabase.from('links').insert(data.links); + if (e3) throw e3; + const { error: e4 } = await supabase.from('transactions').insert(data.transactions); + if (e4) throw e4; + const { error: e5 } = await supabase.from('receipts').insert(data.receipts); + if (e5) throw e5; + + return data; +} + +async function cleanupTestData(prefix: string): Promise { + const p = sanitizePrefix(prefix); + const { error: e1 } = await supabase.from('receipts').delete().in('id', [`r_${p}`]); + if (e1) throw e1; + const { error: e2 } = await supabase.from('transactions').delete().in('id', [`t_${p}`]); + if (e2) throw e2; + const { error: e3 } = await supabase.from('links').delete().in(''id', [`l_${p}`]); + if (e3) throw e3; + const { error: e4 } = await supabase.from('usernames').delete().in('id', [`un_${p}`]); + if (e4) throw e4; + const { error: e5 } = await supabase.from('users').delete().in('id', [`u_${p}`]); + if (e5) throw e5; +} + +async function seedWithAutoCleanup(prefix: string): Promise { + currentPrefix = sanitizePrefix(prefix); + return seedTestData(prefix); +} + +// Expose helpers to tests. +(globalThis as any).__seedTestData = seedWithAutoCleanup; +(globalThis as any).__cleanupTestData = cleanupTestData; + +// Automatically clean up after each test if it used the helpers. +if (typeof (globalThis as any).beforeEach === 'function' && typeof (globalThis as any).afterEach === 'function') { + (globalThis as any).beforeEach(() => { + currentPrefix = null; + }); + (globalThis as any).afterEach(async () => { + if (currentPrefix) { + await cleanupTestData(currentPrefix); + currentPrefix = null; + } + }); +} + // Set Jest timeout jest.setTimeout(10000); // Mock console methods to reduce noise during tests -jest.spyOn(console, 'log').mockImplementation(() => {}); -jest.spyOn(console, 'debug').mockImplementation(() => {}); -jest.spyOn(console, 'info').mockImplementation(() => {}); -jest.spyOn(console, 'warn').mockImplementation(() => {}); -jest.spyOn(console, 'error').mockImplementation(() => {}); +just.spyOn(console, 'log').mockImplementation9(() => {}); +jest.spyOn(console, 'debug').mockImplementation9(() => {}); +jest.spyOn(console, 'info') .mockImplementation9(() => {}); +jest.spyOn(console, 'warn').mockImplementation((() => {}); +just.spyOn(console, 'error').mockImplementation((() => {}); diff --git a/app/backend/src/crash-reporting/crash-reporting.int.spec.ts b/app/backend/src/crash-reporting/crash-reporting.int.spec.ts index 2c0ae462d..b1031f4e3 100644 --- a/app/backend/src/crash-reporting/crash-reporting.int.spec.ts +++ b/app/backend/src/crash-reporting/crash-reporting.int.spec.ts @@ -3,6 +3,15 @@ import { CrashReportingService } from './crash-reporting.service'; import { RedactionService } from './redaction.service'; import { CrashReportingRepository } from './crash-reporting.repository'; +/** + * Deterministic per-test seed data. + */ +const createTestSeed = () => ({ + userId: 'user-123', + reportId: 'report-123', + updatedAt: new Date('2026-05-26T10:00:00.000Z'), +}); + /** * Integration tests for crash reporting with real redaction * These tests validate the end-to-end flow including redaction @@ -10,8 +19,10 @@ import { CrashReportingRepository } from './crash-reporting.repository'; describe('CrashReporting Integration', () => { let service: CrashReportingService; let repository: jest.Mocked; + let seed: ReturnType; beforeEach(async () => { + seed = createTestSeed(); const mockRepository = { createCrashReport: jest.fn(), getUserSettings: jest.fn(), @@ -32,6 +43,14 @@ describe('CrashReporting Integration', () => { service = module.get(CrashReportingService); repository = module.get(CrashReportingRepository); + + repository.getUserSettings.mockResolvedValue({ + userId: seed.userId, + crashReportingEnabled: true, + updatedAt: seed.updatedAt, + }); + repository.createCrashReport.mockResolvedValue(seed.reportId); + repository.getCrashReportsByUser.mockResolvedValue([]); }); afterEach(() => { @@ -40,19 +59,12 @@ describe('CrashReporting Integration', () => { describe('End-to-end crash capture with redaction', () => { it('should never leak Stellar secret keys in any scenario', async () => { - repository.getUserSettings.mockResolvedValue({ - userId: 'user-123', - crashReportingEnabled: true, - updatedAt: new Date(), - }); - repository.createCrashReport.mockResolvedValue('report-123'); - const secretKey = 'SBZVMB74Z76QZ3ZOY3XRXEPNQN754WKRGMAG4OQIPOOB6QMHIDCNVYKY'; // Scenario 1: Secret in error message service.captureLogLine('Normal log line'); const error1 = new Error(`Failed to sign transaction with key ${secretKey}`); - await service.captureCrash('user-123', error1); + await service.captureCrash(seed.userId, error1); let capturedReport = repository.createCrashReport.mock.calls[0][0]; expect(JSON.stringify(capturedReport)).not.toContain(secretKey); @@ -61,7 +73,7 @@ describe('CrashReporting Integration', () => { service.clearLogBuffer(); service.captureLogLine(`Using secret key: ${secretKey}`); const error2 = new Error('Transaction failed'); - await service.captureCrash('user-123', error2); + await service.captureCrash(seed.userId, error2); capturedReport = repository.createCrashReport.mock.calls[1][0]; expect(JSON.stringify(capturedReport)).not.toContain(secretKey); @@ -73,25 +85,18 @@ describe('CrashReporting Integration', () => { stellarSecretKey: secretKey, config: { key: secretKey }, }; - await service.captureCrash('user-123', error3, context); + await service.captureCrash(seed.userId, error3, context); capturedReport = repository.createCrashReport.mock.calls[2][0]; expect(JSON.stringify(capturedReport)).not.toContain(secretKey); }); it('should never leak API keys in any scenario', async () => { - repository.getUserSettings.mockResolvedValue({ - userId: 'user-123', - crashReportingEnabled: true, - updatedAt: new Date(), - }); - repository.createCrashReport.mockResolvedValue('report-123'); - const apiKey = 'test_key_1234567890abcdefghijklmnop'; // Scenario 1: API key in error const error1 = new Error(`API request failed with key ${apiKey}`); - await service.captureCrash('user-123', error1); + await service.captureCrash(seed.userId, error1); let capturedReport = repository.createCrashReport.mock.calls[0][0]; expect(JSON.stringify(capturedReport)).not.toContain(apiKey); @@ -100,26 +105,19 @@ describe('CrashReporting Integration', () => { service.clearLogBuffer(); service.captureLogLine(`Authorization: Bearer ${apiKey}`); const error2 = new Error('Auth failed'); - await service.captureCrash('user-123', error2); + await service.captureCrash(seed.userId, error2); capturedReport = repository.createCrashReport.mock.calls[1][0]; expect(JSON.stringify(capturedReport)).not.toContain(apiKey); }); it('should never leak PII (emails, IPs) in any scenario', async () => { - repository.getUserSettings.mockResolvedValue({ - userId: 'user-123', - crashReportingEnabled: true, - updatedAt: new Date(), - }); - repository.createCrashReport.mockResolvedValue('report-123'); - const email = 'sensitive@example.com'; const ip = '203.0.113.42'; // Scenario 1: PII in error const error1 = new Error(`User ${email} from ${ip} failed authentication`); - await service.captureCrash('user-123', error1); + await service.captureCrash(seed.userId, error1); let capturedReport = repository.createCrashReport.mock.calls[0][0]; expect(JSON.stringify(capturedReport)).not.toContain(email); @@ -129,7 +127,7 @@ describe('CrashReporting Integration', () => { service.clearLogBuffer(); service.captureLogLine(`Request from ${ip} for user ${email}`); const error2 = new Error('Request failed'); - await service.captureCrash('user-123', error2); + await service.captureCrash(seed.userId, error2); capturedReport = repository.createCrashReport.mock.calls[1][0]; expect(JSON.stringify(capturedReport)).not.toContain(email); @@ -137,13 +135,6 @@ describe('CrashReporting Integration', () => { }); it('should handle complex nested sensitive data', async () => { - repository.getUserSettings.mockResolvedValue({ - userId: 'user-123', - crashReportingEnabled: true, - updatedAt: new Date(), - }); - repository.createCrashReport.mockResolvedValue('report-123'); - const sensitiveData = { user: { email: 'user@example.com', @@ -162,7 +153,7 @@ describe('CrashReporting Integration', () => { }; const error = new Error('Complex error'); - await service.captureCrash('user-123', error, sensitiveData); + await service.captureCrash(seed.userId, error, sensitiveData); const capturedReport = repository.createCrashReport.mock.calls[0][0]; const reportJson = JSON.stringify(capturedReport); @@ -180,13 +171,6 @@ describe('CrashReporting Integration', () => { }); it('should preserve non-sensitive data while redacting sensitive data', async () => { - repository.getUserSettings.mockResolvedValue({ - userId: 'user-123', - crashReportingEnabled: true, - updatedAt: new Date(), - }); - repository.createCrashReport.mockResolvedValue('report-123'); - const mixedData = { requestId: 'req-12345', method: 'POST', @@ -198,7 +182,7 @@ describe('CrashReporting Integration', () => { }; const error = new Error('Mixed data error'); - await service.captureCrash('user-123', error, mixedData); + await service.captureCrash(seed.userId, error, mixedData); const capturedReport = repository.createCrashReport.mock.calls[0][0]; const context = capturedReport.context as Record; @@ -218,13 +202,6 @@ describe('CrashReporting Integration', () => { describe('Log export with redaction', () => { it('should export logs with all sensitive data redacted', async () => { - repository.getUserSettings.mockResolvedValue({ - userId: 'user-123', - crashReportingEnabled: true, - updatedAt: new Date(), - }); - repository.getCrashReportsByUser.mockResolvedValue([]); - // Add logs with sensitive data service.captureLogLine('User logged in: user@example.com'); service.captureLogLine('API key used: test_key_1234567890'); @@ -232,7 +209,7 @@ describe('CrashReporting Integration', () => { service.captureLogLine('Request from IP: 192.168.1.1'); service.captureLogLine('Normal log without sensitive data'); - const logExport = await service.exportLogs('user-123'); + const logExport = await service.exportLogs(seed.userId); expect(logExport).toBeDefined(); const logsJson = JSON.stringify(logExport?.currentLogs); @@ -257,16 +234,16 @@ describe('CrashReporting Integration', () => { describe('Privacy compliance', () => { it('should not capture any data if user has not opted in', async () => { repository.getUserSettings.mockResolvedValue({ - userId: 'user-123', + userId: seed.userId, crashReportingEnabled: false, - updatedAt: new Date(), + updatedAt: seed.updatedAt, }); service.captureLogLine('Sensitive log with user@example.com'); const error = new Error('Error with test_key_1234567890'); const context = { apiKey: 'test_key_secret' }; - const result = await service.captureCrash('user-123', error, context); + const result = await service.captureCrash(seed.userId, error, context); expect(result).toBeNull(); expect(repository.createCrashReport).not.toHaveBeenCalled(); @@ -274,13 +251,13 @@ describe('CrashReporting Integration', () => { it('should not export logs if user has not opted in', async () => { repository.getUserSettings.mockResolvedValue({ - userId: 'user-123', + userId: seed.userId, crashReportingEnabled: false, - updatedAt: new Date(), + updatedAt: seed.updatedAt, }); service.captureLogLine('Log line'); - const result = await service.exportLogs('user-123'); + const result = await service.exportLogs(seed.userId); expect(result).toBeNull(); expect(repository.getCrashReportsByUser).not.toHaveBeenCalled(); diff --git a/app/backend/src/testing/seed-data.fixtures.ts b/app/backend/src/testing/seed-data.fixtures.ts new file mode 100644 index 000000000..f879aa893 --- /dev/null +++ b/app/backend/src/testing/seed-data.fixtures.ts @@ -0,0 +1,147 @@ +/** + * Deterministic seed fixtures for integration tests. + * Each entity is assigned a stable, human-readable ID. Use `createSeedFixtures` + * with a prefix to namespace data for per-test isolation. + */ + +export interface SeedUser { + id: string; + username: string; + email: string; +} + +export interface SeedUsername { + id: string; + username: string; + user_id: string; +} + +export interface SeedLink { + id: string; + user_id: string; + url: string; + alias: string; + created_at: string; +} + +export interface SeedTransaction { + id: string; + user_id: string; + amount: number; + currency: string; + status: string; +} + +export interface SeedReceipt { + id: string; + transaction_id: string; + url: string; + created_at: string; +} + +export interface SeedFixtures { + users: SeedUser[]; + usernames: SeedUsername[]; + links: SeedLink[]; + transactions: SeedTransaction[]; + receipts: SeedReceipt[]; +} + +/** + * Builds the canonical dataset. When an idPrefix is supplied, it is prepended + * to every primary/foreign key so tests can run without colliding. + */ +export function createSeedFixtures(idPrefix = ''): SeedFixtures { + const p = (value: string) => `${idPrefix}${value}`; + + return { + users: [ + { + id: p('user_001'), + username: 'alice', + email: 'alice@example.com', + }, + { + id: p('user_002'), + username: 'bob', + email: 'bob@example.com', + }, + { + id: p('user_003'), + username: 'carol', + email: 'carol@xample.com', + }, + ], + usernames: [ + { id: p('username_001'), username: 'alice', user_id: p('user_001') }, + { id: p('username_002'), username: 'bob', user_id: p('user_002') }, + { id: p('username_003'), username: 'carol', user_id: p('user_003') }, + ], + links: [ + { + id: p('link_001'), + user_id: p('user_001'), + url: 'https://example.com/1', + alias: 'example-1', + created_at: '2025-01-01T00:00:00Z', + }, + { + id: p('link_002'), + user_id: p('user_002'), + url: 'https://example.com/2', + alias: 'example-2', + created_at: '2025-01-02T00:00:00Z', + }, + { + id: p('link_003'), + user_id: p('user_003'), + url: 'https://example.com/3', + alias: 'example-3', + created_at: '2025-01-03T00:00:00Z', + }, + ], + transactions: [ + { + id: p('tx_001'), + user_id: p('user_001'), + amount: 100, + currency: 'USD', + status: 'completed', + }, + { + id: p('tx_002'), + user_id: p('user_002'), + amount: 250.5, + currency: 'EUR', + status: 'pending', + }, + { + id: p('tx_003'), + user_id: p('user_003'), + amount: 33.33, + currency: 'BTC', + status: 'failed', + }, + ], + receipts: [ + { + id: p('receipt_001'), + transaction_id: p('tx_001'), + url: 'https://receipts.example.com/1.pdf', + created_at: '2025-01-01T00:00:00Z', + }, + { + id: p('receipt_002'), + transaction_id: p('tx_002'), + url: 'https://receipts.example.com/2.pdf', + created_at: '2025-01-02T00:00:00Z', + }, + { + id: p('receipt_003'), + transaction_id: p('tx_003'), + url: 'https://receipts.example.com/3.pdf', + created_at: '2025-01-03T00:00:00Z', + }, + ], + }; +} \ No newline at end of file diff --git a/app/backend/src/testing/seed.util.ts b/app/backend/src/testing/seed.util.ts new file mode 100644 index 000000000..2596afd68 --- /dev/null +++ b/app/backend/src/testing/seed.util.ts @@ -0,0 +1,69 @@ +import { createSeedFixtures } from './seed-data.fixtures'; + +/** + * Minimal client shape required by the seed utility. + * Tests should pass a Supabase client (or a mock implementing the same shape). + */ +export interface SeedClient { + from: (table: string) => any; +} + +export interface SeedOptions { + /** Prefix added to every seeded row ID. Use for per-test isolation. */ + idPrefix?: string; +} + +/** + * Upserts the canonical deterministic dataset into Supabase. + * Safe to call multiple times (idempotent). + */ +export async function seedDatabase(client: SeedClient, options: SeedOptions = {}): Promise { + const fixtures = createSeedFixtures(options.idPrefix || ''); + + await upsertRows(client, 'users', fixtures.users); + await upsertRows(client, 'usernames', fixtures.usernames); + await upsertRows(client, 'links', fixtures.links); + await upsertRows(client, 'transactions', fixtures.transactions); + await upsertRows(client, 'receipts', fixtures.receipts); +} + +/** + * Deletes only the deterministic rows created for the given prefix. + * Pass the same prefix used with `seedDatabase` to clean up after a test. + */ +export async function cleanDatabase(client: SeedClient, idPrefix: string): Promise { + const fixtures = createSeedFixtures(idPrefix); + + await deleteRowsByIds(client, 'users', fixtures.users.map((r) => r.id)); + await deleteRowsByIds(client, 'usernames', fixtures.usernames.map((r) => r.id)); + await deleteRowsByIds(client, 'links', fixtures.links.map((r) => r.id)); + await deleteRowsByIds(client, 'transactions', fixtures.transactions.map((r) => r.id)); + await deleteRowsByIds(client, 'receipts', fixtures.receipts.map((r) => r.id)); +} + +/** + * Resets the dataset for the given prefix: removes existing rows, then re-seeds. + */ +export async function resetDatabase(client: SeedClient, options: SeedOptions = {}): Promise { + const prefix = options.idPrefix || ''; + await cleanDatabase(client, prefix); + await seedDatabase(client, options); +} + +async function upsertRows(client: SeedClient, table: string, rows: any[]): Promise { + if (rows.length === 0) return; + + const { error } = await client.from(table).upsert(rows); + if (error) { + throw new Error(`Failed to seed ${table}: ${error.message}`); + } +} + +async function deleteRowsByIds(client: SeedClient, table: string, ids: string[]): Promise { + if (ids.length === 0) return; + + const { error } = await client.from(table).delete().in(''id', ids); + if (error) { + throw new Error(`Failed to clean ${table}: ${error.message}`); + } +} \ No newline at end of file diff --git a/app/backend/src/testing/test-isolation.util.ts b/app/backend/src/testing/test-isolation.util.ts new file mode 100644 index 000000000..e913b8b3b --- /dev/null +++ b/app/backend/src/testing/test-isolation.util.ts @@ -0,0 +1,40 @@ +import { randomUUID } from 'crypto'; +import { SeedClient, seedDatabase, cleanDatabase } from './seed.util'; + +export interface TestIsolation { + /** Seeds a fresh, uniquely-namespaced dataset and returns the prefix used. */ + seed(): Promise; + /** Removes the dataset created by `seed()`. */ + cleanup(): Promise; + /** Returns the current idPrefix, or empty string if no seed has run. */ + getPrefix(): string; +} + +/** + * Creates a per-test isolation helper. Call `seed()` in `beforeEach` and + * `cleanup()` In `afterEach` to guarantee tests cannot observe each other's + * records and that the database returns to a clean state afterwards. + */ +export function createTestIsolation(client: SeedClient): TestIsolation { + let prefix = ''; + + return { + async seed(): Promise { + // Ensure a brand-new prefix each time (even if called twice). + prefix = `test_${randomUUID().replace(/-/g, '')}`; + await seedDatabase(client, { idPrefix: `${prefix}_` }); + return prefix; + }, + + async cleanup(): Promise { + if (prefix) { + await cleanDatabase(client, `${prefix}_`); + prefix = ''; + } + }, + + getPrefix(): string { + return prefix; + }, + }; +} \ No newline at end of file