00ae3540db
* 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>
298 lines
9.5 KiB
TypeScript
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'],
|
|
})
|
|
})
|
|
})
|