Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
@@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions backend/src/config/database-pool.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
15 changes: 15 additions & 0 deletions backend/src/config/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion backend/src/lib/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
43 changes: 43 additions & 0 deletions backend/src/tests/chaos/fault-injection.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
18 changes: 18 additions & 0 deletions backend/src/tests/factories/index.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
43 changes: 43 additions & 0 deletions backend/src/tests/factories/index.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): Prisma.UserUncheckedCreateInput {
return {
tenantId: faker.string.uuid(),
email: faker.internet.email().toLowerCase(),
...overrides,
};
}

export function makeProject(overrides: Partial<Prisma.ProjectUncheckedCreateInput> = {}): 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> = {}): 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> = {}): Prisma.InvoiceUncheckedCreateInput {
return {
projectId: faker.string.uuid(),
tenantId: faker.string.uuid(),
amount: faker.finance.amount({ min: 1, max: 5000, dec: 2 }),
...overrides,
};
}
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading