phase-8:server code enhancements.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user