Files
accounted/extensions/general/mcp-server/__tests__/create-company.test.ts
T
0f7625535b feat(mcp): drag-and-drop SIE import card: exact bytes via tools/call, no model in the byte path (#1957)
E2E #6: the flow ordered SIE-first correctly, but the agent never
discovered gnubok_create_sie_upload, ran a local preflight, and sent the
user to the web wizard again; it also rendered a duplicate generic bank
card before the Swedbank-specific one.

1. New sie-drop widget (ui://sie-drop/app.html), rendered
   definition-level by gnubok_create_sie_upload: the user drags the
   .se/.sie file onto the card, the widget reads the EXACT bytes
   (FileReader), computes sha256 (WebCrypto), calls
   gnubok_sie_preflight via tools/call with file_content_base64 +
   sha256, shows the verdict, and on Importera stages
   gnubok_import_sie with the preflight's mappings. No network from
   the iframe, no model reproduction: byte path goes through the host
   bridge only, narrated into chat via ui/updateContext.

2. The inline size cap now applies only WITHOUT sha256: a hash-verified
   payload is byte-exact by proof, so the widget's 100 KB+ base64
   passes while unhashed model-retyped content stays refused.

3. Discovery + ordering fixes: create_sie_upload/preflight/import
   descriptions name the card path explicitly; create_company's
   history_note points at the card; connect_bank description says pass
   bank on the FIRST call when the user has named it (the duplicate
   generic card came from a bare call followed by the nudged retry).

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

198 lines
8.0 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { eventBus } from '@/lib/events/bus'
const mocks = vi.hoisted(() => ({
createCompanyCore: vi.fn(),
}))
vi.mock('@/lib/company/create-company', () => ({
createCompanyCore: (...args: unknown[]) => mocks.createCompanyCore(...args),
}))
import { tools } from '../server'
import { isCompanyDependentTool } from '../company-routing'
const tool = tools.find((t) => t.name === 'gnubok_create_company')!
const TEAM_ID = '44444444-4444-4444-8444-444444444444'
const COMPANY_ID = '55555555-5555-4555-8555-555555555555'
function supabaseWithTeam(teamId: string | null) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {
select: vi.fn(() => chain),
eq: vi.fn(() => chain),
order: vi.fn(() => chain),
limit: vi.fn(() => chain),
maybeSingle: vi.fn().mockResolvedValue({ data: teamId ? { team_id: teamId } : null, error: null }),
}
return {
from: vi.fn(() => chain),
rpc: vi.fn().mockResolvedValue({ data: COMPANY_ID, error: null }),
chain,
}
}
const setup = {
name: 'Testbolaget AB',
entity_type: 'aktiebolag',
org_number: '556000-0001',
vat_registered: true,
moms_period: 'quarterly',
accounting_method: 'accrual',
f_skatt: true,
}
describe('gnubok_create_company', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('is a companies:write, company-independent write tool', () => {
expect(tool).toBeDefined()
expect(TOOL_SCOPE_MAP.gnubok_create_company).toBe('companies:write')
expect(isCompanyDependentTool('gnubok_create_company')).toBe(false)
expect(tool.annotations.readOnlyHint).toBe(false)
expect(tool.annotations.destructiveHint).toBe(false)
})
it('defaults an omitted accounting_method by form and flags it in the preview', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
const { accounting_method: _dropped, ...withoutMethod } = setup
const result = (await tool.execute(withoutMethod, '', 'user-1', supabase as never)) as Record<string, unknown>
expect(result.created).toBe(false)
const preview = result.preview as Record<string, unknown>
expect(preview.accounting_method).toBe('accrual')
expect(preview.accounting_method_defaulted).toBe(true)
})
it('does not flag an explicitly chosen accounting_method as defaulted', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
const result = (await tool.execute(setup, '', 'user-1', supabase as never)) as Record<string, unknown>
const preview = result.preview as Record<string, unknown>
expect(preview.accounting_method).toBe('accrual')
expect('accounting_method_defaulted' in preview).toBe(false)
})
it('previews without creating when confirm is not true', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
const result = (await tool.execute(setup, '', 'user-1', supabase as never)) as Record<string, unknown>
expect(result.created).toBe(false)
expect(result.requires_confirmation).toBe(true)
const preview = result.preview as Record<string, unknown>
expect(preview.org_number).toBe('5560000001')
expect(preview.vat_number).toBe('SE556000000101')
expect(preview.team_id).toBe(TEAM_ID)
expect(preview.fiscal_period).toMatchObject({ name: expect.stringContaining('Räkenskapsår') })
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('creates through the service-role RPC for the key user with confirm=true', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
mocks.createCompanyCore.mockImplementation(
async (_client: unknown, _input: unknown, createRow: () => Promise<{ data: unknown; error: unknown }>) => {
const { data } = await createRow()
return { companyId: data as string }
}
)
const result = (await tool.execute(
{ ...setup, confirm: true },
'',
'user-1',
supabase as never
)) as Record<string, unknown>
expect(result.created).toBe(true)
expect(result.company_id).toBe(COMPANY_ID)
expect(supabase.rpc).toHaveBeenCalledWith('create_company_for_user', {
p_user_id: 'user-1',
p_name: 'Testbolaget AB',
p_entity_type: 'aktiebolag',
p_team_id: TEAM_ID,
})
const [, input] = mocks.createCompanyCore.mock.calls[0] as [unknown, Record<string, unknown>]
expect(input.settings).toMatchObject({ moms_period: 'quarterly', vat_registered: true, company_name: 'Testbolaget AB' })
expect((result.next as Record<string, unknown>).tool).toBe('gnubok_load_skill')
})
it('orders history import before the bank when the fiscal period started over 90 days ago', async () => {
// The default calendar-year setup starts 1 January: from February on the
// period has >90 days of history the bank cannot deliver, so the created
// result must point at SIE-first ordering. Frozen mid-year so the test
// does not flip in January.
vi.useFakeTimers()
vi.setSystemTime(new Date(`${new Date().getFullYear()}-08-15T12:00:00Z`))
try {
const supabase = supabaseWithTeam(TEAM_ID)
mocks.createCompanyCore.mockImplementation(
async (_client: unknown, _input: unknown, createRow: () => Promise<{ data: unknown; error: unknown }>) => {
const { data } = await createRow()
return { companyId: data as string }
}
)
const result = (await tool.execute(
{ ...setup, confirm: true },
'',
'user-1',
supabase as never
)) as Record<string, unknown>
expect(result.history_note).toContain('gnubok_create_sie_upload')
expect(result.message).toContain('IN ORDER')
} finally {
vi.useRealTimers()
}
})
it('refuses a VAT-registered company without a moms period before touching the database', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
await expect(
tool.execute({ ...setup, moms_period: undefined, confirm: true }, '', 'user-1', supabase as never)
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', message: expect.stringContaining('moms_period') })
expect(supabase.rpc).not.toHaveBeenCalled()
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
})
it('defaults the team to the user PERSONAL team only (WL-08)', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
const result = (await tool.execute(setup, '', 'user-1', supabase as never)) as Record<string, unknown>
expect((result.preview as Record<string, unknown>).team_id).toBe(TEAM_ID)
// The default-team lookup must be restricted to kind='personal': picking
// the first membership regardless of kind attached a consultant's private
// company to their byrå team.
expect(supabase.from).toHaveBeenCalledWith('team_members')
expect(supabase.chain.select).toHaveBeenCalledWith('team_id, teams!inner(kind, created_at)')
expect(supabase.chain.eq).toHaveBeenCalledWith('teams.kind', 'personal')
})
it('leaves team_id null when the user has no personal team', async () => {
const supabase = supabaseWithTeam(null)
const result = (await tool.execute(setup, '', 'user-1', supabase as never)) as Record<string, unknown>
expect((result.preview as Record<string, unknown>).team_id).toBeNull()
})
it('uses an explicit team_id over the default team', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
const other = '66666666-6666-4666-8666-666666666666'
const result = (await tool.execute({ ...setup, team_id: other }, '', 'user-1', supabase as never)) as Record<
string,
unknown
>
expect((result.preview as Record<string, unknown>).team_id).toBe(other)
expect(supabase.from).not.toHaveBeenCalled()
})
it('surfaces a creation failure as a coded error', async () => {
const supabase = supabaseWithTeam(null)
mocks.createCompanyCore.mockResolvedValue({ error: 'Kunde inte skapa kontoplan. Försök igen.' })
await expect(
tool.execute({ ...setup, confirm: true }, '', 'user-1', supabase as never)
).rejects.toMatchObject({ code: 'COMPANY_CREATE_FAILED' })
})
})