Files
accounted/app/api/articles/__tests__/id.test.ts
T
Mattsson 072aedeaf9 Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow

* fix: persist and display customer personal numbers

* feat: configure automatic invoice reminder days

* fix: issue credit notes through send flow

* chore: add repository agent guidance

* feat(mcp): route tools across user companies

* fix(articles): delete unused register entries

* feat(invoices): improve issued invoice actions

* feat(supplier-invoices): retain uploaded source documents

* docs: record implementation decisions

* feat: enhance customer personal number handling and validation

- Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers.
- Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema.
- Implemented masking and encryption for personal numbers to enhance data protection.
- Introduced new utility functions for masking and encrypting personal numbers.
- Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries.
- Enhanced error handling and logging for credit note issuance and invoice processing.
- Updated tests to cover new credit note creation guards and personal number handling.

* test: enhance list companies test with supabase query mocks
2026-07-15 15:53:15 +02:00

168 lines
6.0 KiB
TypeScript

/**
* Tests for GET/PATCH/DELETE /api/articles/[id] (artikelregister).
*
* DELETE permanently removes only articles that have never been used on an
* invoice line. PATCH is a sparse update.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
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/init', () => ({ ensureInitialized: vi.fn() }))
import { GET, PATCH, DELETE } from '../[id]/route'
describe('GET/PATCH/DELETE /api/articles/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('GET returns 404 when the article is not found', async () => {
enqueue({ data: null, error: { code: 'PGRST116', message: 'not found' } })
const response = await GET(createMockRequest('/api/articles/a1'), createMockRouteParams({ id: 'a1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('ARTICLE_NOT_FOUND')
})
it('PATCH updates a field and returns the row', async () => {
enqueue({ data: { id: 'a1', name: 'Konsulttimme', price_excl_vat: 1500 } })
const request = createMockRequest('/api/articles/a1', {
method: 'PATCH',
body: { price_excl_vat: 1500 },
})
const response = await PATCH(request, createMockRouteParams({ id: 'a1' }))
const { status, body } = await parseJsonResponse<{ data: { price_excl_vat: number } }>(response)
expect(status).toBe(200)
expect(body.data.price_excl_vat).toBe(1500)
})
it('PATCH answers ACCOUNTS_NOT_IN_CHART for a BAS class-3 account missing from the chart', async () => {
// chart_of_accounts lookup: no row, but 3999 is a known BAS class-3
// account → activatable via the activate-and-retry dialog flow.
enqueue({ data: null })
const request = createMockRequest('/api/articles/a1', {
method: 'PATCH',
body: { revenue_account: '3999' },
})
const response = await PATCH(request, createMockRouteParams({ id: 'a1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; account_numbers: string[] }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(body.error.account_numbers).toEqual(['3999'])
})
it('PATCH rejects a 3xxx revenue_account unknown to both chart and BAS catalogue', async () => {
// No chart row and 3041 is not in the BAS reference → invalid, no dialog.
enqueue({ data: null })
const request = createMockRequest('/api/articles/a1', {
method: 'PATCH',
body: { revenue_account: '3041' },
})
const response = await PATCH(request, createMockRouteParams({ id: 'a1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ARTICLE_REVENUE_ACCOUNT_INVALID')
})
it('DELETE returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await DELETE(
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
createMockRouteParams({ id: 'a1' }),
)
expect(response.status).toBe(401)
})
it('DELETE returns 404 when the article is not found', async () => {
enqueue({ data: null, error: { code: 'PGRST116', message: 'not found' } })
const response = await DELETE(
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
createMockRouteParams({ id: 'a1' }),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('ARTICLE_NOT_FOUND')
})
it('DELETE rejects an article used on an invoice line', async () => {
enqueue({ data: { id: 'a1' }, error: null })
enqueue({ data: null, error: null, count: 1 })
const response = await DELETE(
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
createMockRouteParams({ id: 'a1' }),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('ARTICLE_IN_USE')
expect(supabase.from).toHaveBeenCalledTimes(2)
})
it('DELETE permanently removes an unused article', async () => {
enqueue({ data: { id: 'a1' }, error: null })
enqueue({ data: null, error: null, count: 0 })
enqueue({ data: null, error: null, count: 1 })
const emitSpy = vi.spyOn(eventBus, 'emit')
const response = await DELETE(
createMockRequest('/api/articles/a1', { method: 'DELETE' }),
createMockRouteParams({ id: 'a1' }),
)
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(supabase.from).toHaveBeenNthCalledWith(1, 'articles')
expect(supabase.from).toHaveBeenNthCalledWith(2, 'invoice_items')
expect(supabase.from).toHaveBeenNthCalledWith(3, 'articles')
expect(emitSpy).toHaveBeenCalledWith({
type: 'article.deleted',
payload: { articleId: 'a1', companyId: 'company-1', userId: 'user-1' },
})
})
})