Skip to content

Commit f9e62db

Browse files
mountinycursoragent
andcommitted
Expand unit coverage for currency sanitization edge cases
CurrencyUtilsTest additions: - isValidCurrencyCode: non-string inputs (number, boolean, object, array). - sanitizeCurrencyCode: whitespace/case normalization edge cases (tab/newline/internal-spaces, mixed case), explicit non-string inputs, log throttling (warn fires once per unique malformed value), and no-warn assertion when normalization recovers a valid code. - convertToShortDisplayString, convertAmountToDisplayString, and convertToDisplayStringWithoutCurrency now have malformed-currency fallback coverage matching convertToDisplayString. - convertToDisplayString: covers the shouldUseLocalCurrencySymbol branch with a malformed currency, and the undefined currency default path. New NumberFormatUtilsTest covers the try/catch safety net directly: - format/formatToParts succeed for valid currencies without warning. - format/formatToParts fall back to USD without throwing for malformed currencies (including the empty-string case via the 'currency' in options guard). - format/formatToParts do not swallow RangeErrors when the failing option is not currency (e.g. an unknown unit), preserving normal error propagation for unrelated bugs. - Verifies Intl's native case-insensitive handling of lowercase ISO codes ('usd') does not trigger the fallback path. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 14eea65 commit f9e62db

2 files changed

Lines changed: 165 additions & 4 deletions

File tree

tests/unit/CurrencyUtilsTest.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Onyx from 'react-native-onyx';
22
import CONST from '@src/CONST';
33
import IntlStore from '@src/languages/IntlStore';
44
import * as CurrencyUtils from '@src/libs/CurrencyUtils';
5+
import Log from '@src/libs/Log';
56
import ONYXKEYS from '@src/ONYXKEYS';
67
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
78
// This file can get outdated. In that case, you can follow these steps to update it:
@@ -203,6 +204,10 @@ describe('CurrencyUtils', () => {
203204
['US1', false],
204205
[undefined, false],
205206
[null, false],
207+
[42, false],
208+
[{}, false],
209+
[[], false],
210+
[true, false],
206211
])('isValidCurrencyCode(%p) → %p', (input, expected) => {
207212
expect(CurrencyUtils.isValidCurrencyCode(input)).toBe(expected);
208213
});
@@ -213,14 +218,50 @@ describe('CurrencyUtils', () => {
213218
expect(CurrencyUtils.sanitizeCurrencyCode('EUR')).toBe('EUR');
214219
});
215220

216-
test('normalizes whitespace and case before validating', () => {
217-
expect(CurrencyUtils.sanitizeCurrencyCode(' usd ')).toBe('USD');
218-
expect(CurrencyUtils.sanitizeCurrencyCode('eur')).toBe('EUR');
221+
test.each([
222+
[' usd ', 'USD'],
223+
['eur', 'EUR'],
224+
['UsD', 'USD'],
225+
['\tEUR', 'EUR'],
226+
['JPY\n', 'JPY'],
227+
[' GBP ', 'GBP'],
228+
])('normalizes whitespace and case: %p → %p', (input, expected) => {
229+
expect(CurrencyUtils.sanitizeCurrencyCode(input)).toBe(expected);
230+
});
231+
232+
test.each(['', 'XX', 'USDD', 'US1', '???', 'us-d', 'U S D'])('falls back to USD for malformed string %p', (input) => {
233+
expect(CurrencyUtils.sanitizeCurrencyCode(input)).toBe(CONST.CURRENCY.USD);
219234
});
220235

221-
test.each(['', 'XX', 'USDD', 'US1', '???'])('falls back to USD for malformed input %p', (input) => {
236+
test.each([undefined, null, 42, true, {}, []])('falls back to USD for non-string input %p', (input) => {
222237
expect(CurrencyUtils.sanitizeCurrencyCode(input)).toBe(CONST.CURRENCY.USD);
223238
});
239+
240+
test('logs a warning at most once per unique malformed value', () => {
241+
const warnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => undefined);
242+
try {
243+
// Use unique fixtures so other tests don't already mark these as warned.
244+
CurrencyUtils.sanitizeCurrencyCode('TEST_THROTTLE_A');
245+
CurrencyUtils.sanitizeCurrencyCode('TEST_THROTTLE_A');
246+
CurrencyUtils.sanitizeCurrencyCode('TEST_THROTTLE_A');
247+
expect(warnSpy).toHaveBeenCalledTimes(1);
248+
249+
CurrencyUtils.sanitizeCurrencyCode('TEST_THROTTLE_B');
250+
expect(warnSpy).toHaveBeenCalledTimes(2);
251+
} finally {
252+
warnSpy.mockRestore();
253+
}
254+
});
255+
256+
test('does not log a warning when normalization recovers a valid code', () => {
257+
const warnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => undefined);
258+
try {
259+
expect(CurrencyUtils.sanitizeCurrencyCode(' eur ')).toBe('EUR');
260+
expect(warnSpy).not.toHaveBeenCalled();
261+
} finally {
262+
warnSpy.mockRestore();
263+
}
264+
});
224265
});
225266

