From 48a2f3760ff70898fa10fee687ca4199b4961eb2 Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Wed, 23 Sep 2026 23:33:05 +0100 Subject: [PATCH] test: add chaos fixtures benchmarks and database pooling --- .github/workflows/benchmarks.yml | 8 +++- backend/package.json | 1 + backend/src/config/database-pool.test.ts | 22 ++++++++++ backend/src/config/database.ts | 15 +++++++ backend/src/lib/prisma.ts | 4 +- .../src/tests/chaos/fault-injection.test.ts | 43 +++++++++++++++++++ backend/src/tests/factories/index.test.ts | 18 ++++++++ backend/src/tests/factories/index.ts | 43 +++++++++++++++++++ package-lock.json | 11 +++++ 9 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 backend/src/config/database-pool.test.ts create mode 100644 backend/src/tests/chaos/fault-injection.test.ts create mode 100644 backend/src/tests/factories/index.test.ts create mode 100644 backend/src/tests/factories/index.ts diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 55217b53..c6cdca80 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -1,6 +1,12 @@ name: API Benchmarks -on: workflow_dispatch +on: + workflow_dispatch: + pull_request: + branches: [main] + paths: + - 'backend/**' + - '.github/workflows/benchmarks.yml' concurrency: group: benchmarks-${{ github.ref }} diff --git a/backend/package.json b/backend/package.json index 096ada7e..67497192 100644 --- a/backend/package.json +++ b/backend/package.json @@ -82,6 +82,7 @@ }, "devDependencies": { "@eslint/js": "^9.0.0", + "@faker-js/faker": "^9.9.0", "@openapitools/openapi-generator-cli": "^2.40.1", "@tsoa/runtime": "^7.0.0-alpha.0", "@types/circomlibjs": "^0.1.6", diff --git a/backend/src/config/database-pool.test.ts b/backend/src/config/database-pool.test.ts new file mode 100644 index 00000000..3e9d52ca --- /dev/null +++ b/backend/src/config/database-pool.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { applyPrismaPoolConfig } from './database.js'; + +describe('applyPrismaPoolConfig', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('maps configured pool limits and timeouts to Prisma URL options', () => { + vi.stubEnv('DB_POOL_MAX', '32'); + vi.stubEnv('DB_ACQUIRE_TIMEOUT_MS', '4500'); + vi.stubEnv('DB_CREATE_TIMEOUT_MS', '2500'); + + const result = new URL(applyPrismaPoolConfig('postgresql://db/app', 'production')!); + + expect(result.searchParams.get('connection_limit')).toBe('32'); + expect(result.searchParams.get('pool_timeout')).toBe('5'); + expect(result.searchParams.get('connect_timeout')).toBe('3'); + }); + + it('leaves database configuration absent when no URL is configured', () => { + expect(applyPrismaPoolConfig(undefined)).toBeUndefined(); + }); +}); diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts index 9b07c350..f73f8199 100644 --- a/backend/src/config/database.ts +++ b/backend/src/config/database.ts @@ -55,6 +55,21 @@ export function buildPoolConfig(env = process.env.NODE_ENV): PoolConfig { } } +/** Apply the supported Prisma PostgreSQL pool settings to a connection URL. */ +export function applyPrismaPoolConfig( + databaseUrl: string | undefined, + env = process.env.NODE_ENV, +): string | undefined { + if (!databaseUrl) return undefined; + + const url = new URL(databaseUrl); + const pool = buildPoolConfig(env); + url.searchParams.set('connection_limit', String(pool.max)); + url.searchParams.set('pool_timeout', String(Math.max(1, Math.ceil(pool.acquireTimeoutMs / 1000)))); + url.searchParams.set('connect_timeout', String(Math.max(1, Math.ceil(pool.createTimeoutMs / 1000)))); + return url.toString(); +} + // ── PgBouncer Integration ───────────────────────────────────────────────────── export interface PgBouncerConfig { diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts index 2cb9e9b2..97bbacde 100644 --- a/backend/src/lib/prisma.ts +++ b/backend/src/lib/prisma.ts @@ -2,16 +2,18 @@ // Single shared instance with query logging and slow-query detection. import { PrismaClient } from '@prisma/client'; -import { SLOW_QUERY_THRESHOLD_MS, VERY_SLOW_QUERY_THRESHOLD_MS } from '../config/database.js'; +import { applyPrismaPoolConfig, SLOW_QUERY_THRESHOLD_MS, VERY_SLOW_QUERY_THRESHOLD_MS } from '../config/database.js'; import { createPrismaQueryListener } from '../middleware/queryLogger.js'; import { withTenantIsolationGuard } from '../security/tenant-isolation/guard.js'; import { withEncryptionMiddleware } from '../encryption/index.js'; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; +const prismaDatabaseUrl = applyPrismaPoolConfig(process.env.DATABASE_URL); const basePrismaClient = globalForPrisma.prisma ?? new PrismaClient({ + ...(prismaDatabaseUrl ? { datasources: { db: { url: prismaDatabaseUrl } } } : {}), log: [ { emit: 'event', level: 'query' }, { emit: 'stdout', level: 'warn' }, diff --git a/backend/src/tests/chaos/fault-injection.test.ts b/backend/src/tests/chaos/fault-injection.test.ts new file mode 100644 index 00000000..398c0582 --- /dev/null +++ b/backend/src/tests/chaos/fault-injection.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { CircuitBreaker } from '../../services/circuitBreaker.js'; + +const breakerConfig = { + slidingWindowSize: 10, + minimumCallsToOpen: 3, + failureRateThreshold: 50, + failureThreshold: 3, + successThreshold: 1, + waitDurationInOpenState: 1000, + permittedNumberOfCallsInHalfOpenState: 1, + requestTimeoutMs: 0, + failClosed: false, +}; + +describe('dependency fault injection', () => { + it.each(['database', 'cache', 'stellar-rpc'])('%s outage uses fallback and stops repeated calls', async (dependency) => { + let now = 0; + let failuresRemaining = 3; + let dependencyCalls = 0; + const breaker = new CircuitBreaker(dependency, breakerConfig, () => now); + const query = async () => { + dependencyCalls++; + if (failuresRemaining > 0) { + failuresRemaining--; + throw new Error(`${dependency} unavailable`); + } + return 'primary'; + }; + const fallback = async () => 'degraded'; + + for (let i = 0; i < 3; i++) { + await expect(breaker.protect(query, fallback)).resolves.toBe('degraded'); + } + await expect(breaker.protect(query, fallback)).resolves.toBe('degraded'); + expect(dependencyCalls).toBe(3); + expect(breaker.snapshot().state).toBe('open'); + + now += 1001; + await expect(breaker.protect(query, fallback)).resolves.toBe('primary'); + expect(breaker.snapshot().state).toBe('closed'); + }); +}); diff --git a/backend/src/tests/factories/index.test.ts b/backend/src/tests/factories/index.test.ts new file mode 100644 index 00000000..500c7933 --- /dev/null +++ b/backend/src/tests/factories/index.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { makeInvoice, makePayment, makeProject, makeUser, seedTestData } from './index.js'; + +describe('test data factories', () => { + it('produces repeatable values from a fixed seed', () => { + seedTestData(42); + const first = makeUser(); + seedTestData(42); + expect(makeUser()).toEqual(first); + }); + + it('applies overrides and creates common Prisma input shapes', () => { + expect(makeUser({ email: 'test@example.com' }).email).toBe('test@example.com'); + expect(makeProject({ title: 'Fixture project' }).title).toBe('Fixture project'); + expect(makePayment({ currency: 'USDC' }).currency).toBe('USDC'); + expect(makeInvoice({ currency: 'EUR' }).currency).toBe('EUR'); + }); +}); diff --git a/backend/src/tests/factories/index.ts b/backend/src/tests/factories/index.ts new file mode 100644 index 00000000..64aaf8f5 --- /dev/null +++ b/backend/src/tests/factories/index.ts @@ -0,0 +1,43 @@ +import { faker } from '@faker-js/faker'; +import type { Prisma } from '@prisma/client'; + +export function seedTestData(seed: number): void { + faker.seed(seed); +} + +export function makeUser(overrides: Partial = {}): Prisma.UserUncheckedCreateInput { + return { + tenantId: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + ...overrides, + }; +} + +export function makeProject(overrides: Partial = {}): Prisma.ProjectUncheckedCreateInput { + return { + title: faker.commerce.productName(), + description: faker.lorem.sentence(), + totalAmount: faker.finance.amount({ min: 10, max: 10000, dec: 2 }), + clientAddress: faker.string.alphanumeric(56), + freelancerAddress: faker.string.alphanumeric(56), + tenantId: faker.string.uuid(), + ...overrides, + }; +} + +export function makePayment(overrides: Partial = {}): Prisma.PaymentUncheckedCreateInput { + return { + tenantId: faker.string.uuid(), + amount: faker.finance.amount({ min: 1, max: 5000, dec: 2 }), + ...overrides, + }; +} + +export function makeInvoice(overrides: Partial = {}): Prisma.InvoiceUncheckedCreateInput { + return { + projectId: faker.string.uuid(), + tenantId: faker.string.uuid(), + amount: faker.finance.amount({ min: 1, max: 5000, dec: 2 }), + ...overrides, + }; +} diff --git a/package-lock.json b/package-lock.json index f444004e..07673abe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "zod": "^3.25.76" }, "devDependencies": { + "@faker-js/faker": "^9.9.0", "@eslint/js": "^9.0.0", "@openapitools/openapi-generator-cli": "^2.40.1", "@stryker-mutator/core": "^9.4.0", @@ -2542,6 +2543,16 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@faker-js/faker": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz", + "integrity": "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + } + }, "node_modules/@eslint/js": { "version": "9.39.4", "dev": true,