220 lines
5.9 KiB
TypeScript
220 lines
5.9 KiB
TypeScript
import { beforeAll, describe, expect, it, mock } from 'bun:test';
|
|
|
|
const TEST_USER_ID = 'copilot-api-user';
|
|
|
|
const mockGetSession = mock(async () => ({
|
|
id: 1,
|
|
user_id: TEST_USER_ID,
|
|
ticker: 'NVDA',
|
|
title: 'NVDA copilot',
|
|
selected_sources: ['documents', 'filings', 'research'],
|
|
pinned_artifact_ids: [],
|
|
created_at: '2026-03-14T00:00:00.000Z',
|
|
updated_at: '2026-03-14T00:00:00.000Z',
|
|
messages: []
|
|
}));
|
|
|
|
const mockRunTurn = mock(async () => ({
|
|
session: {
|
|
id: 1,
|
|
user_id: TEST_USER_ID,
|
|
ticker: 'NVDA',
|
|
title: 'NVDA copilot',
|
|
selected_sources: ['filings'],
|
|
pinned_artifact_ids: [4],
|
|
created_at: '2026-03-14T00:00:00.000Z',
|
|
updated_at: '2026-03-14T00:00:01.000Z',
|
|
messages: []
|
|
},
|
|
user_message: {
|
|
id: 1,
|
|
session_id: 1,
|
|
user_id: TEST_USER_ID,
|
|
role: 'user',
|
|
content_markdown: 'What changed?',
|
|
citations: [],
|
|
follow_ups: [],
|
|
suggested_actions: [],
|
|
selected_sources: ['filings'],
|
|
pinned_artifact_ids: [4],
|
|
memo_section: 'thesis',
|
|
created_at: '2026-03-14T00:00:00.000Z'
|
|
},
|
|
assistant_message: {
|
|
id: 2,
|
|
session_id: 1,
|
|
user_id: TEST_USER_ID,
|
|
role: 'assistant',
|
|
content_markdown: 'Demand stayed strong [1].',
|
|
citations: [{
|
|
index: 1,
|
|
label: 'NVDA · 0001 [1]',
|
|
chunkId: 1,
|
|
href: '/analysis/reports/NVDA/0001',
|
|
source: 'filings',
|
|
sourceKind: 'filing_brief',
|
|
sourceRef: '0001',
|
|
title: '10-K brief',
|
|
ticker: 'NVDA',
|
|
accessionNumber: '0001',
|
|
filingDate: '2026-02-18',
|
|
excerpt: 'Demand stayed strong.',
|
|
artifactId: 5
|
|
}],
|
|
follow_ups: ['What changed in risks?'],
|
|
suggested_actions: [],
|
|
selected_sources: ['filings'],
|
|
pinned_artifact_ids: [4],
|
|
memo_section: 'thesis',
|
|
created_at: '2026-03-14T00:00:01.000Z'
|
|
},
|
|
results: []
|
|
}));
|
|
|
|
const mockGenerateBrief = mock(async () => ({
|
|
provider: 'test',
|
|
model: 'test-model',
|
|
bodyMarkdown: '# NVDA brief\n\nDemand held up.',
|
|
evidence: []
|
|
}));
|
|
|
|
const mockFindInFlightTask = mock(async () => null);
|
|
const mockEnqueueTask = mock(async () => ({
|
|
id: 'task-1',
|
|
user_id: TEST_USER_ID,
|
|
task_type: 'research_brief',
|
|
status: 'queued',
|
|
stage: 'queued',
|
|
stage_detail: 'Queued',
|
|
stage_context: null,
|
|
resource_key: 'research_brief:NVDA:update the thesis',
|
|
notification_read_at: null,
|
|
notification_silenced_at: null,
|
|
priority: 55,
|
|
payload: {
|
|
ticker: 'NVDA',
|
|
query: 'Update the thesis',
|
|
sources: ['filings']
|
|
},
|
|
result: null,
|
|
error: null,
|
|
attempts: 0,
|
|
max_attempts: 3,
|
|
workflow_run_id: 'run-1',
|
|
created_at: '2026-03-14T00:00:00.000Z',
|
|
updated_at: '2026-03-14T00:00:00.000Z',
|
|
finished_at: null
|
|
}));
|
|
|
|
function registerMocks() {
|
|
mock.module('@/lib/server/auth-session', () => ({
|
|
requireAuthenticatedSession: async () => ({
|
|
session: {
|
|
user: {
|
|
id: TEST_USER_ID,
|
|
email: 'copilot@example.com',
|
|
name: 'Copilot API User',
|
|
image: null
|
|
}
|
|
},
|
|
response: null
|
|
})
|
|
}));
|
|
|
|
mock.module('@/lib/server/repos/research-copilot', () => ({
|
|
getResearchCopilotSessionByTicker: mockGetSession
|
|
}));
|
|
|
|
mock.module('@/lib/server/research-copilot', () => ({
|
|
runResearchCopilotTurn: mockRunTurn,
|
|
generateResearchBrief: mockGenerateBrief
|
|
}));
|
|
|
|
mock.module('@/lib/server/tasks', () => ({
|
|
enqueueTask: mockEnqueueTask,
|
|
findInFlightTask: mockFindInFlightTask,
|
|
getTaskById: mock(async () => null),
|
|
getTaskQueueSnapshot: mock(async () => ({ items: [], stats: { queued: 0, running: 0, failed: 0 } })),
|
|
getTaskTimeline: mock(async () => []),
|
|
listRecentTasks: mock(async () => []),
|
|
updateTaskNotification: mock(async () => null)
|
|
}));
|
|
}
|
|
|
|
describe('research copilot api', () => {
|
|
let app: { handle: (request: Request) => Promise<Response> };
|
|
|
|
beforeAll(async () => {
|
|
mock.restore();
|
|
registerMocks();
|
|
({ app } = await import('./app'));
|
|
});
|
|
|
|
it('returns the ticker-scoped session payload', async () => {
|
|
const response = await app.handle(new Request('http://localhost/api/research/copilot/session?ticker=nvda'));
|
|
expect(response.status).toBe(200);
|
|
const payload = await response.json() as { session: { ticker: string } };
|
|
expect(payload.session.ticker).toBe('NVDA');
|
|
expect(mockGetSession).toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns turn responses with assistant citations', async () => {
|
|
const response = await app.handle(new Request('http://localhost/api/research/copilot/turn', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
ticker: 'nvda',
|
|
query: 'What changed?',
|
|
sources: ['filings'],
|
|
pinnedArtifactIds: [4],
|
|
memoSection: 'thesis'
|
|
})
|
|
}));
|
|
|
|
expect(response.status).toBe(200);
|
|
const payload = await response.json() as {
|
|
assistant_message: {
|
|
citations: Array<{ artifactId: number | null }>;
|
|
};
|
|
};
|
|
|
|
expect(payload.assistant_message.citations[0]?.artifactId).toBe(5);
|
|
expect(mockRunTurn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('queues research brief jobs with normalized ticker payloads', async () => {
|
|
const response = await app.handle(new Request('http://localhost/api/research/copilot/job', {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
ticker: 'nvda',
|
|
query: 'Update the thesis',
|
|
sources: ['filings']
|
|
})
|
|
}));
|
|
|
|
expect(response.status).toBe(200);
|
|
const payload = await response.json() as {
|
|
task: {
|
|
task_type: string;
|
|
payload: {
|
|
ticker: string;
|
|
};
|
|
};
|
|
};
|
|
|
|
expect(payload.task.task_type).toBe('research_brief');
|
|
expect(payload.task.payload.ticker).toBe('NVDA');
|
|
expect(mockFindInFlightTask).toHaveBeenCalledWith(
|
|
TEST_USER_ID,
|
|
'research_brief',
|
|
'research_brief:NVDA:update the thesis'
|
|
);
|
|
expect(mockEnqueueTask).toHaveBeenCalled();
|
|
});
|
|
});
|