252 lines
7.8 KiB
TypeScript
252 lines
7.8 KiB
TypeScript
import { describe, expect, it } from 'bun:test';
|
|
import { buildTaskNotification } from '@/lib/server/task-notifications';
|
|
import {
|
|
buildNotificationEntries,
|
|
notificationEntrySignature,
|
|
type FilingSyncBatchState
|
|
} from '@/lib/task-notification-entries';
|
|
import type { Task } from '@/lib/types';
|
|
|
|
function makeTask(overrides: Partial<Omit<Task, 'notification'>> = {}): Task {
|
|
const task = {
|
|
id: 'task-1',
|
|
user_id: 'user-1',
|
|
task_type: 'sync_filings',
|
|
status: 'running',
|
|
stage: 'sync.extract_taxonomy',
|
|
stage_detail: 'Extracting taxonomy for NVDA',
|
|
stage_context: {
|
|
progress: {
|
|
current: 2,
|
|
total: 5,
|
|
unit: 'filings'
|
|
},
|
|
counters: {
|
|
fetched: 5,
|
|
inserted: 2,
|
|
updated: 1,
|
|
hydrated: 1,
|
|
failed: 0
|
|
},
|
|
subject: {
|
|
ticker: 'NVDA',
|
|
accessionNumber: '0000000000-26-000001'
|
|
}
|
|
},
|
|
resource_key: 'sync_filings:NVDA',
|
|
notification_read_at: null,
|
|
notification_silenced_at: null,
|
|
priority: 50,
|
|
payload: {
|
|
ticker: 'NVDA',
|
|
limit: 20
|
|
},
|
|
result: null,
|
|
error: null,
|
|
attempts: 1,
|
|
max_attempts: 3,
|
|
workflow_run_id: 'run-1',
|
|
created_at: '2026-03-14T10:00:00.000Z',
|
|
updated_at: '2026-03-14T10:05:00.000Z',
|
|
finished_at: null,
|
|
...overrides
|
|
} satisfies Omit<Task, 'notification'>;
|
|
|
|
return {
|
|
...task,
|
|
notification: buildTaskNotification(task)
|
|
};
|
|
}
|
|
|
|
function batchState(overrides: Partial<FilingSyncBatchState> = {}): FilingSyncBatchState {
|
|
return {
|
|
active: true,
|
|
taskIds: ['task-1', 'task-2'],
|
|
latestTaskId: 'task-2',
|
|
startedAt: '2026-03-14T10:00:00.000Z',
|
|
finishedAt: null,
|
|
terminalVisible: false,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
describe('task notification entries', () => {
|
|
it('collapses multiple active filing sync tasks into one aggregate entry', () => {
|
|
const first = makeTask();
|
|
const second = makeTask({
|
|
id: 'task-2',
|
|
resource_key: 'sync_filings:MSFT',
|
|
payload: { ticker: 'MSFT', limit: 20 },
|
|
stage_context: {
|
|
progress: { current: 3, total: 5, unit: 'filings' },
|
|
counters: { fetched: 4, inserted: 1, updated: 2, hydrated: 0, failed: 0 },
|
|
subject: { ticker: 'MSFT', accessionNumber: '0000000000-26-000002' }
|
|
},
|
|
updated_at: '2026-03-14T10:06:00.000Z'
|
|
});
|
|
|
|
const entries = buildNotificationEntries({
|
|
activeTasks: [first, second],
|
|
finishedTasks: [],
|
|
filingSyncBatch: batchState()
|
|
});
|
|
|
|
const filingEntries = entries.filter((entry) => entry.kind === 'filing_sync_batch');
|
|
expect(filingEntries).toHaveLength(1);
|
|
expect(filingEntries[0]).toMatchObject({
|
|
id: 'filing-sync:active',
|
|
status: 'running',
|
|
statusLine: 'Syncing filings for 2 tickers',
|
|
detailLine: '2 running • 0 queued',
|
|
primaryTaskId: 'task-2'
|
|
});
|
|
expect(filingEntries[0]?.stats).toEqual([
|
|
{ label: 'Fetched', value: '9' },
|
|
{ label: 'Inserted', value: '3' },
|
|
{ label: 'Updated', value: '3' },
|
|
{ label: 'Hydrated', value: '1' },
|
|
{ label: 'Failed', value: '0' }
|
|
]);
|
|
});
|
|
|
|
it('builds active filing sync detail from mixed queued and running tasks', () => {
|
|
const running = makeTask();
|
|
const queued = makeTask({
|
|
id: 'task-2',
|
|
status: 'queued',
|
|
stage: 'queued',
|
|
stage_detail: 'Queued for filings sync',
|
|
resource_key: 'sync_filings:AAPL',
|
|
payload: { ticker: 'AAPL', limit: 20 },
|
|
stage_context: {
|
|
progress: { current: 0, total: 5, unit: 'filings' },
|
|
counters: { fetched: 0, inserted: 0, updated: 0, hydrated: 0, failed: 0 },
|
|
subject: { ticker: 'AAPL', accessionNumber: '0000000000-26-000003' }
|
|
},
|
|
updated_at: '2026-03-14T10:04:00.000Z'
|
|
});
|
|
|
|
const entry = buildNotificationEntries({
|
|
activeTasks: [running, queued],
|
|
finishedTasks: [],
|
|
filingSyncBatch: batchState()
|
|
}).find((candidate) => candidate.kind === 'filing_sync_batch');
|
|
|
|
expect(entry?.detailLine).toBe('1 running • 1 queued');
|
|
});
|
|
|
|
it('keeps one terminal filing sync summary for mixed completion outcomes', () => {
|
|
const completed = makeTask({
|
|
status: 'completed',
|
|
stage: 'completed',
|
|
stage_detail: 'Completed sync for NVDA',
|
|
result: {
|
|
ticker: 'NVDA',
|
|
fetched: 5,
|
|
inserted: 2,
|
|
updated: 1,
|
|
taxonomySnapshotsHydrated: 1,
|
|
taxonomySnapshotsFailed: 0
|
|
},
|
|
finished_at: '2026-03-14T10:07:00.000Z'
|
|
});
|
|
const failed = makeTask({
|
|
id: 'task-2',
|
|
status: 'failed',
|
|
stage: 'sync.persist_filings',
|
|
stage_detail: 'Persist failed for MSFT',
|
|
error: 'Persist failed for MSFT',
|
|
resource_key: 'sync_filings:MSFT',
|
|
payload: { ticker: 'MSFT', limit: 20 },
|
|
stage_context: {
|
|
progress: { current: 5, total: 5, unit: 'filings' },
|
|
counters: { fetched: 5, inserted: 0, updated: 0, hydrated: 0, failed: 1 },
|
|
subject: { ticker: 'MSFT', accessionNumber: '0000000000-26-000002' }
|
|
},
|
|
finished_at: '2026-03-14T10:08:00.000Z',
|
|
updated_at: '2026-03-14T10:08:00.000Z'
|
|
});
|
|
|
|
const entry = buildNotificationEntries({
|
|
activeTasks: [],
|
|
finishedTasks: [completed, failed],
|
|
filingSyncBatch: batchState({
|
|
active: false,
|
|
terminalVisible: true,
|
|
finishedAt: '2026-03-14T10:08:00.000Z'
|
|
})
|
|
}).find((candidate) => candidate.kind === 'filing_sync_batch');
|
|
|
|
expect(entry).toMatchObject({
|
|
status: 'failed',
|
|
statusLine: 'Filing sync finished with issues',
|
|
detailLine: '2 tickers processed • 1 failed'
|
|
});
|
|
});
|
|
|
|
it('leaves non-sync tasks ungrouped', () => {
|
|
const sync = makeTask();
|
|
const refresh = makeTask({
|
|
id: 'task-3',
|
|
task_type: 'refresh_prices',
|
|
resource_key: 'refresh_prices:portfolio',
|
|
payload: {},
|
|
stage: 'refresh.fetch_quotes',
|
|
stage_detail: 'Fetching quotes',
|
|
stage_context: {
|
|
progress: { current: 3, total: 4, unit: 'tickers' },
|
|
counters: { updatedCount: 2, holdings: 4 },
|
|
subject: { ticker: 'NVDA' }
|
|
}
|
|
});
|
|
|
|
const entries = buildNotificationEntries({
|
|
activeTasks: [sync, refresh],
|
|
finishedTasks: [],
|
|
filingSyncBatch: batchState({ taskIds: ['task-1'] })
|
|
});
|
|
|
|
expect(entries.filter((entry) => entry.kind === 'single')).toHaveLength(1);
|
|
expect(entries.some((entry) => entry.id === 'task-3')).toBe(true);
|
|
expect(entries.some((entry) => entry.kind === 'filing_sync_batch')).toBe(true);
|
|
});
|
|
|
|
it('ignores noisy filing sync detail-only changes in the aggregate signature', () => {
|
|
const first = makeTask();
|
|
const second = makeTask({
|
|
id: 'task-2',
|
|
resource_key: 'sync_filings:MSFT',
|
|
payload: { ticker: 'MSFT', limit: 20 },
|
|
stage_detail: 'Extracting taxonomy for MSFT',
|
|
stage_context: {
|
|
progress: { current: 2, total: 5, unit: 'filings' },
|
|
counters: { fetched: 4, inserted: 1, updated: 2, hydrated: 0, failed: 0 },
|
|
subject: { ticker: 'MSFT', accessionNumber: '0000000000-26-000002' }
|
|
},
|
|
updated_at: '2026-03-14T10:06:00.000Z'
|
|
});
|
|
|
|
const original = buildNotificationEntries({
|
|
activeTasks: [first, second],
|
|
finishedTasks: [],
|
|
filingSyncBatch: batchState()
|
|
}).find((entry) => entry.kind === 'filing_sync_batch');
|
|
|
|
const { notification, ...secondCore } = second;
|
|
void notification;
|
|
const noisyUpdate = makeTask({
|
|
...secondCore,
|
|
stage_detail: 'Extracting taxonomy for MSFT filing 2/5'
|
|
});
|
|
const updated = buildNotificationEntries({
|
|
activeTasks: [first, noisyUpdate],
|
|
finishedTasks: [],
|
|
filingSyncBatch: batchState()
|
|
}).find((entry) => entry.kind === 'filing_sync_batch');
|
|
|
|
expect(original).toBeTruthy();
|
|
expect(updated).toBeTruthy();
|
|
expect(notificationEntrySignature(original!)).toBe(notificationEntrySignature(updated!));
|
|
});
|
|
});
|