Files
accounted/extensions/general/mcp-server/__tests__/connect-links.test.ts
T
MattssonandClaude Fable 5.1 b56da5d6c5 feat(api): expose bank-connection freshness in MCP and v1 REST (#2124)
* feat(api): expose bank-connection freshness in MCP and v1 REST

gnubok_connect_bank now returns last_synced_at, consent_expires and
error_message per connection, and its instructions tell the agent to
flag stale or expiring connections. New read-only endpoint
GET /api/v1/companies/{companyId}/bank-connections exposes the same
fields to API-key integrations (scope companies:read).

Background: a user's PSD2 feed died silently in July; bookkeeping
looked complete while three weeks stale, and nothing on the API/MCP
surface could reveal it. Sync stays cron-driven; an agent-triggerable
sync was considered and deferred (see DECISIONS.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USJHnxsindrs9X6zqQDLix

* fix(api): address skeptic findings on bank-connection freshness

- Map the bank-connections group into skills/accounted-api (apiskill:check
  crashed on the unmapped group; regenerated skill files included).
- Gate the v1 route on the bank_sync capability, mirroring the MCP twin:
  a lapsed entitlement now answers with a capability error instead of
  status=active with a frozen last_synced_at.
- Reword MCP instructions + v1 pitfalls: null last_synced_at right after
  connecting is normal, staleness threshold aligned to the UI's 36 hours,
  and re-authorisation is only advised for expired/error/consent-out, not
  for stale-but-active connections (lapsed subscription or deselected
  accounts are the usual causes there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USJHnxsindrs9X6zqQDLix

* fix(mcp): keep gnubok_connect_bank schema under the tools/list token ceiling

The enriched outputSchema plus the worked examples that landed on main
(#2100) pushed the projected tools/list payload 20 tokens over the
61.6K context-budget ceiling. Drop the per-property descriptions from
the new freshness fields; the instructions string (runtime output, not
catalog payload) already explains them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USJHnxsindrs9X6zqQDLix

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:54:55 +02:00

216 lines
8.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { eventBus } from '@/lib/events/bus'
import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys'
import { tools } from '../server'
// Onboarding connect-link tools (issue #1814 PR 3): status + the browser link
// the user opens. Both flows need a cookie session and BankID in a browser,
// so the tools never try to drive them.
const COMPANY_ID = '11111111-1111-4111-8111-111111111111'
const bankTool = tools.find((t) => t.name === 'gnubok_connect_bank')!
const skvTool = tools.find((t) => t.name === 'gnubok_connect_skatteverket')!
function listClient(rows: unknown[] | null, error: unknown = null) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {
select: vi.fn(() => chain),
eq: vi.fn(() => chain),
in: vi.fn(() => chain),
order: vi.fn(() => chain),
limit: vi.fn(() => chain),
maybeSingle: vi.fn().mockResolvedValue({ data: rows?.[0] ?? null, error }),
then: (resolve: (v: unknown) => void) => resolve({ data: rows, error }),
}
return { from: vi.fn(() => chain), chain }
}
describe('onboarding connect-link tools', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.test')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('are read-only companies:read tools gated on the capability their link needs', () => {
expect(TOOL_SCOPE_MAP.gnubok_connect_bank).toBe('companies:read')
expect(TOOL_SCOPE_MAP.gnubok_connect_skatteverket).toBe('companies:read')
expect(MCP_TOOL_CAPABILITY_MAP.gnubok_connect_bank).toBe('bank_sync')
expect(MCP_TOOL_CAPABILITY_MAP.gnubok_connect_skatteverket).toBe('skatteverket')
expect(bankTool.annotations.readOnlyHint).toBe(true)
expect(skvTool.annotations.readOnlyHint).toBe(true)
})
it('bank: reports no connection and hands out the PSD2 import link', async () => {
const { from, chain } = listClient([])
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
expect(result.connected).toBe(false)
expect(result.connect_url).toBe('https://app.example.test/import?mode=psd2')
expect(chain.eq).toHaveBeenCalledWith('company_id', COMPANY_ID)
})
it('bank: a named bank deep-links straight into that bank\'s consent', async () => {
const { from } = listClient([])
const result = (await bankTool.execute(
{ bank: 'Danske Bank' },
COMPANY_ID,
'user-1',
{ from } as never
)) as Record<string, unknown>
expect(result.connect_url).toBe('https://app.example.test/import?mode=psd2&bank=Danske%20Bank')
})
it('bank: a blank bank argument falls back to the plain picker link', async () => {
const { from } = listClient([])
const result = (await bankTool.execute(
{ bank: ' ' },
COMPANY_ID,
'user-1',
{ from } as never
)) as Record<string, unknown>
expect(result.connect_url).toBe('https://app.example.test/import?mode=psd2')
})
it('bank: nudges the agent to ask for the bank when none was passed, and not when one was', async () => {
const bare = (await bankTool.execute({}, COMPANY_ID, 'user-1', {
from: listClient([]).from,
} as never)) as Record<string, unknown>
expect(bare.instructions).toContain('BETTER LINK AVAILABLE')
const named = (await bankTool.execute({ bank: 'Swedbank' }, COMPANY_ID, 'user-1', {
from: listClient([]).from,
} as never)) as Record<string, unknown>
expect(named.instructions).not.toContain('BETTER LINK AVAILABLE')
})
it('bank: reports an active connection with freshness fields', async () => {
const { from } = listClient([
{
id: 'c1',
bank_name: 'Swedbank',
status: 'active',
created_at: '2026-08-01T00:00:00Z',
last_synced_at: '2026-08-30T05:00:00Z',
consent_expires: '2026-11-01T00:00:00Z',
error_message: null,
},
])
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
expect(result.connected).toBe(true)
expect(result.connections).toEqual([
{
connection_id: 'c1',
bank: 'Swedbank',
status: 'active',
since: '2026-08-01T00:00:00Z',
last_synced_at: '2026-08-30T05:00:00Z',
consent_expires: '2026-11-01T00:00:00Z',
error_message: null,
},
])
expect(result.instructions).toContain('last_synced_at')
})
it('bank: surfaces the error_message and null sync stamp on a dead connection', async () => {
const { from } = listClient([
{
id: 'c2',
bank_name: 'SEB',
status: 'expired',
created_at: '2026-05-01T00:00:00Z',
last_synced_at: null,
consent_expires: '2026-07-15T00:00:00Z',
error_message: 'Bankkopplingen behöver förnyas.',
},
])
const result = (await bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
expect(result.connected).toBe(false)
const rows = result.connections as Array<Record<string, unknown>>
expect(rows[0].last_synced_at).toBeNull()
expect(rows[0].consent_expires).toBe('2026-07-15T00:00:00Z')
expect(rows[0].error_message).toBe('Bankkopplingen behöver förnyas.')
})
it('skatteverket: hands out the authorize link when enabled and not connected', async () => {
vi.stubEnv('SKATTEVERKET_ENABLED', 'true')
const { from } = listClient([])
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
expect(result.available).toBe(true)
expect(result.connected).toBe(false)
expect(result.connect_url).toBe(
'https://app.example.test/api/extensions/ext/skatteverket/authorize?return_to=%2F'
)
})
it('skatteverket: reports connected with the token expiry', async () => {
vi.stubEnv('SKATTEVERKET_ENABLED', 'true')
const { from } = listClient([{ expires_at: '2026-12-01T00:00:00Z' }])
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
expect(result.connected).toBe(true)
expect(result.token_expires_at).toBe('2026-12-01T00:00:00Z')
})
it('refuses to hand out a link when NEXT_PUBLIC_APP_URL is not configured', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', '')
const { from } = listClient([])
await expect(bankTool.execute({}, COMPANY_ID, 'user-1', { from } as never)).rejects.toMatchObject({
code: 'INTERNAL_ERROR',
})
})
it('skatteverket: says so when the integration is disabled on the installation', async () => {
vi.stubEnv('SKATTEVERKET_ENABLED', 'false')
const { from } = listClient([])
const result = (await skvTool.execute({}, COMPANY_ID, 'user-1', { from } as never)) as Record<string, unknown>
expect(result.available).toBe(false)
expect(result.connect_url).toBeNull()
})
})
describe('gnubok_connect_migration', () => {
const migrationTool = tools.find((t) => t.name === 'gnubok_connect_migration')!
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.test')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('renders the connect card and deep-links the wizard for an API provider', async () => {
expect(
(migrationTool as { _meta?: { ui: { resourceUri: string } } })._meta
).toEqual({ ui: { resourceUri: 'ui://connect-card/app.html' } })
const result = (await migrationTool.execute(
{ provider: 'fortnox' },
COMPANY_ID,
'user-1',
{} as never
)) as Record<string, unknown>
expect(result.connect_url).toBe('https://app.example.test/import?mode=migration&provider=fortnox')
expect(result.api_connected).toBe(true)
expect(result.provider_name).toBe('Fortnox')
})
it('tells the agent SIE comes first for a file-only provider', async () => {
const result = (await migrationTool.execute(
{ provider: 'bokio' },
COMPANY_ID,
'user-1',
{} as never
)) as Record<string, unknown>
expect(result.api_connected).toBe(false)
expect(result.instructions).toContain('gnubok_create_sie_upload')
})
it('rejects an unknown provider', async () => {
await expect(
migrationTool.execute({ provider: 'monopol' }, COMPANY_ID, 'user-1', {} as never)
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
})
})