From 0bc168ca662de6fd77abc58873f0ab0872330701 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 10 Mar 2026 11:04:10 -0700 Subject: [PATCH 1/8] Daylight saving time fix --- .../recurrence/RecurrenceEvaluator.java | 20 ++++++++++++++++++- .../recurrence/RecurrenceValidator.java | 3 ++- 2 files changed, 21 insertions(+), 2 deletions(-) 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..1b8e8df3d9c5 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 @@ -98,8 +98,26 @@ private static OccurrenceInfo getWeeklyPreviousOccurrence(TimeWindowFilterSettin final long numberOfInterval = Duration.between(firstDayOfFirstWeek, now).toSeconds() / Duration.ofDays((long) interval * RecurrenceConstants.DAYS_PER_WEEK).toSeconds(); - final ZonedDateTime firstDayOfMostRecentOccurringWeek = firstDayOfFirstWeek.plusDays( + 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 java.time.ZoneOffset + && !(now.getZone() instanceof java.time.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) + java.time.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..f09d5d68bd78 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; @@ -135,7 +136,7 @@ private static boolean isDurationCompliantWithDaysOfWeek(TimeWindowFilterSetting } // Get the date of first day of the week - final ZonedDateTime today = ZonedDateTime.now(); + final ZonedDateTime today = ZonedDateTime.now(ZoneOffset.UTC); final DayOfWeek firstDayOfWeek = settings.getRecurrence().getPattern().getFirstDayOfWeek(); final int offset = TimeWindowUtils.getPassedWeekDays(today.getDayOfWeek(), firstDayOfWeek); final ZonedDateTime firstDateOfWeek = today.minusDays(offset).truncatedTo(ChronoUnit.DAYS); From 5a8e5832e071651472cd7a1d722f230c74ddc92c Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 10 Mar 2026 14:22:04 -0700 Subject: [PATCH 2/8] dst tests --- .../RecurrenceEvaluatorDSTTest.java | 430 ++++++++++++++++++ .../RecurrenceValidatorDSTTest.java | 367 +++++++++++++++ 2 files changed, 797 insertions(+) create mode 100644 sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceEvaluatorDSTTest.java create mode 100644 sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceValidatorDSTTest.java 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..061933ad7a2a --- /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,430 @@ +// 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 + + // This test verifies the behavior exists, but the expected result depends + // on whether the local time comparison still finds a match + boolean result = RecurrenceEvaluator.isMatch(settings, now); + // Result may vary based on how different offsets are handled + // The key is that no conversion should happen since offsets don't match + } + + /** + * 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 + + // Should still evaluate correctly even without conversion + boolean result = RecurrenceEvaluator.isMatch(settings, now); + // The test verifies no exception is thrown and evaluation completes + } +} 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..e0529447241b --- /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,367 @@ +// 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.assertNotNull; + +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. + */ +public class RecurrenceValidatorDSTTest { + + /** + * Test that validator uses UTC for today's date calculation. + * This ensures consistency regardless of the system's default timezone + * or DST transitions. + */ + @Test + public void validateSettingsUsesUTCForDateCalculation() { + 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 on a Monday + settings.setStart("2024-03-04T10:00:00+01:00"); // Monday + settings.setEnd("2024-03-04T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + // Should not throw exception, uses UTC internally for validation + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), + "Validator should use UTC and not throw exception"); + } + + /** + * Test validation across DST spring forward transition. + * Ensures that time window duration validation works correctly + * when the start time is before DST and validation runs after DST. + */ + @Test + public void validateSettingsAcrossSpringForwardDST() { + 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 DST (standard time) + settings.setStart("2024-03-15T10:00:00+01:00"); // Friday + settings.setEnd("2024-03-15T11:00:00+01:00"); // 1 hour duration + settings.setRecurrence(recurrence); + + // Validation should work correctly regardless of current time being after DST + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), + "Validator should handle DST spring forward correctly"); + } + + /** + * Test validation across DST fall back transition. + * Ensures that time window duration validation works correctly + * when clocks fall back. + */ + @Test + public void validateSettingsAcrossFallBackDST() { + 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 DST fall back (daylight time) + settings.setStart("2024-10-18T10:00:00+02:00"); // Friday + settings.setEnd("2024-10-18T11:00:00+02:00"); // 1 hour duration + settings.setRecurrence(recurrence); + + // Validation should work correctly after DST fall back + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), + "Validator should handle DST fall back correctly"); + } + + /** + * Test validation with daily recurrence across DST. + * Daily patterns are simpler but should still use UTC for consistency. + */ + @Test + public void validateDailyRecurrenceAcrossDST() { + 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); + + // Start before DST + settings.setStart("2024-03-20T10:00:00+01:00"); + settings.setEnd("2024-03-20T12:00:00+01:00"); // 2 hour duration + settings.setRecurrence(recurrence); + + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), + "Daily recurrence validation should work across DST"); + } + + /** + * Test that validator handles time windows that span the DST transition hour. + * For example, a window from 1:30 AM to 3:30 AM on DST spring forward day. + */ + @Test + public void validateTimeWindowSpanningDSTTransitionHour() { + 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")); + 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 (spans the transition) + settings.setStart("2024-03-31T01:30:00+01:00"); // Sunday + settings.setEnd("2024-03-31T03:30:00+02:00"); // After DST transition + settings.setRecurrence(recurrence); + + // Using UTC ensures consistent duration calculation + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), + "Validator should handle time window spanning DST transition"); + } + + /** + * Test validation with numbered range across DST. + * Ensures occurrence counting validation works correctly. + */ + @Test + public void validateNumberedRangeAcrossDST() { + 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(20); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start before DST + settings.setStart("2024-03-04T10:00:00+01:00"); // Monday + settings.setEnd("2024-03-04T11:00:00+01:00"); + settings.setRecurrence(recurrence); + + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), + "Numbered range validation should work across DST"); + } + + /** + * Test validation with end date across DST. + * Ensures end date validation works correctly with UTC. + */ + @Test + public void validateEndDateAcrossDST() { + 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-30T23:59:59+02:00"); // After DST + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start before DST + settings.setStart("2024-03-01T10:00:00+01:00"); // Friday + settings.setEnd("2024-03-01T11:00:00+01:00"); + settings.setRecurrence(recurrence); + + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), + "End date validation should work across DST"); + } + + /** + * Test validation with different timezone offsets. + * Ensures UTC usage makes validation consistent across timezones. + */ + @Test + public void validateWithDifferentTimezoneOffsets() { + 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("Tuesday", "Thursday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Use a different timezone (US Pacific) + settings.setStart("2024-03-05T10:00:00-08:00"); // Tuesday + settings.setEnd("2024-03-05T11:00:00-08:00"); + settings.setRecurrence(recurrence); + + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), + "Validation should work with different timezone offsets using UTC"); + } + + /** + * Test validation with multi-week interval across DST. + * Ensures proper validation of longer intervals. + */ + @Test + public void validateMultiWeekIntervalAcrossDST() { + 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(3); // Every 3 weeks + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start before DST + settings.setStart("2024-02-19T10:00:00+01:00"); // Monday + settings.setEnd("2024-02-19T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), + "Multi-week interval validation should work across DST"); + } + + /** + * Test that time window duration validation is consistent regardless of DST. + * A 2-hour window should be valid in both standard and daylight time. + */ + @Test + public void validateTimeWindowDurationConsistencyAcrossDST() { + // Test during standard time + final TimeWindowFilterSettings settings1 = new TimeWindowFilterSettings(); + final RecurrencePattern pattern1 = new RecurrencePattern(); + final RecurrenceRange range1 = new RecurrenceRange(); + final Recurrence recurrence1 = new Recurrence(); + + pattern1.setType("Daily"); + pattern1.setInterval(1); + + recurrence1.setRange(range1); + recurrence1.setPattern(pattern1); + + settings1.setStart("2024-01-15T10:00:00+01:00"); // Standard time + settings1.setEnd("2024-01-15T12:00:00+01:00"); // 2 hours + settings1.setRecurrence(recurrence1); + + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings1), + "Time window validation should work during standard time"); + + // Test during daylight time (same duration, different offset) + final TimeWindowFilterSettings settings2 = new TimeWindowFilterSettings(); + final RecurrencePattern pattern2 = new RecurrencePattern(); + final RecurrenceRange range2 = new RecurrenceRange(); + final Recurrence recurrence2 = new Recurrence(); + + pattern2.setType("Daily"); + pattern2.setInterval(1); + + recurrence2.setRange(range2); + recurrence2.setPattern(pattern2); + + settings2.setStart("2024-06-15T10:00:00+02:00"); // Daylight time + settings2.setEnd("2024-06-15T12:00:00+02:00"); // 2 hours + settings2.setRecurrence(recurrence2); + + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings2), + "Time window validation should work during daylight time"); + } + + /** + * Test validation when days of week span DST transition. + * Ensures that the validator correctly handles weekly patterns + * where recurring days cross DST boundaries. + */ + @Test + public void validateDaysOfWeekSpanningDSTTransition() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + // Multiple days including Sunday (typical DST transition day) + pattern.setDaysOfWeek(List.of("Friday", "Saturday", "Sunday", "Monday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start on Friday before DST transition week + settings.setStart("2024-03-22T10:00:00+01:00"); // Friday + settings.setEnd("2024-03-22T11:00:00+01:00"); + settings.setRecurrence(recurrence); + + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), + "Days of week validation should work spanning DST transition"); + } +} From 371b2b9e518cdbe4ea6c079a04aee3d00905332f Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 10 Mar 2026 14:43:04 -0700 Subject: [PATCH 3/8] test updates --- .../filters/recurrence/RecurrenceEvaluatorDSTTest.java | 10 +++++----- .../filters/recurrence/RecurrenceValidatorDSTTest.java | 4 ---- 2 files changed, 5 insertions(+), 9 deletions(-) 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 index 061933ad7a2a..5076699f504d 100644 --- 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 @@ -200,11 +200,11 @@ public void weeklyRecurrenceDifferentGeographicLocations() { final ZonedDateTime now = ZonedDateTime.of(2024, 3, 8, 10, 30, 0, 0, ZoneId.of("Europe/Paris")); // Friday - // This test verifies the behavior exists, but the expected result depends - // on whether the local time comparison still finds a match - boolean result = RecurrenceEvaluator.isMatch(settings, now); - // Result may vary based on how different offsets are handled + // This test verifies that evaluation completes without exceptions + // when different geographic locations are used (no conversion should happen) // The key is that no conversion should happen since offsets don't match + RecurrenceEvaluator.isMatch(settings, now); + // Test passes if no exception is thrown } /** @@ -424,7 +424,7 @@ public void weeklyRecurrenceNoConversionWhenStartIsRegionZone() { ZoneOffset.ofHours(2)); // Friday // Should still evaluate correctly even without conversion - boolean result = RecurrenceEvaluator.isMatch(settings, now); + RecurrenceEvaluator.isMatch(settings, now); // The test verifies no exception is thrown and evaluation completes } } 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 index e0529447241b..18f55ebe6f3e 100644 --- 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 @@ -4,11 +4,7 @@ package com.azure.spring.cloud.feature.management.filters.recurrence; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; import java.util.List; import org.junit.jupiter.api.Test; From bdfde03e77f62593b4d6aa6491ec8acf3e900615 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Tue, 10 Mar 2026 16:13:19 -0700 Subject: [PATCH 4/8] Update RecurrenceEvaluator.java --- .../timewindow/recurrence/RecurrenceEvaluator.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 1b8e8df3d9c5..24742e255b60 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,7 @@ import java.time.DayOfWeek; import java.time.Duration; import java.time.ZonedDateTime; +import java.time.ZoneOffset; import java.util.List; import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; @@ -106,11 +107,11 @@ private static OccurrenceInfo getWeeklyPreviousOccurrence(TimeWindowFilterSettin // 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 java.time.ZoneOffset - && !(now.getZone() instanceof java.time.ZoneOffset)) { + 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) - java.time.ZoneOffset offsetAtStart = now.getZone().getRules().getOffset(start.toInstant()); + 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 From 1a52d1e8fb6902a1f7605e295b4c730ea61d4253 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 11 Mar 2026 09:29:16 -0700 Subject: [PATCH 5/8] test update --- .../recurrence/RecurrenceValidator.java | 34 +- .../RecurrenceEvaluatorDSTTest.java | 26 +- .../RecurrenceValidatorDSTTest.java | 315 ++++++++++-------- 3 files changed, 208 insertions(+), 167 deletions(-) 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 f09d5d68bd78..ca5ba73a711c 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 @@ -23,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); } @@ -54,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); } } @@ -77,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 @@ -91,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")); } } @@ -124,19 +135,20 @@ 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(ZoneOffset.UTC); + // 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); 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 index 5076699f504d..acaa08dab1ce 100644 --- 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 @@ -198,13 +198,15 @@ public void weeklyRecurrenceDifferentGeographicLocations() { // 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 - - // This test verifies that evaluation completes without exceptions - // when different geographic locations are used (no conversion should happen) - // The key is that no conversion should happen since offsets don't match - RecurrenceEvaluator.isMatch(settings, now); - // Test passes if no exception is thrown + 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"); } /** @@ -423,8 +425,12 @@ public void weeklyRecurrenceNoConversionWhenStartIsRegionZone() { final ZonedDateTime now = ZonedDateTime.of(2024, 4, 5, 10, 30, 0, 0, ZoneOffset.ofHours(2)); // Friday - // Should still evaluate correctly even without conversion - RecurrenceEvaluator.isMatch(settings, now); - // The test verifies no exception is thrown and evaluation completes + // 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 index 18f55ebe6f3e..5d1e5b686838 100644 --- 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 @@ -4,7 +4,11 @@ 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; @@ -19,138 +23,163 @@ * 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 validator uses UTC for today's date calculation. - * This ensures consistency regardless of the system's default timezone - * or DST transitions. + * Test that a 23.5-hour window between Monday and Wednesday is valid when using UTC, + * even during a DST transition week. Without UTC, the validator might incorrectly + * calculate the gap as 22.5 or 24.5 hours depending on DST. + * + * This test uses a reference time on the DST transition day (Sunday, March 31, 2024) + * to ensure the validator correctly calculates gaps in UTC. */ @Test - public void validateSettingsUsesUTCForDateCalculation() { + 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", "Friday")); + pattern.setDaysOfWeek(List.of("Monday", "Wednesday")); // 48-hour gap minimum pattern.setFirstDayOfWeek("Monday"); pattern.setInterval(1); recurrence.setRange(range); recurrence.setPattern(pattern); - // Start on a Monday - settings.setStart("2024-03-04T10:00:00+01:00"); // Monday - settings.setEnd("2024-03-04T12:00:00+01:00"); + // 23.5-hour window (just under 24 hours, well within 48-hour gap) + settings.setStart("2024-03-25T10:00:00+01:00"); // Monday, before DST + settings.setEnd("2024-03-26T09:30:00+01:00"); // Tuesday, 23.5 hours later settings.setRecurrence(recurrence); - // Should not throw exception, uses UTC internally for validation - assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), - "Validator should use UTC and not throw exception"); + // Reference time during DST transition week (Sunday, March 31, 2024 in UTC) + // If validator used local time incorrectly, it might miscalculate the Monday-Wednesday gap + final ZonedDateTime referenceDuringDST = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + // Should pass because 23.5 hours < 48 hours (Monday to Wednesday gap) + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringDST), + "23.5-hour window should be valid with 48-hour gap, even during DST"); } /** - * Test validation across DST spring forward transition. - * Ensures that time window duration validation works correctly - * when the start time is before DST and validation runs after DST. + * Test that a 23-hour window is valid with consecutive day recurrence (Monday, Tuesday). + * Without UTC, if the validator ran on the DST transition day, it might incorrectly + * calculate the Monday-Tuesday gap as 23 hours (losing 1 hour), causing validation to fail. */ @Test - public void validateSettingsAcrossSpringForwardDST() { + 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("Monday", "Friday")); + pattern.setDaysOfWeek(List.of("Monday", "Tuesday")); // 24-hour gap minimum pattern.setFirstDayOfWeek("Monday"); pattern.setInterval(1); recurrence.setRange(range); recurrence.setPattern(pattern); - // Start before DST (standard time) - settings.setStart("2024-03-15T10:00:00+01:00"); // Friday - settings.setEnd("2024-03-15T11:00:00+01:00"); // 1 hour duration + // 23-hour window (1 hour less than 24-hour gap) + settings.setStart("2024-03-25T10:00:00+01:00"); // Monday + settings.setEnd("2024-03-26T09:00:00+01:00"); // Tuesday, 23 hours later settings.setRecurrence(recurrence); - // Validation should work correctly regardless of current time being after DST - assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), - "Validator should handle DST spring forward correctly"); + // Reference time on DST transition day - without UTC, the Monday-Tuesday gap + // might be calculated as 23 hours due to DST, causing incorrect failure + final ZonedDateTime referenceDuringDST = ZonedDateTime.of(2024, 3, 31, 2, 30, 0, 0, + ZoneId.of("Europe/Paris")); + + // Should pass because 23 hours < 24 hours (UTC calculation) + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringDST), + "23-hour window should be valid with 24-hour gap when calculated in UTC"); } /** - * Test validation across DST fall back transition. - * Ensures that time window duration validation works correctly - * when clocks fall back. + * Test that a 24-hour window FAILS validation with consecutive day recurrence. + * The window must be strictly less than the gap between occurrences. */ @Test - public void validateSettingsAcrossFallBackDST() { + public void validate24HourWindowWithConsecutiveDaysShouldFail() { 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.setDaysOfWeek(List.of("Monday", "Tuesday")); // 24-hour gap minimum pattern.setFirstDayOfWeek("Monday"); pattern.setInterval(1); recurrence.setRange(range); recurrence.setPattern(pattern); - // Start before DST fall back (daylight time) - settings.setStart("2024-10-18T10:00:00+02:00"); // Friday - settings.setEnd("2024-10-18T11:00:00+02:00"); // 1 hour duration + // Exactly 24-hour window (equal to gap, should fail) + settings.setStart("2024-03-25T10:00:00+01:00"); // Monday + settings.setEnd("2024-03-26T10:00:00+01:00"); // Tuesday, exactly 24 hours later settings.setRecurrence(recurrence); - // Validation should work correctly after DST fall back - assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), - "Validator should handle DST fall back correctly"); + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + // Should fail because window duration must be strictly less than gap + assertThrows(IllegalArgumentException.class, + () -> RecurrenceValidator.validateSettings(settings, reference), + "24-hour window should fail validation with 24-hour gap (not strictly less)"); } /** - * Test validation with daily recurrence across DST. - * Daily patterns are simpler but should still use UTC for consistency. + * Test validation across DST fall back transition. + * A 23.5-hour window on Sunday-Tuesday should be valid even though + * the Sunday night has an extra hour (25-hour day). */ @Test - public void validateDailyRecurrenceAcrossDST() { + 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("Daily"); + 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); - // Start before DST - settings.setStart("2024-03-20T10:00:00+01:00"); - settings.setEnd("2024-03-20T12:00:00+01:00"); // 2 hour duration + // 23.5-hour window starting on Sunday before DST fall back + settings.setStart("2024-10-27T10:00:00+02:00"); // Sunday (daylight time) + settings.setEnd("2024-10-28T09:30:00+01:00"); // Monday, 23.5 hours later (standard time) settings.setRecurrence(recurrence); - assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), - "Daily recurrence validation should work across DST"); + // 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.5 hours < 48 hours (Sunday to Tuesday gap in UTC) + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringFallBack), + "23.5-hour window should be valid during DST fall back when calculated in UTC"); } /** - * Test that validator handles time windows that span the DST transition hour. - * For example, a window from 1:30 AM to 3:30 AM on DST spring forward day. + * Test that a time window spanning the DST spring forward transition hour + * is correctly validated using UTC duration calculation. */ @Test - public void validateTimeWindowSpanningDSTTransitionHour() { + 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")); + pattern.setDaysOfWeek(List.of("Sunday")); // Single day, no gap check pattern.setFirstDayOfWeek("Monday"); pattern.setInterval(1); @@ -158,206 +187,200 @@ public void validateTimeWindowSpanningDSTTransitionHour() { 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 (spans the transition) - settings.setStart("2024-03-31T01:30:00+01:00"); // Sunday - settings.setEnd("2024-03-31T03:30:00+02:00"); // After DST transition + // 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); - // Using UTC ensures consistent duration calculation - assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), - "Validator should handle time window spanning DST transition"); + 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 validation with numbered range across DST. - * Ensures occurrence counting validation works correctly. + * 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 validateNumberedRangeAcrossDST() { + 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("Weekly"); - pattern.setDaysOfWeek(List.of("Monday", "Friday")); - pattern.setFirstDayOfWeek("Monday"); + pattern.setType("Daily"); pattern.setInterval(1); - range.setType("Numbered"); - range.setNumberOfOccurrences(20); - recurrence.setRange(range); recurrence.setPattern(pattern); - // Start before DST - settings.setStart("2024-03-04T10:00:00+01:00"); // Monday - settings.setEnd("2024-03-04T11:00:00+01:00"); + // 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); - assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), - "Numbered range validation should work across DST"); + 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 validation with end date across DST. - * Ensures end date validation works correctly with UTC. + * Test that 24-hour window FAILS with daily recurrence. */ @Test - public void validateEndDateAcrossDST() { + public void validateDailyRecurrence24HourWindowShouldFail() { 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.setType("Daily"); pattern.setInterval(1); - range.setType("EndDate"); - range.setEndDate("2024-04-30T23:59:59+02:00"); // After DST - recurrence.setRange(range); recurrence.setPattern(pattern); - // Start before DST - settings.setStart("2024-03-01T10:00:00+01:00"); // Friday - settings.setEnd("2024-03-01T11:00:00+01:00"); + // Exactly 24-hour window + settings.setStart("2024-03-30T10:00:00+01:00"); + settings.setEnd("2024-03-31T10:00:00+02:00"); // Exactly 24 hours later (crossing DST) settings.setRecurrence(recurrence); - assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), - "End date validation should work across DST"); + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + assertThrows(IllegalArgumentException.class, + () -> RecurrenceValidator.validateSettings(settings, reference), + "24-hour window should fail for daily recurrence (not strictly less than 24-hour gap)"); } /** - * Test validation with different timezone offsets. - * Ensures UTC usage makes validation consistent across timezones. + * Test Monday-Friday-Sunday pattern with 23.5-hour window. + * Minimum gap is Monday-Friday (96 hours), so this should pass. */ @Test - public void validateWithDifferentTimezoneOffsets() { + 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("Tuesday", "Thursday")); + pattern.setDaysOfWeek(List.of("Monday", "Friday", "Sunday")); pattern.setFirstDayOfWeek("Monday"); pattern.setInterval(1); recurrence.setRange(range); recurrence.setPattern(pattern); - // Use a different timezone (US Pacific) - settings.setStart("2024-03-05T10:00:00-08:00"); // Tuesday - settings.setEnd("2024-03-05T11:00:00-08:00"); + // 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); - assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), - "Validation should work with different timezone offsets using UTC"); + // 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 validation with multi-week interval across DST. - * Ensures proper validation of longer intervals. + * Test with consecutive days spanning DST transition. + * Saturday-Sunday-Monday where Sunday is the DST transition day. */ @Test - public void validateMultiWeekIntervalAcrossDST() { + 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("Monday", "Friday")); + pattern.setDaysOfWeek(List.of("Saturday", "Sunday", "Monday")); pattern.setFirstDayOfWeek("Monday"); - pattern.setInterval(3); // Every 3 weeks + pattern.setInterval(1); recurrence.setRange(range); recurrence.setPattern(pattern); - // Start before DST - settings.setStart("2024-02-19T10:00:00+01:00"); // Monday - settings.setEnd("2024-02-19T12:00:00+01:00"); + // 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); - assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), - "Multi-week interval validation should work across DST"); + // Reference on the DST transition day itself + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 2, 30, 0, 0, + ZoneId.of("Europe/Paris")); + + // Minimum gap is Saturday-Sunday or Sunday-Monday (24 hours in UTC) + // 23 hours < 24 hours, so should pass + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, reference), + "23-hour window should be valid spanning DST transition with UTC calculation"); } /** - * Test that time window duration validation is consistent regardless of DST. - * A 2-hour window should be valid in both standard and daylight time. + * Test validation with US timezone DST transition. + * US DST spring forward is second Sunday of March (different from Europe). */ @Test - public void validateTimeWindowDurationConsistencyAcrossDST() { - // Test during standard time - final TimeWindowFilterSettings settings1 = new TimeWindowFilterSettings(); - final RecurrencePattern pattern1 = new RecurrencePattern(); - final RecurrenceRange range1 = new RecurrenceRange(); - final Recurrence recurrence1 = new Recurrence(); - - pattern1.setType("Daily"); - pattern1.setInterval(1); - - recurrence1.setRange(range1); - recurrence1.setPattern(pattern1); - - settings1.setStart("2024-01-15T10:00:00+01:00"); // Standard time - settings1.setEnd("2024-01-15T12:00:00+01:00"); // 2 hours - settings1.setRecurrence(recurrence1); - - assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings1), - "Time window validation should work during standard time"); + public void validate23HourWindowDuringUSDSTTransition() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); - // Test during daylight time (same duration, different offset) - final TimeWindowFilterSettings settings2 = new TimeWindowFilterSettings(); - final RecurrencePattern pattern2 = new RecurrencePattern(); - final RecurrenceRange range2 = new RecurrenceRange(); - final Recurrence recurrence2 = new Recurrence(); + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Sunday", "Tuesday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); - pattern2.setType("Daily"); - pattern2.setInterval(1); + recurrence.setRange(range); + recurrence.setPattern(pattern); - recurrence2.setRange(range2); - recurrence2.setPattern(pattern2); + // 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); - settings2.setStart("2024-06-15T10:00:00+02:00"); // Daylight time - settings2.setEnd("2024-06-15T12:00:00+02:00"); // 2 hours - settings2.setRecurrence(recurrence2); + // Reference during US DST transition + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 10, 18, 0, 0, 0, ZoneOffset.UTC); - assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings2), - "Time window validation should work during daylight time"); + // 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 validation when days of week span DST transition. - * Ensures that the validator correctly handles weekly patterns - * where recurring days cross DST boundaries. + * Test with multi-week interval and 23-hour window. + * Even with larger gaps, the window must still be validated correctly. */ @Test - public void validateDaysOfWeekSpanningDSTTransition() { + 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"); - // Multiple days including Sunday (typical DST transition day) - pattern.setDaysOfWeek(List.of("Friday", "Saturday", "Sunday", "Monday")); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); pattern.setFirstDayOfWeek("Monday"); - pattern.setInterval(1); + pattern.setInterval(2); // Every 2 weeks recurrence.setRange(range); recurrence.setPattern(pattern); - // Start on Friday before DST transition week - settings.setStart("2024-03-22T10:00:00+01:00"); // Friday - settings.setEnd("2024-03-22T11:00:00+01:00"); + // 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); - assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings), - "Days of week validation should work spanning DST transition"); + 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"); } } From 4da9f6aedf1e87f26a355c6de7d416e1433d3e2f Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 16 Mar 2026 09:28:33 -0700 Subject: [PATCH 6/8] Update RecurrenceValidatorDSTTest.java --- .../RecurrenceValidatorDSTTest.java | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) 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 index 5d1e5b686838..7d36bb9f2833 100644 --- 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 @@ -102,11 +102,11 @@ public void validate23HourWindowWithConsecutiveDays() { } /** - * Test that a 24-hour window FAILS validation with consecutive day recurrence. - * The window must be strictly less than the gap between occurrences. + * Test that a window GREATER than the gap fails validation with consecutive day recurrence. + * The window must not exceed the gap between occurrences. */ @Test - public void validate24HourWindowWithConsecutiveDaysShouldFail() { + public void validate25HourWindowWithConsecutiveDaysShouldFail() { final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); final RecurrencePattern pattern = new RecurrencePattern(); final RecurrenceRange range = new RecurrenceRange(); @@ -120,17 +120,17 @@ public void validate24HourWindowWithConsecutiveDaysShouldFail() { recurrence.setRange(range); recurrence.setPattern(pattern); - // Exactly 24-hour window (equal to gap, should fail) + // 25-hour window (greater than 24-hour gap, should fail) settings.setStart("2024-03-25T10:00:00+01:00"); // Monday - settings.setEnd("2024-03-26T10:00:00+01:00"); // Tuesday, exactly 24 hours later + settings.setEnd("2024-03-26T11:00:00+01:00"); // Tuesday, 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 must be strictly less than gap + // Should fail because window duration (25h) > gap (24h) assertThrows(IllegalArgumentException.class, () -> RecurrenceValidator.validateSettings(settings, reference), - "24-hour window should fail validation with 24-hour gap (not strictly less)"); + "25-hour window should fail validation with 24-hour gap"); } /** @@ -228,10 +228,12 @@ public void validateDailyRecurrence23HourWindow() { } /** - * Test that 24-hour window FAILS with daily recurrence. + * 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 validateDailyRecurrence24HourWindowShouldFail() { + public void validateDailyRecurrence25HourWindowShouldFail() { final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); final RecurrencePattern pattern = new RecurrencePattern(); final RecurrenceRange range = new RecurrenceRange(); @@ -243,16 +245,18 @@ public void validateDailyRecurrence24HourWindowShouldFail() { recurrence.setRange(range); recurrence.setPattern(pattern); - // Exactly 24-hour window + // 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-31T10:00:00+02:00"); // Exactly 24 hours later (crossing DST) + 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), - "24-hour window should fail for daily recurrence (not strictly less than 24-hour gap)"); + "25-hour window should fail for daily recurrence (greater than 24-hour interval)"); } /** From 0a4eaaa8e42a7aa108baf1fe9e110faea4ae8f74 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Mon, 16 Mar 2026 10:21:02 -0700 Subject: [PATCH 7/8] copilot comments --- .../recurrence/RecurrenceEvaluator.java | 7 +- .../recurrence/RecurrenceValidator.java | 30 +++++--- .../RecurrenceValidatorDSTTest.java | 69 ++++++++++--------- 3 files changed, 63 insertions(+), 43 deletions(-) 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 24742e255b60..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 @@ -7,6 +7,7 @@ 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; @@ -97,8 +98,10 @@ 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(); + // 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)); 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 ca5ba73a711c..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 @@ -115,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")); } @@ -155,16 +158,19 @@ private static boolean isDurationCompliantWithDaysOfWeek(TimeWindowFilterSetting 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; @@ -174,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/RecurrenceValidatorDSTTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceValidatorDSTTest.java index 7d36bb9f2833..0ca08ae2c3cb 100644 --- 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 @@ -30,12 +30,12 @@ public class RecurrenceValidatorDSTTest { /** - * Test that a 23.5-hour window between Monday and Wednesday is valid when using UTC, - * even during a DST transition week. Without UTC, the validator might incorrectly - * calculate the gap as 22.5 or 24.5 hours depending on DST. + * 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 in UTC. + * to ensure the validator correctly calculates gaps using calendar days (always 24 hours per day). */ @Test public void validate23HourWindowDuringDSTSpringForward() { @@ -45,31 +45,33 @@ public void validate23HourWindowDuringDSTSpringForward() { final Recurrence recurrence = new Recurrence(); pattern.setType("Weekly"); - pattern.setDaysOfWeek(List.of("Monday", "Wednesday")); // 48-hour gap minimum + 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.5-hour window (just under 24 hours, well within 48-hour gap) - settings.setStart("2024-03-25T10:00:00+01:00"); // Monday, before DST - settings.setEnd("2024-03-26T09:30:00+01:00"); // Tuesday, 23.5 hours later + // 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) - // If validator used local time incorrectly, it might miscalculate the Monday-Wednesday gap + // 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.5 hours < 48 hours (Monday to Wednesday gap) + // Should pass because 23 hours < 48 hours (Monday to Wednesday is 2 calendar days = 48 hours) assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringDST), - "23.5-hour window should be valid with 48-hour gap, even during DST"); + "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 (Monday, Tuesday). - * Without UTC, if the validator ran on the DST transition day, it might incorrectly - * calculate the Monday-Tuesday gap as 23 hours (losing 1 hour), causing validation to fail. + * 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() { @@ -79,7 +81,7 @@ public void validate23HourWindowWithConsecutiveDays() { final Recurrence recurrence = new Recurrence(); pattern.setType("Weekly"); - pattern.setDaysOfWeek(List.of("Monday", "Tuesday")); // 24-hour gap minimum + pattern.setDaysOfWeek(List.of("Sunday", "Monday")); // 24-hour gap minimum (1 calendar day) pattern.setFirstDayOfWeek("Monday"); pattern.setInterval(1); @@ -87,23 +89,26 @@ public void validate23HourWindowWithConsecutiveDays() { recurrence.setPattern(pattern); // 23-hour window (1 hour less than 24-hour gap) - settings.setStart("2024-03-25T10:00:00+01:00"); // Monday - settings.setEnd("2024-03-26T09:00:00+01:00"); // Tuesday, 23 hours later + 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 on DST transition day - without UTC, the Monday-Tuesday gap - // might be calculated as 23 hours due to DST, causing incorrect failure - final ZonedDateTime referenceDuringDST = ZonedDateTime.of(2024, 3, 31, 2, 30, 0, 0, + // 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 (UTC calculation) + // 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 calculated in UTC"); + "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() { @@ -121,13 +126,13 @@ public void validate25HourWindowWithConsecutiveDaysShouldFail() { recurrence.setPattern(pattern); // 25-hour window (greater than 24-hour gap, should fail) - settings.setStart("2024-03-25T10:00:00+01:00"); // Monday - settings.setEnd("2024-03-26T11:00:00+01:00"); // Tuesday, 25 hours later + 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) + // 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"); @@ -135,8 +140,8 @@ public void validate25HourWindowWithConsecutiveDaysShouldFail() { /** * Test validation across DST fall back transition. - * A 23.5-hour window on Sunday-Tuesday should be valid even though - * the Sunday night has an extra hour (25-hour day). + * 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() { @@ -153,18 +158,18 @@ public void validate23HourWindowDuringDSTFallBack() { recurrence.setRange(range); recurrence.setPattern(pattern); - // 23.5-hour window starting on Sunday before DST fall back - settings.setStart("2024-10-27T10:00:00+02:00"); // Sunday (daylight time) - settings.setEnd("2024-10-28T09:30:00+01:00"); // Monday, 23.5 hours later (standard time) + // 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.5 hours < 48 hours (Sunday to Tuesday gap in UTC) + // Should pass because 23 hours < 48 hours (Sunday to Tuesday gap with calendar days) assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringFallBack), - "23.5-hour window should be valid during DST fall back when calculated in UTC"); + "23-hour window should be valid during DST fall back when using calendar-based calculation"); } /** From b214f75cd3a916d51ed4b9028da42cc00d4ed3b8 Mon Sep 17 00:00:00 2001 From: Matthew Metcalf Date: Mon, 16 Mar 2026 11:37:07 -0700 Subject: [PATCH 8/8] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../recurrence/RecurrenceValidatorDSTTest.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 index 0ca08ae2c3cb..0e76a6da5895 100644 --- 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 @@ -265,8 +265,8 @@ public void validateDailyRecurrence25HourWindowShouldFail() { } /** - * Test Monday-Friday-Sunday pattern with 23.5-hour window. - * Minimum gap is Monday-Friday (96 hours), so this should pass. + * 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() { @@ -320,12 +320,13 @@ public void validateConsecutiveDaysSpanningDSTTransition() { settings.setEnd("2024-03-31T09:00:00+01:00"); // Sunday, 23 hours later settings.setRecurrence(recurrence); - // Reference on the DST transition day itself - final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 2, 30, 0, 0, + // 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 Saturday-Sunday or Sunday-Monday (24 hours in UTC) - // 23 hours < 24 hours, so should pass + // 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"); }