import { CBS_ERROR_CODES, CbsApiError, describeError, isCbsApiError, isCbsErrorCode, isClientErrorCode, isRetryableError, } from './cbsErrors'; describe('error code catalogue', () => { it('covers all 66 documented codes, with no duplicates', () => { expect(CBS_ERROR_CODES).toHaveLength(66); expect(new Set(CBS_ERROR_CODES).size).toBe(66); }); it('knows the TPIN codes, so none of them collapses into UNKNOWN_ERROR', () => { // The transfer and create-TPIN screens branch on each of these: a wrong // TPIN keeps the sheet up, a missing one sends the customer to create it, // and an existing one ends the create flow. expect(isCbsErrorCode('INVALID_TPIN')).toBe(true); expect(isCbsErrorCode('TPIN_NOT_SET')).toBe(true); expect(isCbsErrorCode('TPIN_ALREADY_SET')).toBe(true); }); it('does not invite a retry on a TPIN refusal', () => { // A wrong TPIN is the customer's to re-enter, not the identical request's // to repeat; the other two are states a retry cannot change. expect(isRetryableError('INVALID_TPIN')).toBe(false); expect(isRetryableError('TPIN_NOT_SET')).toBe(false); expect(isRetryableError('TPIN_ALREADY_SET')).toBe(false); }); it('does not invite a retry when the bank withholds a balance', () => { // The account exists and the bank simply will not price it. Repeating the // call gets the same refusal, so the card must not offer a retry. expect(isCbsErrorCode('ACCOUNT_BALANCE_NOT_AVAILABLE')).toBe(true); expect(isRetryableError('ACCOUNT_BALANCE_NOT_AVAILABLE')).toBe(false); }); it('knows the cheque book codes', () => { expect(isCbsErrorCode('CHEQUE_BOOK_NOT_AVAILABLE')).toBe(true); expect(isCbsErrorCode('CHEQUE_BOOK_REQUEST_DECLINED')).toBe(true); }); it('does not invite a retry on either cheque book refusal', () => { // Neither is transient. The account cannot take a cheque book at all, or // the bank understood the request and refused it — an identical retry gets // an identical answer, and on a paid product that is worse than useless. expect(isRetryableError('CHEQUE_BOOK_NOT_AVAILABLE')).toBe(false); expect(isRetryableError('CHEQUE_BOOK_REQUEST_DECLINED')).toBe(false); }); it('knows the statement codes', () => { expect(isCbsErrorCode('INVALID_DATE_RANGE')).toBe(true); expect(isCbsErrorCode('STATEMENT_VIEW_NOT_AVAILABLE')).toBe(true); expect(isCbsErrorCode('STATEMENT_PROFILE_INCOMPLETE')).toBe(true); }); it('does not invite a retry on either statement refusal', () => { // Both are the request's fault, not the bank's: one range is unusable and // the other view does not exist. Repeating the identical call gets the // identical answer, so the screen must offer a different range — or // nothing — rather than a retry. expect(isRetryableError('INVALID_DATE_RANGE')).toBe(false); expect(isRetryableError('STATEMENT_VIEW_NOT_AVAILABLE')).toBe(false); // The profile has to be corrected at the bank; retrying cannot help. expect(isRetryableError('STATEMENT_PROFILE_INCOMPLETE')).toBe(false); }); it('knows the payee-validate codes, which come from IMPS and not from CBS', () => { expect(isCbsErrorCode('IMPS_UNAVAILABLE')).toBe(true); expect(isCbsErrorCode('IMPS_INVALID_RESPONSE')).toBe(true); expect(isCbsErrorCode('IMPS_CONFIGURATION_ERROR')).toBe(true); }); it('invites a retry when the switch was unreachable, but not when it is misconfigured', () => { // Validate saves nothing, so a failed call is safe to repeat — unlike the // Positive Pay write below. A configuration fault is not the user's to fix. expect(isRetryableError('IMPS_UNAVAILABLE')).toBe(true); expect(isRetryableError('IMPS_INVALID_RESPONSE')).toBe(true); expect(isRetryableError('IMPS_CONFIGURATION_ERROR')).toBe(false); }); it('knows the Positive Pay codes', () => { expect(isCbsErrorCode('POSITIVE_PAY_NOT_AVAILABLE')).toBe(true); expect(isCbsErrorCode('CHEQUE_ALREADY_REGISTERED')).toBe(true); expect(isCbsErrorCode('POSITIVE_PAY_DECLINED')).toBe(true); }); it('knows the stop-payment codes', () => { expect(isCbsErrorCode('CHEQUE_NOT_FOUND')).toBe(true); expect(isCbsErrorCode('STOP_PAYMENT_NOT_AVAILABLE')).toBe(true); expect(isCbsErrorCode('CHEQUE_ALREADY_STOPPED')).toBe(true); expect(isCbsErrorCode('STOP_PAYMENT_DECLINED')).toBe(true); }); it('does not invite a retry on a stop-payment refusal either', () => { // Same reasoning as Positive Pay: stopping a cheque is a write. A second // attempt after a refusal changes nothing, and after a success it returns // CHEQUE_ALREADY_STOPPED — which reads like the customer's mistake. expect(isRetryableError('CHEQUE_NOT_FOUND')).toBe(false); expect(isRetryableError('CHEQUE_ALREADY_STOPPED')).toBe(false); expect(isRetryableError('STOP_PAYMENT_DECLINED')).toBe(false); expect(isRetryableError('STOP_PAYMENT_NOT_AVAILABLE')).toBe(false); }); it('does not invite a retry on a Positive Pay refusal', () => { // Registering a cheque is a write. A refusal is final, and offering "try // again" would only produce CHEQUE_ALREADY_REGISTERED or a duplicate. expect(isRetryableError('CHEQUE_ALREADY_REGISTERED')).toBe(false); expect(isRetryableError('POSITIVE_PAY_DECLINED')).toBe(false); expect(isRetryableError('POSITIVE_PAY_NOT_AVAILABLE')).toBe(false); }); const PPS_CODES = [ 'PPS_NOT_ENABLED', 'PPS_ALREADY_ENABLED', 'PPS_OTP_IN_PROGRESS', 'PPS_OTP_ALREADY_VERIFIED', 'PPS_OTP_RESEND_COOLDOWN', 'PPS_OTP_RESEND_LIMIT', 'PPS_TEMPORARILY_UNAVAILABLE', 'PPS_REQUEST_CONFLICT', 'PPS_CHEQUE_NOT_SUBMITTED', 'PPS_OPERATION_IN_PROGRESS', 'PPS_OPERATION_UNRESOLVED', 'PPS_SUBMIT_FAILED', 'PPS_STOP_FAILED', ]; it('knows every PPS code, so none of them collapses into UNKNOWN_ERROR', () => { // The interceptor maps an unrecognised code to UNKNOWN_ERROR, and the PPS // screens branch on each of these — a missing one would silently take the // unknown-outcome path instead of its own. for (const code of PPS_CODES) { expect(isCbsErrorCode(code)).toBe(true); } }); it('does not invite a retry on any PPS code', () => { // The cheque operations must never be resent automatically, and the // enrollment refusals describe a state an identical call cannot change. for (const code of PPS_CODES) { expect(isRetryableError(code)).toBe(false); } }); it('has copy for every code, so no failure renders as a blank toast', () => { for (const code of CBS_ERROR_CODES) { const copy = describeError(code); expect(copy.title.length).toBeGreaterThan(0); expect(copy.description.length).toBeGreaterThan(0); } }); it('recognises documented codes and rejects invented ones', () => { expect(isCbsErrorCode('MPIN_LOCKED')).toBe(true); expect(isCbsErrorCode('MPIN_UNLOCKED')).toBe(false); expect(isCbsErrorCode(undefined)).toBe(false); }); it('falls back to generic copy for an unknown or missing code', () => { expect(describeError('SOMETHING_NEW').title).toBe(describeError(undefined).title); }); }); describe('isRetryableError', () => { // Retrying these has a real chance of working: the bank was unreachable or a // breaker was open. it('marks upstream and transport failures retryable', () => { expect(isRetryableError('CBS_UNAVAILABLE')).toBe(true); expect(isRetryableError('CONNECTOR_UNAVAILABLE')).toBe(true); expect(isRetryableError('NETWORK_ERROR')).toBe(true); }); // Retrying these can only waste an attempt, and against MPIN_LOCKED there is // nothing to retry at all — the customer must visit a branch. it('does not mark user-input or policy failures retryable', () => { expect(isRetryableError('OTP_INVALID')).toBe(false); expect(isRetryableError('INVALID_CREDENTIALS')).toBe(false); expect(isRetryableError('MPIN_LOCKED')).toBe(false); expect(isRetryableError('VALIDATION_ERROR')).toBe(false); }); }); describe('CbsApiError', () => { it('carries the code and the validation field map', () => { const error = new CbsApiError({ status: 400, code: 'VALIDATION_ERROR', message: 'Request validation failed', fieldErrors: { mobileNumber: 'must be exactly 10 digits' }, }); expect(isCbsApiError(error)).toBe(true); expect(error.code).toBe('VALIDATION_ERROR'); expect(error.fieldErrors?.mobileNumber).toBe('must be exactly 10 digits'); expect(error.describe().title).toBe(describeError('VALIDATION_ERROR').title); }); it('is a real Error, so it survives being thrown and caught', () => { expect(() => { throw new CbsApiError({ status: 0, code: 'NETWORK_ERROR', message: 'offline' }); }).toThrow('offline'); }); it('does not mistake a plain object for one', () => { expect(isCbsApiError({ status: 400, code: 'OTP_INVALID', message: 'nope' })).toBe(false); }); }); describe('isClientErrorCode', () => { // These carry a transport detail, not a message anyone wrote for a customer. it('is true for the codes the client synthesises', () => { expect(isClientErrorCode('NETWORK_ERROR')).toBe(true); expect(isClientErrorCode('REQUEST_TIMEOUT')).toBe(true); expect(isClientErrorCode('UNKNOWN_ERROR')).toBe(true); }); it('is false for codes the backend names, whose message is worth showing', () => { expect(isClientErrorCode('OTP_INVALID')).toBe(false); expect(isClientErrorCode('VALIDATION_ERROR')).toBe(false); expect(isClientErrorCode(undefined)).toBe(false); }); });