Files
supabase/apps/studio/tests/components/Billing/useBillingCustomerDataForm.test.tsx

582 lines
16 KiB
TypeScript

import type { StripeAddressElementChangeEvent } from '@stripe/stripe-js'
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useBillingCustomerDataForm } from '@/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
type BillingProfile = ReturnType<typeof makeCustomerProfile>
type CustomerChangeHandler = Parameters<
typeof useBillingCustomerDataForm
>[0]['onCustomerDataChange']
type HookPropsWithTaxId = {
customerProfile: BillingProfile
taxId: CustomerTaxId | null
onCustomerDataChange: CustomerChangeHandler
}
type HookPropsWithExistingTaxId = {
customerProfile: BillingProfile
taxId: CustomerTaxId
onCustomerDataChange: CustomerChangeHandler
}
type HookPropsWithoutTaxId = {
customerProfile: BillingProfile
onCustomerDataChange: CustomerChangeHandler
}
type HookPropsHydrated = {
customerProfile: BillingProfile | null | undefined
onCustomerDataChange: CustomerChangeHandler
}
const makeCustomerProfile = (
overrides?: Partial<{ address: CustomerAddress; billing_name: string }>
) => ({
billing_name: 'Acme Inc',
address: {
line1: '123 Main St',
line2: 'Suite 100',
city: 'New York',
state: 'NY',
postal_code: '10001',
country: 'US',
},
...overrides,
})
const makeTaxId = (overrides?: Partial<CustomerTaxId>): CustomerTaxId => ({
type: 'us_ein',
value: '12-3456789',
country: 'US',
...overrides,
})
const makeAddressChangeEvent = (
overrides?: Partial<{
name: string
address: Partial<CustomerAddress>
complete: boolean
}>
): StripeAddressElementChangeEvent => ({
complete: overrides?.complete ?? true,
elementType: 'address',
elementMode: 'billing',
empty: false,
isNewAddress: false,
value: {
address: {
city: overrides?.address?.city ?? 'San Francisco',
country: overrides?.address?.country ?? 'US',
line1: overrides?.address?.line1 ?? '500 Market St',
line2: overrides?.address?.line2 ?? '',
postal_code: overrides?.address?.postal_code ?? '94105',
state: overrides?.address?.state ?? 'CA',
},
name: overrides?.name ?? 'Updated Company',
},
})
const submitHook = async <TResult,>(submit: () => Promise<TResult>) => {
let result: TResult | undefined
await act(async () => {
result = await submit()
})
return result as TResult
}
describe('useBillingCustomerDataForm', () => {
it('initializes tax ID fields and address country from the provided profile', () => {
const customerProfile = makeCustomerProfile({
address: {
line1: '1 Infinite Loop',
line2: '',
city: 'Cupertino',
state: 'CA',
postal_code: '95014',
country: 'US',
},
})
const taxId = makeTaxId()
const { result } = renderHook(
({ customerProfile, taxId, onCustomerDataChange }: HookPropsWithTaxId) =>
useBillingCustomerDataForm({
customerProfile,
taxId,
onCustomerDataChange,
}),
{
initialProps: { customerProfile, taxId, onCustomerDataChange: vi.fn() },
}
)
expect(result.current.addressCountry).toBe('US')
expect(result.current.form.getValues()).toEqual({
tax_id_name: 'US EIN',
tax_id_type: 'us_ein',
tax_id_value: '12-3456789',
})
expect(result.current.isDirty).toBe(false)
})
it('returns validation errors when the seeded address is incomplete', async () => {
const customerProfile = makeCustomerProfile({
billing_name: '',
address: {
line1: '',
line2: '',
city: '',
state: '',
postal_code: '',
country: '',
},
})
const { result } = renderHook(
({ customerProfile, onCustomerDataChange }: HookPropsWithoutTaxId) =>
useBillingCustomerDataForm({
customerProfile,
taxId: null,
onCustomerDataChange,
}),
{
initialProps: { customerProfile, onCustomerDataChange: vi.fn() },
}
)
await expect(submitHook(result.current.handleSubmit)).resolves.toEqual({
status: 'error',
message: 'Full name is required.',
})
})
it('blocks submit when Stripe marks the address element as incomplete', async () => {
const onCustomerDataChange = vi.fn()
const customerProfile = makeCustomerProfile()
const { result } = renderHook(
({ customerProfile, onCustomerDataChange }: HookPropsWithoutTaxId) =>
useBillingCustomerDataForm({
customerProfile,
taxId: null,
onCustomerDataChange,
}),
{
initialProps: { customerProfile, onCustomerDataChange },
}
)
act(() => {
result.current.onAddressChange(
makeAddressChangeEvent({
complete: false,
address: { postal_code: 'ABCDE' },
})
)
})
await expect(submitHook(result.current.handleSubmit)).resolves.toEqual({
status: 'error',
message: 'Please enter a valid billing address.',
})
expect(onCustomerDataChange).not.toHaveBeenCalled()
})
it('submits the seeded address for tax-id-only updates', async () => {
const onCustomerDataChange = vi.fn()
const customerProfile = makeCustomerProfile()
const { result } = renderHook(
({ customerProfile, onCustomerDataChange }: HookPropsWithoutTaxId) =>
useBillingCustomerDataForm({
customerProfile,
taxId: null,
onCustomerDataChange,
}),
{
initialProps: { customerProfile, onCustomerDataChange },
}
)
act(() => {
result.current.form.setValue('tax_id_name', 'US EIN', { shouldDirty: true })
result.current.form.setValue('tax_id_type', 'us_ein', { shouldDirty: true })
result.current.form.setValue('tax_id_value', '12-3456789', { shouldDirty: true })
})
await waitFor(() => expect(result.current.isDirty).toBe(true))
const submitResult = await submitHook(result.current.handleSubmit)
expect(submitResult.status).toBe('success')
if (submitResult.status !== 'success') {
throw new Error('Expected successful submit result')
}
act(() => {
result.current.markCurrentValuesAsSaved(
submitResult.submittedState.addressValue,
submitResult.submittedState.taxIdValues
)
})
expect(onCustomerDataChange).toHaveBeenCalledWith({
address: customerProfile.address,
billing_name: 'Acme Inc',
tax_id: {
type: 'us_ein',
value: '12-3456789',
country: 'US',
},
})
expect(result.current.isDirty).toBe(false)
})
it('submits tax_id as null after an existing tax ID is removed', async () => {
const onCustomerDataChange = vi.fn()
const customerProfile = makeCustomerProfile()
const taxId = makeTaxId()
const { result } = renderHook(
({ customerProfile, taxId, onCustomerDataChange }: HookPropsWithTaxId) =>
useBillingCustomerDataForm({
customerProfile,
taxId,
onCustomerDataChange,
}),
{
initialProps: { customerProfile, taxId, onCustomerDataChange },
}
)
act(() => {
result.current.form.setValue('tax_id_name', '', { shouldDirty: true })
result.current.form.setValue('tax_id_type', '', { shouldDirty: true })
result.current.form.setValue('tax_id_value', '', { shouldDirty: true })
})
await waitFor(() => expect(result.current.isDirty).toBe(true))
const submitResult = await submitHook(result.current.handleSubmit)
expect(submitResult).toEqual({
status: 'success',
submittedState: {
addressValue: {
name: 'Acme Inc',
address: customerProfile.address,
},
taxIdValues: {
tax_id_name: '',
tax_id_type: '',
tax_id_value: '',
},
},
})
expect(onCustomerDataChange).toHaveBeenCalledWith({
address: customerProfile.address,
billing_name: 'Acme Inc',
tax_id: null,
})
})
it('keeps a removed tax ID cleared after an intermediate rerender during save', async () => {
const customerProfile = makeCustomerProfile()
const taxId = makeTaxId()
let rerenderHook: ((props: HookPropsWithExistingTaxId) => void) | undefined
const onCustomerDataChange: CustomerChangeHandler = vi.fn(async () => {
rerenderHook?.({
customerProfile: makeCustomerProfile({
address: {
...customerProfile.address,
line1: '500 Market St',
},
}),
taxId,
onCustomerDataChange,
})
await Promise.resolve()
})
const { result, rerender } = renderHook(
({ customerProfile, taxId, onCustomerDataChange }: HookPropsWithTaxId) =>
useBillingCustomerDataForm({
customerProfile,
taxId,
onCustomerDataChange,
}),
{
initialProps: { customerProfile, taxId, onCustomerDataChange },
}
)
rerenderHook = rerender
act(() => {
result.current.form.setValue('tax_id_name', '', { shouldDirty: true })
result.current.form.setValue('tax_id_type', '', { shouldDirty: true })
result.current.form.setValue('tax_id_value', '', { shouldDirty: true })
})
const submitResult = await submitHook(result.current.handleSubmit)
expect(submitResult.status).toBe('success')
if (submitResult.status !== 'success') {
throw new Error('Expected successful submit result')
}
act(() => {
result.current.markCurrentValuesAsSaved(
submitResult.submittedState.addressValue,
submitResult.submittedState.taxIdValues
)
})
expect(result.current.form.getValues()).toEqual({
tax_id_name: '',
tax_id_type: '',
tax_id_value: '',
})
})
it('uses the latest address element value when building the submit payload', async () => {
const onCustomerDataChange = vi.fn()
const customerProfile = makeCustomerProfile()
const taxId = makeTaxId({ type: 'eu_vat', value: '12345678', country: 'AT' })
const { result } = renderHook(
({ customerProfile, taxId, onCustomerDataChange }: HookPropsWithTaxId) =>
useBillingCustomerDataForm({
customerProfile,
taxId,
onCustomerDataChange,
}),
{
initialProps: { customerProfile, taxId, onCustomerDataChange },
}
)
act(() => {
result.current.onAddressChange(
makeAddressChangeEvent({
name: 'Updated GmbH',
address: { country: 'AT', city: 'Vienna', postal_code: '1010' },
})
)
result.current.form.setValue('tax_id_name', 'AT VAT', { shouldDirty: true })
result.current.form.setValue('tax_id_type', 'eu_vat', { shouldDirty: true })
result.current.form.setValue('tax_id_value', '12345678', { shouldDirty: true })
})
await waitFor(() => expect(result.current.addressCountry).toBe('AT'))
const submitResult = await submitHook(result.current.handleSubmit)
expect(submitResult).toEqual({
status: 'success',
submittedState: {
addressValue: {
name: 'Updated GmbH',
address: {
line1: '500 Market St',
line2: '',
city: 'Vienna',
state: 'CA',
postal_code: '1010',
country: 'AT',
},
},
taxIdValues: {
tax_id_name: 'AT VAT',
tax_id_type: 'eu_vat',
tax_id_value: '12345678',
},
},
})
expect(onCustomerDataChange).toHaveBeenCalledWith({
address: {
line1: '500 Market St',
line2: undefined,
city: 'Vienna',
state: 'CA',
postal_code: '1010',
country: 'AT',
},
billing_name: 'Updated GmbH',
tax_id: {
type: 'eu_vat',
value: 'ATU12345678',
country: 'AT',
},
})
})
it('submits an updated address without changing a missing tax ID', async () => {
const onCustomerDataChange = vi.fn()
const customerProfile = makeCustomerProfile()
const { result } = renderHook(
({ customerProfile, onCustomerDataChange }: HookPropsWithoutTaxId) =>
useBillingCustomerDataForm({
customerProfile,
taxId: null,
onCustomerDataChange,
}),
{
initialProps: { customerProfile, onCustomerDataChange },
}
)
act(() => {
result.current.onAddressChange(
makeAddressChangeEvent({
name: 'Updated Company',
address: { city: 'Los Angeles', state: 'CA', postal_code: '90001' },
})
)
})
expect(result.current.isDirty).toBe(true)
const submitResult = await submitHook(result.current.handleSubmit)
expect(submitResult).toEqual({
status: 'success',
submittedState: {
addressValue: {
name: 'Updated Company',
address: {
line1: '500 Market St',
line2: '',
city: 'Los Angeles',
state: 'CA',
postal_code: '90001',
country: 'US',
},
},
taxIdValues: {
tax_id_name: '',
tax_id_type: '',
tax_id_value: '',
},
},
})
expect(onCustomerDataChange).toHaveBeenCalledWith({
address: {
line1: '500 Market St',
line2: undefined,
city: 'Los Angeles',
state: 'CA',
postal_code: '90001',
country: 'US',
},
billing_name: 'Updated Company',
tax_id: null,
})
})
it('resets dirty state and restores the initial address payload', async () => {
const onCustomerDataChange = vi.fn()
const customerProfile = makeCustomerProfile()
const { result } = renderHook(
({ customerProfile, onCustomerDataChange }: HookPropsWithoutTaxId) =>
useBillingCustomerDataForm({
customerProfile,
taxId: null,
onCustomerDataChange,
}),
{
initialProps: { customerProfile, onCustomerDataChange },
}
)
act(() => {
result.current.onAddressChange(
makeAddressChangeEvent({
address: { city: 'Los Angeles', state: 'CA', postal_code: '90001' },
})
)
result.current.form.setValue('tax_id_name', 'US EIN', { shouldDirty: true })
result.current.form.setValue('tax_id_type', 'us_ein', { shouldDirty: true })
result.current.form.setValue('tax_id_value', '12-3456789', { shouldDirty: true })
})
await waitFor(() => expect(result.current.isDirty).toBe(true))
act(() => {
result.current.handleReset()
})
expect(result.current.isDirty).toBe(false)
expect(result.current.addressCountry).toBe('US')
act(() => {
result.current.form.setValue('tax_id_name', 'US EIN', { shouldDirty: true })
result.current.form.setValue('tax_id_type', 'us_ein', { shouldDirty: true })
result.current.form.setValue('tax_id_value', '12-3456789', { shouldDirty: true })
})
const submitResult = await submitHook(result.current.handleSubmit)
expect(submitResult).toEqual({
status: 'success',
submittedState: {
addressValue: {
name: 'Acme Inc',
address: customerProfile.address,
},
taxIdValues: {
tax_id_name: 'US EIN',
tax_id_type: 'us_ein',
tax_id_value: '12-3456789',
},
},
})
expect(onCustomerDataChange).toHaveBeenLastCalledWith({
address: customerProfile.address,
billing_name: 'Acme Inc',
tax_id: {
type: 'us_ein',
value: '12-3456789',
country: 'US',
},
})
})
it('syncs addressCountry when the customer profile loads after mount', async () => {
const onCustomerDataChange = vi.fn<CustomerChangeHandler>()
const initialProps: HookPropsHydrated = {
customerProfile: undefined,
onCustomerDataChange,
}
const { result, rerender } = renderHook(
({ customerProfile, onCustomerDataChange }: HookPropsHydrated) =>
useBillingCustomerDataForm({
customerProfile,
taxId: null,
onCustomerDataChange,
}),
{
initialProps,
}
)
expect(result.current.addressCountry).toBeUndefined()
rerender({
onCustomerDataChange,
customerProfile: makeCustomerProfile({
address: {
line1: 'Schonhauser Allee 1',
line2: '',
city: 'Berlin',
state: 'BE',
postal_code: '10119',
country: 'DE',
},
}),
})
await waitFor(() => expect(result.current.addressCountry).toBe('DE'))
})
})