* feat(mcp): supplier and date filters on list_supplier_invoices Add supplier_id, supplier_name (case-insensitive substring, resolved server-side against the suppliers table), date_from and date_to (inclusive, on invoice_date) to gnubok_list_supplier_invoices, mirroring the v1 REST supplier-invoices filter shapes (eq on supplier_id, gte/lte on invoice_date). Unknown supplier_name returns an empty result without touching the invoice table; malformed dates and non-uuid supplier_id fail loudly with pointer messages. Schema text is kept deliberately terse: the tools/list payload guard sits at near-zero headroom, so the date format hint lives once in the tool description and the redundant status enum prose was trimmed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 * fix(mcp): bound the supplier_name match set fed to the IN clause PR Agent review flagged the unbounded id list: a broad substring on a large supplier register could build an IN clause past PostgREST URL limits. Cap name matches at 200 (cap + 1 fetched so overflow is detected) and fail loudly with a refine hint instead of degrading. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 * fix(mcp): loud validation, literal name matching, truncation signal on list_supplier_invoices Skeptic review refuted three behaviors; all three fixed: 1. Silent truncation: date filters invite period reconciliation but the 50-row cap was unsignalled. The invoice query now uses count exact and returns total_count and has_more (same contract as list_invoices), with a unique-id order tiebreaker so identical calls return identical subsets. 2. Silent filter drop: non-string values for the new params (and blank supplier_name) fell through typeof guards and returned the UNFILTERED ledger presented as filtered, the same class arg-guard exists for. They now throw clear errors. Dates are also calendar-validated, so 2026-02-30 fails loudly instead of as a raw Postgres cast error. 3. Wildcard broadening: PostgREST rewrites * in ilike values to %, so Star*Mart matched Starke Martinsson AB. supplier_name is now matched as a literal case-insensitive substring in JS over the company's suppliers (fetchAllRows, parity with list_suppliers), cap unchanged. Two redundant property descriptions dropped to fund the outputSchema additions under the tools/list payload ceiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import { tools } from '../server'
|
|
|
|
const tool = () => tools.find((candidate) => candidate.name === 'gnubok_list_supplier_invoices')!
|
|
|
|
const COMPANY = 'company-1'
|
|
const USER = 'user-1'
|
|
const SUPPLIER_UUID = '11111111-2222-4333-8444-555555555555'
|
|
|
|
const invoiceRow = (overrides: Record<string, unknown> = {}) => ({
|
|
id: 'si-1',
|
|
supplier_invoice_number: 'F-1001',
|
|
invoice_date: '2026-03-10',
|
|
due_date: '2026-04-09',
|
|
status: 'approved',
|
|
total: 1250,
|
|
total_sek: 1250,
|
|
currency: 'SEK',
|
|
vat_treatment: 'standard',
|
|
remaining_amount: 1250,
|
|
default_dimensions: null,
|
|
supplier: { id: SUPPLIER_UUID, name: 'Office Depot AB' },
|
|
...overrides,
|
|
})
|
|
|
|
type ListResult = {
|
|
invoices: Array<Record<string, unknown>>
|
|
count: number
|
|
total_count: number
|
|
has_more: boolean
|
|
}
|
|
|
|
describe('gnubok_list_supplier_invoices: filters', () => {
|
|
it('filters by supplier_id alone (eq on supplier_id, no supplier lookup)', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: [invoiceRow()], count: 1 })
|
|
|
|
const result = (await tool().execute(
|
|
{ supplier_id: SUPPLIER_UUID },
|
|
COMPANY,
|
|
USER,
|
|
supabase as never,
|
|
)) as ListResult
|
|
|
|
expect(result.count).toBe(1)
|
|
expect(result.total_count).toBe(1)
|
|
expect(result.has_more).toBe(false)
|
|
expect(result.invoices[0].id).toBe('si-1')
|
|
const eqCalls = findCalls('supplier_invoices', 'eq')
|
|
expect(eqCalls).toContainEqual(['supplier_id', SUPPLIER_UUID])
|
|
// Resolving supplier_name is the only reason to touch suppliers.
|
|
expect(findCalls('suppliers', 'select')).toHaveLength(0)
|
|
expect(findCalls('supplier_invoices', 'gte')).toHaveLength(0)
|
|
expect(findCalls('supplier_invoices', 'lte')).toHaveLength(0)
|
|
})
|
|
|
|
it('rejects a non-uuid supplier_id with a pointer to supplier_name', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
await expect(
|
|
tool().execute({ supplier_id: 'Office Depot' }, COMPANY, USER, supabase as never),
|
|
).rejects.toThrow(/supplier UUID.*supplier_name/)
|
|
})
|
|
|
|
it('rejects non-string filter values instead of silently returning the unfiltered list', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
await expect(
|
|
tool().execute({ date_from: 20260101 }, COMPANY, USER, supabase as never),
|
|
).rejects.toThrow(/date_from must be a string/)
|
|
await expect(
|
|
tool().execute({ supplier_name: ['Office Depot'] }, COMPANY, USER, supabase as never),
|
|
).rejects.toThrow(/supplier_name must be a string/)
|
|
await expect(
|
|
tool().execute({ supplier_id: 42 }, COMPANY, USER, supabase as never),
|
|
).rejects.toThrow(/supplier_id must be a string/)
|
|
await expect(
|
|
tool().execute({ date_to: null }, COMPANY, USER, supabase as never),
|
|
).rejects.toThrow(/date_to must be a string/)
|
|
})
|
|
|
|
it('rejects a blank supplier_name instead of dropping the filter', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
await expect(
|
|
tool().execute({ supplier_name: ' ' }, COMPANY, USER, supabase as never),
|
|
).rejects.toThrow(/supplier_name must not be blank/)
|
|
})
|
|
|
|
it('filters by supplier_name alone: literal case-insensitive substring resolved to supplier ids', async () => {
|
|
const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: [
|
|
{ id: 'sup-1', name: 'Office Depot AB' },
|
|
{ id: 'sup-2', name: 'Nordic OFFICE Supplies' },
|
|
{ id: 'sup-3', name: 'Byggmax AB' },
|
|
],
|
|
}) // suppliers fetch
|
|
enqueue({ data: [invoiceRow()], count: 1 }) // invoice list
|
|
|
|
const result = (await tool().execute(
|
|
{ supplier_name: 'office' },
|
|
COMPANY,
|
|
USER,
|
|
supabase as never,
|
|
)) as ListResult
|
|
|
|
expect(result.count).toBe(1)
|
|
expect(findCall('suppliers', 'select')).toEqual(['id, name'])
|
|
expect(findCalls('suppliers', 'eq')).toContainEqual(['company_id', COMPANY])
|
|
expect(findCalls('supplier_invoices', 'in')).toContainEqual([
|
|
'supplier_id',
|
|
['sup-1', 'sup-2'],
|
|
])
|
|
})
|
|
|
|
it('matches supplier_name literally: * and % are not wildcards', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: [
|
|
{ id: 'sup-1', name: 'Star*Mart' },
|
|
{ id: 'sup-2', name: 'Starke Martinsson AB' },
|
|
{ id: 'sup-3', name: '100%_AB' },
|
|
],
|
|
})
|
|
enqueue({ data: [], count: 0 })
|
|
|
|
await tool().execute({ supplier_name: 'Star*Mart' }, COMPANY, USER, supabase as never)
|
|
|
|
expect(findCalls('supplier_invoices', 'in')).toContainEqual(['supplier_id', ['sup-1']])
|
|
})
|
|
|
|
it('unknown supplier_name yields an empty result without querying invoices', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: [{ id: 'sup-1', name: 'Byggmax AB' }] }) // no substring match
|
|
|
|
const result = (await tool().execute(
|
|
{ supplier_name: 'no such vendor' },
|
|
COMPANY,
|
|
USER,
|
|
supabase as never,
|
|
)) as ListResult
|
|
|
|
expect(result).toEqual({ invoices: [], count: 0, total_count: 0, has_more: false })
|
|
expect(findCalls('supplier_invoices', 'select')).toHaveLength(0)
|
|
})
|
|
|
|
it('rejects a supplier_name matching more suppliers than the cap', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: Array.from({ length: 201 }, (_, i) => ({ id: `sup-${i}`, name: `Byrå AB ${i}` })),
|
|
})
|
|
|
|
await expect(
|
|
tool().execute({ supplier_name: 'byrå' }, COMPANY, USER, supabase as never),
|
|
).rejects.toThrow(/matches more than 200 suppliers/)
|
|
})
|
|
|
|
it('filters by date_from alone (gte on invoice_date)', async () => {
|
|
const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: [], count: 0 })
|
|
|
|
await tool().execute({ date_from: '2026-01-01' }, COMPANY, USER, supabase as never)
|
|
|
|
expect(findCall('supplier_invoices', 'gte')).toEqual(['invoice_date', '2026-01-01'])
|
|
expect(findCalls('supplier_invoices', 'lte')).toHaveLength(0)
|
|
})
|
|
|
|
it('filters by date_to alone (lte on invoice_date)', async () => {
|
|
const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: [], count: 0 })
|
|
|
|
await tool().execute({ date_to: '2026-06-30' }, COMPANY, USER, supabase as never)
|
|
|
|
expect(findCall('supplier_invoices', 'lte')).toEqual(['invoice_date', '2026-06-30'])
|
|
expect(findCalls('supplier_invoices', 'gte')).toHaveLength(0)
|
|
})
|
|
|
|
it('combines status, supplier_id, supplier_name and date range', async () => {
|
|
const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: [{ id: SUPPLIER_UUID, name: 'Office Depot AB' }] }) // suppliers fetch
|
|
enqueue({ data: [invoiceRow()], count: 1 }) // invoice list
|
|
|
|
const result = (await tool().execute(
|
|
{
|
|
status: 'to_pay',
|
|
supplier_id: SUPPLIER_UUID,
|
|
supplier_name: 'office',
|
|
date_from: '2026-01-01',
|
|
date_to: '2026-12-31',
|
|
},
|
|
COMPANY,
|
|
USER,
|
|
supabase as never,
|
|
)) as ListResult
|
|
|
|
expect(result.count).toBe(1)
|
|
const inCalls = findCalls('supplier_invoices', 'in')
|
|
expect(inCalls).toContainEqual(['status', ['approved', 'overdue']])
|
|
expect(inCalls).toContainEqual(['supplier_id', [SUPPLIER_UUID]])
|
|
expect(findCalls('supplier_invoices', 'eq')).toContainEqual(['supplier_id', SUPPLIER_UUID])
|
|
expect(findCall('supplier_invoices', 'gte')).toEqual(['invoice_date', '2026-01-01'])
|
|
expect(findCall('supplier_invoices', 'lte')).toEqual(['invoice_date', '2026-12-31'])
|
|
// Deterministic order: due_date with the unique id as tiebreaker.
|
|
const orderCalls = findCalls('supplier_invoices', 'order')
|
|
expect(orderCalls).toContainEqual(['due_date', { ascending: true }])
|
|
expect(orderCalls).toContainEqual(['id', { ascending: true }])
|
|
})
|
|
|
|
it('rejects a malformed date', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
await expect(
|
|
tool().execute({ date_from: '01/02/2026' }, COMPANY, USER, supabase as never),
|
|
).rejects.toThrow(/date_from must be a valid ISO date/)
|
|
await expect(
|
|
tool().execute({ date_to: '2026-6-1' }, COMPANY, USER, supabase as never),
|
|
).rejects.toThrow(/date_to must be a valid ISO date/)
|
|
})
|
|
|
|
it('rejects an impossible calendar date that passes the shape regex', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
await expect(
|
|
tool().execute({ date_from: '2026-02-30' }, COMPANY, USER, supabase as never),
|
|
).rejects.toThrow(/date_from must be a valid ISO date/)
|
|
await expect(
|
|
tool().execute({ date_to: '2026-13-01' }, COMPANY, USER, supabase as never),
|
|
).rejects.toThrow(/date_to must be a valid ISO date/)
|
|
})
|
|
|
|
it('rejects a reversed date range', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
await expect(
|
|
tool().execute(
|
|
{ date_from: '2026-12-31', date_to: '2026-01-01' },
|
|
COMPANY,
|
|
USER,
|
|
supabase as never,
|
|
),
|
|
).rejects.toThrow(/date_from must not be after date_to/)
|
|
})
|
|
|
|
it('signals truncation: total_count and has_more expose rows past the limit', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: Array.from({ length: 50 }, (_, i) => invoiceRow({ id: `si-${i}` })),
|
|
count: 60,
|
|
})
|
|
|
|
const result = (await tool().execute(
|
|
{ date_from: '2026-01-01', date_to: '2026-03-31' },
|
|
COMPANY,
|
|
USER,
|
|
supabase as never,
|
|
)) as ListResult
|
|
|
|
expect(result.count).toBe(50)
|
|
expect(result.total_count).toBe(60)
|
|
expect(result.has_more).toBe(true)
|
|
})
|
|
|
|
it('keeps the unfiltered path unchanged: no supplier lookup, no date filters', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: [invoiceRow(), invoiceRow({ id: 'si-2' })], count: 2 })
|
|
|
|
const result = (await tool().execute({}, COMPANY, USER, supabase as never)) as ListResult
|
|
|
|
expect(result.count).toBe(2)
|
|
expect(result.total_count).toBe(2)
|
|
expect(result.has_more).toBe(false)
|
|
expect(findCalls('suppliers', 'select')).toHaveLength(0)
|
|
expect(findCalls('supplier_invoices', 'gte')).toHaveLength(0)
|
|
expect(findCalls('supplier_invoices', 'lte')).toHaveLength(0)
|
|
expect(findCalls('supplier_invoices', 'in')).toHaveLength(0)
|
|
})
|
|
})
|