547 lines
15 KiB
TypeScript
547 lines
15 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 mockNormalizeFilingTaxonomySnapshotPayload = mock((input: unknown) => {
|
|
return input as Record<string, unknown>;
|
|
});
|
|
const mockUpsertFilingTaxonomySnapshot = mock(async () => {});
|
|
const mockValidateMetricsWithPdfLlm = mock(async () => ({
|
|
validation_result: {
|
|
status: "matched" as const,
|
|
checks: [],
|
|
validatedAt: "2026-03-09T00:00:00.000Z",
|
|
},
|
|
metric_validations: [],
|
|
}));
|
|
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 mockGenerateIssuerOverlayForTicker = mock(async () => ({
|
|
published: false,
|
|
activeRevisionId: null,
|
|
sampledSnapshotIds: [1],
|
|
}));
|
|
const mockRecordIssuerOverlayBuildFailure = mock(async () => {});
|
|
const mockGetActiveIssuerOverlayDefinition = mock(async () => null);
|
|
const mockGetIssuerOverlay = mock(async () => null);
|
|
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: [],
|
|
},
|
|
faithful_rows: {
|
|
income: [],
|
|
balance: [],
|
|
cash_flow: [],
|
|
equity: [],
|
|
comprehensive_income: [],
|
|
},
|
|
surface_rows: {
|
|
income: [],
|
|
balance: [],
|
|
cash_flow: [],
|
|
equity: [],
|
|
comprehensive_income: [],
|
|
},
|
|
detail_rows: {
|
|
income: {},
|
|
balance: {},
|
|
cash_flow: {},
|
|
equity: {},
|
|
comprehensive_income: {},
|
|
},
|
|
kpi_rows: [],
|
|
computed_definitions: [],
|
|
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: [],
|
|
xbrl_validation: {
|
|
status: "passed",
|
|
},
|
|
}),
|
|
);
|
|
|
|
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,
|
|
normalizeFilingTaxonomySnapshotPayload:
|
|
mockNormalizeFilingTaxonomySnapshotPayload,
|
|
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/issuer-overlays", () => ({
|
|
generateIssuerOverlayForTicker: mockGenerateIssuerOverlayForTicker,
|
|
recordIssuerOverlayBuildFailure: mockRecordIssuerOverlayBuildFailure,
|
|
}));
|
|
mock.module("@/lib/server/repos/issuer-overlays", () => ({
|
|
getActiveIssuerOverlayDefinition: mockGetActiveIssuerOverlayDefinition,
|
|
getIssuerOverlay: mockGetIssuerOverlay,
|
|
}));
|
|
mock.module("@/lib/server/taxonomy/engine", () => ({
|
|
hydrateFilingTaxonomySnapshot: mockHydrateFilingTaxonomySnapshot,
|
|
}));
|
|
mock.module("@/lib/server/taxonomy/pdf-validation", () => ({
|
|
validateMetricsWithPdfLlm: mockValidateMetricsWithPdfLlm,
|
|
}));
|
|
|
|
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();
|
|
mockValidateMetricsWithPdfLlm.mockClear();
|
|
mockGenerateIssuerOverlayForTicker.mockClear();
|
|
mockRecordIssuerOverlayBuildFailure.mockClear();
|
|
mockGetActiveIssuerOverlayDefinition.mockClear();
|
|
mockGetIssuerOverlay.mockClear();
|
|
mockNormalizeFilingTaxonomySnapshotPayload.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);
|
|
expect(mockValidateMetricsWithPdfLlm).toHaveBeenCalled();
|
|
expect(mockUpsertFilingTaxonomySnapshot).toHaveBeenCalled();
|
|
});
|
|
|
|
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/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();
|
|
});
|
|
});
|