Improve job status notifications
This commit is contained in:
413
lib/server/task-processors.outcomes.test.ts
Normal file
413
lib/server/task-processors.outcomes.test.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user