Files
accounted/app/api/customers/__tests__/personal-number.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

229 lines
6.7 KiB
TypeScript

import { NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventBus } from '@/lib/events'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
const captured: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] }
let queryResult: { data: unknown; error: unknown } = { data: null, error: null }
const buildChain = (): unknown =>
new Proxy(
{},
{
get(_target, prop) {
if (prop === 'then') {
return (resolve: (value: unknown) => void) => resolve(queryResult)
}
return (...args: unknown[]) => {
if (prop === 'insert') captured.insert.push(args[0])
if (prop === 'update') captured.update.push(args[0])
return buildChain()
}
},
},
)
const supabase = {
from: vi.fn(() => buildChain()),
rpc: vi.fn(() => buildChain()),
}
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 { POST } from '../route'
import { PATCH } from '../[id]/route'
type CustomerWrite = { personal_number?: string | null }
describe('personal_number on customer routes', () => {
const routeParams = { params: Promise.resolve({ id: 'customer-1' }) }
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
captured.insert.length = 0
captured.update.length = 0
queryResult = { data: null, error: null }
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 before creating a customer when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(
createMockRequest('/api/customers', {
method: 'POST',
body: { name: 'Anna Andersson', customer_type: 'individual' },
}),
{ params: Promise.resolve({}) },
)
expect(response.status).toBe(401)
expect(captured.insert).toHaveLength(0)
})
it('returns 400 for an invalid personal number', async () => {
const response = await POST(
createMockRequest('/api/customers', {
method: 'POST',
body: {
name: 'Anna Andersson',
customer_type: 'individual',
personal_number: 'not-a-personal-number',
},
}),
{ params: Promise.resolve({}) },
)
expect(response.status).toBe(400)
expect(captured.insert).toHaveLength(0)
})
it('stores the personal number when creating a private customer', async () => {
queryResult = {
data: {
id: 'customer-1',
name: 'Anna Andersson',
customer_type: 'individual',
personal_number: '19900101-1234',
},
error: null,
}
const response = await POST(
createMockRequest('/api/customers', {
method: 'POST',
body: {
name: 'Anna Andersson',
customer_type: 'individual',
personal_number: '19900101-1234',
},
}),
{ params: Promise.resolve({}) },
)
const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response)
expect(status).toBe(200)
const encrypted = (captured.insert[0] as CustomerWrite).personal_number as string
expect(encrypted).not.toBe('19900101-1234')
expect(decryptPersonnummer(encrypted)).toBe('19900101-1234')
expect(body.data.personal_number).toBe('********-1234')
})
it('updates the personal number for an existing private customer', async () => {
queryResult = {
data: {
id: 'customer-1',
customer_type: 'individual',
personal_number: '900101-1234',
},
error: null,
}
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { personal_number: '900101-1234' },
}),
routeParams,
)
expect(response.status).toBe(200)
const encrypted = (captured.update[0] as CustomerWrite).personal_number as string
expect(encrypted).not.toBe('900101-1234')
expect(decryptPersonnummer(encrypted)).toBe('900101-1234')
})
it('clears the personal number when null is sent', async () => {
queryResult = {
data: { id: 'customer-1', customer_type: 'individual', personal_number: null },
error: null,
}
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { personal_number: null },
}),
routeParams,
)
expect(response.status).toBe(200)
expect((captured.update[0] as CustomerWrite).personal_number).toBeNull()
})
it('does not change the personal number when the field is omitted', async () => {
queryResult = {
data: { id: 'customer-1', customer_type: 'individual', name: 'Anna A' },
error: null,
}
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { name: 'Anna A' },
}),
routeParams,
)
expect(response.status).toBe(200)
expect(captured.update[0]).not.toHaveProperty('personal_number')
})
it('rejects a personal number for a corporate customer', async () => {
queryResult = {
data: { id: 'customer-1', customer_type: 'swedish_business' },
error: null,
}
const response = await PATCH(
createMockRequest('/api/customers/customer-1', {
method: 'PATCH',
body: { personal_number: '900101-1234' },
}),
routeParams,
)
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(response.status).toBe(400)
expect(body.error.code).toBe('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED')
expect(captured.update).toHaveLength(0)
})
it('returns 404 when the customer does not exist', async () => {
queryResult = {
data: null,
error: { code: 'PGRST116', message: 'No rows returned' },
}
const response = await PATCH(
createMockRequest('/api/customers/missing', {
method: 'PATCH',
body: { personal_number: '900101-1234' },
}),
{ params: Promise.resolve({ id: 'missing' }) },
)
expect(response.status).toBe(404)
})
})