Files
accounted/extensions/general/mcp-server/__tests__/skills.test.ts
T
Jakob WennbergandClaude Opus 4.8 c74b19df1b Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)
* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:01 +02:00

492 lines
20 KiB
TypeScript

/**
* Tests for skills over MCP — registry, discovery tools, and resource exposure.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { tools } from '../server'
import { workflowSkills, findSkill, SKILL_URI_PREFIX, skillUri, __resetAtomCache } from '../skills'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
/** Build a supabase mock that satisfies the queries gnubok_list_skills issues:
* - agent_atom_registry (empty atom set, so only static workflows surface)
* - company_settings (entity_type + vat_registered, used by applicability filter)
* - employees (active count, used by applicability filter)
*
* All test queries resolve to the same defaults: entity_type='AB',
* vat_registered=true, 1 active employee — so every applicability-filtered
* skill is included by default. Individual tests can override via the
* optional overrides parameter.
*/
function makeSupabaseWithEmptyAtomRegistry(
rows: unknown[] = [],
overrides: { entityType?: string | null; vatRegistered?: boolean; employeeCount?: number } = {},
refRow: unknown = null,
) {
const entityType = overrides.entityType ?? 'AB'
const vatRegistered = overrides.vatRegistered ?? true
const employeeCount = overrides.employeeCount ?? 1
return {
from: vi.fn((table: string) => {
if (table === 'company_settings') {
return {
select: vi.fn(() => ({
eq: vi.fn(() => ({
maybeSingle: vi.fn().mockResolvedValue({
data: entityType === null ? null : { entity_type: entityType, vat_registered: vatRegistered },
error: null,
}),
})),
})),
}
}
if (table === 'employees') {
return {
select: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn().mockResolvedValue({ count: employeeCount, data: null, error: null }),
})),
})),
}
}
// Default: agent_atom_registry. The same chain serves two query shapes:
// - loadAtomsAsSkills: .eq().eq().is('parent_atom_id', null).order() → resolves `rows`
// - loadReferenceById: .eq('id').not('parent_atom_id','is',null).maybeSingle() → resolves `refRow`
return {
select: vi.fn(() => {
const chain: Record<string, ReturnType<typeof vi.fn>> = {
eq: vi.fn(() => chain),
is: vi.fn(() => chain),
not: vi.fn(() => chain),
order: vi.fn().mockResolvedValue({ data: rows, error: null }),
maybeSingle: vi.fn().mockResolvedValue({ data: refRow, error: null }),
}
return chain
}),
}
}),
}
}
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
return {
...actual,
extractBearerToken: vi.fn().mockReturnValue('test-token'),
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
// Minimal scopes — list/load skill tools are intentionally unscoped.
scopes: [],
}),
createServiceClientNoCookies: vi.fn(() => makeSupabaseWithEmptyAtomRegistry()),
}
})
import { handleMcpRequest } from '../server'
/** Alias for the legacy workflow-only array. New code reads `workflowSkills`. */
const skills = workflowSkills
function mcpRequest(method: string, params?: Record<string, unknown>, id: number | string = 1): Request {
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
})
}
async function parseResult(response: Response) {
const json = await response.json()
return json.result
}
describe('Skills registry', () => {
beforeEach(() => {
vi.clearAllMocks()
__resetAtomCache()
})
it('exports a non-empty workflowSkills array', () => {
expect(skills.length).toBeGreaterThanOrEqual(5)
})
it('every skill has unique slug', () => {
const slugs = skills.map((s) => s.slug)
expect(new Set(slugs).size).toBe(slugs.length)
})
it('every skill body is non-trivial and contains a Tools section', () => {
for (const s of skills) {
expect(s.body.length, `skill ${s.slug} body length`).toBeGreaterThan(500)
expect(s.body, `skill ${s.slug} should have a ## Tools section`).toMatch(/## Tools/i)
}
})
it('every skill has the expected metadata shape', () => {
for (const s of skills) {
expect(s.slug).toMatch(/^[a-z0-9-]+$/)
expect(s.name).toBeTruthy()
expect(s.summary.length).toBeGreaterThan(20)
expect(s.summary.length).toBeLessThan(200)
expect(Array.isArray(s.tags)).toBe(true)
expect(s.tags.length).toBeGreaterThan(0)
expect(s.tier).toBe('workflow')
}
})
it('findSkill resolves the workflow skill or null (sync workflow lookup)', async () => {
const supabase = makeSupabaseWithEmptyAtomRegistry()
expect(await findSkill('month-end-close', supabase as never)).toBeTruthy()
expect(await findSkill('does-not-exist', supabase as never)).toBeNull()
})
it('skillUri uses the Accounted://skill/ prefix; atom slugs are URL-encoded', () => {
expect(skillUri('foo')).toBe('Accounted://skill/foo')
expect(skillUri('vertical/konsult-it')).toBe('Accounted://skill/vertical%2Fkonsult-it')
expect(SKILL_URI_PREFIX).toBe('Accounted://skill/')
})
})
describe('gnubok_list_skills tool', () => {
beforeEach(() => {
__resetAtomCache()
})
it('is registered with correct annotations and no scope requirement', () => {
const tool = tools.find((t) => t.name === 'gnubok_list_skills')
expect(tool).toBeDefined()
expect(tool?.annotations.readOnlyHint).toBe(true)
expect(tool?.annotations.idempotentHint).toBe(true)
})
it('returns all workflow skills when called with no args (empty atom registry)', async () => {
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
const supabase = makeSupabaseWithEmptyAtomRegistry()
const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
skills: Array<{ slug: string; name: string; summary: string; tags: string[]; tier: string }>
count: number
}
expect(result.count).toBe(skills.length)
expect(result.skills.every((s) => s.slug && s.name && s.summary && s.tier === 'workflow')).toBe(true)
// Body should NOT be returned by list (token saving).
expect((result.skills[0] as Record<string, unknown>).body).toBeUndefined()
})
it('filters by tag', async () => {
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
const supabase = makeSupabaseWithEmptyAtomRegistry()
const result = (await tool.execute({ tag: 'vat' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
skills: Array<{ slug: string; tags: string[] }>
count: number
}
expect(result.count).toBeGreaterThan(0)
for (const s of result.skills) {
expect(s.tags.map((t) => t.toLowerCase())).toContain('vat')
}
})
it('filters by tier=workflow (excludes atoms when both present)', async () => {
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
const supabase = makeSupabaseWithEmptyAtomRegistry()
const result = (await tool.execute({ tier: 'workflow' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
skills: Array<{ tier: string }>
count: number
}
expect(result.count).toBeGreaterThan(0)
for (const s of result.skills) {
expect(s.tier).toBe('workflow')
}
})
it('filters by tier=horizontal/vertical/modifier (returns empty when no atoms)', async () => {
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
const supabase = makeSupabaseWithEmptyAtomRegistry()
for (const tier of ['horizontal', 'vertical', 'modifier']) {
const result = (await tool.execute({ tier }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
count: number
}
expect(result.count).toBe(0)
}
})
it('surfaces registry atoms alongside workflow skills', async () => {
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
// Body path points at a real SKILL.md on disk (seeded by Phase 3).
const supabase = makeSupabaseWithEmptyAtomRegistry([
{
id: 'vertical/konsult-it',
tier: 'vertical',
title: 'IT-konsult & systemutvecklare (SNI 62)',
description: 'Konsult-IT description',
sni_prefixes: ['62.01'],
body_path: '.claude/skills/industry/konsult-it/SKILL.md',
},
])
const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
skills: Array<{ slug: string; tier: string }>
count: number
}
expect(result.count).toBe(skills.length + 1)
expect(result.skills.find((s) => s.slug === 'vertical/konsult-it')?.tier).toBe('vertical')
})
it('applicability filter hides AB-only skills for EF companies', async () => {
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
const supabase = makeSupabaseWithEmptyAtomRegistry([], { entityType: 'EF', employeeCount: 0, vatRegistered: true })
const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
skills: Array<{ slug: string }>
hidden_count: number
company_context: { entity_type: string | null; has_employees: boolean; vat_registered: boolean }
}
const slugs = result.skills.map((s) => s.slug)
expect(slugs).not.toContain('year-end-close') // AB-only
expect(slugs).not.toContain('payroll-monthly') // requires employees
expect(slugs).toContain('month-end-close')
expect(slugs).toContain('invoicing-rules')
expect(slugs).toContain('quarterly-vat-review') // vat_registered=true
expect(result.hidden_count).toBeGreaterThan(0)
expect(result.company_context).toEqual({ entity_type: 'EF', has_employees: false, vat_registered: true })
})
it('include_all=true bypasses applicability filter', async () => {
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
const supabase = makeSupabaseWithEmptyAtomRegistry([], { entityType: 'EF', employeeCount: 0, vatRegistered: false })
const filtered = (await tool.execute({}, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
count: number
hidden_count: number
}
const unfiltered = (await tool.execute({ include_all: true }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
count: number
hidden_count: number
}
expect(unfiltered.count).toBe(filtered.count + filtered.hidden_count)
expect(unfiltered.hidden_count).toBe(0)
})
it('tier=vertical filter returns only verticals', async () => {
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
const supabase = makeSupabaseWithEmptyAtomRegistry([
{
id: 'vertical/konsult-it',
tier: 'vertical',
title: 'Konsult-IT',
description: 'desc',
sni_prefixes: ['62.01'],
body_path: '.claude/skills/industry/konsult-it/SKILL.md',
},
])
const result = (await tool.execute({ tier: 'vertical' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
skills: Array<{ slug: string; tier: string }>
count: number
}
expect(result.count).toBe(1)
expect(result.skills[0].slug).toBe('vertical/konsult-it')
})
})
describe('gnubok_load_skill tool', () => {
beforeEach(() => {
__resetAtomCache()
})
it('is registered', () => {
const tool = tools.find((t) => t.name === 'gnubok_load_skill')
expect(tool).toBeDefined()
})
it('returns full body for a valid workflow slug', async () => {
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
const supabase = makeSupabaseWithEmptyAtomRegistry()
const result = (await tool.execute({ slug: 'month-end-close' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
slug: string
name: string
tier: string
body: string
}
expect(result.slug).toBe('month-end-close')
expect(result.tier).toBe('workflow')
expect(result.body).toContain('# Month-End Close')
expect(result.body).toContain('## Tools')
})
it('throws structured error for unknown slug', async () => {
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
const supabase = makeSupabaseWithEmptyAtomRegistry()
await expect(
tool.execute({ slug: 'nonexistent-skill' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
).rejects.toThrow(/Skill not found.*Available skills/)
})
it('throws when slug is missing or empty', async () => {
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
const supabase = makeSupabaseWithEmptyAtomRegistry()
await expect(
tool.execute({ slug: '' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
).rejects.toThrow(/slug is required/)
})
it('resolves an atom slug from the registry and returns its DB body', async () => {
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
const supabase = makeSupabaseWithEmptyAtomRegistry([
{
id: 'vertical/konsult-it',
tier: 'vertical',
title: 'Konsult-IT',
description: 'desc',
sni_prefixes: ['62.01'],
// Body now comes from the DB column, not disk. The frontmatter must be
// preserved verbatim (the composer/system-prompt rely on the `id:` line).
body: '---\nid: vertical/konsult-it\ntier: vertical\n---\n\n# Konsult-IT (loaded from DB)',
body_path: '.claude/skills/industry/konsult-it/SKILL.md',
},
])
const result = (await tool.execute({ slug: 'vertical/konsult-it' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })) as {
slug: string
tier: string
body: string
}
expect(result.slug).toBe('vertical/konsult-it')
expect(result.tier).toBe('vertical')
// Frontmatter preserved, and the body is the DB value (not the on-disk file).
expect(result.body).toContain('id: vertical/konsult-it')
expect(result.body).toContain('loaded from DB')
})
it('resolves a reference child by id even though it is hidden from the listed atom set', async () => {
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
// Listed atoms (loadAtomsAsSkills) is empty — the reference is only reachable
// via loadReferenceById, which findSkill falls back to.
const supabase = makeSupabaseWithEmptyAtomRegistry([], {}, {
id: 'horizontal/swedish-vat/vat-compliance-reference',
tier: 'horizontal',
title: 'Swedish VAT (Moms) Complete Compliance Reference',
description: 'desc',
sni_prefixes: [],
body: '# Swedish VAT (Moms) Complete Compliance Reference\n\nDeep reference body.',
body_path: '.claude/skills/swedish-vat/references/vat-compliance-reference.md',
is_active: true,
mcp_exposed: true,
parent_atom_id: 'horizontal/swedish-vat',
})
const result = (await tool.execute(
{ slug: 'horizontal/swedish-vat/vat-compliance-reference' },
'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { slug: string; tier: string; tags: string[]; body: string }
expect(result.slug).toBe('horizontal/swedish-vat/vat-compliance-reference')
expect(result.tier).toBe('horizontal')
expect(result.tags).toContain('reference')
expect(result.body).toContain('Deep reference body.')
})
it('does not resolve a reference whose curation switch (mcp_exposed) is off', async () => {
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
const supabase = makeSupabaseWithEmptyAtomRegistry([], {}, {
id: 'horizontal/swedish-vat/vat-compliance-reference',
tier: 'horizontal',
title: 'x',
description: 'desc',
sni_prefixes: [],
body: '# body',
body_path: '.claude/skills/swedish-vat/references/vat-compliance-reference.md',
is_active: true,
mcp_exposed: false,
parent_atom_id: 'horizontal/swedish-vat',
})
await expect(
tool.execute(
{ slug: 'horizontal/swedish-vat/vat-compliance-reference' },
'company-1', 'user-1', supabase as never, { type: 'api_key' },
),
).rejects.toThrow(/Skill not found/)
})
it('skips an atom whose body is null in the DB (no on-disk fallback in prod)', async () => {
const prev = process.env.NODE_ENV
// Force the prod path so the dev disk-fallback is disabled.
process.env.NODE_ENV = 'production'
try {
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
const supabase = makeSupabaseWithEmptyAtomRegistry([
{
id: 'vertical/konsult-it',
tier: 'vertical',
title: 'Konsult-IT',
description: 'desc',
sni_prefixes: ['62.01'],
body: null,
body_path: '.claude/skills/industry/konsult-it/SKILL.md',
},
])
// The atom is skipped (empty body), so the slug is not found.
await expect(
tool.execute({ slug: 'vertical/konsult-it' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
).rejects.toThrow()
} finally {
process.env.NODE_ENV = prev
}
})
})
describe('Skills via MCP protocol', () => {
beforeEach(() => {
vi.clearAllMocks()
// Protocol tests use the createServiceClientNoCookies mock (empty registry)
// — reset the module-level atom cache so we don't see stragglers from
// earlier tests in the file that populated the cache via direct execute().
__resetAtomCache()
})
it('resources/list includes one entry per skill at Accounted://skill/<slug>', async () => {
const res = await handleMcpRequest(mcpRequest('resources/list'))
const result = await parseResult(res)
const uris = result.resources.map((r: { uri: string }) => r.uri)
for (const skill of skills) {
expect(uris).toContain(skillUri(skill.slug))
}
})
it('skill resources have the text/markdown mimeType', async () => {
const res = await handleMcpRequest(mcpRequest('resources/list'))
const result = await parseResult(res)
const skillResources = result.resources.filter((r: { uri: string }) =>
r.uri.startsWith(SKILL_URI_PREFIX)
)
expect(skillResources.length).toBe(skills.length)
for (const r of skillResources) {
expect(r.mimeType).toBe('text/markdown')
}
})
it('resources/read returns the Markdown body for a skill URI', async () => {
const res = await handleMcpRequest(
mcpRequest('resources/read', { uri: 'Accounted://skill/quarterly-vat-review' })
)
const result = await parseResult(res)
expect(result.contents).toHaveLength(1)
expect(result.contents[0].uri).toBe('Accounted://skill/quarterly-vat-review')
expect(result.contents[0].mimeType).toBe('text/markdown')
expect(result.contents[0].text).toContain('# Quarterly VAT Review')
})
it('resources/read returns Resource not found for unknown skill slug', async () => {
const res = await handleMcpRequest(
mcpRequest('resources/read', { uri: 'Accounted://skill/does-not-exist' })
)
const json = await res.json()
expect(json.error).toBeDefined()
expect(json.error.message).toContain('Resource not found')
})
it('tools/list includes both skill tools', async () => {
const res = await handleMcpRequest(mcpRequest('tools/list'))
const result = await parseResult(res)
const names = result.tools.map((t: { name: string }) => t.name)
expect(names).toContain('gnubok_list_skills')
expect(names).toContain('gnubok_load_skill')
})
})