Implement fiscal-style research MVP flows
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import type { WorkflowRunStatus } from '@workflow/world';
|
||||
|
||||
const TEST_USER_ID = 'e2e-user';
|
||||
@@ -21,7 +22,7 @@ let runCounter = 0;
|
||||
let workflowBackendHealthy = true;
|
||||
|
||||
let tempDir: string | null = null;
|
||||
let sqliteClient: { exec: (query: string) => void; close: () => void } | null = null;
|
||||
let sqliteClient: Database | null = null;
|
||||
let app: { handle: (request: Request) => Promise<Response> } | null = null;
|
||||
|
||||
mock.module('workflow/api', () => ({
|
||||
@@ -87,7 +88,8 @@ function applySqlMigrations(client: { exec: (query: string) => void }) {
|
||||
'0002_workflow_task_projection_metadata.sql',
|
||||
'0003_task_stage_event_timeline.sql',
|
||||
'0004_watchlist_company_taxonomy.sql',
|
||||
'0005_financial_taxonomy_v3.sql'
|
||||
'0005_financial_taxonomy_v3.sql',
|
||||
'0006_coverage_journal_tracking.sql'
|
||||
];
|
||||
|
||||
for (const file of migrationFiles) {
|
||||
@@ -121,10 +123,72 @@ function ensureTestUser(client: { exec: (query: string) => void }) {
|
||||
function clearProjectionTables(client: { exec: (query: string) => void }) {
|
||||
client.exec('DELETE FROM task_stage_event;');
|
||||
client.exec('DELETE FROM task_run;');
|
||||
client.exec('DELETE FROM research_journal_entry;');
|
||||
client.exec('DELETE FROM holding;');
|
||||
client.exec('DELETE FROM watchlist_item;');
|
||||
client.exec('DELETE FROM portfolio_insight;');
|
||||
client.exec('DELETE FROM filing;');
|
||||
}
|
||||
|
||||
function seedFilingRecord(client: Database, input: {
|
||||
ticker: string;
|
||||
accessionNumber: string;
|
||||
filingType: '10-K' | '10-Q' | '8-K';
|
||||
filingDate: string;
|
||||
companyName: string;
|
||||
cik?: string;
|
||||
metrics?: {
|
||||
revenue: number | null;
|
||||
netIncome: number | null;
|
||||
totalAssets: number | null;
|
||||
cash: number | null;
|
||||
debt: number | null;
|
||||
} | null;
|
||||
analysisText?: string | null;
|
||||
}) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
client.query(`
|
||||
INSERT INTO filing (
|
||||
ticker,
|
||||
filing_type,
|
||||
filing_date,
|
||||
accession_number,
|
||||
cik,
|
||||
company_name,
|
||||
filing_url,
|
||||
submission_url,
|
||||
primary_document,
|
||||
metrics,
|
||||
analysis,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
`).run(
|
||||
input.ticker,
|
||||
input.filingType,
|
||||
input.filingDate,
|
||||
input.accessionNumber,
|
||||
input.cik ?? '0000000000',
|
||||
input.companyName,
|
||||
`https://www.sec.gov/Archives/${input.accessionNumber}.htm`,
|
||||
`https://www.sec.gov/submissions/${input.accessionNumber}.json`,
|
||||
`${input.accessionNumber}.htm`,
|
||||
input.metrics ? JSON.stringify(input.metrics) : null,
|
||||
input.analysisText
|
||||
? JSON.stringify({
|
||||
provider: 'test',
|
||||
model: 'fixture',
|
||||
text: input.analysisText
|
||||
})
|
||||
: null,
|
||||
now,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
async function jsonRequest(
|
||||
method: 'GET' | 'POST' | 'PATCH',
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||
path: string,
|
||||
body?: Record<string, unknown>
|
||||
) {
|
||||
@@ -154,8 +218,8 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
||||
|
||||
resetDbSingletons();
|
||||
|
||||
const dbModule = await import('@/lib/server/db');
|
||||
sqliteClient = dbModule.getSqliteClient();
|
||||
sqliteClient = new Database(join(tempDir, 'e2e.sqlite'), { create: true });
|
||||
sqliteClient.exec('PRAGMA foreign_keys = ON;');
|
||||
applySqlMigrations(sqliteClient);
|
||||
ensureTestUser(sqliteClient);
|
||||
|
||||
@@ -164,6 +228,7 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
sqliteClient?.close();
|
||||
resetDbSingletons();
|
||||
|
||||
if (tempDir) {
|
||||
@@ -291,6 +356,199 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
||||
expect(task.payload.tags).toEqual(['semis', 'ai']);
|
||||
});
|
||||
|
||||
it('updates coverage status and archives while appending status-change journal history', async () => {
|
||||
const created = await jsonRequest('POST', '/api/watchlist', {
|
||||
ticker: 'amd',
|
||||
companyName: 'Advanced Micro Devices, Inc.',
|
||||
sector: 'Technology',
|
||||
status: 'backlog',
|
||||
priority: 'medium',
|
||||
tags: ['semis']
|
||||
});
|
||||
|
||||
expect(created.response.status).toBe(200);
|
||||
const createdItem = (created.json as {
|
||||
item: { id: number; ticker: string; status: string; priority: string };
|
||||
}).item;
|
||||
expect(createdItem.status).toBe('backlog');
|
||||
expect(createdItem.priority).toBe('medium');
|
||||
|
||||
const activated = await jsonRequest('PATCH', `/api/watchlist/${createdItem.id}`, {
|
||||
status: 'active',
|
||||
priority: 'high',
|
||||
lastReviewedAt: '2026-03-01T15:30:00.000Z'
|
||||
});
|
||||
expect(activated.response.status).toBe(200);
|
||||
const activatedBody = activated.json as {
|
||||
item: { status: string; priority: string; last_reviewed_at: string | null };
|
||||
statusChangeJournalCreated: boolean;
|
||||
};
|
||||
expect(activatedBody.item.status).toBe('active');
|
||||
expect(activatedBody.item.priority).toBe('high');
|
||||
expect(activatedBody.item.last_reviewed_at).toBe('2026-03-01T15:30:00.000Z');
|
||||
expect(activatedBody.statusChangeJournalCreated).toBe(true);
|
||||
|
||||
const archived = await jsonRequest('PATCH', `/api/watchlist/${createdItem.id}`, {
|
||||
status: 'archive'
|
||||
});
|
||||
expect(archived.response.status).toBe(200);
|
||||
expect((archived.json as {
|
||||
item: { status: string };
|
||||
statusChangeJournalCreated: boolean;
|
||||
}).item.status).toBe('archive');
|
||||
|
||||
const journal = await jsonRequest('GET', '/api/research/journal?ticker=AMD');
|
||||
expect(journal.response.status).toBe(200);
|
||||
const entries = (journal.json as {
|
||||
entries: Array<{
|
||||
entry_type: string;
|
||||
title: string | null;
|
||||
}>;
|
||||
}).entries;
|
||||
expect(entries.length).toBe(2);
|
||||
expect(entries.every((entry) => entry.entry_type === 'status_change')).toBe(true);
|
||||
expect(entries[0]?.title).toContain('Archive');
|
||||
|
||||
const coverage = await jsonRequest('GET', '/api/watchlist');
|
||||
const saved = (coverage.json as {
|
||||
items: Array<{
|
||||
ticker: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
}>;
|
||||
}).items.find((item) => item.ticker === 'AMD');
|
||||
expect(saved?.status).toBe('archive');
|
||||
expect(saved?.priority).toBe('high');
|
||||
});
|
||||
|
||||
it('supports journal CRUD and includes coverage, preview, reports, and key metrics in analysis payload', async () => {
|
||||
if (!sqliteClient) {
|
||||
throw new Error('sqlite client not initialized');
|
||||
}
|
||||
|
||||
seedFilingRecord(sqliteClient, {
|
||||
ticker: 'NFLX',
|
||||
accessionNumber: '0000000000-26-000777',
|
||||
filingType: '10-K',
|
||||
filingDate: '2026-02-15',
|
||||
companyName: 'Netflix, Inc.',
|
||||
metrics: {
|
||||
revenue: 41000000000,
|
||||
netIncome: 8600000000,
|
||||
totalAssets: 52000000000,
|
||||
cash: 7800000000,
|
||||
debt: 14000000000
|
||||
},
|
||||
analysisText: 'Subscriber growth reaccelerated with improved operating leverage.'
|
||||
});
|
||||
|
||||
await jsonRequest('POST', '/api/watchlist', {
|
||||
ticker: 'nflx',
|
||||
companyName: 'Netflix, Inc.',
|
||||
sector: 'Communication Services',
|
||||
status: 'active',
|
||||
priority: 'high',
|
||||
tags: ['streaming', 'quality']
|
||||
});
|
||||
|
||||
await jsonRequest('POST', '/api/portfolio/holdings', {
|
||||
ticker: 'NFLX',
|
||||
companyName: 'Netflix, Inc.',
|
||||
shares: 12,
|
||||
avgCost: 440,
|
||||
currentPrice: 455
|
||||
});
|
||||
|
||||
const createdEntry = await jsonRequest('POST', '/api/research/journal', {
|
||||
ticker: 'NFLX',
|
||||
entryType: 'note',
|
||||
title: 'Thesis refresh',
|
||||
bodyMarkdown: 'Monitor ad-tier margin progression and content amortization.'
|
||||
});
|
||||
expect(createdEntry.response.status).toBe(200);
|
||||
const entryId = (createdEntry.json as {
|
||||
entry: { id: number };
|
||||
}).entry.id;
|
||||
|
||||
const analysis = await jsonRequest('GET', '/api/analysis/company?ticker=NFLX');
|
||||
expect(analysis.response.status).toBe(200);
|
||||
const payload = (analysis.json as {
|
||||
analysis: {
|
||||
coverage: { status: string; priority: string; tags: string[] } | null;
|
||||
journalPreview: Array<{ title: string | null; body_markdown: string }>;
|
||||
recentAiReports: Array<{ accessionNumber: string; summary: string }>;
|
||||
latestFilingSummary: { accessionNumber: string; summary: string | null } | null;
|
||||
keyMetrics: { revenue: number | null; netMargin: number | null };
|
||||
position: { company_name: string | null } | null;
|
||||
};
|
||||
}).analysis;
|
||||
|
||||
expect(payload.coverage?.status).toBe('active');
|
||||
expect(payload.coverage?.priority).toBe('high');
|
||||
expect(payload.coverage?.tags).toEqual(['streaming', 'quality']);
|
||||
expect(payload.journalPreview.length).toBe(1);
|
||||
expect(payload.journalPreview[0]?.title).toBe('Thesis refresh');
|
||||
expect(payload.recentAiReports.length).toBe(1);
|
||||
expect(payload.latestFilingSummary?.accessionNumber).toBe('0000000000-26-000777');
|
||||
expect(payload.latestFilingSummary?.summary).toContain('Subscriber growth reaccelerated');
|
||||
expect(payload.keyMetrics.revenue).toBe(41000000000);
|
||||
expect(payload.keyMetrics.netMargin).not.toBeNull();
|
||||
expect(payload.position?.company_name).toBe('Netflix, Inc.');
|
||||
|
||||
const updatedEntry = await jsonRequest('PATCH', `/api/research/journal/${entryId}`, {
|
||||
title: 'Thesis refresh v2',
|
||||
bodyMarkdown: 'Monitor ad-tier margin progression, churn, and cash content spend.'
|
||||
});
|
||||
expect(updatedEntry.response.status).toBe(200);
|
||||
expect((updatedEntry.json as {
|
||||
entry: { title: string | null; body_markdown: string };
|
||||
}).entry.title).toBe('Thesis refresh v2');
|
||||
|
||||
const journalAfterUpdate = await jsonRequest('GET', '/api/research/journal?ticker=NFLX');
|
||||
expect(journalAfterUpdate.response.status).toBe(200);
|
||||
expect((journalAfterUpdate.json as {
|
||||
entries: Array<{ title: string | null; body_markdown: string }>;
|
||||
}).entries[0]?.body_markdown).toContain('cash content spend');
|
||||
|
||||
const removed = await jsonRequest('DELETE', `/api/research/journal/${entryId}`);
|
||||
expect(removed.response.status).toBe(200);
|
||||
|
||||
const journalAfterDelete = await jsonRequest('GET', '/api/research/journal?ticker=NFLX');
|
||||
expect((journalAfterDelete.json as {
|
||||
entries: unknown[];
|
||||
}).entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('persists nullable holding company names and allows later enrichment', async () => {
|
||||
const created = await jsonRequest('POST', '/api/portfolio/holdings', {
|
||||
ticker: 'ORCL',
|
||||
shares: 5,
|
||||
avgCost: 100,
|
||||
currentPrice: 110
|
||||
});
|
||||
expect(created.response.status).toBe(200);
|
||||
|
||||
const holdings = await jsonRequest('GET', '/api/portfolio/holdings');
|
||||
expect(holdings.response.status).toBe(200);
|
||||
const saved = (holdings.json as {
|
||||
holdings: Array<{
|
||||
id: number;
|
||||
ticker: string;
|
||||
company_name: string | null;
|
||||
}>;
|
||||
}).holdings.find((entry) => entry.ticker === 'ORCL');
|
||||
|
||||
expect(saved?.company_name).toBeNull();
|
||||
|
||||
const updated = await jsonRequest('PATCH', `/api/portfolio/holdings/${saved?.id}`, {
|
||||
companyName: 'Oracle Corporation'
|
||||
});
|
||||
expect(updated.response.status).toBe(200);
|
||||
expect((updated.json as {
|
||||
holding: { company_name: string | null };
|
||||
}).holding.company_name).toBe('Oracle Corporation');
|
||||
});
|
||||
|
||||
it('updates notification read and silenced state via patch endpoint', async () => {
|
||||
const created = await jsonRequest('POST', '/api/filings/0000000000-26-000010/analyze');
|
||||
const taskId = (created.json as { task: { id: string } }).task.id;
|
||||
|
||||
Reference in New Issue
Block a user