Files
accounted/extensions/general/mcp-server/__tests__/sie-preflight.test.ts
T
b436d47b64 feat(mcp): onboarding flow v2: migration-first SIE import in chat, one-confirm momentum, extended first-year heuristic (#1949)
Three changes from the first real E2E run (Arcim, 2026-08-26):

1. gnubok_sie_preflight: read-only scan of a SIE file shared in chat
   BEFORE anything is staged: parse, validate (per-verifikat balance, IB,
   closed-year P&L residual), CP1252-mojibake tripwire, duplicate
   file/period check, org-number match against the company (the
   wrong-company import is the worst silent failure this flow can have),
   and suggested account mappings shaped for direct passthrough to
   gnubok_import_sie. Both tools now also accept file_content_base64,
   decoded with the same encoding detection as the HTTP upload route so
   CP437 exports keep their åäö.

2. Onboarding skill v2: opens with TWO questions (orgnr + 'vilket system
   hade du innan?'), imports history before the bank (PSD2 rarely reaches
   far enough back), and a momentum rule: the create preview is the ONLY
   stop; connect tools are called without asking, and categorization
   starts as soon as the bank is active. connect_bank instructions now
   describe the account-selection dialog that actually gates the first
   sync, and the bank history cap.

3. deriveFirstYearDefaults: no closed fiscal period in the registry now
   extends the first-year window from 12 to 18 months (BFL 3 kap 3 §):
   a 13-month-old company with no annual report is still in its first,
   extended räkenskapsår (the Arcim case the 12-month rule missed).
   Applied in the web journey and the lookup tool.

tools/list ceiling 61.5K to 62K, documented in the bench.

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:16:40 +02:00

188 lines
6.4 KiB
TypeScript

/**
* gnubok_sie_preflight: the read-only "does this file look correct" scan
* that runs before gnubok_import_sie. Uses the real SIE parser on inline
* fixture files; only the Supabase lookups (company orgnr, duplicate
* imports, stored mappings) are mocked.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { tools } from '../server'
const tool = tools.find((t) => t.name === 'gnubok_sie_preflight')!
const COMPANY_ID = '11111111-1111-4111-8111-111111111111'
// Current-year fiscal year: a past year would trigger the (correct)
// "omföring av årets resultat saknas" warning and turn verdict ok into
// ok_with_warnings.
const YEAR = new Date().getFullYear()
const VALID_SIE = [
'#FLAGGA 0',
'#FORMAT PC8',
'#SIETYP 4',
'#FNAMN "Testbolaget AB"',
'#ORGNR 556000-0001',
`#RAR 0 ${YEAR}0101 ${YEAR}1231`,
'#KONTO 1930 "Bank"',
'#KONTO 3001 "Försäljning"',
`#VER A 1 ${YEAR}0115 "Faktura 1"`,
'{',
'#TRANS 1930 {} 1000.00',
'#TRANS 3001 {} -1000.00',
'}',
].join('\n')
const UNBALANCED_SIE = VALID_SIE.replace('#TRANS 3001 {} -1000.00', '#TRANS 3001 {} -900.00')
type MockConfig = {
companyOrg?: string | null
duplicateFile?: { id: string; imported_at: string } | null
duplicatePeriod?: Record<string, unknown> | null
}
function mockSupabase(config: MockConfig = {}) {
const makeChain = (table: string) => {
const called: string[] = []
const chain: Record<string | symbol, unknown> = new Proxy(
{},
{
get(_t, prop: string | symbol) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
if (table === 'companies') {
resolve({
data: {
org_number: 'companyOrg' in config ? config.companyOrg : '5560000001',
},
error: null,
})
} else if (table === 'sie_imports') {
if (called.includes('single')) {
resolve({ data: config.duplicateFile ?? null, error: null })
} else {
resolve({
data: config.duplicatePeriod ? [config.duplicatePeriod] : [],
error: null,
})
}
} else {
resolve({ data: [], error: null })
}
}
}
return (..._args: unknown[]) => {
called.push(String(prop))
return chain
}
},
}
)
return chain
}
return { from: (table: string) => makeChain(table) }
}
async function run(args: Record<string, unknown>, config?: MockConfig) {
return (await tool.execute(
{ filename: 'export.se', ...args },
COMPANY_ID,
'user-1',
mockSupabase(config) as never
)) as Record<string, unknown>
}
describe('gnubok_sie_preflight', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('is a read-only tool that stages nothing', () => {
expect(tool).toBeDefined()
expect(tool.annotations.readOnlyHint).toBe(true)
expect(tool.annotations.destructiveHint).toBe(false)
})
it('passes a clean matching file with verdict ok and passthrough mappings', async () => {
const result = await run({ file_content: VALID_SIE })
expect(result.verdict).toBe('ok')
const file = result.file as Record<string, unknown>
expect(file.company_name).toBe('Testbolaget AB')
expect(file.voucher_count).toBe(1)
expect((result.org_number_match as Record<string, unknown>).match).toBe(true)
expect(result.duplicate).toBeNull()
// Mappings are shaped for direct passthrough to gnubok_import_sie.
const mappings = result.mappings as Array<Record<string, unknown>>
expect(mappings.length).toBeGreaterThan(0)
expect(mappings[0]).toHaveProperty('sourceAccount')
expect(mappings[0]).toHaveProperty('targetAccount')
})
it('flags an org-number mismatch as another company\'s bookkeeping', async () => {
const result = await run({ file_content: VALID_SIE }, { companyOrg: '5599999999' })
expect(result.verdict).toBe('ok_with_warnings')
expect((result.org_number_match as Record<string, unknown>).match).toBe(false)
expect(result.instructions).toContain('STOP')
})
it('reports unverified instead of ok when the company has no org number', async () => {
const result = await run({ file_content: VALID_SIE }, { companyOrg: null })
const match = result.org_number_match as Record<string, unknown>
expect(match.verified).toBe(false)
expect(match.match).toBeNull()
})
it('returns verdict invalid with the balance error for an unbalanced voucher', async () => {
const result = await run({ file_content: UNBALANCED_SIE })
expect(result.verdict).toBe('invalid')
const validation = result.validation as { errors: string[] }
expect(validation.errors.length).toBeGreaterThan(0)
expect(result.instructions).toContain('do not import')
})
it('returns verdict duplicate when the same file hash is already imported', async () => {
const result = await run(
{ file_content: VALID_SIE },
{ duplicateFile: { id: 'imp-1', imported_at: '2026-08-01T00:00:00Z' } }
)
expect(result.verdict).toBe('duplicate')
expect((result.duplicate as Record<string, unknown>).kind).toBe('file')
})
it('returns verdict duplicate when the fiscal year overlaps a completed import', async () => {
const result = await run(
{ file_content: VALID_SIE },
{
duplicatePeriod: {
id: 'imp-2',
fiscal_year_start: '2025-01-01',
fiscal_year_end: '2025-12-31',
imported_at: '2026-08-01T00:00:00Z',
},
}
)
expect(result.verdict).toBe('duplicate')
expect((result.duplicate as Record<string, unknown>).kind).toBe('period')
})
it('decodes CP437 bytes from file_content_base64 so åäö survive', async () => {
// 'Försäljning' and 'Testbolaget' with CP437 bytes: ö=0x94, ä=0x84.
const cp437 = Buffer.from(
VALID_SIE.replace(/ö/g, '\x94').replace(/ä/g, '\x84').replace(/å/g, '\x86'),
'latin1'
)
const result = await run({ file_content_base64: cp437.toString('base64') })
expect(result.verdict).toBe('ok')
const mappings = result.mappings as Array<{ sourceName?: string }>
expect(mappings.some((m) => m.sourceName === 'Försäljning')).toBe(true)
})
it('rejects a call with neither content field', async () => {
await expect(run({})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
})
})