Implement fiscal-style research MVP flows
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled

This commit is contained in:
2026-03-07 09:51:18 -05:00
parent f69e5b671b
commit 52136271d3
26 changed files with 2719 additions and 243 deletions

View File

@@ -1,9 +1,12 @@
import { Elysia, t } from 'elysia';
import { getWorld } from 'workflow/runtime';
import type {
CoveragePriority,
CoverageStatus,
Filing,
FinancialHistoryWindow,
FinancialStatementKind,
ResearchJournalEntryType,
TaskStatus
} from '@/lib/types';
import { auth } from '@/lib/auth';
@@ -15,18 +18,32 @@ import {
getCompanyFinancialTaxonomy
} from '@/lib/server/financial-taxonomy';
import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction';
import { getFilingByAccession, listFilingsRecords } from '@/lib/server/repos/filings';
import {
getFilingByAccession,
listFilingsRecords,
listLatestFilingDatesByTickers
} from '@/lib/server/repos/filings';
import {
deleteHoldingByIdRecord,
getHoldingByTicker,
listUserHoldings,
updateHoldingByIdRecord,
upsertHoldingRecord
} from '@/lib/server/repos/holdings';
import { getLatestPortfolioInsight } from '@/lib/server/repos/insights';
import {
createResearchJournalEntryRecord,
deleteResearchJournalEntryRecord,
listResearchJournalEntries,
updateResearchJournalEntryRecord
} from '@/lib/server/repos/research-journal';
import {
deleteWatchlistItemRecord,
getWatchlistItemById,
getWatchlistItemByTicker,
listWatchlistItems,
updateWatchlistItemRecord,
updateWatchlistReviewByTicker,
upsertWatchlistItemRecord
} from '@/lib/server/repos/watchlist';
import { getPriceHistory, getQuote } from '@/lib/server/prices';
@@ -52,6 +69,9 @@ const FINANCIAL_STATEMENT_KINDS: FinancialStatementKind[] = [
'comprehensive_income'
];
const FINANCIAL_HISTORY_WINDOWS: FinancialHistoryWindow[] = ['10y', 'all'];
const COVERAGE_STATUSES: CoverageStatus[] = ['backlog', 'active', 'watch', 'archive'];
const COVERAGE_PRIORITIES: CoveragePriority[] = ['low', 'medium', 'high'];
const JOURNAL_ENTRY_TYPES: ResearchJournalEntryType[] = ['note', 'filing_note', 'status_change'];
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -94,6 +114,14 @@ function asOptionalString(value: unknown) {
return normalized.length > 0 ? normalized : null;
}
function asOptionalRecord(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function asTags(value: unknown) {
const source = Array.isArray(value)
? value
@@ -130,6 +158,31 @@ function asHistoryWindow(value: unknown): FinancialHistoryWindow {
: '10y';
}
function asCoverageStatus(value: unknown) {
return COVERAGE_STATUSES.includes(value as CoverageStatus)
? value as CoverageStatus
: undefined;
}
function asCoveragePriority(value: unknown) {
return COVERAGE_PRIORITIES.includes(value as CoveragePriority)
? value as CoveragePriority
: undefined;
}
function asJournalEntryType(value: unknown) {
return JOURNAL_ENTRY_TYPES.includes(value as ResearchJournalEntryType)
? value as ResearchJournalEntryType
: undefined;
}
function formatLabel(value: string) {
return value
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function withFinancialMetricsPolicy(filing: Filing): Filing {
if (FINANCIAL_FORMS.has(filing.filing_type)) {
return filing;
@@ -266,7 +319,14 @@ export const app = new Elysia({ prefix: '/api' })
}
const items = await listWatchlistItems(session.user.id);
return Response.json({ items });
const latestFilingDates = await listLatestFilingDatesByTickers(items.map((item) => item.ticker));
return Response.json({
items: items.map((item) => ({
...item,
latest_filing_date: latestFilingDates.get(item.ticker) ?? null
}))
});
})
.post('/watchlist', async ({ body }) => {
const { session, response } = await requireAuthenticatedSession();
@@ -280,6 +340,9 @@ export const app = new Elysia({ prefix: '/api' })
const sector = asOptionalString(payload.sector) ?? '';
const category = asOptionalString(payload.category) ?? '';
const tags = asTags(payload.tags);
const status = asCoverageStatus(payload.status);
const priority = asCoveragePriority(payload.priority);
const lastReviewedAt = asOptionalString(payload.lastReviewedAt);
if (!ticker) {
return jsonError('ticker is required');
@@ -296,7 +359,10 @@ export const app = new Elysia({ prefix: '/api' })
companyName,
sector,
category,
tags
tags,
status,
priority,
lastReviewedAt
});
const autoFilingSyncQueued = created
@@ -316,9 +382,94 @@ export const app = new Elysia({ prefix: '/api' })
companyName: t.String({ minLength: 1 }),
sector: t.Optional(t.String()),
category: t.Optional(t.String()),
tags: t.Optional(t.Union([t.Array(t.String()), t.String()]))
tags: t.Optional(t.Union([t.Array(t.String()), t.String()])),
status: t.Optional(t.Union([
t.Literal('backlog'),
t.Literal('active'),
t.Literal('watch'),
t.Literal('archive')
])),
priority: t.Optional(t.Union([
t.Literal('low'),
t.Literal('medium'),
t.Literal('high')
])),
lastReviewedAt: t.Optional(t.String())
})
})
.patch('/watchlist/:id', async ({ params, body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const numericId = Number(params.id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return jsonError('Invalid watchlist id', 400);
}
const existing = await getWatchlistItemById(session.user.id, numericId);
if (!existing) {
return jsonError('Watchlist item not found', 404);
}
const payload = asRecord(body);
const nextStatus = payload.status === undefined
? existing.status
: asCoverageStatus(payload.status);
const nextPriority = payload.priority === undefined
? existing.priority
: asCoveragePriority(payload.priority);
if (payload.status !== undefined && !nextStatus) {
return jsonError('Invalid coverage status', 400);
}
if (payload.priority !== undefined && !nextPriority) {
return jsonError('Invalid coverage priority', 400);
}
try {
const item = await updateWatchlistItemRecord({
userId: session.user.id,
id: numericId,
companyName: payload.companyName === undefined ? undefined : (typeof payload.companyName === 'string' ? payload.companyName : ''),
sector: payload.sector === undefined ? undefined : (typeof payload.sector === 'string' ? payload.sector : ''),
category: payload.category === undefined ? undefined : (typeof payload.category === 'string' ? payload.category : ''),
tags: payload.tags === undefined ? undefined : asTags(payload.tags),
status: nextStatus,
priority: nextPriority,
lastReviewedAt: payload.lastReviewedAt === undefined ? undefined : asOptionalString(payload.lastReviewedAt)
});
if (!item) {
return jsonError('Watchlist item not found', 404);
}
const statusChanged = existing.status !== item.status;
if (statusChanged) {
await createResearchJournalEntryRecord({
userId: session.user.id,
ticker: item.ticker,
entryType: 'status_change',
title: `Coverage status changed to ${formatLabel(item.status)}`,
bodyMarkdown: `Coverage status changed from ${formatLabel(existing.status)} to ${formatLabel(item.status)}.`,
metadata: {
previousStatus: existing.status,
nextStatus: item.status,
priority: item.priority
}
});
}
return Response.json({
item,
statusChangeJournalCreated: statusChanged
});
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to update coverage item'));
}
})
.delete('/watchlist/:id', async ({ params }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
@@ -377,13 +528,15 @@ export const app = new Elysia({ prefix: '/api' })
try {
const currentPrice = asPositiveNumber(payload.currentPrice) ?? avgCost;
const companyName = asOptionalString(payload.companyName) ?? undefined;
const { holding, created } = await upsertHoldingRecord({
userId: session.user.id,
ticker,
shares,
avgCost,
currentPrice
currentPrice,
companyName
});
const autoFilingSyncQueued = created
@@ -399,7 +552,8 @@ export const app = new Elysia({ prefix: '/api' })
ticker: t.String({ minLength: 1 }),
shares: t.Number({ exclusiveMinimum: 0 }),
avgCost: t.Number({ exclusiveMinimum: 0 }),
currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 }))
currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 })),
companyName: t.Optional(t.String())
})
})
.patch('/portfolio/holdings/:id', async ({ params, body }) => {
@@ -420,7 +574,8 @@ export const app = new Elysia({ prefix: '/api' })
id: numericId,
shares: asPositiveNumber(payload.shares) ?? undefined,
avgCost: asPositiveNumber(payload.avgCost) ?? undefined,
currentPrice: asPositiveNumber(payload.currentPrice) ?? undefined
currentPrice: asPositiveNumber(payload.currentPrice) ?? undefined,
companyName: payload.companyName === undefined ? undefined : (asOptionalString(payload.companyName) ?? '')
});
if (!updated) {
@@ -435,7 +590,8 @@ export const app = new Elysia({ prefix: '/api' })
body: t.Object({
shares: t.Optional(t.Number({ exclusiveMinimum: 0 })),
avgCost: t.Optional(t.Number({ exclusiveMinimum: 0 })),
currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 }))
currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 })),
companyName: t.Optional(t.String())
})
})
.delete('/portfolio/holdings/:id', async ({ params }) => {
@@ -522,6 +678,127 @@ export const app = new Elysia({ prefix: '/api' })
return Response.json({ insight });
})
.get('/research/journal', async ({ query }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
if (!ticker) {
return jsonError('ticker is required');
}
const entries = await listResearchJournalEntries(session.user.id, ticker);
return Response.json({ entries });
}, {
query: t.Object({
ticker: t.String({ minLength: 1 })
})
})
.post('/research/journal', async ({ body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const payload = asRecord(body);
const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : '';
const entryType = asJournalEntryType(payload.entryType);
const title = asOptionalString(payload.title);
const bodyMarkdown = typeof payload.bodyMarkdown === 'string' ? payload.bodyMarkdown.trim() : '';
const accessionNumber = asOptionalString(payload.accessionNumber);
const metadata = asOptionalRecord(payload.metadata);
if (!ticker) {
return jsonError('ticker is required');
}
if (!entryType) {
return jsonError('entryType is required');
}
if (!bodyMarkdown) {
return jsonError('bodyMarkdown is required');
}
try {
const entry = await createResearchJournalEntryRecord({
userId: session.user.id,
ticker,
entryType,
title,
bodyMarkdown,
accessionNumber,
metadata
});
await updateWatchlistReviewByTicker(session.user.id, ticker, entry.updated_at);
return Response.json({ entry });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to create journal entry'));
}
})
.patch('/research/journal/:id', async ({ params, body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const numericId = Number(params.id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return jsonError('Invalid journal id', 400);
}
const payload = asRecord(body);
const title = payload.title === undefined ? undefined : asOptionalString(payload.title);
const bodyMarkdown = payload.bodyMarkdown === undefined
? undefined
: (typeof payload.bodyMarkdown === 'string' ? payload.bodyMarkdown : '');
try {
const entry = await updateResearchJournalEntryRecord({
userId: session.user.id,
id: numericId,
title,
bodyMarkdown,
metadata: payload.metadata === undefined ? undefined : asOptionalRecord(payload.metadata)
});
if (!entry) {
return jsonError('Journal entry not found', 404);
}
await updateWatchlistReviewByTicker(session.user.id, entry.ticker, entry.updated_at);
return Response.json({ entry });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to update journal entry'));
}
})
.delete('/research/journal/:id', async ({ params }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const numericId = Number(params.id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return jsonError('Invalid journal id', 400);
}
const removed = await deleteResearchJournalEntryRecord(session.user.id, numericId);
if (!removed) {
return jsonError('Journal entry not found', 404);
}
return Response.json({ success: true });
}, {
params: t.Object({
id: t.String({ minLength: 1 })
})
})
.get('/analysis/company', async ({ query }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
@@ -533,22 +810,21 @@ export const app = new Elysia({ prefix: '/api' })
return jsonError('ticker is required');
}
const [filings, holdings, watchlist, liveQuote, priceHistory] = await Promise.all([
const [filings, holding, watchlistItem, liveQuote, priceHistory, journalPreview] = await Promise.all([
listFilingsRecords({ ticker, limit: 40 }),
listUserHoldings(session.user.id),
listWatchlistItems(session.user.id),
getHoldingByTicker(session.user.id, ticker),
getWatchlistItemByTicker(session.user.id, ticker),
getQuote(ticker),
getPriceHistory(ticker)
getPriceHistory(ticker),
listResearchJournalEntries(session.user.id, ticker, 6)
]);
const redactedFilings = filings
.map(redactInternalFilingAnalysisFields)
.map(withFinancialMetricsPolicy);
const latestFiling = redactedFilings[0] ?? null;
const holding = holdings.find((entry) => entry.ticker === ticker) ?? null;
const watchlistItem = watchlist.find((entry) => entry.ticker === ticker) ?? null;
const companyName = latestFiling?.company_name
?? holding?.company_name
?? watchlistItem?.company_name
?? ticker;
@@ -575,6 +851,30 @@ export const app = new Elysia({ prefix: '/api' })
model: entry.analysis?.model ?? 'unknown',
summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? ''
}));
const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null;
const referenceMetrics = latestMetricsFiling?.metrics ?? null;
const keyMetrics = {
referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null,
revenue: referenceMetrics?.revenue ?? null,
netIncome: referenceMetrics?.netIncome ?? null,
totalAssets: referenceMetrics?.totalAssets ?? null,
cash: referenceMetrics?.cash ?? null,
debt: referenceMetrics?.debt ?? null,
netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null
? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100
: null
};
const latestFilingSummary = latestFiling
? {
accessionNumber: latestFiling.accession_number,
filingDate: latestFiling.filing_date,
filingType: latestFiling.filing_type,
filingUrl: latestFiling.filing_url,
submissionUrl: latestFiling.submission_url ?? null,
summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null,
hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights)
}
: null;
return Response.json({
analysis: {
@@ -591,7 +891,17 @@ export const app = new Elysia({ prefix: '/api' })
priceHistory,
financials,
filings: redactedFilings.slice(0, 20),
aiReports
aiReports,
coverage: watchlistItem
? {
...watchlistItem,
latest_filing_date: latestFiling?.filing_date ?? watchlistItem.latest_filing_date ?? null
}
: null,
journalPreview,
recentAiReports: aiReports.slice(0, 5),
latestFilingSummary,
keyMetrics
}
});
}, {

View File

@@ -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;