Files
accounted/extensions/general/mcp-server/__tests__/connect-links.test.ts
T
ec9cab24cc feat(mcp): ask the bank first: connect link deep-starts the named bank's consent (#1951)
* feat(mcp): ask the bank first: connect link deep-starts the named bank's consent

The connect card used to open the generic picker page; the user then chose
the bank there. The agent now asks 'vilken bank har företaget?' among the
opening questions and passes it to gnubok_connect_bank, whose connect_url
becomes /import?mode=psd2&bank=<name>. BankSelector resolves the name
(exact, then unique prefix, then unique substring: ambiguous names fall
back to the prefilled picker rather than guessing an institution) and
auto-starts that bank's consent through the same onConnect handler, so
the duplicate-pending and renew-instead guards stay fully interactive.
The param is stripped via history.replaceState after the one-shot so an
aborted bank flow plus back-navigation does not silently relaunch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): first-year suggestion keeps the AB vs enskild firma distinction

Compliance-review finding on #1949: the suggestion text collapsed both
forms onto a 31 December end. Only an enskild firma's first year MUST end
31 December; an AB may pick any end within BFL 3 kap 3 §'s 18-month cap,
with 31 December as the common default. The lookup tool's still_to_ask
line and the skill now say so explicitly, and first-year-defaults
documents that fiscalYear-null is a strong-not-perfect filed-report
signal that must only ever feed confirm-question suggestions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

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:31:45 +02:00

123 lines
5.1 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: reports an active connection', async () => {
const { from } = listClient([
{ id: 'c1', bank_name: 'Swedbank', status: 'active', created_at: '2026-08-01T00:00:00Z' },
])
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' },
])
})
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()
})
})