/** * Integration tests for FinanceController * Uses Fastify inject() with stub engine, advisor, and in-memory portfolio repo. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import Fastify from 'fastify'; import cors from '@fastify/cors'; import { FinanceController } from '../server/controllers/finance.controller'; import type { ScreenerEngine } from '../server/services/ScreenerEngine'; import type { PortfolioAdvisor } from '../server/services/PortfolioAdvisor'; import type { PortfolioHolding, MarketContext, ScreenerResult } 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, getMarketContext: async () => MARKET_CTX, } as unknown as ScreenerEngine; const stubAdvisor = { advise: async () => [], } as unknown as PortfolioAdvisor; // In-memory PortfolioRepository stub function makePortfolioRepo(seed: PortfolioHolding[] = []) { const holdings: PortfolioHolding[] = [...seed]; return { exists: () => true, read: () => ({ holdings: [...holdings] }), upsert: (entry: PortfolioHolding) => { const idx = holdings.findIndex((h) => h.ticker === entry.ticker); if (idx >= 0) holdings[idx] = entry; else holdings.push(entry); return entry; }, remove: (ticker: string) => { const idx = holdings.findIndex((h) => h.ticker === ticker); if (idx === -1) return false; holdings.splice(idx, 1); return true; }, }; } function makeEmptyRepo() { return { exists: () => false, read: () => ({ holdings: [] }), upsert: () => {}, remove: () => false, }; } // ── App factory ────────────────────────────────────────────────────────────── async function buildTestApp(repo = makePortfolioRepo()) { const app = Fastify({ logger: false }); await app.register(cors, { origin: '*' }); new FinanceController(stubEngine, repo as any, stubAdvisor).register(app); await app.ready(); return app; } // ── Tests ──────────────────────────────────────────────────────────────────── test('GET /api/finance/portfolio → 200 with advice and marketContext keys', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'GET', url: '/api/finance/portfolio' }); assert.equal(res.statusCode, 200); const body = res.json(); assert.ok(Array.isArray(body.advice), 'advice should be array'); assert.ok(body.marketContext, 'marketContext should be present'); }); test('GET /api/finance/portfolio with no portfolio.json → 404', async () => { const app = await buildTestApp(makeEmptyRepo() as any); const res = await app.inject({ method: 'GET', url: '/api/finance/portfolio' }); assert.equal(res.statusCode, 404); }); test('GET /api/finance/market-context → 200 with benchmark fields', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'GET', url: '/api/finance/market-context' }); assert.equal(res.statusCode, 200); const body = res.json(); assert.ok(typeof body.riskFreeRate === 'number'); assert.ok(typeof body.sp500Price === 'number'); assert.ok(body.benchmarks); }); test('POST /api/finance/holdings → 201 and returns the holding', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'POST', url: '/api/finance/holdings', payload: { ticker: 'AAPL', shares: 10, costBasis: 150, type: 'stock', source: 'Robinhood' }, }); assert.equal(res.statusCode, 201); const body = res.json(); assert.equal(body.ticker, 'AAPL'); assert.equal(body.shares, 10); }); test('POST /api/finance/holdings with missing shares → 400', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'POST', url: '/api/finance/holdings', payload: { ticker: 'AAPL' }, }); assert.equal(res.statusCode, 400); }); test('POST /api/finance/holdings with missing ticker → 400', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'POST', url: '/api/finance/holdings', payload: { shares: 5 }, }); assert.equal(res.statusCode, 400); }); test('POST /api/finance/holdings with zero shares → 400', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'POST', url: '/api/finance/holdings', payload: { ticker: 'AAPL', shares: 0 }, }); assert.equal(res.statusCode, 400); }); test('POST /api/finance/holdings with invalid type → 400', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'POST', url: '/api/finance/holdings', payload: { ticker: 'AAPL', shares: 5, type: 'options' }, }); assert.equal(res.statusCode, 400); }); test('DELETE /api/finance/holdings/:ticker removes existing holding → 200', async () => { const repo = makePortfolioRepo([ { ticker: 'MSFT', shares: 5, costBasis: 300, type: 'stock', source: 'Manual' }, ]); const app = await buildTestApp(repo); const res = await app.inject({ method: 'DELETE', url: '/api/finance/holdings/MSFT' }); assert.equal(res.statusCode, 200); assert.deepEqual(res.json(), { ok: true }); }); test('DELETE /api/finance/holdings/:ticker on missing ticker → 404', async () => { const app = await buildTestApp(); const res = await app.inject({ method: 'DELETE', url: '/api/finance/holdings/NOTHERE' }); assert.equal(res.statusCode, 404); });