import { afterEach, describe, expect, it, vi } from 'vitest'; import { NONCE_STALENESS_MS, SAVED_CARD_VERIFICATION_MESSAGE, SCA_FALLBACK_CONSENT_VERSION, SCA_REFUSAL_MESSAGE_ONLINE, SCA_REFUSAL_MESSAGE_TILL, VERIFICATION_REQUIRED_MESSAGE, adminRequestNewTwoFactorCode, buildCashTillPaymentBody, campaignDiscountPence, canSaveCardsForRole, cashChargeBasePence, depositChargePence, isAmbiguousPaymentFailure, isNonceStale, isOverflowTipConfirmationRequired, isSavedCardVerificationRequired, isTwoFactorVerificationGateFailure, isVerificationRequiredSignal, newCardTokenizeResult, parseTokenizeVerificationResult, requestNewTwoFactorCode, resendEmailVerification, sanitizeDecimalInput, scaFallbackConsentFields, shouldShowSCARefusal, submitPaymentWithRetry, type SavedCardVerificationOutcome, verifyEmailCode } 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('buildCashTillPaymentBody', () => { it('sends the due amount alone with no tip flag when no tip applies', () => { // `tip_enabled` is intentionally absent (undefined) — the old inline // bodies only set it when a tip actually existed. expect(buildCashTillPaymentBody(4000, 0)).toEqual({ amount: 4000, payment_type: 'full', payment_method: 'cash' }); expect(buildCashTillPaymentBody(4000, 0).tip_enabled).toBeUndefined(); }); it('folds the tip into the amount and flags tip_enabled (backend has no tip_amount field)', () => { // CreateTerminalPaymentRequest derives the tip from `amount - remaining` // when tip_enabled — the tip must ride inside `amount`, never as a dead // `tip_amount` key. expect(buildCashTillPaymentBody(4000, 500)).toEqual({ amount: 4500, payment_type: 'full', payment_method: 'cash', tip_enabled: true }); }); it('always carries the full/cash payment contract the backend keys on', () => { expect(buildCashTillPaymentBody(4000, 0)).toMatchObject({ payment_type: 'full', payment_method: 'cash' }); }); it('rounds via the caller; the helper sums the pence inputs verbatim', () => { expect(buildCashTillPaymentBody(4000, 50).amount).toBe(4050); }); }); describe('cashChargeBasePence', () => { // FIX-1/FIX-2: The charge base is the FULL totalDuePence — neither the // campaign credit nor the loyalty discount is subtracted. The backend // applies both at completion via separate discount rows, and the tip carve // (`amount − remaining`) uses GetBookingRemainingBalancePence which does // NOT account for pending discounts. Subtracting them would undercharge // the booking and absorb the tip into booking credit. it('charges the full total due — campaign and loyalty are applied server-side', () => { // Booking £100 net (campaign already subtracted), £10 campaign credit, // £10 loyalty redemption → charge base = £100 (full totalDuePence) expect(cashChargeBasePence(10000, 1000, 1000)).toBe(10000); }); it('ignores campaignPence and loyaltyPence — always returns totalDuePence', () => { // totalDue = £90 (no campaign), loyalty £10 → base = £90, not £80 expect(cashChargeBasePence(9000, 0, 1000)).toBe(9000); }); it('never goes below zero', () => { expect(cashChargeBasePence(0, 0, 0)).toBe(0); expect(cashChargeBasePence(-100, 0, 0)).toBe(0); }); it('the folded tip body uses the base, so UI tip == backend-recorded tip', () => { // Booking £100 net, campaign £10, loyalty £10: base = 10000. // Tender £110 → tip £10 → amount £110. The backend carves against // the full £100 remaining (no discount deduction), so it records // £10 tip — matching the UI claim. const basePence = cashChargeBasePence(10000, 1000, 1000); const tipPence = 11000 - basePence; expect(buildCashTillPaymentBody(basePence, tipPence).amount).toBe(11000); expect(tipPence).toBe(1000); }); it('campaignPence and loyaltyPence are accepted but ignored (call-site compat)', () => { // The parameters exist for call-site compatibility — the calling // modals still compute them for display. The arithmetic ignores them. expect(cashChargeBasePence(5000, 9999, 9999)).toBe(5000); }); }); 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('shouldShowSCARefusal', () => { it.each([ ['sca-unavailable', true], ['verified', false], ['challenge-cancelled', false], ['sca-failed', false], ['', false] ])('outcome %s → %s', (outcome, expected) => { expect(shouldShowSCARefusal(outcome)).toBe(expected); }); it('refuses the charge only on sca-unavailable (C6 SCA-only posture)', () => { expect(shouldShowSCARefusal('sca-unavailable')).toBe(true); }); it('keeps SCA primary after a successful verification', () => { expect(shouldShowSCARefusal('verified')).toBe(false); }); it('does NOT treat a cancelled challenge as sca-unavailable (retryable via SCA)', () => { expect(shouldShowSCARefusal('challenge-cancelled')).toBe(false); }); }); describe('C6 SCA-unavailable refusal (no 2FA fallback for saved-card charges)', () => { it('online refusal tells the customer the payment failed and can be retried', () => { expect(SCA_REFUSAL_MESSAGE_ONLINE.length).toBeGreaterThan(0); expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).toContain('not'); expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).toContain('did not go through'); // Online payments are refused as failed — never "pay online later" (the // customer IS online; the deposit/gift-card purchase simply fails). expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).not.toContain('pay online later'); // The refusal must NOT offer the verification-code fallback. expect(SCA_REFUSAL_MESSAGE_ONLINE.toLowerCase()).not.toContain('verification code'); }); it('till refusal is the only surface that invites paying online later', () => { expect(SCA_REFUSAL_MESSAGE_TILL.length).toBeGreaterThan(0); // Only the in-person till surface may suggest paying online later. expect(SCA_REFUSAL_MESSAGE_TILL.toLowerCase()).toContain('pay online later'); // It still must NOT offer the verification-code fallback. expect(SCA_REFUSAL_MESSAGE_TILL.toLowerCase()).not.toContain('verification code'); }); it('the refusal path never sends consent_accepted on its own', () => { // A refused charge (sca-unavailable) shows the refusal and proceeds // nowhere — the versioned consent payload exists only for the explicit // opt-in path (TWO_FACTOR_FALLBACK deployments) via // scaFallbackConsentFields(true), which the refusal never reaches: the // charge surfaces always call it with consent unaccepted, so it yields // an empty object and no consent_accepted/consent_version is sent. expect(shouldShowSCARefusal('sca-unavailable')).toBe(true); expect(scaFallbackConsentFields(false)).toEqual({}); }); it('SCA_FALLBACK_CONSENT_VERSION stays versioned for the explicit opt-in path', () => { expect(SCA_FALLBACK_CONSENT_VERSION).toMatch(/^v\d+$/); expect(scaFallbackConsentFields(true)).toEqual({ consent_version: SCA_FALLBACK_CONSENT_VERSION, consent_accepted: true }); }); }); 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 (C6 refusal)', () => { 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('new-card tokenizeWithVerification contract (dev/mock parity with the real SDK)', () => { // The real Square Web Payments SDK returns `{ nonce, verificationToken: // null }` from a NEW-card tokenizeWithVerification — the SCA-verified cnon // nonce IS the charge source, and a separate verificationToken exists only // on the saved-card-on-file SCA flow. The dev mock must mirror this exactly: // the charge surfaces send `new_card_token: newCardToken ?? verificationToken` // and gate `save_card: X && !verificationToken`, so an always-non-null mock // verificationToken silently suppressed "Save this card for next time" on // new-card flows in dev. MockCardForm.tokenizeWithVerification builds its // result through newCardTokenizeResult (the shared single source), so these // tests pin the contract the mock and the real form both honour. it('returns the cnon nonce as the charge source and a NULL verificationToken', () => { expect(newCardTokenizeResult('cnon:test-card')).toEqual({ nonce: 'cnon:test-card', verificationToken: null }); }); it('keeps the cnon: nonce prefix the backend dev mock accepts as a charge source', () => { expect(newCardTokenizeResult('cnon:visa').nonce).toMatch(/^cnon:/); }); it('pins the save_card gate: the nonce wins the charge source and the save stays live', () => { const { nonce, verificationToken } = newCardTokenizeResult('cnon:test-card'); // `new_card_token: newCardToken ?? verificationToken` → the nonce is used. expect(nonce ?? verificationToken).toBe('cnon:test-card'); // `save_card: X && !verificationToken` → the save is honoured (regression pin). expect(verificationToken).toBeNull(); }); }); 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); }); it('does not auto-retry a 503 on a verificationCodeGated submission (finding 4)', async () => { // A fresh saved-card charge already CONSUMED the 2FA code at the backend // gate and the backend re-issued a fresh one — a same-body retry would // re-send a dead code and come back 400. The 503 must be surfaced to the // caller (which re-runs the gate) instead of retrying. const submit = vi.fn().mockResolvedValue(jsonResponse(503)); const response = await submitPaymentWithRetry(submit, { verificationCodeGated: true, retryDelayMs: 1 }); expect(response.status).toBe(503); expect(submit).toHaveBeenCalledTimes(1); }); it('still auto-retries a 503 when verificationCodeGated is false', async () => { const submit = vi .fn() .mockResolvedValueOnce(jsonResponse(503)) .mockResolvedValueOnce(jsonResponse(200)); const response = await submitPaymentWithRetry(submit, { verificationCodeGated: false, retryDelayMs: 1 }); expect(response.status).toBe(200); expect(submit).toHaveBeenCalledTimes(2); }); }); 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('resendEmailVerification', () => { 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 /api/verify/generate with the email and email_verify purpose', async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ success: true, message: 'ok' })); vi.stubGlobal('fetch', fetchMock); const response = await resendEmailVerification('user@example.com'); expect(response.ok).toBe(true); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe('/api/verify/generate'); expect(init.method).toBe('POST'); expect((init.headers as Record)['Content-Type']).toBe('application/json'); expect(init.body).toBe(JSON.stringify({ email: 'user@example.com', purpose: 'email_verify' })); }); it('passes through the backend response (the caller surfaces success/message)', async () => { const fetchMock = vi .fn() .mockResolvedValue(jsonResponse({ success: false, message: 'cooldown' }, 429)); vi.stubGlobal('fetch', fetchMock); const response = await resendEmailVerification('user@example.com'); expect(response.status).toBe(429); expect(response.ok).toBe(false); }); }); describe('verifyEmailCode', () => { 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 /api/verify/check with the email, code and email_verify purpose', async () => { const fetchMock = vi .fn() .mockResolvedValue(jsonResponse({ success: true, message: 'Email verified successfully' })); vi.stubGlobal('fetch', fetchMock); const response = await verifyEmailCode('user@example.com', 'a1b2c3d4e5f6'); expect(response.ok).toBe(true); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe('/api/verify/check'); expect(init.method).toBe('POST'); expect((init.headers as Record)['Content-Type']).toBe('application/json'); expect(init.body).toBe( JSON.stringify({ email: 'user@example.com', code: 'a1b2c3d4e5f6', purpose: 'email_verify' }) ); }); it('passes through the backend success response body', async () => { const fetchMock = vi .fn() .mockResolvedValue(jsonResponse({ success: true, message: 'Email verified successfully' })); vi.stubGlobal('fetch', fetchMock); const response = await verifyEmailCode('user@example.com', 'a1b2c3d4e5f6'); const data = await response.json(); expect(data).toEqual({ success: true, message: 'Email verified successfully' }); }); it('passes through a failed verification (invalid/expired code)', async () => { const fetchMock = vi .fn() .mockResolvedValue(new Response('invalid or expired code', { status: 400 })); vi.stubGlobal('fetch', fetchMock); const response = await verifyEmailCode('user@example.com', 'wrong-code'); expect(response.status).toBe(400); expect(response.ok).toBe(false); }); }); describe('SCA-unavailable fallback consent (C6)', () => { it('SCA_FALLBACK_CONSENT_VERSION is a non-empty versioned string', () => { expect(SCA_FALLBACK_CONSENT_VERSION).toMatch(/^v\d+$/); }); it('scaFallbackConsentFields returns the versioned payload when accepted', () => { expect(scaFallbackConsentFields(true)).toEqual({ consent_version: SCA_FALLBACK_CONSENT_VERSION, consent_accepted: true }); }); it('scaFallbackConsentFields returns an empty object when not accepted', () => { expect(scaFallbackConsentFields(false)).toEqual({}); }); }); 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); }); }); // The mock card form's saved-card SCA helper drives every outcome of // runSavedCardSCAProactively. tokenizeSavedCardWithVerification dynamically // imports MockCardForm when isSquareMock() is true; the vi.hoisted mock lets // the tests choose the tokenize result per scenario, so the outcome mapping // (verified / challenge-cancelled / sca-unavailable / sca-failed) and the // onOutcome wiring are exercised through the real shared implementation. const mockSCATokenize = vi.hoisted(() => ({ fn: vi.fn() })); vi.mock('$lib/components/payments/MockCardForm.svelte', () => ({ tokenizeSavedCard: mockSCATokenize.fn })); describe('runSavedCardSCAProactively', () => { async function loadSquareInMockMode(): Promise { vi.resetModules(); vi.stubEnv('VITE_SQUARE_ENVIRONMENT', 'mock'); vi.stubEnv('VITE_SQUARE_APPLICATION_ID', ''); vi.stubEnv('VITE_SQUARE_LOCATION_ID', ''); vi.stubEnv('DEV', true); return await import('./square'); } afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); mockSCATokenize.fn.mockClear(); }); it('maps a verified tokenize result and records it via onOutcome', async () => { mockSCATokenize.fn.mockResolvedValue({ verificationToken: 'cnon:sca-test_5000_ok', outcome: 'verified' }); const mod = await loadSquareInMockMode(); const outcomes: SavedCardVerificationOutcome[] = []; const res = await mod.runSavedCardSCAProactively({ amountPence: 5000, squareCardId: 'ccof:test-card', onOutcome: (o) => outcomes.push(o) }); expect(res.outcome).toBe('verified'); expect(res.verificationToken).toBe('cnon:sca-test_5000_ok'); expect(outcomes).toEqual(['verified']); }); it('maps a challenge-cancelled tokenize result (retryable via SCA, never 2FA)', async () => { mockSCATokenize.fn.mockResolvedValue({ verificationToken: null, outcome: 'challenge-cancelled' }); const mod = await loadSquareInMockMode(); const outcomes: SavedCardVerificationOutcome[] = []; const res = await mod.runSavedCardSCAProactively({ amountPence: 5000, squareCardId: 'ccof:test-card', onOutcome: (o) => outcomes.push(o) }); expect(res.outcome).toBe('challenge-cancelled'); expect(res.verificationToken).toBeUndefined(); expect(outcomes).toEqual(['challenge-cancelled']); }); it('maps an sca-unavailable tokenize result (C6 refusal)', async () => { mockSCATokenize.fn.mockResolvedValue({ verificationToken: null, outcome: 'sca-unavailable' }); const mod = await loadSquareInMockMode(); const res = await mod.runSavedCardSCAProactively({ amountPence: 5000, squareCardId: 'ccof:test-card', onOutcome: () => {} }); expect(res.outcome).toBe('sca-unavailable'); expect(res.verificationToken).toBeUndefined(); }); it('maps an sca-failed tokenize result', async () => { mockSCATokenize.fn.mockResolvedValue({ verificationToken: null, outcome: 'sca-failed' }); const mod = await loadSquareInMockMode(); const res = await mod.runSavedCardSCAProactively({ amountPence: 5000, squareCardId: 'ccof:test-card', onOutcome: () => {} }); expect(res.outcome).toBe('sca-failed'); }); it('demotes to sca-unavailable when no saved card is resolved', async () => { const mod = await loadSquareInMockMode(); const outcomes: SavedCardVerificationOutcome[] = []; const res = await mod.runSavedCardSCAProactively({ amountPence: 5000, squareCardId: '', onOutcome: (o) => outcomes.push(o) }); expect(res.outcome).toBe('sca-unavailable'); expect(outcomes).toEqual(['sca-unavailable']); expect(mockSCATokenize.fn).not.toHaveBeenCalled(); }); it('falls back to the deterministic dev token when the mock tokenizer throws', async () => { // tokenizeSavedCardWithVerification swallows a throwing MockCardForm // tokenizer (dev-mock convenience) and returns the deterministic fake // token — so the shared runner reports verified, never a dead-end. mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure')); const mod = await loadSquareInMockMode(); const outcomes: SavedCardVerificationOutcome[] = []; const res = await mod.runSavedCardSCAProactively({ amountPence: 5000, squareCardId: 'ccof:test-card', onOutcome: (o) => outcomes.push(o) }); expect(res.outcome).toBe('verified'); expect(res.verificationToken).toMatch(/^cnon:sca-/); expect(outcomes).toEqual(['verified']); }); it('mints saved-card SCA tokens the backend mock accepts (cnon:sca- prefix)', async () => { // The backend dev mock's saved-card SCA gate (square_dev.go // isSCATokenizeResultSource) only accepts a GENUINE tokenize-result — // a cnon: source carrying the `cnon:sca-` marker. The dev frontend's // deterministic token must match that prefix so the mock charge lands // in dev exactly as a real Square tokenize-result would. mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure')); const mod = await loadSquareInMockMode(); const res = await mod.runSavedCardSCAProactively({ amountPence: 5000, squareCardId: 'ccof:mock_123', onOutcome: () => {} }); expect(res.verificationToken).toMatch(/^cnon:sca-mock_5000_ok$/); }); }); describe('dev mock token-shape parity with the backend gate', () => { async function loadSquareInMockMode(): Promise { vi.resetModules(); vi.stubEnv('VITE_SQUARE_ENVIRONMENT', 'mock'); vi.stubEnv('VITE_SQUARE_APPLICATION_ID', ''); vi.stubEnv('VITE_SQUARE_LOCATION_ID', ''); vi.stubEnv('DEV', true); return await import('./square'); } afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); mockSCATokenize.fn.mockClear(); }); // The backend dev mock's isSCATokenizeResultSource accepts a cnon: source // ONLY when it carries the `cnon:sca-` marker (or the legacy verify_mock_ // transition shape). The frontend's deterministic fallback token must stay // inside that accepted set or dev saved-card charges fail at the mock. it('the deterministic token satisfies the backend isSCATokenizeResultSource predicate', async () => { mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure')); const mod = await loadSquareInMockMode(); const res = await mod.runSavedCardSCAProactively({ amountPence: 2500, squareCardId: 'ccof:1234567890', onOutcome: () => {} }); expect(res.outcome).toBe('verified'); // Predicate parity — the same test the backend mock runs: // isSCATokenizeResultSource = cnon:sca-* || verify_mock_*. expect(res.verificationToken).toMatch(/^cnon:sca-/); }); it('derives the token prefix from the first 4 chars of the ccof card id', async () => { mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure')); const mod = await loadSquareInMockMode(); const res = await mod.runSavedCardSCAProactively({ amountPence: 2500, squareCardId: 'ccof:1234567890', onOutcome: () => {} }); expect(res.verificationToken).toBe('cnon:sca-1234_2500_ok'); }); it('falls back to the test prefix when the card id yields no 4-char prefix', async () => { mockSCATokenize.fn.mockRejectedValue(new Error('SDK load failure')); const mod = await loadSquareInMockMode(); const res = await mod.runSavedCardSCAProactively({ amountPence: 2500, squareCardId: 'ccof:', onOutcome: () => {} }); expect(res.verificationToken).toBe('cnon:sca-test_2500_ok'); }); }); describe('backend 402 error-body parity (errors.go writeVerificationRequiredResponse)', () => { it('VERIFICATION_REQUIRED_MESSAGE matches the backend response text verbatim', () => { // backend/handlers/payments/errors.go writes: // {"error": "Your card issuer requires verification. Approve this // payment in your banking app.", "code": "verification_required"} // The frontend must key the SCA challenge on the byte-identical copy. expect(VERIFICATION_REQUIRED_MESSAGE).toBe( 'Your card issuer requires verification. Approve this payment in your banking app.' ); }); it('isVerificationRequiredSignal detects the exact structured 402 body the backend writes', () => { const backendBody = JSON.stringify({ error: 'Your card issuer requires verification. Approve this payment in your banking app.', code: 'verification_required' }); expect(isVerificationRequiredSignal(402, backendBody)).toBe(true); }); it('isVerificationRequiredSignal rejects a backend body carrying a different code', () => { const body = JSON.stringify({ error: 'Payment failed', code: 'card_declined' }); expect(isVerificationRequiredSignal(402, body)).toBe(false); }); }); // The twoFactorCode.svelte.ts composable is tested under Svelte 5 rune stubs: // this vitest config has no svelte plugin, so `$state` / `$derived` are // provided as plain-value globals (no reactivity — each composable instance is // a state snapshot). That still exercises the full imperative surface: the // consent accept/decline flags, the code setter, the mint selection and the // requestNewCode toast flow for every outcome. const toastMocks = vi.hoisted(() => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock('svelte-sonner', () => toastMocks); vi.mock('$lib/square/square', () => ({ requestNewTwoFactorCode: vi.fn(async () => ({ status: 200, ok: true, message: 'sent' })) })); describe('useTwoFactorCodeForSavedCard', () => { async function loadComposable() { vi.stubGlobal('$state', (v: unknown) => v); vi.stubGlobal('$derived', (v: unknown) => v); const mod = (await import('../stores/twoFactorCode.svelte')) as { useTwoFactorCodeForSavedCard: (options: { enabled: () => boolean; gateActive: () => boolean; mint?: () => Promise<{ status: number; ok: boolean; message: string }>; scaAvailable?: () => boolean; }) => { code: string; setCode: (v: string) => void; reveal: boolean; showInput: boolean; missing: boolean; requesting: boolean; requestNewCode: () => Promise; consentAccepted: boolean; acceptConsent: () => void; declineConsent: () => void; }; }; return mod; } afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it('starts in a clean state with the 2FA input hidden', async () => { const { useTwoFactorCodeForSavedCard } = await loadComposable(); const comp = useTwoFactorCodeForSavedCard({ enabled: () => true, gateActive: () => true }); expect(comp.showInput).toBe(false); expect(comp.missing).toBe(false); expect(comp.reveal).toBe(false); expect(comp.consentAccepted).toBe(false); expect(comp.code).toBe(''); expect(comp.requesting).toBe(false); }); it('still exposes the 2FA code state for account/admin uses (C6)', async () => { // The composable keeps the full code-state API even though charge // surfaces no longer auto-trigger it: code, setCode, reveal, missing, // requestNewCode and the consent flags all remain available. const { useTwoFactorCodeForSavedCard } = await loadComposable(); const comp = useTwoFactorCodeForSavedCard({ enabled: () => true, gateActive: () => true }); comp.setCode('123456'); expect(comp.code).toBe('123456'); comp.reveal = true; expect(comp.reveal).toBe(true); expect(comp.consentAccepted).toBe(false); comp.acceptConsent(); expect(comp.consentAccepted).toBe(true); }); it('an sca-unavailable outcome alone never surfaces the 2FA input (C6 refusal)', async () => { // C6: even when SCA is reported unavailable, the code input must NOT // appear without the explicit opt-in consent (or a backend reveal) — // the charge surfaces refuse instead, so no verification-code fallback // is ever offered off an SCA outcome on its own. const { useTwoFactorCodeForSavedCard } = await loadComposable(); const comp = useTwoFactorCodeForSavedCard({ enabled: () => true, gateActive: () => true, scaAvailable: () => false }); expect(comp.consentAccepted).toBe(false); expect(comp.showInput).toBe(false); expect(comp.missing).toBe(false); }); it('acceptConsent reveals the input; declineConsent hides it again', async () => { const { useTwoFactorCodeForSavedCard } = await loadComposable(); const comp = useTwoFactorCodeForSavedCard({ enabled: () => true, gateActive: () => true }); comp.acceptConsent(); expect(comp.reveal).toBe(true); expect(comp.consentAccepted).toBe(true); comp.declineConsent(); expect(comp.reveal).toBe(false); expect(comp.consentAccepted).toBe(false); }); it('setCode populates the code the caller later submits', async () => { const { useTwoFactorCodeForSavedCard } = await loadComposable(); const comp = useTwoFactorCodeForSavedCard({ enabled: () => true, gateActive: () => true }); comp.setCode('123456'); expect(comp.code).toBe('123456'); }); it('requestNewCode clears the code and toasts the server message on success', async () => { const { useTwoFactorCodeForSavedCard } = await loadComposable(); const mint = vi.fn(async () => ({ status: 200, ok: true, message: 'A new verification code has been sent.' })); const comp = useTwoFactorCodeForSavedCard({ enabled: () => true, gateActive: () => true, mint }); comp.setCode('654321'); await comp.requestNewCode(); expect(mint).toHaveBeenCalledTimes(1); expect(comp.code).toBe(''); expect(toastMocks.toast.success).toHaveBeenCalledWith('A new verification code has been sent.'); }); it('requestNewCode toasts the mint-cooldown error on 429', async () => { const { useTwoFactorCodeForSavedCard } = await loadComposable(); const mint = vi.fn(async () => ({ status: 429, ok: false, message: 'Too many requests. Wait before requesting a new code.' })); const comp = useTwoFactorCodeForSavedCard({ enabled: () => true, gateActive: () => true, mint }); await comp.requestNewCode(); expect(toastMocks.toast.error).toHaveBeenCalledWith( 'Too many requests. Wait before requesting a new code.' ); }); it('requestNewCode toasts the delivery-unavailable error on 503', async () => { const { useTwoFactorCodeForSavedCard } = await loadComposable(); const mint = vi.fn(async () => ({ status: 503, ok: false, message: 'Verification codes are unavailable right now. Try again later.' })); const comp = useTwoFactorCodeForSavedCard({ enabled: () => true, gateActive: () => true, mint }); await comp.requestNewCode(); expect(toastMocks.toast.error).toHaveBeenCalledWith( 'Verification codes are unavailable right now. Try again later.' ); }); it('requestNewCode defaults to the session mint when none is supplied', async () => { const { useTwoFactorCodeForSavedCard } = await loadComposable(); const comp = useTwoFactorCodeForSavedCard({ enabled: () => true, gateActive: () => true }); await comp.requestNewCode(); expect(toastMocks.toast.success).toHaveBeenCalled(); }); it('requestNewCode guards against concurrent mints', async () => { const { useTwoFactorCodeForSavedCard } = await loadComposable(); let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); const mint = vi.fn(async () => { await gate; return { status: 200, ok: true, message: 'slow' }; }); const comp = useTwoFactorCodeForSavedCard({ enabled: () => true, gateActive: () => true, mint }); const first = comp.requestNewCode(); const second = comp.requestNewCode(); release(); await Promise.all([first, second]); expect(mint).toHaveBeenCalledTimes(1); }); });