Files
accounted/extensions/general/mcp-server/__tests__/attention.test.ts
T
5fe3ae71a1 feat(mcp): surface documents that are attached to nothing on the attention resource (#1979)
A document_attachments row is reachable from eight places. A row referenced by
none of them is stored, retained for seven years under BFL, and connected to no
bookkeeping at all. Nothing surfaced those, so they accumulated: 4 497 across
210 companies, 481 of them in the preceding week.

The naive predicate is a trap. Without a mime filter the same query returns
15 806 rows, and 11 309 of those are archived PSD2 bank-API responses that are
unlinked by design. Putting them on an orientation surface would hand an agent
eleven thousand items of work it must not do, which is worse than showing
nothing.

So the rule is an allow-list of the mime types an underlag can actually be.
Measured on production: application/json was 11 309 of 11 309 PSD2 archive, and
pdf/png/jpeg/heic were 0 of 4 495. The split is clean, and an allow-list keeps
the next machine-payload format out by default rather than after someone
notices it leaking.

Two passes, mirroring fetchPurchasesWithoutUnderlag: the indexed column filter
first, then eight reference lookups that run only when candidates exist, so a
company with none costs exactly one query. The scan cap is set by URL length
rather than table size, because every candidate id is echoed back through those
eight .in() lookups.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:00:27 +02:00

406 lines
17 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { attentionResource } from '../resources/attention'
type AttentionResponse = {
generated_at: string
summary: { total_items: number; critical: number; warning: number; info: number }
categories: Array<{
key: string
severity: 'critical' | 'warning' | 'info'
count: number
samples: Array<Record<string, unknown>>
next?: { description: string; tool?: string; args?: Record<string, unknown>; resource?: string }
}>
}
const ctx = (supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']) => ({
supabase: supabase as never,
companyId: 'company-1',
userId: 'user-1',
scopes: [],
})
/**
* Enqueues 15 baseline empty results in the order the resource consumes them.
* Tests can override individual slots before invoking by enqueueing in advance.
*/
function enqueueEmpty(enqueue: (r: { data?: unknown; error?: unknown; count?: number | null }) => void) {
// 1. unbookedHead
enqueue({ count: 0 })
// 2. unbookedSamples
enqueue({ data: [] })
// 3. overdueRows
enqueue({ data: [] })
// 4. pendingSupplierHead
enqueue({ count: 0 })
// 5. pendingSupplierSamples
enqueue({ data: [] })
// 6. pendingOpsHead
enqueue({ count: 0 })
// 7. pendingOpsSamples
enqueue({ data: [] })
// 8. unmatchedReceiptsHead
enqueue({ count: 0 })
// 9. unmatchedReceiptsSamples
enqueue({ data: [] })
// 10. voucherSeriesRows
enqueue({ data: [] })
// 11. deadlineRows
enqueue({ data: [] })
// 12. bankConnRows
enqueue({ data: [] })
// 13. activePeriodRow
enqueue({ data: null })
// 14. companySettingsRow
enqueue({ data: null })
// 15. unlinked-document candidates. fetchUnlinkedDocuments returns early on
// an empty candidate set, so it consumes exactly this one slot; with
// candidates it consumes eight more, one per referencing table.
enqueue({ data: [] })
}
describe('Accounted://attention', () => {
it('returns empty summary for a brand-new company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueEmpty(enqueue)
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
expect(result.generated_at).toMatch(/^\d{4}-\d{2}-\d{2}T/)
expect(result.summary).toEqual({ total_items: 0, critical: 0, warning: 0, info: 0 })
expect(result.categories).toEqual([])
})
it('classifies recently-unbooked transactions as warning', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const today = new Date().toISOString().slice(0, 10)
const txns = [
{ id: 't-1', date: today, amount: -100, currency: 'SEK', description: 'Lunch', merchant_name: 'Café' },
{ id: 't-2', date: today, amount: -200, currency: 'SEK', description: 'Office', merchant_name: 'Clas Ohlson' },
]
enqueue({ count: 2 }) // unbookedHead
enqueue({ data: txns }) // unbookedSamples
enqueue({ data: [] }) // overdueRows
enqueue({ count: 0 }) // pendingSupplierHead
enqueue({ data: [] }) // pendingSupplierSamples
enqueue({ count: 0 }) // pendingOpsHead
enqueue({ data: [] }) // pendingOpsSamples
enqueue({ count: 0 }) // unmatchedReceiptsHead
enqueue({ data: [] }) // unmatchedReceiptsSamples
enqueue({ data: [] }) // voucherSeriesRows
enqueue({ data: [] }) // deadlineRows
enqueue({ data: [] }) // bankConnRows
enqueue({ data: null }) // activePeriodRow
enqueue({ data: null }) // companySettingsRow
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
expect(result.categories).toHaveLength(1)
const cat = result.categories[0]
expect(cat.key).toBe('unbooked_transactions')
expect(cat.severity).toBe('warning')
expect(cat.count).toBe(2)
expect(cat.samples).toEqual(txns)
expect(cat.next?.tool).toBe('gnubok_categorize_transaction')
expect(cat.next?.args).toEqual({ transaction_id: 't-1' })
expect(result.summary).toEqual({ total_items: 2, critical: 0, warning: 1, info: 0 })
})
it('escalates unbooked transactions to critical when oldest is > 30 days old', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const fortyDaysAgo = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 10)
const txns = [{ id: 't-old', date: fortyDaysAgo, amount: -100, currency: 'SEK', description: 'X', merchant_name: null }]
enqueue({ count: 1 })
enqueue({ data: txns })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: null })
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
expect(result.categories[0]?.severity).toBe('critical')
expect(result.summary.critical).toBe(1)
})
it('flags overdue invoices as critical when any are > 30 days past due', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const fortyDaysAgo = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 10)
const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000).toISOString().slice(0, 10)
const overdue = [
{ id: 'i-1', invoice_number: 'F-2024001', customer_id: 'c-1', due_date: fortyDaysAgo, total: 1000, currency: 'SEK', status: 'overdue' },
{ id: 'i-2', invoice_number: 'F-2024002', customer_id: 'c-1', due_date: tenDaysAgo, total: 500, currency: 'SEK', status: 'sent' },
]
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: overdue })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: null })
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
const cat = result.categories.find((c) => c.key === 'overdue_invoices')
expect(cat?.severity).toBe('critical')
expect(cat?.count).toBe(2)
})
it('marks pending operations as critical when any high-risk op is queued', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const ops = [
{ id: 'op-1', operation_type: 'close_period', title: 'Stäng FY2025', risk_level: 'high', actor_label: 'Claude', created_at: new Date().toISOString() },
{ id: 'op-2', operation_type: 'create_customer', title: 'Ny kund', risk_level: 'low', actor_label: 'Claude', created_at: new Date().toISOString() },
]
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 2 })
enqueue({ data: ops })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: null })
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
const cat = result.categories.find((c) => c.key === 'pending_operations')
expect(cat?.severity).toBe('critical')
expect(cat?.count).toBe(2)
expect(result.summary.critical).toBe(1)
})
it('surfaces documents that nothing references, pointing at the link tool', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// Slots 1-14 empty, then this test's own candidate set in slot 15 and the
// eight reference lookups behind it.
for (let i = 0; i < 14; i += 1) enqueue({ data: i === 12 || i === 13 ? null : [], count: 0 })
enqueue({
data: [
{ id: 'doc-new', file_name: 'kvitto.pdf', mime_type: 'application/pdf', file_size_bytes: 900, upload_source: 'api', created_at: '2026-08-20T00:00:00.000Z' },
{ id: 'doc-old', file_name: 'faktura.pdf', mime_type: 'application/pdf', file_size_bytes: 900, upload_source: 'file_upload', created_at: '2026-08-01T00:00:00.000Z' },
],
})
for (let i = 0; i < 8; i += 1) enqueue({ data: [] })
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
const cat = result.categories.find((c) => c.key === 'unlinked_documents')
expect(cat).toBeDefined()
expect(cat?.count).toBe(2)
expect(cat?.severity).toBe('warning')
expect(cat?.next?.tool).toBe('gnubok_link_document_to_voucher')
// Oldest first, matching every other category's "start with the stalest" hint.
expect(cat?.next?.args).toEqual({ document_id: 'doc-old' })
})
it('omits the unlinked-documents category when every candidate is claimed', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
for (let i = 0; i < 14; i += 1) enqueue({ data: i === 12 || i === 13 ? null : [], count: 0 })
enqueue({
data: [
{ id: 'doc-1', file_name: 'kvitto.pdf', mime_type: 'application/pdf', file_size_bytes: 900, upload_source: 'api', created_at: '2026-08-20T00:00:00.000Z' },
],
})
// The first referencing table claims it; the remaining seven return empty.
enqueue({ data: [{ document_id: 'doc-1' }] })
for (let i = 0; i < 7; i += 1) enqueue({ data: [] })
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
expect(result.categories.find((c) => c.key === 'unlinked_documents')).toBeUndefined()
})
it('flags voucher gaps as critical and includes next tool args', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const seriesRows = [{ voucher_series: 'A', fiscal_period_id: 'fp-1' }]
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: seriesRows }) // voucherSeriesRows
enqueue({ data: [] }) // deadlineRows
enqueue({ data: [] }) // bankConnRows
enqueue({ data: null }) // activePeriodRow
enqueue({ data: null }) // companySettingsRow
enqueue({ data: [] }) // unlinked-document candidates
// Loop body for series 'A':
enqueue({ data: [{ gap_start: 5, gap_end: 7 }] }) // detect_voucher_gaps RPC
enqueue({ data: [] }) // voucher_gap_explanations follow-up
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
const cat = result.categories.find((c) => c.key === 'voucher_gaps_unexplained')
expect(cat?.severity).toBe('critical')
expect(cat?.count).toBe(1)
expect(cat?.next?.tool).toBe('gnubok_explain_voucher_gap')
expect(cat?.next?.args).toEqual({
fiscal_period_id: 'fp-1',
voucher_series: 'A',
gap_start: 5,
gap_end: 7,
})
})
it('omits voucher_gaps category when all gaps are explained', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const seriesRows = [{ voucher_series: 'A', fiscal_period_id: 'fp-1' }]
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: seriesRows })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: null })
enqueue({ data: [{ gap_start: 5, gap_end: 7 }] })
enqueue({
data: [{ voucher_series: 'A', gap_start: 5, gap_end: 7, fiscal_period_id: 'fp-1' }],
})
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
expect(result.categories.find((c) => c.key === 'voucher_gaps_unexplained')).toBeUndefined()
})
it('flags expired bank consent as critical', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)
const banks = [
{ id: 'bc-1', bank_name: 'SEB', status: 'active', consent_expires: yesterday },
]
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: banks })
enqueue({ data: null })
enqueue({ data: null })
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
const cat = result.categories.find((c) => c.key === 'bank_consent_expiring')
expect(cat?.severity).toBe('critical')
expect(cat?.count).toBe(1)
})
it('classifies upcoming lock as info severity', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inSevenDays = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10)
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: { id: 'fp-1', name: 'FY2026', period_start: '2026-01-01', period_end: '2026-12-31', locked_at: null, is_closed: false } })
enqueue({ data: { bookkeeping_locked_through: inSevenDays, auto_lock_period_days: null } })
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
const cat = result.categories.find((c) => c.key === 'period_lock_approaching')
expect(cat?.severity).toBe('info')
expect(result.summary.info).toBe(1)
})
it('combines multiple categories into a coherent summary', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const today = new Date().toISOString().slice(0, 10)
enqueue({ count: 1 }) // unbookedHead
enqueue({ data: [{ id: 't-1', date: today, amount: -50, currency: 'SEK', description: 'X', merchant_name: null }] })
enqueue({ data: [] }) // overdueRows
enqueue({ count: 1 }) // pendingSupplierHead
enqueue({ data: [{ id: 'si-1', supplier_invoice_number: 'L-1', supplier_id: 's-1', total: 1000, currency: 'SEK', due_date: today }] })
enqueue({ count: 0 }) // pendingOpsHead
enqueue({ data: [] })
enqueue({ count: 0 })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [{ id: 'd-1', title: 'Moms Q1', due_date: today, deadline_type: 'tax', tax_deadline_type: 'vat', status: 'upcoming' }] })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: null })
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
expect(result.categories).toHaveLength(3)
expect(new Set(result.categories.map((c) => c.key))).toEqual(
new Set(['unbooked_transactions', 'pending_supplier_invoices', 'deadlines_upcoming'])
)
expect(result.summary.total_items).toBe(3)
})
})
describe('Accounted://attention: reconciliation_due', () => {
it('adds the category when signed-off accounts have fallen behind the previous month end', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueEmpty(enqueue)
// countReconciliationDue: sign-offs (adoption + coverage), cash accounts, skattekonto rows.
enqueue({
data: [{ account_key: 'skattekonto', through_date: '2020-01-31', reopened_at: null }],
})
enqueue({ data: [{ id: '11111111-1111-4111-8111-111111111111', iban: null, currency: 'SEK', updated_at: null }] })
enqueue({ count: 3 })
const out = (await attentionResource.read(ctx(supabase))) as AttentionResponse
const cat = out.categories.find((c) => c.key === 'reconciliation_due')
expect(cat).toBeDefined()
// The bank account and the skattekonto are both due.
expect(cat?.count).toBe(2)
expect(cat?.next?.resource).toBe('Accounted://reconciliation/summary')
})
it('stays silent for a company that never signed anything off', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueEmpty(enqueue)
enqueue({ data: [] })
const out = (await attentionResource.read(ctx(supabase))) as AttentionResponse
expect(out.categories.find((c) => c.key === 'reconciliation_due')).toBeUndefined()
})
})