diff --git a/backend/tests/integration/testContainer.test.ts b/backend/tests/integration/testContainer.test.ts index 63d2c333..4eadd9d2 100644 --- a/backend/tests/integration/testContainer.test.ts +++ b/backend/tests/integration/testContainer.test.ts @@ -1,24 +1,738 @@ -import { testContainerManager, detectFlakyTest } from '../setup/testContainer'; +/** + * testContainer.test.ts — Tests for the test-container and fixture infrastructure. + * + * Validates: lifecycle, seeding, savepoints, snapshots, FixtureLoader factories, + * FlakyTestDetector, TestClock, TestEventBus, DatabaseSeeder, RedisFixture, + * PerformanceAssertion, and createTestContext. + */ -describe('TestContainerManager and Fixtures', () => { - beforeAll(async () => { - await testContainerManager.startContainer(); +import { + TestContainerManager, + testContainerManager, + FixtureLoader, + DatabaseSeeder, + TestClock, + TestEventBus, + RedisFixture, + detectFlakyTest, + withTiming, + assertWithinMs, + createTestContext, + type RedisClient, + type SubscriptionFixture, + type InvoiceFixture, +} from '../setup/testContainer'; + +// ─── TestContainerManager — lifecycle ──────────────────────────────────────── + +describe('TestContainerManager — lifecycle', () => { + let mgr: TestContainerManager; + + beforeEach(async () => { + mgr = new TestContainerManager({ inMemory: true }); + await mgr.startContainer(); + }); + + afterEach(async () => { + await mgr.stopContainer(); + }); + + it('starts inactive and becomes active after startContainer', async () => { + const fresh = new TestContainerManager(); + expect(fresh.isContainerActive()).toBe(false); + await fresh.startContainer(); + expect(fresh.isContainerActive()).toBe(true); + await fresh.stopContainer(); + }); + + it('is idempotent — calling startContainer twice does not throw', async () => { + await expect(mgr.startContainer()).resolves.toBeUndefined(); + expect(mgr.isContainerActive()).toBe(true); + }); + + it('becomes inactive after stopContainer', async () => { + await mgr.stopContainer(); + expect(mgr.isContainerActive()).toBe(false); + }); + + it('loads initialSeed on start', async () => { + const seeded = new TestContainerManager({ + inMemory: true, + initialSeed: { plans: [{ id: 'p1', name: 'Starter', price: 5, currency: 'USD', interval: 'monthly' }] }, + }); + await seeded.startContainer(); + expect(seeded.findById('plans', 'p1')).toBeDefined(); + await seeded.stopContainer(); + }); + + it('clears data on stop and re-start', async () => { + await mgr.seedDatabase({ users: [{ id: 'u1', email: 'a@b.com' }] }); + await mgr.stopContainer(); + await mgr.startContainer(); + expect(mgr.findById('users', 'u1')).toBeUndefined(); + }); +}); + +// ─── TestContainerManager — seeding & querying ─────────────────────────────── + +describe('TestContainerManager — seeding and queries', () => { + let mgr: TestContainerManager; + + beforeEach(async () => { + mgr = new TestContainerManager(); + await mgr.startContainer(); + }); + + afterEach(async () => { + await mgr.stopContainer(); + }); + + it('seeds and retrieves a single row by id', async () => { + await mgr.seedDatabase({ users: [{ id: 'u1', email: 'a@b.com' }] }); + const found = mgr.findById('users', 'u1'); + expect(found).toEqual({ id: 'u1', email: 'a@b.com' }); + }); + + it('findAll returns all seeded rows for an entity', async () => { + await mgr.seedDatabase({ + plans: [ + { id: 'p1', name: 'Basic', price: 5, currency: 'USD', interval: 'monthly' }, + { id: 'p2', name: 'Pro', price: 15, currency: 'USD', interval: 'monthly' }, + ], + }); + expect(mgr.findAll('plans')).toHaveLength(2); + }); + + it('findWhere filters rows by predicate', async () => { + await mgr.seedDatabase({ + subscriptions: [ + { id: 's1', userId: 'u1', planId: 'p1', status: 'active' }, + { id: 's2', userId: 'u2', planId: 'p1', status: 'cancelled' }, + { id: 's3', userId: 'u3', planId: 'p2', status: 'active' }, + ], + }); + const active = mgr.findWhere('subscriptions', (r) => r.status === 'active'); + expect(active).toHaveLength(2); + expect(active.map((s) => s.id).sort()).toEqual(['s1', 's3']); + }); + + it('throws when seeding a row without an id', async () => { + await expect( + mgr.seedDatabase({ users: [{ email: 'no-id@test.com' }] }), + ).rejects.toThrow('missing required "id"'); + }); + + it('upsert inserts or replaces a row', async () => { + mgr.upsert('plans', { id: 'p1', name: 'Starter', price: 5, currency: 'USD', interval: 'monthly' }); + expect(mgr.findById('plans', 'p1')).toMatchObject({ name: 'Starter', price: 5 }); + mgr.upsert('plans', { id: 'p1', name: 'Starter Updated', price: 7, currency: 'USD', interval: 'monthly' }); + expect(mgr.findById('plans', 'p1')).toMatchObject({ name: 'Starter Updated', price: 7 }); + }); + + it('delete removes a row and returns true', async () => { + await mgr.seedDatabase({ users: [{ id: 'u1', email: 'a@b.com' }] }); + expect(mgr.delete('users', 'u1')).toBe(true); + expect(mgr.findById('users', 'u1')).toBeUndefined(); + }); + + it('delete returns false for unknown id', async () => { + expect(mgr.delete('users', 'ghost')).toBe(false); + }); + + it('count returns row count per entity', async () => { + await mgr.seedDatabase({ + invoices: [ + { id: 'i1', subscriptionId: 's1', amount: 10, currency: 'USD', status: 'pending' }, + { id: 'i2', subscriptionId: 's1', amount: 10, currency: 'USD', status: 'paid' }, + ], + }); + expect(mgr.count('invoices')).toBe(2); + expect(mgr.count('plans')).toBe(0); + }); + + it('cleanDatabase clears specific entities', async () => { + await mgr.seedDatabase({ + users: [{ id: 'u1', email: 'a@b.com' }], + plans: [{ id: 'p1', name: 'X', price: 1, currency: 'USD', interval: 'monthly' }], + }); + await mgr.cleanDatabase(['users']); + expect(mgr.count('users')).toBe(0); + expect(mgr.count('plans')).toBe(1); + }); + + it('cleanDatabase with no args clears everything', async () => { + await mgr.seedDatabase({ users: [{ id: 'u1', email: 'a@b.com' }] }); + await mgr.cleanDatabase(); + expect(mgr.count('users')).toBe(0); + }); + + it('throws seedDatabase when container is not running', async () => { + const stopped = new TestContainerManager(); + await expect( + stopped.seedDatabase({ users: [{ id: 'u1', email: 'a@b.com' }] }), + ).rejects.toThrow('container is not running'); + }); + + it('cleanDatabase is a no-op when container is not running', async () => { + const stopped = new TestContainerManager(); + await expect(stopped.cleanDatabase()).resolves.toBeUndefined(); + }); +}); + +// ─── TestContainerManager — savepoints ─────────────────────────────────────── + +describe('TestContainerManager — savepoints', () => { + let mgr: TestContainerManager; + + beforeEach(async () => { + mgr = new TestContainerManager(); + await mgr.startContainer(); + }); + + afterEach(async () => { + await mgr.stopContainer(); + }); + + it('rolls back to savepoint after mutations', async () => { + await mgr.seedDatabase({ users: [{ id: 'u1', email: 'a@b.com' }] }); + await mgr.createSavepoint(); + + await mgr.seedDatabase({ users: [{ id: 'u2', email: 'b@b.com' }] }); + expect(mgr.count('users')).toBe(2); + + await mgr.rollbackToSavepoint(); + expect(mgr.count('users')).toBe(1); + expect(mgr.findById('users', 'u1')).toBeDefined(); + expect(mgr.findById('users', 'u2')).toBeUndefined(); }); - afterAll(async () => { + it('savepoints stack — multiple levels work correctly', async () => { + await mgr.seedDatabase({ users: [{ id: 'u1', email: 'a@b.com' }] }); + await mgr.createSavepoint(); // level 1 + + await mgr.seedDatabase({ users: [{ id: 'u2', email: 'b@b.com' }] }); + await mgr.createSavepoint(); // level 2 + + await mgr.seedDatabase({ users: [{ id: 'u3', email: 'c@b.com' }] }); + expect(mgr.count('users')).toBe(3); + + await mgr.rollbackToSavepoint(); // back to level 1 state + expect(mgr.count('users')).toBe(2); + + await mgr.rollbackToSavepoint(); // back to original + expect(mgr.count('users')).toBe(1); + }); + + it('rollbackToSavepoint is a no-op when stack is empty', async () => { + await expect(mgr.rollbackToSavepoint()).resolves.toBeUndefined(); + }); +}); + +// ─── TestContainerManager — snapshots ──────────────────────────────────────── + +describe('TestContainerManager — snapshots', () => { + let mgr: TestContainerManager; + + beforeEach(async () => { + mgr = new TestContainerManager(); + await mgr.startContainer(); + }); + + afterEach(async () => { + await mgr.stopContainer(); + }); + + it('takes a named snapshot and restores it', async () => { + await mgr.seedDatabase({ users: [{ id: 'u1', email: 'a@b.com' }] }); + mgr.takeSnapshot('baseline'); + + await mgr.seedDatabase({ users: [{ id: 'u2', email: 'b@b.com' }] }); + expect(mgr.count('users')).toBe(2); + + await mgr.restoreSnapshot('baseline'); + expect(mgr.count('users')).toBe(1); + expect(mgr.findById('users', 'u1')).toBeDefined(); + expect(mgr.findById('users', 'u2')).toBeUndefined(); + }); + + it('getSnapshot returns undefined for unknown id', () => { + expect(mgr.getSnapshot('nonexistent')).toBeUndefined(); + }); + + it('restoreSnapshot throws for unknown id', async () => { + await expect(mgr.restoreSnapshot('ghost')).rejects.toThrow('snapshot "ghost" not found'); + }); + + it('snapshot contains takenAt timestamp', async () => { + const before = Date.now(); + const snap = mgr.takeSnapshot('ts-test'); + expect(snap.takenAt).toBeGreaterThanOrEqual(before); + expect(snap.takenAt).toBeLessThanOrEqual(Date.now()); + }); +}); + +// ─── FixtureLoader ──────────────────────────────────────────────────────────── + +describe('FixtureLoader', () => { + beforeEach(() => FixtureLoader.resetCounter()); + + it('plan() creates a valid plan fixture with defaults', () => { + const plan = FixtureLoader.plan(); + expect(plan.id).toBeDefined(); + expect(plan.currency).toBe('USD'); + expect(plan.interval).toBe('monthly'); + expect(plan.active).toBe(true); + }); + + it('plan() merges overrides', () => { + const plan = FixtureLoader.plan({ id: 'my-plan', price: 49.99, interval: 'yearly' }); + expect(plan.id).toBe('my-plan'); + expect(plan.price).toBe(49.99); + expect(plan.interval).toBe('yearly'); + }); + + it('user() creates a valid user fixture', () => { + const user = FixtureLoader.user(); + expect(user.id).toBeDefined(); + expect(user.email).toContain('@test.example'); + expect(user.address).toBeDefined(); + }); + + it('subscription() links to plan and user by default', () => { + const sub = FixtureLoader.subscription(); + expect(sub.userId).toBeDefined(); + expect(sub.planId).toBeDefined(); + expect(sub.status).toBe('active'); + expect(sub.amount).toBe(9.99); + }); + + it('merchant() creates a valid merchant fixture', () => { + const m = FixtureLoader.merchant(); + expect(m.email).toContain('@merchant.example'); + expect(m.active).toBe(true); + }); + + it('invoice() defaults to pending status', () => { + const inv = FixtureLoader.invoice(); + expect(inv.status).toBe('pending'); + expect(inv.paidAt).toBeNull(); + }); + + it('invoice() accepts overrides', () => { + const inv = FixtureLoader.invoice({ status: 'paid', paidAt: '2025-01-01T00:00:00Z' }); + expect(inv.status).toBe('paid'); + expect(inv.paidAt).toBe('2025-01-01T00:00:00Z'); + }); + + it('all factories produce unique IDs across calls', () => { + const ids = [ + FixtureLoader.plan().id, + FixtureLoader.plan().id, + FixtureLoader.user().id, + FixtureLoader.subscription().id, + ]; + expect(new Set(ids).size).toBe(4); + }); + + it('fullSubscriptionScenario returns linked entities', () => { + const scenario = FixtureLoader.fullSubscriptionScenario(); + expect(scenario.plan.merchantId).toBe(scenario.merchant.id); + expect(scenario.subscription.userId).toBe(scenario.user.id); + expect(scenario.subscription.planId).toBe(scenario.plan.id); + expect(scenario.invoice.subscriptionId).toBe(scenario.subscription.id); + expect(scenario.invoice.amount).toBe(scenario.plan.price); + }); + + it('fullSubscriptionScenario respects overrides', () => { + const scenario = FixtureLoader.fullSubscriptionScenario({ + plan: { price: 99, interval: 'yearly' }, + subscription: { status: 'paused' }, + }); + expect(scenario.plan.price).toBe(99); + expect(scenario.subscription.status).toBe('paused'); + expect(scenario.invoice.amount).toBe(99); + }); +}); + +// ─── DatabaseSeeder ─────────────────────────────────────────────────────────── + +describe('DatabaseSeeder', () => { + let mgr: TestContainerManager; + + beforeEach(async () => { + mgr = new TestContainerManager(); + await mgr.startContainer(); + }); + + afterEach(async () => { + await mgr.stopContainer(); + }); + + it('builds and seeds data in dependency order', async () => { + const seeder = new DatabaseSeeder() + .withMerchants([FixtureLoader.merchant({ id: 'm1' })]) + .withPlans([FixtureLoader.plan({ id: 'p1', merchantId: 'm1' })]) + .withUsers([FixtureLoader.user({ id: 'u1' })]) + .withSubscriptions([FixtureLoader.subscription({ id: 's1', userId: 'u1', planId: 'p1' })]) + .withInvoices([FixtureLoader.invoice({ id: 'i1', subscriptionId: 's1' })]); + + const built = seeder.build(); + // Merchants should appear before subscriptions in key order + const keys = Object.keys(built); + expect(keys.indexOf('merchants')).toBeLessThan(keys.indexOf('subscriptions')); + expect(keys.indexOf('plans')).toBeLessThan(keys.indexOf('subscriptions')); + + await seeder.seedInto(mgr); + expect(mgr.count('merchants')).toBe(1); + expect(mgr.count('plans')).toBe(1); + expect(mgr.count('users')).toBe(1); + expect(mgr.count('subscriptions')).toBe(1); + expect(mgr.count('invoices')).toBe(1); + }); + + it('withRaw adds arbitrary entity data', async () => { + const seeder = new DatabaseSeeder() + .withRaw('audit_logs', [{ id: 'log1', action: 'login', userId: 'u1' }]); + await seeder.seedInto(mgr); + expect(mgr.count('audit_logs')).toBe(1); + expect(mgr.findById('audit_logs', 'log1')).toMatchObject({ action: 'login' }); + }); + + it('chaining multiple withPlans calls accumulates rows', async () => { + const seeder = new DatabaseSeeder() + .withPlans([FixtureLoader.plan({ id: 'p1' })]) + .withPlans([FixtureLoader.plan({ id: 'p2' })]); + await seeder.seedInto(mgr); + expect(mgr.count('plans')).toBe(2); + }); +}); + +// ─── detectFlakyTest ───────────────────────────────────────────────────────── + +describe('detectFlakyTest', () => { + it('reports non-flaky for always-passing tests', async () => { + const result = await detectFlakyTest(() => { /* always passes */ }, 5); + expect(result.isFlaky).toBe(false); + expect(result.passed).toBe(5); + expect(result.failed).toBe(0); + expect(result.errors).toHaveLength(0); + }); + + it('reports non-flaky for always-failing tests', async () => { + const result = await detectFlakyTest(() => { throw new Error('always fails'); }, 3); + expect(result.isFlaky).toBe(false); + expect(result.passed).toBe(0); + expect(result.failed).toBe(3); + expect(result.errors).toHaveLength(3); + }); + + it('reports flaky when test passes and fails across iterations', async () => { + let call = 0; + const result = await detectFlakyTest(() => { + call++; + if (call % 2 === 0) throw new Error('intermittent'); + }, 4); + expect(result.isFlaky).toBe(true); + expect(result.passed).toBeGreaterThan(0); + expect(result.failed).toBeGreaterThan(0); + }); + + it('captures error messages in the errors array', async () => { + const result = await detectFlakyTest(() => { throw new Error('boom'); }, 2); + expect(result.errors).toEqual(['boom', 'boom']); + }); + + it('reports durationMs >= 0', async () => { + const result = await detectFlakyTest(() => {}, 1); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); +}); + +// ─── TestClock ──────────────────────────────────────────────────────────────── + +describe('TestClock', () => { + it('now() returns the initial time', () => { + const clock = new TestClock(1_000_000); + expect(clock.now()).toBe(1_000_000); + }); + + it('advance() adds milliseconds', () => { + const clock = new TestClock(0); + clock.advance(5000); + expect(clock.now()).toBe(5000); + }); + + it('advanceHours() adds hours correctly', () => { + const clock = new TestClock(0); + clock.advanceHours(2); + expect(clock.now()).toBe(2 * 3_600_000); + }); + + it('advanceDays() adds days correctly', () => { + const clock = new TestClock(0); + clock.advanceDays(1); + expect(clock.now()).toBe(86_400_000); + }); + + it('set() overrides to an absolute time', () => { + const clock = new TestClock(0); + clock.set('2025-01-01T00:00:00Z'); + expect(clock.now()).toBe(new Date('2025-01-01T00:00:00Z').getTime()); + }); + + it('date() returns a Date object matching now()', () => { + const clock = new TestClock(12345678); + expect(clock.date().getTime()).toBe(12345678); + }); + + it('iso() returns an ISO string', () => { + const clock = new TestClock(0); + clock.set('2025-06-15T12:00:00.000Z'); + expect(clock.iso()).toBe('2025-06-15T12:00:00.000Z'); + }); + + it('reset() restores to a given time', () => { + const clock = new TestClock(1000); + clock.advanceDays(10); + clock.reset(1000); + expect(clock.now()).toBe(1000); + }); + + it('chaining works', () => { + const clock = new TestClock(0); + const result = clock.advance(1000).advanceHours(1).advanceDays(1); + expect(result).toBe(clock); + expect(clock.now()).toBe(1000 + 3_600_000 + 86_400_000); + }); +}); + +// ─── TestEventBus ───────────────────────────────────────────────────────────── + +describe('TestEventBus', () => { + let bus: TestEventBus; + + beforeEach(() => { bus = new TestEventBus(); }); + + it('records published events', () => { + bus.publish('subscription.created', { id: 's1' }); + expect(bus.count()).toBe(1); + }); + + it('ofType returns only matching events', () => { + bus.publish('subscription.created', { id: 's1' }); + bus.publish('invoice.paid', { id: 'i1' }); + bus.publish('subscription.cancelled', { id: 's2' }); + const created = bus.ofType('subscription.created'); + expect(created).toHaveLength(1); + expect(created[0].payload).toEqual({ id: 's1' }); + }); + + it('last() returns the most recent event of that type', () => { + bus.publish('payment.failed', { invoiceId: 'i1' }); + bus.publish('payment.failed', { invoiceId: 'i2' }); + const last = bus.last<{ invoiceId: string }>('payment.failed'); + expect(last?.payload.invoiceId).toBe('i2'); + }); + + it('last() returns undefined when no matching events', () => { + expect(bus.last('ghost')).toBeUndefined(); + }); + + it('count() filters by name when provided', () => { + bus.publish('a', {}); + bus.publish('a', {}); + bus.publish('b', {}); + expect(bus.count('a')).toBe(2); + expect(bus.count('b')).toBe(1); + expect(bus.count()).toBe(3); + }); + + it('assertPublished does not throw when event was published', () => { + bus.publish('payment.success', {}); + expect(() => bus.assertPublished('payment.success')).not.toThrow(); + }); + + it('assertPublished throws when event was NOT published', () => { + expect(() => bus.assertPublished('missing.event')).toThrow('"missing.event"'); + }); + + it('assertNotPublished does not throw when event was never published', () => { + expect(() => bus.assertNotPublished('never.happened')).not.toThrow(); + }); + + it('assertNotPublished throws when event was published', () => { + bus.publish('fraud.flagged', { subscriptionId: 's1' }); + expect(() => bus.assertNotPublished('fraud.flagged')).toThrow('"fraud.flagged"'); + }); + + it('clear() empties the event store', () => { + bus.publish('x', {}); + bus.publish('y', {}); + bus.clear(); + expect(bus.count()).toBe(0); + }); + + it('all() returns a copy — mutations do not affect internal state', () => { + bus.publish('x', {}); + const copy = bus.all(); + copy.push({ name: 'injected', payload: {}, timestamp: 0 }); + expect(bus.count()).toBe(1); + }); +}); + +// ─── RedisFixture ───────────────────────────────────────────────────────────── + +describe('RedisFixture', () => { + function makeMockRedis(): RedisClient & { store: Map } { + const store = new Map(); + return { + store, + async set(key: string, value: string) { store.set(key, value); }, + async get(key: string) { return store.get(key) ?? null; }, + async del(...keys: string[]) { + let n = 0; + for (const k of keys) { if (store.delete(k)) n++; } + return n; + }, + async keys(pattern: string) { + const prefix = pattern.replace(/\*$/, ''); + return Array.from(store.keys()).filter((k) => k.startsWith(prefix)); + }, + }; + } + + it('set and get round-trips through the mock client', async () => { + const redis = makeMockRedis(); + const fixture = new RedisFixture(redis); + await fixture.set('test:key', 'hello'); + expect(await fixture.get('test:key')).toBe('hello'); + }); + + it('cleanup deletes all tracked keys', async () => { + const redis = makeMockRedis(); + const fixture = new RedisFixture(redis); + await fixture.set('k1', 'v1'); + await fixture.set('k2', 'v2'); + await fixture.cleanup(); + expect(redis.store.size).toBe(0); + expect(fixture.trackedCount()).toBe(0); + }); + + it('cleanupPattern removes matching keys', async () => { + const redis = makeMockRedis(); + const fixture = new RedisFixture(redis); + await fixture.set('cache:sub:s1', 'a'); + await fixture.set('cache:sub:s2', 'b'); + await fixture.set('other:key', 'c'); + await fixture.cleanupPattern('cache:sub:*'); + expect(redis.store.has('other:key')).toBe(true); + expect(redis.store.has('cache:sub:s1')).toBe(false); + expect(redis.store.has('cache:sub:s2')).toBe(false); + }); + + it('trackedCount reflects number of keys set', async () => { + const redis = makeMockRedis(); + const fixture = new RedisFixture(redis); + await fixture.set('a', '1'); + await fixture.set('b', '2'); + expect(fixture.trackedCount()).toBe(2); + await fixture.cleanup(); + expect(fixture.trackedCount()).toBe(0); + }); +}); + +// ─── withTiming and assertWithinMs ─────────────────────────────────────────── + +describe('withTiming and assertWithinMs', () => { + it('withTiming returns the function result and a non-negative duration', async () => { + const { result, durationMs } = await withTiming(async () => 42); + expect(result).toBe(42); + expect(durationMs).toBeGreaterThanOrEqual(0); + }); + + it('assertWithinMs does not throw when within limit', () => { + expect(() => assertWithinMs(50, 100, 'test op')).not.toThrow(); + }); + + it('assertWithinMs throws when over limit', () => { + expect(() => assertWithinMs(200, 100, 'slow op')).toThrow( + 'Performance assertion failed: slow op took 200ms, limit was 100ms', + ); + }); + + it('withTiming + assertWithinMs as a combined check', async () => { + const { durationMs } = await withTiming(() => Promise.resolve('done')); + expect(() => assertWithinMs(durationMs, 1000, 'trivial op')).not.toThrow(); + }); +}); + +// ─── createTestContext ──────────────────────────────────────────────────────── + +describe('createTestContext', () => { + it('creates a context with all components', () => { + const ctx = createTestContext({ inMemory: true }); + expect(ctx.container).toBeInstanceOf(TestContainerManager); + expect(ctx.fixtures).toBe(FixtureLoader); + expect(ctx.clock).toBeDefined(); + expect(ctx.events).toBeInstanceOf(TestEventBus); + expect(ctx.seeder).toBeDefined(); + }); + + it('container starts inactive', () => { + const ctx = createTestContext(); + expect(ctx.container.isContainerActive()).toBe(false); + }); + + it('full lifecycle through createTestContext', async () => { + const ctx = createTestContext({ inMemory: true }); + await ctx.container.startContainer(); + + const plan = ctx.fixtures.plan({ id: 'p1', price: 15 }); + const user = ctx.fixtures.user({ id: 'u1' }); + const sub = ctx.fixtures.subscription({ id: 's1', planId: 'p1', userId: 'u1', amount: 15 }); + + await ctx.seeder + .withPlans([plan]) + .withUsers([user]) + .withSubscriptions([sub]) + .seedInto(ctx.container); + + expect(ctx.container.count('plans')).toBe(1); + expect(ctx.container.count('subscriptions')).toBe(1); + + ctx.clock.advanceDays(30); + expect(ctx.clock.now()).toBeGreaterThan(Date.now() - 1000); + + ctx.events.publish('subscription.renewed', { subscriptionId: 's1' }); + ctx.events.assertPublished('subscription.renewed'); + + await ctx.container.stopContainer(); + expect(ctx.container.isContainerActive()).toBe(false); + }); +}); + +// ─── Singleton testContainerManager export ─────────────────────────────────── + +describe('testContainerManager singleton', () => { + afterEach(async () => { await testContainerManager.stopContainer(); }); - it('manages container lifecycle and database seeding', async () => { + it('is exported and usable', async () => { + await testContainerManager.startContainer(); expect(testContainerManager.isContainerActive()).toBe(true); - await testContainerManager.seedDatabase({ users: [{ id: 'u1' }] }); + }); + + it('manages container lifecycle and database seeding (smoke)', async () => { + await testContainerManager.startContainer(); + expect(testContainerManager.isContainerActive()).toBe(true); + await testContainerManager.seedDatabase({ users: [{ id: 'u1', email: 'test@test.com' }] }); await testContainerManager.cleanDatabase(); + expect(testContainerManager.count('users')).toBe(0); }); it('detects flaky test behavior accurately', async () => { - const result = await detectFlakyTest(() => { - // Deterministic test passing - }, 2); + const result = await detectFlakyTest(() => { /* deterministic pass */ }, 2); expect(result.isFlaky).toBe(false); expect(result.passed).toBe(2); }); @@ -26,11 +740,7 @@ describe('TestContainerManager and Fixtures', () => { it('matches API response snapshots', () => { const apiResponse = { status: 'success', - data: { - id: 'sub_123', - amount: 15.99, - currency: 'USD', - }, + data: { id: 'sub_123', amount: 15.99, currency: 'USD' }, }; expect(apiResponse).toMatchSnapshot(); }); diff --git a/backend/tests/setup/testContainer.ts b/backend/tests/setup/testContainer.ts index e2928e27..840c8b09 100644 --- a/backend/tests/setup/testContainer.ts +++ b/backend/tests/setup/testContainer.ts @@ -1,66 +1,637 @@ /** - * Test Container and Environment Fixture Manager for Backend Integration Testing + * testContainer.ts — Test container and fixture infrastructure for backend integration tests. + * + * Provides: + * - TestContainerManager: lifecycle management (start/stop/seed/clean/snapshot/restore) + * - FixtureLoader: typed fixture factories for subscriptions, plans, users, merchants, invoices + * - FlakyTestDetector: retry-based flakiness detection + * - TestClock: deterministic time control for time-sensitive tests + * - TestEventBus: in-memory event capture for integration assertions + * - DatabaseSeeder: declarative seed helper with dependency ordering + * - RedisFixture: Redis key management with auto-cleanup + * - PerformanceAssertion: timing assertions with configurable thresholds */ +// ─── Types ──────────────────────────────────────────────────────────────────── + export interface TestContainerConfig { + /** PostgreSQL port (default: 5432 or env PG_PORT) */ dbPort?: number; + /** Redis port (default: 6379 or env REDIS_PORT) */ redisPort?: number; + /** Container image to use */ image?: string; + /** Whether to use an isolated in-memory store (default: true for unit tests) */ + inMemory?: boolean; + /** Auto-rollback each test via savepoint (default: true) */ + autoRollback?: boolean; + /** Seed data to load on start */ + initialSeed?: SeedData; +} + +export interface SeedData { + plans?: PlanFixture[]; + users?: UserFixture[]; + subscriptions?: SubscriptionFixture[]; + merchants?: MerchantFixture[]; + invoices?: InvoiceFixture[]; + [entity: string]: Record[] | undefined; +} + +export interface PlanFixture { + id: string; + name: string; + price: number; + currency: string; + interval: 'monthly' | 'yearly' | 'weekly'; + merchantId?: string; + active?: boolean; + createdAt?: string; +} + +export interface UserFixture { + id: string; + email: string; + address?: string; + createdAt?: string; +} + +export interface SubscriptionFixture { + id: string; + userId: string; + planId: string; + status: 'active' | 'cancelled' | 'paused' | 'past_due'; + startedAt?: string; + nextBillingAt?: string; + amount?: number; + currency?: string; +} + +export interface MerchantFixture { + id: string; + name: string; + email: string; + address?: string; + active?: boolean; +} + +export interface InvoiceFixture { + id: string; + subscriptionId: string; + amount: number; + currency: string; + status: 'pending' | 'paid' | 'failed' | 'voided'; + dueAt?: string; + paidAt?: string | null; +} + +export interface ContainerSnapshot { + id: string; + takenAt: number; + data: SeedData; +} + +// ─── In-memory store ────────────────────────────────────────────────────────── + +type EntityStore = Map>>; + +function storeSet(store: EntityStore, entity: string, id: string, value: Record): void { + if (!store.has(entity)) store.set(entity, new Map()); + store.get(entity)!.set(id, value); +} + +function storeGet(store: EntityStore, entity: string, id: string): Record | undefined { + return store.get(entity)?.get(id); +} + +function storeGetAll(store: EntityStore, entity: string): Record[] { + return Array.from(store.get(entity)?.values() ?? []); } +function storeClear(store: EntityStore, entity?: string): void { + if (entity) { + store.get(entity)?.clear(); + } else { + store.clear(); + } +} + +// ─── TestContainerManager ───────────────────────────────────────────────────── + +/** + * Manages test environment lifecycle and provides fixture helpers. + * + * Uses a fast in-memory store by default (inMemory: true). + * When inMemory is false, real containers are assumed to be running via + * environment-configured connection strings (PG_PORT, REDIS_PORT). + */ export class TestContainerManager { private isRunning = false; + private store: EntityStore = new Map(); + private snapshots: Map = new Map(); + private savepointStack: SeedData[] = []; + readonly config: Required; - public async startContainer(config?: TestContainerConfig): Promise { - // Simulated container startup / test environment isolation lifecycle + constructor(config: TestContainerConfig = {}) { + this.config = { + dbPort: config.dbPort ?? Number(process.env['PG_PORT'] ?? 5432), + redisPort: config.redisPort ?? Number(process.env['REDIS_PORT'] ?? 6379), + image: config.image ?? 'postgres:15-alpine', + inMemory: config.inMemory ?? true, + autoRollback: config.autoRollback ?? true, + initialSeed: config.initialSeed ?? {}, + }; + } + + // ── Lifecycle ─────────────────────────────────────────────────────────────── + + async startContainer(): Promise { + if (this.isRunning) return; + this.store = new Map(); + this.snapshots.clear(); + this.savepointStack = []; this.isRunning = true; + if (Object.keys(this.config.initialSeed).length > 0) { + await this.seedDatabase(this.config.initialSeed); + } + } + + async stopContainer(): Promise { + this.isRunning = false; + this.store = new Map(); + this.snapshots.clear(); + this.savepointStack = []; + } + + isContainerActive(): boolean { + return this.isRunning; } - public async seedDatabase(seedData: Record): Promise { - if (!this.isRunning) { - throw new Error('Test container is not running'); + // ── Seeding ───────────────────────────────────────────────────────────────── + + async seedDatabase(seedData: SeedData): Promise { + if (!this.isRunning) throw new Error('TestContainerManager: container is not running'); + for (const [entity, rows] of Object.entries(seedData)) { + if (!rows) continue; + for (const row of rows) { + const id = row['id'] as string; + if (!id) throw new Error(`Seed row in "${entity}" is missing required "id" field`); + storeSet(this.store, entity, id, { ...row }); + } } - // Database seeding helper } - public async cleanDatabase(): Promise { - if (!this.isRunning) { - return; + async cleanDatabase(entities?: string[]): Promise { + if (!this.isRunning) return; + if (entities) { + for (const e of entities) storeClear(this.store, e); + } else { + storeClear(this.store); } - // Clean database records between test runs } - public async stopContainer(): Promise { - this.isRunning = false; + // ── Savepoints (per-test isolation) ───────────────────────────────────────── + + async createSavepoint(): Promise { + const snap: SeedData = {}; + for (const [entity, entityMap] of this.store) { + snap[entity] = Array.from(entityMap.values()) as Record[]; + } + this.savepointStack.push(snap); } - public isContainerActive(): boolean { - return this.isRunning; + async rollbackToSavepoint(): Promise { + const snap = this.savepointStack.pop(); + if (!snap) return; + storeClear(this.store); + if (Object.keys(snap).length > 0) await this.seedDatabase(snap); + } + + // ── Named snapshots ───────────────────────────────────────────────────────── + + takeSnapshot(id: string): ContainerSnapshot { + const data: SeedData = {}; + for (const [entity, entityMap] of this.store) { + data[entity] = Array.from(entityMap.values()) as Record[]; + } + const snap: ContainerSnapshot = { id, takenAt: Date.now(), data }; + this.snapshots.set(id, snap); + return snap; + } + + async restoreSnapshot(id: string): Promise { + const snap = this.snapshots.get(id); + if (!snap) throw new Error(`TestContainerManager: snapshot "${id}" not found`); + storeClear(this.store); + await this.seedDatabase(snap.data); + } + + getSnapshot(id: string): ContainerSnapshot | undefined { + return this.snapshots.get(id); + } + + // ── Query helpers ─────────────────────────────────────────────────────────── + + findById>(entity: string, id: string): T | undefined { + return storeGet(this.store, entity, id) as T | undefined; + } + + findAll>(entity: string): T[] { + return storeGetAll(this.store, entity) as T[]; + } + + findWhere>(entity: string, predicate: (row: T) => boolean): T[] { + return this.findAll(entity).filter(predicate); + } + + upsert>(entity: string, row: T): T { + const id = row['id'] as string; + if (!id) throw new Error(`upsert: missing id in entity "${entity}"`); + storeSet(this.store, entity, id, row); + return row; + } + + delete(entity: string, id: string): boolean { + return this.store.get(entity)?.delete(id) ?? false; + } + + count(entity: string): number { + return this.store.get(entity)?.size ?? 0; } } export const testContainerManager = new TestContainerManager(); +// ─── FixtureLoader ──────────────────────────────────────────────────────────── + +let _fixtureCounter = 0; + +function nextId(prefix: string): string { + return `${prefix}_${String(++_fixtureCounter).padStart(4, '0')}`; +} + +/** + * Typed fixture factory. Uses sensible defaults so tests only specify what matters. + */ +export class FixtureLoader { + static resetCounter(): void { + _fixtureCounter = 0; + } + + static plan(overrides: Partial = {}): PlanFixture { + return { + id: nextId('plan'), + name: 'Test Plan', + price: 9.99, + currency: 'USD', + interval: 'monthly', + merchantId: 'merchant_default', + active: true, + createdAt: new Date().toISOString(), + ...overrides, + }; + } + + static user(overrides: Partial = {}): UserFixture { + const id = overrides.id ?? nextId('user'); + return { + id, + email: `${id}@test.example`, + address: `GTEST${id.toUpperCase().slice(0, 51).padEnd(51, 'A')}`, + createdAt: new Date().toISOString(), + ...overrides, + }; + } + + static subscription(overrides: Partial = {}): SubscriptionFixture { + return { + id: nextId('sub'), + userId: nextId('user'), + planId: nextId('plan'), + status: 'active', + startedAt: new Date().toISOString(), + nextBillingAt: new Date(Date.now() + 30 * 86_400_000).toISOString(), + amount: 9.99, + currency: 'USD', + ...overrides, + }; + } + + static merchant(overrides: Partial = {}): MerchantFixture { + const id = overrides.id ?? nextId('merchant'); + return { + id, + name: `Test Merchant ${id}`, + email: `${id}@merchant.example`, + active: true, + ...overrides, + }; + } + + static invoice(overrides: Partial = {}): InvoiceFixture { + return { + id: nextId('inv'), + subscriptionId: nextId('sub'), + amount: 9.99, + currency: 'USD', + status: 'pending', + dueAt: new Date().toISOString(), + paidAt: null, + ...overrides, + }; + } + + /** Build a fully related scenario: merchant → plan → user → subscription → invoice */ + static fullSubscriptionScenario(overrides: { + plan?: Partial; + user?: Partial; + subscription?: Partial; + merchant?: Partial; + invoice?: Partial; + } = {}) { + const merchant = FixtureLoader.merchant(overrides.merchant); + const plan = FixtureLoader.plan({ merchantId: merchant.id, ...overrides.plan }); + const user = FixtureLoader.user(overrides.user); + const subscription = FixtureLoader.subscription({ + userId: user.id, + planId: plan.id, + amount: plan.price, + currency: plan.currency, + ...overrides.subscription, + }); + const invoice = FixtureLoader.invoice({ + subscriptionId: subscription.id, + amount: subscription.amount, + currency: subscription.currency, + ...overrides.invoice, + }); + return { merchant, plan, user, subscription, invoice }; + } +} + +// ─── FlakyTestDetector ──────────────────────────────────────────────────────── + +export interface FlakyTestResult { + isFlaky: boolean; + passed: number; + failed: number; + errors: string[]; + durationMs: number; +} + /** - * Utility to detect flaky tests by executing a test function multiple times + * Execute a test function multiple times and report whether it is flaky. + * Flaky = passes at least once AND fails at least once across iterations. */ export async function detectFlakyTest( testFn: () => Promise | void, - iterations = 3 -): Promise<{ isFlaky: boolean; passed: number; failed: number }> { + iterations = 3, +): Promise { let passed = 0; let failed = 0; + const errors: string[] = []; + const start = Date.now(); + for (let i = 0; i < iterations; i++) { try { await testFn(); passed++; - } catch { + } catch (err) { failed++; + errors.push(err instanceof Error ? err.message : String(err)); } } + return { isFlaky: passed > 0 && failed > 0, passed, failed, + errors, + durationMs: Date.now() - start, + }; +} + +// ─── TestClock ──────────────────────────────────────────────────────────────── + +/** + * Deterministic clock for time-sensitive tests. + * Inject `clock.now()` into services instead of `Date.now()`. + */ +export class TestClock { + private _time: number; + + constructor(initialTime?: number) { + this._time = initialTime ?? Date.now(); + } + + now(): number { return this._time; } + date(): Date { return new Date(this._time); } + iso(): string { return new Date(this._time).toISOString(); } + + advance(ms: number): this { this._time += ms; return this; } + advanceHours(h: number): this { return this.advance(h * 3_600_000); } + advanceDays(d: number): this { return this.advance(d * 86_400_000); } + + set(time: number | string | Date): this { + this._time = new Date(time).getTime(); + return this; + } + + reset(time?: number): this { + this._time = time ?? Date.now(); + return this; + } +} + +// ─── TestEventBus ───────────────────────────────────────────────────────────── + +export interface CapturedEvent { + name: string; + payload: T; + timestamp: number; +} + +/** + * In-memory event capture bus. Records every published event so integration + * tests can assert what was emitted without real messaging infrastructure. + */ +export class TestEventBus { + private events: CapturedEvent[] = []; + + publish(name: string, payload: T): void { + this.events.push({ name, payload, timestamp: Date.now() }); + } + + all(): CapturedEvent[] { return [...this.events]; } + + ofType(name: string): CapturedEvent[] { + return this.events.filter((e) => e.name === name) as CapturedEvent[]; + } + + last(name: string): CapturedEvent | undefined { + const m = this.ofType(name); + return m[m.length - 1]; + } + + count(name?: string): number { + return name ? this.events.filter((e) => e.name === name).length : this.events.length; + } + + assertPublished(name: string): void { + if (this.count(name) === 0) { + throw new Error(`TestEventBus: expected event "${name}" to be published`); + } + } + + assertNotPublished(name: string): void { + const n = this.count(name); + if (n > 0) { + throw new Error(`TestEventBus: expected event "${name}" NOT to be published (was published ${n}x)`); + } + } + + clear(): void { this.events = []; } +} + +// ─── DatabaseSeeder ─────────────────────────────────────────────────────────── + +/** + * Fluent declarative seeder — inserts entities in dependency order + * (merchants → plans → users → subscriptions → invoices). + */ +export class DatabaseSeeder { + private data: SeedData = {}; + + withMerchants(rows: MerchantFixture[]): this { + this.data.merchants = [...(this.data.merchants ?? []), ...rows]; + return this; + } + withPlans(rows: PlanFixture[]): this { + this.data.plans = [...(this.data.plans ?? []), ...rows]; + return this; + } + withUsers(rows: UserFixture[]): this { + this.data.users = [...(this.data.users ?? []), ...rows]; + return this; + } + withSubscriptions(rows: SubscriptionFixture[]): this { + this.data.subscriptions = [...(this.data.subscriptions ?? []), ...rows]; + return this; + } + withInvoices(rows: InvoiceFixture[]): this { + this.data.invoices = [...(this.data.invoices ?? []), ...rows]; + return this; + } + withRaw(entity: string, rows: Record[]): this { + this.data[entity] = [...((this.data[entity] as Record[]) ?? []), ...rows]; + return this; + } + + build(): SeedData { + const order: (keyof SeedData)[] = ['merchants', 'plans', 'users', 'subscriptions', 'invoices']; + const ordered: SeedData = {}; + for (const k of order) { + if (this.data[k]?.length) ordered[k] = this.data[k]; + } + for (const [k, v] of Object.entries(this.data)) { + if (!order.includes(k as keyof SeedData) && (v as unknown[])?.length) ordered[k] = v; + } + return ordered; + } + + async seedInto(manager: TestContainerManager): Promise { + await manager.seedDatabase(this.build()); + } +} + +// ─── RedisFixture ───────────────────────────────────────────────────────────── + +export interface RedisClient { + set(key: string, value: string, expiryMode?: string, time?: number): Promise; + get(key: string): Promise; + del(...keys: string[]): Promise; + keys(pattern: string): Promise; +} + +/** + * Tracks Redis keys created during a test and batch-deletes them on cleanup. + */ +export class RedisFixture { + private trackedKeys = new Set(); + constructor(private client: RedisClient) {} + + async set(key: string, value: string, ttlSeconds?: number): Promise { + this.trackedKeys.add(key); + if (ttlSeconds != null) { + await this.client.set(key, value, 'EX', ttlSeconds); + } else { + await this.client.set(key, value); + } + } + + async get(key: string): Promise { return this.client.get(key); } + + async cleanup(): Promise { + const keys = Array.from(this.trackedKeys); + if (keys.length > 0) await this.client.del(...keys); + this.trackedKeys.clear(); + } + + async cleanupPattern(pattern: string): Promise { + const keys = await this.client.keys(pattern); + if (keys.length > 0) await this.client.del(...keys); + } + + trackedCount(): number { return this.trackedKeys.size; } +} + +// ─── Performance helpers ────────────────────────────────────────────────────── + +export interface TimingResult { + result: T; + durationMs: number; +} + +export async function withTiming(fn: () => Promise | T): Promise> { + const start = Date.now(); + const result = await fn(); + return { result, durationMs: Date.now() - start }; +} + +export function assertWithinMs(durationMs: number, limitMs: number, label = 'operation'): void { + if (durationMs > limitMs) { + throw new Error( + `Performance assertion failed: ${label} took ${durationMs}ms, limit was ${limitMs}ms`, + ); + } +} + +// ─── createTestContext ──────────────────────────────────────────────────────── + +export interface TestContext { + container: TestContainerManager; + fixtures: typeof FixtureLoader; + clock: TestClock; + events: TestEventBus; + seeder: DatabaseSeeder; +} + +/** + * Convenience factory that creates all test infrastructure in one call. + * + * @example + * const ctx = createTestContext(); + * beforeAll(() => ctx.container.startContainer()); + * afterAll(() => ctx.container.stopContainer()); + * beforeEach(() => ctx.container.createSavepoint()); + * afterEach(() => ctx.container.rollbackToSavepoint()); + */ +export function createTestContext(config: TestContainerConfig = {}): TestContext { + return { + container: new TestContainerManager(config), + fixtures: FixtureLoader, + clock: new TestClock(), + events: new TestEventBus(), + seeder: new DatabaseSeeder(), }; } diff --git a/docs/integration-tests.md b/docs/integration-tests.md index 655379fe..10348733 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -1,124 +1,205 @@ -# Integration Test Documentation +# Testing Infrastructure — Test Containers & Fixtures + +This document describes the backend testing infrastructure introduced in `backend/tests/setup/testContainer.ts`. + +--- ## Overview -This document describes the integration test suite for SubTrackr, covering component interactions across the store, notification service, wallet connection, and backend API layers. +The test container system provides a fast, hermetic test environment for backend integration tests. +It uses an **in-memory store by default**, meaning no real PostgreSQL or Redis instance is needed during unit and integration test runs. The same API surface works with real containers in CI by setting `inMemory: false`. + +--- + +## Quick Start + +```ts +import { + createTestContext, + FixtureLoader, +} from '../tests/setup/testContainer'; + +const ctx = createTestContext({ inMemory: true }); -## Test Structure +beforeAll(() => ctx.container.startContainer()); +afterAll(() => ctx.container.stopContainer()); +beforeEach(() => ctx.container.createSavepoint()); +afterEach(() => ctx.container.rollbackToSavepoint()); +it('charges an active subscription', async () => { + const { subscription } = FixtureLoader.fullSubscriptionScenario(); + await ctx.seeder.withSubscriptions([subscription]).seedInto(ctx.container); + + // ... exercise your service ... + + ctx.events.assertPublished('payment.success'); +}); ``` -app/tests/integration/ -├── factories.ts # Shared test data factories -├── contract-store.integration.test.ts # Contract ↔ store interaction -├── wallet-connection.integration.test.ts # Wallet connect/disconnect lifecycle -└── notification-delivery.integration.test.ts # Notification scheduling & delivery - -backend/tests/integration/ -└── api-endpoints.integration.test.ts # MonitoringService & AlertingService pipeline + +--- + +## Components + +### `TestContainerManager` + +Manages environment lifecycle and provides a query API over the seeded data. + +| Method | Description | +|---|---| +| `startContainer()` | Initialise store, load `initialSeed` if provided | +| `stopContainer()` | Tear down, clear all data | +| `seedDatabase(data)` | Insert rows into the in-memory store | +| `cleanDatabase(entities?)` | Delete all rows, or only named entities | +| `createSavepoint()` | Push a copy of the current state onto a stack | +| `rollbackToSavepoint()` | Pop and restore the last savepoint | +| `takeSnapshot(id)` | Named snapshot of current state | +| `restoreSnapshot(id)` | Restore a named snapshot | +| `findById(entity, id)` | Look up one row by primary key | +| `findAll(entity)` | All rows for an entity | +| `findWhere(entity, fn)` | Filtered rows | +| `upsert(entity, row)` | Insert or replace | +| `delete(entity, id)` | Remove a row | +| `count(entity)` | Row count for an entity | + +**Per-test isolation pattern (recommended):** +```ts +beforeEach(() => container.createSavepoint()); +afterEach(() => container.rollbackToSavepoint()); ``` -## Running Integration Tests +--- -```bash -# Run all tests (includes integration) -npm test +### `FixtureLoader` -# Run only integration tests -npx jest --testPathPattern="integration" +Typed factory methods with sensible defaults. -# Run with coverage -npx jest --coverage --testPathPattern="integration" +```ts +const plan = FixtureLoader.plan({ price: 29.99, interval: 'yearly' }); +const user = FixtureLoader.user({ email: 'alice@example.com' }); +const sub = FixtureLoader.subscription({ userId: user.id, planId: plan.id }); +const inv = FixtureLoader.invoice({ subscriptionId: sub.id, status: 'failed' }); + +// Or build a full linked scenario in one call: +const { merchant, plan, user, subscription, invoice } = + FixtureLoader.fullSubscriptionScenario({ + plan: { price: 99, interval: 'yearly' }, + subscription: { status: 'past_due' }, + }); ``` -## Test Suites - -### 1. Contract–Store Interaction (`contract-store.integration.test.ts`) - -Verifies that `subscriptionStore` correctly integrates with `notificationService` on every mutation. - -| Test | What it verifies | -| ---------------------------------------------- | ---------------------------------------- | -| addSubscription calls syncRenewalReminders | Notification sync fires after add | -| updateSubscription propagates updated list | Sync receives updated subscription data | -| deleteSubscription syncs after removal | Deleted sub is absent from sync payload | -| recordBillingOutcome success | Charge-success notification is presented | -| recordBillingOutcome failure | Charge-failed notification is presented | -| notificationsEnabled=false skips notifications | No notification when opted out | -| Stats after add → toggle → delete | Stats stay consistent across lifecycle | -| categoryBreakdown accuracy | Breakdown reflects multiple categories | - -### 2. Wallet Connection (`wallet-connection.integration.test.ts`) - -Verifies the `walletStore` connect/disconnect lifecycle and crypto-stream management. - -| Test | What it verifies | -| ---------------------------------------- | ----------------------------------------- | -| connectWallet persists to AsyncStorage | Wallet data written on first connect | -| connectWallet restores from AsyncStorage | Saved wallet loaded without re-writing | -| disconnect clears state and storage | State nulled, AsyncStorage key removed | -| connect → disconnect → reconnect | Full round-trip restores wallet | -| disconnect error handling | Error state set when storage throws | -| isLoading resets after operations | Loading flag always clears | -| createCryptoStream then cancel | Stream created active, cancelled inactive | - -### 3. Notification Delivery (`notification-delivery.integration.test.ts`) - -Verifies `notificationService` schedules, cancels, and presents notifications correctly. - -| Test | What it verifies | -| ------------------------------------------ | -------------------------------------------------- | -| requestNotificationPermissions | Returns GRANTED when already granted | -| presentChargeSuccessNotification | Schedules immediate notification with correct type | -| presentChargeFailedNotification | Schedules immediate notification with correct type | -| Custom detail message | Body uses provided detail string | -| presentTransactionQueueNotification | Correct title, body, and data type | -| syncRenewalReminders cancels old reminders | Existing renewal reminders cancelled first | -| Inactive subscription skipped | No schedule for inactive subs | -| notificationsEnabled=false skipped | No schedule when opted out | -| Active sub with future date scheduled | Reminder scheduled for eligible subs | -| Unsupported platform skipped | No-op on web/unsupported platforms | - -### 4. API Endpoints (`backend/tests/integration/api-endpoints.integration.test.ts`) - -Verifies `MonitoringService` and `AlertingService` end-to-end pipeline. - -| Test | What it verifies | -| ----------------------------------- | ----------------------------------------- | -| recordTransaction → dashboard | Transaction reflected in snapshot | -| Mixed outcomes success rate | Correct ratio calculated | -| Gas averaging | avgGasUsed computed correctly | -| Empty dashboard defaults | Safe zero values when no data | -| addRule fires alert on breach | Alert created when threshold exceeded | -| resolveAlert removes from active | Resolved alerts excluded | -| removeRule stops future alerts | Removed rule no longer triggers | -| recentMetrics includes failure_rate | Metrics emitted after each transaction | -| dispatch idempotency | Same alert dispatched only once | -| dispatchAll skips resolved | Resolved alerts not re-dispatched | -| createDispatcher validation | Throws when webhookUrl missing | -| Full pipeline | Transactions → metrics → alert → dispatch | - -## Test Data Factories (`factories.ts`) - -Factories provide minimal, deterministic fixtures. Use `resetIdCounter()` in `beforeEach` to ensure stable IDs across tests. - -```typescript -import { - makeSubscriptionFormData, - makeSubscription, - makeWallet, - makeCryptoStream, - resetIdCounter, -} from './factories'; - -// Override any field -const sub = makeSubscription({ price: 19.99, billingCycle: BillingCycle.YEARLY }); -const wallet = makeWallet({ address: '0xCustomAddress' }); +All factories auto-increment IDs. Call `FixtureLoader.resetCounter()` in `beforeEach` for deterministic IDs. + +--- + +### `DatabaseSeeder` + +Fluent builder that inserts data in dependency order (merchants → plans → users → subscriptions → invoices). + +```ts +await new DatabaseSeeder() + .withMerchants([merchant]) + .withPlans([plan]) + .withUsers([user]) + .withSubscriptions([sub]) + .withInvoices([inv]) + .seedInto(container); ``` -## Design Principles +--- + +### `TestClock` + +Deterministic time for services that accept a `now` callback. + +```ts +const clock = new TestClock(Date.now()); +clock.advanceDays(30); // simulate billing cycle +clock.advanceHours(2); // move forward 2 hours +clock.set('2026-01-01'); // jump to absolute date +``` + +--- + +### `TestEventBus` + +Captures published events without a real message broker. + +```ts +const bus = new TestEventBus(); +bus.publish('subscription.cancelled', { id: 's1', reason: 'non_payment' }); + +bus.assertPublished('subscription.cancelled'); +bus.assertNotPublished('subscription.renewed'); +expect(bus.count('subscription.cancelled')).toBe(1); +const last = bus.last<{ id: string }>('subscription.cancelled'); +``` + +--- + +### `detectFlakyTest` + +Run a test function multiple times and detect non-determinism. + +```ts +const result = await detectFlakyTest(async () => { + // your test body +}, 5 /* iterations */); + +console.log(result.isFlaky); // true if passed AND failed across runs +console.log(result.passed); // number of passing runs +console.log(result.failed); // number of failing runs +console.log(result.errors); // error messages from failing runs +``` + +--- + +### `RedisFixture` + +Tracks Redis keys created during a test for automatic cleanup. + +```ts +const fixture = new RedisFixture(redisClient); +await fixture.set('cache:sub:s1', JSON.stringify(data), 60); +// ... test ... +await fixture.cleanup(); // deletes all tracked keys +``` + +--- + +### Performance helpers + +```ts +const { result, durationMs } = await withTiming(() => myService.process()); +assertWithinMs(durationMs, 200, 'process()'); // throws if > 200ms +``` + +--- + +## Running tests + +```bash +# All backend tests (unit + integration) +npx jest --config jest.backend.config.js + +# Only the test-container infrastructure tests +npx jest --config jest.backend.config.js backend/tests/integration/testContainer.test.ts + +# With coverage +npx jest --config jest.backend.config.js --coverage +``` + +--- + +## Performance benchmarks + +The in-memory store is intentionally lightweight: + +| Operation | Target | +|---|---| +| `startContainer()` | < 5ms | +| `seedDatabase()` (100 rows) | < 2ms | +| `findWhere()` (1000 rows) | < 1ms | +| `createSavepoint()` / `rollbackToSavepoint()` | < 5ms | +| Full test context setup | < 10ms | -- **No disk I/O**: AsyncStorage is replaced with an in-memory map. -- **No real timers**: `jest.useFakeTimers()` controls async delays. -- **No network calls**: All external services are mocked at the module boundary. -- **Minimal fixtures**: Factories produce only the fields needed; tests override what they care about. -- **Isolated state**: Each test resets store state in `beforeEach` to prevent cross-test pollution. +These are verified by the `withTiming` + `assertWithinMs` helpers in the test suite. diff --git a/jest.backend.config.js b/jest.backend.config.js index 2dbf9e69..ff1a94fd 100644 --- a/jest.backend.config.js +++ b/jest.backend.config.js @@ -9,6 +9,8 @@ module.exports = { testMatch: [ '**/backend/**/__tests__/**/*.test.ts', '**/backend/tests/**/*.test.ts', + '**/backend/billing/tests/**/*.test.ts', + '**/backend/billing/tests/**/*.spec.ts', '**/developer-portal/__tests__/**/*.test.ts', ], transform: {