Files
accounted/extensions/general/mcp-server/__tests__/dimension-tools.test.ts
T
Jakob WennbergandClaude Fable 5 11126d6d56 feat(dimensions): PR3 tagging — voucher-form pickers, MCP dimension tools with resolve-don't-select, engine soft validation (#859)
Phase 3 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false see zero change; existing free-text API writers keep
working (validation is toggle-governed).

Engine (soft validation):
- validateEntryDimensions() in dimension-resolver: zero queries for untagged
  entries; toggle off → passthrough; toggle on → one settings fetch + two
  registry queries, rejects unknown dims/codes and archived values with
  Swedish per-code messages (DimensionValidationError, 400, details.issues).
  Wired into createDraftEntry + updateDraftEntry before any insert; reversal/
  storno paths untouched (verbatim copies). Fails open on transient registry
  errors — soft validation must never block bookkeeping.

MCP (agent write path):
- New tools: gnubok_list_dimensions, gnubok_list_dimension_values (fuse.js
  fuzzy), gnubok_create_dimension_value (STAGED via pending_operations —
  agents never silently mint reporting values; new op type + CHECK migration
  + executor with duplicate-idempotency).
- create_voucher/correct_entry: per-line dimensions bag + default_dimensions,
  resolve-don't-select server-side (code OR natural-language name; exact →
  fuzzy ≤0.30 with ≥0.15 runner-up margin; non-exact resolutions echoed with
  confidence; ambiguous → ranked candidates, no auto-create).
- gnubok_get_agent_briefing gains a dimensions block (enabled, dims, top
  values) — omitted when registry empty.
- TOOL_SCOPE_MAP entries; risk tier low for staged value creation.

UI:
- JournalEntryForm (manual voucher + TransactionBookingDialog embed): header
  "+ Kostnadsställe/Projekt" progressive disclosure (gäller alla rader with
  documented inheritance rule) + per-row tag popover + compact KS·PR badges;
  gated on dimensions_enabled.
- Voucher detail: display-only dimension badges with registry-name resolution.
- EditDraftEntryDialog carries line dimensions so editing a draft no longer
  strips tags.

categorize/bulk_book dims deferred to PR7 (needs the bulk_book RPC migration).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 13:36:45 +02:00

581 lines
24 KiB
TypeScript

