Files
Crussell/frontend/src/lib/square/square.test.ts
T
popertotsandSisyphus 9a12a2d886 fix: round-3 — tip gate asymmetry, webhook VAT align + 503 notifications, cash-tip campaign overcharge, lockout DoS, erasure durability, S3 retry cap, env parsing, per-user rate limiters, consume dead code, frontend 2FA remnants
- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate
- webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table
- cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password
- lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env
- erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added
- env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency
- auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO
- frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

1380 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof SquareModule> {
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<string, string>)['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<string, string>)['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<string, string>)['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<string, string>)['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<typeof SquareModule> {
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<typeof SquareModule> {
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<void>;
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<void>((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);
});
});