5ca64bde30
* feat(bokslut): IL 18 kap pooled tax depreciation with method election Rakenskapsenlig (huvudregel 30 / kompletteringsregel 20) and restvarde 25 as a company-level annual pool separate from per-asset book depreciation. Method election persisted with immutable snapshots and book-conformity confirmation for rakenskapsenlig. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(db): move tax depreciation migrations to coordinated versions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): keep tax depreciation view loadable when a saved election goes stale A predecessor's changed closing value can push a saved elected deduction above the new statutory maximum; the view now falls back to the statutory recomputation so the snapshot is flagged stale instead of crashing. Ratchet naive-ore-round baseline down by 3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): resolve tax-depreciation period selects statically The no-phantom-columns guard counts every select it cannot resolve toward a hard ceiling, and the PERIOD_COLUMNS join pushed the repo 4 over (364 > 360). Inline the literal column list at the four call sites so the guard verifies these columns instead of skipping them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): address review findings on tax depreciation election - DepreciationPanel: gate the saving flag on a dedicated save sequence so a successful save (which refreshes the view and bumps the request version) no longer leaves the card permanently busy - computeTaxDepreciation: refuse kompletteringsregel_20 with a positive basis and no acquisition cohorts instead of degenerating to a full write-off the cohort evidence does not support (IL 18 kap. 17 §) - migration 227000: judge the asset-method guards on NEW.disposed_at so reversing a disposal cannot reactivate a grandfathered non-linear row - migration 227200: require snapshot column completeness in the CHECK; SQL NULL semantics let partially populated snapshots pass the pure arithmetic comparisons - depreciation route: use the string issue code 'custom' like the rest of the codebase instead of the Zod 3 compat enum Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
214 lines
7.2 KiB
TypeScript
214 lines
7.2 KiB
TypeScript
/**
|
|
* Tests for GET/PATCH /api/assets/[id].
|
|
*
|
|
* Exercises the routes through the real withRouteContext wrapper, mocking the
|
|
* asset service and auth/company dependencies. The K3 component cross-sum
|
|
* validation runs the REAL validateComponents so the regression case (body
|
|
* changes acquisition_cost and k3_components together — sum must match the
|
|
* NEW cost) is covered end to end.
|
|
*/
|
|
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'),
|
|
}))
|
|
|
|
const requireWriteMock = vi.fn()
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
|
|
createAsset: vi.fn(),
|
|
listAssets: vi.fn(),
|
|
getAsset: vi.fn(),
|
|
updateAsset: vi.fn(),
|
|
}))
|
|
|
|
import { createAsset, getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
|
|
import { GET, PATCH } from '../[id]/route'
|
|
import { POST } from '../route'
|
|
|
|
const mockGetAsset = vi.mocked(getAsset)
|
|
const mockUpdateAsset = vi.mocked(updateAsset)
|
|
const mockCreateAsset = vi.mocked(createAsset)
|
|
const routeParams = { params: Promise.resolve({ id: 'asset-1' }) }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
|
requireWriteMock.mockResolvedValue({ ok: true })
|
|
})
|
|
|
|
describe('GET /api/assets/[id]', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
|
|
const res = await GET(createMockRequest('/api/assets/asset-1'), routeParams)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns 404 when the asset does not exist', async () => {
|
|
mockGetAsset.mockResolvedValue(null)
|
|
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
|
await GET(createMockRequest('/api/assets/asset-1'), routeParams)
|
|
)
|
|
|
|
expect(status).toBe(404)
|
|
expect(body.error.code).toBe('ASSET_NOT_FOUND')
|
|
})
|
|
})
|
|
|
|
describe('POST /api/assets', () => {
|
|
it('rejects legacy per-asset tax depreciation methods with 400', async () => {
|
|
const response = await POST(createMockRequest('/api/assets', {
|
|
method: 'POST',
|
|
body: {
|
|
name: 'Maskin',
|
|
category: 'machinery',
|
|
acquisition_date: '2025-01-01',
|
|
acquisition_cost: 100_000,
|
|
useful_life_months: 60,
|
|
depreciation_method: 'declining_balance_30',
|
|
},
|
|
}))
|
|
|
|
expect(response.status).toBe(400)
|
|
})
|
|
|
|
it('creates a valid asset with ordinary linear depreciation', async () => {
|
|
mockCreateAsset.mockResolvedValue({ id: 'asset-new', depreciation_method: 'linear' } as never)
|
|
const response = await POST(createMockRequest('/api/assets', {
|
|
method: 'POST',
|
|
body: {
|
|
name: 'Maskin',
|
|
category: 'machinery',
|
|
acquisition_date: '2025-01-01',
|
|
acquisition_cost: 100_000,
|
|
useful_life_months: 60,
|
|
depreciation_method: 'linear',
|
|
},
|
|
}))
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(mockCreateAsset).toHaveBeenCalledWith(
|
|
supabase,
|
|
'company-1',
|
|
'user-1',
|
|
expect.objectContaining({ depreciation_method: 'linear' }),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('PATCH /api/assets/[id]', () => {
|
|
it('rejects legacy per-asset tax depreciation methods with 400', async () => {
|
|
const req = createMockRequest('/api/assets/asset-1', {
|
|
method: 'PATCH',
|
|
body: { depreciation_method: 'declining_balance_30' },
|
|
})
|
|
|
|
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
|
|
|
|
expect(status).toBe(400)
|
|
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects an invalid body (non-positive acquisition_cost) with 400', async () => {
|
|
const req = createMockRequest('/api/assets/asset-1', {
|
|
method: 'PATCH',
|
|
body: { acquisition_cost: -5 },
|
|
})
|
|
|
|
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
|
|
|
|
expect(status).toBe(400)
|
|
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects k3_components for a K2 company with 422', async () => {
|
|
enqueue({ data: { accounting_framework: 'k2' } })
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any)
|
|
|
|
const req = createMockRequest('/api/assets/asset-1', {
|
|
method: 'PATCH',
|
|
body: {
|
|
k3_components: [{ name: 'Stomme', cost: 100000, useful_life_months: 600 }],
|
|
},
|
|
})
|
|
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
|
await PATCH(req, routeParams)
|
|
)
|
|
|
|
expect(status).toBe(422)
|
|
expect(body.error.code).toBe('K3_REQUIRED_FOR_COMPONENTS')
|
|
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('validates the component sum against the NEW acquisition_cost when both change', async () => {
|
|
// Regression: stored cost is 100 000 but the PATCH raises it to 120 000.
|
|
// Components summing to 120 000 must pass — previously they were checked
|
|
// against the stale stored cost and wrongly rejected.
|
|
enqueue({ data: { accounting_framework: 'k3' } })
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mockUpdateAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 120000 } as any)
|
|
|
|
const req = createMockRequest('/api/assets/asset-1', {
|
|
method: 'PATCH',
|
|
body: {
|
|
acquisition_cost: 120000,
|
|
k3_components: [
|
|
{ name: 'Stomme', cost: 90000, useful_life_months: 600 },
|
|
{ name: 'Tak', cost: 30000, useful_life_months: 240 },
|
|
],
|
|
},
|
|
})
|
|
|
|
const { status } = await parseJsonResponse(await PATCH(req, routeParams))
|
|
|
|
expect(status).toBe(200)
|
|
expect(mockUpdateAsset).toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects components that sum to the OLD cost when the PATCH changes the cost', async () => {
|
|
enqueue({ data: { accounting_framework: 'k3' } })
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
mockGetAsset.mockResolvedValue({ id: 'asset-1', acquisition_cost: 100000 } as any)
|
|
|
|
const req = createMockRequest('/api/assets/asset-1', {
|
|
method: 'PATCH',
|
|
body: {
|
|
acquisition_cost: 120000,
|
|
k3_components: [{ name: 'Stomme', cost: 100000, useful_life_months: 600 }],
|
|
},
|
|
})
|
|
|
|
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
|
await PATCH(req, routeParams)
|
|
)
|
|
|
|
expect(status).toBe(400)
|
|
expect(body.error.code).toBe('INVALID_K3_COMPONENTS')
|
|
expect(mockUpdateAsset).not.toHaveBeenCalled()
|
|
})
|
|
})
|