Files
accounted/extensions/general/mcp-server/__tests__/report-date-range.test.ts
T
MattssonandClaude Fable 5 85e039035d feat(reports): custom date ranges on report endpoints in REST v1 and MCP, plus PDF export via API (#1909)
* feat(reports): custom date ranges on report endpoints in REST v1 and MCP, plus PDF export via API

Requested by a v1/MCP user: the web UI can produce resultat- and
balansrapport for a custom period with PDF export, but REST v1 and the
MCP tools only served whole fiscal years and silently ignored
from_date/to_date.

- v1 income-statement: optional from_date/to_date (validated against the
  fiscal period via the same parseReportDateRange the dashboard uses)
- v1 balance-sheet: same, plus as_of as the natural alias for to_date
  (mutually exclusive with it)
- Unknown query params on these report routes now return
  VALIDATION_ERROR with the unknown and allowed names instead of being
  silently dropped (scoped to these routes, not a global v1 change)
- MCP gnubok_get_income_statement: from_date/to_date;
  gnubok_get_balance_sheet: as_of_date; both validate format, in-period
  and ordering, and reject unknown args (tools/list payload bench held
  under the ceiling by trimming the same tools' descriptions)
- New v1 PDF endpoints reports/{income-statement,balance-sheet}/pdf,
  byte-equivalent to the dashboard export: the K2/K3 grouping and the
  balance gate moved to lib/reports/financial-statement-pdf.ts, shared
  by both surfaces
- Both JSON endpoints echo the effective range in data.period

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reports): range semantics, empty-date validation, and review findings on PR #1909

Consolidated resolution of the skeptic refutations, CI failures, and
CodeRabbit findings:

- Ranged income statement summed closing balances, so from_date after
  period start returned year-to-date figures mislabeled as the range
  (July revenue reported as Jan-Jul on JSON, PDF, and MCP). The trial
  balance rolls pre-range P&L activity into opening columns, so
  generateIncomeStatement now builds from period movements whenever
  fromDate is set, matching the resultatrapport convention. Full-period
  behavior is unchanged; generator-level regression tests added.
- from_date dropped from the v1 balance-sheet routes (JSON + PDF): a
  balansraking is a cumulative position, not a flow over a window
  (ÅRL 3 kap); matches the MCP tool's as_of_date-only surface.
- Empty date values (from_date=) now fail validation instead of
  silently producing a full-period report with an empty period echo
  (null-check instead of truthiness in parseReportDateRange).
- dry_run, read by the withApiV1 wrapper on every request, is tolerated
  by the strict param check instead of being rejected as unknown.
- Unbalanced balansrakning on the v1 PDF route returns 400 (caller-data
  condition), matching the dashboard export, instead of 500.
- skills/accounted-api regenerated (apiskill:check gate).
- Removed the ISO_DATE_RE import that collided with the pre-existing
  local declaration in the MCP server (TS2440 on core build).
- CodeRabbit: 401 tests for both PDF endpoints; event bus cleared in
  the new MCP test's beforeEach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 20:34:21 +02:00

167 lines
5.7 KiB
TypeScript

/**
* Custom date-range args on the report tools:
* gnubok_get_income_statement (from_date/to_date) and
* gnubok_get_balance_sheet (as_of_date). The generators are mocked; under
* test is the MCP layer: validation (format, inside-period, ordering), the
* options handoff, the effective-period echo, and the unknown-arg rejection
* that replaces the old silent ignoring.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { tools } from '../server'
vi.mock('@/lib/reports/income-statement', () => ({ generateIncomeStatement: vi.fn() }))
vi.mock('@/lib/reports/balance-sheet', () => ({ generateBalanceSheet: vi.fn() }))
const incomeStatement = tools.find((t) => t.name === 'gnubok_get_income_statement')!
const balanceSheet = tools.find((t) => t.name === 'gnubok_get_balance_sheet')!
const mockIncomeStatement = vi.mocked(generateIncomeStatement)
const mockBalanceSheet = vi.mocked(generateBalanceSheet)
const PERIOD_ROW = {
id: 'fp-1',
name: '2026',
period_start: '2026-01-01',
period_end: '2026-12-31',
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('gnubok_get_income_statement: from_date/to_date', () => {
it('passes the range to the generator and echoes the effective window', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null }) // period info
mockIncomeStatement.mockResolvedValueOnce({ net_result: 42 } as never)
const result = (await incomeStatement.execute(
{ period_id: 'fp-1', from_date: '2026-01-01', to_date: '2026-07-31' },
'company-1',
'user-1',
supabase as never,
)) as { period: { start: string; end: string } }
expect(mockIncomeStatement).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', {
fromDate: '2026-01-01',
toDate: '2026-07-31',
})
expect(result.period).toEqual({ start: '2026-01-01', end: '2026-07-31' })
})
it('rejects a malformed from_date instead of silently ignoring it', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
await expect(
incomeStatement.execute(
{ period_id: 'fp-1', from_date: '31/07/2026' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/from_date must be an ISO date/)
expect(mockIncomeStatement).not.toHaveBeenCalled()
})
it('rejects a range outside the fiscal period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
await expect(
incomeStatement.execute(
{ period_id: 'fp-1', to_date: '2027-01-31' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/within the fiscal period/)
expect(mockIncomeStatement).not.toHaveBeenCalled()
})
it('rejects from_date after to_date', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
await expect(
incomeStatement.execute(
{ period_id: 'fp-1', from_date: '2026-08-01', to_date: '2026-07-01' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/must not be after/)
expect(mockIncomeStatement).not.toHaveBeenCalled()
})
it('rejects unknown args instead of silently ignoring them', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
await expect(
incomeStatement.execute(
{ period_id: 'fp-1', fromdate: '2026-01-01' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Unknown parameter\(s\): fromdate/)
expect(mockIncomeStatement).not.toHaveBeenCalled()
})
})
describe('gnubok_get_balance_sheet: as_of_date', () => {
it('maps as_of_date to the generator toDate and echoes the effective window', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null }) // period info (most-recent lookup skipped: period_id given)
mockBalanceSheet.mockResolvedValueOnce({ total_assets: 0, total_equity_liabilities: 0 } as never)
const result = (await balanceSheet.execute(
{ period_id: 'fp-1', as_of_date: '2026-07-31' },
'company-1',
'user-1',
supabase as never,
)) as { period: { start: string; end: string } }
expect(mockBalanceSheet).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', {
toDate: '2026-07-31',
})
expect(result.period).toEqual({ start: '2026-01-01', end: '2026-07-31' })
})
it('rejects an as_of_date outside the fiscal period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
await expect(
balanceSheet.execute(
{ period_id: 'fp-1', as_of_date: '2025-12-31' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/within the fiscal period/)
expect(mockBalanceSheet).not.toHaveBeenCalled()
})
it('rejects unknown args instead of silently ignoring them', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
await expect(
balanceSheet.execute(
{ period_id: 'fp-1', to_date: '2026-07-31' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Unknown parameter\(s\): to_date/)
expect(mockBalanceSheet).not.toHaveBeenCalled()
})
})