Follow-up sweep to #1842 for sibling bugs of both classes. FX denomination (same defect as categorize's vat_amount): - mapping-engine buildResult computed input VAT, reverse-charge fiktiv moms AND the rutor 20-24 basbelopp pair from the transaction-currency amount while journal lines are SEK. Now resolved through the same lenient SEK ladder buildTransactionEntryLines uses for the gross. Reachable at the ledger only via env-gated auto-book (dev/test) and the mapping-rules evaluate API, but one flag from production. - categorize-core's header doc claimed transaction.amount already IS SEK; rewritten, it was license for exactly this bug class. Output-schema nullability (same defect as matched_supplier_id): - get_payslip.calculation_breakdown: returned as explicit null before calculation (the schema description even said so) but typed object. - get_document_content.mime_type/size_bytes: verbatim nullable columns. - export_sie: company_name nullable, and org_number was returned but never declared under additionalProperties:false, so strict clients failed EVERY successful export. - currency (two transaction listings) and voucher_series (verifikat listing): nullable columns with defaults, loosened defensively. - New declaration-pinning test in output-schema.test.ts. Known and deliberately not fixed here: skatteverket bank-counterpart hint compares a foreign amount to SEK (display-only, missed hint); own-account-detector cross-currency magnitude tie-break (heuristic); invoice-inbox still withholds rule proposals on foreign rows (guard can be lifted separately now that the defect it guards is fixed). Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
79 lines
3.4 KiB
TypeScript
79 lines
3.4 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { tools } from '../server'
|
|
|
|
describe('outputSchema coverage', () => {
|
|
it('every tool declares an outputSchema', () => {
|
|
const missing = tools.filter((t) => !t.outputSchema).map((t) => t.name)
|
|
expect(missing).toEqual([])
|
|
})
|
|
|
|
it('every outputSchema is an object schema', () => {
|
|
for (const t of tools) {
|
|
expect(t.outputSchema, `tool ${t.name} outputSchema`).toBeDefined()
|
|
const schema = t.outputSchema as Record<string, unknown>
|
|
expect(schema.type, `tool ${t.name} outputSchema.type`).toBe('object')
|
|
}
|
|
})
|
|
|
|
it('every tool has a tight description (<= 280 chars)', () => {
|
|
const tooLong = tools.filter((t) => t.description.length > 280)
|
|
expect(tooLong.map((t) => `${t.name}: ${t.description.length} chars`)).toEqual([])
|
|
})
|
|
|
|
it('no description embeds Args:/Returns:/Examples: blocks (those belong to JSON Schema)', () => {
|
|
const verbose = tools.filter((t) =>
|
|
/Args:\s*\n|Returns JSON:|Examples:\s*\n|Errors:\s*\n/.test(t.description)
|
|
)
|
|
expect(verbose.map((t) => t.name)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('annotation correctness', () => {
|
|
it('gnubok_feedback is not read-only (it writes a telemetry event + mutates the rate-limit map)', () => {
|
|
const feedback = tools.find((t) => t.name === 'gnubok_feedback')
|
|
expect(feedback).toBeDefined()
|
|
expect(feedback?.annotations?.readOnlyHint).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('nullable output properties stay declared nullable', () => {
|
|
// 2026-08-24 audit (sibling of the matched_supplier_id fix, PR #1842):
|
|
// these properties are emitted with null on real code paths (explicit
|
|
// `?? null`, or verbatim passthrough of a nullable column). A bare
|
|
// { type: 'string' } makes strict clients fail SUCCESSFUL calls, and the
|
|
// repeated "failures" trip the caller's circuit breaker. This test pins
|
|
// the declarations so a schema edit cannot silently regress them.
|
|
const NULLABLE: Array<[tool: string, prop: string]> = [
|
|
['gnubok_get_payslip', 'calculation_breakdown'],
|
|
['gnubok_get_document_content', 'mime_type'],
|
|
['gnubok_get_document_content', 'size_bytes'],
|
|
['gnubok_export_sie', 'company_name'],
|
|
['gnubok_export_sie', 'org_number'],
|
|
['gnubok_complete_document_upload', 'matched_supplier_id'],
|
|
['gnubok_upload_document', 'matched_supplier_id'],
|
|
]
|
|
|
|
it.each(NULLABLE)('%s.%s allows null and is not required', (toolName, prop) => {
|
|
const tool = tools.find((t) => t.name === toolName)
|
|
expect(tool, toolName).toBeDefined()
|
|
const schema = tool!.outputSchema as {
|
|
properties: Record<string, { type: unknown }>
|
|
required?: string[]
|
|
}
|
|
const declared = schema.properties[prop]
|
|
expect(declared, `${toolName}.${prop} must be declared (additionalProperties is false)`).toBeDefined()
|
|
expect(declared.type, `${toolName}.${prop}`).toContain('null')
|
|
expect(schema.required ?? []).not.toContain(prop)
|
|
})
|
|
|
|
it('gnubok_export_sie declares every key its execute returns', () => {
|
|
// The tool returned an undeclared org_number under
|
|
// additionalProperties: false, failing strict validation on EVERY call.
|
|
const tool = tools.find((t) => t.name === 'gnubok_export_sie')!
|
|
const props = Object.keys((tool.outputSchema as { properties: Record<string, unknown> }).properties)
|
|
for (const key of ['content', 'byte_size', 'fiscal_period_id', 'company_name', 'org_number', 'generated_at']) {
|
|
expect(props, key).toContain(key)
|
|
}
|
|
})
|
|
})
|