Files
accounted/extensions/general/mcp-server/__tests__/paginated-list-continuation.test.ts
T
Mattsson 8a2498a987 fix(mcp): add true continuation to invoice list tools (#1327)
* fix(mcp): add true list continuation

Signed-off-by: Emil <emilmattsson14@gmail.com>

* test(mcp): cover pagination edge cases

Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(mcp): validate pagination offsets

Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(mcp): preserve continuation without counts

Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
2026-08-01 19:03:51 +02:00

197 lines
5.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools } from '../server'
const invoiceRow = (id: string) => ({
id,
invoice_number: 'INV-100',
status: 'sent',
customer_id: 'customer-1',
total: 1250,
currency: 'SEK',
invoice_date: '2026-08-01',
due_date: '2026-08-31',
document_type: 'invoice',
default_dimensions: null,
customers: { name: 'Example Customer AB' },
})
const scheduleRow = (id: string) => ({
id,
name: 'Monthly support',
status: 'active',
customer_id: 'customer-1',
day_of_month: 25,
send_hour: 9,
payment_terms_days: 30,
currency: 'SEK',
auto_send: false,
default_dimensions: null,
next_run_date: '2026-08-25',
last_run_at: null,
last_invoice_id: null,
last_run_warning: null,
generated_count: 0,
customer: { name: 'Example Customer AB' },
items: [],
})
const cases = [
{
name: 'gnubok_list_invoices',
table: 'invoices',
itemsKey: 'invoices',
primaryOrder: 'invoice_date',
row: invoiceRow,
},
{
name: 'gnubok_list_recurring_schedules',
table: 'recurring_invoice_schedules',
itemsKey: 'schedules',
primaryOrder: 'created_at',
row: scheduleRow,
},
] as const
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe.each(cases)('$name continuation', ({ name, table, itemsKey, primaryOrder, row }) => {
const tool = tools.find((candidate) => candidate.name === name)!
it('advertises optional offset input and the complete pagination envelope', () => {
expect(tool).toBeDefined()
const input = tool.inputSchema as {
required?: string[]
properties: Record<string, { type?: string; minimum?: number }>
}
expect(input.properties.offset).toMatchObject({ type: 'integer', minimum: 0 })
expect(input.required ?? []).not.toContain('offset')
const output = tool.outputSchema as { required: string[]; properties: Record<string, unknown> }
expect(output.required).toEqual(expect.arrayContaining([itemsKey, 'count', 'total_count', 'has_more']))
expect(output.properties.next_offset).toBeDefined()
})
it('continues from offset and returns the next offset when more rows exist', async () => {
const { supabase, enqueue, findCall, findCalls } = createQueuedMockSupabase()
enqueue({ data: [row('row-5'), row('row-6')], error: null, count: 9 })
const result = (await tool.execute(
{ limit: 2, offset: 4 },
'company-1',
'user-1',
supabase as never,
)) as Record<string, unknown>
expect(findCall(table, 'range')).toEqual([4, 6])
expect(findCalls(table, 'order')).toEqual([
[primaryOrder, { ascending: false }],
['id', { ascending: false }],
])
expect((result[itemsKey] as unknown[])).toHaveLength(2)
expect(result).toMatchObject({
count: 2,
total_count: 9,
has_more: true,
next_offset: 6,
})
})
it('preserves the first-page default and omits next_offset on the last page', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: [row('only-row')], error: null, count: 1 })
const result = (await tool.execute(
{},
'company-1',
'user-1',
supabase as never,
)) as Record<string, unknown>
expect(findCall(table, 'range')).toEqual([0, 50])
expect(result).toMatchObject({
count: 1,
total_count: 1,
has_more: false,
})
expect(result).not.toHaveProperty('next_offset')
})
it('returns an empty terminal page', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [], error: null, count: 0 })
const result = (await tool.execute(
{ limit: 2, offset: 4 },
'company-1',
'user-1',
supabase as never,
)) as Record<string, unknown>
expect(result[itemsKey]).toEqual([])
expect(result).toMatchObject({
count: 0,
total_count: 0,
has_more: false,
})
expect(result).not.toHaveProperty('next_offset')
})
it('normalizes fractional offsets for direct execution', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: [row('row-5')], error: null, count: 6 })
const result = (await tool.execute(
{ limit: 2, offset: 4.9 },
'company-1',
'user-1',
supabase as never,
)) as Record<string, unknown>
expect(findCall(table, 'range')).toEqual([4, 6])
expect(result).toMatchObject({
count: 1,
total_count: 6,
has_more: true,
next_offset: 5,
})
})
it('uses a lookahead row when the exact count is unavailable', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [row('row-5'), row('row-6'), row('lookahead-row')], error: null, count: null })
const result = (await tool.execute(
{ limit: 2, offset: 4 },
'company-1',
'user-1',
supabase as never,
)) as Record<string, unknown>
expect(result[itemsKey]).toHaveLength(2)
expect(result).toMatchObject({
count: 2,
total_count: 7,
has_more: true,
next_offset: 6,
})
})
it('reports database errors', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'connection reset' }, count: null })
await expect(tool.execute(
{},
'company-1',
'user-1',
supabase as never,
)).rejects.toThrow('Database error: connection reset')
})
})