/** * Integration tests for CallsController * Uses Fastify inject() with an in-memory MarketCallRepository stub. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import Fastify from 'fastify'; import cors from '@fastify/cors'; import { CallsController } from '../server/controllers/calls.controller'; import type { ScreenerEngine } from '../server/services/ScreenerEngine'; import type { CalendarService } from '../server/services/CalendarService'; import type { MarketCall, ScreenerResult, MarketContext, CreateCallInput } from '../server/types'; // ── Stubs ──────────────────────────────────────────────────────────────────── const MARKET_CTX: MarketContext = { sp500Price: 5000, riskFreeRate: 4.5, vixLevel: 18, rateRegime: 'NORMAL', volatilityRegime: 'NORMAL', benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 }, }; const EMPTY_RESULT: ScreenerResult = { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: MARKET_CTX, }; const stubEngine = { screenTickers: async () => EMPTY_RESULT, } as unknown as ScreenerEngine; const stubCalendar = { getEvents: async () => ({ events: [], tickers: [] }), } as unknown as CalendarService; // In-memory MarketCallRepository stub function makeRepoStub() { const calls: (MarketCall & { createdAt: string })[] = []; return { list: () => [...calls].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()), get: (id: string) => calls.find((c) => c.id === id) ?? null, create: ({ title, quarter, date, thesis, tickers, snapshot, }: CreateCallInput & { snapshot: any }) => { const call = { id: `call-${calls.length + 1}`, title, quarter, date: date ?? new Date().toISOString().slice(0, 10), thesis, tickers, snapshot, createdAt: new Date().toISOString(), }; calls.push(call); return call; }, delete: (id: string) => { const idx = calls.findIndex((c) => c.id === id); if (idx === -1) return false; calls.splice(idx, 1); return true; }, }; } // ── App factory ────────────────────────────────────────────────────────────── async function buildTestApp() { const app = Fastify({ logger: false }); await app.register(cors, { origin: '*' }); new CallsController(makeRepoStub() as any, stubEngine, stubCalendar).register(app); await app.ready(); return app; } // ── Tests ──────────────────────────────────────────────────────────────────── test('GET /api/calls → 200 with empty calls list', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'GET', url: '/api/calls' }); assert.equal(res.statusCode, 200); assert.deepEqual(res.json(), { calls: [] }); }); test('POST /api/calls → 201 and returns the created call', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'POST', url: '/api/calls', payload: { title: 'Q3 rate pivot play', quarter: 'Q3 2025', thesis: 'Fed cuts incoming — rotate into duration and growth.', tickers: ['TLT', 'QQQ'], }, }); assert.equal(res.statusCode, 201); const body = res.json(); assert.equal(body.title, 'Q3 rate pivot play'); assert.deepEqual(body.tickers, ['TLT', 'QQQ']); assert.ok(body.id); assert.ok(body.createdAt); }); test('POST /api/calls → created call appears in GET /api/calls', async () => { const app = await buildTestApp(); await app.inject({ method: 'POST', url: '/api/calls', payload: { title: 'AI semiconductor cycle', quarter: 'Q4 2025', thesis: 'Capex cycle benefits chip designers more than hyperscalers.', tickers: ['NVDA', 'AMD'], }, }); const listRes = await app.inject({ method: 'GET', url: '/api/calls' }); assert.equal(listRes.json().calls.length, 1); assert.equal(listRes.json().calls[0].title, 'AI semiconductor cycle'); }); test('POST /api/calls with missing required fields → 400', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'POST', url: '/api/calls', payload: { title: 'incomplete' }, // missing quarter, thesis, tickers }); assert.equal(res.statusCode, 400); }); test('POST /api/calls with thesis too short → 400', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'POST', url: '/api/calls', payload: { title: 'Test', quarter: 'Q1', thesis: 'short', tickers: ['AAPL'] }, }); assert.equal(res.statusCode, 400); }); test('DELETE /api/calls/:id on non-existent id → 404', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'DELETE', url: '/api/calls/nonexistent' }); assert.equal(res.statusCode, 404); }); test('DELETE /api/calls/:id removes the call', async () => { const app = await buildTestApp(); const created = await app.inject({ method: 'POST', url: '/api/calls', payload: { title: 'Call to delete', quarter: 'Q1 2025', thesis: 'This call will be deleted in the test.', tickers: ['SPY'], }, }); const { id } = created.json(); const del = await app.inject({ method: 'DELETE', url: `/api/calls/${id}` }); assert.equal(del.statusCode, 200); assert.deepEqual(del.json(), { ok: true }); const list = await app.inject({ method: 'GET', url: '/api/calls' }); assert.equal(list.json().calls.length, 0); }); test('GET /api/calls/:id on non-existent id → 404', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'GET', url: '/api/calls/no-such-id' }); assert.equal(res.statusCode, 404); }); test('GET /api/calls/:id returns call with current snapshot shape', async () => { const app = await buildTestApp(); const created = await app.inject({ method: 'POST', url: '/api/calls', payload: { title: 'Rate trade', quarter: 'Q2 2025', thesis: 'Long duration bonds when yield curve inverts.', tickers: ['TLT'], }, }); const { id } = created.json(); const res = await app.inject({ method: 'GET', url: `/api/calls/${id}` }); assert.equal(res.statusCode, 200); const body = res.json(); assert.equal(body.id, id); assert.ok('current' in body, 'response should include current snapshot'); }); test('GET /api/calls/calendar with no calls → 200 empty events', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'GET', url: '/api/calls/calendar' }); assert.equal(res.statusCode, 200); assert.deepEqual(res.json(), { events: [], tickers: [] }); });