diff --git a/package-lock.json b/package-lock.json index 95b1428a4ace..c48c140c2ab4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,8 @@ "awesome-phonenumber": "^5.4.0", "babel-plugin-transform-remove-console": "^6.9.4", "babel-polyfill": "^6.26.0", + "date-fns": "^2.30.0", + "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#98d8fea356f114f8b5b0cea889a41b355e5daf58", @@ -21420,9 +21422,12 @@ } }, "node_modules/date-fns": { - "version": "2.29.1", - "dev": true, - "license": "MIT", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, "engines": { "node": ">=0.11" }, @@ -21431,6 +21436,14 @@ "url": "https://opencollective.com/date-fns" } }, + "node_modules/date-fns-tz": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.0.tgz", + "integrity": "sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==", + "peerDependencies": { + "date-fns": ">=2.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.7", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz", @@ -58189,8 +58202,18 @@ } }, "date-fns": { - "version": "2.29.1", - "dev": true + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "requires": { + "@babel/runtime": "^7.21.0" + } + }, + "date-fns-tz": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.0.tgz", + "integrity": "sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==", + "requires": {} }, "dayjs": { "version": "1.11.7", diff --git a/package.json b/package.json index e0b0cc035796..01db8b6f2986 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,8 @@ "awesome-phonenumber": "^5.4.0", "babel-plugin-transform-remove-console": "^6.9.4", "babel-polyfill": "^6.26.0", + "date-fns": "^2.30.0", + "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#98d8fea356f114f8b5b0cea889a41b355e5daf58", diff --git a/tests/unit/DateUtilsTest.js b/tests/unit/DateUtilsTest.js index da2c9456e205..fe193ef150d5 100644 --- a/tests/unit/DateUtilsTest.js +++ b/tests/unit/DateUtilsTest.js @@ -8,6 +8,7 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; const LOCALE = CONST.LOCALES.EN; describe('DateUtils', () => { + let originalNow; beforeAll(() => { Onyx.init({ keys: ONYXKEYS, @@ -19,6 +20,15 @@ describe('DateUtils', () => { return waitForPromisesToResolve(); }); + beforeEach(() => { + originalNow = moment.now; + }); + + afterEach(() => { + Onyx.clear(); + moment.now = originalNow; + }); + const datetime = '2022-11-07 00:00:00'; it('should return a moment object with the formatted datetime when calling getLocalMomentFromDatetime', () => { const localMoment = DateUtils.getLocalMomentFromDatetime(LOCALE, datetime, 'America/Los_Angeles'); @@ -26,6 +36,11 @@ describe('DateUtils', () => { expect(moment(localMoment).format()).toEqual('2022-11-06T16:00:00-08:00'); }); + it('should return a moment object when calling getLocalMomentFromDatetime with null instead of a datetime', () => { + const localMoment = DateUtils.getLocalMomentFromDatetime(LOCALE, null, 'America/Los_Angeles'); + expect(moment.isMoment(localMoment)).toBe(true); + }); + it('should return the date in calendar time when calling datetimeToCalendarTime', () => { const today = moment.utc().set({hour: 14, minute: 32}); expect(DateUtils.datetimeToCalendarTime(LOCALE, today)).toBe('Today at 2:32 PM'); @@ -35,6 +50,49 @@ describe('DateUtils', () => { const date = moment.utc('2022-11-05').set({hour: 10, minute: 17}); expect(DateUtils.datetimeToCalendarTime(LOCALE, date)).toBe('Nov 5, 2022 at 10:17 AM'); + + const todayLowercaseDate = moment.utc().set({hour: 14, minute: 32}); + expect(DateUtils.datetimeToCalendarTime(LOCALE, todayLowercaseDate, false, undefined, true)).toBe('today at 2:32 PM'); + }); + + it('should update timezone if automatic and selected timezone do not match', () => { + Intl.DateTimeFormat = jest.fn(() => ({ + resolvedOptions: () => ({timeZone: 'America/Chicago'}), + })); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: 'Etc/UTC', automatic: true}}}).then(() => { + const result = DateUtils.getCurrentTimezone(); + expect(result).toEqual({ + selected: 'America/Chicago', + automatic: true, + }); + }); + }); + + it('should not update timezone if automatic and selected timezone match', () => { + Intl.DateTimeFormat = jest.fn(() => ({ + resolvedOptions: () => ({timeZone: 'Etc/UTC'}), + })); + Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: 'Etc/UTC', automatic: true}}}).then(() => { + const result = DateUtils.getCurrentTimezone(); + expect(result).toEqual({ + selected: 'Etc/UTC', + automatic: true, + }); + }); + }); + + it('canUpdateTimezone should return true when lastUpdatedTimezoneTime is more than 5 minutes ago', () => { + const currentTime = moment().add(6, 'minutes'); + moment.now = jest.fn(() => currentTime); + const isUpdateTimezoneAllowed = DateUtils.canUpdateTimezone(); + expect(isUpdateTimezoneAllowed).toBe(true); + }); + + it('canUpdateTimezone should return false when lastUpdatedTimezoneTime is less than 5 minutes ago', () => { + const currentTime = moment().add(4, 'minutes'); + moment.now = jest.fn(() => currentTime); + const isUpdateTimezoneAllowed = DateUtils.canUpdateTimezone(); + expect(isUpdateTimezoneAllowed).toBe(false); }); it('should return the date in calendar time when calling datetimeToRelative', () => { @@ -48,6 +106,14 @@ describe('DateUtils', () => { expect(DateUtils.datetimeToRelative(LOCALE, anHourAgo)).toBe('an hour ago'); }); + it('subtractMillisecondsFromDateTime should subtract milliseconds from a given date and time', () => { + const initialDateTime = '2023-07-18T10:30:00Z'; + const millisecondsToSubtract = 5000; // 5 seconds + const expectedDateTime = '2023-07-18 10:29:55.000'; + const result = DateUtils.subtractMillisecondsFromDateTime(initialDateTime, millisecondsToSubtract); + expect(result.valueOf()).toBe(expectedDateTime); + }); + describe('getDBTime', () => { it('should return the date in the format expected by the database', () => { const getDBTime = DateUtils.getDBTime(); diff --git a/tests/unit/ValidationUtilsTest.js b/tests/unit/ValidationUtilsTest.js index 82cd3b84304e..abccdf59d711 100644 --- a/tests/unit/ValidationUtilsTest.js +++ b/tests/unit/ValidationUtilsTest.js @@ -1,6 +1,208 @@ +import CONST from '../../src/CONST'; + +const moment = require('moment'); const ValidationUtils = require('../../src/libs/ValidationUtils'); describe('ValidationUtils', () => { + describe('isValidDate', () => { + test('Should return true for a valid date within the range', () => { + const validDate = '2023-07-18'; + const isValid = ValidationUtils.isValidDate(validDate); + expect(isValid).toBe(true); + }); + + test('Should return false for an invalid date', () => { + const invalidDate = '2023-07-32'; + const isValid = ValidationUtils.isValidDate(invalidDate); + expect(isValid).toBe(false); + }); + + test('Should return false for an empty date', () => { + const invalidDate = ''; + const isValid = ValidationUtils.isValidDate(invalidDate); + expect(isValid).toBe(false); + }); + + test('Should return false for a date after the range', () => { + const futureDate = '3024-07-18'; + const isValid = ValidationUtils.isValidDate(futureDate); + expect(isValid).toBe(false); + }); + }); + + describe('isValidPastDate', () => { + test('Should return true for a valid past date within the range', () => { + const validPastDate = '1990-01-01'; + const isValid = ValidationUtils.isValidPastDate(validPastDate); + expect(isValid).toBe(true); + }); + + test('Should return false for an invalid date', () => { + const invalidDate = '2023-07-32'; + const isValid = ValidationUtils.isValidPastDate(invalidDate); + expect(isValid).toBe(false); + }); + + test('Should return false for an empty date', () => { + const emptyDate = ''; + const isValid = ValidationUtils.isValidPastDate(emptyDate); + expect(isValid).toBe(false); + }); + + test('Should return false for a future date', () => { + const futureDate = moment().add(1, 'day').format(CONST.DATE.MOMENT_FORMAT_STRING); + const isValid = ValidationUtils.isValidPastDate(futureDate); + expect(isValid).toBe(false); + }); + }); + + describe('isRequiredFulfilled', () => { + test('Should return true for a non-empty string value', () => { + const stringValue = 'Test'; + const isFulfilled = ValidationUtils.isRequiredFulfilled(stringValue); + expect(isFulfilled).toBe(true); + }); + + test('Should return false for an empty string value', () => { + const emptyStringValue = ''; + const isFulfilled = ValidationUtils.isRequiredFulfilled(emptyStringValue); + expect(isFulfilled).toBe(false); + }); + + test('Should return false for a whitespace string value', () => { + const whitespaceStringValue = ' '; + const isFulfilled = ValidationUtils.isRequiredFulfilled(whitespaceStringValue); + expect(isFulfilled).toBe(false); + }); + + test('Should return true for a valid date value', () => { + const dateValue = moment(); + const isFulfilled = ValidationUtils.isRequiredFulfilled(dateValue); + expect(isFulfilled).toBe(true); + }); + + test('Should return false for an invalid date value', () => { + const invalidDateValue = new Date('2023-07-33'); + const isFulfilled = ValidationUtils.isRequiredFulfilled(invalidDateValue); + expect(isFulfilled).toBe(false); + }); + + test('Should return true for a non-empty array value', () => { + const arrayValue = [1, 2, 3]; + const isFulfilled = ValidationUtils.isRequiredFulfilled(arrayValue); + expect(isFulfilled).toBe(true); + }); + + test('Should return false for an empty array value', () => { + const emptyArrayValue = []; + const isFulfilled = ValidationUtils.isRequiredFulfilled(emptyArrayValue); + expect(isFulfilled).toBe(false); + }); + + test('Should return true for a non-empty object value', () => { + const objectValue = {key: 'value'}; + const isFulfilled = ValidationUtils.isRequiredFulfilled(objectValue); + expect(isFulfilled).toBe(true); + }); + + test('Should return false for an empty object value', () => { + const emptyObjectValue = {}; + const isFulfilled = ValidationUtils.isRequiredFulfilled(emptyObjectValue); + expect(isFulfilled).toBe(false); + }); + }); + + describe('isValidExpirationDate', () => { + test('Should return true for a valid formats expiration date in the future', () => { + const firstFutureExpirationDate = '12/25'; // MM/YY format, in the future + const secondFutureExpirationDate = '12/2025'; // MM/YYYY format, in the future + const thirdFutureExpirationDate = '1225'; // MMYY format, in the future + const fourthFutureExpirationDate = '122025'; // MMYYYY format, in the future + expect(ValidationUtils.isValidExpirationDate(firstFutureExpirationDate)).toBe(true); + expect(ValidationUtils.isValidExpirationDate(secondFutureExpirationDate)).toBe(true); + expect(ValidationUtils.isValidExpirationDate(thirdFutureExpirationDate)).toBe(true); + expect(ValidationUtils.isValidExpirationDate(fourthFutureExpirationDate)).toBe(true); + }); + + test('Should return false for a valid expiration date, but in the past', () => { + const pastExpirationDate = '06/20'; // MM/YY format, in the past + const isValid = ValidationUtils.isValidExpirationDate(pastExpirationDate); + expect(isValid).toBe(false); + }); + + test('Should return false for an invalid expiration date format', () => { + const invalidExpirationDate = '2006'; // Invalid format, missing YYMM + const isValid = ValidationUtils.isValidExpirationDate(invalidExpirationDate); + expect(isValid).toBe(false); + }); + }); + + describe('meetsMinimumAgeRequirement', () => { + test('Should return true for a date that meets the minimum age requirement', () => { + const validDate = moment().subtract(18, 'years').format(CONST.DATE.MOMENT_FORMAT_STRING); // Date of birth 18 years ago + const meetsRequirement = ValidationUtils.meetsMinimumAgeRequirement(validDate); + expect(meetsRequirement).toBe(true); + }); + + test('Should return false for a date that does not meet the minimum age requirement', () => { + const invalidDate = moment().subtract(17, 'years').format(CONST.DATE.MOMENT_FORMAT_STRING); // Date of birth 17 years ago + const meetsRequirement = ValidationUtils.meetsMinimumAgeRequirement(invalidDate); + expect(meetsRequirement).toBe(false); + }); + + test('Should return false for an invalid date', () => { + const invalidDate = '2023-07-32'; // Invalid date + const meetsRequirement = ValidationUtils.meetsMinimumAgeRequirement(invalidDate); + expect(meetsRequirement).toBe(false); + }); + }); + + describe('meetsMaximumAgeRequirement', () => { + test('Should return true for a date that meets the maximum age requirement', () => { + const validDate = moment().subtract(65, 'years').format(CONST.DATE.MOMENT_FORMAT_STRING); // Date of birth 65 years ago + const meetsRequirement = ValidationUtils.meetsMaximumAgeRequirement(validDate); + expect(meetsRequirement).toBe(true); + }); + + test('Should return false for a date that does not meet the maximum age requirement', () => { + const invalidDate = moment().subtract(151, 'years').format(CONST.DATE.MOMENT_FORMAT_STRING); // Date of birth 151 years ago + const meetsRequirement = ValidationUtils.meetsMaximumAgeRequirement(invalidDate); + expect(meetsRequirement).toBe(false); + }); + + test('Should return false for an invalid date', () => { + const invalidDate = '2023-07-32'; // Invalid date + const meetsRequirement = ValidationUtils.meetsMaximumAgeRequirement(invalidDate); + expect(meetsRequirement).toBe(false); + }); + }); + + describe('getAgeRequirementError', () => { + test('Should return an empty string for a date within the specified range', () => { + const validDate = moment().subtract(30, 'years').format(CONST.DATE.MOMENT_FORMAT_STRING); // Date of birth 30 years ago + const error = ValidationUtils.getAgeRequirementError(validDate, 18, 150); + expect(error).toBe(''); + }); + + test('Should return an error message for a date before the minimum age requirement', () => { + const invalidDate = moment().subtract(17, 'years').format(CONST.DATE.MOMENT_FORMAT_STRING); // Date of birth 17 years ago + const error = ValidationUtils.getAgeRequirementError(invalidDate, 18, 150); + expect(error).toEqual(['privatePersonalDetails.error.dateShouldBeBefore', {dateString: moment().subtract(18, 'years').startOf('day').format(CONST.DATE.MOMENT_FORMAT_STRING)}]); + }); + + test('Should return an error message for a date after the maximum age requirement', () => { + const invalidDate = moment().subtract(160, 'years').format(CONST.DATE.MOMENT_FORMAT_STRING); // Date of birth 160 years ago + const error = ValidationUtils.getAgeRequirementError(invalidDate, 18, 150); + expect(error).toEqual(['privatePersonalDetails.error.dateShouldBeAfter', {dateString: moment().subtract(150, 'years').startOf('day').format(CONST.DATE.MOMENT_FORMAT_STRING)}]); + }); + + test('Should return an error message for an invalid date', () => { + const invalidDate = '2023-07-32'; // Invalid date + const error = ValidationUtils.getAgeRequirementError(invalidDate, 18, 150); + expect(error).toBe('common.error.dateInvalid'); + }); + }); + describe('isValidTwoFactorCode', () => { test('numeric two-factor code', () => { expect(ValidationUtils.isValidTwoFactorCode('123456')).toBe(true);