import { afterEach, describe, expect, it, vi } from 'vitest'; import { NONCE_STALENESS_MS, SAVED_CARD_VERIFICATION_MESSAGE, VERIFICATION_REQUIRED_MESSAGE, adminRequestNewTwoFactorCode, campaignDiscountPence, canSaveCardsForRole, depositChargePence, isAmbiguousPaymentFailure, isNonceStale, isOverflowTipConfirmationRequired, isSavedCardVerificationRequired, isTwoFactorVerificationGateFailure, isVerificationRequiredSignal, parseTokenizeVerificationResult, requestNewTwoFactorCode, sanitizeDecimalInput, shouldFallbackTo2FA, submitPaymentWithRetry } from './square'; import type * as SquareModule from './square'; describe('isNonceStale', () => { const tokenizedFor = 2500; it('is false for a fresh nonce (under the staleness limit)', () => { const tokenizedAt = 1_000_000; const now = tokenizedAt + NONCE_STALENESS_MS - 1; expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(false); }); it('is false exactly at the staleness boundary (limit is exclusive, > not >=)', () => { const tokenizedAt = 1_000_000; const now = tokenizedAt + NONCE_STALENESS_MS; expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(false); }); it('is true just past the staleness boundary', () => { const tokenizedAt = 1_000_000; const now = tokenizedAt + NONCE_STALENESS_MS + 1; expect(isNonceStale(tokenizedAt, tokenizedFor, tokenizedFor, now)).toBe(true); }); it('is true when the nonce was tokenized for a different amount than now', () => { expect(isNonceStale(1_000_000, tokenizedFor, tokenizedFor + 1, Date.now())).toBe(true); }); it('is true for a very old nonce', () => { expect(isNonceStale(1, tokenizedFor, tokenizedFor, Date.now())).toBe(true); }); it('defaults `now` to Date.now() when omitted', () => { expect(isNonceStale(Date.now(), tokenizedFor, tokenizedFor)).toBe(false); }); }); describe('isSquareMock / isSquareConfigured / getSquareConfig', () => { type SquareEnv = { VITE_SQUARE_ENVIRONMENT?: string; VITE_SQUARE_APPLICATION_ID?: string; VITE_SQUARE_LOCATION_ID?: string; DEV?: boolean; }; // square.ts captures APP_ID/LOCATION_ID/SQUARE_ENV at module load, so each // env combination must reload the module with vi.resetModules + vi.stubEnv. // All three VITE_* keys are stubbed explicitly ('' when omitted) so local // .env files can never leak into the suite. async function loadSquareWithEnv(env: SquareEnv): Promise { vi.resetModules(); vi.stubEnv('VITE_SQUARE_ENVIRONMENT', env.VITE_SQUARE_ENVIRONMENT ?? ''); vi.stubEnv('VITE_SQUARE_APPLICATION_ID', env.VITE_SQUARE_APPLICATION_ID ?? ''); vi.stubEnv('VITE_SQUARE_LOCATION_ID', env.VITE_SQUARE_LOCATION_ID ?? ''); if (env.DEV !== undefined) { vi.stubEnv('DEV', env.DEV); } return await import('./square'); } afterEach(() => { vi.unstubAllEnvs(); }); it('mock env in a dev build → isSquareMock true, isSquareConfigured true', async () => { const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock', DEV: true }); expect(mod.isSquareMock()).toBe(true); expect(mod.isSquareConfigured()).toBe(true); }); it('mock env in a production build → isSquareMock false (DEV gate)', async () => { const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock', DEV: false }); expect(mod.isSquareMock()).toBe(false); }); it('mock env without credentials → isSquareConfigured true (mock form needs no keys)', async () => { const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'mock' }); expect(mod.isSquareConfigured()).toBe(true); }); it('sandbox env with credentials → not mock, configured, config returned', async () => { const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'sandbox', VITE_SQUARE_APPLICATION_ID: 'sandbox-sq0idb-abc123', VITE_SQUARE_LOCATION_ID: 'L0MOCK123' }); expect(mod.isSquareMock()).toBe(false); expect(mod.isSquareConfigured()).toBe(true); expect(mod.getSquareConfig()).toEqual({ appId: 'sandbox-sq0idb-abc123', locationId: 'L0MOCK123' }); }); it('sandbox env without credentials → not configured, config null', async () => { const mod = await loadSquareWithEnv({ VITE_SQUARE_ENVIRONMENT: 'sandbox' }); expect(mod.isSquareMock()).toBe(false); expect(mod.isSquareConfigured()).toBe(false); expect(mod.getSquareConfig()).toBeNull(); }); it('empty env → neither mock nor configured', async () => { const mod = await loadSquareWithEnv({}); expect(mod.isSquareMock()).toBe(false); expect(mod.isSquareConfigured()).toBe(false); expect(mod.getSquareConfig()).toBeNull(); }); }); describe('sanitizeDecimalInput', () => { it.each([ ['', ''], ['0', '0'], ['12.34', '12.34'], ['1.2.3', '1.23'], ['£50', '50'], ['1,234.56', '1234.56'], ['abc', ''], ['..', '.'], ['1.', '1.'] ])('sanitizes %s → %s', (input, expected) => { expect(sanitizeDecimalInput(input)).toBe(expected); }); }); describe('campaignDiscountPence', () => { const base = { eligible: true, discounts: [ { source: 'campaign', name: '10% Off', percent: 10, amount: 5 }, { source: 'campaign', name: 'Referral', percent: 5, amount: 2.5 } ], original_total: 50, discounted_total: 42.5 }; it('sums eligible discount amounts in pence', () => { expect(campaignDiscountPence(base)).toBe(750); }); it('rounds each discount amount to pence before summing', () => { expect( campaignDiscountPence({ ...base, discounts: [{ ...base.discounts[0], amount: 5.005 }] }) ).toBe(501); }); it('is 0 when no preview', () => { expect(campaignDiscountPence(null)).toBe(0); }); it('is 0 when the preview is not eligible', () => { expect(campaignDiscountPence({ ...base, eligible: false })).toBe(0); }); it('is 0 for an empty discount list', () => { expect(campaignDiscountPence({ ...base, discounts: [] })).toBe(0); }); }); describe('canSaveCardsForRole', () => { it.each([ ['admin', true], ['verified_email', true], ['unverified_email', false], ['guest', false], ['affiliate', false], ['user', false], ['', false], [undefined, false] ])('role %s → %s', (role, expected) => { expect(canSaveCardsForRole(role)).toBe(expected); }); }); describe('payment failure classification', () => { it('a definitive 402 on a saved-card charge is an issuer verification failure', () => { expect(isSavedCardVerificationRequired(402, true)).toBe(true); }); it('a 402 on a new-card charge is a plain decline, not a verification failure', () => { expect(isSavedCardVerificationRequired(402, false)).toBe(false); }); it('isAmbiguousPaymentFailure matches only 503', () => { expect(isAmbiguousPaymentFailure(503)).toBe(true); expect(isAmbiguousPaymentFailure(402)).toBe(false); expect(isAmbiguousPaymentFailure(200)).toBe(false); expect(isAmbiguousPaymentFailure(500)).toBe(false); }); it.each([ [true, 402, true], [false, 402, false], [true, 503, false], [true, 200, false], [false, 200, false] ])('isSavedCardVerificationRequired(%s, %d) → %s', (usedSavedCard, status, expected) => { expect(isSavedCardVerificationRequired(status, usedSavedCard)).toBe(expected); }); it('SAVED_CARD_VERIFICATION_MESSAGE is non-empty and mentions verification', () => { expect(SAVED_CARD_VERIFICATION_MESSAGE.length).toBeGreaterThan(0); expect(SAVED_CARD_VERIFICATION_MESSAGE.toLowerCase()).toContain('verification'); }); it('VERIFICATION_REQUIRED_MESSAGE is non-empty and mentions verification', () => { expect(VERIFICATION_REQUIRED_MESSAGE.length).toBeGreaterThan(0); expect(VERIFICATION_REQUIRED_MESSAGE.toLowerCase()).toContain('verification'); }); }); describe('isVerificationRequiredSignal', () => { it('matches a 402 JSON body carrying the verification_required code', () => { const body = JSON.stringify({ error: 'Saved card charge requires buyer verification', code: 'verification_required' }); expect(isVerificationRequiredSignal(402, body)).toBe(true); }); it('matches the raw CARD_DECLINED_VERIFICATION_REQUIRED text (dev/mock parity)', () => { expect( isVerificationRequiredSignal( 402, 'CARD_DECLINED_VERIFICATION_REQUIRED: card requires verification' ) ).toBe(true); }); it('matches the plain "verification required" phrasing', () => { expect( isVerificationRequiredSignal(402, 'Payment failed: verification required by your card issuer') ).toBe(true); }); it('is false for a 402 body with a different code', () => { expect( isVerificationRequiredSignal( 402, JSON.stringify({ error: 'Declined', code: 'card_declined' }) ) ).toBe(false); }); it('is false for a 402 body with only error text and no code', () => { expect(isVerificationRequiredSignal(402, 'Payment failed')).toBe(false); }); it('is false for a non-JSON body that does not mention verification', () => { expect(isVerificationRequiredSignal(402, 'Payment declined')).toBe(false); }); it('is false for an empty body', () => { expect(isVerificationRequiredSignal(402, '')).toBe(false); }); it('is false for any non-402 status even with the code present', () => { expect( isVerificationRequiredSignal( 503, JSON.stringify({ error: 'x', code: 'verification_required' }) ) ).toBe(false); expect( isVerificationRequiredSignal( 400, JSON.stringify({ error: 'x', code: 'verification_required' }) ) ).toBe(false); expect( isVerificationRequiredSignal( 200, JSON.stringify({ error: 'x', code: 'verification_required' }) ) ).toBe(false); }); it('is false when the code is not an exact match (guards against prefix drift)', () => { expect( isVerificationRequiredSignal( 402, JSON.stringify({ error: 'x', code: 'verification_required_extra' }) ) ).toBe(false); }); }); describe('shouldFallbackTo2FA', () => { it.each([ ['sca-unavailable', true], ['verified', false], ['challenge-cancelled', false], ['sca-failed', false], ['', false] ])('outcome %s → %s', (outcome, expected) => { expect(shouldFallbackTo2FA(outcome)).toBe(expected); }); it('demotes to the 2FA gate only on sca-unavailable', () => { expect(shouldFallbackTo2FA('sca-unavailable')).toBe(true); }); it('keeps SCA primary after a successful verification', () => { expect(shouldFallbackTo2FA('verified')).toBe(false); }); it('does NOT treat a cancelled challenge as sca-unavailable (retryable via SCA)', () => { expect(shouldFallbackTo2FA('challenge-cancelled')).toBe(false); }); }); describe('parseTokenizeVerificationResult', () => { it('maps a status OK result with a token to a verified outcome carrying the SAME token', () => { expect( parseTokenizeVerificationResult({ status: 'OK', token: 'ccof:sca-verified-token' }) ).toEqual({ verificationToken: 'ccof:sca-verified-token', outcome: 'verified' }); }); it('maps a status OK result with NO token to a verified, tokenless outcome (no SCA required)', () => { expect(parseTokenizeVerificationResult({ status: 'OK' })).toEqual({ verificationToken: null, outcome: 'verified' }); }); it('maps a VERIFICATION_CHALLENGE status to a retryable challenge-cancelled outcome', () => { expect(parseTokenizeVerificationResult({ status: 'VERIFICATION_CHALLENGE' })).toEqual({ verificationToken: null, outcome: 'challenge-cancelled' }); }); it('maps a cancel-coded error to a retryable challenge-cancelled outcome', () => { expect( parseTokenizeVerificationResult({ status: 'FAILED', errors: [{ code: 'CANCEL', message: 'challenge cancelled by buyer' }] }) ).toEqual({ verificationToken: null, outcome: 'challenge-cancelled' }); }); it('maps CARD_DECLINED_VERIFICATION_REQUIRED to sca-unavailable (2FA fallback)', () => { expect( parseTokenizeVerificationResult({ status: 'FAILED', errors: [{ code: 'CARD_DECLINED_VERIFICATION_REQUIRED' }] }) ).toEqual({ verificationToken: null, outcome: 'sca-unavailable' }); }); it('maps any other failure to sca-failed', () => { expect( parseTokenizeVerificationResult({ status: 'FAILED', errors: [{ code: 'CARD_DECLINED', message: 'card declined' }] }) ).toEqual({ verificationToken: null, outcome: 'sca-failed' }); }); }); describe('isTwoFactorVerificationGateFailure', () => { it.each([ [403, 'A two-factor verification code is required to use this saved card', true], [403, 'Two-factor authentication is required to use online card payments', true], [429, 'Too many attempts', true], [400, 'Invalid verification code', true], [400, 'Verification code expired — request a new one', true], [400, 'Booking must be in_progress or completed to create payment', false], [400, 'Invalid request', false], [402, 'Payment failed', false], [200, '', false], [500, 'internal server error', false] ])('status %d + message "%s" → %s', (status, message, expected) => { expect(isTwoFactorVerificationGateFailure(status, message)).toBe(expected); }); it('treats any 403 as a gate failure on a saved-card charge (missing code / 2FA not enabled)', () => { expect(isTwoFactorVerificationGateFailure(403, 'something else')).toBe(true); }); }); describe('isOverflowTipConfirmationRequired', () => { it('matches the backend overflow-guard error body by its code', () => { const body = JSON.stringify({ error: 'The extra amount will be recorded as a tip. Confirm to continue.', code: 'overflow_tip_confirmation_required' }); expect(isOverflowTipConfirmationRequired(body)).toBe(true); }); it('is false for a 400 body with a different code', () => { expect( isOverflowTipConfirmationRequired( JSON.stringify({ error: 'Bad amount', code: 'invalid_amount' }) ) ).toBe(false); }); it('is false for a body with only the error text and no code', () => { expect(isOverflowTipConfirmationRequired('The extra amount will be recorded as a tip.')).toBe( false ); }); it('is false for a non-JSON body', () => { expect(isOverflowTipConfirmationRequired('Payment declined')).toBe(false); }); it('is false for an empty body', () => { expect(isOverflowTipConfirmationRequired('')).toBe(false); }); it('is false when the code is not an exact match (guards against prefix drift)', () => { expect( isOverflowTipConfirmationRequired( JSON.stringify({ error: 'x', code: 'overflow_tip_confirmation_required_extra' }) ) ).toBe(false); }); }); describe('submitPaymentWithRetry', () => { function jsonResponse(status: number): Response { return new Response(null, { status }); } it('retries on a 503 and resolves the follow-up 200', async () => { const submit = vi .fn() .mockResolvedValueOnce(jsonResponse(503)) .mockResolvedValueOnce(jsonResponse(200)); const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 }); expect(response.status).toBe(200); expect(response.ok).toBe(true); expect(submit).toHaveBeenCalledTimes(2); }); it('returns a 402 immediately without retrying', async () => { const submit = vi.fn().mockResolvedValue(jsonResponse(402)); const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 }); expect(response.status).toBe(402); expect(submit).toHaveBeenCalledTimes(1); }); it('succeeds after two 503s followed by a 200', async () => { const submit = vi .fn() .mockResolvedValueOnce(jsonResponse(503)) .mockResolvedValueOnce(jsonResponse(503)) .mockResolvedValueOnce(jsonResponse(200)); const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 }); expect(response.status).toBe(200); expect(submit).toHaveBeenCalledTimes(3); }); it('gives up after maxRetries and returns the last 503', async () => { const submit = vi.fn().mockResolvedValue(jsonResponse(503)); const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1 }); expect(response.status).toBe(503); expect(submit).toHaveBeenCalledTimes(4); }); it('honours a custom maxRetries', async () => { const submit = vi.fn().mockResolvedValue(jsonResponse(503)); const response = await submitPaymentWithRetry(submit, { maxRetries: 1, retryDelayMs: 1 }); expect(response.status).toBe(503); expect(submit).toHaveBeenCalledTimes(2); }); it('backs off with retryDelayMs between retries', async () => { const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); const submit = vi .fn() .mockResolvedValueOnce(jsonResponse(503)) .mockResolvedValueOnce(jsonResponse(200)); const response = await submitPaymentWithRetry(submit, { retryDelayMs: 1234 }); expect(response.status).toBe(200); expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1234); }); }); describe('requestNewTwoFactorCode', () => { function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); } afterEach(() => { vi.unstubAllGlobals(); }); it('returns ok with the server message on a 200 mint', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(jsonResponse({ message: 'Verification code sent' })) ); const result = await requestNewTwoFactorCode(); expect(result.ok).toBe(true); expect(result.status).toBe(200); expect(result.message).toBe('Verification code sent'); }); it('falls back to a default message when the 200 body has none', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}, 200))); const result = await requestNewTwoFactorCode(); expect(result.ok).toBe(true); expect(result.message).toContain('verification code'); }); it('surfaces the 429 mint-cooldown error message', async () => { vi.stubGlobal( 'fetch', vi .fn() .mockResolvedValue( jsonResponse({ error: 'Too many requests. Wait before requesting a new code.' }, 429) ) ); const result = await requestNewTwoFactorCode(); expect(result.ok).toBe(false); expect(result.status).toBe(429); expect(result.message).toContain('Too many requests'); }); it('surfaces the 503 delivery-unavailable error message', async () => { vi.stubGlobal( 'fetch', vi .fn() .mockResolvedValue(jsonResponse({ error: 'Verification code delivery unavailable' }, 503)) ); const result = await requestNewTwoFactorCode(); expect(result.ok).toBe(false); expect(result.status).toBe(503); expect(result.message).toContain('delivery'); }); it('reports a network failure with status 0', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom'))); const result = await requestNewTwoFactorCode(); expect(result.ok).toBe(false); expect(result.status).toBe(0); expect(result.message).toContain('Network error'); }); it('attaches the Bearer token from localStorage and POSTs', async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' })); vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('localStorage', { getItem: (key: string) => (key === 'authToken' ? 'abc.def.ghi' : null) }); const result = await requestNewTwoFactorCode(); expect(result.ok).toBe(true); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe('/api/user/2fa/code'); expect(init.method).toBe('POST'); expect((init.headers as Record)['Authorization']).toBe('Bearer abc.def.ghi'); }); }); describe('adminRequestNewTwoFactorCode', () => { function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); } afterEach(() => { vi.unstubAllGlobals(); }); it('POSTs to the admin customer-scoped mint URL', async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' })); vi.stubGlobal('fetch', fetchMock); const result = await adminRequestNewTwoFactorCode('usr_abc'); expect(result.ok).toBe(true); const [url] = fetchMock.mock.calls[0] as [string]; expect(url).toBe('/api/admin/users/usr_abc/2fa/code'); }); it('URL-encodes the customer userID', async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' })); vi.stubGlobal('fetch', fetchMock); await adminRequestNewTwoFactorCode('usr a/b'); const [url] = fetchMock.mock.calls[0] as [string]; expect(url).toBe('/api/admin/users/usr%20a%2Fb/2fa/code'); }); it('surfaces the 429 mint-cooldown error message', async () => { vi.stubGlobal( 'fetch', vi .fn() .mockResolvedValue( jsonResponse({ error: 'Too many requests. Wait before requesting.' }, 429) ) ); const result = await adminRequestNewTwoFactorCode('usr_abc'); expect(result.ok).toBe(false); expect(result.status).toBe(429); expect(result.message).toContain('Too many requests'); }); it('attaches the Bearer token from localStorage and POSTs', async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' })); vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('localStorage', { getItem: (key: string) => (key === 'authToken' ? 'abc.def.ghi' : null) }); await adminRequestNewTwoFactorCode('usr_abc'); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe('/api/admin/users/usr_abc/2fa/code'); expect(init.method).toBe('POST'); expect((init.headers as Record)['Authorization']).toBe('Bearer abc.def.ghi'); }); }); describe('depositChargePence', () => { it('subtracts the eligible campaign credit when it is smaller than the deposit', () => { expect(depositChargePence(2000, 500)).toBe(1500); }); it('charges the full deposit when the credit equals the deposit (A6 clamp-up)', () => { expect(depositChargePence(2000, 2000)).toBe(2000); }); it('charges the full deposit when the credit exceeds the deposit (A6 clamp-up)', () => { expect(depositChargePence(2000, 2500)).toBe(2000); }); it('is unchanged when no campaign credit applies', () => { expect(depositChargePence(2000, 0)).toBe(2000); }); });