- 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
174 lines
4.5 KiB
TypeScript
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);
|
|
});
|
|
});
|