Files
accounted/lib/notices/__tests__/aggregate.test.ts
T
9fc05c383f feat(notices): one aggregated notice line instead of stacked degraded-state banners (#1733)
* feat(notices): lib/notices aggregator + single notice line on Hem

Degraded-state surfaces (broken/expiring bank connections, Skatteverket
reconnect, failing cloud backups, wrong-account hint) each hand-rolled
their own detection and stacked independently on the dashboard. This adds
lib/notices, mirroring lib/worklist, as the single owner of every health
predicate, and de-clutters the surfaces:

- lib/notices/{types,predicates,categories,aggregate}: five documented
  categories with a fixed priority order; every predicate soft-fails to
  null; pure decision helpers live in predicates.ts so 'use client' pages
  can import them without pulling server-only modules. Broken supersedes
  expiring for the same bank connection by construction (status filter).
- GET /api/notices + POST /api/notices/dismiss (withRouteContext), and a
  notice_dismissals table (per company+user+notice_id, RLS user-scoped).
  Notice ids embed a state discriminator, so a dismissal hides exactly
  the state the user saw and a NEW failure surfaces again.
- Hem renders only the highest-priority notice as ONE AttnLine where the
  boxed BackupHealthBanner card sat (banner deleted; its multi-provider
  sentence logic moved into the backup_failing predicate), with a quiet
  "+N till" inline expander. otherAccountHint joins the same list as the
  lowest-priority category instead of an unconditional extra line.
- transactions and skattekonto keep their own AttnLine copy/CTA but source
  the reconnect decision from the shared skvStatusNeedsReconnect /
  skvAuthErrorNeedsReconnect predicates; Hem's Bevaka row imports the
  expiring-consent day-math instead of duplicating it.
- design.md convention 6 addendum: max one global notice line + max one
  page-domain attn line (locked convention: needs founder sign-off).
- i18n: new notices namespace in sv+en; moved banner/hint keys deleted.
- notice_dismissals classified as archive-excluded (UI state, not
  räkenskapsinformation) to satisfy the full-archive contract.

SkatteverketPromoCard keeps its localStorage dismiss for now; migrating it
to notice_dismissals is a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(notices): stable dismissals with reaping, bounded ids, unnamed-bank copy

Review fixes on the notice aggregator:

- Migration renamed 20260819080000 -> 20260819190000_notice_dismissals.sql
  (version collision with another in-flight PR; content unchanged).
- backup_failing dismissal stability: the id no longer embeds
  last_auto_sync_at / needs_reauth_at, which the cron re-stamps while the
  SAME incident persists and so resurrected a dismissed notice daily. The
  id is now stable per (provider, reason), and the opposite direction is
  kept correct by stale-dismissal reaping in getCompanyNotices: when a
  category is currently healthy, the caller's stored dismissals for that
  category (matched on the 'category:' id prefix) are best-effort deleted,
  so error -> dismiss -> healthy (reaped) -> new error resurfaces. Audit of
  the other ids: bank ids embed connection id + status/expiry and skv
  embeds the incident's first-error/expiry timestamp (markNeedsReconsent
  only fires post-connect), all stable per incident; they get the same
  reaping as hygiene. Contract documented on Notice.id in types.ts.
- NULL bank_name no longer interpolates the Swedish fallback 'banken' into
  the English message: a bank_broken_one_unnamed message variant (sv + en)
  is selected instead of a name param.
- Bounded notice ids: folding several connections into one discriminator
  now collapses to count + first 8 hex of a sha256 over the sorted parts
  (node:crypto, server-only) instead of concatenating uuids; single
  connection ids stay human-readable. Dismiss schema cap tightened to 200
  with an updated rationale.
- Tests: persisting failure stays dismissed across two aggregations,
  healthy state reaps, new failure after reap resurfaces, hint never
  reaped, failed reap swallowed, 30-connection id under 200 chars and
  stable across orderings, unnamed-bank variant, sorted backup id stable
  across cron re-stamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(notices): pg-real coverage for the notice_dismissals policies

The coverage gate is right to flag the migration: every policy on this table
binds company membership AND auth.uid(), and nothing exercised it. The suite
pins the property that makes the table different from the rest of the schema:
a dismissal is personal, so a colleague in the same company keeps seeing a
notice the other member hid. It also covers the upsert re-stamp (which needs
the UPDATE policy), cross-tenant refusal, dismissing on behalf of another
user, the caller-scoped DELETE that reaping relies on, and the composite key.

Falsification-verified against a real Postgres: weakening the SELECT policy
to company-only scoping fails the colleague-isolation test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 10:27:52 +02:00

176 lines
7.5 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(),
other: vi.fn(),
}))
vi.mock('../categories', () => ({
detectBrokenBankConnections: detectMocks.broken,
detectSkvDisconnected: detectMocks.skv,
detectBackupFailing: detectMocks.backup,
detectExpiringBankConnections: detectMocks.expiring,
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.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('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'])
})
})