Collapse filing sync notifications into one batch surface
This commit is contained in:
251
lib/task-notification-entries.test.ts
Normal file
251
lib/task-notification-entries.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
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!));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user