- 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
252 lines
7.7 KiB
TypeScript
252 lines
7.7 KiB
TypeScript
import { describe, expect, it, mock } from 'bun:test';
|
|
import type {
|
|
CompanyAnalysis,
|
|
Filing,
|
|
ResearchMemo
|
|
} from '@/lib/types';
|
|
import {
|
|
__companyAnalysisInternals,
|
|
getCompanyAnalysisPayload
|
|
} from './company-analysis';
|
|
|
|
function buildFiling(updatedAt = '2026-03-10T00:00:00.000Z'): Filing {
|
|
return {
|
|
id: 1,
|
|
ticker: 'MSFT',
|
|
filing_type: '10-K',
|
|
filing_date: '2026-02-01',
|
|
accession_number: '0000000000-26-000001',
|
|
cik: '0000789019',
|
|
company_name: 'Microsoft Corporation',
|
|
filing_url: 'https://www.sec.gov/Archives/test.htm',
|
|
submission_url: 'https://www.sec.gov/submissions/test.json',
|
|
primary_document: 'test.htm',
|
|
metrics: null,
|
|
analysis: null,
|
|
created_at: updatedAt,
|
|
updated_at: updatedAt
|
|
};
|
|
}
|
|
|
|
function buildMemo(updatedAt = '2026-03-10T00:00:00.000Z', thesis = 'Azure remains durable.'): ResearchMemo {
|
|
return {
|
|
id: 1,
|
|
user_id: 'user-1',
|
|
organization_id: null,
|
|
ticker: 'MSFT',
|
|
rating: 'buy',
|
|
conviction: 'high',
|
|
time_horizon_months: 24,
|
|
packet_title: null,
|
|
packet_subtitle: null,
|
|
thesis_markdown: thesis,
|
|
variant_view_markdown: '',
|
|
catalysts_markdown: '',
|
|
risks_markdown: '',
|
|
disconfirming_evidence_markdown: '',
|
|
next_actions_markdown: '',
|
|
created_at: updatedAt,
|
|
updated_at: updatedAt
|
|
};
|
|
}
|
|
|
|
function buildLocalInputs(overrides: Partial<Parameters<typeof __companyAnalysisInternals.buildCompanyAnalysisSourceSignature>[0]['localInputs']> = {}) {
|
|
return {
|
|
filings: [buildFiling()],
|
|
holding: null,
|
|
watchlistItem: null,
|
|
journalPreview: [],
|
|
memo: null,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
function buildAnalysisPayload(companyName: string): CompanyAnalysis {
|
|
return {
|
|
company: {
|
|
ticker: 'MSFT',
|
|
companyName,
|
|
sector: null,
|
|
category: null,
|
|
tags: [],
|
|
cik: null
|
|
},
|
|
quote: { value: 100, stale: false },
|
|
position: null,
|
|
priceHistory: { value: [], stale: false },
|
|
benchmarkHistory: { value: [], stale: false },
|
|
financials: [],
|
|
filings: [],
|
|
aiReports: [],
|
|
coverage: null,
|
|
journalPreview: [],
|
|
recentAiReports: [],
|
|
latestFilingSummary: null,
|
|
keyMetrics: {
|
|
referenceDate: null,
|
|
revenue: null,
|
|
netIncome: null,
|
|
totalAssets: null,
|
|
cash: null,
|
|
debt: null,
|
|
netMargin: null
|
|
},
|
|
companyProfile: {
|
|
description: null,
|
|
exchange: null,
|
|
industry: null,
|
|
country: null,
|
|
website: null,
|
|
fiscalYearEnd: null,
|
|
employeeCount: null,
|
|
source: 'unavailable'
|
|
},
|
|
valuationSnapshot: {
|
|
sharesOutstanding: null,
|
|
marketCap: null,
|
|
enterpriseValue: null,
|
|
trailingPe: null,
|
|
evToRevenue: null,
|
|
evToEbitda: null,
|
|
source: 'unavailable'
|
|
},
|
|
bullBear: {
|
|
source: 'unavailable',
|
|
bull: [],
|
|
bear: [],
|
|
updatedAt: null
|
|
},
|
|
recentDevelopments: {
|
|
status: 'unavailable',
|
|
items: [],
|
|
weeklySnapshot: null
|
|
}
|
|
};
|
|
}
|
|
|
|
function buildCacheRecord(payload: CompanyAnalysis, sourceSignature: string) {
|
|
return {
|
|
id: 1,
|
|
user_id: 'user-1',
|
|
ticker: 'MSFT',
|
|
cache_version: 1,
|
|
source_signature: sourceSignature,
|
|
payload,
|
|
created_at: '2026-03-13T11:55:00.000Z',
|
|
updated_at: '2026-03-13T11:55:00.000Z'
|
|
};
|
|
}
|
|
|
|
describe('company analysis cache orchestration', () => {
|
|
it('changes the source signature when local inputs change', () => {
|
|
const base = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
|
ticker: 'MSFT',
|
|
localInputs: buildLocalInputs()
|
|
});
|
|
const memoChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
|
ticker: 'MSFT',
|
|
localInputs: buildLocalInputs({ memo: buildMemo('2026-03-11T00:00:00.000Z') })
|
|
});
|
|
const filingChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
|
ticker: 'MSFT',
|
|
localInputs: buildLocalInputs({ filings: [buildFiling('2026-03-11T00:00:00.000Z')] })
|
|
});
|
|
const journalChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
|
ticker: 'MSFT',
|
|
localInputs: buildLocalInputs({
|
|
journalPreview: [{
|
|
id: 7,
|
|
user_id: 'user-1',
|
|
ticker: 'MSFT',
|
|
accession_number: null,
|
|
entry_type: 'note',
|
|
title: 'Updated note',
|
|
body_markdown: 'Body',
|
|
metadata: null,
|
|
created_at: '2026-03-01T00:00:00.000Z',
|
|
updated_at: '2026-03-11T00:00:00.000Z'
|
|
}]
|
|
})
|
|
});
|
|
|
|
expect(memoChanged).not.toBe(base);
|
|
expect(filingChanged).not.toBe(base);
|
|
expect(journalChanged).not.toBe(base);
|
|
});
|
|
|
|
it('returns a fresh cached payload when signature and ttl match', async () => {
|
|
const localInputs = buildLocalInputs();
|
|
const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
|
ticker: 'MSFT',
|
|
localInputs
|
|
});
|
|
const buildOverview = mock(async () => buildAnalysisPayload('Built Corp'));
|
|
const upsertCachedOverview = mock(async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature));
|
|
|
|
const analysis = await getCompanyAnalysisPayload({
|
|
userId: 'user-1',
|
|
ticker: 'MSFT',
|
|
now: new Date('2026-03-13T12:00:00.000Z')
|
|
}, {
|
|
getLocalInputs: async () => localInputs,
|
|
getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature),
|
|
buildOverview,
|
|
upsertCachedOverview
|
|
});
|
|
|
|
expect(analysis.company.companyName).toBe('Cached Corp');
|
|
expect(buildOverview).not.toHaveBeenCalled();
|
|
expect(upsertCachedOverview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('bypasses the cache when refresh is requested', async () => {
|
|
const localInputs = buildLocalInputs();
|
|
const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
|
ticker: 'MSFT',
|
|
localInputs
|
|
});
|
|
const buildOverview = mock(async () => buildAnalysisPayload('Refreshed Corp'));
|
|
const upsertCachedOverview = mock(async () => buildCacheRecord(buildAnalysisPayload('Refreshed Corp'), sourceSignature));
|
|
|
|
const analysis = await getCompanyAnalysisPayload({
|
|
userId: 'user-1',
|
|
ticker: 'MSFT',
|
|
refresh: true,
|
|
now: new Date('2026-03-13T12:00:00.000Z')
|
|
}, {
|
|
getLocalInputs: async () => localInputs,
|
|
getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature),
|
|
buildOverview,
|
|
upsertCachedOverview
|
|
});
|
|
|
|
expect(analysis.company.companyName).toBe('Refreshed Corp');
|
|
expect(buildOverview).toHaveBeenCalledTimes(1);
|
|
expect(upsertCachedOverview).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rebuilds when local inputs invalidate the cached signature', async () => {
|
|
const cachedInputs = buildLocalInputs({ memo: buildMemo('2026-03-10T00:00:00.000Z', 'Old memo') });
|
|
const freshInputs = buildLocalInputs({ memo: buildMemo('2026-03-11T00:00:00.000Z', 'New memo') });
|
|
const staleSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
|
ticker: 'MSFT',
|
|
localInputs: cachedInputs
|
|
});
|
|
const buildOverview = mock(async () => buildAnalysisPayload('Rebuilt Corp'));
|
|
|
|
const analysis = await getCompanyAnalysisPayload({
|
|
userId: 'user-1',
|
|
ticker: 'MSFT',
|
|
now: new Date('2026-03-13T12:00:00.000Z')
|
|
}, {
|
|
getLocalInputs: async () => freshInputs,
|
|
getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), staleSignature),
|
|
buildOverview,
|
|
upsertCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Rebuilt Corp'), staleSignature)
|
|
});
|
|
|
|
expect(analysis.company.companyName).toBe('Rebuilt Corp');
|
|
expect(buildOverview).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|