Files
accounted/app/api/dimensions/__tests__/create-dimension.test.ts
T
Jakob Wennberg 764348e99c feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement (#886)
* feat(dimensions): PR10 advanced — custom dimensions, hierarchy, account rules, commit enforcement

The final rung of the dimensions ladder
(dev_docs/dimensions_implementation_plan.md §7 row 10):

- custom dimensions: POST /api/dimensions creates registry dims (next free
  SIE number >= 20 when omitted; explicit numbers allowed — SIE import
  already mints reserved ones); register gets a 'Ny dimension' dialog with
  a quiet Avancerat disclosure for the #UNDERDIM parent; GET now carries
  parent_sie_dim_no (the column + SIE round-trip existed since PR1/PR5 —
  this exposes it)
- account_dimension_rules (migration 20260703120000): one rule per
  (account, dimension) — required / default / fixed, per-rule is_active,
  company-scoped RLS, composite FK to the registry, value-presence CHECK
- enforcement, opt-in BY CONSTRUCTION (zero rules = engine byte-identical;
  deliberately NO settings toggle — a rule that exists but is ignored is
  worse than either extreme): default/fixed apply onto line bags at draft
  creation (fixed overwrites, default fills); required asserts at
  commitEntry with a Swedish MANDATORY_DIMENSION_MISSING naming every
  account + dimension; the bulk-book route runs the same policy before its
  RPC; storno/correction paths never pass through commitEntry so history
  always reverses regardless of policy; rule fetches fail open incl.
  thrown exceptions
- chart of accounts: per-account Dimensionsregler section in
  EditAccountDialog (Krävs/Förval/Låst, value picker, pause switch),
  gated on the existing dimensions toggle, quiet when empty
- pickers: LineDimensionFields is registry-driven (one combobox per active
  dimension, cached fetch, hardcoded 1/6 fallback) — every existing mount
  lights up custom dims with zero changes
- agent briefing: per-dimension required_on_accounts/default_on_accounts
  so agents self-correct instead of bouncing off the policy error
- rules CRUD API with existence/active/company validation and qualified
  DTO ids; firm_id FK deferred until the firms table lands (per plan)

39 new tests (pure-fn rules, engine enforcement, both new API surfaces,
pg-real RLS/CHECK/cascade suite); full suite 6,791 green; migration
replayed on a fresh container.

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

* fix: renumber migration to 20260703200000 — version collision with prod

The concurrent session shipped pending_operations_add_link_document_to_voucher
as 20260703120000 today; the Supabase preview branch (cloned from prod)
rejected the duplicate version key.

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

* fix: review round — auto-pick retry on collision, fail-open warnings, query schema

- POST /api/dimensions retries once past a concurrent number claim when the
  number was auto-picked (explicit choices still 409)
- every fail-open skip of the dimension-rules policy now logs a structured
  warning (engine draft/commit paths + bulk-book) — deliberate fail-open,
  but observable
- GET /api/dimensions/rules validates its query through
  ListDimensionRulesQuerySchema instead of an inline regex

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:50:28 +02:00

167 lines
5.4 KiB
TypeScript

/**
* Tests for POST /api/dimensions (create a custom dimension — PR10).
*
* Covers: auto-picking the next free SIE number >= 20 (proved both via the
* insert result and via the self-parent guard, which names the picked
* number), 409 on an explicitly taken number, 400 on an invalid parent, and
* the 201 { data: { dimension } } contract for an explicit number.
*
* Queue order per request: ensure_company_dimensions RPC → existing-numbers
* select → insert returning the row.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
import { POST } from '../route'
const noParams = { params: Promise.resolve({}) }
const postRequest = (body: Record<string, unknown>) =>
createMockRequest('/api/dimensions', { method: 'POST', body })
interface DimensionRow {
id: string
sie_dim_no: number
name: string
parent_sie_dim_no: number | null
resets_annually: boolean
is_system: boolean
is_active: boolean
sort_order: number
}
type DimensionBody = { data: { dimension: DimensionRow } }
type ErrorBody = { error: { code: string; message: string } }
function makeDimensionRow(overrides: Partial<DimensionRow> = {}): DimensionRow {
return {
id: 'dim-new',
sie_dim_no: 21,
name: 'Avdelning',
parent_sie_dim_no: null,
resets_annually: true,
is_system: false,
is_active: true,
sort_order: 100,
...overrides,
}
}
/** Enqueue the ensure RPC + the existing-numbers select. */
function enqueuePreamble(existingNumbers: number[]) {
enqueue({ data: null }) // ensure_company_dimensions
enqueue({ data: existingNumbers.map((n) => ({ sie_dim_no: n })) })
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
})
describe('POST /api/dimensions', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(postRequest({ name: 'Avdelning' }), noParams)
expect(response.status).toBe(401)
})
it('picks the next free number >= 20 when sie_dim_no is omitted ([1,6,20] → 21)', async () => {
enqueuePreamble([1, 6, 20])
enqueue({ data: makeDimensionRow({ sie_dim_no: 21 }) })
const response = await POST(postRequest({ name: 'Avdelning' }), noParams)
const { status, body } = await parseJsonResponse<DimensionBody>(response)
expect(status).toBe(201)
expect(body.data.dimension.sie_dim_no).toBe(21)
expect(body.data.dimension.is_system).toBe(false)
})
it('auto-picks exactly 21 — pinned via the self-parent guard message', async () => {
// The queued mock cannot capture insert payloads, so pin the computed
// number through an observable branch: parent 21 collides with the pick
// ONLY if the route picked 21 (any other pick yields the "finns inte"
// message instead, since 21 is not a registered number).
enqueuePreamble([1, 6, 20])
const response = await POST(
postRequest({ name: 'Avdelning', parent_sie_dim_no: 21 }),
noParams,
)
const { status, body } = await parseJsonResponse<ErrorBody>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('DIMENSION_PARENT_INVALID')
expect(body.error.message).toBe(
'En dimension kan inte vara sin egen överordnade dimension.',
)
})
it('returns 409 DIMENSION_NUMBER_TAKEN for an explicitly taken number', async () => {
enqueuePreamble([1, 6])
const response = await POST(postRequest({ name: 'Projekt igen', sie_dim_no: 6 }), noParams)
const { status, body } = await parseJsonResponse<ErrorBody>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('DIMENSION_NUMBER_TAKEN')
})
it('returns 400 DIMENSION_PARENT_INVALID for an unknown parent', async () => {
enqueuePreamble([1, 6])
const response = await POST(
postRequest({ name: 'Avdelning', sie_dim_no: 30, parent_sie_dim_no: 99 }),
noParams,
)
const { status, body } = await parseJsonResponse<ErrorBody>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('DIMENSION_PARENT_INVALID')
expect(body.error.message).toContain('99')
})
it('creates an explicit-number dimension with the 201 { data: { dimension } } shape', async () => {
enqueuePreamble([1, 6])
const row = makeDimensionRow({
sie_dim_no: 30,
name: 'Maskin',
parent_sie_dim_no: 6,
resets_annually: false,
})
enqueue({ data: row })
const response = await POST(
postRequest({ name: 'Maskin', sie_dim_no: 30, parent_sie_dim_no: 6, resets_annually: false }),
noParams,
)
const { status, body } = await parseJsonResponse<DimensionBody>(response)
expect(status).toBe(201)
expect(body.data.dimension).toEqual(row)
})
})