Skip to content
Open
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
168 changes: 168 additions & 0 deletions backend/__tests__/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Backend server integration tests (no real Redis/Postgres required).
*/

import http from 'node:http';
import { startServer } from '../server';
import type { Pool } from '../shared/db/connectionPool';
import { PlanCacheService } from '../subscription/domain/PlanCacheService';
import { InMemoryPlanRepository } from '../subscription/domain/PlanRepository';
import type { PlanMetadata } from '../subscription/domain/types';
import type { RedisClient } from '../shared/cache/types';
import { setPlanCacheService } from '../subscription/planCacheRegistry';

class FakeRedis implements RedisClient {
private store = new Map<string, string>();

async get(key: string): Promise<string | null> {
return this.store.get(key) ?? null;
}

async set(key: string, value: string, _mode: 'EX', _ttl: number): Promise<'OK'> {
this.store.set(key, value);
return 'OK';
}

async del(...keys: string[]): Promise<number> {
let n = 0;
for (const k of keys) {
if (this.store.delete(k)) n++;
}
return n;
}

async keys(pattern: string): Promise<string[]> {
const prefix = pattern.replace(/\*$/, '');
return [...this.store.keys()].filter((k) => k.startsWith(prefix));
}

async ping(): Promise<string> {
return 'PONG';
}

async quit(): Promise<'OK'> {
return 'OK';
}
}

const seedPlan: PlanMetadata = {
id: 'plan-1',
name: 'Starter',
price: 9,
currency: 'USD',
billingCycle: 'monthly',
features: ['basic'],
limits: {},
isActive: true,
metadata: {},
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};

function makeMockPool(): Pool {
return {
query: jest.fn(async () => ({ rows: [], rowCount: 0 })),
connect: jest.fn(),
end: jest.fn(),
on: jest.fn(),
totalCount: 0,
idleCount: 0,
waitingCount: 0,
} as unknown as Pool;
}

function makeBootstrap(repository = new InMemoryPlanRepository([seedPlan])) {
const redis = new FakeRedis();
const planCache = new PlanCacheService(redis, repository);
return { planCache, redis, repository };
}

function request(
port: number,
path: string,
method = 'GET',
body?: unknown,
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const payload = body !== undefined ? JSON.stringify(body) : undefined;
const req = http.request(
{
host: '127.0.0.1',
port,
path,
method,
headers: payload
? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
: undefined,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
resolve({
status: res.statusCode ?? 0,
body: Buffer.concat(chunks).toString('utf8'),
});
});
},
);
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}

async function listenEphemeral(server: http.Server): Promise<number> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
return typeof address === 'object' && address ? address.port : 0;
}

describe('backend server', () => {
afterEach(() => {
setPlanCacheService(null);
});

it('serves health, plan REST, and plan cache metrics', async () => {
const pool = makeMockPool();
const planBootstrap = makeBootstrap();

const running = await startServer({ pool, planBootstrap, listen: false });
const port = await listenEphemeral(running.server);

const health = await request(port, '/health');
expect(health.status).toBe(200);
expect(JSON.parse(health.body).status).toBe('ok');

const plan = await request(port, '/plans/plan-1');
expect(plan.status).toBe(200);
expect(JSON.parse(plan.body).data.name).toBe('Starter');

const metrics = await request(port, '/metrics/plan-cache');
expect(metrics.status).toBe(200);
expect(metrics.body).toContain('subtrackr_plan_cache_hits_total');

await running.shutdown();
});

it('creates a plan via POST /plans with write-through cache', async () => {
const pool = makeMockPool();
const planBootstrap = makeBootstrap(new InMemoryPlanRepository());

const running = await startServer({ pool, planBootstrap, listen: false });
const port = await listenEphemeral(running.server);

const created = await request(port, '/plans', 'POST', {
name: 'Growth',
price: 25,
currency: 'USD',
billingCycle: 'monthly',
});

expect(created.status).toBe(201);
const parsed = JSON.parse(created.body);
expect(parsed.data.name).toBe('Growth');

await running.shutdown();
});
});
53 changes: 53 additions & 0 deletions backend/config/__tests__/redis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {
DEFAULT_REDIS_CONFIG,
loadRedisConfig,
redisConnectionUrl,
} from '../redis';

describe('redis config', () => {
it('loads defaults when env vars are unset', () => {
const config = loadRedisConfig({});
expect(config.host).toBe(DEFAULT_REDIS_CONFIG.host);
expect(config.port).toBe(6379);
expect(config.db).toBe(0);
expect(config.defaultTtlSeconds).toBe(3600);
});

it('reads custom env values', () => {
const config = loadRedisConfig({
REDIS_HOST: 'redis.internal',
REDIS_PORT: '6380',
REDIS_PASSWORD: 'secret',
REDIS_DB: '2',
REDIS_DEFAULT_TTL_SECONDS: '7200',
});
expect(config.host).toBe('redis.internal');
expect(config.port).toBe(6380);
expect(config.password).toBe('secret');
expect(config.db).toBe(2);
expect(config.defaultTtlSeconds).toBe(7200);
});

it('builds connection URL without password', () => {
expect(redisConnectionUrl({ ...DEFAULT_REDIS_CONFIG })).toBe(
'redis://localhost:6379/0',
);
});

it('builds connection URL with password', () => {
const url = redisConnectionUrl({
...DEFAULT_REDIS_CONFIG,
password: 'p@ss',
});
expect(url).toBe('redis://:p%40ss@localhost:6379/0');
});

it('falls back for invalid numeric env values', () => {
const config = loadRedisConfig({
REDIS_PORT: 'not-a-number',
REDIS_DB: '-5',
});
expect(config.port).toBe(DEFAULT_REDIS_CONFIG.port);
expect(config.db).toBe(0);
});
});
60 changes: 60 additions & 0 deletions backend/config/redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Redis connection configuration for distributed caching.
*
* Environment variables:
* REDIS_HOST – default: localhost
* REDIS_PORT – default: 6379
* REDIS_PASSWORD – optional
* REDIS_DB – default: 0
* REDIS_DEFAULT_TTL_SECONDS – default plan cache TTL: 3600 (1 hour)
*/

