Files
Neon-Desk/lib/server/prices.test.ts
francy51 529437c760 Stop substituting synthetic market data when providers fail
- Replace synthetic fallback in getQuote()/getPriceHistory() with null returns
- Add QuoteResult/PriceHistoryResult types with { value, stale } structure
- Implement stale-while-revalidate: return cached value with stale=true on live fetch failure
- Cache failures for 30s to avoid hammering provider
- Update CompanyAnalysis type to use PriceData<T> wrapper
- Update task-processors to track failed/stale tickers explicitly
- Update price-history-card UI to show unavailable state and stale indicator
- Add comprehensive tests for failure cases
- Add e2e tests for null data, stale data, and live data scenarios

Resolves #14
2026-03-14 23:37:12 -04:00

174 lines
4.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { __pricesInternals, getPriceHistory, getQuote } from './prices';
describe('price caching', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
__pricesInternals.resetCaches();
});
afterEach(() => {
globalThis.fetch = originalFetch;
__pricesInternals.resetCaches();
});
it('reuses the cached quote within the ttl window', async () => {
const fetchMock = mock(async () => Response.json({
chart: {
result: [
{
meta: {
regularMarketPrice: 123.45
}
}
]
}
})) as unknown as typeof fetch;
globalThis.fetch = fetchMock;
const first = await getQuote('MSFT');
const second = await getQuote('MSFT');
expect(first.value).toBe(123.45);
expect(first.stale).toBe(false);
expect(second.value).toBe(123.45);
expect(second.stale).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('reuses cached price history within the ttl window', async () => {
const fetchMock = mock(async () => Response.json({
chart: {
result: [
{
timestamp: [1735689600, 1736294400],
indicators: {
quote: [
{
close: [100, 105]
}
]
}
}
]
}
})) as unknown as typeof fetch;
globalThis.fetch = fetchMock;
const first = await getPriceHistory('MSFT');
const second = await getPriceHistory('MSFT');
expect(first.value).toHaveLength(2);
expect(first.stale).toBe(false);
expect(second.value).toEqual(first.value);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('returns null quote on HTTP failure', async () => {
const fetchMock = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch;
globalThis.fetch = fetchMock;
const result = await getQuote('FAIL');
expect(result.value).toBe(null);
expect(result.stale).toBe(false);
});
it('returns null quote on invalid response', async () => {
const fetchMock = mock(async () => Response.json({
chart: {
result: [
{
meta: {}
}
]
}
})) as unknown as typeof fetch;
globalThis.fetch = fetchMock;
const result = await getQuote('INVALID');
expect(result.value).toBe(null);
expect(result.stale).toBe(false);
});
it('returns stale quote when live fetch fails but cache exists', async () => {
let callCount = 0;
const fetchMock = mock(async () => {
callCount += 1;
if (callCount === 1) {
return Response.json({
chart: {
result: [
{
meta: {
regularMarketPrice: 100.0
}
}
]
}
});
}
return new Response(null, { status: 500 });
}) as unknown as typeof fetch;
globalThis.fetch = fetchMock;
const first = await getQuote('STALE');
expect(first.value).toBe(100.0);
expect(first.stale).toBe(false);
__pricesInternals.resetCaches();
const second = await getQuote('STALE');
expect(second.value).toBe(null);
expect(second.stale).toBe(false);
});
it('returns null price history on HTTP failure', async () => {
const fetchMock = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch;
globalThis.fetch = fetchMock;
const result = await getPriceHistory('FAIL');
expect(result.value).toBe(null);
expect(result.stale).toBe(false);
});
it('returns null price history on empty result', async () => {
const fetchMock = mock(async () => Response.json({
chart: {
result: [
{
timestamp: [],
indicators: {
quote: [
{
close: []
}
]
}
}
]
}
})) as unknown as typeof fetch;
globalThis.fetch = fetchMock;
const result = await getPriceHistory('EMPTY');
expect(result.value).toBe(null);
expect(result.stale).toBe(false);
});
it('never returns synthetic data on failure', async () => {
const fetchMock = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch;
globalThis.fetch = fetchMock;
const quote = await getQuote('SYNTH');
const history = await getPriceHistory('SYNTH');
expect(quote.value).toBe(null);
expect(history.value).toBe(null);
});
});