* refactor: optimize page loading and data fetching * fix: resolve recurring production runtime errors * feat: add MCP company and customer updates * fix: handle year-end tax adjustments * feat: harden annual report compliance * fix: expand invoice logo and font support * fix: sanitize API route error responses * fix: sanitize user-facing error messages * feat: persist onboarding and tax assessment notices * fix: reduce cloud backup audit churn * feat: refine invoice editor layout * fix: show saved tax adjustments in INK2 * fix: complete annual report API mappings * docs: record operational safeguards and decisions * fix: harden annual report review findings * fix: adjust column span for description based on VAT registration * New css class name
204 lines
6.3 KiB
TypeScript
204 lines
6.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
|
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
|
|
import { tools } from '../server'
|
|
|
|
const getTool = () => tools.find((tool) => tool.name === 'gnubok_get_company_settings')!
|
|
const updateTool = () => tools.find((tool) => tool.name === 'gnubok_update_company_settings')!
|
|
|
|
describe('company settings MCP tools: registration', () => {
|
|
it('registers the read and staged write tools with company scopes', () => {
|
|
expect(getTool()).toBeDefined()
|
|
expect(updateTool()).toBeDefined()
|
|
expect(getTool().annotations.readOnlyHint).toBe(true)
|
|
expect(getTool().catalogVisibility).toBe('search')
|
|
expect(updateTool().annotations.readOnlyHint).toBe(false)
|
|
expect(updateTool().annotations.idempotentHint).toBe(true)
|
|
expect(updateTool().catalogVisibility).toBe('search')
|
|
expect(TOOL_SCOPE_MAP.gnubok_get_company_settings).toBe('companies:read')
|
|
expect(TOOL_SCOPE_MAP.gnubok_update_company_settings).toBe('companies:write')
|
|
})
|
|
|
|
it('classifies payment-routing changes as medium risk', () => {
|
|
expect(OPERATION_RISK_TIERS.update_company_settings).toBe('medium')
|
|
})
|
|
|
|
it('uses strict top-level input schemas', () => {
|
|
expect(getTool().inputSchema.additionalProperties).toBe(false)
|
|
expect(updateTool().inputSchema.additionalProperties).toBe(false)
|
|
})
|
|
|
|
it('keeps both settings schemas discoverable through tool search', async () => {
|
|
const search = tools.find((tool) => tool.name === 'gnubok_search_tools')!
|
|
const readResult = (await search.execute(
|
|
{
|
|
query: 'get company settings',
|
|
detail: 'full',
|
|
__keyScopes: ['companies:read'],
|
|
},
|
|
'company-1',
|
|
'user-1',
|
|
{} as never,
|
|
)) as { tools: Array<{ name: string; inputSchema?: Record<string, unknown> }> }
|
|
const writeResult = (await search.execute(
|
|
{
|
|
query: 'update company settings',
|
|
detail: 'full',
|
|
__keyScopes: ['companies:write'],
|
|
},
|
|
'company-1',
|
|
'user-1',
|
|
{} as never,
|
|
)) as { tools: Array<{ name: string; inputSchema?: Record<string, unknown> }> }
|
|
|
|
expect(readResult.tools).toEqual([
|
|
expect.objectContaining({
|
|
name: 'gnubok_get_company_settings',
|
|
inputSchema: expect.any(Object),
|
|
}),
|
|
])
|
|
expect(writeResult.tools).toEqual([
|
|
expect.objectContaining({
|
|
name: 'gnubok_update_company_settings',
|
|
inputSchema: expect.any(Object),
|
|
}),
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('gnubok_get_company_settings', () => {
|
|
it('returns payment details and maps the default reference to contact_person', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: {
|
|
bank_name: 'Testbanken',
|
|
clearing_number: '1234',
|
|
account_number: '1234567',
|
|
bankgiro: '5050-1055',
|
|
plusgiro: null,
|
|
swish: '1231231231',
|
|
iban: null,
|
|
bic: null,
|
|
default_our_reference: 'Test Contact',
|
|
},
|
|
})
|
|
|
|
const result = await getTool().execute({}, 'company-1', 'user-1', supabase as never)
|
|
|
|
expect(result).toMatchObject({
|
|
company_id: 'company-1',
|
|
bankgiro: '5050-1055',
|
|
contact_person: 'Test Contact',
|
|
})
|
|
expect(supabase.from).toHaveBeenCalledWith('company_settings')
|
|
})
|
|
|
|
it('fails when the company has no settings row', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: null, error: null })
|
|
|
|
await expect(
|
|
getTool().execute({}, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toThrow(/not found/i)
|
|
})
|
|
})
|
|
|
|
describe('gnubok_update_company_settings', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('rejects an empty change set before querying the database', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
|
|
await expect(
|
|
updateTool().execute({ dry_run: true }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toThrow(/at least one/i)
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects a Bankgiro number with an invalid check digit', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
|
|
await expect(
|
|
updateTool().execute(
|
|
{ bankgiro: '1234567', dry_run: true },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
),
|
|
).rejects.toThrow(/bankgiro/i)
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns a merged dry-run preview without staging', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: {
|
|
bank_name: 'Old Bank',
|
|
clearing_number: '1234',
|
|
account_number: '1234567',
|
|
bankgiro: null,
|
|
plusgiro: null,
|
|
swish: null,
|
|
iban: null,
|
|
bic: null,
|
|
default_our_reference: 'Old Contact',
|
|
},
|
|
})
|
|
|
|
const result = (await updateTool().execute(
|
|
{ bankgiro: '5050-1055', contact_person: 'New Contact', dry_run: true },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
)) as {
|
|
staged: boolean
|
|
dry_run?: boolean
|
|
preview: { proposed?: Record<string, unknown> }
|
|
}
|
|
|
|
expect(result.staged).toBe(false)
|
|
expect(result.dry_run).toBe(true)
|
|
expect(result.preview.proposed).toMatchObject({
|
|
bank_name: 'Old Bank',
|
|
bankgiro: '5050-1055',
|
|
contact_person: 'New Contact',
|
|
})
|
|
expect(supabase.from).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('stages a validated update for approval', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: {
|
|
bank_name: null,
|
|
clearing_number: null,
|
|
account_number: null,
|
|
bankgiro: null,
|
|
plusgiro: null,
|
|
swish: null,
|
|
iban: null,
|
|
bic: null,
|
|
default_our_reference: null,
|
|
},
|
|
})
|
|
enqueue({ data: { id: 'op-settings-1' } })
|
|
|
|
const result = (await updateTool().execute(
|
|
{ contact_person: 'Test Contact' },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
)) as { staged: boolean; operation_id?: string; risk_level: string }
|
|
|
|
expect(result).toMatchObject({
|
|
staged: true,
|
|
operation_id: 'op-settings-1',
|
|
risk_level: 'medium',
|
|
})
|
|
expect(supabase.from).toHaveBeenNthCalledWith(2, 'pending_operations')
|
|
})
|
|
})
|