Add/ai native supp (#385)
* feat(branding): implement dynamic branding in service worker and reports * feat(auth): enhance API key scopes and add bookkeeping write scope - Updated transaction write scope description to include additional tools. - Enhanced reports read scope description to reflect new functionality. - Introduced bookkeeping write scope with relevant description. - Updated SCOPE_GROUPS to include bookkeeping domain. - Modified TOOL_SCOPE_MAP to include new bookkeeping operations. - Updated validateApiKey function to return api_key_id and api_key_name for better actor attribution. feat(tests): add unit tests for MCP resource registry - Created tests for data resources to ensure all required fields are present. - Added tests for resource query parsing and retrieval. feat(resources): implement MCP resources for company and accounting data - Added capabilities resource to expose API key capabilities based on granted scopes. - Implemented chart of accounts resource to retrieve active BAS chart. - Created company current resource to fetch active company details. - Developed active fiscal period resource to check posting eligibility. - Implemented recent activity resource to fetch latest journal entries, invoices, and transactions. - Added VAT treatments resource to provide available VAT rates per customer type. feat(pending-operations): introduce risk tiers for operations - Added risk level classification for pending operations to determine auto-commit eligibility. - Implemented functions to classify operation risk levels and identify high-risk operations. feat(migrations): add actor model and risk tier to pending operations - Updated pending_operations table to include actor type and risk level columns. - Enhanced audit_log to mirror actor information for compliance. - Modified validate_and_increment_api_key function to return actor details. - Expanded operation types in pending_operations to include new high-risk operations. * feat: add auto-commit functionality for low-risk pending operations - Implemented shouldAutoCommit function to determine eligibility for auto-commit based on operation type, actor type, and company settings. - Created commitPendingOperation function to handle execution of pending operations with consistent status updates. - Added tests for shouldAutoCommit to cover various scenarios including high-risk operations, user actors, company opt-in status, and monetary thresholds. - Introduced new columns in company_settings for agent_auto_commit_enabled and agent_auto_commit_max_amount to allow companies to opt-in for auto-commit functionality. - Added SQL migration to update the database schema for new auto-commit settings. * feat(idempotency): implement idempotency key handling for safe retries and cleanup * feat: expand API key scopes and pending operations for bookkeeping - Added 'suppliers:write' scope to API key scopes for supplier invoice management. - Updated SCOPE_GROUPS to include the new 'suppliers:write' scope. - Introduced new pending operation types for bookkeeping: close_period, lock_period, run_year_end, set_opening_balances, run_currency_revaluation, explain_voucher_gap, uncategorize_transaction, approve_supplier_invoice, credit_supplier_invoice, and convert_invoice. - Implemented corresponding commit functions for the new operations in the pending operations module. - Enhanced PendingOperation type to include actor model and risk level attributes. - Added tests for new functionality, ensuring proper behavior and constraints in the database. * feat: implement unlockPeriod functionality and related tests * feat: add agent auto-commit settings and related functionality * feat: add attention resource with comprehensive summary of outstanding tasks * feat: enhance pending operations with 'committing' status and immutability checks, improve idempotency handling, and add original voucher reference for credit notes
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { attentionResource } from '../resources/attention'
|
||||
|
||||
type AttentionResponse = {
|
||||
generated_at: string
|
||||
summary: { total_items: number; critical: number; warning: number; info: number }
|
||||
categories: Array<{
|
||||
key: string
|
||||
severity: 'critical' | 'warning' | 'info'
|
||||
count: number
|
||||
samples: Array<Record<string, unknown>>
|
||||
next?: { description: string; tool?: string; args?: Record<string, unknown>; resource?: string }
|
||||
}>
|
||||
}
|
||||
|
||||
const ctx = (supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']) => ({
|
||||
supabase: supabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
scopes: [],
|
||||
})
|
||||
|
||||
/**
|
||||
* Enqueues 14 baseline empty results in the order the resource consumes them.
|
||||
* Tests can override individual slots before invoking by enqueueing in advance.
|
||||
*/
|
||||
function enqueueEmpty(enqueue: (r: { data?: unknown; error?: unknown; count?: number | null }) => void) {
|
||||
// 1. unbookedHead
|
||||
enqueue({ count: 0 })
|
||||
// 2. unbookedSamples
|
||||
enqueue({ data: [] })
|
||||
// 3. overdueRows
|
||||
enqueue({ data: [] })
|
||||
// 4. pendingSupplierHead
|
||||
enqueue({ count: 0 })
|
||||
// 5. pendingSupplierSamples
|
||||
enqueue({ data: [] })
|
||||
// 6. pendingOpsHead
|
||||
enqueue({ count: 0 })
|
||||
// 7. pendingOpsSamples
|
||||
enqueue({ data: [] })
|
||||
// 8. unmatchedReceiptsHead
|
||||
enqueue({ count: 0 })
|
||||
// 9. unmatchedReceiptsSamples
|
||||
enqueue({ data: [] })
|
||||
// 10. voucherSeriesRows
|
||||
enqueue({ data: [] })
|
||||
// 11. deadlineRows
|
||||
enqueue({ data: [] })
|
||||
// 12. bankConnRows
|
||||
enqueue({ data: [] })
|
||||
// 13. activePeriodRow
|
||||
enqueue({ data: null })
|
||||
// 14. companySettingsRow
|
||||
enqueue({ data: null })
|
||||
}
|
||||
|
||||
describe('gnubok://attention', () => {
|
||||
it('returns empty summary for a brand-new company', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueueEmpty(enqueue)
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
|
||||
expect(result.generated_at).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
||||
expect(result.summary).toEqual({ total_items: 0, critical: 0, warning: 0, info: 0 })
|
||||
expect(result.categories).toEqual([])
|
||||
})
|
||||
|
||||
it('classifies recently-unbooked transactions as warning', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const txns = [
|
||||
{ id: 't-1', date: today, amount: -100, currency: 'SEK', description: 'Lunch', merchant_name: 'Café' },
|
||||
{ id: 't-2', date: today, amount: -200, currency: 'SEK', description: 'Office', merchant_name: 'Clas Ohlson' },
|
||||
]
|
||||
|
||||
enqueue({ count: 2 }) // unbookedHead
|
||||
enqueue({ data: txns }) // unbookedSamples
|
||||
enqueue({ data: [] }) // overdueRows
|
||||
enqueue({ count: 0 }) // pendingSupplierHead
|
||||
enqueue({ data: [] }) // pendingSupplierSamples
|
||||
enqueue({ count: 0 }) // pendingOpsHead
|
||||
enqueue({ data: [] }) // pendingOpsSamples
|
||||
enqueue({ count: 0 }) // unmatchedReceiptsHead
|
||||
enqueue({ data: [] }) // unmatchedReceiptsSamples
|
||||
enqueue({ data: [] }) // voucherSeriesRows
|
||||
enqueue({ data: [] }) // deadlineRows
|
||||
enqueue({ data: [] }) // bankConnRows
|
||||
enqueue({ data: null }) // activePeriodRow
|
||||
enqueue({ data: null }) // companySettingsRow
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
|
||||
expect(result.categories).toHaveLength(1)
|
||||
const cat = result.categories[0]
|
||||
expect(cat.key).toBe('unbooked_transactions')
|
||||
expect(cat.severity).toBe('warning')
|
||||
expect(cat.count).toBe(2)
|
||||
expect(cat.samples).toEqual(txns)
|
||||
expect(cat.next?.tool).toBe('gnubok_categorize_transaction')
|
||||
expect(cat.next?.args).toEqual({ transaction_id: 't-1' })
|
||||
expect(result.summary).toEqual({ total_items: 2, critical: 0, warning: 1, info: 0 })
|
||||
})
|
||||
|
||||
it('escalates unbooked transactions to critical when oldest is > 30 days old', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const fortyDaysAgo = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 10)
|
||||
const txns = [{ id: 't-old', date: fortyDaysAgo, amount: -100, currency: 'SEK', description: 'X', merchant_name: null }]
|
||||
|
||||
enqueue({ count: 1 })
|
||||
enqueue({ data: txns })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
expect(result.categories[0]?.severity).toBe('critical')
|
||||
expect(result.summary.critical).toBe(1)
|
||||
})
|
||||
|
||||
it('flags overdue invoices as critical when any are > 30 days past due', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const fortyDaysAgo = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 10)
|
||||
const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000).toISOString().slice(0, 10)
|
||||
const overdue = [
|
||||
{ id: 'i-1', invoice_number: 'F-2024001', customer_id: 'c-1', due_date: fortyDaysAgo, total: 1000, currency: 'SEK', status: 'overdue' },
|
||||
{ id: 'i-2', invoice_number: 'F-2024002', customer_id: 'c-1', due_date: tenDaysAgo, total: 500, currency: 'SEK', status: 'sent' },
|
||||
]
|
||||
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: overdue })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
const cat = result.categories.find((c) => c.key === 'overdue_invoices')
|
||||
expect(cat?.severity).toBe('critical')
|
||||
expect(cat?.count).toBe(2)
|
||||
})
|
||||
|
||||
it('marks pending operations as critical when any high-risk op is queued', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const ops = [
|
||||
{ id: 'op-1', operation_type: 'close_period', title: 'Stäng FY2025', risk_level: 'high', actor_label: 'Claude', created_at: new Date().toISOString() },
|
||||
{ id: 'op-2', operation_type: 'create_customer', title: 'Ny kund', risk_level: 'low', actor_label: 'Claude', created_at: new Date().toISOString() },
|
||||
]
|
||||
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 2 })
|
||||
enqueue({ data: ops })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
const cat = result.categories.find((c) => c.key === 'pending_operations')
|
||||
expect(cat?.severity).toBe('critical')
|
||||
expect(cat?.count).toBe(2)
|
||||
expect(result.summary.critical).toBe(1)
|
||||
})
|
||||
|
||||
it('flags voucher gaps as critical and includes next tool args', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const seriesRows = [{ voucher_series: 'A', fiscal_period_id: 'fp-1' }]
|
||||
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: seriesRows }) // voucherSeriesRows
|
||||
enqueue({ data: [] }) // deadlineRows
|
||||
enqueue({ data: [] }) // bankConnRows
|
||||
enqueue({ data: null }) // activePeriodRow
|
||||
enqueue({ data: null }) // companySettingsRow
|
||||
// Loop body for series 'A':
|
||||
enqueue({ data: [{ gap_start: 5, gap_end: 7 }] }) // detect_voucher_gaps RPC
|
||||
enqueue({ data: [] }) // voucher_gap_explanations follow-up
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
const cat = result.categories.find((c) => c.key === 'voucher_gaps_unexplained')
|
||||
expect(cat?.severity).toBe('critical')
|
||||
expect(cat?.count).toBe(1)
|
||||
expect(cat?.next?.tool).toBe('gnubok_explain_voucher_gap')
|
||||
expect(cat?.next?.args).toEqual({
|
||||
fiscal_period_id: 'fp-1',
|
||||
voucher_series: 'A',
|
||||
gap_start: 5,
|
||||
gap_end: 7,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits voucher_gaps category when all gaps are explained', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const seriesRows = [{ voucher_series: 'A', fiscal_period_id: 'fp-1' }]
|
||||
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: seriesRows })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: [{ gap_start: 5, gap_end: 7 }] })
|
||||
enqueue({
|
||||
data: [{ voucher_series: 'A', gap_start: 5, gap_end: 7, fiscal_period_id: 'fp-1' }],
|
||||
})
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
expect(result.categories.find((c) => c.key === 'voucher_gaps_unexplained')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('flags expired bank consent as critical', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)
|
||||
const banks = [
|
||||
{ id: 'bc-1', bank_name: 'SEB', status: 'active', consent_expires: yesterday },
|
||||
]
|
||||
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: banks })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
const cat = result.categories.find((c) => c.key === 'bank_consent_expiring')
|
||||
expect(cat?.severity).toBe('critical')
|
||||
expect(cat?.count).toBe(1)
|
||||
})
|
||||
|
||||
it('classifies upcoming lock as info severity', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const inSevenDays = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10)
|
||||
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: { id: 'fp-1', name: 'FY2026', period_start: '2026-01-01', period_end: '2026-12-31', locked_at: null, is_closed: false } })
|
||||
enqueue({ data: { bookkeeping_locked_through: inSevenDays, auto_lock_period_days: null } })
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
const cat = result.categories.find((c) => c.key === 'period_lock_approaching')
|
||||
expect(cat?.severity).toBe('info')
|
||||
expect(result.summary.info).toBe(1)
|
||||
})
|
||||
|
||||
it('combines multiple categories into a coherent summary', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
enqueue({ count: 1 }) // unbookedHead
|
||||
enqueue({ data: [{ id: 't-1', date: today, amount: -50, currency: 'SEK', description: 'X', merchant_name: null }] })
|
||||
enqueue({ data: [] }) // overdueRows
|
||||
enqueue({ count: 1 }) // pendingSupplierHead
|
||||
enqueue({ data: [{ id: 'si-1', supplier_invoice_number: 'L-1', supplier_id: 's-1', total: 1000, currency: 'SEK', due_date: today }] })
|
||||
enqueue({ count: 0 }) // pendingOpsHead
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [{ id: 'd-1', title: 'Moms Q1', due_date: today, deadline_type: 'tax', tax_deadline_type: 'vat', status: 'upcoming' }] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
expect(result.categories).toHaveLength(3)
|
||||
expect(new Set(result.categories.map((c) => c.key))).toEqual(
|
||||
new Set(['unbooked_transactions', 'pending_supplier_invoices', 'deadlines_upcoming'])
|
||||
)
|
||||
expect(result.summary.total_items).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -209,17 +209,24 @@ describe('MCP Receipt Matcher', () => {
|
||||
// ── Protocol: resources/list ──
|
||||
|
||||
describe('resources/list', () => {
|
||||
it('returns the receipt-matcher resource', async () => {
|
||||
it('includes the receipt-matcher widget alongside data resources', async () => {
|
||||
const res = await handleMcpRequest(mcpRequest('resources/list'))
|
||||
const result = await parseResult(res)
|
||||
|
||||
expect(result.resources).toHaveLength(1)
|
||||
expect(result.resources[0]).toEqual({
|
||||
const widget = result.resources.find(
|
||||
(r: { uri: string }) => r.uri === 'ui://receipt-matcher/app.html'
|
||||
)
|
||||
expect(widget).toEqual({
|
||||
uri: 'ui://receipt-matcher/app.html',
|
||||
name: 'Receipt Matcher',
|
||||
description: 'Interactive widget for matching receipts to uncategorized transactions',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
})
|
||||
|
||||
// Data resources (added in Stream 3 Phase 1) should also be listed.
|
||||
const uris = result.resources.map((r: { uri: string }) => r.uri)
|
||||
expect(uris).toContain('gnubok://company/current')
|
||||
expect(uris).toContain('gnubok://capabilities')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { dataResources, findResource, parseResourceQuery } from '../resources'
|
||||
|
||||
describe('mcp resource registry', () => {
|
||||
it('exposes all data resources with required fields', () => {
|
||||
expect(dataResources).toHaveLength(7)
|
||||
const uris = dataResources.map((r) => r.uri).sort()
|
||||
expect(uris).toEqual([
|
||||
'gnubok://attention',
|
||||
'gnubok://capabilities',
|
||||
'gnubok://chart-of-accounts',
|
||||
'gnubok://company/current',
|
||||
'gnubok://period/active',
|
||||
'gnubok://recent-activity',
|
||||
'gnubok://settings/vat-treatments',
|
||||
])
|
||||
|
||||
for (const r of dataResources) {
|
||||
expect(r.name).toBeTruthy()
|
||||
expect(r.description.length).toBeGreaterThan(20)
|
||||
expect(r.mimeType).toBe('application/json')
|
||||
expect(typeof r.read).toBe('function')
|
||||
}
|
||||
})
|
||||
|
||||
it('matches base URI ignoring query string', () => {
|
||||
const r = findResource('gnubok://recent-activity?limit=5')
|
||||
expect(r?.uri).toBe('gnubok://recent-activity')
|
||||
})
|
||||
|
||||
it('returns null for unknown URI', () => {
|
||||
expect(findResource('gnubok://does-not-exist')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses query params from URI', () => {
|
||||
const q = parseResourceQuery('gnubok://recent-activity?limit=5&offset=10')
|
||||
expect(q?.get('limit')).toBe('5')
|
||||
expect(q?.get('offset')).toBe('10')
|
||||
})
|
||||
|
||||
it('returns undefined when no query', () => {
|
||||
expect(parseResourceQuery('gnubok://capabilities')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('vat-treatments resource', () => {
|
||||
it('returns matrix for all customer types without DB access', async () => {
|
||||
const r = findResource('gnubok://settings/vat-treatments')!
|
||||
const result = (await r.read({
|
||||
// Pure-function resource: no DB calls
|
||||
supabase: undefined as never,
|
||||
companyId: 'irrelevant',
|
||||
userId: 'irrelevant',
|
||||
scopes: [],
|
||||
})) as { treatments: string[]; by_customer_type: Record<string, unknown> }
|
||||
|
||||
expect(result.treatments).toContain('standard_25')
|
||||
expect(result.treatments).toContain('reverse_charge')
|
||||
expect(Object.keys(result.by_customer_type)).toEqual([
|
||||
'individual',
|
||||
'swedish_business',
|
||||
'eu_business',
|
||||
'non_eu_business',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { withNext, toToolError } from '../tool-result'
|
||||
|
||||
describe('withNext', () => {
|
||||
it('returns plain { data } when no hint provided', () => {
|
||||
expect(withNext({ id: 'x' })).toEqual({ data: { id: 'x' } })
|
||||
})
|
||||
|
||||
it('attaches next hint when provided', () => {
|
||||
const result = withNext(
|
||||
{ id: 'x' },
|
||||
{ description: 'Send the invoice', tool: 'gnubok_send_invoice' }
|
||||
)
|
||||
expect(result).toEqual({
|
||||
data: { id: 'x' },
|
||||
next: { description: 'Send the invoice', tool: 'gnubok_send_invoice' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toToolError', () => {
|
||||
it('produces structured error from arbitrary throw', () => {
|
||||
const result = toToolError(new Error('Period must be locked before closing'))
|
||||
expect(result.error.code).toBe('PERIOD_NOT_LOCKED')
|
||||
expect(result.error.message_sv).toBeTruthy()
|
||||
expect(result.error.message_en).toContain('Period must be locked')
|
||||
expect(result.error.remediation?.tool).toBe('gnubok_lock_period')
|
||||
})
|
||||
|
||||
it('extracts attempted scope from "Insufficient scope:" message', () => {
|
||||
const result = toToolError(
|
||||
new Error('Insufficient scope: this API key does not have the "payroll:write" scope')
|
||||
)
|
||||
expect(result.error.code).toBe('INSUFFICIENT_SCOPE')
|
||||
expect(result.error.remediation?.description).toContain('"payroll:write"')
|
||||
})
|
||||
|
||||
it('handles non-Error throws', () => {
|
||||
const result = toToolError('something broke')
|
||||
expect(result.error.code).toBe('UNKNOWN_ERROR')
|
||||
expect(result.error.message_en).toBe('something broke')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user