Files
Neon-Desk/lib/server/company-analysis.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

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);
});
});