Files
accounted/app/api/customers/__tests__/customer-number.test.ts
T
Mattsson 00ae3540db feat(customers): carry contact person and invoice copy recipients through migration (#1392)
* feat(customers): carry contact person and invoice copy recipients through migration

Extends the arcim-migration entity mapper, Fortnox provider mapper, canonical
DTOs, customer APIs (web + v1) and invoice send flows so contact person and
customer-level invoice CC/BCC addresses survive provider migrations. NULL
means unconfigured and empty means an explicit clear, so re-syncs enrich
legacy gaps without resurrecting deliberately removed values. Fortnox fixed
assets are split into a dedicated follow-up issue.

Fixes #1345

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(db): bump customer metadata migration past pack-slug version

Main already contains 20260803230000; keep new versions strictly newest so
Supabase branching applies them in order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(customers): complete Customer type consumers and make enrichment payload resolvable

The preview-pdf mock customer and the makeCustomer fixture now carry the
three new metadata fields, fixing the type-check failure in Build (zero
extensions) and Vercel.

The enrichment update in the migration orchestrator now spells its payload
as an object literal typed CustomerMetadataEnrichment (absent keys drop at
serialization), so the phantom-column guard resolves the columns instead of
counting another unresolvable dynamic payload past its ceiling. The cc/bcc
guards also verify element types instead of casting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 10:00:03 +02:00

298 lines
9.5 KiB
TypeScript

/**
* Tests for the customer_number field (kundnummer, issue #914) on
* POST /api/customers and PATCH /api/customers/[id].
*
* Exercises the routes through the real withRouteContext wrapper, mocking its
* auth/company/write dependencies. Uses a hand-rolled Supabase mock that
* records insert/update payloads so the tests can assert the route-level
* normalization: the value is trimmed by the Zod schema, and empty string or
* null clears the column to null.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
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: (v: 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 CustomerRow = {
customer_number?: string | null
contact_person?: string | null
invoice_email_cc_addresses?: string[] | null
invoice_email_bcc_addresses?: string[] | null
}
describe('customer_number on POST /api/customers', () => {
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('rejects a customer_number longer than 32 characters with 400', async () => {
const request = createMockRequest('/api/customers', {
method: 'POST',
body: {
name: 'Test AB',
customer_type: 'swedish_business',
customer_number: 'X'.repeat(33),
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(captured.insert).toHaveLength(0)
})
it('stores a trimmed customer_number on create', async () => {
queryResult = {
data: { id: 'cust-1', name: 'Test AB', customer_type: 'swedish_business', customer_number: '1001' },
error: null,
}
const request = createMockRequest('/api/customers', {
method: 'POST',
body: {
name: 'Test AB',
customer_type: 'swedish_business',
customer_number: ' 1001 ',
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: CustomerRow }>(response)
expect(status).toBe(200)
expect((captured.insert[0] as CustomerRow).customer_number).toBe('1001')
expect(body.data.customer_number).toBe('1001')
})
it('normalizes an empty customer_number to null on create', async () => {
queryResult = {
data: { id: 'cust-1', name: 'Test AB', customer_type: 'swedish_business', customer_number: null },
error: null,
}
const request = createMockRequest('/api/customers', {
method: 'POST',
body: { name: 'Test AB', customer_type: 'swedish_business', customer_number: '' },
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect((captured.insert[0] as CustomerRow).customer_number).toBeNull()
})
it('defaults customer_number to null when omitted', async () => {
queryResult = {
data: { id: 'cust-1', name: 'Test AB', customer_type: 'swedish_business', customer_number: null },
error: null,
}
const request = createMockRequest('/api/customers', {
method: 'POST',
body: { name: 'Test AB', customer_type: 'swedish_business' },
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect((captured.insert[0] as CustomerRow).customer_number).toBeNull()
})
it('stores customer invoice contact metadata on create', async () => {
queryResult = {
data: { id: 'cust-1', name: 'Test AB', customer_type: 'swedish_business' },
error: null,
}
const request = createMockRequest('/api/customers', {
method: 'POST',
body: {
name: 'Test AB',
customer_type: 'swedish_business',
contact_person: 'Anna Andersson',
invoice_email_cc_addresses: ['finance@example.test'],
invoice_email_bcc_addresses: ['archive@example.test'],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
expect((await parseJsonResponse(response)).status).toBe(200)
expect(captured.insert[0]).toMatchObject({
contact_person: 'Anna Andersson',
invoice_email_cc_addresses: ['finance@example.test'],
invoice_email_bcc_addresses: ['archive@example.test'],
})
})
it('rejects more than 19 customer invoice copy recipients', async () => {
const request = createMockRequest('/api/customers', {
method: 'POST',
body: {
name: 'Test AB',
customer_type: 'swedish_business',
invoice_email_cc_addresses: Array.from(
{ length: 20 },
(_, index) => `copy-${index}@example.test`,
),
},
})
const response = await POST(request, { params: Promise.resolve({}) })
expect((await parseJsonResponse(response)).status).toBe(400)
expect(captured.insert).toHaveLength(0)
})
})
describe('customer_number on PATCH /api/customers/[id]', () => {
const routeParams = { params: Promise.resolve({ id: 'cust-1' }) }
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
captured.insert.length = 0
captured.update.length = 0
queryResult = {
data: { id: 'cust-1', customer_type: 'swedish_business' },
error: null,
}
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('rejects a customer_number longer than 32 characters with 400', async () => {
const request = createMockRequest('/api/customers/cust-1', {
method: 'PATCH',
body: { customer_number: 'X'.repeat(33) },
})
const response = await PATCH(request, routeParams)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(captured.update).toHaveLength(0)
})
it('updates the customer_number', async () => {
const request = createMockRequest('/api/customers/cust-1', {
method: 'PATCH',
body: { customer_number: 'K-42' },
})
const response = await PATCH(request, routeParams)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect((captured.update[0] as CustomerRow).customer_number).toBe('K-42')
})
it('clears the customer_number when null is sent', async () => {
const request = createMockRequest('/api/customers/cust-1', {
method: 'PATCH',
body: { customer_number: null },
})
const response = await PATCH(request, routeParams)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect((captured.update[0] as CustomerRow).customer_number).toBeNull()
})
it('clears the customer_number when an empty string is sent', async () => {
const request = createMockRequest('/api/customers/cust-1', {
method: 'PATCH',
body: { customer_number: '' },
})
const response = await PATCH(request, routeParams)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect((captured.update[0] as CustomerRow).customer_number).toBeNull()
})
it('leaves the customer_number untouched when the field is omitted', async () => {
const request = createMockRequest('/api/customers/cust-1', {
method: 'PATCH',
body: { name: 'New Name AB' },
})
const response = await PATCH(request, routeParams)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(captured.update[0]).not.toHaveProperty('customer_number')
})
it('updates customer invoice contact metadata', async () => {
const request = createMockRequest('/api/customers/cust-1', {
method: 'PATCH',
body: {
contact_person: '',
invoice_email_cc_addresses: [],
invoice_email_bcc_addresses: ['archive@example.test'],
},
})
const response = await PATCH(request, routeParams)
expect((await parseJsonResponse(response)).status).toBe(200)
expect(captured.update[0]).toMatchObject({
contact_person: '',
invoice_email_cc_addresses: [],
invoice_email_bcc_addresses: ['archive@example.test'],
})
})
})