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> = {}): 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; return { ...task, notification: buildTaskNotification(task) }; } function batchState(overrides: Partial = {}): 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!)); }); });