072aedeaf9
* 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
168 lines
6.0 KiB
TypeScript
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' },
|
|
})
|
|
})
|
|
})
|