Files
accounted/lib/notices/__tests__/aggregate.test.ts
T
Jakob Wennberg a8980a3a41 perf(hem): stream the home page in three sections and trim its query plan (#1945)
Hem was one ~33-query render behind a single fallback: the greeting waited
for the slowest worklist scan, and the request also paid a sequential
bank_file_imports read after the batch, a second scan of the suggested
matches (getWorklistCounts counted the same 200 rows the pane listed) and
an awaited stale-dismissal delete on the read path.

- page.tsx awaits only what the greeting shell and the redirects need
  (settings, profile, agent profile, the Skatteverket flag); the notice
  line, the setup checklist and the Att göra + Fortsätt panes are async
  server components behind their own Suspense (hem-sections.tsx). RSC
  streaming applies to client navigations too, so the greeting paints
  first on every visit and each block fills in as its queries land.
- DashboardContent becomes the shell with three slots; HemNotices keeps
  the one client-side action (the wrong-account sign-out); HemSkeletons
  are the two fallbacks.
- getWorklistCounts accepts the suggested matches the caller is already
  fetching (a promise, so it stays parallel); listSuggestedMatches runs
  once at the scan cap and the pane shows the first five.
- countInboxDocuments runs its id chunks in one wave instead of N
  sequential round trips.
- getCompanyNotices takes deferReap; Hem passes Next's after() so the
  stale-dismissal delete runs after the response.
- bank_file_imports joins the checklist section's batch.

Tests: worklist aggregate (precomputed matches skip the rescan), notices
aggregate (deferReap receives the reap; the delete does not run inline
and runs when the task does).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:02:09 +02:00

198 lines
8.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createMockSupabase, createQueuedMockSupabase } from '@/tests/helpers'
import type { Notice } from '../types'
const detectMocks = vi.hoisted(() => ({
broken: vi.fn(),
skv: vi.fn(),
backup: vi.fn(),
expiring: vi.fn(),
unexplained: vi.fn(),
other: vi.fn(),
}))
vi.mock('../categories', () => ({
detectBrokenBankConnections: detectMocks.broken,
detectSkvDisconnected: detectMocks.skv,
detectBackupFailing: detectMocks.backup,
detectExpiringBankConnections: detectMocks.expiring,
detectSkvUnexplained: detectMocks.unexplained,
detectOtherAccountHint: detectMocks.other,
}))
import { getCompanyNotices } from '../aggregate'
const notice = (category: Notice['category'], id: string): Notice => ({
id,
category,
severity: 'warning',
messageKey: category,
actionKey: `${category}_action`,
actionHref: '/x',
})
const { supabase: mockSupabase, mockResult } = createMockSupabase()
const supabase = mockSupabase as unknown as SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
detectMocks.broken.mockResolvedValue(null)
detectMocks.skv.mockResolvedValue(null)
detectMocks.backup.mockResolvedValue(null)
detectMocks.expiring.mockResolvedValue(null)
detectMocks.unexplained.mockResolvedValue(null)
detectMocks.other.mockResolvedValue(null)
mockResult({ data: [] }) // notice_dismissals: none
})
describe('getCompanyNotices', () => {
it('returns an empty list when nothing is degraded', async () => {
await expect(
getCompanyNotices(supabase, 'company-1', { userId: 'user-1' }),
).resolves.toEqual([])
})
it('orders notices by the documented priority regardless of resolution order', async () => {
detectMocks.other.mockResolvedValue(notice('other_account_hint', 'other_account_hint'))
detectMocks.expiring.mockResolvedValue(notice('bank_connection_expiring', 'exp:1'))
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'broken:1'))
detectMocks.backup.mockResolvedValue(notice('backup_failing', 'backup:1'))
detectMocks.skv.mockResolvedValue(notice('skv_disconnected', 'skv:1'))
const notices = await getCompanyNotices(supabase, 'company-1', { userId: 'user-1' })
expect(notices.map((n) => n.category)).toEqual([
'bank_connection_broken',
'skv_disconnected',
'backup_failing',
'bank_connection_expiring',
'other_account_hint',
])
})
it('hides dismissed notice ids and keeps the rest', async () => {
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'broken:1'))
detectMocks.skv.mockResolvedValue(notice('skv_disconnected', 'skv:1'))
mockResult({ data: [{ notice_id: 'broken:1' }] })
const notices = await getCompanyNotices(supabase, 'company-1', { userId: 'user-1' })
expect(notices.map((n) => n.id)).toEqual(['skv:1'])
})
it('does NOT hide a notice whose state discriminator changed since the dismissal', async () => {
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'broken:2'))
mockResult({ data: [{ notice_id: 'broken:1' }] })
const notices = await getCompanyNotices(supabase, 'company-1', { userId: 'user-1' })
expect(notices.map((n) => n.id)).toEqual(['broken:2'])
})
it('shows everything when the dismissal read fails (over-show beats hiding a real problem)', async () => {
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'broken:1'))
mockResult({ error: { message: 'boom' } })
const notices = await getCompanyNotices(supabase, 'company-1', { userId: 'user-1' })
expect(notices.map((n) => n.id)).toEqual(['broken:1'])
})
it('passes the caller identity through to the per-user predicates', async () => {
const now = new Date('2026-08-19T12:00:00Z')
await getCompanyNotices(supabase, 'company-1', { userId: 'user-1', now })
expect(detectMocks.skv).toHaveBeenCalledWith(supabase, 'user-1', 'company-1', now)
expect(detectMocks.expiring).toHaveBeenCalledWith(supabase, 'company-1', now)
})
})
describe('stale-dismissal reaping (contract in lib/notices/types.ts)', () => {
// A queued mock records builder calls, so the delete (or its absence) is
// observable. The backup id is timestamp-free and identical per incident,
// making it the category whose resurface behavior depends on reaping.
const BACKUP_ID = 'backup_failing:google_drive=sync_error'
const backupNotice = notice('backup_failing', BACKUP_ID)
const queued = createQueuedMockSupabase()
const qSupabase = queued.supabase as unknown as SupabaseClient
beforeEach(() => {
queued.reset()
})
it('keeps a persisting failure dismissed across two aggregations (no reap while failing)', async () => {
detectMocks.backup.mockResolvedValue(backupNotice)
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
const first = await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
const second = await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
expect(first).toEqual([])
expect(second).toEqual([])
expect(queued.findCalls('notice_dismissals', 'delete')).toEqual([])
})
it('reaps the stored dismissal once the category is healthy again', async () => {
// All detects resolve null (healthy) via the outer beforeEach.
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] }) // dismissal read
queued.enqueue({ data: null }) // delete result
const notices = await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
expect(notices).toEqual([])
expect(queued.findCalls('notice_dismissals', 'delete').length).toBe(1)
expect(queued.findCall('notice_dismissals', 'in')).toEqual(['notice_id', [BACKUP_ID]])
})
it('hands the reap to deferReap instead of awaiting it on the read path (Hem streams)', async () => {
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] }) // dismissal read
queued.enqueue({ data: null }) // delete result, consumed only when the task runs
const deferred: Array<() => Promise<void>> = []
const notices = await getCompanyNotices(qSupabase, 'company-1', {
userId: 'user-1',
deferReap: (task) => deferred.push(task),
})
expect(notices).toEqual([])
expect(deferred).toHaveLength(1)
expect(queued.findCalls('notice_dismissals', 'delete')).toEqual([])
await deferred[0]()
expect(queued.findCalls('notice_dismissals', 'delete').length).toBe(1)
expect(queued.findCall('notice_dismissals', 'in')).toEqual(['notice_id', [BACKUP_ID]])
})
it('resurfaces a NEW failure after the healthy spell reaped the dismissal', async () => {
// error -> dismiss: hidden while the incident persists.
detectMocks.backup.mockResolvedValue(backupNotice)
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
expect(await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })).toEqual([])
// success: the read reaps the now-stale dismissal.
detectMocks.backup.mockResolvedValue(null)
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
queued.enqueue({ data: null })
expect(await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })).toEqual([])
expect(queued.findCall('notice_dismissals', 'in')).toEqual(['notice_id', [BACKUP_ID]])
// new error later, same id: the dismissal is gone, so it surfaces again.
detectMocks.backup.mockResolvedValue(backupNotice)
queued.enqueue({ data: [] })
expect(
(await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })).map((n) => n.id),
).toEqual([BACKUP_ID])
})
it('never reaps other_account_hint: its id has no category-prefix discriminator', async () => {
queued.enqueue({ data: [{ notice_id: 'other_account_hint' }] })
await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
expect(queued.findCalls('notice_dismissals', 'delete')).toEqual([])
})
it('swallows a failed reap and still returns the computed notices', async () => {
detectMocks.broken.mockResolvedValue(notice('bank_connection_broken', 'bank_connection_broken:c1=expired'))
queued.enqueue({ data: [{ notice_id: BACKUP_ID }] })
queued.enqueue({ error: { message: 'boom' } }) // delete fails
const notices = await getCompanyNotices(qSupabase, 'company-1', { userId: 'user-1' })
expect(notices.map((n) => n.id)).toEqual(['bank_connection_broken:c1=expired'])
})
})