export interface RedisConfig {
host: string;
port: number;
password?: string;
db: number;
/** Default TTL for plan metadata entries in seconds. */
defaultTtlSeconds: number;
/** Connection timeout in milliseconds. */
connectTimeoutMs: number;
}

export const DEFAULT_REDIS_CONFIG: Readonly<RedisConfig> = {
host: 'localhost',
port: 6379,
db: 0,
defaultTtlSeconds: 3600,
connectTimeoutMs: 5_000,
};

function parsePositiveInt(value: string | undefined, fallback: number): number {
if (value === undefined || value === '') return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}

/** Load Redis configuration from environment variables. */
export function loadRedisConfig(env: NodeJS.ProcessEnv = process.env): RedisConfig {
const password = env.REDIS_PASSWORD?.trim();
return {
host: env.REDIS_HOST?.trim() || DEFAULT_REDIS_CONFIG.host,
port: parsePositiveInt(env.REDIS_PORT, DEFAULT_REDIS_CONFIG.port),
password: password || undefined,
db: parsePositiveInt(env.REDIS_DB, DEFAULT_REDIS_CONFIG.db),
defaultTtlSeconds: parsePositiveInt(
env.REDIS_DEFAULT_TTL_SECONDS,
DEFAULT_REDIS_CONFIG.defaultTtlSeconds,
),
connectTimeoutMs: parsePositiveInt(
env.REDIS_CONNECT_TIMEOUT_MS,
DEFAULT_REDIS_CONFIG.connectTimeoutMs,
),
};
}

/** Build a redis:// connection URL from config (password omitted when unset). */
export function redisConnectionUrl(config: RedisConfig = loadRedisConfig()): string {
const auth = config.password ? `:${encodeURIComponent(config.password)}@` : '';
return `redis://${auth}${config.host}:${config.port}/${config.db}`;
}
11 changes: 11 additions & 0 deletions backend/graphql/dataloaders/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
*/

import { Pool } from '../../shared/db/connectionPool';
import { getPlanCacheService } from '../../subscription/planCacheRegistry';
import { planMetadataToRow } from '../../subscription/domain/PostgresPlanRepository';

// ── Minimal DataLoader-compatible interface ───────────────────────────────────
// Install: npm i dataloader @types/dataloader
Expand Down Expand Up @@ -143,6 +145,15 @@ export async function createPlanLoader(pool: Pool): Promise<IDataLoader<string,
default: new <K, V>(fn: (keys: readonly K[]) => Promise<Array<V | Error | null>>) => IDataLoader<K, V>;
};

const planCache = getPlanCacheService();

if (planCache) {
return new DataLoader<string, PlanRow>(async (ids) => {
const plans = await Promise.all(ids.map((id) => planCache.getPlan(id)));
return plans.map((plan) => (plan ? planMetadataToRow(plan) : null));
});
}

return new DataLoader<string, PlanRow>(async (ids) => {
const result = await pool.query<PlanRow>(
`SELECT id, name, price, currency, billing_cycle AS "billingCycle"
Expand Down
19 changes: 19 additions & 0 deletions backend/graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import { Pool } from '../shared/db/connectionPool';
import { DataLoaderContext } from './dataloaders';
import { getPlanCacheService } from '../subscription/planCacheRegistry';
import { planMetadataToRow } from '../subscription/domain/PostgresPlanRepository';

// ── Cursor helpers ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -271,6 +273,23 @@ export const resolvers = {
) => {
const first = Math.min(args.first ?? 50, 100);
const decoded = args.after ? decodeCursor(args.after) : null;
const planCache = getPlanCacheService();

if (planCache) {
let rows = (await planCache.getActivePlans()).map(planMetadataToRow);
if (decoded) {
const idx = rows.findIndex((r) => r.id > decoded.id);
rows = idx >= 0 ? rows.slice(idx) : [];
}
const hasNextPage = rows.length > first;
const page = hasNextPage ? rows.slice(0, first) : rows;
const edges = page.map((row) => ({
cursor: encodeCursor(String(row.id), String(row.id)),
node: row,
}));
return { edges, pageInfo: buildPageInfo(edges, hasNextPage, decoded !== null) };
}

let whereSql = '';
const params: unknown[] = [];

Expand Down
11 changes: 11 additions & 0 deletions backend/migrations/003_plans_cache_columns.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Plan metadata columns for Redis cache layer (idempotent).
-- Run before using PostgresPlanRepository write paths.

ALTER TABLE plans ADD COLUMN IF NOT EXISTS features JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE plans ADD COLUMN IF NOT EXISTS limits JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE plans ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE plans ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE plans ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE plans ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();

CREATE INDEX IF NOT EXISTS idx_plans_is_active ON plans (is_active) WHERE is_active = true;
Loading
Loading