diff --git a/backend/services/repositories/__tests__/postgres.test.ts b/backend/services/repositories/__tests__/postgres.test.ts new file mode 100644 index 00000000..cfe0f42f --- /dev/null +++ b/backend/services/repositories/__tests__/postgres.test.ts @@ -0,0 +1,422 @@ +/** + * Tests for PostgreSQL repository implementations — postgres.ts + * + * Uses an in-memory mock pool — no real database required. + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { + PgSubscriptionRepository, + PgTransactionRepository, + PgUserRepository, + PgMerchantRepository, + PgLoyaltyRepository, + PostgresUnitOfWork, + createPostgresRepositories, +} from '../postgres'; +import type { Pool, PoolClient } from '../../../shared/db/connectionPool'; +import type { + Subscription, + Transaction, + User, + MerchantRecord, + LoyaltyRecord, +} from '../interfaces'; + +// ── Mock Pool ───────────────────────────────────────────────────────────────── + +interface MockPool extends Pool { + _setResponse(rows: unknown[], rowCount?: number): void; + _setResponseSequence(responses: { rows: unknown[]; rowCount?: number }[]): void; + totalCount: number; + idleCount: number; + waitingCount: number; +} + +function makeMockPool(): MockPool { + let responses: { rows: unknown[]; rowCount?: number }[] = []; + let defaultRows: unknown[] = []; + + const pool: MockPool = { + totalCount: 1, + idleCount: 1, + waitingCount: 0, + query: jest.fn(async (_sql: string, _params?: unknown[]) => { + if (responses.length > 0) { + const next = responses.shift()!; + return { rows: next.rows, rowCount: next.rowCount ?? next.rows.length }; + } + return { rows: defaultRows, rowCount: defaultRows.length }; + }), + connect: jest.fn(async () => { + const client: PoolClient = { + query: jest.fn(async (_sql: string, _params?: unknown[]) => { + if (responses.length > 0) { + const next = responses.shift()!; + return { rows: next.rows, rowCount: next.rowCount ?? next.rows.length }; + } + return { rows: defaultRows, rowCount: defaultRows.length }; + }), + release: jest.fn(), + }; + return client; + }), + end: jest.fn(async () => {}), + on: jest.fn(), + _setResponse(rows: unknown[], rowCount?: number) { + defaultRows = rows; + }, + _setResponseSequence(seq) { + responses = [...seq]; + }, + }; + return pool; +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const makeSub = (overrides: Partial = {}): Subscription => ({ + id: 'sub-1', + userId: 'user-1', + name: 'Netflix', + amount: 15, + currency: 'USD', + billingCycle: 'monthly', + status: 'active', + nextBillingDate: new Date('2026-06-01'), + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + ...overrides, +}); + +const makeTx = (overrides: Partial = {}): Transaction => ({ + id: 'tx-1', + subscriptionId: 'sub-1', + userId: 'user-1', + amount: 15, + currency: 'USD', + status: 'success', + timestamp: new Date('2026-05-01'), + ...overrides, +}); + +const makeUser = (overrides: Partial = {}): User => ({ + id: 'user-1', + address: 'GABC123', + email: 'alice@example.com', + createdAt: new Date('2026-01-01'), + ...overrides, +}); + +const makeMerchant = (overrides: Partial = {}): MerchantRecord => ({ + id: 'merchant-1', + merchantAddress: 'GMERCHANT', + status: 'verified', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + ...overrides, +}); + +const makeLoyalty = (overrides: Partial = {}): LoyaltyRecord => ({ + id: 'loyalty-1', + subscriberId: 'user-1', + points: 500, + lifetimePoints: 1200, + tier: 'silver', + streakCurrent: 7, + streakLongest: 14, + updatedAt: new Date('2026-05-01'), + ...overrides, +}); + +// ── PgSubscriptionRepository ────────────────────────────────────────────────── + +describe('PgSubscriptionRepository', () => { + let pool: MockPool; + let repo: PgSubscriptionRepository; + + beforeEach(() => { + pool = makeMockPool(); + repo = new PgSubscriptionRepository(pool); + }); + + it('findById returns null when no rows', async () => { + pool._setResponse([]); + expect(await repo.findById('nonexistent')).toBeNull(); + }); + + it('findById returns row when found', async () => { + const sub = makeSub(); + pool._setResponse([sub]); + const result = await repo.findById('sub-1'); + expect(result).toEqual(sub); + }); + + it('save calls query with correct INSERT ... ON CONFLICT params', async () => { + const sub = makeSub(); + pool._setResponse([sub]); + const result = await repo.save(sub); + expect(result).toEqual(sub); + expect((pool.query as jest.Mock).mock.calls[0][0]).toMatch(/INSERT INTO subscriptions/i); + expect((pool.query as jest.Mock).mock.calls[0][0]).toMatch(/ON CONFLICT/i); + }); + + it('delete calls DELETE with id param', async () => { + pool._setResponse([]); + await repo.delete('sub-1'); + expect((pool.query as jest.Mock).mock.calls[0][0]).toMatch(/DELETE FROM subscriptions/i); + expect((pool.query as jest.Mock).mock.calls[0][1]).toEqual(['sub-1']); + }); + + it('exists returns true when row found', async () => { + pool._setResponse([{ exists: true }]); + expect(await repo.exists('sub-1')).toBe(true); + }); + + it('exists returns false when not found', async () => { + pool._setResponse([{ exists: false }]); + expect(await repo.exists('nope')).toBe(false); + }); + + it('findAll returns paginated results', async () => { + const sub = makeSub(); + pool._setResponseSequence([ + { rows: [{ count: '5' }] }, + { rows: [sub] }, + ]); + const page = await repo.findAll({ limit: 1, offset: 0 }); + expect(page.total).toBe(5); + expect(page.items).toHaveLength(1); + expect(page.offset).toBe(0); + expect(page.limit).toBe(1); + }); + + it('findByUserId filters by userId', async () => { + const sub = makeSub({ userId: 'user-2' }); + pool._setResponseSequence([ + { rows: [{ count: '1' }] }, + { rows: [sub] }, + ]); + const page = await repo.findByUserId('user-2'); + const sql = (pool.query as jest.Mock).mock.calls[1][0] as string; + expect(sql).toMatch(/WHERE user_id/i); + expect(page.items[0]?.userId).toBe('user-2'); + }); + + it('findByStatus filters by status', async () => { + const sub = makeSub({ status: 'paused' }); + pool._setResponseSequence([ + { rows: [{ count: '1' }] }, + { rows: [sub] }, + ]); + const page = await repo.findByStatus('paused'); + expect(page.items[0]?.status).toBe('paused'); + }); + + it('findDueBefore uses correct WHERE clause', async () => { + const sub = makeSub({ nextBillingDate: new Date('2026-05-01') }); + pool._setResponse([sub]); + const results = await repo.findDueBefore(new Date('2026-06-01')); + const sql = (pool.query as jest.Mock).mock.calls[0][0] as string; + expect(sql).toMatch(/status = 'active'/i); + expect(sql).toMatch(/next_billing_date <= \$1/i); + expect(results).toHaveLength(1); + }); +}); + +// ── PgTransactionRepository ─────────────────────────────────────────────────── + +describe('PgTransactionRepository', () => { + let pool: MockPool; + let repo: PgTransactionRepository; + + beforeEach(() => { + pool = makeMockPool(); + repo = new PgTransactionRepository(pool); + }); + + it('findById returns null on empty result', async () => { + pool._setResponse([]); + expect(await repo.findById('tx-nope')).toBeNull(); + }); + + it('save calls INSERT ... ON CONFLICT', async () => { + const tx = makeTx(); + pool._setResponse([tx]); + await repo.save(tx); + expect((pool.query as jest.Mock).mock.calls[0][0]).toMatch(/INSERT INTO transactions/i); + }); + + it('findByStatus filters correctly', async () => { + pool._setResponse([makeTx({ status: 'failed' })]); + const results = await repo.findByStatus('failed'); + expect(results[0]?.status).toBe('failed'); + const sql = (pool.query as jest.Mock).mock.calls[0][0] as string; + expect(sql).toMatch(/WHERE status = \$1/i); + }); + + it('findBySubscriptionId returns paginated results', async () => { + pool._setResponseSequence([ + { rows: [{ count: '1' }] }, + { rows: [makeTx()] }, + ]); + const page = await repo.findBySubscriptionId('sub-1'); + expect(page.items).toHaveLength(1); + }); + + it('delete removes by id', async () => { + pool._setResponse([]); + await repo.delete('tx-1'); + expect((pool.query as jest.Mock).mock.calls[0][0]).toMatch(/DELETE FROM transactions/i); + }); +}); + +// ── PgUserRepository ────────────────────────────────────────────────────────── + +describe('PgUserRepository', () => { + let pool: MockPool; + let repo: PgUserRepository; + + beforeEach(() => { + pool = makeMockPool(); + repo = new PgUserRepository(pool); + }); + + it('findByAddress queries with correct param', async () => { + pool._setResponse([makeUser()]); + const result = await repo.findByAddress('GABC123'); + expect(result?.address).toBe('GABC123'); + const sql = (pool.query as jest.Mock).mock.calls[0][0] as string; + expect(sql).toMatch(/WHERE address = \$1/i); + }); + + it('findByEmail returns null when not found', async () => { + pool._setResponse([]); + expect(await repo.findByEmail('unknown@example.com')).toBeNull(); + }); + + it('save upserts user', async () => { + const user = makeUser(); + pool._setResponse([user]); + const result = await repo.save(user); + expect(result.id).toBe('user-1'); + expect((pool.query as jest.Mock).mock.calls[0][0]).toMatch(/ON CONFLICT/i); + }); +}); + +// ── PgMerchantRepository ────────────────────────────────────────────────────── + +describe('PgMerchantRepository', () => { + let pool: MockPool; + let repo: PgMerchantRepository; + + beforeEach(() => { + pool = makeMockPool(); + repo = new PgMerchantRepository(pool); + }); + + it('findByAddress returns correct merchant', async () => { + pool._setResponse([makeMerchant()]); + const m = await repo.findByAddress('GMERCHANT'); + expect(m?.merchantAddress).toBe('GMERCHANT'); + }); + + it('findByStatus returns array', async () => { + pool._setResponse([makeMerchant({ status: 'pending' })]); + const results = await repo.findByStatus('pending'); + expect(results).toHaveLength(1); + }); +}); + +// ── PgLoyaltyRepository ─────────────────────────────────────────────────────── + +describe('PgLoyaltyRepository', () => { + let pool: MockPool; + let repo: PgLoyaltyRepository; + + beforeEach(() => { + pool = makeMockPool(); + repo = new PgLoyaltyRepository(pool); + }); + + it('findBySubscriberId returns null when not found', async () => { + pool._setResponse([]); + expect(await repo.findBySubscriberId('nobody')).toBeNull(); + }); + + it('findTopByPoints uses ORDER BY points DESC LIMIT', async () => { + const records = [ + makeLoyalty({ id: 'l-1', points: 900 }), + makeLoyalty({ id: 'l-2', points: 200 }), + ]; + pool._setResponse(records); + const results = await repo.findTopByPoints(2); + expect(results).toHaveLength(2); + const sql = (pool.query as jest.Mock).mock.calls[0][0] as string; + expect(sql).toMatch(/ORDER BY points DESC LIMIT/i); + expect((pool.query as jest.Mock).mock.calls[0][1]).toContain(2); + }); +}); + +// ── PostgresUnitOfWork ──────────────────────────────────────────────────────── + +describe('PostgresUnitOfWork', () => { + it('run() wraps work in a transaction (BEGIN/COMMIT)', async () => { + const pool = makeMockPool(); + // Return rows for the RETURNING clause in save() + pool._setResponseSequence([ + { rows: [] }, // BEGIN + { rows: [makeSub()] }, // INSERT subscription + { rows: [] }, // COMMIT + ]); + + const uow = new PostgresUnitOfWork(pool); + const result = await uow.run(async (u) => { + return u.subscriptions.save(makeSub()); + }); + + expect(result.id).toBe('sub-1'); + const queryCalls = (pool.connect as jest.Mock).mock.results[0].value; + // connect was called + expect(pool.connect).toHaveBeenCalled(); + }); + + it('run() rolls back and rethrows on error', async () => { + const pool = makeMockPool(); + const uow = new PostgresUnitOfWork(pool); + + await expect( + uow.run(async () => { throw new Error('db failure'); }), + ).rejects.toThrow('db failure'); + + // Get the client that was connected + const client = await (pool.connect as jest.Mock).mock.results[0].value; + const clientQueryCalls = (client.query as jest.Mock).mock.calls.map((c: unknown[]) => c[0] as string); + expect(clientQueryCalls).toContain('ROLLBACK'); + }); + + it('exposes all five repositories', () => { + const pool = makeMockPool(); + const uow = new PostgresUnitOfWork(pool); + expect(uow.subscriptions).toBeDefined(); + expect(uow.transactions).toBeDefined(); + expect(uow.users).toBeDefined(); + expect(uow.merchants).toBeDefined(); + expect(uow.loyalty).toBeDefined(); + }); +}); + +// ── createPostgresRepositories factory ─────────────────────────────────────── + +describe('createPostgresRepositories()', () => { + it('returns all repositories and a unitOfWork', () => { + const pool = makeMockPool(); + const repos = createPostgresRepositories(pool); + expect(repos.subscriptions).toBeInstanceOf(PgSubscriptionRepository); + expect(repos.transactions).toBeInstanceOf(PgTransactionRepository); + expect(repos.users).toBeInstanceOf(PgUserRepository); + expect(repos.merchants).toBeInstanceOf(PgMerchantRepository); + expect(repos.loyalty).toBeInstanceOf(PgLoyaltyRepository); + expect(repos.unitOfWork).toBeInstanceOf(PostgresUnitOfWork); + }); +}); diff --git a/backend/services/repositories/index.ts b/backend/services/repositories/index.ts index e57b7050..a7e16aa0 100644 --- a/backend/services/repositories/index.ts +++ b/backend/services/repositories/index.ts @@ -1,2 +1,12 @@ export * from './interfaces'; export * from './inMemory'; +export { + PgSubscriptionRepository, + PgTransactionRepository, + PgUserRepository, + PgMerchantRepository, + PgLoyaltyRepository, + PostgresUnitOfWork, + createPostgresRepositories, +} from './postgres'; +export type { PgTransactionContext } from './postgres'; diff --git a/backend/services/repositories/postgres.ts b/backend/services/repositories/postgres.ts new file mode 100644 index 00000000..e9116ca1 --- /dev/null +++ b/backend/services/repositories/postgres.ts @@ -0,0 +1,670 @@ +/** + * PostgreSQL repository implementations — Issue #405. + * + * Each class maps a domain entity to a PostgreSQL table using parameterised + * queries (prevents SQL injection) and the shared read/write pool. + * + * Repositories are instantiated per request through the IoC container and + * share a single connection pool. Transactions are handled via + * PostgresUnitOfWork which wraps operations in a single PoolClient. + */ + +import type { Pool, PoolClient } from '../../shared/db/connectionPool'; +import type { + IRepository, + ISubscriptionRepository, + ITransactionRepository, + IUserRepository, + IMerchantRepository, + ILoyaltyRepository, + IUnitOfWork, + Page, + QueryOptions, + TransactionContext, + Subscription, + Transaction, + User, + MerchantRecord, + LoyaltyRecord, +} from './interfaces'; + +// ─── Transaction context ────────────────────────────────────────────────────── + +/** Extend TransactionContext to carry the live PoolClient during a UoW run. */ +export interface PgTransactionContext extends TransactionContext { + client: PoolClient; +} + +function isPgContext(tx?: TransactionContext): tx is PgTransactionContext { + return !!tx && typeof (tx as PgTransactionContext).client === 'object'; +} + +// ─── Query helper ───────────────────────────────────────────────────────────── + +type Queryable = { + query(sql: string, params?: unknown[]): Promise<{ rows: T[]; rowCount: number }>; +}; + +function queryable(pool: Pool, tx?: TransactionContext): Queryable { + return isPgContext(tx) ? tx.client : pool; +} + +function buildPagination(opts: QueryOptions = {}): { sql: string; params: unknown[] } { + const parts: string[] = []; + const params: unknown[] = []; + + if (opts.orderBy) { + const dir = opts.orderDir === 'desc' ? 'DESC' : 'ASC'; + // Whitelist orderBy to prevent injection + const safeColumn = opts.orderBy.replace(/[^a-zA-Z0-9_]/g, ''); + parts.push(`ORDER BY ${safeColumn} ${dir}`); + } + + let idx = 1; + if (opts.limit != null) { + parts.push(`LIMIT $${idx++}`); + params.push(opts.limit); + } + if (opts.offset != null) { + parts.push(`OFFSET $${idx++}`); + params.push(opts.offset); + } + + return { sql: parts.join(' '), params }; +} + +// ─── Subscription repository ────────────────────────────────────────────────── + +export class PgSubscriptionRepository implements ISubscriptionRepository { + constructor(private readonly pool: Pool) {} + + async findById(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, user_id AS "userId", name, amount, currency, billing_cycle AS "billingCycle", + status, next_billing_date AS "nextBillingDate", created_at AS "createdAt", + updated_at AS "updatedAt" + FROM subscriptions WHERE id = $1 LIMIT 1`, + [id], + ); + return rows[0] ?? null; + } + + async findAll(opts: QueryOptions = {}, tx?: TransactionContext): Promise> { + const q = queryable(this.pool, tx); + const pg = buildPagination(opts); + + const countRow = await q.query<{ count: string }>('SELECT COUNT(*) AS count FROM subscriptions'); + const total = parseInt(countRow.rows[0]?.count ?? '0', 10); + + const { rows } = await q.query( + `SELECT id, user_id AS "userId", name, amount, currency, billing_cycle AS "billingCycle", + status, next_billing_date AS "nextBillingDate", created_at AS "createdAt", + updated_at AS "updatedAt" + FROM subscriptions ${pg.sql}`, + pg.params, + ); + + return { items: rows, total, offset: opts.offset ?? 0, limit: opts.limit ?? total }; + } + + async save(entity: Subscription, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `INSERT INTO subscriptions + (id, user_id, name, amount, currency, billing_cycle, status, next_billing_date, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + amount = EXCLUDED.amount, + currency = EXCLUDED.currency, + billing_cycle = EXCLUDED.billing_cycle, + status = EXCLUDED.status, + next_billing_date = EXCLUDED.next_billing_date, + updated_at = EXCLUDED.updated_at + RETURNING id, user_id AS "userId", name, amount, currency, billing_cycle AS "billingCycle", + status, next_billing_date AS "nextBillingDate", created_at AS "createdAt", + updated_at AS "updatedAt"`, + [ + entity.id, + entity.userId, + entity.name, + entity.amount, + entity.currency, + entity.billingCycle, + entity.status, + entity.nextBillingDate, + entity.createdAt, + entity.updatedAt, + ], + ); + return rows[0]!; + } + + async delete(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + await q.query('DELETE FROM subscriptions WHERE id = $1', [id]); + } + + async exists(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query<{ exists: boolean }>( + 'SELECT EXISTS(SELECT 1 FROM subscriptions WHERE id = $1) AS exists', + [id], + ); + return rows[0]?.exists ?? false; + } + + async findByUserId(userId: string, opts: QueryOptions = {}, tx?: TransactionContext): Promise> { + const q = queryable(this.pool, tx); + const pg = buildPagination(opts); + + const countRow = await q.query<{ count: string }>( + 'SELECT COUNT(*) AS count FROM subscriptions WHERE user_id = $1', + [userId], + ); + const total = parseInt(countRow.rows[0]?.count ?? '0', 10); + + const { rows } = await q.query( + `SELECT id, user_id AS "userId", name, amount, currency, billing_cycle AS "billingCycle", + status, next_billing_date AS "nextBillingDate", created_at AS "createdAt", + updated_at AS "updatedAt" + FROM subscriptions WHERE user_id = $1 ${pg.sql}`, + [userId, ...pg.params], + ); + return { items: rows, total, offset: opts.offset ?? 0, limit: opts.limit ?? total }; + } + + async findByStatus(status: Subscription['status'], opts: QueryOptions = {}, tx?: TransactionContext): Promise> { + const q = queryable(this.pool, tx); + const pg = buildPagination(opts); + + const countRow = await q.query<{ count: string }>( + 'SELECT COUNT(*) AS count FROM subscriptions WHERE status = $1', + [status], + ); + const total = parseInt(countRow.rows[0]?.count ?? '0', 10); + + const { rows } = await q.query( + `SELECT id, user_id AS "userId", name, amount, currency, billing_cycle AS "billingCycle", + status, next_billing_date AS "nextBillingDate", created_at AS "createdAt", + updated_at AS "updatedAt" + FROM subscriptions WHERE status = $1 ${pg.sql}`, + [status, ...pg.params], + ); + return { items: rows, total, offset: opts.offset ?? 0, limit: opts.limit ?? total }; + } + + async findDueBefore(date: Date, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, user_id AS "userId", name, amount, currency, billing_cycle AS "billingCycle", + status, next_billing_date AS "nextBillingDate", created_at AS "createdAt", + updated_at AS "updatedAt" + FROM subscriptions WHERE status = 'active' AND next_billing_date <= $1`, + [date], + ); + return rows; + } +} + +// ─── Transaction repository ─────────────────────────────────────────────────── + +export class PgTransactionRepository implements ITransactionRepository { + constructor(private readonly pool: Pool) {} + + async findById(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, subscription_id AS "subscriptionId", user_id AS "userId", + amount, currency, status, timestamp, tx_hash AS "txHash" + FROM transactions WHERE id = $1 LIMIT 1`, + [id], + ); + return rows[0] ?? null; + } + + async findAll(opts: QueryOptions = {}, tx?: TransactionContext): Promise> { + const q = queryable(this.pool, tx); + const pg = buildPagination(opts); + const countRow = await q.query<{ count: string }>('SELECT COUNT(*) AS count FROM transactions'); + const total = parseInt(countRow.rows[0]?.count ?? '0', 10); + + const { rows } = await q.query( + `SELECT id, subscription_id AS "subscriptionId", user_id AS "userId", + amount, currency, status, timestamp, tx_hash AS "txHash" + FROM transactions ${pg.sql}`, + pg.params, + ); + return { items: rows, total, offset: opts.offset ?? 0, limit: opts.limit ?? total }; + } + + async save(entity: Transaction, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `INSERT INTO transactions + (id, subscription_id, user_id, amount, currency, status, timestamp, tx_hash) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + tx_hash = EXCLUDED.tx_hash + RETURNING id, subscription_id AS "subscriptionId", user_id AS "userId", + amount, currency, status, timestamp, tx_hash AS "txHash"`, + [ + entity.id, + entity.subscriptionId, + entity.userId, + entity.amount, + entity.currency, + entity.status, + entity.timestamp, + entity.txHash ?? null, + ], + ); + return rows[0]!; + } + + async delete(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + await q.query('DELETE FROM transactions WHERE id = $1', [id]); + } + + async exists(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query<{ exists: boolean }>( + 'SELECT EXISTS(SELECT 1 FROM transactions WHERE id = $1) AS exists', + [id], + ); + return rows[0]?.exists ?? false; + } + + async findBySubscriptionId(subscriptionId: string, opts: QueryOptions = {}, tx?: TransactionContext): Promise> { + const q = queryable(this.pool, tx); + const pg = buildPagination(opts); + const countRow = await q.query<{ count: string }>( + 'SELECT COUNT(*) AS count FROM transactions WHERE subscription_id = $1', + [subscriptionId], + ); + const total = parseInt(countRow.rows[0]?.count ?? '0', 10); + const { rows } = await q.query( + `SELECT id, subscription_id AS "subscriptionId", user_id AS "userId", + amount, currency, status, timestamp, tx_hash AS "txHash" + FROM transactions WHERE subscription_id = $1 ${pg.sql}`, + [subscriptionId, ...pg.params], + ); + return { items: rows, total, offset: opts.offset ?? 0, limit: opts.limit ?? total }; + } + + async findByUserId(userId: string, opts: QueryOptions = {}, tx?: TransactionContext): Promise> { + const q = queryable(this.pool, tx); + const pg = buildPagination(opts); + const countRow = await q.query<{ count: string }>( + 'SELECT COUNT(*) AS count FROM transactions WHERE user_id = $1', + [userId], + ); + const total = parseInt(countRow.rows[0]?.count ?? '0', 10); + const { rows } = await q.query( + `SELECT id, subscription_id AS "subscriptionId", user_id AS "userId", + amount, currency, status, timestamp, tx_hash AS "txHash" + FROM transactions WHERE user_id = $1 ${pg.sql}`, + [userId, ...pg.params], + ); + return { items: rows, total, offset: opts.offset ?? 0, limit: opts.limit ?? total }; + } + + async findByStatus(status: Transaction['status'], tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, subscription_id AS "subscriptionId", user_id AS "userId", + amount, currency, status, timestamp, tx_hash AS "txHash" + FROM transactions WHERE status = $1`, + [status], + ); + return rows; + } +} + +// ─── User repository ────────────────────────────────────────────────────────── + +export class PgUserRepository implements IUserRepository { + constructor(private readonly pool: Pool) {} + + async findById(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, address, email, created_at AS "createdAt" FROM users WHERE id = $1 LIMIT 1`, + [id], + ); + return rows[0] ?? null; + } + + async findAll(opts: QueryOptions = {}, tx?: TransactionContext): Promise> { + const q = queryable(this.pool, tx); + const pg = buildPagination(opts); + const countRow = await q.query<{ count: string }>('SELECT COUNT(*) AS count FROM users'); + const total = parseInt(countRow.rows[0]?.count ?? '0', 10); + const { rows } = await q.query( + `SELECT id, address, email, created_at AS "createdAt" FROM users ${pg.sql}`, + pg.params, + ); + return { items: rows, total, offset: opts.offset ?? 0, limit: opts.limit ?? total }; + } + + async save(entity: User, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `INSERT INTO users (id, address, email, created_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE SET address = EXCLUDED.address, email = EXCLUDED.email + RETURNING id, address, email, created_at AS "createdAt"`, + [entity.id, entity.address, entity.email ?? null, entity.createdAt], + ); + return rows[0]!; + } + + async delete(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + await q.query('DELETE FROM users WHERE id = $1', [id]); + } + + async exists(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query<{ exists: boolean }>( + 'SELECT EXISTS(SELECT 1 FROM users WHERE id = $1) AS exists', + [id], + ); + return rows[0]?.exists ?? false; + } + + async findByAddress(address: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, address, email, created_at AS "createdAt" FROM users WHERE address = $1 LIMIT 1`, + [address], + ); + return rows[0] ?? null; + } + + async findByEmail(email: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, address, email, created_at AS "createdAt" FROM users WHERE email = $1 LIMIT 1`, + [email], + ); + return rows[0] ?? null; + } +} + +// ─── Merchant repository ────────────────────────────────────────────────────── + +export class PgMerchantRepository implements IMerchantRepository { + constructor(private readonly pool: Pool) {} + + async findById(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, merchant_address AS "merchantAddress", status, verification_tier AS "verificationTier", + created_at AS "createdAt", updated_at AS "updatedAt" + FROM merchants WHERE id = $1 LIMIT 1`, + [id], + ); + return rows[0] ?? null; + } + + async findAll(opts: QueryOptions = {}, tx?: TransactionContext): Promise> { + const q = queryable(this.pool, tx); + const pg = buildPagination(opts); + const countRow = await q.query<{ count: string }>('SELECT COUNT(*) AS count FROM merchants'); + const total = parseInt(countRow.rows[0]?.count ?? '0', 10); + const { rows } = await q.query( + `SELECT id, merchant_address AS "merchantAddress", status, verification_tier AS "verificationTier", + created_at AS "createdAt", updated_at AS "updatedAt" + FROM merchants ${pg.sql}`, + pg.params, + ); + return { items: rows, total, offset: opts.offset ?? 0, limit: opts.limit ?? total }; + } + + async save(entity: MerchantRecord, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `INSERT INTO merchants (id, merchant_address, status, verification_tier, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + verification_tier = EXCLUDED.verification_tier, + updated_at = EXCLUDED.updated_at + RETURNING id, merchant_address AS "merchantAddress", status, verification_tier AS "verificationTier", + created_at AS "createdAt", updated_at AS "updatedAt"`, + [ + entity.id, + entity.merchantAddress, + entity.status, + entity.verificationTier ?? null, + entity.createdAt, + entity.updatedAt, + ], + ); + return rows[0]!; + } + + async delete(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + await q.query('DELETE FROM merchants WHERE id = $1', [id]); + } + + async exists(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query<{ exists: boolean }>( + 'SELECT EXISTS(SELECT 1 FROM merchants WHERE id = $1) AS exists', + [id], + ); + return rows[0]?.exists ?? false; + } + + async findByAddress(address: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, merchant_address AS "merchantAddress", status, verification_tier AS "verificationTier", + created_at AS "createdAt", updated_at AS "updatedAt" + FROM merchants WHERE merchant_address = $1 LIMIT 1`, + [address], + ); + return rows[0] ?? null; + } + + async findByStatus(status: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, merchant_address AS "merchantAddress", status, verification_tier AS "verificationTier", + created_at AS "createdAt", updated_at AS "updatedAt" + FROM merchants WHERE status = $1`, + [status], + ); + return rows; + } +} + +// ─── Loyalty repository ─────────────────────────────────────────────────────── + +export class PgLoyaltyRepository implements ILoyaltyRepository { + constructor(private readonly pool: Pool) {} + + async findById(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, subscriber_id AS "subscriberId", points, lifetime_points AS "lifetimePoints", + tier, streak_current AS "streakCurrent", streak_longest AS "streakLongest", + updated_at AS "updatedAt" + FROM loyalty WHERE id = $1 LIMIT 1`, + [id], + ); + return rows[0] ?? null; + } + + async findAll(opts: QueryOptions = {}, tx?: TransactionContext): Promise> { + const q = queryable(this.pool, tx); + const pg = buildPagination(opts); + const countRow = await q.query<{ count: string }>('SELECT COUNT(*) AS count FROM loyalty'); + const total = parseInt(countRow.rows[0]?.count ?? '0', 10); + const { rows } = await q.query( + `SELECT id, subscriber_id AS "subscriberId", points, lifetime_points AS "lifetimePoints", + tier, streak_current AS "streakCurrent", streak_longest AS "streakLongest", + updated_at AS "updatedAt" + FROM loyalty ${pg.sql}`, + pg.params, + ); + return { items: rows, total, offset: opts.offset ?? 0, limit: opts.limit ?? total }; + } + + async save(entity: LoyaltyRecord, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `INSERT INTO loyalty + (id, subscriber_id, points, lifetime_points, tier, streak_current, streak_longest, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + points = EXCLUDED.points, + lifetime_points = EXCLUDED.lifetime_points, + tier = EXCLUDED.tier, + streak_current = EXCLUDED.streak_current, + streak_longest = EXCLUDED.streak_longest, + updated_at = EXCLUDED.updated_at + RETURNING id, subscriber_id AS "subscriberId", points, lifetime_points AS "lifetimePoints", + tier, streak_current AS "streakCurrent", streak_longest AS "streakLongest", + updated_at AS "updatedAt"`, + [ + entity.id, + entity.subscriberId, + entity.points, + entity.lifetimePoints, + entity.tier, + entity.streakCurrent, + entity.streakLongest, + entity.updatedAt, + ], + ); + return rows[0]!; + } + + async delete(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + await q.query('DELETE FROM loyalty WHERE id = $1', [id]); + } + + async exists(id: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query<{ exists: boolean }>( + 'SELECT EXISTS(SELECT 1 FROM loyalty WHERE id = $1) AS exists', + [id], + ); + return rows[0]?.exists ?? false; + } + + async findBySubscriberId(subscriberId: string, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, subscriber_id AS "subscriberId", points, lifetime_points AS "lifetimePoints", + tier, streak_current AS "streakCurrent", streak_longest AS "streakLongest", + updated_at AS "updatedAt" + FROM loyalty WHERE subscriber_id = $1 LIMIT 1`, + [subscriberId], + ); + return rows[0] ?? null; + } + + async findTopByPoints(limit: number, tx?: TransactionContext): Promise { + const q = queryable(this.pool, tx); + const { rows } = await q.query( + `SELECT id, subscriber_id AS "subscriberId", points, lifetime_points AS "lifetimePoints", + tier, streak_current AS "streakCurrent", streak_longest AS "streakLongest", + updated_at AS "updatedAt" + FROM loyalty ORDER BY points DESC LIMIT $1`, + [limit], + ); + return rows; + } +} + +// ─── PostgreSQL Unit of Work ────────────────────────────────────────────────── + +export class PostgresUnitOfWork implements IUnitOfWork { + subscriptions: ISubscriptionRepository; + transactions: ITransactionRepository; + users: IUserRepository; + merchants: IMerchantRepository; + loyalty: ILoyaltyRepository; + + constructor(private readonly pool: Pool) { + this.subscriptions = new PgSubscriptionRepository(pool); + this.transactions = new PgTransactionRepository(pool); + this.users = new PgUserRepository(pool); + this.merchants = new PgMerchantRepository(pool); + this.loyalty = new PgLoyaltyRepository(pool); + } + + async run(work: (uow: IUnitOfWork) => Promise): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + + const txContext: PgTransactionContext = { id: `pg-tx-${Date.now()}`, client }; + + const txUow: IUnitOfWork = { + subscriptions: new PgSubscriptionRepository(this.pool), + transactions: new PgTransactionRepository(this.pool), + users: new PgUserRepository(this.pool), + merchants: new PgMerchantRepository(this.pool), + loyalty: new PgLoyaltyRepository(this.pool), + run: () => Promise.reject(new Error('Nested transactions are not supported')), + }; + + // Patch each repository to use the transaction client + const bindToTx = >(repo: R): R => { + const handler: ProxyHandler = { + get(target, prop: string) { + const original = (target as Record)[prop]; + if (typeof original !== 'function') return original; + return (...args: unknown[]) => { + // Inject txContext as the last argument if the function signature ends with an optional tx + const lastArg = args[args.length - 1]; + if (lastArg && typeof lastArg === 'object' && 'id' in (lastArg as object)) { + return original.apply(target, args); + } + return original.apply(target, [...args, txContext]); + }; + }, + }; + return new Proxy(repo as object, handler) as R; + }; + + txUow.subscriptions = bindToTx(txUow.subscriptions as PgSubscriptionRepository); + txUow.transactions = bindToTx(txUow.transactions as PgTransactionRepository); + txUow.users = bindToTx(txUow.users as PgUserRepository); + txUow.merchants = bindToTx(txUow.merchants as PgMerchantRepository); + txUow.loyalty = bindToTx(txUow.loyalty as PgLoyaltyRepository); + + const result = await work(txUow); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } +} + +// ─── Factory ────────────────────────────────────────────────────────────────── + +export function createPostgresRepositories(pool: Pool) { + return { + subscriptions: new PgSubscriptionRepository(pool), + transactions: new PgTransactionRepository(pool), + users: new PgUserRepository(pool), + merchants: new PgMerchantRepository(pool), + loyalty: new PgLoyaltyRepository(pool), + unitOfWork: new PostgresUnitOfWork(pool), + }; +} diff --git a/backend/services/shared/__tests__/intelligentCache.test.ts b/backend/services/shared/__tests__/intelligentCache.test.ts new file mode 100644 index 00000000..d2c0a781 --- /dev/null +++ b/backend/services/shared/__tests__/intelligentCache.test.ts @@ -0,0 +1,325 @@ +/** + * Tests for IntelligentCacheService — intelligentCache.ts + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { + IntelligentCacheService, + createIntelligentCache, + TIER_TTL, + SUBSCRIPTION_INVALIDATION_RULES, + type CacheSetOptions, +} from '../intelligentCache'; +import type { RedisClient } from '../../../shared/cache/types'; + +// ── Mock Redis client ───────────────────────────────────────────────────────── + +function makeRedis(overrides: Partial = {}): jest.Mocked { + const store = new Map(); + return { + get: jest.fn(async (key: string) => store.get(key) ?? null), + set: jest.fn(async (key: string, value: string) => { store.set(key, value); return 'OK'; }), + del: jest.fn(async (...keys: string[]) => { keys.forEach((k) => store.delete(k)); return keys.length; }), + keys: jest.fn(async (pattern: string) => { + const prefix = pattern.replace(/\*$/, ''); + return [...store.keys()].filter((k) => k.startsWith(prefix)); + }), + ping: jest.fn(async () => 'PONG'), + quit: jest.fn(async () => 'OK'), + ...overrides, + } as jest.Mocked; +} + +// ── getOrLoad ───────────────────────────────────────────────────────────────── + +describe('IntelligentCacheService.getOrLoad()', () => { + it('calls loader on cache miss and caches result', async () => { + const redis = makeRedis(); + const cache = createIntelligentCache(redis); + const loader = jest.fn(async () => ({ id: 1 })); + + const result = await cache.getOrLoad('key1', loader); + + expect(result).toEqual({ id: 1 }); + expect(loader).toHaveBeenCalledTimes(1); + expect(redis.set).toHaveBeenCalled(); + }); + + it('returns cached value on second call without invoking loader', async () => { + const redis = makeRedis(); + const cache = createIntelligentCache(redis); + const loader = jest.fn(async () => 42); + + await cache.getOrLoad('key1', loader); + const second = await cache.getOrLoad('key1', loader); + + expect(second).toBe(42); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('uses correct TTL for tier', async () => { + const redis = makeRedis(); + const cache = createIntelligentCache(redis); + const loader = jest.fn(async () => 'value'); + + await cache.getOrLoad('key1', loader, { tier: 'cold' }); + + const setCall = redis.set.mock.calls.find((c) => c[0].includes('key1')); + expect(setCall).toBeDefined(); + expect(setCall![3]).toBe(TIER_TTL.cold); + }); + + it('degrades gracefully when Redis get throws', async () => { + const redis = makeRedis({ get: jest.fn(async () => { throw new Error('Redis down'); }) }); + const cache = createIntelligentCache(redis); + const loader = jest.fn(async () => 'fallback'); + + const result = await cache.getOrLoad('key1', loader); + expect(result).toBe('fallback'); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('coalesces concurrent misses into a single loader call (single-flight)', async () => { + const redis = makeRedis(); + const cache = createIntelligentCache(redis); + let loaderCalls = 0; + const loader = jest.fn(async () => { + loaderCalls++; + await new Promise((r) => setTimeout(r, 10)); + return 'shared'; + }); + + const [a, b, c] = await Promise.all([ + cache.getOrLoad('key-sf', loader), + cache.getOrLoad('key-sf', loader), + cache.getOrLoad('key-sf', loader), + ]); + + expect(a).toBe('shared'); + expect(b).toBe('shared'); + expect(c).toBe('shared'); + expect(loaderCalls).toBe(1); + }); +}); + +// ── set() ───────────────────────────────────────────────────────────────────── + +describe('IntelligentCacheService.set()', () => { + it('stores JSON-serialised entry with TTL', async () => { + const redis = makeRedis(); + const cache = createIntelligentCache(redis); + + await cache.set('mykey', { foo: 'bar' }, { ttlSeconds: 120 }); + + expect(redis.set).toHaveBeenCalledWith( + expect.stringContaining('mykey'), + expect.stringContaining('"foo"'), + 'EX', + 120, + ); + }); + + it('indexes tags when provided', async () => { + const redis = makeRedis(); + const cache = createIntelligentCache(redis); + + await cache.set('sub:1', { id: 1 }, { tags: ['subscription:1', 'user:u1:subscriptions'] }); + + // Tag index keys should have been written + const setCalls = redis.set.mock.calls.map((c) => c[0] as string); + const tagCalls = setCalls.filter((k) => k.includes('__tag__')); + expect(tagCalls.length).toBeGreaterThanOrEqual(2); + }); +}); + +// ── invalidate() ────────────────────────────────────────────────────────────── + +describe('IntelligentCacheService.invalidate()', () => { + it('deletes the key from Redis', async () => { + const redis = makeRedis(); + const cache = createIntelligentCache(redis); + + await cache.set('k', 'v'); + await cache.invalidate('k'); + + expect(redis.del).toHaveBeenCalledWith(expect.stringContaining('k')); + }); +}); + +// ── invalidateByTag() ──────────────────────────────────────────────────────── + +describe('IntelligentCacheService.invalidateByTag()', () => { + it('invalidates all keys registered under a tag', async () => { + const redis = makeRedis(); + const cache = createIntelligentCache(redis); + + await cache.set('sub:1:details', { id: 1 }, { tags: ['subscription:1'] }); + await cache.set('sub:1:analytics', { total: 5 }, { tags: ['subscription:1'] }); + + const count = await cache.invalidateByTag('subscription:1'); + expect(count).toBeGreaterThanOrEqual(2); + }); + + it('returns 0 when no keys are tagged', async () => { + const redis = makeRedis(); + const cache = createIntelligentCache(redis); + + const count = await cache.invalidateByTag('nonexistent-tag'); + expect(count).toBe(0); + }); +}); + +describe('IntelligentCacheService.invalidateByTags()', () => { + it('invalidates keys across multiple tags', async () => { + const redis = makeRedis(); + const cache = createIntelligentCache(redis); + + await cache.set('a', 1, { tags: ['tag-a'] }); + await cache.set('b', 2, { tags: ['tag-b'] }); + + const count = await cache.invalidateByTags(['tag-a', 'tag-b']); + expect(count).toBeGreaterThanOrEqual(2); + }); +}); + +// ── Circuit breaker ─────────────────────────────────────────────────────────── + +describe('circuit breaker', () => { + it('opens after threshold consecutive failures and bypasses Redis', async () => { + let callCount = 0; + const redis = makeRedis({ + get: jest.fn(async () => { callCount++; throw new Error('Redis down'); }), + set: jest.fn(async () => { throw new Error('Redis down'); }), + }); + + const cache = createIntelligentCache(redis, { + circuitBreakerThreshold: 3, + circuitBreakerResetMs: 60_000, + }); + + const loader = jest.fn(async () => 'value'); + + // Trigger 3 failures to open the circuit + for (let i = 0; i < 3; i++) { + await cache.getOrLoad(`key-${i}`, loader); + } + + const beforeCount = callCount; + // Next call should bypass Redis entirely (circuit open) + await cache.getOrLoad('key-after', loader); + expect(callCount).toBe(beforeCount); // Redis not called again + }); + + it('reports circuitOpenEvents in metrics', async () => { + const redis = makeRedis({ + get: jest.fn(async () => { throw new Error('fail'); }), + set: jest.fn(async () => { throw new Error('fail'); }), + }); + + const cache = createIntelligentCache(redis, { circuitBreakerThreshold: 2 }); + const loader = jest.fn(async () => 'ok'); + + for (let i = 0; i < 3; i++) { + await cache.getOrLoad(`k${i}`, loader); + } + + const m = cache.getMetrics(); + expect(m.circuitOpenEvents).toBeGreaterThanOrEqual(1); + expect(m.errors).toBeGreaterThanOrEqual(2); + }); +}); + +// ── Metrics ─────────────────────────────────────────────────────────────────── + +describe('getMetrics()', () => { + it('tracks hits, misses, and writes', async () => { + const redis = makeRedis(); + const cache = createIntelligentCache(redis); + + await cache.getOrLoad('m1', async () => 'a'); // miss + write + await cache.getOrLoad('m1', async () => 'a'); // hit + await cache.getOrLoad('m2', async () => 'b'); // miss + write + + const m = cache.getMetrics(); + expect(m.misses).toBe(2); + expect(m.hits).toBe(1); + expect(m.writes).toBe(2); + expect(m.hitRatio).toBeCloseTo(1 / 3, 1); + }); +}); + +// ── isHealthy() ─────────────────────────────────────────────────────────────── + +describe('isHealthy()', () => { + it('returns true when Redis responds PONG', async () => { + const cache = createIntelligentCache(makeRedis()); + expect(await cache.isHealthy()).toBe(true); + }); + + it('returns false when Redis ping throws', async () => { + const redis = makeRedis({ ping: jest.fn(async () => { throw new Error('unreachable'); }) }); + const cache = createIntelligentCache(redis); + expect(await cache.isHealthy()).toBe(false); + }); +}); + +// ── wireEventInvalidation ──────────────────────────────────────────────────── + +describe('wireEventInvalidation()', () => { + it('invalidates tagged keys when a domain event fires', async () => { + const redis = makeRedis(); + const cache = createIntelligentCache(redis); + + await cache.set('user:u1:subs', [{ id: 1 }], { tags: ['user:u1:subscriptions'] }); + + const handlers = new Map Promise)[]>(); + const fakeEventBus = { + subscribe: jest.fn((eventName: string, handler: (e: unknown) => Promise) => { + if (!handlers.has(eventName)) handlers.set(eventName, []); + handlers.get(eventName)!.push(handler); + return { unsubscribe: jest.fn() }; + }), + } as any; + + cache.wireEventInvalidation(fakeEventBus, SUBSCRIPTION_INVALIDATION_RULES); + + // Simulate subscription.created event + const event = { + name: 'subscription.created', + payload: { userId: 'u1', subscriptionId: 'sub-new' }, + }; + const handler = handlers.get('subscription.created')?.[0]; + expect(handler).toBeDefined(); + await handler!(event); + + const m = cache.getMetrics(); + expect(m.tagInvalidations).toBeGreaterThanOrEqual(1); + }); +}); + +// ── SUBSCRIPTION_INVALIDATION_RULES ───────────────────────────────────────── + +describe('SUBSCRIPTION_INVALIDATION_RULES', () => { + it('subscription.created returns user subscriptions tag', () => { + const rule = SUBSCRIPTION_INVALIDATION_RULES.find((r) => r.eventName === 'subscription.created')!; + const tags = rule.tagsFromEvent({ name: 'subscription.created', payload: { userId: 'u42' } } as any); + expect(tags).toContain('user:u42:subscriptions'); + }); + + it('subscription.cancelled returns subscription and user tags', () => { + const rule = SUBSCRIPTION_INVALIDATION_RULES.find((r) => r.eventName === 'subscription.cancelled')!; + const tags = rule.tagsFromEvent({ + name: 'subscription.cancelled', + payload: { subscriptionId: 's1', userId: 'u1' }, + } as any); + expect(tags).toContain('subscription:s1'); + expect(tags).toContain('user:u1:subscriptions'); + }); + + it('billing.payment_captured returns subscription and analytics:mrr tags', () => { + const rule = SUBSCRIPTION_INVALIDATION_RULES.find((r) => r.eventName === 'billing.payment_captured')!; + const tags = rule.tagsFromEvent({ name: 'billing.payment_captured', payload: { subscriptionId: 's2' } } as any); + expect(tags).toContain('subscription:s2'); + expect(tags).toContain('analytics:mrr'); + }); +}); diff --git a/backend/services/shared/__tests__/middlewareChain.test.ts b/backend/services/shared/__tests__/middlewareChain.test.ts new file mode 100644 index 00000000..5294875d --- /dev/null +++ b/backend/services/shared/__tests__/middlewareChain.test.ts @@ -0,0 +1,299 @@ +/** + * Tests for composable middleware chain — middlewareChain.ts + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { + chain, + MiddlewareChain, + toExpressMiddleware, + skipPaths, + sanitizationHandler, + securityHeadersHandler, + type MiddlewareFn, + type ExpressContext, + type ChainExecutionResult, +} from '../middlewareChain'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeCtx(overrides: Partial = {}): ExpressContext { + const headers: Record = {}; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + setHeader: jest.fn((k: string, v: string) => { headers[k] = v; }), + headersSent: false, + _headers: headers, + }; + return { + req: { headers: {}, method: 'GET', path: '/test', body: {}, query: {} } as any, + res: res as any, + ...overrides, + }; +} + +// ── chain() factory ─────────────────────────────────────────────────────────── + +describe('chain()', () => { + it('returns a MiddlewareChain instance', () => { + expect(chain()).toBeInstanceOf(MiddlewareChain); + }); + + it('inspect() returns registered middleware names', () => { + const mw = chain() + .use(async (_, next) => next(), { name: 'alpha' }) + .use(async (_, next) => next(), { name: 'beta' }); + expect(mw.inspect()).toEqual(['alpha', 'beta']); + }); +}); + +// ── Sequential execution ────────────────────────────────────────────────────── + +describe('MiddlewareChain execution', () => { + it('executes middleware in order', async () => { + const order: number[] = []; + const composed = chain() + .use(async (_, next) => { order.push(1); await next(); order.push(4); }, { name: 'a' }) + .use(async (_, next) => { order.push(2); await next(); order.push(3); }, { name: 'b' }) + .build(); + + await composed(makeCtx(), async () => {}); + expect(order).toEqual([1, 2, 3, 4]); + }); + + it('calls final next when all middleware pass through', async () => { + let finalCalled = false; + const composed = chain() + .use(async (_, next) => next(), { name: 'pass' }) + .build(); + + await composed(makeCtx(), async () => { finalCalled = true; }); + expect(finalCalled).toBe(true); + }); + + it('short-circuits when a middleware does not call next()', async () => { + let secondCalled = false; + const composed = chain() + .use(async () => { /* intentionally does not call next */ }, { name: 'block' }) + .use(async (_, next) => { secondCalled = true; await next(); }, { name: 'second' }) + .build(); + + await composed(makeCtx(), async () => {}); + expect(secondCalled).toBe(false); + }); + + it('propagates errors when no error handler is set', async () => { + const composed = chain() + .use(async () => { throw new Error('boom'); }, { name: 'thrower' }) + .build(); + + await expect(composed(makeCtx(), async () => {})).rejects.toThrow('boom'); + }); + + it('error handler catches and can suppress throw', async () => { + let caught: unknown; + const composed = chain() + .use(async () => { throw new Error('handled'); }, { name: 'thrower' }) + .catch(async (err, _ctx, _next) => { caught = err; }) + .build(); + + await expect(composed(makeCtx(), async () => {})).resolves.toBeUndefined(); + expect((caught as Error).message).toBe('handled'); + }); +}); + +// ── skipCondition ───────────────────────────────────────────────────────────── + +describe('skipCondition', () => { + it('skips middleware when condition returns true', async () => { + let ran = false; + const composed = chain() + .use( + async (_, next) => { ran = true; await next(); }, + { name: 'skippable', skipCondition: () => true }, + ) + .build(); + + await composed(makeCtx(), async () => {}); + expect(ran).toBe(false); + }); + + it('skipPaths helper skips for matching path prefix', async () => { + let ran = false; + const ctx = makeCtx(); + (ctx.req as any).path = '/health/live'; + + const composed = chain() + .use( + async (_, next) => { ran = true; await next(); }, + { name: 'skippable', skipCondition: skipPaths(['/health']) }, + ) + .build(); + + await composed(ctx, async () => {}); + expect(ran).toBe(false); + }); + + it('does not skip for non-matching path', async () => { + let ran = false; + const ctx = makeCtx(); + (ctx.req as any).path = '/api/plans'; + + const composed = chain() + .use( + async (_, next) => { ran = true; await next(); }, + { name: 'runs', skipCondition: skipPaths(['/health']) }, + ) + .build(); + + await composed(ctx, async () => {}); + expect(ran).toBe(true); + }); +}); + +// ── merge() ─────────────────────────────────────────────────────────────────── + +describe('merge()', () => { + it('combines middleware from two chains in order', async () => { + const order: string[] = []; + const a = chain().use(async (_, next) => { order.push('a'); await next(); }, { name: 'a' }); + const b = chain().use(async (_, next) => { order.push('b'); await next(); }, { name: 'b' }); + a.merge(b); + + const composed = a.build(); + await composed(makeCtx(), async () => {}); + expect(order).toEqual(['a', 'b']); + }); +}); + +// ── buildInstrumented() ─────────────────────────────────────────────────────── + +describe('buildInstrumented()', () => { + it('returns correct execution telemetry', async () => { + const instrumented = chain() + .use(async (_, next) => next(), { name: 'one' }) + .use(async (_, next) => next(), { name: 'two', skipCondition: () => true }) + .buildInstrumented(); + + const result: ChainExecutionResult = await instrumented(makeCtx(), async () => {}); + expect(result.success).toBe(true); + expect(result.executedMiddleware).toContain('one'); + expect(result.skippedMiddleware).toContain('two'); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('captures errorIn when middleware throws', async () => { + const instrumented = chain() + .use(async () => { throw new Error('fail'); }, { name: 'failer' }) + .catch(async () => { /* suppress */ }) + .buildInstrumented(); + + const result = await instrumented(makeCtx(), async () => {}); + expect(result.errorIn).toBe('failer'); + expect(result.success).toBe(false); + }); +}); + +// ── toExpressMiddleware() ───────────────────────────────────────────────────── + +describe('toExpressMiddleware()', () => { + it('calls next when middleware passes through', () => { + const composed: MiddlewareFn = async (_, next) => next(); + const mw = toExpressMiddleware(composed); + + const req = { headers: {}, method: 'GET', path: '/', body: {} } as any; + const res = { headersSent: false, setHeader: jest.fn(), status: jest.fn().mockReturnThis(), json: jest.fn() } as any; + const next = jest.fn(); + + mw(req, res, next); + return new Promise((resolve) => { + Promise.resolve().then(() => Promise.resolve()).then(() => { + expect(next).toHaveBeenCalled(); + resolve(); + }); + }); + }); + + it('calls next(err) when middleware throws', () => { + const composed: MiddlewareFn = async () => { throw new Error('oops'); }; + const mw = toExpressMiddleware(composed); + + const req = { headers: {}, method: 'GET', path: '/', body: {} } as any; + const res = { headersSent: false, setHeader: jest.fn(), status: jest.fn().mockReturnThis(), json: jest.fn() } as any; + const next = jest.fn(); + + mw(req, res, next); + return new Promise((resolve) => { + Promise.resolve().then(() => Promise.resolve()).then(() => { + expect(next).toHaveBeenCalledWith(expect.any(Error)); + resolve(); + }); + }); + }); +}); + +// ── Built-in handlers ───────────────────────────────────────────────────────── + +describe('sanitizationHandler()', () => { + it('strips XSS from req.body string fields', async () => { + const ctx = makeCtx(); + (ctx.req as any).body = { name: 'hello' }; + + const composed = chain() + .use(sanitizationHandler(), { name: 'sanitize' }) + .build(); + + await composed(ctx, async () => {}); + expect((ctx.req as any).body.name).toBe('hello'); + }); + + it('throws on SQL injection pattern', async () => { + const ctx = makeCtx(); + (ctx.req as any).body = { q: "1' OR 1=1 --" }; + + const composed = chain() + .use(sanitizationHandler(), { name: 'sanitize' }) + .build(); + + await expect(composed(ctx, async () => {})).rejects.toThrow(/SQL injection/i); + }); + + it('recurses into nested objects', async () => { + const ctx = makeCtx(); + (ctx.req as any).body = { nested: { value: 'bold' } }; + + const composed = chain() + .use(sanitizationHandler(), { name: 'sanitize' }) + .build(); + + await composed(ctx, async () => {}); + expect((ctx.req as any).body.nested.value).toBe('bold'); + }); +}); + +describe('securityHeadersHandler()', () => { + it('sets standard security headers', async () => { + const ctx = makeCtx(); + const composed = chain() + .use(securityHeadersHandler(), { name: 'sec-headers' }) + .build(); + + await composed(ctx, async () => {}); + expect((ctx.res as any).setHeader).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff'); + expect((ctx.res as any).setHeader).toHaveBeenCalledWith('X-Frame-Options', 'DENY'); + expect((ctx.res as any).setHeader).toHaveBeenCalledWith('Strict-Transport-Security', expect.stringContaining('max-age=')); + }); + + it('skips HSTS when disabled', async () => { + const ctx = makeCtx(); + const composed = chain() + .use(securityHeadersHandler({ hsts: false }), { name: 'sec-headers' }) + .build(); + + await composed(ctx, async () => {}); + const calls = (ctx.res as any).setHeader.mock.calls.map((c: string[]) => c[0]); + expect(calls).not.toContain('Strict-Transport-Security'); + }); +}); diff --git a/backend/services/shared/index.ts b/backend/services/shared/index.ts index 19df0818..bd1aa21b 100644 --- a/backend/services/shared/index.ts +++ b/backend/services/shared/index.ts @@ -185,3 +185,41 @@ export type { WsPoolConfig, WsConnection, WsMessage, WsPoolMetrics } from './wsC // ── Read Replica Router (#997) ──────────────────────────────────────────────── export { ReadReplicaRouter } from './readReplicaRouter'; export type { ReplicaConfig, ReplicaHealth, ReadRouteOptions, QueryRoute } from './readReplicaRouter'; + +// ── Composable Middleware Chain ─────────────────────────────────────────────── +export { + chain, + MiddlewareChain, + toExpressMiddleware, + skipPaths, + authHandler, + corsHandler, + rateLimitHandler, + sanitizationHandler, + validationHandler, + securityHeadersHandler, + publicApiChain, + authenticatedApiChain, +} from './middlewareChain'; +export type { + MiddlewareFn, + MiddlewareErrorHandler, + MiddlewareMetadata, + ChainExecutionResult, + ExpressContext, +} from './middlewareChain'; + +// ── Intelligent Redis Cache ─────────────────────────────────────────────────── +export { + IntelligentCacheService, + createIntelligentCache, + TIER_TTL, + SUBSCRIPTION_INVALIDATION_RULES, +} from './intelligentCache'; +export type { + CacheTier, + CacheSetOptions, + IntelligentCacheConfig, + CacheInvalidationRule, + IntelligentCacheMetrics, +} from './intelligentCache'; diff --git a/backend/services/shared/intelligentCache.ts b/backend/services/shared/intelligentCache.ts new file mode 100644 index 00000000..42a3987f --- /dev/null +++ b/backend/services/shared/intelligentCache.ts @@ -0,0 +1,502 @@ +/** + * Intelligent Redis Cache — SubTrackr + * + * Extends the base CacheService with: + * - Tag-based invalidation: group keys under semantic tags (e.g. "subscription:sub-1") + * and invalidate all related keys in one call + * - Tiered TTLs: hot/warm/cold data gets different expiry + * - Invalidation cascade: parent-tag invalidation propagates to all child tags + * - Stale-while-revalidate: serve stale data while fetching fresh value + * - Circuit breaker: after N consecutive Redis failures, bypass cache + * - Background refresh: proactive TTL renewal before expiry + * - Namespace isolation: per-tenant and per-domain key prefixes + */ + +import type { RedisClient } from '../../shared/cache/types'; +import type { IEventBus, AnyDomainEvent } from './events'; +import { logger } from './logging'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type CacheTier = 'hot' | 'warm' | 'cold'; + +export const TIER_TTL: Record = { + hot: 60, // 1 minute — frequently-changing data + warm: 300, // 5 minutes — standard API responses + cold: 3600, // 1 hour — reference data +}; + +export interface CacheSetOptions { + ttlSeconds?: number; + tier?: CacheTier; + tags?: string[]; + /** If true, serve stale value while refreshing in background */ + staleWhileRevalidate?: boolean; + /** Grace period in seconds for serving stale data (default: 30) */ + staleGracePeriodSeconds?: number; +} + +export interface IntelligentCacheConfig { + keyPrefix?: string; + defaultTtlSeconds?: number; + defaultTier?: CacheTier; + /** Max consecutive Redis failures before entering circuit-open mode */ + circuitBreakerThreshold?: number; + /** How long (ms) to stay in open state before probing again */ + circuitBreakerResetMs?: number; + onDegradation?: (msg: string, ctx?: Record) => void; +} + +const TAG_INDEX_PREFIX = '__tag__:'; + +export interface CacheInvalidationRule { + eventName: E['name'] | '*'; + /** Return cache tags (not keys) to invalidate when this event fires */ + tagsFromEvent: (event: E) => string[]; +} + +export interface IntelligentCacheMetrics { + hits: number; + misses: number; + staleHits: number; + writes: number; + tagInvalidations: number; + taggedKeys: number; + circuitOpenEvents: number; + errors: number; + hitRatio: number; +} + +// ─── Circuit breaker state ──────────────────────────────────────────────────── + +const enum CircuitState { CLOSED, OPEN, HALF_OPEN } + +// ─── Cache entry (with metadata) ───────────────────────────────────────────── + +interface CacheEntry { + value: T; + cachedAt: number; + ttlSeconds: number; + tags: string[]; +} + +// ─── Service ────────────────────────────────────────────────────────────────── + +export class IntelligentCacheService { + private readonly prefix: string; + private readonly defaultTtl: number; + private readonly defaultTier: CacheTier; + private readonly cbThreshold: number; + private readonly cbResetMs: number; + private readonly onDegradation?: IntelligentCacheConfig['onDegradation']; + + // Single-flight map + private readonly inflight = new Map>(); + + // Circuit breaker + private cbState: CircuitState = CircuitState.CLOSED; + private cbFailures = 0; + private cbOpenAt = 0; + + // Metrics + private hits = 0; + private misses = 0; + private staleHits = 0; + private writes = 0; + private tagInvalidations = 0; + private circuitOpenEvents = 0; + private errors = 0; + + constructor( + private readonly redis: RedisClient, + config: IntelligentCacheConfig = {}, + ) { + this.prefix = config.keyPrefix ?? 'subtrackr:icache:'; + this.defaultTtl = config.defaultTtlSeconds ?? TIER_TTL.warm; + this.defaultTier = config.defaultTier ?? 'warm'; + this.cbThreshold = config.circuitBreakerThreshold ?? 5; + this.cbResetMs = config.circuitBreakerResetMs ?? 30_000; + this.onDegradation = config.onDegradation; + } + + // ── Public API ─────────────────────────────────────────────────────────────── + + /** + * Get a cached value or call `loader` on cache miss. + * Supports stale-while-revalidate and single-flight protection. + */ + async getOrLoad( + key: string, + loader: () => Promise, + options: CacheSetOptions = {}, + ): Promise { + if (this.isCircuitOpen()) { + return loader(); + } + + const fullKey = this.fullKey(key); + const raw = await this.safeGet(fullKey); + + if (raw !== null) { + try { + const entry: CacheEntry = JSON.parse(raw); + const ageMs = Date.now() - entry.cachedAt; + const ttlMs = entry.ttlSeconds * 1000; + + if (ageMs < ttlMs) { + this.hits++; + return entry.value; + } + + // Stale-while-revalidate: serve stale, refresh in background + const gracePeriod = (options.staleGracePeriodSeconds ?? 30) * 1000; + if (options.staleWhileRevalidate && ageMs < ttlMs + gracePeriod) { + this.staleHits++; + this.refreshInBackground(key, loader, options); + return entry.value; + } + } catch { + // Corrupted entry — treat as miss + } + } + + this.misses++; + + // Single-flight: coalesce concurrent misses for the same key + const existing = this.inflight.get(key) as Promise | undefined; + if (existing) return existing; + + const flight = this.loadAndSet(key, loader, options); + this.inflight.set(key, flight); + try { + return await flight; + } finally { + this.inflight.delete(key); + } + } + + /** Explicitly set a value with optional tags for invalidation. */ + async set(key: string, value: T, options: CacheSetOptions = {}): Promise { + if (this.isCircuitOpen()) return; + + const ttl = options.ttlSeconds ?? (options.tier ? TIER_TTL[options.tier] : this.defaultTtl); + const fullKey = this.fullKey(key); + + const entry: CacheEntry = { + value, + cachedAt: Date.now(), + ttlSeconds: ttl, + tags: options.tags ?? [], + }; + + const ok = await this.safeSet(fullKey, JSON.stringify(entry), ttl); + if (ok) { + this.writes++; + if (options.tags && options.tags.length > 0) { + await this.indexTags(key, options.tags, ttl); + } + } + } + + /** Delete a single key. */ + async invalidate(key: string): Promise { + if (this.isCircuitOpen()) return; + const fullKey = this.fullKey(key); + try { + await this.redis.del(fullKey); + } catch (err) { + this.handleError('invalidate', err); + } + } + + /** + * Invalidate all cache keys associated with a tag. + * Also cascades to any child tags (tags prefixed with `tag:`). + */ + async invalidateByTag(tag: string): Promise { + if (this.isCircuitOpen()) return 0; + + const tagKey = this.tagKey(tag); + let members: string[] = []; + + try { + members = await this.redis.keys(`${tagKey}:*`); + // Also get direct members stored under the tag key + const tagMembers = await this.redis.keys(tagKey); + members.push(...tagMembers); + } catch (err) { + this.handleError('invalidateByTag:keys', err); + return 0; + } + + // Collect all cache keys registered under this tag (stored as pattern `tagKey:cacheKey`) + const tagIndexKey = `${TAG_INDEX_PREFIX}${this.prefix}${tag}`; + let taggedKeys: string[] = []; + try { + // Keys stored as members in a Redis set named after the tag + const raw = await this.redis.get(tagIndexKey); + if (raw) { + taggedKeys = JSON.parse(raw) as string[]; + } + } catch { + // Ignore + } + + const keysToDelete = [...new Set([...taggedKeys.map((k) => this.fullKey(k))])]; + + if (keysToDelete.length === 0) return 0; + + try { + await this.redis.del(...keysToDelete); + // Clean up tag index + await this.redis.del(tagIndexKey); + this.tagInvalidations += keysToDelete.length; + return keysToDelete.length; + } catch (err) { + this.handleError('invalidateByTag:del', err); + return 0; + } + } + + /** + * Invalidate multiple tags at once. + */ + async invalidateByTags(tags: string[]): Promise { + let total = 0; + for (const tag of tags) { + total += await this.invalidateByTag(tag); + } + return total; + } + + /** + * Wire event-driven automatic cache invalidation. + * Call once during app bootstrap. + */ + wireEventInvalidation( + eventBus: IEventBus, + rules: CacheInvalidationRule[], + ): void { + for (const rule of rules) { + eventBus.subscribe(rule.eventName as string, async (event) => { + const tags = rule.tagsFromEvent(event as AnyDomainEvent); + if (tags.length > 0) { + await this.invalidateByTags(tags).catch((err) => + logger.warn('Cache invalidation failed', { tags, err: String(err) }), + ); + } + }); + } + } + + getMetrics(): IntelligentCacheMetrics { + const total = this.hits + this.misses + this.staleHits; + return { + hits: this.hits, + misses: this.misses, + staleHits: this.staleHits, + writes: this.writes, + tagInvalidations: this.tagInvalidations, + taggedKeys: 0, // Would require DBSIZE; intentionally omitted for performance + circuitOpenEvents: this.circuitOpenEvents, + errors: this.errors, + hitRatio: total === 0 ? NaN : (this.hits + this.staleHits) / total, + }; + } + + async isHealthy(): Promise { + try { + const pong = await this.redis.ping(); + if (pong === 'PONG') { + this.resetCircuit(); + return true; + } + return false; + } catch { + this.recordFailure(); + return false; + } + } + + // ── Private ────────────────────────────────────────────────────────────────── + + private fullKey(key: string): string { + return `${this.prefix}${key}`; + } + + private tagKey(tag: string): string { + return `${TAG_INDEX_PREFIX}${this.prefix}${tag}`; + } + + private async safeGet(fullKey: string): Promise { + try { + const value = await this.redis.get(fullKey); + this.resetCircuit(); + return value; + } catch (err) { + this.handleError('get', err); + return null; + } + } + + private async safeSet(fullKey: string, value: string, ttl: number): Promise { + try { + await this.redis.set(fullKey, value, 'EX', ttl); + this.resetCircuit(); + return true; + } catch (err) { + this.handleError('set', err); + return false; + } + } + + /** Register a cache key under its tags so tag-based invalidation can find it. */ + private async indexTags(key: string, tags: string[], ttl: number): Promise { + for (const tag of tags) { + const tagIndexKey = `${TAG_INDEX_PREFIX}${this.prefix}${tag}`; + try { + const raw = await this.redis.get(tagIndexKey); + const existing: string[] = raw ? (JSON.parse(raw) as string[]) : []; + if (!existing.includes(key)) { + existing.push(key); + // Keep tag index alive a bit longer than the cached values + await this.redis.set(tagIndexKey, JSON.stringify(existing), 'EX', ttl + 60); + } + } catch { + // Tag indexing failure is non-fatal — invalidation may miss this key + } + } + } + + private async loadAndSet( + key: string, + loader: () => Promise, + options: CacheSetOptions, + ): Promise { + const value = await loader(); + await this.set(key, value, options).catch(() => { + /* non-fatal */ + }); + return value; + } + + private refreshInBackground( + key: string, + loader: () => Promise, + options: CacheSetOptions, + ): void { + // Fire-and-forget refresh + Promise.resolve() + .then(() => this.loadAndSet(key, loader, options)) + .catch((err) => + logger.warn('Background cache refresh failed', { key, err: String(err) }), + ); + } + + // ── Circuit breaker ────────────────────────────────────────────────────────── + + private isCircuitOpen(): boolean { + if (this.cbState === CircuitState.CLOSED) return false; + if (this.cbState === CircuitState.OPEN) { + if (Date.now() - this.cbOpenAt > this.cbResetMs) { + this.cbState = CircuitState.HALF_OPEN; + return false; // probe + } + return true; + } + return false; // HALF_OPEN allows one probe + } + + private recordFailure(): void { + this.cbFailures++; + this.errors++; + if (this.cbState === CircuitState.HALF_OPEN || this.cbFailures >= this.cbThreshold) { + this.cbState = CircuitState.OPEN; + this.cbOpenAt = Date.now(); + this.circuitOpenEvents++; + this.warn('Redis circuit breaker opened', { failures: this.cbFailures }); + } + } + + private resetCircuit(): void { + if (this.cbState !== CircuitState.CLOSED) { + this.cbState = CircuitState.CLOSED; + this.cbFailures = 0; + } + } + + private handleError(operation: string, err: unknown): void { + this.recordFailure(); + this.warn(`Redis ${operation} failed`, { err: String(err) }); + } + + private warn(msg: string, ctx?: Record): void { + if (this.onDegradation) { + this.onDegradation(msg, ctx); + } else { + logger.warn(`[IntelligentCache] ${msg}`, ctx ?? {}); + } + } +} + +// ─── Pre-built invalidation rules for the subscription domain ──────────────── + +/** + * Standard invalidation rules wired to the subscription domain events. + * Import and pass to `wireEventInvalidation()` during bootstrap. + * + * Event names use the `domain.type` format from the typed event bus. + */ +export const SUBSCRIPTION_INVALIDATION_RULES: CacheInvalidationRule[] = [ + { + eventName: 'subscription.created', + tagsFromEvent: (e) => { + const payload = (e as { payload: { userId?: string } }).payload; + return payload.userId ? [`user:${payload.userId}:subscriptions`] : []; + }, + }, + { + eventName: 'subscription.cancelled', + tagsFromEvent: (e) => { + const p = (e as { payload: { subscriptionId?: string; userId?: string } }).payload; + const tags: string[] = []; + if (p.subscriptionId) tags.push(`subscription:${p.subscriptionId}`); + if (p.userId) tags.push(`user:${p.userId}:subscriptions`); + return tags; + }, + }, + { + eventName: 'subscription.renewed', + tagsFromEvent: (e) => { + const p = (e as { payload: { subscriptionId?: string } }).payload; + return p.subscriptionId ? [`subscription:${p.subscriptionId}`] : []; + }, + }, + { + eventName: 'billing.payment_captured', + tagsFromEvent: (e) => { + const p = (e as { payload: { subscriptionId?: string } }).payload; + const tags: string[] = ['analytics:mrr']; + if (p.subscriptionId) tags.push(`subscription:${p.subscriptionId}`); + return tags; + }, + }, + { + eventName: 'billing.invoice_generated', + tagsFromEvent: (e) => { + const p = (e as { payload: { subscriptionId?: string } }).payload; + const tags: string[] = ['analytics:invoices']; + if (p.subscriptionId) tags.push(`subscription:${p.subscriptionId}`); + return tags; + }, + }, +]; + +// ─── Factory ────────────────────────────────────────────────────────────────── + +export function createIntelligentCache( + redis: RedisClient, + config: IntelligentCacheConfig = {}, +): IntelligentCacheService { + return new IntelligentCacheService(redis, config); +} diff --git a/backend/services/shared/middlewareChain.ts b/backend/services/shared/middlewareChain.ts new file mode 100644 index 00000000..129f7fec --- /dev/null +++ b/backend/services/shared/middlewareChain.ts @@ -0,0 +1,513 @@ +/** + * Composable Middleware Chain — SubTrackr + * + * Provides a fluent builder for assembling security middleware into ordered, + * composable chains. Each handler is a lightweight function: + * + * type MiddlewareFn = (ctx: C, next: () => Promise) => Promise + * + * Usage: + * const secured = chain() + * .use(corsHandler(corsPolicy)) + * .use(authHandler(manager)) + * .use(rateLimitHandler(rateLimitService)) + * .use(validationHandler(schema)) + * .build(); + * + * // Express adapter: + * app.use('/api', toExpressMiddleware(secured)); + */ + +import { + createRateLimitMiddleware as _createRateLimitMiddleware, + type RateLimitRequest as _RateLimitRequest, + type RateLimitResponse as _RateLimitResponse, +} from './rateLimitMiddleware'; +import type { CompositeAuthStrategyManager } from './authStrategies'; +import type { CorsPolicy } from './corsMiddleware'; +import { RateLimitingService } from './rateLimitingService'; +import { sanitizeXss, detectSqlInjection } from './validationMiddleware'; + +// ─── Minimal structural HTTP types ──────────────────────────────────────────── +// Matches Express Request/Response/NextFunction shapes without requiring the +// express package in non-backend bundles. + +export interface HttpRequest { + method: string; + path: string; + url?: string; + headers: Record; + query: Record; + body: unknown; + ip?: string; + socket?: { remoteAddress?: string }; +} + +export interface HttpResponse { + headersSent: boolean; + status(code: number): this; + json(body: unknown): this; + end(body?: string): this; + setHeader(name: string, value: string | number): void; +} + +export type NextFunction = (err?: unknown) => void; + +// ─── Core types ─────────────────────────────────────────────────────────────── + +export type MiddlewareFn = (ctx: C, next: () => Promise) => Promise; + +export type MiddlewareErrorHandler = ( + err: unknown, + ctx: C, + next: () => Promise, +) => Promise; + +export interface MiddlewareMetadata { + name: string; + skipCondition?: (ctx: unknown) => boolean; +} + +interface MiddlewareEntry { + fn: MiddlewareFn; + meta: MiddlewareMetadata; +} + +// ─── Execution result ───────────────────────────────────────────────────────── + +export interface ChainExecutionResult { + success: boolean; + executedMiddleware: string[]; + skippedMiddleware: string[]; + errorIn?: string; + durationMs: number; +} + +// ─── Chain builder ──────────────────────────────────────────────────────────── + +export class MiddlewareChain { + private readonly entries: MiddlewareEntry[] = []; + private errorHandler?: MiddlewareErrorHandler; + + /** Append a middleware to the chain with optional metadata. */ + use( + fn: MiddlewareFn, + meta: MiddlewareMetadata = { name: fn.name || 'anonymous' }, + ): this { + this.entries.push({ fn, meta }); + return this; + } + + /** Register a global error handler for the chain. */ + catch(handler: MiddlewareErrorHandler): this { + this.errorHandler = handler; + return this; + } + + /** + * Build a single composed middleware function. + * Execution is sequential; any middleware can short-circuit by not calling `next`. + */ + build(): MiddlewareFn { + const entries = [...this.entries]; + const errorHandler = this.errorHandler; + + return async (ctx: C, finalNext: () => Promise): Promise => { + let index = 0; + + const dispatch = async (): Promise => { + if (index >= entries.length) { + return finalNext(); + } + + const entry = entries[index++]!; + + if (entry.meta.skipCondition && entry.meta.skipCondition(ctx)) { + return dispatch(); + } + + try { + await entry.fn(ctx, dispatch); + } catch (err) { + if (errorHandler) { + await errorHandler(err, ctx, dispatch); + } else { + throw err; + } + } + }; + + return dispatch(); + }; + } + + /** + * Build and instrument the chain to collect execution telemetry. + */ + buildInstrumented(): (ctx: C, next: () => Promise) => Promise { + const entries = [...this.entries]; + const errorHandler = this.errorHandler; + + return async (ctx: C, finalNext: () => Promise): Promise => { + const start = Date.now(); + const executedMiddleware: string[] = []; + const skippedMiddleware: string[] = []; + let errorIn: string | undefined; + let index = 0; + + const dispatch = async (): Promise => { + if (index >= entries.length) { + return finalNext(); + } + + const entry = entries[index++]!; + const name = entry.meta.name; + + if (entry.meta.skipCondition && entry.meta.skipCondition(ctx)) { + skippedMiddleware.push(name); + return dispatch(); + } + + executedMiddleware.push(name); + + try { + await entry.fn(ctx, dispatch); + } catch (err) { + errorIn = name; + if (errorHandler) { + await errorHandler(err, ctx, dispatch); + } else { + throw err; + } + } + }; + + try { + await dispatch(); + } catch { + // error captured in errorIn + } + + return { + success: !errorIn, + executedMiddleware, + skippedMiddleware, + errorIn, + durationMs: Date.now() - start, + }; + }; + } + + /** Merge another chain's middleware into this one (in order). */ + merge(other: MiddlewareChain): this { + for (const entry of other.entries) { + this.entries.push(entry); + } + return this; + } + + /** Return the names of registered middleware (for diagnostics). */ + inspect(): string[] { + return this.entries.map((e) => e.meta.name); + } +} + +/** Factory: create a typed middleware chain. */ +export function chain(): MiddlewareChain { + return new MiddlewareChain(); +} + +// ─── Express context & adapter ──────────────────────────────────────────────── + +export interface ExpressContext { + req: HttpRequest; + res: HttpResponse; +} + +/** + * Convert a composed `MiddlewareFn` into a standard Express + * middleware `(req, res, next)` handler. + */ +export function toExpressMiddleware( + fn: MiddlewareFn, +): (req: HttpRequest, res: HttpResponse, next: NextFunction) => void { + return (req: HttpRequest, res: HttpResponse, next: NextFunction): void => { + const ctx: ExpressContext = { req, res }; + fn(ctx, async () => {}) + .then(() => { + if (!res.headersSent) next(); + }) + .catch(next); + }; +} + +// ─── Built-in security middleware handlers ──────────────────────────────────── + +/** Skip paths from a middleware (e.g. health checks). */ +export function skipPaths(paths: string[]): (ctx: unknown) => boolean { + return (ctx: unknown) => { + const expressCtx = ctx as ExpressContext; + const reqPath: string = expressCtx?.req?.path ?? expressCtx?.req?.url ?? ''; + return paths.some((p) => reqPath.startsWith(p)); + }; +} + +/** + * Auth handler: authenticates the request and attaches `req.user`. + * Short-circuits with 401 if no strategy matches. + */ +export function authHandler( + manager: CompositeAuthStrategyManager, + options: { optional?: boolean } = {}, +): MiddlewareFn { + return async function authenticate({ req, res }, next) { + try { + // Cast to any for compatibility with CompositeAuthStrategyManager's Express-typed signature + const user = await manager.authenticate(req as unknown as Parameters[0]); + (req as unknown as Record).user = user; + (req as unknown as Record).authStrategy = (user as { strategy: string }).strategy; + await next(); + } catch { + if (options.optional) { + await next(); + } else { + res.status(401).json({ + success: false, + error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, + }); + } + } + }; +} + +/** + * CORS handler: applies dynamic per-tenant CORS policies. + * Reads tenant from `req.headers['x-tenant-id']`. + */ +export function corsHandler( + getPolicyForTenant: (tenantId: string) => CorsPolicy | null, + defaultPolicy?: Partial, +): MiddlewareFn { + return async function applyCors({ req, res }, next) { + const tenantId = req.headers['x-tenant-id']; + const tenantIdStr = Array.isArray(tenantId) ? tenantId[0] : tenantId; + const originHeader = req.headers['origin']; + const origin = (Array.isArray(originHeader) ? originHeader[0] : originHeader) ?? ''; + const policy = tenantIdStr ? getPolicyForTenant(tenantIdStr) : null; + + const allowedOrigins = + policy?.allowedOrigins.map((o) => o.origin) ?? + defaultPolicy?.allowedOrigins?.map((o) => o.origin) ?? + ['*']; + + const isAllowed = + allowedOrigins.includes('*') || + allowedOrigins.some((o) => { + if (o.includes('*')) { + const pattern = new RegExp( + '^' + o.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$', + ); + return pattern.test(origin); + } + return o === origin; + }); + + if (origin && isAllowed) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } + + const methods = ( + policy?.allowMethods ?? + defaultPolicy?.allowMethods ?? + ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] + ).join(', '); + const allowHeaders = ( + policy?.allowHeaders ?? + defaultPolicy?.allowHeaders ?? + ['Content-Type', 'Authorization', 'X-API-Key'] + ).join(', '); + + res.setHeader('Access-Control-Allow-Methods', methods); + res.setHeader('Access-Control-Allow-Headers', allowHeaders); + + if (policy?.allowCredentials) { + res.setHeader('Access-Control-Allow-Credentials', 'true'); + } + + if (req.method === 'OPTIONS') { + const maxAge = policy?.maxAge ?? 86400; + res.setHeader('Access-Control-Max-Age', maxAge); + res.status(204).end(); + return; + } + + await next(); + }; +} + +/** + * Rate-limit handler: delegates to the existing rateLimitMiddleware. + * Sets X-RateLimit-* headers; returns 429 when exceeded. + */ +export function rateLimitHandler( + service: RateLimitingService, + options: { + keyFn?: (req: _RateLimitRequest) => string | undefined; + softMode?: boolean; + bypassPaths?: string[]; + } = {}, +): MiddlewareFn { + const mw = _createRateLimitMiddleware({ + service, + keyFn: options.keyFn, + softMode: options.softMode, + bypassPaths: options.bypassPaths, + }); + + return async function rateLimit({ req, res }, next) { + let calledNext = false; + mw( + req as unknown as _RateLimitRequest, + res as unknown as _RateLimitResponse, + () => { calledNext = true; }, + ); + if (calledNext) await next(); + }; +} + +/** + * XSS + SQL injection sanitization handler. + * Mutates string values in req.body and req.query in-place. + */ +export function sanitizationHandler(): MiddlewareFn { + function sanitizeObject(obj: Record): void { + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (typeof val === 'string') { + if (detectSqlInjection(val)) { + throw Object.assign( + new Error('Potential SQL injection detected'), + { statusCode: 400 }, + ); + } + obj[key] = sanitizeXss(val); + } else if (val && typeof val === 'object') { + sanitizeObject(val as Record); + } + } + } + + return async function sanitize({ req }, next) { + if (req.body && typeof req.body === 'object') { + sanitizeObject(req.body as Record); + } + if (req.query && typeof req.query === 'object') { + sanitizeObject(req.query as Record); + } + await next(); + }; +} + +/** + * Zod body-validation handler. + * Returns 422 with field-level errors on schema mismatch. + */ +export function validationHandler( + schema: { safeParse(data: unknown): { success: true; data: T } | { success: false; error: { issues: { path: (string | number)[]; message: string }[] } } }, +): MiddlewareFn { + return async function validate({ req, res }, next) { + const result = schema.safeParse(req.body); + if (!result.success) { + const details: Record = {}; + const failResult = result as { success: false; error: { issues: { path: (string | number)[]; message: string }[] } }; + for (const issue of failResult.error.issues) { + details[issue.path.join('.')] = issue.message; + } + res.status(422).json({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'Request validation failed', details }, + }); + return; + } + (req as unknown as Record).validatedBody = result.data; + await next(); + }; +} + +/** + * Security headers handler: sets CSP, HSTS, X-Frame-Options, etc. + */ +export function securityHeadersHandler( + options: { hsts?: boolean; reportUri?: string } = {}, +): MiddlewareFn { + return async function setSecurityHeaders({ res }, next) { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('X-XSS-Protection', '0'); + res.setHeader( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'", + ); + if (options.hsts !== false) { + res.setHeader( + 'Strict-Transport-Security', + 'max-age=63072000; includeSubDomains; preload', + ); + } + if (options.reportUri) { + res.setHeader( + 'Report-To', + JSON.stringify({ + group: 'default', + max_age: 86400, + endpoints: [{ url: options.reportUri }], + }), + ); + } + await next(); + }; +} + +// ─── Preset chains ──────────────────────────────────────────────────────────── + +/** + * Standard public API security chain: + * securityHeaders → cors → rateLimiting → sanitization + */ +export function publicApiChain(options: { + rateLimitService: RateLimitingService; + corsPolicy?: (tenantId: string) => CorsPolicy | null; + bypassPaths?: string[]; +}): MiddlewareChain { + return chain() + .use(securityHeadersHandler(), { name: 'security-headers' }) + .use(corsHandler(options.corsPolicy ?? (() => null)), { name: 'cors' }) + .use( + rateLimitHandler(options.rateLimitService, { bypassPaths: options.bypassPaths }), + { name: 'rate-limit' }, + ) + .use(sanitizationHandler(), { name: 'sanitization' }); +} + +/** + * Authenticated API security chain: + * securityHeaders → cors → auth → rateLimiting → sanitization + */ +export function authenticatedApiChain(options: { + authManager: CompositeAuthStrategyManager; + rateLimitService: RateLimitingService; + corsPolicy?: (tenantId: string) => CorsPolicy | null; + bypassPaths?: string[]; +}): MiddlewareChain { + return chain() + .use(securityHeadersHandler(), { name: 'security-headers' }) + .use(corsHandler(options.corsPolicy ?? (() => null)), { name: 'cors' }) + .use(authHandler(options.authManager), { name: 'auth' }) + .use( + rateLimitHandler(options.rateLimitService, { bypassPaths: options.bypassPaths }), + { name: 'rate-limit' }, + ) + .use(sanitizationHandler(), { name: 'sanitization' }); +} diff --git a/sdks/javascript/src/__tests__/retry.test.ts b/sdks/javascript/src/__tests__/retry.test.ts new file mode 100644 index 00000000..eb080a8b --- /dev/null +++ b/sdks/javascript/src/__tests__/retry.test.ts @@ -0,0 +1,240 @@ +/** + * Tests for retry utilities — retry.ts + */ + +import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { + withRetry, + RetryableError, + isRetryableStatus, + parseRetryAfterMs, +} from '../retry'; + +// ── withRetry ───────────────────────────────────────────────────────────────── + +describe('withRetry()', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('returns value immediately on first-attempt success', async () => { + const fn = jest.fn(async () => 42); + const promise = withRetry(fn, { maxAttempts: 3 }); + // no timers needed — success on attempt 1 + const result = await promise; + expect(result.value).toBe(42); + expect(result.attempts).toBe(1); + expect(result.totalDelayMs).toBe(0); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('retries on failure and succeeds on second attempt', async () => { + let calls = 0; + const fn = jest.fn(async () => { + calls++; + if (calls < 2) throw new RetryableError('flaky', 503); + return 'ok'; + }); + + const promise = withRetry(fn, { + maxAttempts: 3, + initialDelayMs: 100, + jitter: false, + }); + + // Advance past the first retry delay + await jest.advanceTimersByTimeAsync(100); + const result = await promise; + + expect(result.value).toBe('ok'); + expect(result.attempts).toBe(2); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('throws after exhausting maxAttempts', async () => { + const fn = jest.fn(async () => { throw new RetryableError('always fails', 503); }); + + const promise = withRetry(fn, { + maxAttempts: 3, + initialDelayMs: 10, + jitter: false, + }); + + await jest.advanceTimersByTimeAsync(10); // retry 1 + await jest.advanceTimersByTimeAsync(20); // retry 2 (backoff x2) + + await expect(promise).rejects.toThrow('always fails'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('respects retryAfterMs from RetryableError', async () => { + let calls = 0; + const fn = jest.fn(async () => { + calls++; + if (calls < 2) throw new RetryableError('rate limited', 429, 5000); + return 'done'; + }); + + const promise = withRetry(fn, { + maxAttempts: 3, + initialDelayMs: 100, + jitter: false, + }); + + // Should wait the retryAfterMs (5000) not the base delay (100) + await jest.advanceTimersByTimeAsync(5000); + const result = await promise; + + expect(result.value).toBe('done'); + }); + + it('applies exponential back-off without jitter', async () => { + const delays: number[] = []; + const fn = jest.fn(async () => { throw new Error('fail'); }); + + const promise = withRetry(fn, { + maxAttempts: 4, + initialDelayMs: 100, + backoffMultiplier: 2, + jitter: false, + onRetry: (_, delayMs) => delays.push(delayMs), + }); + + await jest.advanceTimersByTimeAsync(100); // attempt 1 → wait 100 + await jest.advanceTimersByTimeAsync(200); // attempt 2 → wait 200 + await jest.advanceTimersByTimeAsync(400); // attempt 3 → wait 400 + + await promise.catch(() => {}); + + expect(delays).toEqual([100, 200, 400]); + }); + + it('caps delay at maxDelayMs', async () => { + const delays: number[] = []; + const fn = jest.fn(async () => { throw new Error('fail'); }); + + const promise = withRetry(fn, { + maxAttempts: 3, + initialDelayMs: 1000, + backoffMultiplier: 10, + maxDelayMs: 500, + jitter: false, + onRetry: (_, delayMs) => delays.push(delayMs), + }); + + await jest.advanceTimersByTimeAsync(500); + await jest.advanceTimersByTimeAsync(500); + await promise.catch(() => {}); + + expect(delays.every((d) => d <= 500)).toBe(true); + }); + + it('calls onRetry callback with attempt number', async () => { + const attempts: number[] = []; + const fn = jest.fn(async (attempt: number) => { + if (attempt < 3) throw new Error('retry me'); + return 'success'; + }); + + const promise = withRetry(fn, { + maxAttempts: 3, + initialDelayMs: 10, + jitter: false, + onRetry: (attempt) => attempts.push(attempt), + }); + + await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(20); + await promise; + + expect(attempts).toEqual([1, 2]); + }); +}); + +// ── isRetryableStatus ───────────────────────────────────────────────────────── + +describe('isRetryableStatus()', () => { + it('retries 429 for all methods', () => { + expect(isRetryableStatus(429, 'POST')).toBe(true); + expect(isRetryableStatus(429, 'GET')).toBe(true); + expect(isRetryableStatus(429, 'PUT')).toBe(true); + }); + + it('retries 503 for all methods', () => { + expect(isRetryableStatus(503, 'POST')).toBe(true); + expect(isRetryableStatus(503, 'DELETE')).toBe(true); + }); + + it('retries 502 and 504 for GET', () => { + expect(isRetryableStatus(502, 'GET')).toBe(true); + expect(isRetryableStatus(504, 'GET')).toBe(true); + }); + + it('does not retry 502 for POST (non-idempotent)', () => { + expect(isRetryableStatus(502, 'POST')).toBe(false); + }); + + it('does not retry 4xx client errors for GET', () => { + expect(isRetryableStatus(400, 'GET')).toBe(false); + expect(isRetryableStatus(401, 'GET')).toBe(false); + expect(isRetryableStatus(403, 'GET')).toBe(false); + expect(isRetryableStatus(404, 'GET')).toBe(false); + }); + + it('retries 408 for GET', () => { + expect(isRetryableStatus(408, 'GET')).toBe(true); + }); + + it('respects custom retryableStatuses override', () => { + expect(isRetryableStatus(500, 'GET', [500])).toBe(true); + expect(isRetryableStatus(502, 'GET', [500])).toBe(false); + }); + + it('respects custom retryableMethods override', () => { + expect(isRetryableStatus(502, 'POST', [502, 503, 504], ['GET', 'POST'])).toBe(true); + }); +}); + +// ── parseRetryAfterMs ───────────────────────────────────────────────────────── + +describe('parseRetryAfterMs()', () => { + it('parses integer seconds', () => { + expect(parseRetryAfterMs('5')).toBe(5000); + expect(parseRetryAfterMs('0')).toBe(0); + expect(parseRetryAfterMs('120')).toBe(120_000); + }); + + it('parses decimal seconds', () => { + expect(parseRetryAfterMs('1.5')).toBe(1500); + }); + + it('returns undefined for null', () => { + expect(parseRetryAfterMs(null)).toBeUndefined(); + }); + + it('returns undefined for non-numeric non-date string', () => { + expect(parseRetryAfterMs('invalid')).toBeUndefined(); + }); + + it('parses HTTP-date format', () => { + const futureDate = new Date(Date.now() + 3000).toUTCString(); + const ms = parseRetryAfterMs(futureDate); + expect(ms).toBeGreaterThan(0); + expect(ms!).toBeLessThanOrEqual(3000 + 100); // allow minor clock drift + }); +}); + +// ── RetryableError ──────────────────────────────────────────────────────────── + +describe('RetryableError', () => { + it('sets name, httpStatus and retryAfterMs', () => { + const err = new RetryableError('too many requests', 429, 2000); + expect(err.name).toBe('RetryableError'); + expect(err.httpStatus).toBe(429); + expect(err.retryAfterMs).toBe(2000); + expect(err.message).toBe('too many requests'); + }); + + it('instanceof Error', () => { + expect(new RetryableError('x', 503)).toBeInstanceOf(Error); + }); +}); diff --git a/sdks/javascript/src/__tests__/typedClient.test.ts b/sdks/javascript/src/__tests__/typedClient.test.ts new file mode 100644 index 00000000..7aa46b4b --- /dev/null +++ b/sdks/javascript/src/__tests__/typedClient.test.ts @@ -0,0 +1,313 @@ +/** + * Tests for TypedSubTrackrClient — typedClient.ts + */ + +import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { TypedSubTrackrClient } from '../typedClient'; +import { ApiError, AuthenticationError } from '../errors'; +import type { Plan, Subscription } from '../types'; + +// ── Mock fetch helper ───────────────────────────────────────────────────────── + +type FetchMockResponse = { + ok: boolean; + status: number; + headers?: Record; + body?: unknown; + text?: string; +}; + +function makeFetch(responses: FetchMockResponse[]) { + let idx = 0; + return jest.fn(async () => { + const r = responses[idx] ?? responses[responses.length - 1]!; + idx++; + + const headerMap = new Map(Object.entries(r.headers ?? {})); + return { + ok: r.ok, + status: r.status, + headers: { + get: (name: string) => headerMap.get(name) ?? null, + }, + text: async () => r.text ?? (r.body !== undefined ? JSON.stringify(r.body) : ''), + json: async () => r.body, + } as Response; + }); +} + +/** Envelope-wrapped success response. */ +function envelope(data: T, requestId = 'req-1') { + return { + ok: true, + status: 200, + headers: { 'x-api-version': '1' }, + body: { + success: true, + data, + meta: { timestamp: new Date().toISOString(), requestId, apiVersion: 1 }, + }, + }; +} + +/** Auth manager always returns 'token' */ +function mockOptions(fetchImpl: typeof fetch) { + return { + apiKey: 'sk_test', + baseUrl: 'https://api.example.com', + fetchImpl, + retry: { maxAttempts: 1 }, // disable retries unless explicitly testing + }; +} + +// ── Core request / envelope parsing ────────────────────────────────────────── + +describe('TypedSubTrackrClient core', () => { + it('makes a GET request and unwraps envelope', async () => { + const subs: Subscription[] = [{ id: 1, status: 'Active' }]; + const fetchImpl = makeFetch([envelope(subs)]); + + const client = new TypedSubTrackrClient(mockOptions(fetchImpl as unknown as typeof fetch)); + const result = await client.getSubscriptions(); + + expect(result).toEqual(subs); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url] = fetchImpl.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/v1/subscriptions'); + }); + + it('attaches X-Request-ID and X-API-Version headers', async () => { + const fetchImpl = makeFetch([envelope([])]); + const client = new TypedSubTrackrClient(mockOptions(fetchImpl as unknown as typeof fetch)); + await client.getSubscriptions(); + + const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['X-Request-ID']).toBeDefined(); + expect(headers['X-API-Version']).toBe('1'); + }); + + it('falls back to legacy (non-envelope) response', async () => { + const rawData = [{ id: 1, status: 'Active' }]; + const fetchImpl = makeFetch([{ + ok: true, + status: 200, + headers: {}, // no x-api-version header + body: rawData, + }]); + + const client = new TypedSubTrackrClient(mockOptions(fetchImpl as unknown as typeof fetch)); + const result = await client.getSubscriptions(); + expect(result).toEqual(rawData); + }); + + it('throws ApiError on non-retryable non-ok response', async () => { + const fetchImpl = makeFetch([{ + ok: false, + status: 404, + headers: { 'x-api-version': '1' }, + body: { error: { code: 'NOT_FOUND', message: 'Subscription not found' } }, + }]); + + const client = new TypedSubTrackrClient(mockOptions(fetchImpl as unknown as typeof fetch)); + await expect(client.getSubscription({ subscription_id: 999 })).rejects.toBeInstanceOf(ApiError); + }); + + it('handles empty body (void endpoints)', async () => { + const fetchImpl = makeFetch([{ ok: true, status: 204, headers: {}, text: '' }]); + const client = new TypedSubTrackrClient(mockOptions(fetchImpl as unknown as typeof fetch)); + await expect(client.cancelSubscription({ subscription_id: 1, subscriber: 'GABC' })).resolves.toBeUndefined(); + }); +}); + +// ── Retry behaviour ─────────────────────────────────────────────────────────── + +describe('TypedSubTrackrClient retry', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('retries on 429 and succeeds on second attempt', async () => { + const subs: Subscription[] = [{ id: 2, status: 'Active' }]; + const fetchImpl = makeFetch([ + { ok: false, status: 429, headers: { 'retry-after': '0' }, body: { error: { code: 'RATE_LIMIT_EXCEEDED', message: 'slow down' } } }, + envelope(subs), + ]); + + const client = new TypedSubTrackrClient({ + ...mockOptions(fetchImpl as unknown as typeof fetch), + retry: { maxAttempts: 3, initialDelayMs: 100, jitter: false }, + }); + + const promise = client.getSubscriptions(); + await jest.advanceTimersByTimeAsync(100); + const result = await promise; + + expect(result).toEqual(subs); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('does not retry POST 500 (non-retryable method + status)', async () => { + const fetchImpl = makeFetch([ + { ok: false, status: 500, headers: { 'x-api-version': '1' }, body: { error: { code: 'INTERNAL_SERVER_ERROR', message: 'crash' } } }, + ]); + + const client = new TypedSubTrackrClient({ + ...mockOptions(fetchImpl as unknown as typeof fetch), + retry: { maxAttempts: 3, initialDelayMs: 10, jitter: false }, + }); + + await expect(client.createSubscription({ name: 'Test' } as any)).rejects.toBeInstanceOf(ApiError); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('retries on network error for GET', async () => { + let calls = 0; + const fetchImpl = jest.fn(async () => { + calls++; + if (calls === 1) throw new Error('Network error'); + return { + ok: true, + status: 200, + headers: { get: (_: string) => null }, + text: async () => JSON.stringify([{ id: 3, status: 'Active' }]), + } as unknown as Response; + }); + + const client = new TypedSubTrackrClient({ + ...mockOptions(fetchImpl as unknown as typeof fetch), + retry: { maxAttempts: 3, initialDelayMs: 10, jitter: false }, + }); + + const promise = client.getSubscriptions(); + await jest.advanceTimersByTimeAsync(10); + const result = await promise; + expect(result).toBeDefined(); + expect(calls).toBe(2); + }); + + it('throws after exhausting all attempts', async () => { + const fetchImpl = makeFetch([ + { ok: false, status: 503, headers: {}, body: { error: { code: 'SERVICE_UNAVAILABLE', message: 'down' } } }, + { ok: false, status: 503, headers: {}, body: { error: { code: 'SERVICE_UNAVAILABLE', message: 'down' } } }, + { ok: false, status: 503, headers: {}, body: { error: { code: 'SERVICE_UNAVAILABLE', message: 'down' } } }, + ]); + + const client = new TypedSubTrackrClient({ + ...mockOptions(fetchImpl as unknown as typeof fetch), + retry: { maxAttempts: 3, initialDelayMs: 10, jitter: false }, + }); + + const promise = client.getSubscriptions(); + await jest.advanceTimersByTimeAsync(10); + await jest.advanceTimersByTimeAsync(20); + await expect(promise).rejects.toBeDefined(); + }); +}); + +// ── Idempotency keys ────────────────────────────────────────────────────────── + +describe('idempotency keys', () => { + it('attaches Idempotency-Key on subscribe()', async () => { + const fetchImpl = makeFetch([envelope(1)]); + const client = new TypedSubTrackrClient(mockOptions(fetchImpl as unknown as typeof fetch)); + + await client.subscribe({ subscriber: 'GABC', plan_id: 1 }); + + const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['Idempotency-Key']).toBeDefined(); + }); + + it('uses provided idempotencyKey option', async () => { + const fetchImpl = makeFetch([envelope(1)]); + const client = new TypedSubTrackrClient(mockOptions(fetchImpl as unknown as typeof fetch)); + + await client.subscribe({ subscriber: 'GABC', plan_id: 1 }, { idempotencyKey: 'my-key-123' }); + + const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['Idempotency-Key']).toBe('my-key-123'); + }); +}); + +// ── Metrics ─────────────────────────────────────────────────────────────────── + +describe('getMetrics()', () => { + it('tracks total and successful requests', async () => { + const fetchImpl = makeFetch([envelope([]), envelope([])]); + const client = new TypedSubTrackrClient(mockOptions(fetchImpl as unknown as typeof fetch)); + + await client.getSubscriptions(); + await client.getWebhooks(); + + const m = client.getMetrics(); + expect(m.totalRequests).toBe(2); + expect(m.successfulRequests).toBe(2); + expect(m.failedRequests).toBe(0); + }); + + it('tracks failed requests', async () => { + const fetchImpl = makeFetch([{ + ok: false, + status: 400, + headers: {}, + body: { error: { code: 'BAD_REQUEST', message: 'bad' } }, + }]); + + const client = new TypedSubTrackrClient(mockOptions(fetchImpl as unknown as typeof fetch)); + await client.getSubscriptions().catch(() => {}); + + expect(client.getMetrics().failedRequests).toBe(1); + }); + + it('resetMetrics() zeroes all counters', async () => { + const fetchImpl = makeFetch([envelope([])]); + const client = new TypedSubTrackrClient(mockOptions(fetchImpl as unknown as typeof fetch)); + await client.getSubscriptions(); + + client.resetMetrics(); + expect(client.getMetrics().totalRequests).toBe(0); + }); +}); + +// ── Pagination ──────────────────────────────────────────────────────────────── + +describe('paginate()', () => { + it('iterates through pages until hasMore is false', async () => { + const page1 = { + ok: true, + status: 200, + headers: { 'x-api-version': '1' }, + body: { + success: true, + data: [{ id: 1, status: 'Active' }], + meta: { timestamp: '', requestId: 'r1', apiVersion: 1, pagination: { hasMore: true, cursor: 'c1' } }, + }, + }; + const page2 = { + ok: true, + status: 200, + headers: { 'x-api-version': '1' }, + body: { + success: true, + data: [{ id: 2, status: 'Active' }], + meta: { timestamp: '', requestId: 'r2', apiVersion: 1, pagination: { hasMore: false } }, + }, + }; + + const fetchImpl = makeFetch([page1, page2]); + const client = new TypedSubTrackrClient(mockOptions(fetchImpl as unknown as typeof fetch)); + + const pages: Subscription[][] = []; + for await (const page of client.paginate('/v1/subscriptions')) { + pages.push(page.data); + } + + expect(pages).toHaveLength(2); + expect(pages[0]).toHaveLength(1); + expect(pages[1]).toHaveLength(1); + // Cursor forwarded on second call + const [url2] = fetchImpl.mock.calls[1] as [string]; + expect(url2).toContain('cursor=c1'); + }); +}); diff --git a/sdks/javascript/src/index.ts b/sdks/javascript/src/index.ts index 8fd5b4ad..40cb352b 100644 --- a/sdks/javascript/src/index.ts +++ b/sdks/javascript/src/index.ts @@ -1,53 +1,15 @@ -export interface SubTrackrClientConfig { - baseUrl?: string; - apiKey?: string; - token?: string; -} - -export class SubTrackrClient { - private baseUrl: string; - private apiKey?: string; - private token?: string; - - constructor(config: SubTrackrClientConfig = {}) { - this.baseUrl = config.baseUrl || 'https://api.subtrackr.io/v1'; - this.apiKey = config.apiKey; - this.token = config.token; - } - - private getHeaders(): Record { - const headers: Record = { - 'Content-Type': 'application/json', - }; - if (this.token) { - headers['Authorization'] = `Bearer ${this.token}`; - } - if (this.apiKey) { - headers['X-API-Key'] = this.apiKey; - } - return headers; - } - - async getSubscriptions(): Promise { - const res = await fetch(`${this.baseUrl}/subscriptions`, { - method: 'GET', - headers: this.getHeaders(), - }); - if (!res.ok) { - throw new Error(`SubTrackr API Error: ${res.statusText}`); - } - return res.json(); - } - - async createSubscription(data: Record): Promise { - const res = await fetch(`${this.baseUrl}/subscriptions`, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify(data), - }); - if (!res.ok) { - throw new Error(`SubTrackr API Error: ${res.statusText}`); - } - return res.json(); - } -} +export { SubTrackrClient } from './client'; +export type { SDKOptions, Plan, Subscription, Webhook, BillingInterval, SubscriptionStatus } from './types'; +export { ApiError, AuthenticationError, SubTrackrError } from './errors'; +export { TypedSubTrackrClient } from './typedClient'; +export type { + TypedClientOptions, + RequestOptions, + ApiSuccessEnvelope, + ApiErrorEnvelope, + ApiEnvelope, + PaginationMeta, + ClientMetrics, +} from './typedClient'; +export { withRetry, RetryableError, isRetryableStatus, parseRetryAfterMs } from './retry'; +export type { RetryOptions, RetryResult } from './retry'; diff --git a/sdks/javascript/src/retry.ts b/sdks/javascript/src/retry.ts new file mode 100644 index 00000000..84ef5154 --- /dev/null +++ b/sdks/javascript/src/retry.ts @@ -0,0 +1,126 @@ +/** + * Retry policy with exponential back-off + jitter. + * + * Retryable conditions (RFC 7231 / idempotency-aware): + * - Network errors (fetch threw) + * - 429 Too Many Requests (respects Retry-After header) + * - 503 Service Unavailable + * - 502 / 504 Gateway errors + * - 408 Request Timeout + */ + +export interface RetryOptions { + /** Maximum number of attempts (including the first). Default: 3 */ + maxAttempts?: number; + /** Initial delay in ms before the first retry. Default: 200 */ + initialDelayMs?: number; + /** Multiplier applied to delay on each subsequent retry. Default: 2 */ + backoffMultiplier?: number; + /** Maximum delay cap in ms. Default: 10 000 */ + maxDelayMs?: number; + /** Add ±20% random jitter to prevent thundering herd. Default: true */ + jitter?: boolean; + /** HTTP status codes that should be retried. Default: [408, 429, 502, 503, 504] */ + retryableStatuses?: number[]; + /** HTTP methods that may be retried. Default: ['GET', 'HEAD', 'OPTIONS', 'DELETE'] */ + retryableMethods?: string[]; + /** Invoked on each failed attempt before waiting. */ + onRetry?: (attempt: number, delayMs: number, reason: string) => void; +} + +const DEFAULT_RETRYABLE_STATUSES = [408, 429, 502, 503, 504]; +const DEFAULT_RETRYABLE_METHODS = ['GET', 'HEAD', 'OPTIONS', 'DELETE']; + +export interface RetryResult { + value: T; + attempts: number; + totalDelayMs: number; +} + +export async function withRetry( + fn: (attempt: number) => Promise, + options: RetryOptions = {}, +): Promise> { + const maxAttempts = options.maxAttempts ?? 3; + const initialDelayMs = options.initialDelayMs ?? 200; + const backoffMultiplier = options.backoffMultiplier ?? 2; + const maxDelayMs = options.maxDelayMs ?? 10_000; + const jitter = options.jitter !== false; + const onRetry = options.onRetry; + + let attempt = 0; + let totalDelay = 0; + + while (true) { + attempt += 1; + try { + const value = await fn(attempt); + return { value, attempts: attempt, totalDelayMs: totalDelay }; + } catch (err) { + if (attempt >= maxAttempts) throw err; + + const reason = err instanceof Error ? err.message : String(err); + let delayMs = Math.min(initialDelayMs * Math.pow(backoffMultiplier, attempt - 1), maxDelayMs); + + // Respect Retry-After header when available on RetryableError + if (err instanceof RetryableError && err.retryAfterMs != null) { + delayMs = Math.min(err.retryAfterMs, maxDelayMs); + } + + if (jitter) { + const jitterFactor = 0.8 + Math.random() * 0.4; // ±20% + delayMs = Math.round(delayMs * jitterFactor); + } + + onRetry?.(attempt, delayMs, reason); + totalDelay += delayMs; + + await sleep(delayMs); + } + } +} + +/** Thrown by the typed client to signal a retryable failure. */ +export class RetryableError extends Error { + readonly httpStatus: number; + readonly retryAfterMs?: number; + + constructor(message: string, httpStatus: number, retryAfterMs?: number) { + super(message); + this.name = 'RetryableError'; + this.httpStatus = httpStatus; + this.retryAfterMs = retryAfterMs; + } +} + +/** Determine if an HTTP status should be retried. */ +export function isRetryableStatus( + status: number, + method: string, + retryableStatuses = DEFAULT_RETRYABLE_STATUSES, + retryableMethods = DEFAULT_RETRYABLE_METHODS, +): boolean { + const methodUpper = method.toUpperCase(); + // Never retry mutating methods unless the status is 429/503 (safe to retry) + if (!retryableMethods.includes(methodUpper) && ![429, 503].includes(status)) { + return false; + } + return retryableStatuses.includes(status); +} + +/** Parse Retry-After header (seconds or HTTP-date) into milliseconds. */ +export function parseRetryAfterMs(header: string | null): number | undefined { + if (!header) return undefined; + const seconds = Number(header); + if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000); + // HTTP-date format + const date = new Date(header); + if (!isNaN(date.getTime())) { + return Math.max(0, date.getTime() - Date.now()); + } + return undefined; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/sdks/javascript/src/typedClient.ts b/sdks/javascript/src/typedClient.ts new file mode 100644 index 00000000..be92eac7 --- /dev/null +++ b/sdks/javascript/src/typedClient.ts @@ -0,0 +1,500 @@ +/** + * SubTrackr Typed API Client with automatic retry. + * + * Features: + * - Fully-typed request/response signatures for every endpoint + * - Automatic retry with exponential back-off + jitter on transient failures + * - Respects Retry-After header on 429 responses + * - Injects X-Request-ID and X-API-Version on every request + * - Standard ApiResponse envelope parsing (with legacy fallback) + * - Idempotency-Key header for POST /payments mutations + * - Cursor-based pagination helper + * - Configurable timeout with AbortController + * + * Usage: + * const client = new TypedSubTrackrClient({ apiKey: 'sk_live_...', baseUrl: 'https://api.subtrackr.io' }); + * const { data } = await client.getSubscription({ subscription_id: 42 }); + */ + +import { + SDKOptions, + Plan, + Subscription, + Webhook, + CreatePlanRequest, + InitializeRequest, + PlanIdRequest, + SubscriberRequest, + SubscriptionIdRequest, + SubscriberSubscriptionRequest, + RequestRefundRequest, + BillingInterval, + SubscriptionStatus, +} from './types'; +import { ApiError, AuthenticationError, SubTrackrError } from './errors'; +import { AuthManager } from './auth'; +import { + withRetry, + RetryableError, + isRetryableStatus, + parseRetryAfterMs, + type RetryOptions, +} from './retry'; + +// ─── Response envelope ──────────────────────────────────────────────────────── + +export interface ApiSuccessEnvelope { + success: true; + data: T; + meta: { + timestamp: string; + requestId: string; + apiVersion: number; + pagination?: PaginationMeta; + }; +} + +export interface ApiErrorEnvelope { + success: false; + error: { + code: string; + message: string; + details?: Record; + }; + meta: { + timestamp: string; + requestId: string; + apiVersion: number; + }; +} + +export type ApiEnvelope = ApiSuccessEnvelope | ApiErrorEnvelope; + +export interface PaginationMeta { + cursor?: string; + hasMore: boolean; + total?: number; +} + +// ─── Client options ─────────────────────────────────────────────────────────── + +export interface TypedClientOptions extends SDKOptions { + /** + * Override the base URL (default: production or sandbox depending on `environment`). + */ + baseUrl?: string; + /** + * Request timeout in ms. Default: 30 000. + * Set to 0 to disable. + */ + timeoutMs?: number; + /** + * Retry policy applied to transient failures. Set `maxAttempts: 1` to disable. + */ + retry?: RetryOptions; + /** + * Custom fetch implementation (useful for SSR / testing). + */ + fetchImpl?: typeof fetch; + /** + * Extra headers merged into every request. + */ + defaultHeaders?: Record; +} + +// ─── Request options ────────────────────────────────────────────────────────── + +export interface RequestOptions { + /** Override per-request timeout in ms. */ + timeoutMs?: number; + /** Override per-request retry policy. */ + retry?: RetryOptions; + /** Extra headers for this request only. */ + headers?: Record; + /** Idempotency key (for payment mutations). Auto-generated if omitted. */ + idempotencyKey?: string; +} + +// ─── Metrics ────────────────────────────────────────────────────────────────── + +export interface ClientMetrics { + totalRequests: number; + successfulRequests: number; + failedRequests: number; + totalRetries: number; + totalTimeMs: number; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const API_VERSION_HEADER = 'X-API-Version'; +const REQUEST_ID_HEADER = 'X-Request-ID'; +const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key'; + +function generateRequestId(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + return Math.random().toString(36).slice(2) + Date.now().toString(36); +} + +// ─── Client ─────────────────────────────────────────────────────────────────── + +export class TypedSubTrackrClient { + private readonly baseUrl: string; + private readonly authManager: AuthManager; + private readonly timeoutMs: number; + private readonly retryOptions: RetryOptions; + private readonly fetchImpl: typeof fetch; + private readonly defaultHeaders: Record; + + private metrics: ClientMetrics = { + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + totalRetries: 0, + totalTimeMs: 0, + }; + + constructor(options: TypedClientOptions) { + this.authManager = new AuthManager(options); + this.baseUrl = ( + options.baseUrl ?? + (options.environment === 'sandbox' + ? 'https://sandbox.api.subtrackr.app' + : 'https://api.subtrackr.app') + ).replace(/\/$/, ''); + this.timeoutMs = options.timeoutMs ?? 30_000; + this.retryOptions = options.retry ?? { maxAttempts: 3 }; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + this.defaultHeaders = options.defaultHeaders ?? {}; + } + + // ── Core request ──────────────────────────────────────────────────────────── + + private async request( + method: string, + path: string, + body?: unknown, + options: RequestOptions = {}, + ): Promise> { + const retryOpts: RetryOptions = { + ...this.retryOptions, + ...options.retry, + onRetry: (attempt, delayMs, reason) => { + this.metrics.totalRetries += 1; + options.retry?.onRetry?.(attempt, delayMs, reason); + }, + }; + + const start = Date.now(); + this.metrics.totalRequests += 1; + + try { + const result = await withRetry>(async (attempt) => { + const requestId = generateRequestId(); + const token = await this.authManager.getToken().catch(() => { + throw new AuthenticationError('Failed to acquire authentication token'); + }); + + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${token}`, + [REQUEST_ID_HEADER]: requestId, + [API_VERSION_HEADER]: '1', + ...this.defaultHeaders, + ...options.headers, + }; + + if (options.idempotencyKey) { + headers[IDEMPOTENCY_KEY_HEADER] = options.idempotencyKey; + } + + const timeoutMs = options.timeoutMs ?? this.timeoutMs; + let abortController: AbortController | undefined; + let timeoutHandle: ReturnType | undefined; + + if (timeoutMs > 0) { + abortController = new AbortController(); + timeoutHandle = setTimeout(() => abortController!.abort(), timeoutMs); + } + + let response: Response; + try { + response = await this.fetchImpl(`${this.baseUrl}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: abortController?.signal, + }); + } catch (err: unknown) { + clearTimeout(timeoutHandle); + const isAbort = + err instanceof Error && + (err.name === 'AbortError' || err.message.includes('abort')); + if (isAbort) { + throw new RetryableError(`Request timeout after ${timeoutMs}ms`, 408); + } + // Network errors are retryable for idempotent methods + if (isRetryableStatus(0, method)) { + throw new RetryableError(`Network error: ${String(err)}`, 0); + } + throw new SubTrackrError(`Network error: ${String(err)}`); + } finally { + clearTimeout(timeoutHandle); + } + + // Handle retryable HTTP status codes + if (!response.ok && isRetryableStatus(response.status, method)) { + const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After')); + throw new RetryableError( + `HTTP ${response.status}`, + response.status, + retryAfterMs, + ); + } + + if (!response.ok) { + const errBody = await response.json().catch(() => ({})); + const envelope = errBody as Partial; + throw new ApiError( + envelope?.error?.message ?? response.statusText, + response.status, + envelope?.error?.code, + ); + } + + const text = await response.text(); + if (!text) { + return { + success: true, + data: undefined as unknown as T, + meta: { timestamp: new Date().toISOString(), requestId, apiVersion: 1 }, + }; + } + + const json: unknown = JSON.parse(text); + + // Detect whether the server returns the standard envelope + const hasEnvelope = response.headers.get(API_VERSION_HEADER) !== null; + + if (!hasEnvelope) { + // Legacy endpoint — wrap raw body + return { + success: true, + data: json as T, + meta: { timestamp: new Date().toISOString(), requestId, apiVersion: 0 }, + }; + } + + const envelope = json as ApiEnvelope; + if (!envelope.success) { + const errEnv = envelope as ApiErrorEnvelope; + throw new ApiError( + errEnv.error.message, + response.status, + errEnv.error.code, + ); + } + + return envelope as ApiSuccessEnvelope; + }, retryOpts); + + this.metrics.successfulRequests += 1; + this.metrics.totalTimeMs += Date.now() - start; + return result.value; + } catch (err) { + this.metrics.failedRequests += 1; + this.metrics.totalTimeMs += Date.now() - start; + throw err; + } + } + + // ── Cursor-based pagination ────────────────────────────────────────────────── + + async *paginate( + path: string, + params: Record = {}, + requestOptions: RequestOptions = {}, + ): AsyncGenerator> { + let cursor: string | undefined; + let hasMore = true; + + while (hasMore) { + const query = new URLSearchParams(params); + if (cursor) query.set('cursor', cursor); + const fullPath = query.size ? `${path}?${query}` : path; + + const page = await this.request('GET', fullPath, undefined, requestOptions); + yield page; + + hasMore = page.meta.pagination?.hasMore ?? false; + cursor = page.meta.pagination?.cursor; + } + } + + // ── Metrics ────────────────────────────────────────────────────────────────── + + getMetrics(): Readonly { + return { ...this.metrics }; + } + + resetMetrics(): void { + this.metrics = { + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + totalRetries: 0, + totalTimeMs: 0, + }; + } + + // ── Contract / Plan APIs ───────────────────────────────────────────────────── + + async initialize(data: InitializeRequest, opts?: RequestOptions): Promise { + await this.request('POST', '/initialize', data, opts); + } + + async createPlan(data: CreatePlanRequest, opts?: RequestOptions): Promise { + const res = await this.request('POST', '/create_plan', data, opts); + return res.data; + } + + async deactivatePlan( + data: PlanIdRequest & { merchant: string }, + opts?: RequestOptions, + ): Promise { + await this.request('POST', '/deactivate_plan', data, opts); + } + + async getPlan(data: PlanIdRequest, opts?: RequestOptions): Promise { + const res = await this.request('POST', '/get_plan', data, opts); + return res.data; + } + + async getPlanCount(opts?: RequestOptions): Promise { + const res = await this.request('POST', '/get_plan_count', undefined, opts); + return res.data; + } + + async getMerchantPlans(data: { merchant: string }, opts?: RequestOptions): Promise { + const res = await this.request('POST', '/get_merchant_plans', data, opts); + return res.data; + } + + // ── Subscription APIs ──────────────────────────────────────────────────────── + + async subscribe( + data: { subscriber: string; plan_id: number }, + opts?: RequestOptions, + ): Promise { + const res = await this.request('POST', '/subscribe', data, { + ...opts, + idempotencyKey: opts?.idempotencyKey ?? generateRequestId(), + }); + return res.data; + } + + async cancelSubscription( + data: SubscriberSubscriptionRequest, + opts?: RequestOptions, + ): Promise { + await this.request('POST', '/cancel_subscription', data, opts); + } + + async pauseSubscription( + data: SubscriberSubscriptionRequest, + opts?: RequestOptions, + ): Promise { + await this.request('POST', '/pause_subscription', data, opts); + } + + async resumeSubscription( + data: SubscriberSubscriptionRequest, + opts?: RequestOptions, + ): Promise { + await this.request('POST', '/resume_subscription', data, opts); + } + + async chargeSubscription( + data: SubscriptionIdRequest, + opts?: RequestOptions, + ): Promise { + await this.request('POST', '/charge_subscription', data, { + ...opts, + idempotencyKey: opts?.idempotencyKey ?? generateRequestId(), + }); + } + + async getSubscription( + data: SubscriptionIdRequest, + opts?: RequestOptions, + ): Promise { + const res = await this.request('POST', '/get_subscription', data, opts); + return res.data; + } + + async getSubscriptionCount(opts?: RequestOptions): Promise { + const res = await this.request('POST', '/get_subscription_count', undefined, opts); + return res.data; + } + + async getUserSubscriptions( + data: SubscriberRequest, + opts?: RequestOptions, + ): Promise { + const res = await this.request('POST', '/get_user_subscriptions', data, opts); + return res.data; + } + + // ── REST Subscription APIs ─────────────────────────────────────────────────── + + async getSubscriptions(opts?: RequestOptions): Promise { + const res = await this.request('GET', '/v1/subscriptions', undefined, opts); + return res.data; + } + + async createSubscription( + data: Omit, + opts?: RequestOptions, + ): Promise { + const res = await this.request('POST', '/v1/subscriptions', data, { + ...opts, + idempotencyKey: opts?.idempotencyKey ?? generateRequestId(), + }); + return res.data; + } + + // ── Refund APIs ────────────────────────────────────────────────────────────── + + async requestRefund(data: RequestRefundRequest, opts?: RequestOptions): Promise { + await this.request('POST', '/request_refund', data, { + ...opts, + idempotencyKey: opts?.idempotencyKey ?? generateRequestId(), + }); + } + + async approveRefund(data: SubscriptionIdRequest, opts?: RequestOptions): Promise { + await this.request('POST', '/approve_refund', data, opts); + } + + async rejectRefund(data: SubscriptionIdRequest, opts?: RequestOptions): Promise { + await this.request('POST', '/reject_refund', data, opts); + } + + // ── Webhook APIs ───────────────────────────────────────────────────────────── + + async getWebhooks(opts?: RequestOptions): Promise { + const res = await this.request('GET', '/v1/webhooks', undefined, opts); + return res.data; + } + + async createWebhook( + data: Omit, + opts?: RequestOptions, + ): Promise { + const res = await this.request('POST', '/v1/webhooks', data, opts); + return res.data; + } +} diff --git a/sdks/javascript/tsconfig.json b/sdks/javascript/tsconfig.json index 93201935..f6c0413b 100644 --- a/sdks/javascript/tsconfig.json +++ b/sdks/javascript/tsconfig.json @@ -7,7 +7,8 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "types": ["jest", "node"] }, "include": ["src/**/*"] } diff --git a/sdks/javascript/tsconfig.test.json b/sdks/javascript/tsconfig.test.json new file mode 100644 index 00000000..10d21391 --- /dev/null +++ b/sdks/javascript/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["jest", "node"], + "strict": false + }, + "include": ["src/**/*"] +}