/**
* Dimensions PR3 — MCP surface tests.
*
* Covers the three new tools (gnubok_list_dimensions,
* gnubok_list_dimension_values, staged gnubok_create_dimension_value), the
* resolve-don't-select helper (exact / fuzzy / ambiguous / none / archived),
* and the dims bag flowing from gnubok_create_voucher params into the staged
* lines. Executor-side coverage (commitCreateDimensionValue incl. duplicate
* idempotency) lives in lib/pending-operations/__tests__/.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { tools } from '../server'
import {
resolveValueInDimension,
resolveDimensionBags,
DimensionResolutionError,
mergeLineDimensions,
parseDimensionsArg,
type DimensionRegistryEntry,
} from '../dimensions'
const listDimensions = tools.find((t) => t.name === 'gnubok_list_dimensions')!
const listDimensionValues = tools.find((t) => t.name === 'gnubok_list_dimension_values')!
const createDimensionValue = tools.find((t) => t.name === 'gnubok_create_dimension_value')!
const createVoucher = tools.find((t) => t.name === 'gnubok_create_voucher')!
beforeEach(() => {
vi.clearAllMocks()
})
function makeDim(overrides: Partial<DimensionRegistryEntry> = {}): DimensionRegistryEntry {
return {
id: 'dim-6',
sie_dim_no: 6,
name: 'Projekt',
resets_annually: false,
is_system: true,
is_active: true,
sort_order: 20,
values: [
{ id: 'v1', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
{ id: 'v2', code: 'P002', name: 'Kontorsflytt Solna', is_active: true, start_date: null, end_date: null },
{ id: 'v3', code: 'P099', name: 'Gammalt projekt', is_active: false, start_date: null, end_date: null },
],
...overrides,
}
}
// ── Registration + scopes ────────────────────────────────────────────────────
describe('dimension tools registration', () => {
it('all three tools exist and are mapped in TOOL_SCOPE_MAP (unmapped = any key)', () => {
expect(listDimensions).toBeDefined()
expect(listDimensionValues).toBeDefined()
expect(createDimensionValue).toBeDefined()
expect(TOOL_SCOPE_MAP.gnubok_list_dimensions).toBe('reports:read')
expect(TOOL_SCOPE_MAP.gnubok_list_dimension_values).toBe('reports:read')
expect(TOOL_SCOPE_MAP.gnubok_create_dimension_value).toBe('bookkeeping:write')
})
it('gnubok_create_dimension_value stages (staged-operation output contract)', () => {
const schema = createDimensionValue.outputSchema as {
properties?: Record<string, unknown>
required?: string[]
}
expect(schema?.properties?.staged).toBeDefined()
expect(schema?.required).toContain('staged')
expect(createDimensionValue.description).toMatch(/stag(e|ing)/i)
})
})
// ── gnubok_list_dimensions ───────────────────────────────────────────────────
describe('gnubok_list_dimensions', () => {
it('seeds system dims then returns the registry with nested values (dashboard GET shape)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure_company_dimensions rpc
enqueue({
data: [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
],
error: null,
})
enqueue({
data: [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null },
],
error: null,
})
const result = (await listDimensions.execute({}, 'company-1', 'user-1', supabase as never)) as {
dimensions: Array<{ sie_dim_no: number; values: Array<{ code: string }> }>
}
expect(result.dimensions).toHaveLength(2)
expect(result.dimensions[0].sie_dim_no).toBe(1)
expect(result.dimensions[0].values.map((v) => v.code)).toEqual(['KS01'])
expect(result.dimensions[1].values.map((v) => v.code)).toEqual(['P001'])
expect((supabase.rpc as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe('ensure_company_dimensions')
})
it('surfaces the ensure RPC failure', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'rls denied' } })
await expect(
listDimensions.execute({}, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/rls denied/)
})
})
// ── gnubok_list_dimension_values ─────────────────────────────────────────────
describe('gnubok_list_dimension_values', () => {
it('rejects an unregistered dimension number', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: null, error: null }) // dimensions lookup → not found
await expect(
listDimensionValues.execute({ sie_dim_no: 12 }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/Dimension 12 finns inte/)
})
it('lists values alphabetically without a query', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_active: true }, error: null })
enqueue({
data: [
{ id: 'v1', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null },
{ id: 'v2', code: 'P002', name: 'Kontorsflytt', is_active: true, start_date: null, end_date: null },
],
error: null,
})
const result = (await listDimensionValues.execute(
{ sie_dim_no: 6 },
'company-1',
'user-1',
supabase as never,
)) as { dimension: { sie_dim_no: number }; values: Array<{ code: string; confidence?: number }>; count: number }
expect(result.dimension.sie_dim_no).toBe(6)
expect(result.count).toBe(2)
expect(result.values[0].confidence).toBeUndefined()
})
it('ranks by fuzzy confidence when a query is given', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_active: true }, error: null })
enqueue({
data: [
{ id: 'v1', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
{ id: 'v2', code: 'P002', name: 'Kontorsflytt Solna', is_active: true, start_date: null, end_date: null },
],
error: null,
})
const result = (await listDimensionValues.execute(
{ sie_dim_no: 6, query: 'villa almgren' },
'company-1',
'user-1',
supabase as never,
)) as { values: Array<{ code: string; confidence?: number }> }
expect(result.values[0].code).toBe('P001')
expect(result.values[0].confidence).toBeGreaterThan(0.5)
})
})
// ── gnubok_create_dimension_value (staging) ──────────────────────────────────
describe('gnubok_create_dimension_value — staging', () => {
it('rejects a non-Fortnox code before any staging', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P 001"', name: 'Bad code' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Koden får bara innehålla/)
})
it('rejects when the code already exists as an active value', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
enqueue({ data: { id: 'v1', code: 'P001', name: 'Villa Almgren', is_active: true }, error: null })
await expect(
createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P001', name: 'Duplicate' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/finns redan/)
})
it('rejects an archived code with the reactivation hint', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
enqueue({ data: { id: 'v1', code: 'P099', name: 'Gammalt', is_active: false }, error: null })
await expect(
createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P099', name: 'Revive attempt' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/arkiverat — återaktivera/)
})
it('rejects value dates on a resets-annually dimension (kostnadsställe)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true }, error: null })
await expect(
createDimensionValue.execute(
{ sie_dim_no: 1, code: 'KS01', name: 'Stockholm', start_date: '2026-01-01' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Start-\/slutdatum är inte tillåtna/)
})
it('happy path: stages a create_dimension_value pending operation', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
enqueue({ data: null, error: null }) // duplicate lookup → none
enqueue({ data: { id: 'op-dim-1' }, error: null }) // pending_operations insert
const result = (await createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P010', name: 'Villa Almgren etapp 2' },
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
)) as { staged: boolean; operation_id?: string; risk_level: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-dim-1')
expect(result.risk_level).toBe('low')
expect(result.preview.code).toBe('P010')
expect(result.preview.dimension_name).toBe('Projekt')
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(true)
})
it('dry_run validates and previews without staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // ensure rpc
enqueue({ data: { id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false }, error: null })
enqueue({ data: null, error: null }) // duplicate lookup → none
const result = (await createDimensionValue.execute(
{ sie_dim_no: 6, code: 'P010', name: 'Etapp 2', dry_run: true },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; dry_run?: boolean }
expect(result.staged).toBe(false)
expect(result.dry_run).toBe(true)
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
})
})
// ── Resolve-don't-select helper ──────────────────────────────────────────────
describe('resolveValueInDimension', () => {
it('resolves an exact code match without echo (exact=true)', () => {
const r = resolveValueInDimension(makeDim(), 'P001')
expect(r).toMatchObject({ code: 'P001', exact: true, confidence: 1 })
})
it('resolves an exact name match with confidence 1 but exact=false (echoed)', () => {
const r = resolveValueInDimension(makeDim(), 'Kontorsflytt Solna')
expect(r).toMatchObject({ code: 'P002', exact: false, confidence: 1 })
})
it('resolves a unique high-confidence fuzzy hit', () => {
const r = resolveValueInDimension(makeDim(), 'villa almgren tak')
expect(r.code).toBe('P001')
expect(r.exact).toBe(false)
expect(r.confidence).toBeGreaterThan(0.7)
})
it('rejects an archived code with the Swedish reactivation message', () => {
expect(() => resolveValueInDimension(makeDim(), 'P099')).toThrow(/arkiverat — återaktivera/)
})
it('rejects an unknown value with the create-first instruction (never auto-creates)', () => {
try {
resolveValueInDimension(makeDim(), 'Bryggeriet ombyggnad')
expect.unreachable('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(DimensionResolutionError)
expect((err as Error).message).toMatch(/Okänt projekt: "Bryggeriet ombyggnad" \(dimension 6\)/)
expect((err as Error).message).toMatch(/gnubok_create_dimension_value/)
}
})
it('rejects ambiguous near-tied candidates with a ranked list', () => {
const dim = makeDim({
values: [
{ id: 'v1', code: 'P001', name: 'Villa Almgren etapp 1', is_active: true, start_date: null, end_date: null },
{ id: 'v2', code: 'P002', name: 'Villa Almgren etapp 2', is_active: true, start_date: null, end_date: null },
],
})
try {
resolveValueInDimension(dim, 'Villa Almgren etapp')
expect.unreachable('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(DimensionResolutionError)
const e = err as DimensionResolutionError
expect(e.candidates.length).toBeGreaterThanOrEqual(2)
expect(e.message).toMatch(/Kandidater/)
expect(e.message).toMatch(/gnubok_create_dimension_value/)
}
})
})
describe('resolveDimensionBags', () => {
it('costs zero queries when no line carries a bag', async () => {
const { supabase } = createQueuedMockSupabase()
const result = await resolveDimensionBags(supabase as never, 'company-1', [undefined, undefined])
expect(result.resolutions).toEqual([])
expect(supabase.from).not.toHaveBeenCalled()
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('passes bags through verbatim when dimensions_enabled is false (backward compatible)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: false }, error: null }) // company_settings
const result = await resolveDimensionBags(supabase as never, 'company-1', [{ '6': 'fritext-projekt' }])
expect(result.bags).toEqual([{ '6': 'fritext-projekt' }])
expect(result.resolutions).toEqual([])
expect(supabase.rpc).not.toHaveBeenCalled() // no ensure, no registry reads
})
it('resolves names to codes and echoes non-exact resolutions when enabled', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: true }, error: null }) // company_settings
enqueue({ data: null, error: null }) // ensure rpc
enqueue({
data: [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
],
error: null,
})
enqueue({
data: [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
],
error: null,
})
const result = await resolveDimensionBags(supabase as never, 'company-1', [
{ '1': 'KS01', '6': 'villa almgren tak' },
{ '6': 'villa almgren tak' },
])
expect(result.bags).toEqual([
{ '1': 'KS01', '6': 'P001' },
{ '6': 'P001' },
])
// Exact code KS01 is not echoed; the fuzzy name is echoed ONCE (cached).
expect(result.resolutions).toHaveLength(1)
expect(result.resolutions[0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
resolved_name: 'Villa Almgren takrenovering',
})
expect(result.resolutions[0].confidence).toBeGreaterThan(0.7)
})
it('rejects a dim number with no registry row when enabled', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null }) // ensure rpc
enqueue({
data: [{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 }],
error: null,
})
enqueue({ data: [], error: null })
await expect(
resolveDimensionBags(supabase as never, 'company-1', [{ '12': 'X' }]),
).rejects.toThrow(/Okänd dimension 12/)
})
})
describe('mergeLineDimensions / parseDimensionsArg', () => {
it('line bag wins per key over defaults and legacy aliases fill unset keys', () => {
const merged = mergeLineDimensions(
{ dimensions: { '6': 'P002' }, cost_center: 'KS01' },
{ '6': 'P001', '1': 'KS99' },
)
// Line's own bag beats the default for dim 6; the line's cost_center alias
// beats the default for dim 1 (per-line explicit > voucher default).
expect(merged).toEqual({ '1': 'KS01', '6': 'P002' })
})
it('returns undefined when nothing is tagged', () => {
expect(mergeLineDimensions({}, undefined)).toBeUndefined()
})
it('parseDimensionsArg throws loudly on an invalid bag shape', () => {
expect(() => parseDimensionsArg({ '0': 'X' }, 'default_dimensions')).toThrow(/Invalid default_dimensions/)
expect(() => parseDimensionsArg({ '6': 'har"citat' }, 'lines[0].dimensions')).toThrow(/Invalid lines\[0\]\.dimensions/)
})
})
// ── Dims bag flowing through gnubok_create_voucher ───────────────────────────
describe('gnubok_create_voucher — dimensions bag', () => {
it('resolves and stages per-line dims + default_dimensions onto the staged lines', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// resolveDimensionBags: settings → ensure rpc → dimensions → dimension_values
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null })
enqueue({
data: [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
],
error: null,
})
enqueue({
data: [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
],
error: null,
})
// fiscal period (explicit id path)
enqueue({
data: { id: 'fp-1', is_closed: false, period_start: '2026-01-01', period_end: '2026-12-31', name: '2026' },
error: null,
})
// chart_of_accounts
enqueue({
data: [
{ account_number: '4010', account_name: 'Inköp material', is_active: true },
{ account_number: '1930', account_name: 'Företagskonto', is_active: true },
],
error: null,
})
// resolvePeriodStatusForDate: 2 layers
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
// pending_operations insert
enqueue({ data: { id: 'op-dims' }, error: null })
const result = (await createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'Material Villa Almgren',
fiscal_period_id: 'fp-1',
default_dimensions: { '6': 'villa almgren tak' },
lines: [
{ account_number: '4010', debit_amount: 250, credit_amount: 0, dimensions: { '1': 'KS01' } },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: {
lines: Array<{ dimensions: Record<string, string> | null }>
dimension_resolutions?: Array<{ dimension: number; input: string; resolved_code: string; resolved_name: string; confidence: number }>
}
}
expect(result.staged).toBe(true)
// Expense line: own dim 1 + the voucher default dim 6, resolved to codes.
expect(result.preview.lines[0].dimensions).toEqual({ '1': 'KS01', '6': 'P001' })
// Bank line inherits the voucher default too (it lacks the key itself).
expect(result.preview.lines[1].dimensions).toEqual({ '6': 'P001' })
// Non-exact resolution is echoed with the full contract shape.
expect(result.preview.dimension_resolutions).toHaveLength(1)
expect(result.preview.dimension_resolutions![0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
resolved_name: 'Villa Almgren takrenovering',
})
})
it('rejects before staging when a dims value has no registry match (no auto-create)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: true }, error: null })
enqueue({ data: null, error: null }) // ensure rpc
enqueue({
data: [{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 }],
error: null,
})
enqueue({
data: [{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null }],
error: null,
})
await expect(
createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'Unknown project',
fiscal_period_id: 'fp-1',
lines: [
{ account_number: '4010', debit_amount: 100, credit_amount: 0, dimensions: { '6': 'Bryggeriet ombyggnad' } },
{ account_number: '1930', debit_amount: 0, credit_amount: 100 },
],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/Okänt projekt[\s\S]*gnubok_create_dimension_value/)
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
})
it('keeps the untagged path query-free (legacy tests unchanged): no settings read without dims', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: { id: 'fp-1', is_closed: false, period_start: '2026-01-01', period_end: '2026-12-31', name: '2026' },
error: null,
})
enqueue({
data: [
{ account_number: '1010', account_name: 'Balanserade utgifter', is_active: true },
{ account_number: '1930', account_name: 'Företagskonto', is_active: true },
],
error: null,
})
enqueue({ data: null, error: null }) // period status layer 1
enqueue({ data: null, error: null }) // period status layer 2
enqueue({ data: { id: 'op-plain' }, error: null }) // pending_operations insert
const result = (await createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'Ingen dimension',
fiscal_period_id: 'fp-1',
lines: [
{ account_number: '1010', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: { dimension_resolutions?: unknown } }
expect(result.staged).toBe(true)
expect(result.preview.dimension_resolutions).toBeUndefined()
expect(supabase.rpc).not.toHaveBeenCalled()
})
})