diff --git a/src/feature-management/package-lock.json b/src/feature-management/package-lock.json index cb413c2..a4153b7 100644 --- a/src/feature-management/package-lock.json +++ b/src/feature-management/package-lock.json @@ -22,6 +22,7 @@ "rimraf": "^5.0.5", "rollup": "^4.22.4", "rollup-plugin-dts": "^6.1.0", + "sinon": "^18.0.0", "tslib": "^2.6.2", "typescript": "^5.3.3" } @@ -737,6 +738,55 @@ "win32" ] }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -1299,6 +1349,7 @@ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2126,6 +2177,13 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2163,6 +2221,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2232,6 +2297,7 @@ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -2369,6 +2435,30 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -2504,6 +2594,16 @@ "node": "14 || >=16.14" } }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -2860,6 +2960,25 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sinon": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.1.tgz", + "integrity": "sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.2.0", + "nise": "^6.0.0", + "supports-color": "^7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", diff --git a/src/feature-management/package.json b/src/feature-management/package.json index bc9a10b..ac13096 100644 --- a/src/feature-management/package.json +++ b/src/feature-management/package.json @@ -18,7 +18,7 @@ "dev": "rollup --config --watch", "lint": "eslint src/ test/ --ignore-pattern test/browser/testcases.js", "fix-lint": "eslint src/ test/ --fix --ignore-pattern test/browser/testcases.js", - "test": "mocha out/*.test.{js,cjs,mjs} --parallel", + "test": "mocha out/test/*.test.{js,cjs,mjs} --parallel", "test-browser": "npx playwright install chromium && npx playwright test" }, "repository": { @@ -47,6 +47,7 @@ "rimraf": "^5.0.5", "rollup": "^4.22.4", "rollup-plugin-dts": "^6.1.0", + "sinon": "^18.0.0", "tslib": "^2.6.2", "typescript": "^5.3.3" }, diff --git a/src/feature-management/src/filter/recurrence/evaluator.ts b/src/feature-management/src/filter/recurrence/evaluator.ts new file mode 100644 index 0000000..73c6fe1 --- /dev/null +++ b/src/feature-management/src/filter/recurrence/evaluator.ts @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { RecurrenceSpec, RecurrencePatternType, RecurrenceRangeType, DAYS_PER_WEEK, ONE_DAY_IN_MILLISECONDS } from "./model.js"; +import { calculateWeeklyDayOffset, sortDaysOfWeek, getDayOfWeek, addDays } from "./utils.js"; + +type RecurrenceState = { + previousOccurrence: Date; + numberOfOccurrences: number; +} + +/** + * Checks if a provided datetime is within any recurring time window specified by the recurrence information + * @param time A datetime + * @param recurrenceSpec The recurrence spcification + * @returns True if the given time is within any recurring time window; otherwise, false + */ +export function matchRecurrence(time: Date, recurrenceSpec: RecurrenceSpec): boolean { + const recurrenceState = findPreviousRecurrence(time, recurrenceSpec); + if (recurrenceState) { + return time.getTime() < recurrenceState.previousOccurrence.getTime() + recurrenceSpec.duration; + } + return false; +} + +/** + * Finds the closest previous recurrence occurrence before the given time according to the recurrence information + * @param time A datetime + * @param recurrenceSpec The recurrence specification + * @returns The recurrence state if any previous occurrence is found; otherwise, undefined + */ +function findPreviousRecurrence(time: Date, recurrenceSpec: RecurrenceSpec): RecurrenceState | undefined { + if (time < recurrenceSpec.startTime) { + return undefined; + } + let result: RecurrenceState; + const pattern = recurrenceSpec.pattern; + if (pattern.type === RecurrencePatternType.Daily) { + result = findPreviousDailyRecurrence(time, recurrenceSpec); + } else if (pattern.type === RecurrencePatternType.Weekly) { + result = findPreviousWeeklyRecurrence(time, recurrenceSpec); + } else { + throw new Error("Unsupported recurrence pattern type."); + } + const { previousOccurrence, numberOfOccurrences } = result; + + const range = recurrenceSpec.range; + if (range.type === RecurrenceRangeType.EndDate) { + if (previousOccurrence > range.endDate!) { + return undefined; + } + } else if (range.type === RecurrenceRangeType.Numbered) { + if (numberOfOccurrences > range.numberOfOccurrences!) { + return undefined; + } + } + return result; +} + +function findPreviousDailyRecurrence(time: Date, recurrenceSpec: RecurrenceSpec): RecurrenceState { + const startTime = recurrenceSpec.startTime; + const timeGap = time.getTime() - startTime.getTime(); + const pattern = recurrenceSpec.pattern; + const numberOfIntervals = Math.floor(timeGap / (pattern.interval * ONE_DAY_IN_MILLISECONDS)); + return { + previousOccurrence: addDays(startTime, numberOfIntervals * pattern.interval), + numberOfOccurrences: numberOfIntervals + 1 + }; +} + +function findPreviousWeeklyRecurrence(time: Date, recurrenceSpec: RecurrenceSpec): RecurrenceState { + /* + * Algorithm: + * 1. first find day 0 (d0), it's the day representing the start day on the week of `Start`. + * 2. find start day of the most recent occurring week d0 + floor((time - d0) / (interval * 7)) * (interval * 7) + * 3. if that's over 7 days ago, then previous occurence is the day with the max offset of the last occurring week + * 4. if gotten this far, then the current week is the most recent occurring week: + i. if time > day with min offset, then previous occurence is the day with max offset less than current + ii. if time < day with min offset, then previous occurence is the day with the max offset of previous occurring week + */ + const startTime = recurrenceSpec.startTime; + const startDay = getDayOfWeek(startTime, recurrenceSpec.timezoneOffset); + const pattern = recurrenceSpec.pattern; + const sortedDaysOfWeek = sortDaysOfWeek(pattern.daysOfWeek!, pattern.firstDayOfWeek!); + + /* + * Example: + * startTime = 2024-12-11 (Tue) + * pattern.interval = 2 pattern.firstDayOfWeek = Sun pattern.daysOfWeek = [Wed, Sun] + * sortedDaysOfWeek = [Sun, Wed] + * firstDayofStartWeek = 2024-12-08 (Sun) + * + * time = 2024-12-23 (Mon) timeGap = 15 days + * the most recent occurring week: 2024-12-22 ~ 2024-12-28 + * number of intervals before the most recent occurring week = 15 / (2 * 7) = 1 (2024-12-08 ~ 2023-12-21) + * number of occurrences before the most recent occurring week = 1 * 2 - 1 = 1 (2024-12-11) + * firstDayOfLastOccurringWeek = 2024-12-22 + */ + const firstDayofStartWeek = addDays(startTime, -calculateWeeklyDayOffset(startDay, pattern.firstDayOfWeek!)); + const timeGap = time.getTime() - firstDayofStartWeek.getTime(); + // number of intervals before the most recent occurring week + const numberOfIntervals = Math.floor(timeGap / (pattern.interval * DAYS_PER_WEEK * ONE_DAY_IN_MILLISECONDS)); + // number of occurrences before the most recent occurring week, it is possible to be negative + let numberOfOccurrences = numberOfIntervals * sortedDaysOfWeek.length - sortedDaysOfWeek.indexOf(startDay); + const firstDayOfLatestOccurringWeek = addDays(firstDayofStartWeek, numberOfIntervals * pattern.interval * DAYS_PER_WEEK); + + // the current time is out of the last occurring week + if (time > addDays(firstDayOfLatestOccurringWeek, DAYS_PER_WEEK)) { + numberOfOccurrences += sortDaysOfWeek.length; + // day with max offset in the last occurring week + const previousOccurrence = addDays(firstDayOfLatestOccurringWeek, calculateWeeklyDayOffset(sortedDaysOfWeek.at(-1)!, pattern.firstDayOfWeek!)); + return { + previousOccurrence: previousOccurrence, + numberOfOccurrences: numberOfOccurrences + }; + } + + let dayWithMinOffset = addDays(firstDayOfLatestOccurringWeek, calculateWeeklyDayOffset(sortedDaysOfWeek[0], pattern.firstDayOfWeek!)); + if (dayWithMinOffset < startTime) { + numberOfOccurrences = 0; + dayWithMinOffset = startTime; + } + let previousOccurrence; + if (time >= dayWithMinOffset) { + // the previous occurence is the day with max offset less than current + previousOccurrence = dayWithMinOffset; + numberOfOccurrences += 1; + const dayWithMinOffsetIndex = sortedDaysOfWeek.indexOf(getDayOfWeek(dayWithMinOffset, recurrenceSpec.timezoneOffset)); + for (let i = dayWithMinOffsetIndex + 1; i < sortedDaysOfWeek.length; i++) { + const day = addDays(firstDayOfLatestOccurringWeek, calculateWeeklyDayOffset(sortedDaysOfWeek[i], pattern.firstDayOfWeek!)); + if (time < day) { + break; + } + previousOccurrence = day; + numberOfOccurrences += 1; + } + } else { + const firstDayOfPreviousOccurringWeek = addDays(firstDayOfLatestOccurringWeek, -pattern.interval * DAYS_PER_WEEK); + // the previous occurence is the day with the max offset of previous occurring week + previousOccurrence = addDays(firstDayOfPreviousOccurringWeek, calculateWeeklyDayOffset(sortedDaysOfWeek.at(-1)!, pattern.firstDayOfWeek!)); + } + return { + previousOccurrence: previousOccurrence, + numberOfOccurrences: numberOfOccurrences + }; +} diff --git a/src/feature-management/src/filter/recurrence/model.ts b/src/feature-management/src/filter/recurrence/model.ts new file mode 100644 index 0000000..88d9602 --- /dev/null +++ b/src/feature-management/src/filter/recurrence/model.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export const DAYS_PER_WEEK = 7; +export const ONE_DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; + +export enum DayOfWeek { + Sunday = 0, + Monday = 1, + Tuesday = 2, + Wednesday = 3, + Thursday = 4, + Friday = 5, + Saturday = 6 +} + +/** + * The recurrence pattern describes the frequency by which the time window repeats + */ +export enum RecurrencePatternType { + /** + * The pattern where the time window will repeat based on the number of days specified by interval between occurrences + */ + Daily, + /** + * The pattern where the time window will repeat on the same day or days of the week, based on the number of weeks between each set of occurrences + */ + Weekly +} + +/** + * The recurrence range specifies the date range over which the time window repeats + */ +export enum RecurrenceRangeType { + /** + * The recurrence has no end and repeats on all the days that fit the corresponding pattern + */ + NoEnd, + /** + * The recurrence repeats on all the days that fit the corresponding pattern until or on the specified end date + */ + EndDate, + /** + * The recurrence repeats for the specified number of occurrences that match the pattern + */ + Numbered +} + +/** + * The recurrence pattern describes the frequency by which the time window repeats + */ +export type RecurrencePattern = { + /** + * The type of the recurrence pattern + */ + type: RecurrencePatternType; + /** + * The number of units between occurrences, where units can be in days or weeks, depending on the pattern type + */ + interval: number; + /** + * The days of the week when the time window occurs, which is only applicable for 'Weekly' pattern + */ + daysOfWeek?: DayOfWeek[]; + /** + * The first day of the week, which is only applicable for 'Weekly' pattern + */ + firstDayOfWeek?: DayOfWeek; +}; + +/** + * The recurrence range describes a date range over which the time window repeats + */ +export type RecurrenceRange = { + /** + * The type of the recurrence range + */ + type: RecurrenceRangeType; + /** + * The date to stop applying the recurrence pattern, which is only applicable for 'EndDate' range + */ + endDate?: Date; + /** + * The number of times to repeat the time window, which is only applicable for 'Numbered' range + */ + numberOfOccurrences?: number; +}; + +/** + * Specification defines the recurring time window + */ +export type RecurrenceSpec = { + /** + * The start time of the first/base time window + */ + startTime: Date; + /** + * The duration of each time window in milliseconds + */ + duration: number; + /** + * The recurrence pattern + */ + pattern: RecurrencePattern; + /** + * The recurrence range + */ + range: RecurrenceRange; + /** + * The timezone offset in milliseconds, which helps to determine the day of week of a given date + */ + timezoneOffset: number; +}; diff --git a/src/feature-management/src/filter/recurrence/utils.ts b/src/feature-management/src/filter/recurrence/utils.ts new file mode 100644 index 0000000..41acc66 --- /dev/null +++ b/src/feature-management/src/filter/recurrence/utils.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { DayOfWeek, DAYS_PER_WEEK } from "./model.js"; + +/** + * Calculates the offset in days between two given days of the week. + * @param day1 A day of week + * @param day2 A day of week + * @returns The number of days to be added to day2 to reach day1 + */ +export function calculateWeeklyDayOffset(day1: DayOfWeek, day2: DayOfWeek): number { + return (day1 - day2 + DAYS_PER_WEEK) % DAYS_PER_WEEK; +} + +/** + * Sorts a collection of days of week based on their offsets from a specified first day of week. + * @param daysOfWeek A collection of days of week + * @param firstDayOfWeek The first day of week which will be the first element in the sorted result + * @returns The sorted days of week + */ +export function sortDaysOfWeek(daysOfWeek: DayOfWeek[], firstDayOfWeek: DayOfWeek): DayOfWeek[] { + const sortedDaysOfWeek = daysOfWeek.slice(); + sortedDaysOfWeek.sort((x, y) => calculateWeeklyDayOffset(x, firstDayOfWeek) - calculateWeeklyDayOffset(y, firstDayOfWeek)); + return sortedDaysOfWeek; +} + +/** + * Gets the day of week of a given date based on the timezone offset. + * @param date A UTC date + * @param timezoneOffsetInMs The timezone offset in milliseconds + * @returns The day of week (0 for Sunday, 1 for Monday, ..., 6 for Saturday) + */ +export function getDayOfWeek(date: Date, timezoneOffsetInMs: number): number { + const alignedDate = new Date(date.getTime() + timezoneOffsetInMs); + return alignedDate.getUTCDay(); +} + +/** + * Adds a specified number of days to a given date. + * @param date The date to add days to + * @param days The number of days to add + * @returns The new date + */ +export function addDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +} diff --git a/src/feature-management/src/filter/recurrence/validator.ts b/src/feature-management/src/filter/recurrence/validator.ts new file mode 100644 index 0000000..d10ae23 --- /dev/null +++ b/src/feature-management/src/filter/recurrence/validator.ts @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { RecurrenceParameters } from "../timeWindowFilter.js"; +import { VALUE_OUT_OF_RANGE_ERROR_MESSAGE, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE, buildInvalidParameterErrorMessage } from "../utils.js"; +import { DayOfWeek, RecurrenceSpec, RecurrencePattern, RecurrenceRange, RecurrencePatternType, RecurrenceRangeType, DAYS_PER_WEEK, ONE_DAY_IN_MILLISECONDS } from "./model.js"; +import { calculateWeeklyDayOffset, sortDaysOfWeek, getDayOfWeek } from "./utils.js"; + +export const START_NOT_MATCHED_ERROR_MESSAGE = "Start date is not a valid first occurrence."; +export const TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE = "Time window duration cannot be longer than how frequently it occurs or be longer than 10 years."; +export const PATTERN = "Recurrence.Pattern"; +export const PATTERN_TYPE = "Recurrence.Pattern.Type"; +export const INTERVAL = "Recurrence.Pattern.Interval"; +export const DAYS_OF_WEEK = "Recurrence.Pattern.DaysOfWeek"; +export const FIRST_DAY_OF_WEEK = "Recurrence.Pattern.FirstDayOfWeek"; +export const RANGE = "Recurrence.Range"; +export const RANGE_TYPE = "Recurrence.Range.Type"; +export const END_DATE = "Recurrence.Range.EndDate"; +export const NUMBER_OF_OCCURRENCES = "Recurrence.Range.NumberOfOccurrences"; + +/** + * Parses @see RecurrenceParameters into a @see RecurrenceSpec object. If the parameter is invalid, an error will be thrown. + * @param startTime The start time of the base time window + * @param day2 The end time of the base time window + * @param recurrenceParameters The @see RecurrenceParameters to parse + * @param TimeZoneOffset The time zone offset in milliseconds, by default 0 + * @returns A @see RecurrenceSpec object + */ +export function parseRecurrenceParameter(startTime: Date | undefined, endTime: Date | undefined, recurrenceParameters: RecurrenceParameters, TimeZoneOffset: number = 0): RecurrenceSpec { + if (startTime === undefined) { + throw new Error(buildInvalidParameterErrorMessage("Start", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + } + if (endTime === undefined) { + throw new Error(buildInvalidParameterErrorMessage("End", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + } + if (startTime >= endTime) { + throw new Error(buildInvalidParameterErrorMessage("End", VALUE_OUT_OF_RANGE_ERROR_MESSAGE)); + } + const timeWindowDuration = endTime.getTime() - startTime.getTime(); + if (timeWindowDuration > 10 * 365 * ONE_DAY_IN_MILLISECONDS) { // time window duration cannot be longer than 10 years + throw new Error(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE)); + } + + return { + startTime: startTime, + duration: timeWindowDuration, + pattern: parseRecurrencePattern(startTime, endTime, recurrenceParameters, TimeZoneOffset), + range: parseRecurrenceRange(startTime, recurrenceParameters), + timezoneOffset: TimeZoneOffset + }; +} + +function parseRecurrencePattern(startTime: Date, endTime: Date, recurrenceParameters: RecurrenceParameters, timeZoneOffset: number): RecurrencePattern { + const rawPattern = recurrenceParameters.Pattern; + if (rawPattern === undefined) { + throw new Error(buildInvalidParameterErrorMessage(PATTERN, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + } + if (rawPattern.Type === undefined) { + throw new Error(buildInvalidParameterErrorMessage(PATTERN_TYPE, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + } + const patternType = RecurrencePatternType[rawPattern.Type]; + if (patternType === undefined) { + throw new Error(buildInvalidParameterErrorMessage(PATTERN_TYPE, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + } + let interval = rawPattern.Interval; + if (interval !== undefined) { + if (typeof interval !== "number") { + throw new Error(buildInvalidParameterErrorMessage(INTERVAL, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + } else if (interval <= 0 || !Number.isInteger(interval)) { + throw new Error(buildInvalidParameterErrorMessage(INTERVAL, VALUE_OUT_OF_RANGE_ERROR_MESSAGE)); + } + } else { + interval = 1; + } + const parsedPattern: RecurrencePattern = { + type: patternType, + interval: interval + }; + const timeWindowDuration = endTime.getTime() - startTime.getTime(); + if (patternType === RecurrencePatternType.Daily) { + if (timeWindowDuration > interval * ONE_DAY_IN_MILLISECONDS) { + throw new Error(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE)); + } + } else if (patternType === RecurrencePatternType.Weekly) { + let firstDayOfWeek: DayOfWeek; + if (rawPattern.FirstDayOfWeek !== undefined) { + firstDayOfWeek = DayOfWeek[rawPattern.FirstDayOfWeek]; + if (firstDayOfWeek === undefined) { + throw new Error(buildInvalidParameterErrorMessage(FIRST_DAY_OF_WEEK, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + } + } + else { + firstDayOfWeek = DayOfWeek.Sunday; + } + parsedPattern.firstDayOfWeek = firstDayOfWeek; + + if (rawPattern.DaysOfWeek === undefined || rawPattern.DaysOfWeek.length === 0) { + throw new Error(buildInvalidParameterErrorMessage(DAYS_OF_WEEK, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + } + if (!Array.isArray(rawPattern.DaysOfWeek)) { + throw new Error(buildInvalidParameterErrorMessage(DAYS_OF_WEEK, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + } + const daysOfWeek = [...new Set(rawPattern.DaysOfWeek.map(day => DayOfWeek[day]))]; // dedup array + if (daysOfWeek.some(day => day === undefined)) { + throw new Error(buildInvalidParameterErrorMessage(DAYS_OF_WEEK, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + } + if (timeWindowDuration > interval * DAYS_PER_WEEK * ONE_DAY_IN_MILLISECONDS || + !isDurationCompliantWithDaysOfWeek(timeWindowDuration, interval, daysOfWeek, firstDayOfWeek)) { + throw new Error(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE)); + } + parsedPattern.daysOfWeek = daysOfWeek; + + // check whether "Start" is a valid first occurrence + const alignedStartDay = getDayOfWeek(startTime, timeZoneOffset); + if (!daysOfWeek.find(day => day === alignedStartDay)) { + throw new Error(buildInvalidParameterErrorMessage("Start", START_NOT_MATCHED_ERROR_MESSAGE)); + } + } + return parsedPattern; +} + +function parseRecurrenceRange(startTime: Date, recurrenceParameters: RecurrenceParameters): RecurrenceRange { + const rawRange = recurrenceParameters.Range; + if (rawRange === undefined) { + throw new Error(buildInvalidParameterErrorMessage(RANGE, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + } + if (rawRange.Type === undefined) { + throw new Error(buildInvalidParameterErrorMessage(RANGE_TYPE, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + } + const rangeType = RecurrenceRangeType[rawRange.Type]; + if (rangeType === undefined) { + throw new Error(buildInvalidParameterErrorMessage(RANGE_TYPE, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + } + const parsedRange: RecurrenceRange = { type: rangeType }; + if (rangeType === RecurrenceRangeType.EndDate) { + let endDate: Date; + if (rawRange.EndDate !== undefined) { + endDate = new Date(rawRange.EndDate); + if (isNaN(endDate.getTime())) { + throw new Error(buildInvalidParameterErrorMessage(END_DATE, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + } + if (endDate < startTime) { + throw new Error(buildInvalidParameterErrorMessage(END_DATE, VALUE_OUT_OF_RANGE_ERROR_MESSAGE)); + } + } else { + endDate = new Date(8.64e15); // the maximum date in ECMAScript: https://262.ecma-international.org/5.1/#sec-15.9.1.1 + } + parsedRange.endDate = endDate; + } else if (rangeType === RecurrenceRangeType.Numbered) { + let numberOfOccurrences = rawRange.NumberOfOccurrences; + if (numberOfOccurrences !== undefined) { + if (typeof numberOfOccurrences !== "number") { + throw new Error(buildInvalidParameterErrorMessage(NUMBER_OF_OCCURRENCES, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + } else if (numberOfOccurrences <= 0 || !Number.isInteger(numberOfOccurrences)) { + throw new Error(buildInvalidParameterErrorMessage(NUMBER_OF_OCCURRENCES, VALUE_OUT_OF_RANGE_ERROR_MESSAGE)); + } + } else { + numberOfOccurrences = Number.MAX_SAFE_INTEGER; + } + parsedRange.numberOfOccurrences = numberOfOccurrences; + } + return parsedRange; +} + +function isDurationCompliantWithDaysOfWeek(duration: number, interval: number, daysOfWeek: DayOfWeek[], firstDayOfWeek: DayOfWeek): boolean { + if (daysOfWeek.length === 1) { + return true; + } + const sortedDaysOfWeek = sortDaysOfWeek(daysOfWeek, firstDayOfWeek); + let prev = sortedDaysOfWeek[0]; // the closest occurrence day to the first day of week + let minGap = DAYS_PER_WEEK * ONE_DAY_IN_MILLISECONDS; + for (let i = 1; i < sortedDaysOfWeek.length; i++) { // skip the first day + const gap = calculateWeeklyDayOffset(sortedDaysOfWeek[i], prev) * ONE_DAY_IN_MILLISECONDS; + minGap = gap < minGap ? gap : minGap; + prev = sortedDaysOfWeek[i]; + } + // It may across weeks. Check the next week if the interval is one week. + if (interval == 1) { + const gap = calculateWeeklyDayOffset(sortedDaysOfWeek[0], prev) * ONE_DAY_IN_MILLISECONDS; + minGap = gap < minGap ? gap : minGap; + } + return minGap >= duration; +} diff --git a/src/feature-management/src/filter/timeWindowFilter.ts b/src/feature-management/src/filter/timeWindowFilter.ts index 90d310a..975f123 100644 --- a/src/feature-management/src/filter/timeWindowFilter.ts +++ b/src/feature-management/src/filter/timeWindowFilter.ts @@ -2,17 +2,35 @@ // Licensed under the MIT license. import { IFeatureFilter } from "./featureFilter.js"; +import { RecurrenceSpec } from "./recurrence/model.js"; +import { parseRecurrenceParameter } from "./recurrence/validator.js"; +import { matchRecurrence } from "./recurrence/evaluator.js"; +import { UNRECOGNIZABLE_VALUE_ERROR_MESSAGE, buildInvalidParameterErrorMessage } from "./utils.js"; + +type TimeWindowFilterEvaluationContext = { + featureName: string; + parameters: TimeWindowParameters; +}; -// [Start, End) type TimeWindowParameters = { Start?: string; End?: string; -} + Recurrence?: RecurrenceParameters; +}; -type TimeWindowFilterEvaluationContext = { - featureName: string; - parameters: TimeWindowParameters; -} +export type RecurrenceParameters = { + Pattern: { + Type: string; + Interval?: number; + DaysOfWeek?: string[]; + FirstDayOfWeek?: string; + }, + Range: { + Type: string; + EndDate?: string; + NumberOfOccurrences?: number; + } +}; export class TimeWindowFilter implements IFeatureFilter { readonly name: string = "Microsoft.TimeWindow"; @@ -22,12 +40,41 @@ export class TimeWindowFilter implements IFeatureFilter { const startTime = parameters.Start !== undefined ? new Date(parameters.Start) : undefined; const endTime = parameters.End !== undefined ? new Date(parameters.End) : undefined; + const baseErrorMessage = `The ${this.name} feature filter is not valid for feature ${featureName}. `; + if (startTime === undefined && endTime === undefined) { // If neither start nor end time is specified, then the filter is not applicable. - console.warn(`The ${this.name} feature filter is not valid for feature ${featureName}. It must specify either 'Start', 'End', or both.`); + console.warn(baseErrorMessage + "It must specify either 'Start', 'End', or both."); return false; } + + if (startTime !== undefined && isNaN(startTime.getTime())) { + console.warn(baseErrorMessage + buildInvalidParameterErrorMessage("Start", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + return false; + } + + if (endTime !== undefined && isNaN(endTime.getTime())) { + console.warn(baseErrorMessage + buildInvalidParameterErrorMessage("End", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + return false; + } + const now = new Date(); - return (startTime === undefined || startTime <= now) && (endTime === undefined || now < endTime); + + if ((startTime === undefined || startTime <= now) && (endTime === undefined || now < endTime)) { + return true; + } + + if (parameters.Recurrence !== undefined) { + let recurrence: RecurrenceSpec; + try { + recurrence = parseRecurrenceParameter(startTime, endTime, parameters.Recurrence); + } catch (error) { + console.warn(baseErrorMessage + error.message); + return false; + } + return matchRecurrence(now, recurrence); + } + + return false; } } diff --git a/src/feature-management/src/filter/utils.ts b/src/feature-management/src/filter/utils.ts new file mode 100644 index 0000000..62dde70 --- /dev/null +++ b/src/feature-management/src/filter/utils.ts @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export const VALUE_OUT_OF_RANGE_ERROR_MESSAGE = "The value is out of the accepted range."; +export const UNRECOGNIZABLE_VALUE_ERROR_MESSAGE = "The value is unrecognizable."; +export const REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE = "Value cannot be undefined or empty."; + +export function buildInvalidParameterErrorMessage(parameterName: string, additionalInfo?: string): string { + return `The ${parameterName} parameter is not valid. ` + (additionalInfo ?? ""); +} diff --git a/src/feature-management/test/featureEvaluation.test.ts b/src/feature-management/test/featureEvaluation.test.ts index 2729a2e..b7e857e 100644 --- a/src/feature-management/test/featureEvaluation.test.ts +++ b/src/feature-management/test/featureEvaluation.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import * as chai from "chai"; -import { FeatureManager, ConfigurationObjectFeatureFlagProvider, EvaluationResult, VariantAssignmentReason } from "../"; +import { FeatureManager, ConfigurationObjectFeatureFlagProvider, EvaluationResult, VariantAssignmentReason } from "../src/index.js"; const expect = chai.expect; let called: number = 0; diff --git a/src/feature-management/test/featureManager.test.ts b/src/feature-management/test/featureManager.test.ts index f7f6daa..a59848a 100644 --- a/src/feature-management/test/featureManager.test.ts +++ b/src/feature-management/test/featureManager.test.ts @@ -6,7 +6,7 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -import { FeatureManager, ConfigurationObjectFeatureFlagProvider, ConfigurationMapFeatureFlagProvider } from "../"; +import { FeatureManager, ConfigurationObjectFeatureFlagProvider, ConfigurationMapFeatureFlagProvider } from "../src/index.js"; describe("feature manager", () => { it("should load from json string", async () => { diff --git a/src/feature-management/test/noFilters.test.ts b/src/feature-management/test/noFilters.test.ts index 889a8d9..869aa0a 100644 --- a/src/feature-management/test/noFilters.test.ts +++ b/src/feature-management/test/noFilters.test.ts @@ -6,7 +6,7 @@ import * as chaiAsPromised from "chai-as-promised"; chai.use(chaiAsPromised); const expect = chai.expect; -import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "../"; +import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "../src/index.js"; const featureFlagsDataObject = { "feature_management": { diff --git a/src/feature-management/test/recurrence.test.ts b/src/feature-management/test/recurrence.test.ts new file mode 100644 index 0000000..97b40e2 --- /dev/null +++ b/src/feature-management/test/recurrence.test.ts @@ -0,0 +1,607 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { + parseRecurrenceParameter, + PATTERN, + PATTERN_TYPE, + INTERVAL, + DAYS_OF_WEEK, + FIRST_DAY_OF_WEEK, + RANGE, + RANGE_TYPE, + END_DATE, + NUMBER_OF_OCCURRENCES, + START_NOT_MATCHED_ERROR_MESSAGE, + TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE } from "../src/filter/recurrence/validator.js"; +import { VALUE_OUT_OF_RANGE_ERROR_MESSAGE, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE, buildInvalidParameterErrorMessage } from "../src/filter/utils.js"; +import { DayOfWeek, RecurrencePatternType, RecurrenceRangeType } from "../src/filter/recurrence/model"; +import { matchRecurrence } from "../src/filter/recurrence/evaluator.js"; + +describe("recurrence validator", () => { + it("should check general required parameter", () => { + const recurrence1 = { + Pattern: { + Type: "Daily" + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(undefined, new Date(), recurrence1)).to.throw(buildInvalidParameterErrorMessage("Start", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + expect(() => parseRecurrenceParameter(new Date(), undefined, recurrence1)).to.throw(buildInvalidParameterErrorMessage("End", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + const recurrence2 = { + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence2 as any)).to.throw(buildInvalidParameterErrorMessage(PATTERN, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + const recurrence3 = { + Pattern: { + Type: "Daily" + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence3 as any)).to.throw(buildInvalidParameterErrorMessage(RANGE, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + }); + + it("should check pattern and range required parameter", () => { + const recurrence1 = { + Pattern: {}, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence1 as any)).to.throw(buildInvalidParameterErrorMessage(PATTERN_TYPE, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + const recurrence2 = { + Pattern: { + Type: "Weekly" + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence2)).to.throw(buildInvalidParameterErrorMessage(DAYS_OF_WEEK, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + const recurrence3 = { + Pattern: { + Type: "Weekly", + DaysOfWeek: [] + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence3)).to.throw(buildInvalidParameterErrorMessage(DAYS_OF_WEEK, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + const recurrence4 = { + Pattern: { + Type: "Daily" + }, + Range: {} + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence4 as any)).to.throw(buildInvalidParameterErrorMessage(RANGE_TYPE, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); + + }); + + it("should check invalid value", () => { + const recurrence1 = { + Pattern: { + Type: "Daily", + Interval: "1" + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence1 as any)).to.throw(buildInvalidParameterErrorMessage(INTERVAL, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + const recurrence2 = { + Pattern: { + Type: "Daily", + Interval: 0 + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence2)).to.throw(buildInvalidParameterErrorMessage(INTERVAL, VALUE_OUT_OF_RANGE_ERROR_MESSAGE)); + const recurrence3 = { + Pattern: { + Type: "Daily" + }, + Range: { + Type: "Numbered", + NumberOfOccurrences: "1" + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence3 as any)).to.throw(buildInvalidParameterErrorMessage(NUMBER_OF_OCCURRENCES, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + const recurrence4 = { + Pattern: { + Type: "Daily" + }, + Range: { + Type: "Numbered", + NumberOfOccurrences: 0 + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence4)).to.throw(buildInvalidParameterErrorMessage(NUMBER_OF_OCCURRENCES, VALUE_OUT_OF_RANGE_ERROR_MESSAGE)); + const recurrence5 = { + Pattern: { + Type: "Weekly", + DaysOfWeek: ["Monday", "Tue"] + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence5)).to.throw(buildInvalidParameterErrorMessage(DAYS_OF_WEEK, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + const recurrence6 = { + Pattern: { + Type: "Weekly", + DaysOfWeek: "Monday" + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence6 as any)).to.throw(buildInvalidParameterErrorMessage(DAYS_OF_WEEK, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + const recurrence7 = { + Pattern: { + Type: "Weekly", + DaysOfWeek: ["Monday"], + FirstDayOfWeek: "Mon" + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence7)).to.throw(buildInvalidParameterErrorMessage(FIRST_DAY_OF_WEEK, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + const recurrence8 = { + Pattern: { + Type: "Daily" + }, + Range: { + Type: "EndDate", + EndDate: "AppConfig" + } + }; + expect(() => parseRecurrenceParameter(new Date(1), new Date(2), recurrence8)).to.throw(buildInvalidParameterErrorMessage(END_DATE, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); + const recurrence9 = { + Pattern: { + Type: "Daily" + }, + Range: { + Type: "EndDate", + EndDate: "2024-12-10T00:00:00+0000" + } + }; + expect(() => parseRecurrenceParameter(new Date("2024-12-11T00:00:00+0000"), new Date("2024-12-11T00:00:01+0000"), recurrence9)).to.throw(buildInvalidParameterErrorMessage(END_DATE, VALUE_OUT_OF_RANGE_ERROR_MESSAGE)); + }); + + it("should check time window duration", () => { + const recurrence1 = { + Pattern: { + Type: "Daily" + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date(2), new Date(1), recurrence1)).to.throw(buildInvalidParameterErrorMessage("End", VALUE_OUT_OF_RANGE_ERROR_MESSAGE)); + const recurrence2 = { + Pattern: { + Type: "Daily" + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date("2024-12-10T00:00:00+0000"), new Date("2024-12-12T00:00:00+0000"), recurrence2)).to.throw(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE)); + const recurrence3 = { + Pattern: { + Type: "Weekly", + DaysOfWeek: ["Monday"] + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date("2024-12-09T00:00:00+0000"), new Date("2024-12-16T00:00:01+0000"), recurrence3)).to.throw(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE)); + const recurrence4 = { + Pattern: { + Type: "Weekly", + DaysOfWeek: ["Monday", "Thursday", "Sunday"] + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date("2024-12-09T00:00:00+0000"), new Date("2024-12-11T00:00:01+0000"), recurrence4)).to.throw(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE)); + const recurrence5 = { + Pattern: { + Type: "Weekly", + DaysOfWeek: ["Monday", "Saturday"] + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date("2024-12-09T00:00:00+0000"), new Date("2024-12-11T00:00:01+0000"), recurrence5)).to.throw(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE)); + const recurrence6 = { + Pattern: { + Type: "Weekly", + DaysOfWeek: ["Tuesday", "Saturday"] + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date("2024-01-16T00:00:00+0000"), new Date("2024-01-19T00:00:00+0000"), recurrence6)).to.not.throw(); + const recurrence7 = { + Pattern: { + Type: "Weekly", + Interval: 2, + DaysOfWeek: ["Monday", "Sunday"], + FirstDayOfWeek: "Monday" + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date("2024-01-15T00:00:00+0000"), new Date("2024-01-19T00:00:00+0000"), recurrence7)).to.not.throw(); + const recurrence8 = { + Pattern: { + Type: "Weekly", + Interval: 1, + DaysOfWeek: ["Monday", "Saturday"], + FirstDayOfWeek: "Sunday" + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date("2024-01-15T00:00:00+0000"), new Date("2024-01-17T00:00:00+0000"), recurrence8)).to.not.throw(); + expect(() => parseRecurrenceParameter(new Date("2024-01-15T00:00:00+0000"), new Date("2024-01-17T00:00:01+0000"), recurrence8)).to.throw(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE)); + const recurrence9 = { + Pattern: { + Type: "Weekly", + Interval: 1, + DaysOfWeek: ["Monday", "Sunday"], + FirstDayOfWeek: "Monday" + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter(new Date("2024-01-15T00:00:00+0000"), new Date("2024-01-19T00:00:00+0000"), recurrence9)).to.throw(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE)); + }); + + it("should check whether start is a valid first occurrence", () => { + const recurrence1 = { + Pattern: { + Type: "Weekly", + DaysOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday", "Saturday", "Sunday"] + }, + Range: { + Type: "NoEnd" + } + }; + expect(() => parseRecurrenceParameter( + new Date("2023-09-01T00:00:00+08:00"), + new Date("2023-09-01T00:00:01+08:00"), + recurrence1, + 8 * 24 * 60 * 60 * 1000) + ).to.throw(buildInvalidParameterErrorMessage("Start", START_NOT_MATCHED_ERROR_MESSAGE)); + }); +}); + +describe("recurrence evaluator", () => { + it("should match daily recurrence", () => { + const spec1 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: {type: RecurrencePatternType.Daily, interval: 1}, + range: {type: RecurrenceRangeType.NoEnd} + }; + expect(matchRecurrence(new Date("2023-09-02T00:00:00+08:00"), spec1 as any)).to.be.true; + const spec2 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: {type: RecurrencePatternType.Daily, interval: 2}, + range: {type: RecurrenceRangeType.NoEnd} + }; + expect(matchRecurrence(new Date("2023-09-02T00:00:00+08:00"), spec2 as any)).to.be.false; + expect(matchRecurrence(new Date("2023-09-03T00:00:00+08:00"), spec2 as any)).to.be.true; + const spec3 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 2 * 24 * 60 * 60 * 1000, + pattern: {type: RecurrencePatternType.Daily, interval: 4}, + range: {type: RecurrenceRangeType.NoEnd} + }; + expect(matchRecurrence(new Date("2023-09-05T00:00:00+08:00"), spec3 as any)).to.be.true; + expect(matchRecurrence(new Date("2023-09-06T00:00:00+08:00"), spec3 as any)).to.be.true; + expect(matchRecurrence(new Date("2023-09-09T00:00:00+08:00"), spec3 as any)).to.be.true; + const spec4 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: {type: RecurrencePatternType.Daily, interval: 1}, + range: {type: RecurrenceRangeType.Numbered, numberOfOccurrences: 2} + }; + expect(matchRecurrence(new Date("2023-09-02T00:00:00+08:00"), spec4 as any)).to.be.true; + expect(matchRecurrence(new Date("2023-09-03T00:00:00+08:00"), spec4 as any)).to.be.false; + const spec5 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: {type: RecurrencePatternType.Daily, interval: 1}, + range: {type: RecurrenceRangeType.EndDate, endDate: new Date("2023-09-03T00:00:00+08:00")} + }; + expect(matchRecurrence(new Date("2023-09-04T00:00:00+08:00"), spec5 as any)).to.be.false; + const spec6 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 12 * 60 * 60 * 1000 + 1000, + pattern: {type: RecurrencePatternType.Daily, interval: 2}, + range: {type: RecurrenceRangeType.NoEnd} + }; + expect(matchRecurrence(new Date("2023-09-02T16:00:00+0000"), spec6 as any)).to.be.true; + expect(matchRecurrence(new Date("2023-09-02T15:59:59+0000"), spec6 as any)).to.be.false; + }); + it("should match weekly recurrence", () => { + const spec1 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 1, + daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Friday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.NoEnd}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-04T00:00:00+08:00"), spec1)).to.be.true; + expect(matchRecurrence(new Date("2023-09-08T00:00:00+08:00"), spec1)).to.be.true; + const spec2 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 2, + daysOfWeek: [DayOfWeek.Friday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.NoEnd}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-08T00:00:00+08:00"), spec2)).to.be.false; + expect(matchRecurrence(new Date("2023-09-15T00:00:00+08:00"), spec2)).to.be.true; + const spec3 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 1, + daysOfWeek: [DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.NoEnd}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-04T00:00:00+08:00"), spec3)).to.be.false; + const spec4 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 1, + daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.Numbered, numberOfOccurrences: 1}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-02T00:00:00+08:00"), spec4)).to.be.false; + const spec5 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 1, + daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.Numbered, numberOfOccurrences: 2}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-03T00:00:00+08:00"), spec5)).to.be.false; + const spec6 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 1, + daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.Numbered, numberOfOccurrences: 3}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-03T00:00:00+08:00"), spec6)).to.be.true; + const spec7 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 1, + daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.Numbered, numberOfOccurrences: 7}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-08T00:00:00+08:00"), spec7)).to.be.false; + const spec8 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 1, + daysOfWeek: [DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.Numbered, numberOfOccurrences: 8}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-08T00:00:00+08:00"), spec8)).to.be.true; + const spec9 = { + startTime: new Date("2024-01-04T00:00:00+08:00"), + duration: 60 * 60 * 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 2, + daysOfWeek: [DayOfWeek.Tuesday, DayOfWeek.Thursday, DayOfWeek.Friday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.Numbered, numberOfOccurrences: 3}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2024-01-18T00:30:00+08:00"), spec9)).to.be.false; + const spec10 = { + startTime: new Date("2024-01-04T00:00:00+08:00"), + duration: 60 * 60 * 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 2, + daysOfWeek: [DayOfWeek.Tuesday, DayOfWeek.Thursday, DayOfWeek.Friday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.Numbered, numberOfOccurrences: 4}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2024-01-18T00:30:00+08:00"), spec10)).to.be.true; + const spec11 = { + startTime: new Date("2023-09-03T00:00:00+08:00"), + duration: 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 2, + daysOfWeek: [DayOfWeek.Sunday, DayOfWeek.Monday], + firstDayOfWeek: DayOfWeek.Monday + }, + range: {type: RecurrenceRangeType.NoEnd}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-04T00:00:00+08:00"), spec11)).to.be.false; + expect(matchRecurrence(new Date("2023-09-18T00:00:00+08:00"), spec11)).to.be.false; + const spec12 = { + startTime: new Date("2023-09-03T00:00:00+08:00"), + duration: 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 2, + daysOfWeek: [DayOfWeek.Sunday, DayOfWeek.Monday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.NoEnd}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-04T00:00:00+08:00"), spec12)).to.be.true; + expect(matchRecurrence(new Date("2023-09-18T00:00:00+08:00"), spec12)).to.be.true; + const spec13 = { + startTime: new Date("2023-09-03T00:00:00+08:00"), + duration: 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 2, + daysOfWeek: [DayOfWeek.Sunday, DayOfWeek.Monday], + firstDayOfWeek: DayOfWeek.Monday + }, + range: {type: RecurrenceRangeType.Numbered, numberOfOccurrences: 3}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-17T00:00:00+08:00"), spec13)).to.be.true; + const spec14 = { + startTime: new Date("2024-02-02T12:00:00+08:00"), + duration: 24 * 60 * 60 * 1000 + 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 2, + daysOfWeek: [DayOfWeek.Friday, DayOfWeek.Monday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.NoEnd}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2024-02-12T08:00:00+08:00"), spec14)).to.be.false; + const spec15 = { + startTime: new Date("2023-09-03T00:00:00+08:00"), + duration: 4 * 24 * 60 * 60 * 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 2, + daysOfWeek: [DayOfWeek.Sunday, DayOfWeek.Monday], + firstDayOfWeek: DayOfWeek.Monday + }, + range: {type: RecurrenceRangeType.NoEnd}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-13T00:00:00+08:00"), spec15)).to.be.true; + const spec16 = { + startTime: new Date("2023-09-03T00:00:00+08:00"), + duration: 4 * 24 * 60 * 60 * 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 2, + daysOfWeek: [DayOfWeek.Sunday, DayOfWeek.Monday], + firstDayOfWeek: DayOfWeek.Monday + }, + range: {type: RecurrenceRangeType.Numbered, numberOfOccurrences: 3}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-19T00:00:00+08:00"), spec16)).to.be.true; + const spec17 = { + startTime: new Date("2023-09-03T00:00:00+08:00"), + duration: 4 * 24 * 60 * 60 * 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 2, + daysOfWeek: [DayOfWeek.Sunday, DayOfWeek.Monday], + firstDayOfWeek: DayOfWeek.Monday + }, + range: {type: RecurrenceRangeType.Numbered, numberOfOccurrences: 2}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-19T00:00:00+08:00"), spec17)).to.be.false; + const spec18 = { + startTime: new Date("2023-09-01T00:00:00+08:00"), + duration: 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 1, + daysOfWeek: [DayOfWeek.Friday, DayOfWeek.Monday], + firstDayOfWeek: DayOfWeek.Sunday + }, + range: {type: RecurrenceRangeType.NoEnd}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-03T16:00:00+00:00"), spec18)).to.be.true; + expect(matchRecurrence(new Date("2023-09-07T16:00:00+00:00"), spec18)).to.be.true; + expect(matchRecurrence(new Date("2023-09-03T15:59:59+00:00"), spec18)).to.be.false; + expect(matchRecurrence(new Date("2023-09-07T15:59:59+00:00"), spec18)).to.be.false; + const spec19 = { + startTime: new Date("2023-09-03T00:00:00+08:00"), + duration: 4 * 24 * 60 * 60 * 1000, + pattern: { + type: RecurrencePatternType.Weekly, + interval: 2, + daysOfWeek: [DayOfWeek.Sunday, DayOfWeek.Monday], + firstDayOfWeek: DayOfWeek.Monday + }, + range: {type: RecurrenceRangeType.NoEnd}, + timezoneOffset: 8 * 60 * 60 * 1000 + }; + expect(matchRecurrence(new Date("2023-09-10T16:00:00+00:00"), spec19)).to.be.true; + expect(matchRecurrence(new Date("2023-09-10T15:59:59+00:00"), spec19)).to.be.false; + }); +}); diff --git a/src/feature-management/test/targetingFilter.test.ts b/src/feature-management/test/targetingFilter.test.ts index 6da25f6..8e2d380 100644 --- a/src/feature-management/test/targetingFilter.test.ts +++ b/src/feature-management/test/targetingFilter.test.ts @@ -3,7 +3,7 @@ import * as chai from "chai"; import * as chaiAsPromised from "chai-as-promised"; -import { FeatureManager, ConfigurationMapFeatureFlagProvider } from "../"; +import { FeatureManager, ConfigurationMapFeatureFlagProvider } from "../src/index.js"; chai.use(chaiAsPromised); const expect = chai.expect; diff --git a/src/feature-management/test/timeWindowFilter.test.ts b/src/feature-management/test/timeWindowFilter.test.ts new file mode 100644 index 0000000..60610d6 --- /dev/null +++ b/src/feature-management/test/timeWindowFilter.test.ts @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as chai from "chai"; +import * as chaiAsPromised from "chai-as-promised"; +import * as sinon from "sinon"; +chai.use(chaiAsPromised); +const expect = chai.expect; + +import { FeatureManager, ConfigurationMapFeatureFlagProvider } from "../src/index.js"; + +const createTimeWindowFeature = (name: string, description: string, parameters: any) => { + const featureFlag = { + "id": name, + "description": description, + "enabled": true, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.TimeWindow", + "parameters": parameters + } + ] + } + }; + + return featureFlag; +}; + +describe("time window filter", () => { + it("should evaluate basic time window", async () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + createTimeWindowFeature("PastTimeWindow", + "A feature flag using a time window filter, that is active from 2023-06-29 07:00:00 to 2023-08-30 07:00:00. Will always return false as the current time is outside the time window.", + { + "Start": "Thu, 29 Jun 2023 07:00:00 GMT", + "End": "Wed, 30 Aug 2023 07:00:00 GMT" + } + ), + createTimeWindowFeature("FutureTimeWindow", + "A feature flag using a time window filter, that is active from 3023-06-27 06:00:00 to 3023-06-28 06:05:00. Will always return false as the time window has yet been reached.", + { + "Start": "Fri, 27 Jun 3023 06:00:00 GMT", + "End": "Sat, 28 Jun 3023 06:05:00 GMT" + } + ), + createTimeWindowFeature("PresentTimeWindow", + "A feature flag using a time window filter, that is active from 2023-06-27 06:00:00 to 3023-06-28 06:05:00. Will always return true as we are in the time window.", + { + "Start": "Thu, 29 Jun 2023 07:00:00 GMT", + "End": "Sat, 28 Jun 3023 06:05:00 GMT" + } + ), + createTimeWindowFeature("StartedTimeWindow", + "A feature flag using a time window filter, that will always return true as the current time is within the time window.", + { + "Start": "Tue, 27 Jun 2023 06:00:00 GMT" + } + ), + createTimeWindowFeature("WillEndTimeWindow", + "A feature flag using a time window filter, that will always return true as the current time is within the time window.", + { + "End": "Sat, 28 Jun 3023 06:05:00 GMT" + } + ) + ] + }); + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + + // specify the date you want to mock + const fakeDate = new Date(2024, 12, 10); // Dec 10, 2024 + const clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("PastTimeWindow")).eq(false); + expect(await featureManager.isEnabled("PresentTimeWindow")).eq(true); + expect(await featureManager.isEnabled("FutureTimeWindow")).eq(false); + expect(await featureManager.isEnabled("StartedTimeWindow")).eq(true); + expect(await featureManager.isEnabled("WillEndTimeWindow")).eq(true); + clock.restore(); + }); + + it("should evaluate recurring time window", async () => { + const dataSource = new Map(); + dataSource.set("feature_management", { + feature_flags: [ + createTimeWindowFeature("DailyTimeWindow", + "A feature flag using a recurring time window filter, that is active from 18:00:00 to 20:00:00 every other day since 2024-12-10, until 2025-1-1.", + { + "Start": "Tue, 10 Dec 2024 18:00:00 GMT", + "End": "Tue, 10 Dec 2024 20:00:00 GMT", + "Recurrence": { + "Pattern": { + "Type": "Daily", + "Interval": 2 + }, + "Range": { + "Type": "EndDate", + "EndDate": "Wed, 1 Jan 2025 20:00:00 GMT" + } + } + } + ) + ] + }); + const provider = new ConfigurationMapFeatureFlagProvider(dataSource); + const featureManager = new FeatureManager(provider); + + // daily recurring time window + let fakeDate = new Date("2024-12-10T17:59:59+0000"); + let clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(false); + clock.restore(); + + fakeDate = new Date("2024-12-10T18:00:00+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2024-12-10T19:59:59+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2024-12-10T20:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(false); + clock.restore(); + + fakeDate = new Date("2024-12-11T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(false); + clock.restore(); + + fakeDate = new Date("2024-12-12T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2024-12-12T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2024-12-12T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2024-12-24T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2024-12-25T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(false); + clock.restore(); + + fakeDate = new Date("2025-01-01T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2025-01-01T20:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(false); + clock.restore(); + + fakeDate = new Date("2025-01-03T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("DailyTimeWindow")).eq(false); + clock.restore(); + + // weekly recurring time window + dataSource.set("feature_management", { + feature_flags: [ + createTimeWindowFeature("WeeklyTimeWindow", + "A feature flag using a recurring time window filter, that is active from 18:00:00 to 20:00:00 every weekday since 2024-12-10, until the time window recurs for 10 times.", + { + "Start": "Tue, 10 Dec 2024 18:00:00 GMT", + "End": "Tue, 10 Dec 2024 20:00:00 GMT", + "Recurrence": { + "Pattern": { + "Type": "Weekly", + "Interval": 1, + "DaysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] + }, + "Range": { + "Type": "Numbered", + "NumberOfOccurrences": 10 + } + } + } + ) + ] + }); + fakeDate = new Date("2024-12-10T17:59:59+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("WeeklyTimeWindow")).eq(false); + clock.restore(); + + fakeDate = new Date("2024-12-10T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("WeeklyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2024-12-11T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("WeeklyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2024-12-12T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("WeeklyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2024-12-13T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("WeeklyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2024-12-14T18:00:01+0000"); // Saturday + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("WeeklyTimeWindow")).eq(false); + clock.restore(); + + fakeDate = new Date("2024-12-15T18:00:01+0000"); // Sunday + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("WeeklyTimeWindow")).eq(false); + clock.restore(); + + fakeDate = new Date("2024-12-16T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("WeeklyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2024-12-16T20:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("WeeklyTimeWindow")).eq(false); + clock.restore(); + + fakeDate = new Date("2024-12-23T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("WeeklyTimeWindow")).eq(true); + clock.restore(); + + fakeDate = new Date("2024-12-24T18:00:01+0000"); + clock = sinon.useFakeTimers(fakeDate.getTime()); + expect(await featureManager.isEnabled("WeeklyTimeWindow")).eq(false); + clock.restore(); + }); +}); diff --git a/src/feature-management/test/variant.test.ts b/src/feature-management/test/variant.test.ts index edff9a0..823f68c 100644 --- a/src/feature-management/test/variant.test.ts +++ b/src/feature-management/test/variant.test.ts @@ -3,7 +3,7 @@ import * as chai from "chai"; import * as chaiAsPromised from "chai-as-promised"; -import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "../"; +import { FeatureManager, ConfigurationObjectFeatureFlagProvider } from "../src/index.js"; import { Features, featureFlagsConfigurationObject } from "./sampleVariantFeatureFlags.js"; chai.use(chaiAsPromised); const expect = chai.expect;