414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
import {
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
mock
|
|
} from 'bun:test';
|
|
import type { Filing, Holding, Task } from '@/lib/types';
|
|
|
|
const stageUpdates: Array<{
|
|
taskId: string;
|
|
stage: string;
|
|
detail: string | null;
|
|
context: Record<string, unknown> | null;
|
|
}> = [];
|
|
|
|
const mockRunAiAnalysis = mock(async (_prompt: string, _instruction: string, options?: { workload?: string }) => {
|
|
if (options?.workload === 'extraction') {
|
|
return {
|
|
provider: 'zhipu',
|
|
model: 'glm-extract',
|
|
text: JSON.stringify({
|
|
summary: 'Revenue growth remained resilient despite FX pressure.',
|
|
keyPoints: ['Revenue up year-over-year'],
|
|
redFlags: ['Debt service burden is rising'],
|
|
followUpQuestions: ['Is margin guidance sustainable?'],
|
|
portfolioSignals: ['Monitor leverage trend'],
|
|
segmentSpecificData: ['Services segment outgrew hardware segment.'],
|
|
geographicRevenueBreakdown: ['EMEA revenue grew faster than Americas.'],
|
|
companySpecificData: ['Same-store sales increased 4.2%.'],
|
|
secApiCrossChecks: ['Revenue from SEC API aligns with filing narrative.'],
|
|
confidence: 0.72
|
|
})
|
|
};
|
|
}
|
|
|
|
return {
|
|
provider: 'zhipu',
|
|
model: options?.workload === 'report' ? 'glm-report' : 'glm-generic',
|
|
text: 'Structured output'
|
|
};
|
|
});
|
|
|
|
const mockBuildPortfolioSummary = mock((_holdings: Holding[]) => ({
|
|
positions: 14,
|
|
total_value: '100000',
|
|
total_gain_loss: '1000',
|
|
total_cost_basis: '99000',
|
|
avg_return_pct: '0.01'
|
|
}));
|
|
|
|
const mockGetQuote = mock(async (ticker: string) => {
|
|
return ticker === 'MSFT' ? 410 : 205;
|
|
});
|
|
|
|
const mockIndexSearchDocuments = mock(async (input: {
|
|
onStage?: (stage: 'collect' | 'fetch' | 'chunk' | 'embed' | 'persist', detail: string, context?: Record<string, unknown> | null) => Promise<void> | void;
|
|
}) => {
|
|
await input.onStage?.('collect', 'Collected 12 source records for search indexing', {
|
|
counters: {
|
|
sourcesCollected: 12,
|
|
deleted: 3
|
|
}
|
|
});
|
|
await input.onStage?.('fetch', 'Preparing filing_brief 0000320193-26-000001', {
|
|
progress: {
|
|
current: 1,
|
|
total: 12,
|
|
unit: 'sources'
|
|
},
|
|
subject: {
|
|
ticker: 'AAPL',
|
|
accessionNumber: '0000320193-26-000001'
|
|
}
|
|
});
|
|
await input.onStage?.('embed', 'Embedding 248 chunks for 0000320193-26-000001', {
|
|
progress: {
|
|
current: 1,
|
|
total: 12,
|
|
unit: 'sources'
|
|
},
|
|
counters: {
|
|
chunksEmbedded: 248
|
|
}
|
|
});
|
|
|
|
return {
|
|
sourcesCollected: 12,
|
|
indexed: 12,
|
|
skipped: 1,
|
|
deleted: 3,
|
|
chunksEmbedded: 248
|
|
};
|
|
});
|
|
|
|
const sampleFiling = (): Filing => ({
|
|
id: 1,
|
|
ticker: 'AAPL',
|
|
filing_type: '10-Q',
|
|
filing_date: '2026-01-30',
|
|
accession_number: '0000320193-26-000001',
|
|
cik: '0000320193',
|
|
company_name: 'Apple Inc.',
|
|
filing_url: 'https://www.sec.gov/Archives/edgar/data/320193/000032019326000001/a10q.htm',
|
|
submission_url: 'https://data.sec.gov/submissions/CIK0000320193.json',
|
|
primary_document: 'a10q.htm',
|
|
metrics: {
|
|
revenue: 120_000_000_000,
|
|
netIncome: 25_000_000_000,
|
|
totalAssets: 410_000_000_000,
|
|
cash: 70_000_000_000,
|
|
debt: 98_000_000_000
|
|
},
|
|
analysis: null,
|
|
created_at: '2026-01-30T00:00:00.000Z',
|
|
updated_at: '2026-01-30T00:00:00.000Z'
|
|
});
|
|
|
|
const mockGetFilingByAccession = mock(async () => sampleFiling());
|
|
const mockListFilingsRecords = mock(async () => [sampleFiling(), {
|
|
...sampleFiling(),
|
|
id: 2,
|
|
accession_number: '0000320193-26-000002',
|
|
filing_date: '2026-02-28'
|
|
}]);
|
|
const mockSaveFilingAnalysis = mock(async () => {});
|
|
const mockUpdateFilingMetricsById = mock(async () => {});
|
|
const mockUpsertFilingsRecords = mock(async () => ({
|
|
inserted: 2,
|
|
updated: 0
|
|
}));
|
|
|
|
const mockDeleteCompanyFinancialBundlesForTicker = mock(async () => {});
|
|
const mockGetFilingTaxonomySnapshotByFilingId = mock(async () => null);
|
|
const mockUpsertFilingTaxonomySnapshot = mock(async () => {});
|
|
const mockApplyRefreshedPrices = mock(async () => 24);
|
|
const mockListHoldingsForPriceRefresh = mock(async () => [
|
|
{
|
|
id: 1,
|
|
user_id: 'user-1',
|
|
ticker: 'AAPL',
|
|
company_name: 'Apple Inc.',
|
|
shares: '10',
|
|
avg_cost: '150',
|
|
current_price: '200',
|
|
market_value: '2000',
|
|
gain_loss: '500',
|
|
gain_loss_pct: '0.33',
|
|
last_price_at: null,
|
|
created_at: '2026-03-09T00:00:00.000Z',
|
|
updated_at: '2026-03-09T00:00:00.000Z'
|
|
},
|
|
{
|
|
id: 2,
|
|
user_id: 'user-1',
|
|
ticker: 'MSFT',
|
|
company_name: 'Microsoft Corporation',
|
|
shares: '4',
|
|
avg_cost: '300',
|
|
current_price: '400',
|
|
market_value: '1600',
|
|
gain_loss: '400',
|
|
gain_loss_pct: '0.25',
|
|
last_price_at: null,
|
|
created_at: '2026-03-09T00:00:00.000Z',
|
|
updated_at: '2026-03-09T00:00:00.000Z'
|
|
}
|
|
]);
|
|
const mockListUserHoldings = mock(async () => await mockListHoldingsForPriceRefresh());
|
|
const mockCreatePortfolioInsight = mock(async () => {});
|
|
const mockUpdateTaskStage = mock(async (taskId: string, stage: string, detail: string | null, context?: Record<string, unknown> | null) => {
|
|
stageUpdates.push({
|
|
taskId,
|
|
stage,
|
|
detail,
|
|
context: context ?? null
|
|
});
|
|
});
|
|
const mockFetchPrimaryFilingText = mock(async () => ({
|
|
text: 'Revenue accelerated in services and margins improved.',
|
|
source: 'primary_document' as const
|
|
}));
|
|
const mockFetchRecentFilings = mock(async () => ([
|
|
{
|
|
ticker: 'AAPL',
|
|
filingType: '10-Q',
|
|
filingDate: '2026-01-30',
|
|
accessionNumber: '0000320193-26-000001',
|
|
cik: '0000320193',
|
|
companyName: 'Apple Inc.',
|
|
filingUrl: 'https://www.sec.gov/Archives/edgar/data/320193/000032019326000001/a10q.htm',
|
|
submissionUrl: 'https://data.sec.gov/submissions/CIK0000320193.json',
|
|
primaryDocument: 'a10q.htm'
|
|
},
|
|
{
|
|
ticker: 'AAPL',
|
|
filingType: '10-K',
|
|
filingDate: '2025-10-30',
|
|
accessionNumber: '0000320193-25-000001',
|
|
cik: '0000320193',
|
|
companyName: 'Apple Inc.',
|
|
filingUrl: 'https://www.sec.gov/Archives/edgar/data/320193/000032019325000001/a10k.htm',
|
|
submissionUrl: 'https://data.sec.gov/submissions/CIK0000320193.json',
|
|
primaryDocument: 'a10k.htm'
|
|
}
|
|
]));
|
|
const mockEnqueueTask = mock(async () => ({
|
|
id: 'search-task-1'
|
|
}));
|
|
const mockHydrateFilingTaxonomySnapshot = mock(async (input: { filingId: number }) => ({
|
|
filing_id: input.filingId,
|
|
ticker: 'AAPL',
|
|
filing_date: '2026-01-30',
|
|
filing_type: '10-Q',
|
|
parse_status: 'ready',
|
|
parse_error: null,
|
|
source: 'xbrl_instance',
|
|
periods: [],
|
|
statement_rows: {
|
|
income: [],
|
|
balance: [],
|
|
cash_flow: [],
|
|
equity: [],
|
|
comprehensive_income: []
|
|
},
|
|
derived_metrics: {
|
|
revenue: 120_000_000_000
|
|
},
|
|
validation_result: {
|
|
status: 'matched',
|
|
checks: [],
|
|
validatedAt: '2026-03-09T00:00:00.000Z'
|
|
},
|
|
facts_count: 1,
|
|
concepts_count: 1,
|
|
dimensions_count: 0,
|
|
assets: [],
|
|
concepts: [],
|
|
facts: [],
|
|
metric_validations: []
|
|
}));
|
|
|
|
mock.module('@/lib/server/ai', () => ({
|
|
runAiAnalysis: mockRunAiAnalysis
|
|
}));
|
|
mock.module('@/lib/server/portfolio', () => ({
|
|
buildPortfolioSummary: mockBuildPortfolioSummary
|
|
}));
|
|
mock.module('@/lib/server/prices', () => ({
|
|
getQuote: mockGetQuote
|
|
}));
|
|
mock.module('@/lib/server/search', () => ({
|
|
indexSearchDocuments: mockIndexSearchDocuments
|
|
}));
|
|
mock.module('@/lib/server/repos/filings', () => ({
|
|
getFilingByAccession: mockGetFilingByAccession,
|
|
listFilingsRecords: mockListFilingsRecords,
|
|
saveFilingAnalysis: mockSaveFilingAnalysis,
|
|
updateFilingMetricsById: mockUpdateFilingMetricsById,
|
|
upsertFilingsRecords: mockUpsertFilingsRecords
|
|
}));
|
|
mock.module('@/lib/server/repos/company-financial-bundles', () => ({
|
|
deleteCompanyFinancialBundlesForTicker: mockDeleteCompanyFinancialBundlesForTicker
|
|
}));
|
|
mock.module('@/lib/server/repos/filing-taxonomy', () => ({
|
|
getFilingTaxonomySnapshotByFilingId: mockGetFilingTaxonomySnapshotByFilingId,
|
|
upsertFilingTaxonomySnapshot: mockUpsertFilingTaxonomySnapshot
|
|
}));
|
|
mock.module('@/lib/server/repos/holdings', () => ({
|
|
applyRefreshedPrices: mockApplyRefreshedPrices,
|
|
listHoldingsForPriceRefresh: mockListHoldingsForPriceRefresh,
|
|
listUserHoldings: mockListUserHoldings
|
|
}));
|
|
mock.module('@/lib/server/repos/insights', () => ({
|
|
createPortfolioInsight: mockCreatePortfolioInsight
|
|
}));
|
|
mock.module('@/lib/server/repos/tasks', () => ({
|
|
updateTaskStage: mockUpdateTaskStage
|
|
}));
|
|
mock.module('@/lib/server/sec', () => ({
|
|
fetchPrimaryFilingText: mockFetchPrimaryFilingText,
|
|
fetchRecentFilings: mockFetchRecentFilings
|
|
}));
|
|
mock.module('@/lib/server/tasks', () => ({
|
|
enqueueTask: mockEnqueueTask
|
|
}));
|
|
mock.module('@/lib/server/taxonomy/engine', () => ({
|
|
hydrateFilingTaxonomySnapshot: mockHydrateFilingTaxonomySnapshot
|
|
}));
|
|
|
|
const { runTaskProcessor } = await import('./task-processors');
|
|
|
|
function taskFactory(overrides: Partial<Task> = {}): Task {
|
|
return {
|
|
id: 'task-1',
|
|
user_id: 'user-1',
|
|
task_type: 'sync_filings',
|
|
status: 'running',
|
|
stage: 'running',
|
|
stage_detail: 'Running',
|
|
stage_context: null,
|
|
resource_key: null,
|
|
notification_read_at: null,
|
|
notification_silenced_at: null,
|
|
priority: 50,
|
|
payload: {},
|
|
result: null,
|
|
error: null,
|
|
attempts: 1,
|
|
max_attempts: 3,
|
|
workflow_run_id: 'run-1',
|
|
created_at: '2026-03-09T00:00:00.000Z',
|
|
updated_at: '2026-03-09T00:00:00.000Z',
|
|
finished_at: null,
|
|
notification: {
|
|
title: 'Task',
|
|
statusLine: 'Running',
|
|
detailLine: null,
|
|
tone: 'info',
|
|
progress: null,
|
|
stats: [],
|
|
actions: []
|
|
},
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
describe('task processor outcomes', () => {
|
|
beforeEach(() => {
|
|
stageUpdates.length = 0;
|
|
mockRunAiAnalysis.mockClear();
|
|
mockGetQuote.mockClear();
|
|
mockIndexSearchDocuments.mockClear();
|
|
mockSaveFilingAnalysis.mockClear();
|
|
mockCreatePortfolioInsight.mockClear();
|
|
mockUpdateTaskStage.mockClear();
|
|
mockEnqueueTask.mockClear();
|
|
});
|
|
|
|
it('returns sync filing completion detail and progress context', async () => {
|
|
const outcome = await runTaskProcessor(taskFactory({
|
|
task_type: 'sync_filings',
|
|
payload: {
|
|
ticker: 'AAPL',
|
|
limit: 2
|
|
}
|
|
}));
|
|
|
|
expect(outcome.completionDetail).toContain('Synced 2 filings for AAPL');
|
|
expect(outcome.result.fetched).toBe(2);
|
|
expect(outcome.result.searchTaskId).toBe('search-task-1');
|
|
expect(outcome.completionContext?.counters?.hydrated).toBe(2);
|
|
expect(stageUpdates.some((entry) => entry.stage === 'sync.extract_taxonomy' && entry.context?.subject)).toBe(true);
|
|
});
|
|
|
|
it('returns refresh price completion detail with live quote progress', async () => {
|
|
const outcome = await runTaskProcessor(taskFactory({
|
|
task_type: 'refresh_prices'
|
|
}));
|
|
|
|
expect(outcome.completionDetail).toBe('Refreshed prices for 2 tickers across 2 holdings.');
|
|
expect(outcome.result.updatedCount).toBe(24);
|
|
expect(stageUpdates.filter((entry) => entry.stage === 'refresh.fetch_quotes')).toHaveLength(3);
|
|
expect(stageUpdates.at(-1)?.context?.counters).toBeDefined();
|
|
});
|
|
|
|
it('returns analyze filing completion detail with report metadata', async () => {
|
|
const outcome = await runTaskProcessor(taskFactory({
|
|
task_type: 'analyze_filing',
|
|
payload: {
|
|
accessionNumber: '0000320193-26-000001'
|
|
}
|
|
}));
|
|
|
|
expect(outcome.completionDetail).toBe('Analysis report generated for AAPL 10-Q 0000320193-26-000001.');
|
|
expect(outcome.result.ticker).toBe('AAPL');
|
|
expect(outcome.result.filingType).toBe('10-Q');
|
|
expect(outcome.result.model).toBe('glm-report');
|
|
expect(mockSaveFilingAnalysis).toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns index search completion detail and counters', async () => {
|
|
const outcome = await runTaskProcessor(taskFactory({
|
|
task_type: 'index_search',
|
|
payload: {
|
|
ticker: 'AAPL',
|
|
sourceKinds: ['filing_brief']
|
|
}
|
|
}));
|
|
|
|
expect(outcome.completionDetail).toBe('Indexed 12 sources, embedded 248 chunks, skipped 1, deleted 3 stale documents.');
|
|
expect(outcome.result.indexed).toBe(12);
|
|
expect(outcome.completionContext?.counters?.chunksEmbedded).toBe(248);
|
|
expect(stageUpdates.some((entry) => entry.stage === 'search.embed')).toBe(true);
|
|
});
|
|
|
|
it('returns portfolio insight completion detail and summary payload', async () => {
|
|
const outcome = await runTaskProcessor(taskFactory({
|
|
task_type: 'portfolio_insights'
|
|
}));
|
|
|
|
expect(outcome.completionDetail).toBe('Generated portfolio insight for 14 holdings.');
|
|
expect(outcome.result.provider).toBe('zhipu');
|
|
expect(outcome.result.summary).toEqual({
|
|
positions: 14,
|
|
total_value: '100000',
|
|
total_gain_loss: '1000',
|
|
total_cost_basis: '99000',
|
|
avg_return_pct: '0.01'
|
|
});
|
|
expect(mockCreatePortfolioInsight).toHaveBeenCalled();
|
|
});
|
|
});
|