Files
Crussell/frontend/src/lib/square/square.test.ts
T
popertots b7122be3a0 fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement,
backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work.
ALL findings fixed, including every pre-existing red CI job:

GDPR (HIGH):
- anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors
  delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII)
  no longer survive registered-user account deletion; gdpr test added

BACKEND TEST GAPS (all 10):
- delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker
- twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper
- insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all
  6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors)
- CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip,
  token-less fallback + SCA-required (new terminal_sca_test.go)
- isVerificationRequiredError at all 5 charge sites (402 + code:verification_required)
- customer_initiated handler-level assertions (MIT false admin / CIT true customer)
- Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix,
  parseVerifyToken unit tests

FRONTEND SCA + Square-API (CRITICAL):
- tokenizeSavedCardWithVerification reads result.token (the verified token) not
  result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could
  never succeed in production before); parseTokenizeVerificationResult pure fn
  extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless
- HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token
  under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled
- challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show;
  'waiting for approval in your banking app' state on CIT surfaces
- sca-unavailable demotion resets per attempt; card selection disabled mid-challenge;
  genuine saved-card declines no longer relabeled 'requires verification';
  modal-close guard during processing; retry affordance standardized

PIPELINE (every red job now green):
- prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe)
- govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean
- race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic
- DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes)
- frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex),
  deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases

DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical
Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY,
Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified
against code everywhere

Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/
gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
2026-08-22 00:34:50 +01:00

662 lines
22 KiB
TypeScript

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