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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
70e893b8d4
commit
764348e99c
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Tests for /api/dimensions/rules (list + create) and
|
||||
* /api/dimensions/rules/[id] (update + delete) — dimensions PR10.
|
||||
*
|
||||
* Exercises the routes through the real withRouteContext wrapper, mocking
|
||||
* only its auth/company dependencies and injecting a queued Supabase mock via
|
||||
* requireAuth. Covers: 401, the DTO mapping contract, the account filter
|
||||
* validation, the schema's value-presence superRefine, referential 404s, the
|
||||
* 23505 → 409 duplicate mapping, PATCH's effective-type validation, and
|
||||
* DELETE's count-based 404.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
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 { GET, POST } from '../rules/route'
|
||||
import { PATCH, DELETE } from '../rules/[id]/route'
|
||||
|
||||
const DIM_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const VALUE_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const RULE_ID = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
const noParams = { params: Promise.resolve({}) }
|
||||
const idParams = createMockRouteParams({ id: RULE_ID })
|
||||
|
||||
interface RuleDto {
|
||||
account_dimension_rule_id: string
|
||||
account_number: string
|
||||
dimension_id: string
|
||||
sie_dim_no: number
|
||||
dimension_name: string
|
||||
rule_type: string
|
||||
value_id: string | null
|
||||
value_code: string | null
|
||||
value_name: string | null
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
type RuleBody = { data: { rule: RuleDto } }
|
||||
type RulesBody = { data: { rules: RuleDto[] } }
|
||||
type ErrorBody = { error: { code: string; message: string } }
|
||||
|
||||
/** Raw row exactly as RULE_SELECT returns it (joined registry aliases). */
|
||||
function makeRawRule(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: RULE_ID,
|
||||
account_number: '4010',
|
||||
rule_type: 'default',
|
||||
value_id: VALUE_ID,
|
||||
is_active: true,
|
||||
dimension: { id: DIM_ID, sie_dim_no: 6, name: 'Projekt' },
|
||||
value: { code: 'P001', name: 'Projekt Alpha' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
})
|
||||
|
||||
describe('GET /api/dimensions/rules', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await GET(createMockRequest('/api/dimensions/rules'), noParams)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('maps rows through the DTO (account_dimension_rule_id + value fields)', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeRawRule(),
|
||||
makeRawRule({
|
||||
id: '44444444-4444-4444-8444-444444444444',
|
||||
account_number: '5010',
|
||||
rule_type: 'required',
|
||||
value_id: null,
|
||||
value: null,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const response = await GET(createMockRequest('/api/dimensions/rules'), noParams)
|
||||
const { status, body } = await parseJsonResponse<RulesBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.rules).toHaveLength(2)
|
||||
expect(body.data.rules[0]).toEqual({
|
||||
account_dimension_rule_id: RULE_ID,
|
||||
account_number: '4010',
|
||||
dimension_id: DIM_ID,
|
||||
sie_dim_no: 6,
|
||||
dimension_name: 'Projekt',
|
||||
rule_type: 'default',
|
||||
value_id: VALUE_ID,
|
||||
value_code: 'P001',
|
||||
value_name: 'Projekt Alpha',
|
||||
is_active: true,
|
||||
})
|
||||
// A required rule has no value — the DTO carries explicit nulls.
|
||||
expect(body.data.rules[1]).toMatchObject({
|
||||
rule_type: 'required',
|
||||
value_id: null,
|
||||
value_code: null,
|
||||
value_name: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a malformed account_number filter with 400', async () => {
|
||||
const response = await GET(
|
||||
createMockRequest('/api/dimensions/rules', { searchParams: { account_number: '40' } }),
|
||||
noParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ type: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
// validateQuery's canonical envelope (review fix: inline regex → schema).
|
||||
expect(body.type).toBe('validation_error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/dimensions/rules', () => {
|
||||
const postRequest = (body: Record<string, unknown>) =>
|
||||
createMockRequest('/api/dimensions/rules', { method: 'POST', body })
|
||||
|
||||
const validDefaultBody = {
|
||||
account_number: '4010',
|
||||
dimension_id: DIM_ID,
|
||||
rule_type: 'default',
|
||||
value_id: VALUE_ID,
|
||||
}
|
||||
|
||||
it('creates a default rule (dimension → value → account → insert) with 201', async () => {
|
||||
enqueue({ data: { id: DIM_ID, is_active: true } }) // dimension lookup
|
||||
enqueue({ data: { id: VALUE_ID, is_active: true } }) // value lookup
|
||||
enqueue({ data: { account_number: '4010' } }) // chart lookup
|
||||
enqueue({ data: makeRawRule() }) // insert returning RULE_SELECT
|
||||
|
||||
const response = await POST(postRequest(validDefaultBody), noParams)
|
||||
const { status, body } = await parseJsonResponse<RuleBody>(response)
|
||||
|
||||
expect(status).toBe(201)
|
||||
expect(body.data.rule.account_dimension_rule_id).toBe(RULE_ID)
|
||||
expect(body.data.rule.rule_type).toBe('default')
|
||||
expect(body.data.rule.value_code).toBe('P001')
|
||||
})
|
||||
|
||||
it('rejects a required rule that carries a value (schema superRefine)', async () => {
|
||||
const response = await POST(
|
||||
postRequest({
|
||||
account_number: '4010',
|
||||
dimension_id: DIM_ID,
|
||||
rule_type: 'required',
|
||||
value_id: VALUE_ID,
|
||||
}),
|
||||
noParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a default rule without a value (schema superRefine)', async () => {
|
||||
const response = await POST(
|
||||
postRequest({ account_number: '4010', dimension_id: DIM_ID, rule_type: 'default' }),
|
||||
noParams,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 for a dimension the company does not have', async () => {
|
||||
enqueue({ data: null }) // dimension lookup misses
|
||||
|
||||
const response = await POST(postRequest(validDefaultBody), noParams)
|
||||
const { status, body } = await parseJsonResponse<ErrorBody>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('DIMENSION_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('maps the UNIQUE violation (23505) to 409 DIMENSION_RULE_EXISTS', async () => {
|
||||
enqueue({ data: { id: DIM_ID, is_active: true } })
|
||||
enqueue({ data: { id: VALUE_ID, is_active: true } })
|
||||
enqueue({ data: { account_number: '4010' } })
|
||||
enqueue({
|
||||
error: { code: '23505', message: 'duplicate key value violates unique constraint' },
|
||||
})
|
||||
|
||||
const response = await POST(postRequest(validDefaultBody), noParams)
|
||||
const { status, body } = await parseJsonResponse<ErrorBody>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('DIMENSION_RULE_EXISTS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/dimensions/rules/[id]', () => {
|
||||
const patchRequest = (body: Record<string, unknown>) =>
|
||||
createMockRequest(`/api/dimensions/rules/${RULE_ID}`, { method: 'PATCH', body })
|
||||
|
||||
it('pauses a rule via is_active without touching the value', async () => {
|
||||
enqueue({
|
||||
data: { id: RULE_ID, rule_type: 'default', value_id: VALUE_ID, dimension_id: DIM_ID },
|
||||
}) // existing lookup
|
||||
enqueue({ data: makeRawRule({ is_active: false }) }) // update returning RULE_SELECT
|
||||
|
||||
const response = await PATCH(patchRequest({ is_active: false }), idParams)
|
||||
const { status, body } = await parseJsonResponse<RuleBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.rule.is_active).toBe(false)
|
||||
expect(body.data.rule.account_dimension_rule_id).toBe(RULE_ID)
|
||||
})
|
||||
|
||||
it('rejects switching to required while the stored value remains (effective type)', async () => {
|
||||
enqueue({
|
||||
data: { id: RULE_ID, rule_type: 'default', value_id: VALUE_ID, dimension_id: DIM_ID },
|
||||
})
|
||||
|
||||
const response = await PATCH(patchRequest({ rule_type: 'required' }), idParams)
|
||||
const { status, body } = await parseJsonResponse<ErrorBody>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('VALIDATION_FAILED')
|
||||
})
|
||||
|
||||
it('returns 404 for a rule outside the company', async () => {
|
||||
enqueue({ data: null })
|
||||
|
||||
const response = await PATCH(patchRequest({ is_active: false }), idParams)
|
||||
const { status, body } = await parseJsonResponse<ErrorBody>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('DIMENSION_RULE_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/dimensions/rules/[id]', () => {
|
||||
it('deletes the rule and confirms', async () => {
|
||||
enqueue({ count: 1 })
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest(`/api/dimensions/rules/${RULE_ID}`, { method: 'DELETE' }),
|
||||
idParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { deleted: boolean } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.deleted).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 404 when nothing was deleted (count 0)', async () => {
|
||||
enqueue({ count: 0 })
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest(`/api/dimensions/rules/${RULE_ID}`, { method: 'DELETE' }),
|
||||
idParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<ErrorBody>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('DIMENSION_RULE_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user