215 lines
6.9 KiB
TypeScript
215 lines
6.9 KiB
TypeScript
/**
|
|
* 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 { YahooFinanceClient } from '../server/clients/YahooFinanceClient';
|
|
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 stubYahoo = {
|
|
fetchCalendarEvents: async () => null,
|
|
} as unknown as YahooFinanceClient;
|
|
|
|
// 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, stubYahoo).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: [] });
|
|
});
|