Implements #914 (kundnummer on customers, printed on the invoice PDF). - Migration: nullable text column customers.customer_number, no unique constraint in v1 so existing rows and imports keep working. - API: CreateCustomerSchema/UpdateCustomerSchema accept an optional customer_number (trimmed, max 32 chars, nullable-then-optional so the OpenAPI registry sees it as not required); create/update routes persist it and normalize empty string to null so it can be cleared. - v1 public API: customers create/detail/update round-trip the field (insert and update field lists, response projections, response schemas), and the invoices :send route fetches customer_number in its explicit customer join so the emailed PDF matches the downloaded one (the pdf route already selects customers(*)). - UI: optional Kundnummer field in CustomerForm (next-intl keys in both sv and en), wired into the edit dialog's initialData; read-only Kundnummer row on the customer detail page's business-details card. - Invoice PDF: renders "Kundnr:" / "Customer no.:" in the customer box when set; the PDF reads the live customers join, so no snapshot column is needed. - Tests: route tests cover 400 validation, trimming, clearing with null/empty, and omit-leaves-untouched on POST and PATCH; v1 tests cover the create/update round-trip (insert/update payload + response projection) and the :send customer-join projection. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
231 lines
7.4 KiB
TypeScript
231 lines
7.4 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 }
|
|
|
|
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()
|
|
})
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|