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
This commit is contained in:
2026-03-14 23:37:12 -04:00
parent 5b68333a07
commit 529437c760
10 changed files with 496 additions and 230 deletions

View File

@@ -30,8 +30,10 @@ describe('price caching', () => {
const first = await getQuote('MSFT');
const second = await getQuote('MSFT');
expect(first).toBe(123.45);
expect(second).toBe(123.45);
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);
});
@@ -57,8 +59,115 @@ describe('price caching', () => {
const first = await getPriceHistory('MSFT');
const second = await getPriceHistory('MSFT');
expect(first).toHaveLength(2);
expect(second).toEqual(first);
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);
});
});