diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java index a49837a0fe46..daabaca43938 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java @@ -6,6 +6,8 @@ import java.time.DayOfWeek; import java.time.Duration; import java.time.ZonedDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; import java.util.List; import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; @@ -96,10 +98,30 @@ private static OccurrenceInfo getWeeklyPreviousOccurrence(TimeWindowFilterSettin final ZonedDateTime firstDayOfFirstWeek = start.minusDays( TimeWindowUtils.getPassedWeekDays(start.getDayOfWeek(), pattern.getFirstDayOfWeek())); - final long numberOfInterval = Duration.between(firstDayOfFirstWeek, now).toSeconds() - / Duration.ofDays((long) interval * RecurrenceConstants.DAYS_PER_WEEK).toSeconds(); - final ZonedDateTime firstDayOfMostRecentOccurringWeek = firstDayOfFirstWeek.plusDays( + // Use calendar-based week calculation to avoid DST-related undercounting + // Convert both dates to the same zone for accurate week counting + final ZonedDateTime alignedNow = now.withZoneSameInstant(firstDayOfFirstWeek.getZone()); + final long numberOfInterval = ChronoUnit.WEEKS.between(firstDayOfFirstWeek, alignedNow) / interval; + ZonedDateTime firstDayOfMostRecentOccurringWeek = firstDayOfFirstWeek.plusDays( numberOfInterval * (interval * RecurrenceConstants.DAYS_PER_WEEK)); + + // Handle DST transitions: If the calculated week start has a fixed offset (ZoneOffset) + // and 'now' has a region zone, check if they represent the same geographic location. + // We do this by checking if the fixed offset matches what the region zone's offset + // was at the *original start time*. If it matches, they're in the same timezone, + // and we should convert to the region zone for DST-aware comparisons. + if (firstDayOfMostRecentOccurringWeek.getZone() instanceof ZoneOffset + && !(now.getZone() instanceof ZoneOffset)) { + // Check if the fixed offset matches the region zone's offset at the *start* instant + // (not at firstDayOfMostRecentOccurringWeek's instant, which might have crossed DST) + ZoneOffset offsetAtStart = now.getZone().getRules().getOffset(start.toInstant()); + if (start.getOffset().equals(offsetAtStart)) { + // Same geographic location, convert to region zone for DST-aware comparisons + firstDayOfMostRecentOccurringWeek = firstDayOfMostRecentOccurringWeek + .withZoneSameLocal(now.getZone()); + } + } + final List sortedDaysOfWeek = TimeWindowUtils.sortDaysOfWeek(pattern.getDaysOfWeek(), pattern.getFirstDayOfWeek()); final int maxDayOffset = TimeWindowUtils.getPassedWeekDays(sortedDaysOfWeek.get(sortedDaysOfWeek.size() - 1), pattern.getFirstDayOfWeek()); final int minDayOffset = TimeWindowUtils.getPassedWeekDays(sortedDaysOfWeek.get(0), pattern.getFirstDayOfWeek()); diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceValidator.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceValidator.java index 366e36394da5..2f94f03c9021 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceValidator.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceValidator.java @@ -9,6 +9,7 @@ import java.time.DayOfWeek; import java.time.Duration; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.List; @@ -22,8 +23,19 @@ public class RecurrenceValidator { public static void validateSettings(TimeWindowFilterSettings settings) { + validateSettings(settings, ZonedDateTime.now(ZoneOffset.UTC)); + } + + /** + * Validates recurrence settings with a specified reference time. + * Public for testing purposes to allow deterministic validation. + * + * @param settings The time window filter settings to validate + * @param referenceTime The reference time to use for "today" in validation calculations + */ + public static void validateSettings(TimeWindowFilterSettings settings, ZonedDateTime referenceTime) { validateRecurrenceRequiredParameter(settings); - validateRecurrencePattern(settings); + validateRecurrencePattern(settings, referenceTime); validateRecurrenceRange(settings); } @@ -53,13 +65,13 @@ private static void validateRecurrenceRequiredParameter(TimeWindowFilterSettings } } - private static void validateRecurrencePattern(TimeWindowFilterSettings settings) { + private static void validateRecurrencePattern(TimeWindowFilterSettings settings, ZonedDateTime referenceTime) { final RecurrencePatternType patternType = settings.getRecurrence().getPattern().getType(); if (patternType == RecurrencePatternType.DAILY) { validateDailyRecurrencePattern(settings); } else { - validateWeeklyRecurrencePattern(settings); + validateWeeklyRecurrencePattern(settings, referenceTime); } } @@ -76,7 +88,7 @@ private static void validateDailyRecurrencePattern(TimeWindowFilterSettings sett validateTimeWindowDuration(settings); } - private static void validateWeeklyRecurrencePattern(TimeWindowFilterSettings settings) { + private static void validateWeeklyRecurrencePattern(TimeWindowFilterSettings settings, ZonedDateTime referenceTime) { validateDaysOfWeek(settings); // Check whether "Start" is a valid first occurrence @@ -90,7 +102,7 @@ private static void validateWeeklyRecurrencePattern(TimeWindowFilterSettings set validateTimeWindowDuration(settings); // Check whether the time window duration is shorter than the minimum gap between days of week - if (!isDurationCompliantWithDaysOfWeek(settings)) { + if (!isDurationCompliantWithDaysOfWeek(settings, referenceTime)) { throw new IllegalArgumentException(String.format(RecurrenceConstants.TIME_WINDOW_DURATION_OUT_OF_RANGE, "Recurrence.Pattern.DaysOfWeek")); } } @@ -103,7 +115,10 @@ private static void validateTimeWindowDuration(TimeWindowFilterSettings settings final Duration intervalDuration = RecurrencePatternType.DAILY.equals(pattern.getType()) ? Duration.ofDays(pattern.getInterval()) : Duration.ofDays((long) pattern.getInterval() * RecurrenceConstants.DAYS_PER_WEEK); - final Duration timeWindowDuration = Duration.between(settings.getStart(), settings.getEnd()); + // Convert to UTC to ensure consistent duration calculation across DST transitions + final Duration timeWindowDuration = Duration.between( + settings.getStart().withZoneSameInstant(ZoneOffset.UTC), + settings.getEnd().withZoneSameInstant(ZoneOffset.UTC)); if (timeWindowDuration.compareTo(intervalDuration) > 0) { throw new IllegalArgumentException(String.format(RecurrenceConstants.TIME_WINDOW_DURATION_OUT_OF_RANGE, "Recurrence.Pattern.Interval")); } @@ -123,35 +138,39 @@ private static void validateEndDate(TimeWindowFilterSettings settings) { } /** - * Check whether the duration is shorter than the minimum gap between recurrence of days of week. - * - * @param settings time window filter settings + /** + * Validate if time window duration is shorter than the minimum gap between days of week + * @param settings The settings to validate + * @param referenceTime The reference time to use for "today" in gap calculations * @return True if the duration is compliant with days of week, false otherwise. */ - private static boolean isDurationCompliantWithDaysOfWeek(TimeWindowFilterSettings settings) { + private static boolean isDurationCompliantWithDaysOfWeek(TimeWindowFilterSettings settings, ZonedDateTime referenceTime) { final List daysOfWeek = settings.getRecurrence().getPattern().getDaysOfWeek(); if (daysOfWeek.size() == 1) { return true; } - // Get the date of first day of the week - final ZonedDateTime today = ZonedDateTime.now(); + // Get the date of first day of the week using provided reference time + final ZonedDateTime today = referenceTime; final DayOfWeek firstDayOfWeek = settings.getRecurrence().getPattern().getFirstDayOfWeek(); final int offset = TimeWindowUtils.getPassedWeekDays(today.getDayOfWeek(), firstDayOfWeek); final ZonedDateTime firstDateOfWeek = today.minusDays(offset).truncatedTo(ChronoUnit.DAYS); final List sortedDaysOfWeek = TimeWindowUtils.sortDaysOfWeek(daysOfWeek, firstDayOfWeek); // Loop the whole week to get the min gap between the two consecutive recurrences + // Use calendar-based day counting to avoid DST-related issues (23/25 hour elapsed time) ZonedDateTime date; ZonedDateTime prevOccurrence = null; - Duration minGap = Duration.ofDays(RecurrenceConstants.DAYS_PER_WEEK); + long minGapDays = RecurrenceConstants.DAYS_PER_WEEK; for (DayOfWeek day: sortedDaysOfWeek) { date = firstDateOfWeek.plusDays(TimeWindowUtils.getPassedWeekDays(day, firstDayOfWeek)); if (prevOccurrence != null) { - final Duration currentGap = Duration.between(prevOccurrence, date); - if (currentGap.compareTo(minGap) < 0) { - minGap = currentGap; + // Use ChronoUnit.DAYS to count calendar days, not elapsed time + // This ensures Sunday-Monday is always 1 day (24 hours) regardless of DST + final long currentGapDays = ChronoUnit.DAYS.between(prevOccurrence, date); + if (currentGapDays < minGapDays) { + minGapDays = currentGapDays; } } prevOccurrence = date; @@ -161,13 +180,19 @@ private static boolean isDurationCompliantWithDaysOfWeek(TimeWindowFilterSetting // It may across weeks. Check the adjacent week date = firstDateOfWeek.plusDays(RecurrenceConstants.DAYS_PER_WEEK) .plusDays(TimeWindowUtils.getPassedWeekDays(sortedDaysOfWeek.get(0), firstDayOfWeek)); - final Duration currentGap = Duration.between(prevOccurrence, date); - if (currentGap.compareTo(minGap) < 0) { - minGap = currentGap; + // Use ChronoUnit.DAYS to count calendar days, not elapsed time + final long currentGapDays = ChronoUnit.DAYS.between(prevOccurrence, date); + if (currentGapDays < minGapDays) { + minGapDays = currentGapDays; } } - final Duration timeWindowDuration = Duration.between(settings.getStart(), settings.getEnd()); + final Duration minGap = Duration.ofDays(minGapDays); + + // Convert to UTC to ensure consistent duration calculation across DST transitions + final Duration timeWindowDuration = Duration.between( + settings.getStart().withZoneSameInstant(ZoneOffset.UTC), + settings.getEnd().withZoneSameInstant(ZoneOffset.UTC)); return minGap.compareTo(timeWindowDuration) >= 0; } } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceEvaluatorDSTTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceEvaluatorDSTTest.java new file mode 100644 index 000000000000..acaa08dab1ce --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceEvaluatorDSTTest.java @@ -0,0 +1,436 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.spring.cloud.feature.management.filters.recurrence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.azure.spring.cloud.feature.management.implementation.models.Recurrence; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrenceRange; +import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowFilterSettings; +import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceEvaluator; + +/** + * Tests for Daylight Saving Time (DST) handling in RecurrenceEvaluator. + * These tests verify that recurrence evaluation correctly handles timezone transitions + * when comparing fixed offset zones with region-based zones. + */ +public class RecurrenceEvaluatorDSTTest { + + /** + * Test weekly recurrence across DST transition (Spring forward). + * Verifies that when a start time is defined with a fixed offset (e.g., +01:00) + * and the current time uses a region zone (e.g., Europe/Paris), + * the evaluator correctly identifies they represent the same geographic location + * and handles the DST transition properly. + */ + @Test + public void weeklyRecurrenceSpringForwardDSTTransition() { + // Setup: Weekly recurrence starting before DST transition + // In Europe/Paris, DST transition happens last Sunday of March (2:00 AM -> 3:00 AM) + // Start: 2024-03-15 10:00 +01:00 (before DST, standard time) + // Now: 2024-04-05 10:00 +02:00 (after DST, daylight time) + + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start time with fixed offset (before DST) + settings.setStart("2024-03-15T10:00:00+01:00"); // Friday + settings.setEnd("2024-03-15T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + // Current time with region zone (after DST) + final ZonedDateTime now = ZonedDateTime.of(2024, 4, 5, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday, during time window + + // Should match because Friday is a valid recurrence day + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match weekly recurrence on Friday after DST transition"); + } + + /** + * Test weekly recurrence across DST transition (Fall back). + * Verifies handling when clocks fall back (e.g., from +02:00 to +01:00). + */ + @Test + public void weeklyRecurrenceFallBackDSTTransition() { + // Setup: Weekly recurrence starting before DST fall back + // In Europe/Paris, DST fall back happens last Sunday of October (3:00 AM -> 2:00 AM) + // Start: 2024-10-18 10:00 +02:00 (before fall back, daylight time) + // Now: 2024-11-01 10:00 +01:00 (after fall back, standard time) + + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start time with fixed offset (before fall back) + settings.setStart("2024-10-18T10:00:00+02:00"); // Friday + settings.setEnd("2024-10-18T12:00:00+02:00"); + settings.setRecurrence(recurrence); + + // Current time with region zone (after fall back) + final ZonedDateTime now = ZonedDateTime.of(2024, 11, 1, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday, during time window + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match weekly recurrence on Friday after DST fall back"); + } + + /** + * Test that recurrence evaluation works correctly when both start and now + * use fixed offsets (no DST conversion needed). + */ + @Test + public void weeklyRecurrenceBothFixedOffsets() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Wednesday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Both use fixed offsets + settings.setStart("2024-03-01T10:00:00+01:00"); // Friday + settings.setEnd("2024-03-01T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + final ZonedDateTime now = ZonedDateTime.of(2024, 3, 8, 10, 30, 0, 0, + ZoneOffset.ofHours(1)); // Friday + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match when both use fixed offsets"); + } + + /** + * Test that recurrence evaluation works correctly when both start and now + * use region zones (DST-aware zones). + */ + @Test + public void weeklyRecurrenceBothRegionZones() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Wednesday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Both use region zones + ZonedDateTime startTime = ZonedDateTime.of(2024, 3, 1, 10, 0, 0, 0, + ZoneId.of("Europe/Paris")); // Friday + ZonedDateTime endTime = ZonedDateTime.of(2024, 3, 1, 12, 0, 0, 0, + ZoneId.of("Europe/Paris")); + + settings.setStart(startTime.toString()); + settings.setEnd(endTime.toString()); + settings.setRecurrence(recurrence); + + final ZonedDateTime now = ZonedDateTime.of(2024, 3, 8, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match when both use region zones"); + } + + /** + * Test weekly recurrence with different geographic locations. + * When fixed offset doesn't match the region zone's offset at start time, + * they represent different geographic locations and should not be converted. + */ + @Test + public void weeklyRecurrenceDifferentGeographicLocations() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Wednesday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start with offset that doesn't match Europe/Paris + settings.setStart("2024-03-01T10:00:00+05:00"); // Friday, Asia timezone + settings.setEnd("2024-03-01T12:00:00+05:00"); + settings.setRecurrence(recurrence); + + // Now in Europe/Paris (offset +01:00 at this time) + final ZonedDateTime now = ZonedDateTime.of(2024, 3, 8, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday, 10:30 local time + + // When offsets don't match (+05:00 vs +01:00), they represent different geographic locations + // No conversion should happen. The evaluator will compare times as-is. + // Start: 2024-03-01 10:00 +05:00 = 2024-03-01 05:00 UTC + // Now: 2024-03-08 10:30 +01:00 = 2024-03-08 09:30 UTC + // The recurrence day (Friday) matches, and time window should match + assertEquals(false, RecurrenceEvaluator.isMatch(settings, now), + "Should not match due to different geographic locations with different time windows"); + } + + /** + * Test weekly recurrence on the exact day of DST transition. + * This tests the edge case where 'now' is during the DST transition day. + */ + @Test + public void weeklyRecurrenceOnDSTTransitionDay() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Sunday", "Wednesday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start before DST + settings.setStart("2024-03-20T10:00:00+01:00"); // Wednesday + settings.setEnd("2024-03-20T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + // Now on DST transition day (last Sunday of March 2024) + // In Europe/Paris, 2024-03-31 is the DST transition day + final ZonedDateTime now = ZonedDateTime.of(2024, 3, 31, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Sunday + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match on DST transition day"); + } + + /** + * Test weekly recurrence with multiple intervals across DST. + * Verifies that multi-week intervals work correctly across DST boundaries. + */ + @Test + public void weeklyRecurrenceMultiIntervalAcrossDST() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(2); // Every 2 weeks + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start in February (before DST) + settings.setStart("2024-02-16T10:00:00+01:00"); // Friday + settings.setEnd("2024-02-16T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + // Now in April (after DST), on a recurrence week + final ZonedDateTime now = ZonedDateTime.of(2024, 4, 12, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday, 8 weeks later (4th occurrence) + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match multi-interval recurrence across DST"); + } + + /** + * Test weekly recurrence with numbered range across DST. + * Ensures that occurrence counting works correctly across DST transitions. + */ + @Test + public void weeklyRecurrenceNumberedRangeAcrossDST() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + range.setType("Numbered"); + range.setNumberOfOccurrences(15); // 15 occurrences + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start before DST - 2024-03-08 is a Friday + // Occurrences: 1:Fri 3/8, 2:Mon 3/11, 3:Fri 3/15, 4:Mon 3/18, 5:Fri 3/22, 6:Mon 3/25, 7:Fri 3/29, + // 8:Mon 4/1 (after DST on 3/31), 9:Fri 4/5, 10:Mon 4/8, 11:Fri 4/12, 12:Mon 4/15... + settings.setStart("2024-03-08T10:00:00+01:00"); // Friday + settings.setEnd("2024-03-08T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + // Now after DST - 2024-04-05 is Friday, should be 9th occurrence (within range) + final ZonedDateTime now = ZonedDateTime.of(2024, 4, 5, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match numbered occurrence across DST"); + + // Test after the numbered range ends - 2024-04-22 is Monday, would be 16th occurrence (beyond range) + final ZonedDateTime afterRange = ZonedDateTime.of(2024, 4, 22, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Monday + + assertEquals(false, RecurrenceEvaluator.isMatch(settings, afterRange), + "Should not match after numbered range ends"); + } + + /** + * Test weekly recurrence with end date across DST. + * Verifies that end date comparisons work correctly across DST transitions. + */ + @Test + public void weeklyRecurrenceEndDateAcrossDST() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Wednesday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + range.setType("EndDate"); + range.setEndDate("2024-04-10T23:59:59+02:00"); // After DST + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start before DST + settings.setStart("2024-03-15T10:00:00+01:00"); // Friday + settings.setEnd("2024-03-15T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + // Now after DST, before end date + final ZonedDateTime now = ZonedDateTime.of(2024, 4, 5, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match before end date across DST"); + + // Test after end date + final ZonedDateTime afterEnd = ZonedDateTime.of(2024, 4, 12, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday, after end date + + assertEquals(false, RecurrenceEvaluator.isMatch(settings, afterEnd), + "Should not match after end date"); + } + + /** + * Test with US timezone (different DST transition dates than Europe). + * US DST: Second Sunday of March (2:00 AM -> 3:00 AM) + */ + @Test + public void weeklyRecurrenceUSDSTTransition() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start before US DST (PST: -08:00) + settings.setStart("2024-03-01T10:00:00-08:00"); // Friday + settings.setEnd("2024-03-01T12:00:00-08:00"); + settings.setRecurrence(recurrence); + + // Now after US DST (PDT: -07:00) + final ZonedDateTime now = ZonedDateTime.of(2024, 3, 15, 10, 30, 0, 0, + ZoneId.of("America/Los_Angeles")); // Friday, after DST + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match weekly recurrence across US DST transition"); + } + + /** + * Test that the DST conversion only happens when start uses fixed offset + * and now uses region zone, not the other way around. + */ + @Test + public void weeklyRecurrenceNoConversionWhenStartIsRegionZone() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start with region zone + ZonedDateTime startTime = ZonedDateTime.of(2024, 3, 1, 10, 0, 0, 0, + ZoneId.of("Europe/Paris")); + ZonedDateTime endTime = ZonedDateTime.of(2024, 3, 1, 12, 0, 0, 0, + ZoneId.of("Europe/Paris")); + + settings.setStart(startTime.toString()); + settings.setEnd(endTime.toString()); + settings.setRecurrence(recurrence); + + // Now with fixed offset + final ZonedDateTime now = ZonedDateTime.of(2024, 4, 5, 10, 30, 0, 0, + ZoneOffset.ofHours(2)); // Friday + + // The DST conversion logic only applies when start is fixed offset and now is region zone. + // In this reverse case (start is region zone, now is fixed offset), no conversion happens. + // Start: 2024-03-01 10:00 Europe/Paris (+01:00) = Friday + // Now: 2024-04-05 10:30 +02:00 = Friday (matches recurring day) + // Time window: 10:00-12:00, Now: 10:30 (within window) + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match - region zone start with fixed offset now should evaluate correctly"); + } +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceValidatorDSTTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceValidatorDSTTest.java new file mode 100644 index 000000000000..0e76a6da5895 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceValidatorDSTTest.java @@ -0,0 +1,396 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.spring.cloud.feature.management.filters.recurrence; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.azure.spring.cloud.feature.management.implementation.models.Recurrence; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrenceRange; +import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowFilterSettings; +import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceValidator; + +/** + * Tests for Daylight Saving Time (DST) handling in RecurrenceValidator. + * These tests verify that validation uses UTC time to avoid DST-related issues + * when calculating time window durations and validating recurrence patterns. + * + * Tests use time windows close to 23-24 hours (the minimum gap between daily occurrences) + * to ensure that DST transitions don't cause incorrect validation failures. + */ +public class RecurrenceValidatorDSTTest { + + /** + * Test that a 23-hour window between Monday and Wednesday is valid when using calendar-based gap calculation. + * Without calendar-based calculation, the validator might incorrectly calculate gaps based on elapsed time + * which could be 23 or 25 hours depending on DST transitions. + * + * This test uses a reference time on the DST transition day (Sunday, March 31, 2024) + * to ensure the validator correctly calculates gaps using calendar days (always 24 hours per day). + */ + @Test + public void validate23HourWindowDuringDSTSpringForward() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Wednesday")); // 48-hour gap minimum (2 calendar days) + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window in UTC (unambiguous duration) + settings.setStart("2024-03-25T10:00:00Z"); // Monday + settings.setEnd("2024-03-26T09:00:00Z"); // Tuesday, 23 hours later + settings.setRecurrence(recurrence); + + // Reference time during DST transition week (Sunday, March 31, 2024 in UTC) + // This forces the validator to calculate gaps during a DST week + final ZonedDateTime referenceDuringDST = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + // Should pass because 23 hours < 48 hours (Monday to Wednesday is 2 calendar days = 48 hours) + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringDST), + "23-hour window should be valid with 48-hour gap using calendar-based calculation"); + } + + /** + * Test that a 23-hour window is valid with consecutive day recurrence (Sunday, Monday). + * The reference time is set to the DST transition week, so when the validator calculates + * the Sunday-Monday gap, it crosses the DST spring forward boundary (March 31 2AM -> April 1). + * With calendar-based calculation, Sunday to Monday is always 1 day = 24 hours. + * A 23-hour window should pass validation (23 < 24). + */ + @Test + public void validate23HourWindowWithConsecutiveDays() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Sunday", "Monday")); // 24-hour gap minimum (1 calendar day) + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window (1 hour less than 24-hour gap) + settings.setStart("2024-03-24T10:00:00Z"); // Sunday 10:00 UTC + settings.setEnd("2024-03-25T09:00:00Z"); // Monday 09:00 UTC (23 hours later) + settings.setRecurrence(recurrence); + + // Reference time: Wednesday March 27, 2024 in Europe/Paris + // This week spans Monday Mar 25 - Sunday Mar 31 (DST transition on Mar 31) + // The validator will check the gap from Sunday Mar 31 to Monday Apr 1 which crosses DST + // With calendar-based calculation: 1 day = 24 hours + final ZonedDateTime referenceDuringDST = ZonedDateTime.of(2024, 3, 27, 12, 0, 0, 0, + ZoneId.of("Europe/Paris")); + + // Should pass because 23 hours < 24 hours (1 calendar day) + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringDST), + "23-hour window should be valid with 24-hour gap when using calendar-based calculation"); + } + + /** + * Test that a window GREATER than the gap fails validation with consecutive day recurrence. + * The window must not exceed the gap between occurrences. + * With calendar-based calculation, the gap is always 24 hours (1 day) regardless of DST. + */ + @Test + public void validate25HourWindowWithConsecutiveDaysShouldFail() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Tuesday")); // 24-hour gap minimum + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 25-hour window (greater than 24-hour gap, should fail) + settings.setStart("2024-03-25T10:00:00Z"); // Monday 10:00 UTC + settings.setEnd("2024-03-26T11:00:00Z"); // Tuesday 11:00 UTC, 25 hours later + settings.setRecurrence(recurrence); + + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + // Should fail because window duration (25h) > gap (24h calendar days) + assertThrows(IllegalArgumentException.class, + () -> RecurrenceValidator.validateSettings(settings, reference), + "25-hour window should fail validation with 24-hour gap"); + } + + /** + * Test validation across DST fall back transition. + * A 23-hour window on Sunday-Tuesday should be valid. + * With calendar-based calculation, Sunday-Tuesday is always 2 days = 48 hours. + */ + @Test + public void validate23HourWindowDuringDSTFallBack() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Sunday", "Tuesday")); // 48-hour gap minimum + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window in UTC + settings.setStart("2024-10-27T10:00:00Z"); // Sunday + settings.setEnd("2024-10-28T09:00:00Z"); // Monday, 23 hours later + settings.setRecurrence(recurrence); + + // Reference time during DST fall back week + final ZonedDateTime referenceDuringFallBack = ZonedDateTime.of(2024, 10, 27, 12, 0, 0, 0, + ZoneOffset.UTC); + + // Should pass because 23 hours < 48 hours (Sunday to Tuesday gap with calendar days) + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringFallBack), + "23-hour window should be valid during DST fall back when using calendar-based calculation"); + } + + /** + * Test that a time window spanning the DST spring forward transition hour + * is correctly validated using UTC duration calculation. + */ + @Test + public void validateTimeWindowSpanningDSTSpringForwardHour() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Sunday")); // Single day, no gap check + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // DST spring forward in Europe/Paris: 2024-03-31 at 2:00 AM -> 3:00 AM + // Time window from 1:30 to 3:30 - appears to be 2 hours locally but is actually 1 hour in UTC + settings.setStart("2024-03-31T01:30:00+01:00"); // Sunday, 00:30 UTC + settings.setEnd("2024-03-31T03:30:00+02:00"); // Sunday, 01:30 UTC (1 hour duration) + settings.setRecurrence(recurrence); + + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + // Should pass - window is only 1 hour in UTC despite appearing as 2 hours locally + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, reference), + "Window spanning DST transition should be validated using UTC duration"); + } + + /** + * Test with daily recurrence and 23-hour window. + * Daily recurrence has a 24-hour minimum gap, so 23-hour window should be valid. + */ + @Test + public void validateDailyRecurrence23HourWindow() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Daily"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window + settings.setStart("2024-03-30T10:00:00+01:00"); + settings.setEnd("2024-03-31T09:00:00+01:00"); // 23 hours later + settings.setRecurrence(recurrence); + + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, reference), + "23-hour window should be valid for daily recurrence (< 24-hour gap)"); + } + + /** + * Test that a window GREATER than the interval fails with daily recurrence. + * Daily recurrence with interval=1 has a 24-hour interval. + * A 25-hour window should fail because it's greater than the interval. + */ + @Test + public void validateDailyRecurrence25HourWindowShouldFail() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Daily"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 25-hour window (in UTC): + // Start: 2024-03-30 10:00 +01:00 = 09:00 UTC + // End: 2024-03-31 12:00 +02:00 = 10:00 UTC (25 hours later in UTC) + settings.setStart("2024-03-30T10:00:00+01:00"); + settings.setEnd("2024-03-31T12:00:00+02:00"); // 25 hours later in UTC + settings.setRecurrence(recurrence); + + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + assertThrows(IllegalArgumentException.class, + () -> RecurrenceValidator.validateSettings(settings, reference), + "25-hour window should fail for daily recurrence (greater than 24-hour interval)"); + } + + /** + * Test weekly recurrence with Monday, Friday, and Sunday and a 23.5-hour window. + * The minimum gap between enabled days is Friday-Sunday (48 hours), so this should pass. + */ + @Test + public void validateMultipleDaysOfWeekWith23HourWindow() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday", "Sunday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23.5-hour window + settings.setStart("2024-03-25T10:00:00+01:00"); // Monday + settings.setEnd("2024-03-26T09:30:00+01:00"); // Tuesday, 23.5 hours later + settings.setRecurrence(recurrence); + + // Reference on DST transition day + final ZonedDateTime referenceDuringDST = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + // Minimum gap is Friday->Sunday (48 hours), so 23.5 hours should pass + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringDST), + "23.5-hour window should be valid with Friday-Sunday 48-hour minimum gap"); + } + + /** + * Test with consecutive days spanning DST transition. + * Saturday-Sunday-Monday where Sunday is the DST transition day. + */ + @Test + public void validateConsecutiveDaysSpanningDSTTransition() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Saturday", "Sunday", "Monday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window on Saturday + settings.setStart("2024-03-30T10:00:00+01:00"); // Saturday + settings.setEnd("2024-03-31T09:00:00+01:00"); // Sunday, 23 hours later + settings.setRecurrence(recurrence); + + // Reference on the DST transition day itself, using a valid local time after the DST gap + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 3, 30, 0, 0, + ZoneId.of("Europe/Paris")); + + // Minimum gap is between consecutive calendar days (Saturday-Sunday or Sunday-Monday). + // On this DST transition day the elapsed UTC time is 23 hours, but recurrence validation + // is based on calendar days, so a 23-hour window is still less than one day and should pass. + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, reference), + "23-hour window should be valid spanning DST transition with UTC calculation"); + } + + /** + * Test validation with US timezone DST transition. + * US DST spring forward is second Sunday of March (different from Europe). + */ + @Test + public void validate23HourWindowDuringUSDSTTransition() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Sunday", "Tuesday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window on Sunday (US DST transition day: March 10, 2024) + settings.setStart("2024-03-10T10:00:00-08:00"); // Sunday, PST + settings.setEnd("2024-03-11T09:00:00-07:00"); // Monday, PDT (23 hours later in UTC) + settings.setRecurrence(recurrence); + + // Reference during US DST transition + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 10, 18, 0, 0, 0, ZoneOffset.UTC); + + // Sunday-Tuesday gap is 48 hours, 23-hour window should pass + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, reference), + "23-hour window should be valid during US DST transition with UTC calculation"); + } + + /** + * Test with multi-week interval and 23-hour window. + * Even with larger gaps, the window must still be validated correctly. + */ + @Test + public void validateMultiWeekIntervalWith23HourWindow() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(2); // Every 2 weeks + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window + settings.setStart("2024-03-04T10:00:00+01:00"); // Monday + settings.setEnd("2024-03-05T09:00:00+01:00"); // Tuesday, 23 hours later + settings.setRecurrence(recurrence); + + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + // Minimum gap is Monday-Friday (96 hours), so 23 hours should pass + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, reference), + "23-hour window should be valid with multi-week interval"); + } +}