From f1b48c257082c39e2f9c5a8e95cd2cf0a4e5be21 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:51:43 +0000 Subject: [PATCH 1/2] Remove unsafe assertions from chart, country and payment flows Seatbelt-Operation: 0e9ad28d-53d1-4aa9-baac-7c8c97723582 --- src/components/AddressForm.tsx | 12 +- src/components/CountrySelector.tsx | 9 +- src/components/Search/SearchChartView.tsx | 6 +- src/components/Search/chartGroupByConfig.ts | 98 ++-- src/languages/IntlStore.ts | 8 +- src/libs/CountryUtils.ts | 23 +- src/libs/Localize/index.ts | 18 +- src/libs/actions/IOU/PayMoneyRequest.ts | 5 +- src/pages/AddressPage.tsx | 3 +- .../PersonalDetails/PersonalAddressPage.tsx | 5 +- src/types/onyx/IntroSelected.ts | 7 +- tests/actions/IOUTest/PayMoneyRequestTest.ts | 154 +++++- tests/ui/AddressPageTest.tsx | 517 +++++++++++++++++- .../ui/WorkspaceTravelAddressCountryTest.tsx | 281 ++++++++++ tests/unit/CountryUtils.test.ts | 114 +++- tests/unit/Search/chartGroupByConfigTest.ts | 214 +++++++- 16 files changed, 1392 insertions(+), 82 deletions(-) create mode 100644 tests/ui/WorkspaceTravelAddressCountryTest.tsx diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index a707afe9f413..fae0e64b53b1 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -29,14 +29,16 @@ type CountryZipRegex = { samples?: string; }; +const countryZipRegexData: Partial> = COMMON_CONST.COUNTRY_ZIP_REGEX_DATA; + type AddressFormID = typeof ONYXKEYS.FORMS.HOME_ADDRESS_FORM | typeof ONYXKEYS.FORMS.WORKSPACE_OFFICE_LOCATION_FORM; type AddressFormProps = { /** Address city field */ city?: string; - /** Address country field */ - country?: Country | ''; + /** Raw default or saved country. User selections and submitted values retain the finite form model. */ + country?: string; /** Address state field */ state?: string; @@ -98,7 +100,7 @@ type AddressFormProps = { /** The values the address inputs start from and report changes to, shared by AddressForm with its AddressForm.Fields */ type AddressFieldsSettings = { city: string; - country: Country | ''; + country: string; state: string; street1: string; street2: string; @@ -126,7 +128,7 @@ function AddressFormFields() { const {translate} = useLocalize(); const {city, country, state, street1, street2, zip, onAddressChanged, shouldSaveDraft, shouldHideCountrySelector} = useContext(AddressFieldsContext); - const zipSampleFormat = (country && (COMMON_CONST.COUNTRY_ZIP_REGEX_DATA[country] as CountryZipRegex)?.samples) ?? ''; + const zipSampleFormat = (country && countryZipRegexData[country]?.samples) ?? ''; const zipFormat = translate('common.zipCodeExampleFormat', zipSampleFormat); @@ -314,7 +316,7 @@ function AddressForm({ } // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object - const countryRegexDetails = (values.country ? COMMON_CONST.COUNTRY_ZIP_REGEX_DATA?.[values.country] : {}) as CountryZipRegex; + const countryRegexDetails = values.country ? countryZipRegexData?.[values.country] : {}; // The postal code system might not exist for a country, so no regex either for them. const countrySpecificZipRegex = countryRegexDetails?.regex; diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx index 6be512e0df20..8d99f0893a3a 100644 --- a/src/components/CountrySelector.tsx +++ b/src/components/CountrySelector.tsx @@ -2,11 +2,11 @@ import useGeographicalStateAndCountryFromRoute from '@hooks/useGeographicalState import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {translateCountry} from '@libs/Localize'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; -import type {Country} from '@src/CONST'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ComponentRef, ForwardedRef} from 'react'; @@ -24,7 +24,8 @@ type CountrySelectorProps = { /** Callback called when the country changes. */ onInputChange?: (value?: string) => void; - value?: Country | ''; + /** Persisted defaults may contain country names or unknown strings before a finite route selection. */ + value?: string; /** inputID used by the Form component */ // eslint-disable-next-line react/no-unused-prop-types @@ -38,10 +39,10 @@ type CountrySelectorProps = { function CountrySelector({errorText = '', value: countryCode, onInputChange = () => {}, onBlur, ref}: CountrySelectorProps) { const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {translate, preferredLocale} = useLocalize(); const {country: countryFromUrl} = useGeographicalStateAndCountryFromRoute(); - const title = countryCode ? translate(`allCountries.${countryCode}`) : ''; + const title = countryCode ? translateCountry(preferredLocale, countryCode) : ''; const countryTitleDescStyle = title.length === 0 ? styles.textNormal : null; const didOpenCountrySelector = useRef(false); diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index b2ed4a50c3dc..936d8990d80e 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -25,7 +25,7 @@ import type {ChartView, GroupedItem, SearchGroupBy, SearchQueryJSON} from './typ import {buildChartSeries} from './buildChartSeries'; import {buildChartDrillDownQuery} from './chartDrillDown'; -import CHART_GROUP_BY_CONFIG from './chartGroupByConfig'; +import CHART_GROUP_BY_CONFIG, {getChartFilterQuery} from './chartGroupByConfig'; import getInProgressBucketLabel from './getInProgressBucketLabel'; import {useSearchQueryContext} from './SearchContext'; @@ -89,7 +89,7 @@ function SearchChartView({ const {getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); const {currentSearchKey} = useSearchQueryContext(); - const {getLabel, getShortLabel, getLineChartLabel, getFilterQuery, getDateRange, axisLabelSteps} = CHART_GROUP_BY_CONFIG[groupBy]; + const {getLabel, getShortLabel, getLineChartLabel, getDateRange, axisLabelSteps} = CHART_GROUP_BY_CONFIG[groupBy]; const today = format(new Date(), CONST.DATE.FNS_FORMAT_STRING); const dateFilterRange = queryJSON ? getDateFilterRange(queryJSON) : {}; @@ -127,7 +127,7 @@ function SearchChartView({ return; } - const query = buildChartDrillDownQuery(queryJSON, getFilterQuery(item)); + const query = buildChartDrillDownQuery(queryJSON, getChartFilterQuery(item)); if (!query) { return; diff --git a/src/components/Search/chartGroupByConfig.ts b/src/components/Search/chartGroupByConfig.ts index ce188f09e56b..baae07fccfe0 100644 --- a/src/components/Search/chartGroupByConfig.ts +++ b/src/components/Search/chartGroupByConfig.ts @@ -2,24 +2,12 @@ import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; -import type { - TransactionCardGroupListItemType, - TransactionCategoryGroupListItemType, - TransactionMemberGroupListItemType, - TransactionMerchantGroupListItemType, - TransactionMonthGroupListItemType, - TransactionQuarterGroupListItemType, - TransactionTagGroupListItemType, - TransactionWeekGroupListItemType, - TransactionWithdrawalIDGroupListItemType, - TransactionYearGroupListItemType, -} from './SearchList/ListItem/types'; import type {GroupedItem, SearchGroupBy} from './types'; /** The dates a time bucket covers */ type ChartBucketRange = {start: string; end: string}; -type ChartGroupByConfig = { +type ChartGroupByConfig = { /** Name of the icon rendered next to the chart title */ titleIconName: 'Users' | 'CreditCard' | 'Send' | 'Folder' | 'Basket' | 'Tag' | 'Calendar'; @@ -36,47 +24,56 @@ type ChartGroupByConfig = { axisLabelSteps?: readonly number[]; /** Builds the query fragment appended to the current query to drill into a group's transactions */ - getFilterQuery: (item: GroupedItem) => string; + getFilterQuery: (item: TFilterItem) => string; /** Returns the dates a time-based group covers, as yyyy-MM-dd strings */ getDateRange?: (item: GroupedItem) => ChartBucketRange | undefined; }; +type ChartFilterItem = TGroupBy extends typeof CONST.SEARCH.GROUP_BY.WEEK + ? Extract + : TGroupBy extends typeof CONST.SEARCH.GROUP_BY.MONTH + ? Extract + : TGroupBy extends typeof CONST.SEARCH.GROUP_BY.QUARTER + ? Extract + : TGroupBy extends typeof CONST.SEARCH.GROUP_BY.YEAR + ? Extract + : GroupedItem; + /** * Chart-specific configuration for each groupBy type - defines how to extract label and build filter query * for displaying grouped transaction data in charts. */ -const CHART_GROUP_BY_CONFIG: Record = { +const CHART_GROUP_BY_CONFIG: {[TGroupBy in SearchGroupBy]: ChartGroupByConfig>} = { [CONST.SEARCH.GROUP_BY.FROM]: { titleIconName: 'Users', - getLabel: (item: GroupedItem) => (item as TransactionMemberGroupListItemType).formattedFrom ?? '', - getFilterQuery: (item: GroupedItem) => `from:${(item as TransactionMemberGroupListItemType).accountID}`, + getLabel: (item: GroupedItem) => ('formattedFrom' in item ? item.formattedFrom : undefined) ?? '', + getFilterQuery: (item: GroupedItem) => `from:${'accountID' in item ? item.accountID : undefined}`, }, [CONST.SEARCH.GROUP_BY.CARD]: { titleIconName: 'CreditCard', - getLabel: (item: GroupedItem) => (item as TransactionCardGroupListItemType).formattedCardName ?? '', - getFilterQuery: (item: GroupedItem) => `cardID:${(item as TransactionCardGroupListItemType).cardID}`, + getLabel: (item: GroupedItem) => ('formattedCardName' in item ? item.formattedCardName : undefined) ?? '', + getFilterQuery: (item: GroupedItem) => `cardID:${'cardID' in item ? item.cardID : undefined}`, }, [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: { titleIconName: 'Send', - // eslint-disable-next-line rulesdir/no-default-id-values -- formattedWithdrawalID is a display label, not an Onyx ID - getLabel: (item: GroupedItem) => (item as TransactionWithdrawalIDGroupListItemType).formattedWithdrawalID ?? '', - getFilterQuery: (item: GroupedItem) => `withdrawalID:${(item as TransactionWithdrawalIDGroupListItemType).entryID}`, + getLabel: (item: GroupedItem) => ('formattedWithdrawalID' in item ? item.formattedWithdrawalID : undefined) ?? '', + getFilterQuery: (item: GroupedItem) => `withdrawalID:${'entryID' in item ? item.entryID : undefined}`, }, [CONST.SEARCH.GROUP_BY.CATEGORY]: { titleIconName: 'Folder', - getLabel: (item: GroupedItem) => (item as TransactionCategoryGroupListItemType).formattedCategory ?? '', - getFilterQuery: (item: GroupedItem) => `category:"${(item as TransactionCategoryGroupListItemType).category}"`, + getLabel: (item: GroupedItem) => ('formattedCategory' in item ? item.formattedCategory : undefined) ?? '', + getFilterQuery: (item: GroupedItem) => `category:"${'category' in item ? item.category : undefined}"`, }, [CONST.SEARCH.GROUP_BY.MERCHANT]: { titleIconName: 'Basket', - getLabel: (item: GroupedItem) => (item as TransactionMerchantGroupListItemType).formattedMerchant ?? '', - getFilterQuery: (item: GroupedItem) => `merchant="${(item as TransactionMerchantGroupListItemType).merchant}"`, + getLabel: (item: GroupedItem) => ('formattedMerchant' in item ? item.formattedMerchant : undefined) ?? '', + getFilterQuery: (item: GroupedItem) => `merchant="${'merchant' in item ? item.merchant : undefined}"`, }, [CONST.SEARCH.GROUP_BY.TAG]: { titleIconName: 'Tag', - getLabel: (item: GroupedItem) => (item as TransactionTagGroupListItemType).formattedTag ?? '', - getFilterQuery: (item: GroupedItem) => `tag:"${(item as TransactionTagGroupListItemType).tag}"`, + getLabel: (item: GroupedItem) => ('formattedTag' in item ? item.formattedTag : undefined) ?? '', + getFilterQuery: (item: GroupedItem) => `tag:"${'tag' in item ? item.tag : undefined}"`, }, [CONST.SEARCH.GROUP_BY.DAY]: { titleIconName: 'Calendar', @@ -89,53 +86,66 @@ const CHART_GROUP_BY_CONFIG: Record = { }, [CONST.SEARCH.GROUP_BY.MONTH]: { titleIconName: 'Calendar', - getLabel: (item: GroupedItem) => (item as TransactionMonthGroupListItemType).formattedMonth ?? '', + getLabel: (item: GroupedItem) => ('formattedMonth' in item ? item.formattedMonth : undefined) ?? '', getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.MONTH ? item.shortFormattedMonth : undefined), axisLabelSteps: [1, 2, 3, 6], - getFilterQuery: (item: GroupedItem) => { - const monthItem = item as TransactionMonthGroupListItemType; - const {start, end} = DateUtils.getMonthDateRange(monthItem.year, monthItem.month); + getFilterQuery: (item: Extract) => { + const {start, end} = DateUtils.getMonthDateRange(item.year, item.month); return `date>=${start} date<=${end}`; }, getDateRange: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.MONTH ? DateUtils.getMonthDateRange(item.year, item.month) : undefined), }, [CONST.SEARCH.GROUP_BY.WEEK]: { titleIconName: 'Calendar', - getLabel: (item: GroupedItem) => (item as TransactionWeekGroupListItemType).formattedWeek ?? '', + getLabel: (item: GroupedItem) => ('formattedWeek' in item ? item.formattedWeek : undefined) ?? '', getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? item.shortFormattedWeek : undefined), getLineChartLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? item.lineChartFormattedWeek : undefined), axisLabelSteps: [1, 2, 4], - getFilterQuery: (item: GroupedItem) => { - const weekItem = item as TransactionWeekGroupListItemType; - const {start, end} = DateUtils.getWeekDateRange(weekItem.week); + getFilterQuery: (item: Extract) => { + const {start, end} = DateUtils.getWeekDateRange(item.week); return `date>=${start} date<=${end}`; }, getDateRange: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? DateUtils.getWeekDateRange(item.week) : undefined), }, [CONST.SEARCH.GROUP_BY.YEAR]: { titleIconName: 'Calendar', - getLabel: (item: GroupedItem) => (item as TransactionYearGroupListItemType).formattedYear ?? '', + getLabel: (item: GroupedItem) => ('formattedYear' in item ? item.formattedYear : undefined) ?? '', axisLabelSteps: [1, 2, 5], - getFilterQuery: (item: GroupedItem) => { - const yearItem = item as TransactionYearGroupListItemType; - const {start, end} = DateUtils.getYearDateRange(yearItem.year); + getFilterQuery: (item: Extract) => { + const {start, end} = DateUtils.getYearDateRange(item.year); return `date>=${start} date<=${end}`; }, getDateRange: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.YEAR ? DateUtils.getYearDateRange(item.year) : undefined), }, [CONST.SEARCH.GROUP_BY.QUARTER]: { titleIconName: 'Calendar', - getLabel: (item: GroupedItem) => (item as TransactionQuarterGroupListItemType).formattedQuarter ?? '', + getLabel: (item: GroupedItem) => ('formattedQuarter' in item ? item.formattedQuarter : undefined) ?? '', getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER ? item.shortFormattedQuarter : undefined), axisLabelSteps: [1, 2, 4], - getFilterQuery: (item: GroupedItem) => { - const quarterItem = item as TransactionQuarterGroupListItemType; - const {start, end} = DateUtils.getQuarterDateRange(quarterItem.year, quarterItem.quarter); + getFilterQuery: (item: Extract) => { + const {start, end} = DateUtils.getQuarterDateRange(item.year, item.quarter); return `date>=${start} date<=${end}`; }, getDateRange: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER ? DateUtils.getQuarterDateRange(item.year, item.quarter) : undefined), }, }; +/** Dispatches rows produced with the same grouping as the chart query. */ +function getChartFilterQuery(item: GroupedItem): string { + switch (item.groupedBy) { + case CONST.SEARCH.GROUP_BY.WEEK: + return CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.WEEK].getFilterQuery(item); + case CONST.SEARCH.GROUP_BY.MONTH: + return CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.MONTH].getFilterQuery(item); + case CONST.SEARCH.GROUP_BY.QUARTER: + return CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.QUARTER].getFilterQuery(item); + case CONST.SEARCH.GROUP_BY.YEAR: + return CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.YEAR].getFilterQuery(item); + default: + return CHART_GROUP_BY_CONFIG[item.groupedBy].getFilterQuery(item); + } +} + export default CHART_GROUP_BY_CONFIG; +export {getChartFilterQuery}; export type {ChartBucketRange}; diff --git a/src/languages/IntlStore.ts b/src/languages/IntlStore.ts index 72f136fd8e54..8ff2be3f9d02 100644 --- a/src/languages/IntlStore.ts +++ b/src/languages/IntlStore.ts @@ -278,12 +278,16 @@ class IntlStore { }); } - public static get(key: TPath, locale?: Locale) { + public static get(key: TPath, locale?: Locale): FlatTranslationsObject[TPath] | null; + + public static get(key: TranslationPaths | `allCountries.${string}`, locale?: Locale): FlatTranslationsObject[TranslationPaths] | null; + + public static get(key: TranslationPaths | `allCountries.${string}`, locale?: Locale): FlatTranslationsObject[TranslationPaths] | null { const localeToUse = locale && this.cache.has(locale) ? locale : this.currentLocale; if (!localeToUse) { return null; } - const translations = this.cache.get(localeToUse); + const translations: (FlatTranslationsObject & Partial>) | undefined = this.cache.get(localeToUse); return translations?.[key] ?? null; } } diff --git a/src/libs/CountryUtils.ts b/src/libs/CountryUtils.ts index cc4d6a2c3279..ae5ad88170f7 100644 --- a/src/libs/CountryUtils.ts +++ b/src/libs/CountryUtils.ts @@ -3,12 +3,21 @@ import type {Country} from '@src/CONST'; import type {PersonalDetailsForm} from '@src/types/form'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; +import {getObjectKeys} from './ObjectUtils'; + type AddressType = PersonalDetailsForm | Address | undefined; /** * Normalizes the address containing a country field by converting country names to country codes. * Handles the case where old data has "United States" instead of "US". */ +function normalizeCountryCode(data: PersonalDetailsForm): PersonalDetailsForm; +function normalizeCountryCode(data: Address): Address; +function normalizeCountryCode(data: undefined): undefined; +function normalizeCountryCode(data: PersonalDetailsForm | undefined): PersonalDetailsForm | undefined; +function normalizeCountryCode(data: Address | undefined): Address | undefined; +function normalizeCountryCode(data: PersonalDetailsForm | Address): PersonalDetailsForm | Address; +function normalizeCountryCode(data: AddressType): AddressType; function normalizeCountryCode(data: AddressType): AddressType { if (!data?.country) { return data; @@ -26,14 +35,18 @@ function normalizeCountryCode(data: AddressType): AddressType { }; } -function getCountryCode(countryValue: string | undefined): Country | undefined { - for (const [code, name] of Object.entries(CONST.ALL_COUNTRIES)) { - if (name === countryValue) { - return code as Country; +function getCountryCode(countryValue: Country | ''): Country | ''; +function getCountryCode(countryValue: Country | '' | undefined): Country | '' | undefined; +function getCountryCode(countryValue: string): string; +function getCountryCode(countryValue: string | undefined): string | undefined; +function getCountryCode(countryValue: string | undefined): string | undefined { + for (const code of getObjectKeys(CONST.ALL_COUNTRIES)) { + if (CONST.ALL_COUNTRIES[code] === countryValue) { + return code; } } - return countryValue as Country | undefined; + return countryValue; } export {normalizeCountryCode, getCountryCode}; diff --git a/src/libs/Localize/index.ts b/src/libs/Localize/index.ts index f9da82d6c820..b93ff194b431 100644 --- a/src/libs/Localize/index.ts +++ b/src/libs/Localize/index.ts @@ -10,6 +10,8 @@ import type {Locale} from '@src/types/onyx'; import * as RNLocalize from 'react-native-localize'; +type CountryTranslationPath = `allCountries.${string}`; + // Note: these Intl instances are created lazily, per locale, rather than at the top level of the file. Two reasons: // Intl is polyfilled, so touching it upon import would run before the polyfill is installed and barf; and an Intl // instance resolves its locale at construction, so one built before that locale's polyfill data has loaded is stuck @@ -34,7 +36,7 @@ const memoizedCreatePluralRules = memoize(createPluralRules); * phrase and stores the translated value in the cache and returns * the translated value. */ -function getTranslatedPhrase(language: Locale, phraseKey: TKey, ...parameters: TranslationParameters): string | null { +function getTranslatedPhrase(language: Locale, phraseKey: TKey | CountryTranslationPath, ...parameters: TranslationParameters): string | null { const translatedPhrase = IntlStore.get(phraseKey, language); if (translatedPhrase) { @@ -95,7 +97,7 @@ const memoizedGetTranslatedPhrase = memoize(getTranslatedPhrase, { * @param [locale] eg 'en', 'es' * @param [parameters] Parameters to supply if the phrase is a template literal. */ -function translate(locale: Locale | undefined, path: TPath, ...parameters: TranslationParameters): string { +function translatePhrase(locale: Locale | undefined, path: TPath | CountryTranslationPath, ...parameters: TranslationParameters): string { if (!locale) { // If no language is provided, return the path as is return Array.isArray(path) ? path.join('.') : path; @@ -116,6 +118,16 @@ function translate(locale: Locale | undefined, p throw new Error(`${path} was not found in the ${locale} locale`); } +/** Translates finite phrase keys with their original parameter contract. */ +function translate(locale: Locale | undefined, path: TPath, ...parameters: TranslationParameters): string { + return translatePhrase(locale, path, ...parameters); +} + +/** Keeps persisted country strings on the same locale, cache and missing-key path as other translations. */ +function translateCountry(locale: Locale | undefined, country: string): string { + return translatePhrase(locale, `allCountries.${country}`); +} + /** * Uses the locale in this file updated by the Onyx subscriber. * @deprecated This function uses imperative Onyx data access patterns, similar to `Onyx.connect`. Use `useLocalize` hook instead for reactive data access in React components. @@ -173,4 +185,4 @@ function getDevicePreferredLocale(): Locale { return RNLocalize.findBestLanguageTag(Object.values(CONST.LOCALES))?.languageTag ?? CONST.LOCALES.DEFAULT; } -export {translate, translateLocal, formatList, formatMessageElementList, getDevicePreferredLocale}; +export {translate, translateCountry, translateLocal, formatList, formatMessageElementList, getDevicePreferredLocale}; diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 3dd956001462..83f4769b9c5e 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -35,7 +35,6 @@ import type {BuildPolicyDataKeys} from '@userActions/Policy/Policy'; import {completeOnboarding} from '@userActions/Report'; import {notifyNewAction} from '@userActions/Report/reportActionSubscribers'; import {getOnboardingMessages} from '@userActions/Welcome/OnboardingFlow'; -import type {OnboardingCompanySize} from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -231,7 +230,7 @@ function getPayMoneyRequestParams({ introSelected, activePolicy, conciergeChat, - companySize: introSelected?.companySize as OnboardingCompanySize, + companySize: introSelected?.companySize, isSelfTourViewed, delegateAccountID, // This workspace is created by the invoice payment command, which does not apply CreatePolicy's @@ -867,7 +866,7 @@ function completePaymentOnboarding( onboardingPolicyID, paymentSelected, wasInvited: true, - companySize: introSelected?.companySize as OnboardingCompanySize, + companySize: introSelected?.companySize, introSelected, isSelfTourViewed, conciergeChat, diff --git a/src/pages/AddressPage.tsx b/src/pages/AddressPage.tsx index 926157b82536..79b5316db922 100644 --- a/src/pages/AddressPage.tsx +++ b/src/pages/AddressPage.tsx @@ -36,7 +36,8 @@ type AddressPageProps = { */ shouldRequireZip?: boolean; - defaultCountry?: Country; + /** Raw IP-derived default used only when the saved country is nullish at mount. */ + defaultCountry?: string; } & BackToParams; function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo, defaultCountry, shouldRequireZip}: AddressPageProps) { diff --git a/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx b/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx index 6dbc276d30f5..76e797d159a2 100644 --- a/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx @@ -9,7 +9,6 @@ import {getCurrentAddress} from '@libs/PersonalDetailsUtils'; import AddressPage from '@pages/AddressPage'; import type {FormOnyxValues} from '@src/components/Form/types'; -import type {Country} from '@src/CONST'; import {updateAddress as updateAddressPersonalDetails} from '@src/libs/actions/PersonalDetails'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; @@ -39,14 +38,14 @@ function PersonalAddressPage() { const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [defaultCountry, defaultCountryStatus] = useOnyx(ONYXKEYS.COUNTRY); const isLoading = isLoadingOnyxValue(defaultCountryStatus); - const address = useMemo(() => normalizeCountryCode(getCurrentAddress(privatePersonalDetails)) as Address, [privatePersonalDetails]); + const address = useMemo(() => normalizeCountryCode(getCurrentAddress(privatePersonalDetails)), [privatePersonalDetails]); if (isLoading) { return ; } return ( updateAddress(values, privatePersonalDetails?.addresses ?? [])} diff --git a/src/types/onyx/IntroSelected.ts b/src/types/onyx/IntroSelected.ts index 2c6015ec83b2..23222762fab3 100644 --- a/src/types/onyx/IntroSelected.ts +++ b/src/types/onyx/IntroSelected.ts @@ -1,5 +1,8 @@ +import type CONST from '@src/CONST'; import type {OnboardingInvite} from '@src/CONST'; +import type {ValueOf} from 'type-fest'; + import type {OnboardingPurpose} from './index'; /** The tasks of IntroSelected model */ @@ -28,8 +31,8 @@ type IntroSelected = { /** Task reportID for 'createWorkspace' type */ createWorkspace?: string; - /** Company size selected during onboarding */ - companySize?: string; + /** Includes retained legacy ranges that are no longer offered by the onboarding UI. */ + companySize?: ValueOf; /** Task reportID for 'setupCategories' type */ setupCategories?: string; diff --git a/tests/actions/IOUTest/PayMoneyRequestTest.ts b/tests/actions/IOUTest/PayMoneyRequestTest.ts index ae82c5e32495..1279b3e06297 100644 --- a/tests/actions/IOUTest/PayMoneyRequestTest.ts +++ b/tests/actions/IOUTest/PayMoneyRequestTest.ts @@ -1,7 +1,8 @@ import {putOnHold} from '@libs/actions/IOU/Hold'; -import {cancelPayment, completePaymentOnboarding, markReportPaymentReceived, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; +import {cancelPayment, completePaymentOnboarding, markReportPaymentReceived, payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; import {requestMoney} from '@libs/actions/IOU/TrackExpense'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import * as PolicyActions from '@libs/actions/Policy/Policy'; import {createWorkspace, generatePolicyID} from '@libs/actions/Policy/Policy'; import {notifyNewAction} from '@libs/actions/Report/reportActionSubscribers'; import * as APIActions from '@libs/API'; @@ -37,7 +38,16 @@ import {createRandomReport} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; import createMock from '../../utils/createMock'; import getOnyxValue from '../../utils/getOnyxValue'; -import {createGlobalFetchMock, formatPhoneNumber, getCurrencyDecimalsLocal, getOnyxData, getRequiredOnyxUpdate, getRequiredWriteCall, translateLocal} from '../../utils/TestHelper'; +import { + createGlobalFetchMock, + formatPhoneNumber, + getCurrencyDecimalsLocal, + getOnyxData, + getRequiredOnyxUpdate, + getRequiredOnyxUpdates, + getRequiredWriteCall, + translateLocal, +} from '../../utils/TestHelper'; import {readProperty, requireRecordArrayProperty, requireStringProperty} from '../../utils/typeGuards'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -125,7 +135,7 @@ describe('actions/IOU/PayMoneyRequest', () => { beforeEach(() => { jest.clearAllTimers(); mockFetch = createGlobalFetchMock(); - global.fetch = mockFetch; + globalThis.fetch = mockFetch; return Onyx.clear().then(waitForBatchedUpdates); }); @@ -2773,6 +2783,128 @@ describe('actions/IOU/PayMoneyRequest', () => { }); }); + describe('invoice workspace company size', () => { + const introVariants: IntroSelected[] = [...Object.values(CONST.ONBOARDING_COMPANY_SIZE).map((companySize) => ({companySize})), {}, {companySize: undefined}]; + + const previousPolicy = createMock({id: 'previous-policy', role: CONST.POLICY.ROLE.USER, type: CONST.POLICY.TYPE.TEAM}); + const workspaceCases = introVariants.flatMap((introSelected) => [ + {introSelected, activePolicy: undefined}, + {introSelected, activePolicy: previousPolicy}, + ]); + + it.each(workspaceCases)('forwards $introSelected.companySize through the real invoice workspace builder with $activePolicy', async ({introSelected, activePolicy}) => { + // Given an individual invoice receiver without an eligible payer workspace. + await Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); + await waitForBatchedUpdates(); + const chatReport = createMock({ + reportID: 'invoice-room', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.INVOICE, + invoiceReceiver: {type: CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL, accountID: CARLOS_ACCOUNT_ID}, + }); + const invoiceReport = createMock({ + reportID: 'invoice-report', + type: CONST.REPORT.TYPE.INVOICE, + ownerAccountID: RORY_ACCOUNT_ID, + currency: CONST.CURRENCY.USD, + total: 100, + nonReimbursableTotal: 0, + }); + const args: Parameters[0] = { + paymentMethodType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + chatReport, + invoiceReport, + // This workspace-forwarding case does not exercise per-transaction unhold or violation updates. + invoiceReportTransactions: [], + introSelected, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + currentUserLocalCurrency: CONST.CURRENCY.USD, + payAsBusiness: true, + activePolicy, + conciergeChat: undefined, + isASAPSubmitBetaEnabled: false, + isSelfTourViewed: false, + defaultWorkspaceName: 'Invoice workspace', + chatReportActions: undefined, + shouldPlaySuccessSound: false, + delegateAccountID: undefined, + isTrackIntentUser: false, + getCurrencyDecimals: getCurrencyDecimalsLocal, + bankAccountList: undefined, + rules: undefined, + }; + const builderSpy = jest.spyOn(PolicyActions, 'buildPolicyData'); + const writeSpy = jest.spyOn(APIActions, 'write').mockResolvedValue(undefined); + + try { + // When the exported invoice action executes, the builder spy calls through without a replacement. + payInvoice(args); + + // Then every current/legacy size and both absence shapes reach the actual builder unchanged. + expect(builderSpy).toHaveBeenCalledTimes(1); + expect(builderSpy.mock.calls.at(0)?.[0]).toEqual(expect.objectContaining({companySize: introSelected.companySize, introSelected})); + const builderResult = builderSpy.mock.results.at(0); + if (!builderResult || builderResult.type !== 'return') { + throw new Error('Expected the real invoice workspace builder to return.'); + } + const built = builderResult.value; + expect(built.params.companySize).toBe(introSelected.companySize); + const [command, params, onyxData] = getRequiredWriteCall(writeSpy.mock.calls); + expect(command).toBe(WRITE_COMMANDS.PAY_INVOICE); + expect(params).toEqual( + expect.objectContaining({ + reportID: invoiceReport.reportID, + payAsBusiness: true, + policyID: built.params.policyID, + policyName: 'Invoice workspace', + ownerEmail: CARLOS_EMAIL, + adminsChatReportID: built.params.adminsChatReportID, + expenseChatReportID: built.params.expenseChatReportID, + customUnitID: built.params.customUnitID, + customUnitRateID: built.params.customUnitRateID, + }), + ); + expect(params).not.toHaveProperty('companySize'); + expect(params).not.toHaveProperty('guidedSetupData'); + const activeOptimistic = {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, value: built.params.policyID}; + const activeFailure = {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, value: activePolicy?.id ?? null}; + const optimisticData = getRequiredOnyxUpdates(onyxData, 'optimisticData'); + const failureData = getRequiredOnyxUpdates(onyxData, 'failureData'); + const successData = getRequiredOnyxUpdates(onyxData, 'successData'); + expect(optimisticData.slice(0, (built.optimisticData?.length ?? 0) + 1)).toEqual([...(built.optimisticData ?? []), activeOptimistic]); + expect(failureData.slice(0, (built.failureData?.length ?? 0) + 1)).toEqual([...(built.failureData ?? []), activeFailure]); + expect(successData.slice(0, built.successData?.length ?? 0)).toEqual(built.successData ?? []); + expect(getRequiredOnyxUpdate(onyxData, 'optimisticData', `${ONYXKEYS.COLLECTION.POLICY}${built.params.policyID}`, Onyx.METHOD.SET, true).value).toEqual( + expect.objectContaining({id: built.params.policyID, name: 'Invoice workspace'}), + ); + expect(onyxData.failureData).toEqual(expect.arrayContaining([activeFailure])); + expect(onyxData.successData).toEqual(expect.arrayContaining(built.successData ?? [])); + + // When business payment is disabled, the same invoice does not create a workspace. + builderSpy.mockClear(); + writeSpy.mockClear(); + payInvoice({...args, payAsBusiness: false}); + expect(builderSpy).not.toHaveBeenCalled(); + expect(getRequiredWriteCall(writeSpy.mock.calls)[0]).toBe(WRITE_COMMANDS.PAY_INVOICE); + + // Then an existing eligible admin workspace also bypasses creation and remains the payer. + writeSpy.mockClear(); + payInvoice({...args, activePolicy: createMock({id: 'eligible-policy', role: CONST.POLICY.ROLE.ADMIN, type: CONST.POLICY.TYPE.TEAM})}); + expect(builderSpy).not.toHaveBeenCalled(); + const [existingCommand, existingParams, existingData] = getRequiredWriteCall(writeSpy.mock.calls); + expect(existingCommand).toBe(WRITE_COMMANDS.PAY_INVOICE); + expect(existingParams).not.toHaveProperty('policyID'); + expect(getRequiredOnyxUpdate(existingData, 'optimisticData', `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, Onyx.METHOD.MERGE, true).value).toEqual( + expect.objectContaining({invoiceReceiver: {type: CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, policyID: 'eligible-policy'}}), + ); + } finally { + builderSpy.mockRestore(); + writeSpy.mockRestore(); + } + }); + }); + describe('completePaymentOnboarding', () => { let completeOnboardingSpy: jest.SpyInstance; @@ -2796,6 +2928,22 @@ describe('actions/IOU/PayMoneyRequest', () => { completeOnboardingSpy.mockRestore(); }); + it.each([...Object.values(CONST.ONBOARDING_COMPANY_SIZE), undefined])('preserves company size %s at the onboarding seam', (companySize) => { + // Given a real invite selection for every current and retained legacy company-size constant. + const introSelected: IntroSelected = { + choice: CONST.ONBOARDING_CHOICES.SUBMIT, + inviteType: CONST.ONBOARDING_INVITE_TYPES.IOU, + companySize, + }; + + // When the real onboarding gate passes an IOU payment through. + completePaymentOnboarding(CONST.PAYMENT_SELECTED.BBA, introSelected, false, CARLOS_ACCOUNT_ID, undefined, undefined); + + // Then no range is filtered or defaulted, including explicit absence. + expect(completeOnboardingSpy).toHaveBeenCalledTimes(1); + expect(completeOnboardingSpy).toHaveBeenCalledWith(expect.objectContaining({companySize, introSelected, engagementChoice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM})); + }); + it('should not call completeOnboarding when introSelected is undefined', () => { completePaymentOnboarding(CONST.PAYMENT_SELECTED.BBA, undefined, false, CARLOS_ACCOUNT_ID, undefined, undefined); expect(completeOnboardingSpy).not.toHaveBeenCalled(); diff --git a/tests/ui/AddressPageTest.tsx b/tests/ui/AddressPageTest.tsx index 2ea54772111a..1309d5200f98 100644 --- a/tests/ui/AddressPageTest.tsx +++ b/tests/ui/AddressPageTest.tsx @@ -1,26 +1,41 @@ -import {act, render, screen} from '@testing-library/react-native'; +import {act, fireEvent, render, renderHook, screen} from '@testing-library/react-native'; +import * as AddressFormModule from '@components/AddressForm'; import ComposeProviders from '@components/ComposeProviders'; +import * as FormWrapperModule from '@components/Form/FormWrapper'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; +import useOnyx from '@hooks/useOnyx'; +import * as API from '@libs/API'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import {translate, translateCountry} from '@libs/Localize'; +import Log from '@libs/Log'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import PersonalAddressPage from '@pages/settings/Profile/PersonalDetails/PersonalAddressPage'; +import Config from '@src/CONFIG'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {GestureResponderEvent} from 'react-native'; + import {PortalProvider} from '@gorhom/portal'; import {NavigationContainer} from '@react-navigation/native'; import Onyx from 'react-native-onyx'; +import createMock from '../utils/createMock'; import * as TestHelper from '../utils/TestHelper'; +import {getRequiredWriteCall} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; jest.mock('@rnmapbox/maps', () => { @@ -31,6 +46,8 @@ jest.mock('@rnmapbox/maps', () => { }; }); +type AddressWriteSpy = jest.SpyInstance, Parameters>; + const Stack = createPlatformStackNavigator(); const renderPage = (initialRouteName: typeof SCREENS.SETTINGS.PROFILE.ADDRESS) => { @@ -51,6 +68,7 @@ const renderPage = (initialRouteName: typeof SCREENS.SETTINGS.PROFILE.ADDRESS) = }; describe('AddressPageTest', () => { + let writeSpy: AddressWriteSpy; beforeAll(async () => { Onyx.init({ keys: ONYXKEYS, @@ -61,7 +79,23 @@ describe('AddressPageTest', () => { }); await waitForBatchedUpdatesWithAct(); }); + beforeEach(async () => { + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); + await IntlStore.load(CONST.LOCALES.EN); + }); + await waitForBatchedUpdatesWithAct(); + writeSpy = jest.spyOn(API, 'write'); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + it('should not reset state', async () => { + // Given a saved state and country that already match the returning selector route. await TestHelper.signInWithTestUser(); await act(async () => { await Onyx.merge(`${ONYXKEYS.PRIVATE_PERSONAL_DETAILS}`, { @@ -78,6 +112,7 @@ describe('AddressPageTest', () => { await waitForBatchedUpdatesWithAct(); + // When the real page mounts from those saved values. renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); await waitForBatchedUpdatesWithAct(); @@ -87,11 +122,13 @@ describe('AddressPageTest', () => { country: 'VN', }); await waitForBatchedUpdatesWithAct(); + // Then a same-country selection retains the existing state. const stateInputAfterParams = screen.getByLabelText('State / Province'); expect(stateInputAfterParams.props.value).toEqual('Test'); }); it('should prefill address line 2 from explicit street2', async () => { + // Given both an explicit second line and a legacy newline fallback. await TestHelper.signInWithTestUser(); await act(async () => { await Onyx.merge(`${ONYXKEYS.PRIVATE_PERSONAL_DETAILS}`, { @@ -108,13 +145,16 @@ describe('AddressPageTest', () => { await waitForBatchedUpdatesWithAct(); + // When the real page reads the saved address. renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); await waitForBatchedUpdatesWithAct(); + // Then the explicit second line takes precedence. expect(screen.getByDisplayValue('Suite 500')).toBeDefined(); }); it('should prefill address line 2 from legacy newline when street2 is missing', async () => { + // Given an address that still stores the second line after a newline. await TestHelper.signInWithTestUser(); await act(async () => { await Onyx.merge(`${ONYXKEYS.PRIVATE_PERSONAL_DETAILS}`, { @@ -130,9 +170,484 @@ describe('AddressPageTest', () => { await waitForBatchedUpdatesWithAct(); + // When the real page reads the saved address. renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); await waitForBatchedUpdatesWithAct(); + // Then the historical second line remains visible. expect(screen.getByDisplayValue('Legacy Suite')).toBeDefined(); }); + + it('uses the current saved address before the last entry and the default country', async () => { + // Given a current Canadian address before a later US entry and a Vietnamese IP default. + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.set(ONYXKEYS.COUNTRY, 'VN'); + await Onyx.set(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, { + addresses: [ + {street: 'Current street', country: 'CA', state: 'Ontario', current: true}, + {street: 'Last street', country: 'US', state: 'NY'}, + ], + }); + }); + + // When the real personal-address page selects and normalizes the saved address. + renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); + await waitForBatchedUpdatesWithAct(); + + // Then saved data takes precedence over both the last entry and the IP default. + expect(screen.getByText('Canada')).toBeDefined(); + expect(screen.getByDisplayValue('Current street')).toBeDefined(); + expect(screen.getByLabelText('State / Province').props.value).toBe('Ontario'); + }); + + it('normalizes a legacy saved country name in the real personal-address consumer', async () => { + // Given the historical address representation accepted by the normalizer boundary. + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.set(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, { + addresses: [ + { + street: 'Legacy street', + // @ts-expect-error - This exact legacy country-name field predates the finite Address model. + country: 'Canada', + state: 'Ontario', + }, + ], + }); + }); + + // When the actual page loads the persisted legacy address. + renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); + await waitForBatchedUpdatesWithAct(); + + // Then the original country-name normalization still supplies the selector label. + expect(screen.getByText('Canada')).toBeDefined(); + expect(screen.getByLabelText('State / Province').props.value).toBe('Ontario'); + }); + + it('preserves saved-empty precedence over a nonempty default', async () => { + // Given an explicitly empty saved country rather than an absent saved address. + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.set(ONYXKEYS.COUNTRY, 'US'); + await Onyx.set(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {addresses: [{street: 'Saved street', country: ''}]}); + }); + + // When the page initializes from saved country ?? default. + renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); + await waitForBatchedUpdatesWithAct(); + + // Then the empty value wins and retains the non-US state input. + expect(screen.queryByText('United States')).toBeNull(); + expect(screen.getByLabelText('State / Province')).toBeDefined(); + }); + + it('returns undefined with loading metadata while the real country merge is pending', async () => { + // Given an uncached COUNTRY key with an actual pending Onyx merge. + const mergePromise = Onyx.merge(ONYXKEYS.COUNTRY, 'CA'); + + // When the production hook subscribes before that merge reaches the cache. + const {result} = renderHook(() => useOnyx(ONYXKEYS.COUNTRY)); + + // Then loading blanks the value until the same real merge completes. + expect(result.current[1].status).toBe('loading'); + expect(result.current[0]).toBeUndefined(); + await act(async () => { + await mergePromise; + }); + await waitForBatchedUpdatesWithAct(); + expect(result.current[1].status).toBe('loaded'); + expect(result.current[0]).toBe('CA'); + }); + + it('retains actual COUNTRY hook absence, raw strings and deletion-to-undefined', async () => { + // Given the custom production hook without a COUNTRY selector or a snapshot-compatible key. + const {result} = renderHook(() => useOnyx(ONYXKEYS.COUNTRY)); + await waitForBatchedUpdatesWithAct(); + expect(result.current[0]).toBeUndefined(); + expect(result.current[1].status).toBe('loaded'); + + // When legitimate persisted strings and the Onyx deletion value reach the installed hook. + for (const country of ['', 'US', 'United States', 'unknown-country']) { + await act(async () => { + await Onyx.set(ONYXKEYS.COUNTRY, country); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the hook preserves all strings without finite-country coercion. + expect(result.current[0]).toBe(country); + expect(result.current[1].status).toBe('loaded'); + } + await act(async () => { + await Onyx.set(ONYXKEYS.COUNTRY, null); + }); + await waitForBatchedUpdatesWithAct(); + expect(result.current[0]).toBeUndefined(); + expect(result.current[1].status).toBe('loaded'); + }); + + it('keeps registered empty defaults when a saved address hydrates the mounted page', async () => { + // Given no saved address and a Canadian default when the actual form first registers its empty fields. + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.set(ONYXKEYS.COUNTRY, 'CA'); + }); + const formSpy = jest.spyOn(AddressFormModule, 'default'); + writeSpy.mockClear().mockResolvedValue(undefined); + try { + renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText('Canada')).toBeDefined(); + + // When only the IP default changes or is deleted, the mounted country is not reinitialized. + await act(async () => { + await Onyx.set(ONYXKEYS.COUNTRY, 'VN'); + }); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText('Canada')).toBeDefined(); + await act(async () => { + await Onyx.set(ONYXKEYS.COUNTRY, null); + }); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText('Canada')).toBeDefined(); + await act(async () => { + await Onyx.set(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {addresses: [{street: 'Saved street', country: 'VN', state: 'Province', city: 'City', zip: '133836'}]}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then parent defaults change while already registered empty street/city/ZIP values retain. + expect(formSpy.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({country: 'VN', state: 'Province', street1: 'Saved street', city: 'City', zip: '133836'})); + expect(screen.getByText('Vietnam')).toBeDefined(); + expect(screen.getByLabelText('State / Province').props.value).toBe('Province'); + expect(screen.getByLabelText('Address line 1').props.value).toBe(''); + expect(screen.getByLabelText('City').props.value).toBe(''); + expect(screen.getByLabelText('Zip / Postcode').props.value).toBe(''); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + expect(writeSpy).not.toHaveBeenCalled(); + } finally { + writeSpy.mockRestore(); + formSpy.mockRestore(); + } + }); + + it('initializes registered fields from an address already saved before a fresh mount', async () => { + // Given the same valid saved address before any form input has registered. + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.set(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {addresses: [{street: 'Saved street', country: 'VN', state: 'Province', city: 'City', zip: '133836'}]}); + }); + + // When a new page registers its input defaults from that saved address. + renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); + await waitForBatchedUpdatesWithAct(); + + // Then defaults initialize missing registered values, separately from mounted hydration. + expect(screen.getByDisplayValue('Saved street')).toBeDefined(); + expect(screen.getByText('Vietnam')).toBeDefined(); + expect(screen.getByLabelText('State / Province').props.value).toBe('Province'); + expect(screen.getByLabelText('City').props.value).toBe('City'); + expect(screen.getByLabelText('Zip / Postcode').props.value).toBe('133836'); + }); + + it('submits retained registered street and city after populated saved-address hydration', async () => { + // Given populated defaults registered by a fresh Canadian address form. + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.set(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, { + addresses: [{street: 'Old street', street2: 'Old suite', country: 'CA', state: 'Old state', city: 'Toronto', zip: 'M5V 2T6'}], + }); + }); + const formSpy = jest.spyOn(AddressFormModule, 'default'); + writeSpy.mockClear().mockResolvedValue(undefined); + const wrapperSpy = jest.spyOn(FormWrapperModule, 'default'); + try { + renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); + await waitForBatchedUpdatesWithAct(); + + // When new saved data hydrates parent props without remounting or resetting the actual form. + await act(async () => { + await Onyx.set(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, { + addresses: [{street: 'New street', street2: 'New suite', country: 'VN', state: 'Province', city: 'New city', zip: '133836'}], + }); + }); + await waitForBatchedUpdatesWithAct(); + expect(formSpy.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({country: 'VN', state: 'Province', street1: 'New street', street2: 'New suite', city: 'New city', zip: '133836'}), + ); + expect(screen.getByText('Vietnam')).toBeDefined(); + expect(screen.getByLabelText('State / Province').props.value).toBe('Province'); + expect(screen.getByDisplayValue('Old street')).toBeDefined(); + expect(screen.getByDisplayValue('Old suite')).toBeDefined(); + expect(screen.getByLabelText('City').props.value).toBe('Toronto'); + expect(screen.getByLabelText('Zip / Postcode').props.value).toBe('M5V 2T6'); + // Control the real form's 1,000 ms submit debounce because setupAfterEnv uses real timers. + jest.useFakeTimers({doNotFake: ['nextTick', 'setImmediate']}); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + expect(writeSpy).not.toHaveBeenCalled(); + fireEvent.changeText(screen.getByLabelText('Zip / Postcode'), '133836'); + await waitForBatchedUpdatesWithAct(); + await act(async () => { + // Allow the existing failed-submit debounce to expire before retrying. + jest.advanceTimersByTime(1000); + }); + // Observe the real validator's errors before the retry, rather than assuming the edited form is valid. + expect(wrapperSpy.mock.calls.at(-1)?.[0].errors).toEqual({}); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + + // Then the real action submits registered street/city with hydrated country/state and edited ZIP. + const [command, params] = getRequiredWriteCall(writeSpy.mock.calls); + expect(command).toBe(WRITE_COMMANDS.UPDATE_HOME_ADDRESS); + expect(params).toEqual({ + homeAddressStreet: 'Old street', + addressStreet2: 'Old suite', + homeAddressCity: 'Toronto', + addressState: 'Province', + addressStateLong: 'Province', + addressZipCode: '133836', + addressCountry: 'VN', + }); + } finally { + jest.useRealTimers(); + wrapperSpy.mockRestore(); + writeSpy.mockRestore(); + formSpy.mockRestore(); + } + }); + + it('shows an empty country after deletion on a fresh mount and waits for app loading', async () => { + // Given a deleted default and no saved address while the app is still loading. + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.set(ONYXKEYS.COUNTRY, 'US'); + await Onyx.set(ONYXKEYS.COUNTRY, null); + await Onyx.set(ONYXKEYS.IS_LOADING_APP, true); + }); + renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByLabelText('State / Province')).toBeNull(); + + // When loading finishes, the real form mounts from the hook's undefined default. + await act(async () => { + await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); + }); + await waitForBatchedUpdatesWithAct(); + + // Then no deleted value is resurrected into the selector. + expect(screen.getByLabelText('State / Province')).toBeDefined(); + expect(screen.queryByText('United States')).toBeNull(); + }); + + it('keeps raw default spelling through the country title and selection route', async () => { + // Given an arbitrary persisted string, which remains a supported default input. + const production = jest.replaceProperty(Config, 'IS_IN_PRODUCTION', true); + const alertSpy = jest.spyOn(Log, 'alert').mockImplementation(() => {}); + const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(() => {}); + try { + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.set(ONYXKEYS.COUNTRY, 'Unknown Country'); + }); + renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); + await waitForBatchedUpdatesWithAct(); + const title = screen.getByText('allCountries.Unknown Country'); + const route = createDynamicRoute(DYNAMIC_ROUTES.ADDRESS_COUNTRY.getRoute('Unknown Country')); + + // When the real country selector is opened. + const pressEvent = createMock({nativeEvent: {}}); + fireEvent.press(title, pressEvent); + + // Then both the missing label and the route retain the raw string. + expect(navigateSpy).toHaveBeenCalledWith(route); + expect(screen.getByLabelText('State / Province')).toBeDefined(); + } finally { + navigateSpy.mockRestore(); + alertSpy.mockRestore(); + production.restore(); + } + }); + + it('validates the US ZIP before submitting finite address values', async () => { + // Given a complete US address with an invalid registered ZIP. + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.set(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {addresses: [{street: '123 Main St', country: 'US', state: 'NY', city: 'New York', zip: 'bad'}]}); + }); + renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); + await waitForBatchedUpdatesWithAct(); + writeSpy.mockClear().mockResolvedValue(undefined); + const wrapperSpy = jest.spyOn(FormWrapperModule, 'default'); + try { + // When the real validator rejects the ZIP, only an actual valid edit permits the next submission. + // Control the real form's 1,000 ms submit debounce because setupAfterEnv uses real timers. + jest.useFakeTimers({doNotFake: ['nextTick', 'setImmediate']}); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + expect(writeSpy).not.toHaveBeenCalled(); + fireEvent.changeText(screen.getByLabelText('Zip / Postcode'), '10001'); + await waitForBatchedUpdatesWithAct(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + // Observe the real validator's errors before the retry, rather than assuming the edited form is valid. + expect(wrapperSpy.mock.calls.at(-1)?.[0].errors).toEqual({}); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + + // Then the real personal-details action receives finite US data. + const [command, params] = getRequiredWriteCall(writeSpy.mock.calls); + expect(command).toBe(WRITE_COMMANDS.UPDATE_HOME_ADDRESS); + expect(params).toEqual(expect.objectContaining({addressCountry: 'US', addressState: 'NY', addressZipCode: '10001', homeAddressCity: 'New York'})); + } finally { + jest.useRealTimers(); + wrapperSpy.mockRestore(); + writeSpy.mockRestore(); + } + }); + + it('retains registered city and ZIP through a finite country route and validates actual edits', async () => { + // Given a fresh US form so this route case does not reuse an earlier successful submit/loading state. + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.set(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {addresses: [{street: '123 Main St', country: 'US', state: 'NY', city: 'New York', zip: '10001'}]}); + }); + const formSpy = jest.spyOn(AddressFormModule, 'default'); + writeSpy.mockClear().mockResolvedValue(undefined); + const wrapperSpy = jest.spyOn(FormWrapperModule, 'default'); + try { + renderPage(SCREENS.SETTINGS.PROFILE.ADDRESS); + await waitForBatchedUpdatesWithAct(); + + // When the validated route returns Canada, parent clears differ from retained registered values. + await act(async () => { + Navigation.setParams({country: 'CA'}); + }); + await waitForBatchedUpdatesWithAct(); + expect(formSpy.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({country: 'CA', state: '', city: '', zip: ''})); + expect(screen.getByText('Canada')).toBeDefined(); + expect(screen.getByLabelText('State / Province').props.value).toBe(''); + expect(screen.getByLabelText('City').props.value).toBe('New York'); + expect(screen.getByLabelText('Zip / Postcode').props.value).toBe('10001'); + expect(navigationRef.getCurrentRoute()?.params).toEqual(expect.objectContaining({country: undefined})); + // Control the real form's 1,000 ms submit debounce because setupAfterEnv uses real timers. + jest.useFakeTimers({doNotFake: ['nextTick', 'setImmediate']}); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + expect(writeSpy).not.toHaveBeenCalled(); + fireEvent.changeText(screen.getByLabelText('State / Province'), 'Ontario'); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(screen.getByLabelText('Zip / Postcode'), 'M5V 2T6'); + await waitForBatchedUpdatesWithAct(); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + // Observe the real validator's errors before the retry, rather than assuming the edited form is valid. + expect(wrapperSpy.mock.calls.at(-1)?.[0].errors).toEqual({}); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + + // Then actual edits pass CA validation while the submitted city retains its original registration. + const [command, params] = getRequiredWriteCall(writeSpy.mock.calls); + expect(command).toBe(WRITE_COMMANDS.UPDATE_HOME_ADDRESS); + expect(params).toEqual( + expect.objectContaining({ + addressCountry: 'CA', + addressState: 'Ontario', + addressStateLong: 'Ontario', + homeAddressCity: 'New York', + addressZipCode: 'M5V 2T6', + }), + ); + } finally { + jest.useRealTimers(); + wrapperSpy.mockRestore(); + writeSpy.mockRestore(); + formSpy.mockRestore(); + } + }); +}); + +describe('country translation through the existing localization engine', () => { + afterEach(async () => { + await act(async () => { + await IntlStore.load(CONST.LOCALES.EN); + }); + }); + + it('uses real locale loaders while retaining finite translation parameter inference', async () => { + // Given the actual English and Spanish translation caches. + await IntlStore.load(CONST.LOCALES.EN); + await IntlStore.load(CONST.LOCALES.ES); + + // When the country entry and ordinary parameterized entry use explicit locales. + const country: string | null = IntlStore.get('allCountries.US', CONST.LOCALES.EN); + const addressLine: ((lineNumber: number) => string) | null = IntlStore.get('common.addressLine', CONST.LOCALES.EN); + if (!addressLine) { + throw new Error('Expected the real parameterized address-line translation.'); + } + + // Then country lookup shares the existing cache without widening finite function inference. + expect(country).toBe('United States'); + expect(translateCountry(CONST.LOCALES.EN, 'US')).toBe('United States'); + expect(translateCountry(CONST.LOCALES.ES, 'US')).toBe('Estados Unidos de América'); + expect(translateCountry(undefined, 'Unknown Country')).toBe('allCountries.Unknown Country'); + expect(addressLine(2)).toBe('Address line 2'); + expect(translate(CONST.LOCALES.EN, 'common.addressLine', 2)).toBe('Address line 2'); + const finiteTranslationContract: { + acceptsUnknownCountryPath: `allCountries.${string}` extends Parameters[1] ? true : false; + acceptsStringLineNumber: string extends Parameters>[2] ? true : false; + } = {acceptsUnknownCountryPath: false, acceptsStringLineNumber: false}; + expect(finiteTranslationContract).toEqual({acceptsUnknownCountryPath: false, acceptsStringLineNumber: false}); + }); + + it.each([ + {production: true, staging: false, country: 'production-missing-country'}, + {production: false, staging: true, country: 'staging-missing-country'}, + ])('retains lookup-cache and per-call missing alerts with $country', async ({production, staging, country}) => { + // Given an uncached missing key in each supported deployment mode. + await IntlStore.load(CONST.LOCALES.EN); + const productionSetting = jest.replaceProperty(Config, 'IS_IN_PRODUCTION', production); + const stagingSetting = jest.replaceProperty(Config, 'IS_IN_STAGING', staging); + const alertSpy = jest.spyOn(Log, 'alert').mockImplementation(() => {}); + try { + // When the same country is translated twice, only the lookup's first alert is cached. + const first = translateCountry(CONST.LOCALES.EN, country); + expect(alertSpy).toHaveBeenCalledTimes(2); + const second = translateCountry(CONST.LOCALES.EN, country); + + // Then the outer missing-key alert still runs on every translation call. + expect(first).toBe(`allCountries.${country}`); + expect(second).toBe(first); + expect(alertSpy).toHaveBeenCalledTimes(3); + expect(alertSpy).toHaveBeenNthCalledWith(1, `allCountries.${country} was not found in the en locale`); + expect(alertSpy).toHaveBeenNthCalledWith(3, `allCountries.${country} was not found in the en locale`); + } finally { + alertSpy.mockRestore(); + productionSetting.restore(); + stagingSetting.restore(); + } + }); + + it('retains the development missing-key throw and cached lookup alert', async () => { + // Given a distinct missing key and both deployment fallback flags disabled. + await IntlStore.load(CONST.LOCALES.EN); + const productionSetting = jest.replaceProperty(Config, 'IS_IN_PRODUCTION', false); + const stagingSetting = jest.replaceProperty(Config, 'IS_IN_STAGING', false); + const alertSpy = jest.spyOn(Log, 'alert').mockImplementation(() => {}); + try { + // When development translates the same missing country twice. + expect(() => translateCountry(CONST.LOCALES.EN, 'development-missing-country')).toThrow('allCountries.development-missing-country was not found in the en locale'); + expect(() => translateCountry(CONST.LOCALES.EN, 'development-missing-country')).toThrow('allCountries.development-missing-country was not found in the en locale'); + + // Then the engine throws on both calls but caches the inner missing lookup. + expect(alertSpy).toHaveBeenCalledTimes(1); + } finally { + alertSpy.mockRestore(); + productionSetting.restore(); + stagingSetting.restore(); + } + }); }); diff --git a/tests/ui/WorkspaceTravelAddressCountryTest.tsx b/tests/ui/WorkspaceTravelAddressCountryTest.tsx new file mode 100644 index 000000000000..cb261db9b03f --- /dev/null +++ b/tests/ui/WorkspaceTravelAddressCountryTest.tsx @@ -0,0 +1,281 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import * as AddressFormModule from '@components/AddressForm'; +import ComposeProviders from '@components/ComposeProviders'; +import * as FormWrapperModule from '@components/Form/FormWrapper'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; + +import * as API from '@libs/API'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import WorkspaceAddressStep from '@pages/Travel/EnableTravel/subPages/WorkspaceAddressStep'; +import type {EnableTravelSubPageProps} from '@pages/Travel/EnableTravel/types'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/HomeAddressForm'; +import type {Policy} from '@src/types/onyx'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import Onyx from 'react-native-onyx'; + +import createMock from '../utils/createMock'; +import {getRequiredWriteCall, signInWithTestUser} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@rnmapbox/maps', () => ({default: jest.fn(), MarkerView: jest.fn(), setAccessToken: jest.fn()})); + +type TravelAddressWriteSpy = jest.SpyInstance, Parameters>; + +const Stack = createPlatformStackNavigator(); + +/** Keeps the actual Travel step, form, selectors and navigation in the same rendered flow. */ +function renderTravelAddress(policy: Policy, onNext: EnableTravelSubPageProps['onNext']) { + return render( + + + + + + {() => ( + {}} + isEditing={false} + resolvedDomain="expensifail.com" + /> + )} + + + + + , + ); +} + +describe('Workspace travel address country', () => { + let writeSpy: TravelAddressWriteSpy; + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await IntlStore.load(CONST.LOCALES.EN); + }); + await signInWithTestUser(); + await waitForBatchedUpdatesWithAct(); + writeSpy = jest.spyOn(API, 'write'); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('retains registered city and ZIP after a country switch and validates before submitting', async () => { + // Given a real Travel form whose city and ZIP are already registered from a Canadian address. + const policy = createMock({ + id: 'travel-policy', + address: {addressStreet: ' 123 Main St ', addressStreet2: ' Suite 4 ', country: 'CA', state: 'Ontario', city: 'Toronto', zipCode: 'M5V 2T6'}, + }); + const onNext = jest.fn(); + renderTravelAddress(policy, onNext); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText('Canada')).toBeDefined(); + writeSpy.mockClear().mockResolvedValue(undefined); + const wrapperSpy = jest.spyOn(FormWrapperModule, 'default'); + try { + // When real route selection changes the country, only the explicit country/state values track parent clears. + await act(async () => { + Navigation.setParams({country: 'VN'}); + }); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText('Vietnam')).toBeDefined(); + expect(screen.getByLabelText('State / Province').props.value).toBe(''); + expect(screen.getByLabelText('City').props.value).toBe('Toronto'); + expect(screen.getByLabelText('Zip / Postcode').props.value).toBe('M5V 2T6'); + expect(navigationRef.getCurrentRoute()?.params).toEqual(expect.objectContaining({country: undefined})); + // Control the real form's 1,000 ms submit debounce because setupAfterEnv uses real timers. + jest.useFakeTimers({doNotFake: ['nextTick', 'setImmediate']}); + fireEvent.press(screen.getByText('Next')); + await waitForBatchedUpdatesWithAct(); + expect(writeSpy).not.toHaveBeenCalled(); + expect(onNext).not.toHaveBeenCalled(); + + // The retained Canadian ZIP is invalid for VN. Actual edits must pass validation before a retry. + fireEvent.changeText(screen.getByLabelText('State / Province'), ' Province '); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(screen.getByLabelText('Zip / Postcode'), '133836'); + await waitForBatchedUpdatesWithAct(); + await act(async () => { + // Exhaust the existing leading submit debounce before the next press. + jest.advanceTimersByTime(1000); + }); + // Observe the real validator's errors before the retry, rather than assuming the edited form is valid. + expect(wrapperSpy.mock.calls.at(-1)?.[0].errors).toEqual({}); + fireEvent.press(screen.getByText('Next')); + await waitForBatchedUpdatesWithAct(); + + // Then submission uses the retained registered city and edited finite country/state/ZIP. + const [command, params] = getRequiredWriteCall(writeSpy.mock.calls); + expect(command).toBe(WRITE_COMMANDS.UPDATE_POLICY_ADDRESS); + expect(params).toEqual({ + policyID: policy.id, + addressStreet: '123 Main St', + addressStreet2: 'Suite 4', + city: 'Toronto', + state: 'Province', + zipCode: '133836', + country: 'VN', + }); + expect(onNext).toHaveBeenCalledTimes(1); + expect(writeSpy.mock.invocationCallOrder.at(0)).toBeLessThan(onNext.mock.invocationCallOrder.at(0) ?? 0); + } finally { + jest.useRealTimers(); + wrapperSpy.mockRestore(); + writeSpy.mockRestore(); + } + }); + + it('trims the real submitted address and uppercases a Canadian postal code', async () => { + // Given a complete Canadian office address with whitespace and a lowercase postal code. + const policy = createMock({ + id: 'travel-policy', + address: {addressStreet: ' 123 Main St ', addressStreet2: ' Suite 4 ', country: 'CA', state: ' Ontario ', city: ' Toronto ', zipCode: ' m5v 2t6 '}, + }); + const onNext = jest.fn(); + renderTravelAddress(policy, onNext); + await waitForBatchedUpdatesWithAct(); + writeSpy.mockClear().mockResolvedValue(undefined); + try { + // When the real form validates and submits the saved address. + fireEvent.press(screen.getByText('Next')); + await waitForBatchedUpdatesWithAct(); + + // Then Travel retains its original trimming, ZIP uppercase and update-before-next behavior. + const [command, params] = getRequiredWriteCall(writeSpy.mock.calls); + expect(command).toBe(WRITE_COMMANDS.UPDATE_POLICY_ADDRESS); + expect(params).toEqual({policyID: policy.id, addressStreet: '123 Main St', addressStreet2: 'Suite 4', city: 'Toronto', state: 'Ontario', zipCode: 'M5V 2T6', country: 'CA'}); + expect(onNext).toHaveBeenCalledTimes(1); + expect(writeSpy.mock.invocationCallOrder.at(0)).toBeLessThan(onNext.mock.invocationCallOrder.at(0) ?? 0); + } finally { + writeSpy.mockRestore(); + } + }); + + it('registers actual state, city and ZIP edits without applying downstream default clears', async () => { + // Given a real non-US form and a call-through observer that leaves registration intact. + const policy = createMock({id: 'travel-policy', address: {addressStreet: '123 Main St', country: 'CA', state: 'Ontario', city: 'Toronto', zipCode: 'M5V 2T6'}}); + const onNext = jest.fn(); + const formSpy = jest.spyOn(AddressFormModule, 'default'); + writeSpy.mockClear().mockResolvedValue(undefined); + try { + renderTravelAddress(policy, onNext); + await waitForBatchedUpdatesWithAct(); + + // When state changes, the parent clears defaults while the real registered city and ZIP retain. + fireEvent.changeText(screen.getByLabelText('State / Province'), 'Quebec'); + await waitForBatchedUpdatesWithAct(); + expect(formSpy.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({country: 'CA', state: 'Quebec', city: '', zip: ''})); + expect(screen.getByText('Canada')).toBeDefined(); + expect(screen.getByLabelText('City').props.value).toBe('Toronto'); + expect(screen.getByLabelText('Zip / Postcode').props.value).toBe('M5V 2T6'); + fireEvent.changeText(screen.getByLabelText('City'), 'Montreal'); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByLabelText('City').props.value).toBe('Montreal'); + expect(screen.getByLabelText('Zip / Postcode').props.value).toBe('M5V 2T6'); + fireEvent.changeText(screen.getByLabelText('Zip / Postcode'), 'H2Y 1C6'); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(screen.getByLabelText('City'), 'Laval'); + await waitForBatchedUpdatesWithAct(); + + // Then each edited key registers itself, even when the parent ZIP default is empty again. + expect(formSpy.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({country: 'CA', state: 'Quebec', city: 'Laval', zip: ''})); + expect(screen.getByLabelText('State / Province').props.value).toBe('Quebec'); + expect(screen.getByLabelText('City').props.value).toBe('Laval'); + expect(screen.getByLabelText('Zip / Postcode').props.value).toBe('H2Y 1C6'); + fireEvent.press(screen.getByText('Next')); + await waitForBatchedUpdatesWithAct(); + const [command, params] = getRequiredWriteCall(writeSpy.mock.calls); + expect(command).toBe(WRITE_COMMANDS.UPDATE_POLICY_ADDRESS); + expect(params).toEqual(expect.objectContaining({country: 'CA', state: 'Quebec', city: 'Laval', zipCode: 'H2Y 1C6'})); + expect(onNext).toHaveBeenCalledTimes(1); + } finally { + writeSpy.mockRestore(); + formSpy.mockRestore(); + } + }); + + it('observes local clears and same-country ZIP fallthrough through captured child callbacks', async () => { + // Given a call-through AddressForm observer. These captures bypass user event production and cannot prove batched setter order. + const formSpy = jest.spyOn(AddressFormModule, 'default'); + const policy = createMock({id: 'travel-policy', address: {addressStreet: '123 Main St', country: 'CA', state: 'Ontario', city: 'Toronto', zipCode: 'M5V 2T6'}}); + try { + renderTravelAddress(policy, () => {}); + await waitForBatchedUpdatesWithAct(); + expect(formSpy.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({country: 'CA', state: 'Ontario', city: 'Toronto', zip: 'M5V 2T6', addBottomSafeAreaPadding: false})); + + // When each captured callback is taken from the latest rendered props, its current-country closure stays current. + await act(async () => { + const props = formSpy.mock.calls.at(-1)?.[0]; + if (!props?.onAddressChanged) { + throw new Error('Expected the real Travel AddressForm change callback.'); + } + props.onAddressChanged('CA', INPUT_IDS.COUNTRY); + }); + await waitForBatchedUpdatesWithAct(); + + // Then same-country fallthrough updates only the parent ZIP prop, not the registered ZIP. + expect(formSpy.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({country: 'CA', state: 'Ontario', city: 'Toronto', zip: 'CA'})); + expect(screen.getByLabelText('Zip / Postcode').props.value).toBe('M5V 2T6'); + await act(async () => { + const props = formSpy.mock.calls.at(-1)?.[0]; + if (!props?.onAddressChanged) { + throw new Error('Expected the real Travel AddressForm change callback.'); + } + props.onAddressChanged('ignored', {toString: () => 'unrecognized'}); + }); + await waitForBatchedUpdatesWithAct(); + expect(formSpy.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({country: 'CA', state: 'Ontario', city: 'Toronto', zip: 'CA'})); + await act(async () => { + const props = formSpy.mock.calls.at(-1)?.[0]; + if (!props?.onAddressChanged) { + throw new Error('Expected the real Travel AddressForm change callback.'); + } + props.onAddressChanged(123, {toString: () => INPUT_IDS.STATE}); + }); + await waitForBatchedUpdatesWithAct(); + expect(formSpy.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({country: 'CA', state: '', city: '', zip: ''})); + expect(screen.getByLabelText('State / Province').props.value).toBe(''); + expect(screen.getByLabelText('City').props.value).toBe('Toronto'); + expect(screen.getByLabelText('Zip / Postcode').props.value).toBe('M5V 2T6'); + await act(async () => { + const props = formSpy.mock.calls.at(-1)?.[0]; + if (!props?.onAddressChanged) { + throw new Error('Expected the real Travel AddressForm change callback.'); + } + props.onAddressChanged('VN', INPUT_IDS.COUNTRY); + }); + await waitForBatchedUpdatesWithAct(); + expect(formSpy.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({country: 'VN', state: '', city: '', zip: ''})); + expect(screen.getByText('Vietnam')).toBeDefined(); + expect(screen.getByLabelText('City').props.value).toBe('Toronto'); + expect(screen.getByLabelText('Zip / Postcode').props.value).toBe('M5V 2T6'); + } finally { + formSpy.mockRestore(); + } + }); +}); diff --git a/tests/unit/CountryUtils.test.ts b/tests/unit/CountryUtils.test.ts index 6bc00d2057b9..6406510e7978 100644 --- a/tests/unit/CountryUtils.test.ts +++ b/tests/unit/CountryUtils.test.ts @@ -1,7 +1,12 @@ import {getCountryCode, normalizeCountryCode} from '@libs/CountryUtils'; +import {getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils'; +import type {Country} from '@src/CONST'; +import type {PersonalDetailsForm} from '@src/types/form'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; +import createMock from '../utils/createMock'; + describe('CountryUtils', () => { describe('getCountryCode', () => { it('should return the same value if it is already a valid country code', () => { @@ -89,6 +94,7 @@ describe('CountryUtils', () => { }); it('should handle multiple country name conversions', () => { + // Given legacy country names with known current codes. const testCases = [ {input: 'United States', expected: 'US'}, {input: 'Canada', expected: 'CA'}, @@ -99,11 +105,13 @@ describe('CountryUtils', () => { {input: 'Australia', expected: 'AU'}, ]; + // When each legacy value reaches the real normalizer. for (const {input, expected} of testCases) { const data = {street: '789 Test St', city: 'Test City', country: input}; // @ts-expect-error - Legacy addresses can contain country names before normalization. const result = normalizeCountryCode(data); - expect(result?.country).toBe(expected); + // Then the actual returned object must contain the corresponding country code. + expect(result).toHaveProperty('country', expected); } }); @@ -160,4 +168,108 @@ describe('CountryUtils', () => { expect(result).toEqual({street: '789 Test St', city: 'Test City', country: 'MISSING TRANSLATION'}); }); }); + + it('preserves empty, absent, general-string and own-key lookup categories', () => { + // Given finite model values and arbitrary persisted strings, including inherited object names. + const finiteCountry: Country | '' = 'US'; + const optionalFiniteCountry: Country | '' | undefined = undefined; + const rawCountry = 'constructor'; + const optionalRawCountry: string | undefined = undefined; + + // When the real lookup is used, these assignments check each public overload's return category. + const finiteResult: Country | '' = getCountryCode(finiteCountry); + const optionalFiniteResult: Country | '' | undefined = getCountryCode(optionalFiniteCountry); + const rawResult: string = getCountryCode(rawCountry); + const optionalRawResult: string | undefined = getCountryCode(optionalRawCountry); + + // Then only own country-name entries are translated and every other input keeps its value. + expect(finiteResult).toBe('US'); + expect(optionalFiniteResult).toBeUndefined(); + expect(rawResult).toBe('constructor'); + expect(optionalRawResult).toBeUndefined(); + expect(getCountryCode('toString')).toBe('toString'); + expect(getCountryCode('')).toBe(''); + expect(getCountryCode('us')).toBe('us'); + }); + + it('keeps the reference and country property presence of falsey address inputs', () => { + // Given the distinct supported absence and empty-string shapes in Address. + const missing: Address = {street: '123 Main St'}; + const explicitUndefined: Address = {street: '123 Main St', country: undefined}; + const empty: Address = {street: '123 Main St', country: ''}; + + // When normalization sees no truthy country. + // Then it returns the exact original objects. + expect(normalizeCountryCode(missing)).toBe(missing); + expect(normalizeCountryCode(explicitUndefined)).toBe(explicitUndefined); + expect(normalizeCountryCode(empty)).toBe(empty); + expect(Object.hasOwn(normalizeCountryCode(missing), 'country')).toBe(false); + expect(Object.hasOwn(normalizeCountryCode(explicitUndefined), 'country')).toBe(true); + expect(normalizeCountryCode(empty).country).toBe(''); + }); + + it('characterizes deliberately invalid nested null without claiming a supported country producer', () => { + // Given malformed historical data, outside the finite Address model and the live COUNTRY hook contract. + const data = {street: '123 Main St', country: null}; + + // When this exact invalid expression reaches the existing falsey guard. + // @ts-expect-error - Nested null is deliberately invalid Address data, not a supported UI input. + const result = normalizeCountryCode(data); + + // Then the guard retains both reference and explicit property presence. + expect(result).toBe(data); + expect(Object.hasOwn(result, 'country')).toBe(true); + expect(result).toHaveProperty('country', null); + }); + + it('preserves complete form fields, metadata and nested references in a shallow copy', () => { + // Given a form from the finite form owner with server metadata and an extension value. + const errors = getMicroSecondOnyxErrorWithMessage('Server error', 123); + const extension = {source: 'profile'}; + const data = { + ...createMock({country: 'CA', legalFirstName: 'Alex', phoneNumber: '123', isLoading: true, errors}), + extension, + }; + + // When a truthy country is normalized, the Form overload preserves its complete category. + const result: PersonalDetailsForm = normalizeCountryCode(data); + + // Then values and nested references survive while the containing object is copied. + expect(result).not.toBe(data); + expect(result).toEqual(data); + expect(result.errors).toBe(errors); + expect(result.isLoading).toBe(true); + expect(result.legalFirstName).toBe('Alex'); + expect(result.phoneNumber).toBe('123'); + expect(result).toHaveProperty('extension', extension); + expect('extension' in result && result.extension).toBe(extension); + }); + + it('retains required and optional Form and Address return categories', () => { + // Given both production object categories, with absence represented separately. + const form = createMock({country: 'US', city: 'New York'}); + const address: Address = {street: '123 Main St', country: 'US'}; + const optionalForm: PersonalDetailsForm | undefined = form; + const optionalAddress: Address | undefined = address; + const requiredUnion: PersonalDetailsForm | Address = [form, address].find((value) => 'street' in value) ?? form; + const optionalUnion: PersonalDetailsForm | Address | undefined = [requiredUnion].at(0); + + // When the declared categories are checked, no caller-selected generic result is promised. + const formResult: PersonalDetailsForm = normalizeCountryCode(form); + const addressResult: Address = normalizeCountryCode(address); + const absentResult: undefined = normalizeCountryCode(undefined); + const optionalFormResult: PersonalDetailsForm | undefined = normalizeCountryCode(optionalForm); + const optionalAddressResult: Address | undefined = normalizeCountryCode(optionalAddress); + const unionResult: PersonalDetailsForm | Address = normalizeCountryCode(requiredUnion); + const optionalUnionResult: PersonalDetailsForm | Address | undefined = normalizeCountryCode(optionalUnion); + + // Then runtime values also retain their full original shapes and absence. + expect(formResult).toEqual(form); + expect(addressResult).toEqual(address); + expect(absentResult).toBeUndefined(); + expect(optionalFormResult).toEqual(optionalForm); + expect(optionalAddressResult).toEqual(optionalAddress); + expect(unionResult).toEqual(requiredUnion); + expect(optionalUnionResult).toEqual(optionalUnion); + }); }); diff --git a/tests/unit/Search/chartGroupByConfigTest.ts b/tests/unit/Search/chartGroupByConfigTest.ts index 26a5be3d6dfe..8dbcaf9b1e88 100644 --- a/tests/unit/Search/chartGroupByConfigTest.ts +++ b/tests/unit/Search/chartGroupByConfigTest.ts @@ -1,5 +1,6 @@ -import CHART_GROUP_BY_CONFIG from '@components/Search/chartGroupByConfig'; +import CHART_GROUP_BY_CONFIG, {getChartFilterQuery} from '@components/Search/chartGroupByConfig'; import type {TransactionMerchantGroupListItemType} from '@components/Search/SearchList/ListItem/types'; +import type {GroupedItem, SearchGroupBy} from '@components/Search/types'; import CONST from '@src/CONST'; @@ -7,8 +8,217 @@ import createMock from '../../utils/createMock'; describe('chartGroupByConfig', () => { it('uses the explicit exact operator for merchant drill-downs', () => { + // Given an exact merchant value whose query operator must remain equals. const item = createMock({merchant: 'I'}); - expect(CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.MERCHANT].getFilterQuery(item)).toBe('merchant="I"'); + // When the real merchant filter is called. + const query = CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.MERCHANT].getFilterQuery(item); + + // Then its query keeps the explicit operator. + expect(query).toBe('merchant="I"'); + }); + + it('dispatches every real chart group with its labels, filters and axis contracts', () => { + // Given rows with the formatted fields produced by SearchUIUtils for each grouping. + const cases: Array<{ + item: GroupedItem; + label: string; + query: string; + icon: string; + shortLabel?: string; + lineLabel?: string; + steps?: readonly number[]; + range?: {start: string; end: string}; + }> = [ + { + item: createMock>({groupedBy: CONST.SEARCH.GROUP_BY.FROM, accountID: 42, formattedFrom: 'Member'}), + label: 'Member', + query: 'from:42', + icon: 'Users', + }, + { + item: createMock>({ + groupedBy: CONST.SEARCH.GROUP_BY.CARD, + accountID: 42, + cardID: 17, + formattedCardName: 'Card', + }), + label: 'Card', + query: 'cardID:17', + icon: 'CreditCard', + }, + { + item: createMock>({ + groupedBy: CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID, + entryID: 19, + formattedWithdrawalID: 'Withdrawal', + }), + label: 'Withdrawal', + query: 'withdrawalID:19', + icon: 'Send', + }, + { + item: createMock>({ + groupedBy: CONST.SEARCH.GROUP_BY.CATEGORY, + category: 'Meals & drinks', + formattedCategory: 'Meals', + }), + label: 'Meals', + query: 'category:"Meals & drinks"', + icon: 'Folder', + }, + { + item: createMock>({ + groupedBy: CONST.SEARCH.GROUP_BY.MERCHANT, + merchant: 'I', + formattedMerchant: 'Merchant', + }), + label: 'Merchant', + query: 'merchant="I"', + icon: 'Basket', + }, + { + item: createMock>({groupedBy: CONST.SEARCH.GROUP_BY.TAG, tag: 'Team: dinner', formattedTag: 'Tag'}), + label: 'Tag', + query: 'tag:"Team: dinner"', + icon: 'Tag', + }, + { + item: createMock>({ + groupedBy: CONST.SEARCH.GROUP_BY.DAY, + day: '2024-02-29', + formattedDay: 'February 29, 2024', + shortFormattedDay: 'Feb 29', + lineChartFormattedDay: 'Feb 29, 2024', + }), + label: 'February 29, 2024', + query: 'date>=2024-02-29 date<=2024-02-29', + icon: 'Calendar', + shortLabel: 'Feb 29', + lineLabel: 'Feb 29, 2024', + steps: [1, 2, 7, 14], + range: {start: '2024-02-29', end: '2024-02-29'}, + }, + { + item: createMock>({ + groupedBy: CONST.SEARCH.GROUP_BY.MONTH, + year: 2024, + month: 2, + formattedMonth: 'February 2024', + shortFormattedMonth: 'Feb', + }), + label: 'February 2024', + query: 'date>=2024-02-01 date<=2024-02-29', + icon: 'Calendar', + shortLabel: 'Feb', + steps: [1, 2, 3, 6], + range: {start: '2024-02-01', end: '2024-02-29'}, + }, + { + item: createMock>({ + groupedBy: CONST.SEARCH.GROUP_BY.WEEK, + week: '2023-12-31', + formattedWeek: 'Dec 31 to Jan 6', + shortFormattedWeek: 'Dec 31', + lineChartFormattedWeek: 'Dec 31, 2023', + }), + label: 'Dec 31 to Jan 6', + query: 'date>=2023-12-31 date<=2024-01-06', + icon: 'Calendar', + shortLabel: 'Dec 31', + lineLabel: 'Dec 31, 2023', + steps: [1, 2, 4], + range: {start: '2023-12-31', end: '2024-01-06'}, + }, + { + item: createMock>({groupedBy: CONST.SEARCH.GROUP_BY.YEAR, year: 2024, formattedYear: '2024'}), + label: '2024', + query: 'date>=2024-01-01 date<=2024-12-31', + icon: 'Calendar', + steps: [1, 2, 5], + range: {start: '2024-01-01', end: '2024-12-31'}, + }, + { + item: createMock>({ + groupedBy: CONST.SEARCH.GROUP_BY.QUARTER, + year: 2024, + quarter: 4, + formattedQuarter: 'Q4 2024', + shortFormattedQuarter: 'Q4', + }), + label: 'Q4 2024', + query: 'date>=2024-10-01 date<=2024-12-31', + icon: 'Calendar', + shortLabel: 'Q4', + steps: [1, 2, 4], + range: {start: '2024-10-01', end: '2024-12-31'}, + }, + ]; + + // When the same dispatcher used by SearchChartView handles each row. + for (const {item, label, query, icon, shortLabel, lineLabel, steps, range} of cases) { + const config = CHART_GROUP_BY_CONFIG[item.groupedBy]; + + // Then all eleven entries retain the exact filters, labels, date bounds and optional fallbacks. + expect(getChartFilterQuery(item)).toBe(query); + expect(config.getLabel(item)).toBe(label); + expect(config.titleIconName).toBe(icon); + expect(config.getShortLabel?.(item)).toBe(shortLabel); + expect(config.getLineChartLabel?.(item)).toBe(lineLabel); + expect(config.axisLabelSteps).toEqual(steps); + expect(config.getDateRange?.(item)).toEqual(range); + } + expect(cases.map(({item}) => item.groupedBy).sort()).toEqual(Object.keys(CHART_GROUP_BY_CONFIG).sort()); + }); + + it('keeps broad label and simple-filter fallbacks and existing date guards', () => { + // Given an unformatted category row, which lacks every other group-specific display field. + const item = createMock>({ + groupedBy: CONST.SEARCH.GROUP_BY.CATEGORY, + category: '', + }); + const groups: SearchGroupBy[] = Object.values(CONST.SEARCH.GROUP_BY); + + // When broad callbacks receive this row. + // Then absence still produces the original fallbacks. + for (const group of groups) { + const config = CHART_GROUP_BY_CONFIG[group]; + expect(config.getLabel(item)).toBe(''); + expect(config.getShortLabel?.(item)).toBeUndefined(); + expect(config.getLineChartLabel?.(item)).toBeUndefined(); + expect(config.getDateRange?.(item)).toBeUndefined(); + } + expect(CHART_GROUP_BY_CONFIG.from.getFilterQuery(item)).toBe('from:undefined'); + expect(CHART_GROUP_BY_CONFIG.card.getFilterQuery(item)).toBe('cardID:undefined'); + expect(CHART_GROUP_BY_CONFIG[CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID].getFilterQuery(item)).toBe('withdrawalID:undefined'); + expect(CHART_GROUP_BY_CONFIG.merchant.getFilterQuery(item)).toBe('merchant="undefined"'); + expect(CHART_GROUP_BY_CONFIG.tag.getFilterQuery(item)).toBe('tag:"undefined"'); + expect(CHART_GROUP_BY_CONFIG.day.getFilterQuery(item)).toBe(''); + }); + + it('retains shared account and year fields across their real union members', () => { + // Given a card with a cardholder and month/quarter rows carrying a year. + const card = createMock>({groupedBy: CONST.SEARCH.GROUP_BY.CARD, accountID: 42}); + const month = createMock>({groupedBy: CONST.SEARCH.GROUP_BY.MONTH, year: 2024, month: 2}); + const quarter = createMock>({groupedBy: CONST.SEARCH.GROUP_BY.QUARTER, year: 2023, quarter: 1}); + + // When direct callbacks consume shared fields. + // Then the supported cross-group calls remain valid. + expect(CHART_GROUP_BY_CONFIG.from.getFilterQuery(card)).toBe('from:42'); + expect(CHART_GROUP_BY_CONFIG.category.getFilterQuery(card)).toBe('category:"undefined"'); + expect(CHART_GROUP_BY_CONFIG.year.getFilterQuery(month)).toBe('date>=2024-01-01 date<=2024-12-31'); + expect(CHART_GROUP_BY_CONFIG.year.getFilterQuery(quarter)).toBe('date>=2023-01-01 date<=2023-12-31'); + expect(getChartFilterQuery(quarter)).toBe('date>=2023-01-01 date<=2023-03-31'); + }); + + it('requires the actual time fields at the four time-filter call boundaries', () => { + // Given the production filter signatures, the full union cannot satisfy any time-specific input. + const acceptsWholeUnion: { + [TGroup in 'month' | 'week' | 'quarter' | 'year']: GroupedItem extends Parameters<(typeof CHART_GROUP_BY_CONFIG)[TGroup]['getFilterQuery']>[0] ? true : false; + } = {month: false, week: false, quarter: false, year: false}; + + // When TypeScript checks the matrix, widening any filter to the whole union invalidates its false entry. + // Then the checked values also describe the four runtime callback boundaries. + expect(acceptsWholeUnion).toEqual({month: false, week: false, quarter: false, year: false}); }); }); From df55dd50f04e0742b0afc9c4e51a1740cea4d2fb Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:27:21 +0000 Subject: [PATCH 2/2] Fix spelling in cleanup regression tests Seatbelt-Operation: 9c3ccfa2-8f76-4f56-9b19-48906f453380 --- tests/ui/AddressPageTest.tsx | 2 ++ tests/ui/WorkspaceTravelAddressCountryTest.tsx | 4 ++-- tests/unit/CountryUtils.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/ui/AddressPageTest.tsx b/tests/ui/AddressPageTest.tsx index 1309d5200f98..7a248099a475 100644 --- a/tests/ui/AddressPageTest.tsx +++ b/tests/ui/AddressPageTest.tsx @@ -592,6 +592,8 @@ describe('country translation through the existing localization engine', () => { // Then country lookup shares the existing cache without widening finite function inference. expect(country).toBe('United States'); expect(translateCountry(CONST.LOCALES.EN, 'US')).toBe('United States'); + // Keep the exact Spanish label to independently check the real locale. + // cspell:disable-next-line expect(translateCountry(CONST.LOCALES.ES, 'US')).toBe('Estados Unidos de América'); expect(translateCountry(undefined, 'Unknown Country')).toBe('allCountries.Unknown Country'); expect(addressLine(2)).toBe('Address line 2'); diff --git a/tests/ui/WorkspaceTravelAddressCountryTest.tsx b/tests/ui/WorkspaceTravelAddressCountryTest.tsx index cb261db9b03f..c1c6808155ce 100644 --- a/tests/ui/WorkspaceTravelAddressCountryTest.tsx +++ b/tests/ui/WorkspaceTravelAddressCountryTest.tsx @@ -53,7 +53,7 @@ function renderTravelAddress(policy: Policy, onNext: EnableTravelSubPageProps['o onNext={onNext} onMove={() => {}} isEditing={false} - resolvedDomain="expensifail.com" + resolvedDomain="company.com" /> )} @@ -150,7 +150,7 @@ describe('Workspace travel address country', () => { } }); - it('trims the real submitted address and uppercases a Canadian postal code', async () => { + it('trims the real submitted address and converts a Canadian postal code to uppercase', async () => { // Given a complete Canadian office address with whitespace and a lowercase postal code. const policy = createMock({ id: 'travel-policy', diff --git a/tests/unit/CountryUtils.test.ts b/tests/unit/CountryUtils.test.ts index 6406510e7978..a4b01dfbf55f 100644 --- a/tests/unit/CountryUtils.test.ts +++ b/tests/unit/CountryUtils.test.ts @@ -192,7 +192,7 @@ describe('CountryUtils', () => { expect(getCountryCode('us')).toBe('us'); }); - it('keeps the reference and country property presence of falsey address inputs', () => { + it('keeps the reference and country property presence of falsy address inputs', () => { // Given the distinct supported absence and empty-string shapes in Address. const missing: Address = {street: '123 Main St'}; const explicitUndefined: Address = {street: '123 Main St', country: undefined}; @@ -212,7 +212,7 @@ describe('CountryUtils', () => { // Given malformed historical data, outside the finite Address model and the live COUNTRY hook contract. const data = {street: '123 Main St', country: null}; - // When this exact invalid expression reaches the existing falsey guard. + // When this exact invalid expression reaches the existing falsy guard. // @ts-expect-error - Nested null is deliberately invalid Address data, not a supported UI input. const result = normalizeCountryCode(data);