// Copyright 2020 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package timeinterval import ( "encoding/json" "reflect" "testing" "time" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" ) var timeIntervalTestCases = []struct { validTimeStrings []string invalidTimeStrings []string timeInterval TimeInterval }{ { timeInterval: TimeInterval{}, validTimeStrings: []string{ "02 Jan 06 15:04 +0000", "03 Jan 07 10:04 +0000", "04 Jan 06 09:04 +0000", }, invalidTimeStrings: []string{}, }, { // 9am to 5pm, monday to friday timeInterval: TimeInterval{ Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, }, validTimeStrings: []string{ "04 May 20 15:04 +0000", "05 May 20 10:04 +0000", "09 Jun 20 09:04 +0000", }, invalidTimeStrings: []string{ "03 May 20 15:04 +0000", "04 May 20 08:59 +0000", "05 May 20 05:00 +0000", }, }, { // Easter 2020 timeInterval: TimeInterval{ DaysOfMonth: []DayOfMonthRange{{InclusiveRange{Begin: 4, End: 6}}}, Months: []MonthRange{{InclusiveRange{Begin: 4, End: 4}}}, Years: []YearRange{{InclusiveRange{Begin: 2020, End: 2020}}}, }, validTimeStrings: []string{ "04 Apr 20 15:04 +0000", "05 Apr 20 00:00 +0000", "06 Apr 20 23:05 +0000", }, invalidTimeStrings: []string{ "03 May 18 15:04 +0000", "03 Apr 20 23:59 +0000", "04 Jun 20 23:59 +0000", "06 Apr 19 23:59 +0000", "07 Apr 20 00:00 +0000", }, }, { // Check negative days of month, last 3 days of each month timeInterval: TimeInterval{ DaysOfMonth: []DayOfMonthRange{{InclusiveRange{Begin: -3, End: -1}}}, }, validTimeStrings: []string{ "31 Jan 20 15:04 +0000", "30 Jan 20 15:04 +0000", "29 Jan 20 15:04 +0000", "30 Jun 20 00:00 +0000", "29 Feb 20 23:05 +0000", }, invalidTimeStrings: []string{ "03 May 18 15:04 +0000", "27 Jan 20 15:04 +0000", "03 Apr 20 23:59 +0000", "04 Jun 20 23:59 +0000", "06 Apr 19 23:59 +0000", "07 Apr 20 00:00 +0000", "01 Mar 20 00:00 +0000", }, }, { // Check out of bound days are clamped to month boundaries timeInterval: TimeInterval{ Months: []MonthRange{{InclusiveRange{Begin: 6, End: 6}}}, DaysOfMonth: []DayOfMonthRange{{InclusiveRange{Begin: -31, End: 31}}}, }, validTimeStrings: []string{ "30 Jun 20 00:00 +0000", "01 Jun 20 00:00 +0000", }, invalidTimeStrings: []string{ "31 May 20 00:00 +0000", "1 Jul 20 00:00 +0000", }, }, { // Check alternative timezones can be used to compare times. // AEST 9AM to 5PM, Monday to Friday. timeInterval: TimeInterval{ Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, Location: &Location{mustLoadLocation("Australia/Sydney")}, }, validTimeStrings: []string{ "06 Apr 21 13:00 +1000", }, invalidTimeStrings: []string{ "06 Apr 21 13:00 +0000", }, }, { // Check an alternative timezone during daylight savings time. timeInterval: TimeInterval{ Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, Months: []MonthRange{{InclusiveRange{Begin: 11, End: 11}}}, Location: &Location{mustLoadLocation("Australia/Sydney")}, }, validTimeStrings: []string{ "01 Nov 21 09:00 +1100", "31 Oct 21 22:00 +0000", }, invalidTimeStrings: []string{ "31 Oct 21 21:00 +0000", }, }, } var timeStringTestCases = []struct { timeString string TimeRange TimeRange expectError bool }{ { timeString: "{'start_time': '00:00', 'end_time': '24:00'}", TimeRange: TimeRange{StartMinute: 0, EndMinute: 1440}, expectError: false, }, { timeString: "{'start_time': '01:35', 'end_time': '17:39'}", TimeRange: TimeRange{StartMinute: 95, EndMinute: 1059}, expectError: false, }, { timeString: "{'start_time': '09:35', 'end_time': '09:39'}", TimeRange: TimeRange{StartMinute: 575, EndMinute: 579}, expectError: false, }, { // Error: Begin and End times are the same timeString: "{'start_time': '17:31', 'end_time': '17:31'}", TimeRange: TimeRange{}, expectError: true, }, { // Error: End time out of range timeString: "{'start_time': '12:30', 'end_time': '24:01'}", TimeRange: TimeRange{}, expectError: true, }, { // Error: Start time greater than End time timeString: "{'start_time': '09:30', 'end_time': '07:41'}", TimeRange: TimeRange{}, expectError: true, }, { // Error: Start time out of range and greater than End time timeString: "{'start_time': '24:00', 'end_time': '17:41'}", TimeRange: TimeRange{}, expectError: true, }, { // Error: No range specified timeString: "{'start_time': '14:03'}", TimeRange: TimeRange{}, expectError: true, }, } var yamlUnmarshalTestCases = []struct { in string intervals []TimeInterval contains []string excludes []string expectError bool err string }{ { // Simple business hours test in: ` --- - weekdays: ['monday:friday'] times: - start_time: '09:00' end_time: '17:00' `, intervals: []TimeInterval{ { Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, }, }, contains: []string{ "08 Jul 20 09:00 +0000", "08 Jul 20 16:59 +0000", }, excludes: []string{ "08 Jul 20 05:00 +0000", "08 Jul 20 08:59 +0000", }, expectError: false, }, { // More advanced test with negative indices and ranges in: ` --- # Last week, excluding Saturday, of the first quarter of the year during business hours from 2020 to 2025 and 2030-2035 - weekdays: ['monday:friday', 'sunday'] months: ['january:march'] days_of_month: ['-7:-1'] years: ['2020:2025', '2030:2035'] times: - start_time: '09:00' end_time: '17:00' `, intervals: []TimeInterval{ { Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}, {InclusiveRange{Begin: 0, End: 0}}}, Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, Months: []MonthRange{{InclusiveRange{1, 3}}}, DaysOfMonth: []DayOfMonthRange{{InclusiveRange{-7, -1}}}, Years: []YearRange{{InclusiveRange{2020, 2025}}, {InclusiveRange{2030, 2035}}}, }, }, contains: []string{ "27 Jan 21 09:00 +0000", "28 Jan 21 16:59 +0000", "29 Jan 21 13:00 +0000", "31 Mar 25 13:00 +0000", "31 Mar 25 13:00 +0000", "31 Jan 35 13:00 +0000", }, excludes: []string{ "30 Jan 21 13:00 +0000", // Saturday "01 Apr 21 13:00 +0000", // 4th month "30 Jan 26 13:00 +0000", // 2026 "31 Jan 35 17:01 +0000", // After 5pm }, expectError: false, }, { in: ` --- - weekdays: ['monday:friday'] times: - start_time: '09:00' end_time: '17:00'`, intervals: []TimeInterval{ { Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, }, }, contains: []string{ "01 Apr 21 13:00 +0000", }, }, { // Invalid start time. in: ` --- - times: - start_time: '01:99' end_time: '23:59'`, expectError: true, err: "couldn't parse timestamp 01:99, invalid format", }, { // Invalid end time. in: ` --- - times: - start_time: '00:00' end_time: '99:99'`, expectError: true, err: "couldn't parse timestamp 99:99, invalid format", }, { // Start day before end day. in: ` --- - weekdays: ['friday:monday']`, expectError: true, err: "start day cannot be before end day", }, { // Invalid weekdays. in: ` --- - weekdays: ['blurgsday:flurgsday'] `, expectError: true, err: "blurgsday is not a valid weekday", }, { // Numeric weekdays aren't allowed. in: ` --- - weekdays: ['1:3'] `, expectError: true, err: "1 is not a valid weekday", }, { // Negative numeric weekdays aren't allowed. in: ` --- - weekdays: ['-2:-1'] `, expectError: true, err: "-2 is not a valid weekday", }, { // 0 day of month. in: ` --- - days_of_month: ['0'] `, expectError: true, err: "0 is not a valid day of the month: out of range", }, { // Start day of month < 0. in: ` --- - days_of_month: ['-50:-20'] `, expectError: true, err: "-50 is not a valid day of the month: out of range", }, { // End day of month > 31. in: ` --- - days_of_month: ['1:50'] `, expectError: true, err: "50 is not a valid day of the month: out of range", }, { // Negative indices should work. in: ` --- - days_of_month: ['1:-1'] `, intervals: []TimeInterval{ { DaysOfMonth: []DayOfMonthRange{{InclusiveRange{1, -1}}}, }, }, expectError: false, }, { // End day must be negative if begin day is negative. in: ` --- - days_of_month: ['-15:5'] `, expectError: true, err: "end day must be negative if start day is negative", }, { // Negative end date before positive positive start date. in: ` --- - days_of_month: ['10:-25'] `, expectError: true, err: "end day -25 is always before start day 10", }, { // Months should work regardless of case in: ` --- - months: ['January:december'] `, expectError: false, intervals: []TimeInterval{ { Months: []MonthRange{{InclusiveRange{1, 12}}}, }, }, }, { // Time zones may be specified by location. in: ` --- - years: ['2020:2022'] location: 'Australia/Sydney' `, expectError: false, intervals: []TimeInterval{ { Years: []YearRange{{InclusiveRange{2020, 2022}}}, Location: &Location{mustLoadLocation("Australia/Sydney")}, }, }, }, { // Invalid start month. in: ` --- - months: ['martius:june'] `, expectError: true, err: "martius is not a valid month", }, { // Invalid end month. in: ` --- - months: ['march:junius'] `, expectError: true, err: "junius is not a valid month", }, { // Start month after end month. in: ` --- - months: ['december:january'] `, expectError: true, err: "end month january is before start month december", }, { // Start year after end year. in: ` --- - years: ['2022:2020'] `, expectError: true, err: "end year 2020 is before start year 2022", }, } func TestYamlUnmarshal(t *testing.T) { for _, tc := range yamlUnmarshalTestCases { var ti []TimeInterval err := yaml.Unmarshal([]byte(tc.in), &ti) if err != nil && !tc.expectError { t.Errorf("Received unexpected error: %v when parsing %v", err, tc.in) } else if err == nil && tc.expectError { t.Errorf("Expected error when unmarshalling %s but didn't receive one", tc.in) } else if err != nil && tc.expectError { if err.Error() != tc.err { t.Errorf("Incorrect error: Want %s, got %s", tc.err, err.Error()) } continue } if !reflect.DeepEqual(ti, tc.intervals) { t.Errorf("Error unmarshalling %s: Want %+v, got %+v", tc.in, tc.intervals, ti) } for _, ts := range tc.contains { _t, _ := time.Parse(time.RFC822Z, ts) isContained := false for _, interval := range ti { if interval.ContainsTime(_t) { isContained = true } } if !isContained { t.Errorf("Expected intervals to contain time %s", _t) } } for _, ts := range tc.excludes { _t, _ := time.Parse(time.RFC822Z, ts) isContained := false for _, interval := range ti { if interval.ContainsTime(_t) { isContained = true } } if isContained { t.Errorf("Expected intervals to exclude time %s", _t) } } } } func TestContainsTime(t *testing.T) { for _, tc := range timeIntervalTestCases { for _, ts := range tc.validTimeStrings { _t, _ := time.Parse(time.RFC822Z, ts) if !tc.timeInterval.ContainsTime(_t) { t.Errorf("Expected period %+v to contain %+v", tc.timeInterval, _t) } } for _, ts := range tc.invalidTimeStrings { _t, _ := time.Parse(time.RFC822Z, ts) if tc.timeInterval.ContainsTime(_t) { t.Errorf("Period %+v not expected to contain %+v", tc.timeInterval, _t) } } } } func TestParseTimeString(t *testing.T) { for _, tc := range timeStringTestCases { var tr TimeRange err := yaml.Unmarshal([]byte(tc.timeString), &tr) if err != nil && !tc.expectError { t.Errorf("Received unexpected error: %v when parsing %v", err, tc.timeString) } else if err == nil && tc.expectError { t.Errorf("Expected error for invalid string %s but didn't receive one", tc.timeString) } else if !reflect.DeepEqual(tr, tc.TimeRange) { t.Errorf("Error parsing time string %s: Want %+v, got %+v", tc.timeString, tc.TimeRange, tr) } } } func TestYamlMarshal(t *testing.T) { for _, tc := range yamlUnmarshalTestCases { if tc.expectError { continue } var ti []TimeInterval err := yaml.Unmarshal([]byte(tc.in), &ti) if err != nil { t.Error(err) } out, err := yaml.Marshal(&ti) if err != nil { t.Error(err) } var ti2 []TimeInterval yaml.Unmarshal(out, &ti2) if !reflect.DeepEqual(ti, ti2) { t.Errorf("Re-marshalling %s produced a different TimeInterval.", tc.in) } } } // Test JSON marshalling by marshalling a time interval // and then unmarshalling to ensure they're identical func TestJsonMarshal(t *testing.T) { for _, tc := range yamlUnmarshalTestCases { if tc.expectError { continue } var ti []TimeInterval err := yaml.Unmarshal([]byte(tc.in), &ti) if err != nil { t.Error(err) } out, err := json.Marshal(&ti) if err != nil { t.Error(err) } var ti2 []TimeInterval json.Unmarshal(out, &ti2) if !reflect.DeepEqual(ti, ti2) { t.Errorf("Re-marshalling %s produced a different TimeInterval. Used:\n%s and got:\n%v", tc.in, out, ti2) } } } var completeTestCases = []struct { in string contains []string excludes []string }{ { in: ` --- weekdays: ['monday:wednesday', 'saturday', 'sunday'] times: - start_time: '13:00' end_time: '15:00' days_of_month: ['1', '10', '20:-1'] years: ['2020:2023'] months: ['january:march'] `, contains: []string{ "10 Jan 21 13:00 +0000", "30 Jan 21 14:24 +0000", }, excludes: []string{ "09 Jan 21 13:00 +0000", "20 Jan 21 12:59 +0000", "02 Feb 21 13:00 +0000", }, }, { // Check for broken clamping (clamping begin date after end of month to the end of the month) in: ` --- days_of_month: ['30:31'] years: ['2020:2023'] months: ['february'] `, excludes: []string{ "28 Feb 21 13:00 +0000", }, }, } // Tests the entire flow from unmarshalling to containing a time func TestTimeIntervalComplete(t *testing.T) { for _, tc := range completeTestCases { var ti TimeInterval if err := yaml.Unmarshal([]byte(tc.in), &ti); err != nil { t.Error(err) } for _, ts := range tc.contains { tt, err := time.Parse(time.RFC822Z, ts) if err != nil { t.Error(err) } if !ti.ContainsTime(tt) { t.Errorf("Expected %s to contain %s", tc.in, ts) } } for _, ts := range tc.excludes { tt, err := time.Parse(time.RFC822Z, ts) if err != nil { t.Error(err) } if ti.ContainsTime(tt) { t.Errorf("Expected %s to exclude %s", tc.in, ts) } } } } // Utility function for declaring time locations in test cases. Panic if the location can't be loaded. func mustLoadLocation(name string) *time.Location { loc, err := time.LoadLocation(name) if err != nil { panic(err) } return loc } func TestIntervener_Mutes(t *testing.T) { // muteIn mutes alerts outside business hours in November, using the +1100 timezone. muteIn := ` --- - weekdays: - monday:friday location: Australia/Sydney months: - November times: - start_time: 00:00 end_time: 09:00 - start_time: 17:00 end_time: 24:00 - weekdays: - saturday - sunday months: - November location: 'Australia/Sydney' ` intervalName := "test" var intervals []TimeInterval err := yaml.Unmarshal([]byte(muteIn), &intervals) require.NoError(t, err) m := map[string][]TimeInterval{intervalName: intervals} tc := []struct { name string firedAt string expected bool err error }{ { name: "Should not mute on Friday during business hours", firedAt: "19 Nov 21 13:00 +1100", expected: false, }, { name: "Should not mute on a Tuesday before 5pm", firedAt: "16 Nov 21 16:59 +1100", expected: false, }, { name: "Should mute on a Saturday", firedAt: "20 Nov 21 10:00 +1100", expected: true, }, { name: "Should mute before 9am on a Wednesday", firedAt: "17 Nov 21 05:00 +1100", expected: true, }, { name: "Should mute even if we are in a different timezone (KST)", firedAt: "14 Nov 21 20:00 +0900", expected: true, }, { name: "Should mute even if the timezone is UTC", firedAt: "14 Nov 21 21:30 +0000", expected: true, }, { name: "Should not mute different timezone (KST)", firedAt: "15 Nov 22 14:30 +0900", expected: false, }, { name: "Should mute in a different timezone (PET)", firedAt: "15 Nov 21 02:00 -0500", expected: true, }, } for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { now, err := time.Parse(time.RFC822Z, tt.firedAt) require.NoError(t, err) intervener := NewIntervener(m) expected, err := intervener.Mutes([]string{intervalName}, now) if err != nil { require.Error(t, tt.err) require.False(t, tt.expected) } require.NoError(t, err) require.Equal(t, expected, tt.expected) }) } }