Files
accounted/extensions/general/mcp-server/__tests__/year-end-readiness.test.ts
T
Mattsson 73c63209f1 feat: stage kontantmetod year-end cutoff (#1586)
* feat: stage kontantmetod year-end cutoff

* fix: keep cutoff tool payload searchable

* fix: trim year-end tool metadata
2026-08-13 15:55:01 +02:00

278 lines
11 KiB
TypeScript

/**
* Unit tests for gnubok_year_end_readiness.
*
* Covers tool registration, scope mapping, and the blocker-kind classification
* that turns the lib's coded blockers into structured agent-friendly entries.
* Full integration with validateYearEndReadiness is covered by
* lib/core/bookkeeping tests + the manual MCP smoke test.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { tools, YEAR_END_BLOCKER_KIND } from '../server'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
vi.mock('@/lib/core/bookkeeping/year-end-service', () => ({
validateYearEndReadiness: vi.fn(),
previewYearEndClosing: vi.fn(),
}))
import {
validateYearEndReadiness,
previewYearEndClosing,
} from '@/lib/core/bookkeeping/year-end-service'
describe('gnubok_year_end_readiness: registration', () => {
it('is registered in the tools array', () => {
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')
expect(tool).toBeDefined()
expect(tool?.annotations.readOnlyHint).toBe(true)
expect(tool?.annotations.destructiveHint).toBe(false)
expect(tool?.annotations.idempotentHint).toBe(true)
})
it('requires fiscal_period_id', () => {
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
const schema = tool.inputSchema as { required?: string[] }
expect(schema.required).toContain('fiscal_period_id')
})
it('declares output schema with intent fields', () => {
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
const schema = tool.outputSchema as { required?: string[] }
expect(schema.required).toContain('ready')
expect(schema.required).toContain('blockers')
expect(schema.required).toContain('warnings')
expect(schema.required).toContain('summary')
})
it('is mapped to reports:read scope', () => {
expect(TOOL_SCOPE_MAP.gnubok_year_end_readiness).toBe('reports:read')
})
// The description is the only thing an agent reads before deciding what to
// pre-check, and the 280-char budget does not fit all eleven kinds. It must
// therefore name every kind the caller can DO something about ahead of time;
// the four period-state kinds are summarized, since nothing can be
// pre-checked about them.
const PERIOD_STATE_KINDS = new Set([
'period_not_found',
'period_not_ended',
'period_already_closed',
'closing_entry_exists',
])
it('names every actionable blocker kind the tool can emit', () => {
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
const actionable = [...new Set(Object.values(YEAR_END_BLOCKER_KIND))].filter(
(kind) => !PERIOD_STATE_KINDS.has(kind),
)
// Guards the summarizing itself: if a kind stops being period-state, or a
// new one appears, it has to show up in the description.
expect(actionable.length).toBe(8)
for (const kind of actionable) {
expect(tool.description, `blocker kind ${kind} missing from description`).toContain(kind)
}
expect(tool.description).toContain('period-state')
})
it('names unbooked transactions as the most common blocker', () => {
// The omission that sent agents pre-checking the wrong things: this is the
// blocker a real close trips on, and the description never mentioned it.
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
expect(tool.description).toMatch(/unbooked_transactions \(most common\)/)
})
it('does not present open foreign-currency items as a blocker', () => {
// validateYearEndReadiness pushes those onto `warnings`, never `blockers`:
// executeYearEndClosing runs the revaluation itself, so escalating would
// block a close that the very next step performs.
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
expect(tool.description).toMatch(/FX = warning, never blocker/)
expect(tool.description).not.toMatch(/[Bb]lockers[^.]*revaluation/)
})
})
function makeMockSupabase(period: Record<string, unknown> | null) {
return {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({ data: period, error: null }),
}),
}),
}),
}),
} as never
}
describe('gnubok_year_end_readiness: execute', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('classifies blocker codes into structured kinds', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue({
ready: false,
blockers: [
{ code: 'DRAFT_ENTRIES', message: '3 utkast måste bokföras eller raderas innan bokslut' },
{ code: 'UNEXPLAINED_VOUCHER_GAP', message: 'Oförklarat verifikationsnummerglapp i serie A: 5-7' },
{ code: 'TRIAL_BALANCE_UNBALANCED', message: 'Råbalansen balanserar inte: debet=100, kredit=200' },
// Classification is by code, so an off-wording message (here the legacy
// English one) must still land on sequence_mismatch.
{ code: 'SEQUENCE_COUNTER_BEHIND', message: 'Sequence counter integrity error in series A: counter=3 but max voucher=5' },
{ code: 'UNBOOKED_TRANSACTIONS', message: '3 transaktioner i perioden saknar bokföring: bokför dem eller markera dem som privata innan bokslut' },
// The fail-closed variant shares the kind: an agent reacts to both by
// going to look at the transactions and re-running readiness.
{ code: 'UNBOOKED_CHECK_FAILED', message: 'Kontrollen av obokförda transaktioner kunde inte genomföras: försök igen' },
],
errors: [
'3 utkast måste bokföras eller raderas innan bokslut',
'Oförklarat verifikationsnummerglapp i serie A: 5-7',
'Råbalansen balanserar inte: debet=100, kredit=200',
'Sequence counter integrity error in series A: counter=3 but max voucher=5',
'3 transaktioner i perioden saknar bokföring: bokför dem eller markera dem som privata innan bokslut',
'Kontrollen av obokförda transaktioner kunde inte genomföras: försök igen',
],
warnings: ['Inga bokförda verifikationer i perioden'],
draftCount: 3,
voucherGaps: [{ series: 'A', gap_start: 5, gap_end: 7 }],
unexplainedGaps: [{ series: 'A', gap_start: 5, gap_end: 7 }],
sequenceMismatches: [{ series: 'A', sequenceCounter: 3, actualMax: 5 }],
trialBalanceBalanced: false,
})
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
const supabase = makeMockSupabase({
id: 'period-1',
name: '2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
is_closed: false,
locked_at: null,
closing_entry_id: null,
continuity_verified: true,
})
const result = (await tool.execute(
{ fiscal_period_id: 'period-1' },
'company-1',
'user-1',
supabase,
)) as { ready: boolean; blockers: { kind: string }[]; summary: string }
expect(result.ready).toBe(false)
const kinds = result.blockers.map((b) => b.kind)
expect(kinds).toEqual([
'draft_entries',
'unexplained_voucher_gap',
'trial_balance_unbalanced',
'sequence_mismatch',
'unbooked_transactions',
'unbooked_transactions',
])
expect(kinds).not.toContain('other')
expect(result.summary).toMatch(/Inte klart/)
})
it('falls back to the wording heuristic for an unmapped blocker code', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue({
ready: false,
// A code the kind map does not know yet (a future blocker shipped
// without a map entry): the message heuristic must still route it.
blockers: [
{ code: 'SOME_FUTURE_CODE' as never, message: 'Råbalansen balanserar inte: debet=100, kredit=200' },
{ code: 'ANOTHER_FUTURE_CODE' as never, message: 'Något helt nytt gick fel' },
],
errors: [
'Råbalansen balanserar inte: debet=100, kredit=200',
'Något helt nytt gick fel',
],
warnings: [],
draftCount: 0,
voucherGaps: [],
unexplainedGaps: [],
sequenceMismatches: [],
trialBalanceBalanced: false,
})
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
const supabase = makeMockSupabase({
id: 'period-1', name: '2026',
period_start: '2026-01-01', period_end: '2026-12-31',
is_closed: false, locked_at: null, closing_entry_id: null, continuity_verified: true,
})
const result = (await tool.execute(
{ fiscal_period_id: 'period-1' },
'company-1', 'user-1', supabase,
)) as { blockers: { kind: string }[] }
expect(result.blockers.map((b) => b.kind)).toEqual(['trial_balance_unbalanced', 'other'])
})
it('skips preview when not requested even if ready', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue({
ready: true,
blockers: [],
errors: [],
warnings: [],
draftCount: 0,
voucherGaps: [],
unexplainedGaps: [],
sequenceMismatches: [],
trialBalanceBalanced: true,
})
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
const supabase = makeMockSupabase({
id: 'period-1', name: '2026',
period_start: '2026-01-01', period_end: '2026-12-31',
is_closed: false, locked_at: null, closing_entry_id: null, continuity_verified: true,
})
const result = (await tool.execute(
{ fiscal_period_id: 'period-1' },
'company-1', 'user-1', supabase,
)) as { ready: boolean; preview: unknown; summary: string }
expect(result.ready).toBe(true)
expect(result.preview).toBeNull()
expect(vi.mocked(previewYearEndClosing)).not.toHaveBeenCalled()
expect(result.summary).toMatch(/Klart för bokslut/)
})
it('returns the preview when include_preview=true and ready', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue({
ready: true,
blockers: [],
errors: [],
warnings: [],
draftCount: 0,
voucherGaps: [],
unexplainedGaps: [],
sequenceMismatches: [],
trialBalanceBalanced: true,
})
vi.mocked(previewYearEndClosing).mockResolvedValue({
net_result: 12345,
closing_account: '2099',
lines: [],
} as never)
const tool = tools.find((t) => t.name === 'gnubok_year_end_readiness')!
const supabase = makeMockSupabase({
id: 'period-1', name: '2026',
period_start: '2026-01-01', period_end: '2026-12-31',
is_closed: false, locked_at: null, closing_entry_id: null, continuity_verified: true,
})
const result = (await tool.execute(
{ fiscal_period_id: 'period-1', include_preview: true },
'company-1', 'user-1', supabase,
)) as { preview: { net_result?: number } | null }
expect(result.preview).not.toBeNull()
expect(result.preview?.net_result).toBe(12345)
})
})