226267
describe('convertToDisplayString with malformed currency', () => {
@@ -232,6 +273,41 @@ describe('CurrencyUtils', () => {
232273
test('normalizes case-only variations to the intended currency instead of USD', () => {
233274
expect(CurrencyUtils.convertToDisplayString(2500, 'eur')).toBe(CurrencyUtils.convertToDisplayString(2500, 'EUR'));
234275
});
276+
277+
test('falls back to USD when shouldUseLocalCurrencySymbol is true and currency is malformed', () => {
278+
expect(() => CurrencyUtils.convertToDisplayString(2500, 'invalid', true)).not.toThrow();
279+
// USD has a known local symbol in the currencyList, so the local-symbol branch should produce a $-prefixed result.
280+
expect(CurrencyUtils.convertToDisplayString(2500, 'invalid', true)).toMatch(/\$/);
281+
});
282+
283+
test('handles undefined currency via the default parameter', () => {
284+
expect(CurrencyUtils.convertToDisplayString(2500, undefined)).toBe('$25.00');
285+
});
286+
});
287+
288+
describe('convertToShortDisplayString with malformed currency', () => {
289+
test.each(['', 'XX', 'USDD', '???'])('does not throw and falls back to USD formatting for %p', (input) => {
290+
expect(() => CurrencyUtils.convertToShortDisplayString(2500, input)).not.toThrow();
291+
expect(CurrencyUtils.convertToShortDisplayString(2500, input)).toBe('$25');
292+
});
293+
});
294+
295+
describe('convertAmountToDisplayString with malformed currency', () => {
296+
test.each(['', 'XX', 'USDD', '???'])('does not throw and falls back to USD formatting for %p', (input) => {
297+
expect(() => CurrencyUtils.convertAmountToDisplayString(2500, input)).not.toThrow();
298+
// The result should at least include a $ symbol from the USD fallback.
299+
expect(CurrencyUtils.convertAmountToDisplayString(2500, input)).toMatch(/\$/);
300+
});
301+
});
302+
303+
describe('convertToDisplayStringWithoutCurrency with malformed currency', () => {
304+
test.each(['', 'XX', 'USDD', '???'])('does not throw and produces a numeric output for %p', (input) => {
305+
expect(() => CurrencyUtils.convertToDisplayStringWithoutCurrency(2500, input)).not.toThrow();
306+
// Output should not contain a currency symbol but should contain the numeric portion.
307+
const result = CurrencyUtils.convertToDisplayStringWithoutCurrency(2500, input);
308+
expect(result).not.toMatch(/\$/);
309+
expect(result).toContain('25');
310+
});
235311
});
236312

237313
describe('getLocalizedCurrencySymbol with malformed currency', () => {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import CONST from '@src/CONST';
2+
import Log from '@src/libs/Log';
3+
import * as NumberFormatUtils from '@src/libs/NumberFormatUtils';
4+
5+
describe('NumberFormatUtils', () => {
6+
let warnSpy: jest.SpyInstance;
7+
8+
beforeEach(() => {
9+
warnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => undefined);
10+
});
11+
12+
afterEach(() => {
13+
warnSpy.mockRestore();
14+
});
15+
16+
describe('format', () => {
17+
test('formats a valid currency without entering the fallback path', () => {
18+
expect(NumberFormatUtils.format(CONST.LOCALES.EN, 25, {style: 'currency', currency: 'USD'})).toBe('$25.00');
19+
expect(warnSpy).not.toHaveBeenCalled();
20+
});
21+
22+
test('formats decimal style without a currency option', () => {
23+
expect(NumberFormatUtils.format(CONST.LOCALES.EN, 25.5, {style: 'decimal', minimumFractionDigits: 2})).toBe('25.50');
24+
expect(warnSpy).not.toHaveBeenCalled();
25+
});
26+
27+
test.each(['', 'XX', '1USD', 'US-D', '???'])('falls back to USD without throwing for malformed currency %p', (input) => {
28+
expect(() => NumberFormatUtils.format(CONST.LOCALES.EN, 25, {style: 'currency', currency: input})).not.toThrow();
29+
expect(NumberFormatUtils.format(CONST.LOCALES.EN, 25, {style: 'currency', currency: input})).toBe('$25.00');
30+
expect(warnSpy).toHaveBeenCalled();
31+
});
32+
33+
test('explicitly recovers from an empty-string currency (RangeError guard via "in" check)', () => {
34+
expect(() => NumberFormatUtils.format(CONST.LOCALES.EN, 25, {style: 'currency', currency: ''})).not.toThrow();
35+
expect(NumberFormatUtils.format(CONST.LOCALES.EN, 25, {style: 'currency', currency: ''})).toBe('$25.00');
36+
});
37+
38+
test('passes case-insensitive valid codes through without entering the fallback', () => {
39+
// Intl.NumberFormat accepts lowercase ISO 4217 codes natively (it normalizes case),
40+
// so no RangeError is thrown and the fallback should not run.
41+
expect(NumberFormatUtils.format(CONST.LOCALES.EN, 25, {style: 'currency', currency: 'usd'})).toBe('$25.00');
42+
expect(warnSpy).not.toHaveBeenCalled();
43+
});
44+
45+
test('does not swallow RangeErrors when options has no currency key', () => {
46+
// An invalid `unit` triggers a RangeError but the fallback only applies when `currency` is in options,
47+
// so this should still throw rather than being silently rewritten to USD.
48+
expect(() => NumberFormatUtils.format(CONST.LOCALES.EN, 25, {style: 'unit', unit: 'not-a-real-unit'} as Intl.NumberFormatOptions)).toThrow(RangeError);
49+
});
50+
51+
test('does not swallow RangeErrors when options is undefined', () => {
52+
expect(NumberFormatUtils.format(CONST.LOCALES.EN, 25)).toBe('25');
53+
expect(warnSpy).not.toHaveBeenCalled();
54+
});
55+
56+
test('uses the default locale when locale is undefined', () => {
57+
expect(NumberFormatUtils.format(undefined, 25, {style: 'currency', currency: 'USD'})).toBe('$25.00');
58+
});
59+
});
60+
61+
describe('formatToParts', () => {
62+
test('returns parts for a valid currency without entering the fallback path', () => {
63+
const parts = NumberFormatUtils.formatToParts(CONST.LOCALES.EN, 0, {style: 'currency', currency: 'USD'});
64+
expect(parts.find((part) => part.type === 'currency')?.value).toBe('$');
65+
expect(warnSpy).not.toHaveBeenCalled();
66+
});
67+
68+
test.each(['', 'XX', '1USD', 'US-D', '???'])('falls back to USD without throwing for malformed currency %p', (input) => {
69+
expect(() => NumberFormatUtils.formatToParts(CONST.LOCALES.EN, 0, {style: 'currency', currency: input})).not.toThrow();
70+
const parts = NumberFormatUtils.formatToParts(CONST.LOCALES.EN, 0, {style: 'currency', currency: input});
71+
expect(parts.find((part) => part.type === 'currency')?.value).toBe('$');
72+
expect(warnSpy).toHaveBeenCalled();
73+
});
74+
75+
test('explicitly recovers from an empty-string currency (RangeError guard via "in" check)', () => {
76+
expect(() => NumberFormatUtils.formatToParts(CONST.LOCALES.EN, 0, {style: 'currency', currency: ''})).not.toThrow();
77+
const parts = NumberFormatUtils.formatToParts(CONST.LOCALES.EN, 0, {style: 'currency', currency: ''});
78+
expect(parts.find((part) => part.type === 'currency')?.value).toBe('$');
79+
});
80+
81+
test('does not swallow RangeErrors when options has no currency key', () => {
82+
expect(() => NumberFormatUtils.formatToParts(CONST.LOCALES.EN, 0, {style: 'unit', unit: 'not-a-real-unit'} as Intl.NumberFormatOptions)).toThrow(RangeError);
83+
});
84+
});
85+
});

0 commit comments

Comments
 (0)