diff --git a/README.md b/README.md index c549be6..9582fe9 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,23 @@ # Time Resource -Implements a resource that reports new versions on a configured interval. The -interval can be arbitrarily long. +Implements a resource that reports new versions on a configured interval or cron schedule. The time intervals can be +arbitrarily long. Build Status -This resource is built to satisfy "trigger this build at least once every 5 -minutes," not "trigger this build on the 10th hour of every Sunday." That -level of precision is better left to other tools. +This resource is built to satisfy needs like "trigger this build at least once every 5 minutes" or "trigger this build +at specific times using cron expressions." For simple interval-based triggering, the interval configuration is simpler +to use. For more complex scheduling, the cron configuration provides greater flexibility. ## Source Configuration +### Interval-based Configuration + * `interval`: *Optional.* The interval on which to report new versions. Valid - units are: “ns”, “us” (or “µs”), “ms”, “s”, “m”, “h”. Examples: `60s`, `90m`, - `1h30m`. If not specified, this resource will generate exactly 1 new version - per calendar day on each of the valid `days`. + units are: "s", "m", "h". Examples: `60s`, `90m`, `1h30m`. If not specified, this resource will + generate exactly 1 new version per calendar day on each of the valid `days`. * `location`: *Optional. Default `UTC`.* The [location](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) in @@ -30,7 +31,7 @@ level of precision is better left to other tools. * `start` and `stop`: *Optional.* Limit the creation of new versions to times on/after `start` and before `stop`. The supported formats for the times are: - `3:04 PM`, `3PM`, `3PM`, `15:04`, and `1504`. If a `start` is specified, a + `3:04 PM`, `3PM`, `3 PM`, `15:04`, and `1504`. If a `start` is specified, a `stop` must also be specified, and vice versa. If neither value is specified, both values will default to `00:00` and this resource can generate a new version (based on `interval`) at any time of day. @@ -70,72 +71,162 @@ level of precision is better left to other tools. These can be combined to emit a new version on an interval during a particular time period. -* `initial_version`: *Optional.* When using `start` and `stop` as a trigger for - a job, you will be unable to run the job manually until it goes into the - configured time range for the first time (manual runs will work once the `time` - resource has produced it's first version). +### Cron-based Configuration + +* `cron`: *Optional.* A cron expression that defines when new versions should be created. Standard cron format is + supported with 5 fields (minute, hour, day of month, month, day of week). + + **Important:** The 6-field format (including seconds) is not supported. You must use the standard 5-field format that + only specifies minute precision. + + e.g. + + ``` + cron: "0 * * * *" # Every hour at minute 0 + ``` + +**Tags:** Shorthand aliases for common schedules: + +| Tag | Expression | Schedule | +|-----|------------|----------| +| `@yearly` / `@annually` | `0 0 1 1 *` | Midnight, Jan 1st | +| `@monthly` | `0 0 1 * *` | Midnight, 1st of month | +| `@weekly` | `0 0 * * 0` | Midnight, Sunday | +| `@daily` | `0 0 * * *` | Midnight | +| `@hourly` | `0 * * * *` | Start of every hour | +| `@30minutes` | `0,30 * * * *` | Every 30 minutes (:00, :30) | +| `@15minutes` | `*/15 * * * *` | Every 15 minutes | +| `@10minutes` | `*/10 * * * *` | Every 10 minutes | +| `@5minutes` | `*/5 * * * *` | Every 5 minutes | + +e.g. + ``` + cron: "@daily" # Run once a day at midnight + ``` + +**Modifiers:** Special modifiers for complex scheduling: + +| Field | Modifier | Example | Description | +|-------|----------|---------|-------------| +| Day of Month | `L` | `0 2 L * *` | Last day of month (e.g., 28th/29th/30th/31st) | +| | `W` | `0 1 15W * *` | Nearest weekday to date (if 15th is Sat, triggers Fri 14th) | +| Day of Week | `L` | `0 3 * * 5L` | Last occurrence in month (5L = last Friday) | +| | `#` | `0 5 * * 1#2` | Nth occurrence in month (1#2 = second Monday) | + +**Note: You cannot use `cron` together with `interval`, `start`, `stop`, or `days`. Use either the cron-based or +interval-based configuration.** + +* `location`: *Optional. Default `UTC`.* When used with `cron`, the cron schedule is evaluated in this timezone. + See interval-based configuration above for format details. + +### Common Configuration Options + +* `initial_version`: *Optional.* When using `start` and `stop` or `cron` as a trigger for + a job, you will be unable to run the job manually until it reaches the + configured time range or cron schedule for the first time (manual runs will work once the `time` + resource has produced its first version). To get around this issue, there are two approaches: - * Use `initial_version: true`, which will produce a new version that is - set to the current time, if `check` runs and there isn't already a version - specified. **NOTE: This has a downside that if used with `trigger: true`, it will - kick off the correlating job when the pipeline is first created, even - outside of the specified window**. - * Alternatively, once you push a pipeline that utilizes `start` and `stop`, run the - following fly command to run the resource check from a previous point - in time (see [this issue](https://github.com/concourse/time-resource/issues/24#issuecomment-689422764) - for 6.x.x+ or [this issue](https://github.com/concourse/time-resource/issues/11#issuecomment-562385742) - for older Concourse versions). - - ``` - fly -t \ - check-resource --resource / - --from "time:2000-01-01T00:00:00Z" # the important part - ``` - - This has the benefit that it shouldn't trigger that initial job run, but - will still allow you to manually run the job if needed. + * Use `initial_version: true`, which will produce a new version that is + set to the current time, if `check` runs and there isn't already a version + specified. **NOTE: This has a downside that if used with `trigger: true`, it will + kick off the correlating job when the pipeline is first created, even + outside of the specified window**. + * Alternatively, once you push a pipeline that utilizes time-based constraints, run the + following fly command to run the resource check from a previous point + in time (see [this issue](https://github.com/concourse/time-resource/issues/24#issuecomment-689422764) + for 6.x.x+ or [this issue](https://github.com/concourse/time-resource/issues/11#issuecomment-562385742) + for older Concourse versions). + + ``` + fly -t \ + check-resource --resource / + --from "time:2000-01-01T00:00:00Z" # the important part + ``` + This has the benefit that it shouldn't trigger that initial job run, but + will still allow you to manually run the job if needed. e.g. ``` initial_version: true ``` -* `start_after`: *Optional.* Specifies the earliest datetime from which new time-based versions can be created. +* `start_after`: *Optional.* Specifies the earliest datetime from which new time-based versions can be created. Supported formats are `2006-01-02 15:04:05`, `2006-01-02T15:04:05`, `2006-01-02T15:04`, `2006-01-02T15`, `2006-01-02`. Behavior: - - If the `start_after` datetime is specified and is in the future, it will determine when the first version is created. - - If the `start_after` datetime is in the past, the resource will continue to generate versions based on the other configuration parameters. - - When `initial_version` is set to true, the first version will be created based on the current time. Subsequent versions will only be generated if they fall after the `start_after` datetime. - - If a `location` is provided, the `start_after` datetime will be interpreted in the context of the specified timezone, rather than in UTC. + - If the `start_after` datetime is specified and is in the future, it will determine when the first version is created. + - If the `start_after` datetime is in the past, the resource will continue to generate versions based on the other configuration parameters. + - When `initial_version` is set to true, the first version will be created based on the current time. Subsequent versions will only be generated if they fall after the `start_after` datetime. + - If a `location` is provided, the `start_after` datetime will be interpreted in the context of the specified timezone, rather than in UTC. e.g. ``` start_after: 2023-10-01T00:00:00 ``` + +### Differences Between `interval` and `cron` + +There is a difference between `interval` and `cron` when trying to create similar schedules. `interval` will trigger regardless of calendar boundaries, while `cron` will trigger strictly following calendar boundaries. Let's look at an example. + +If we want something to run "every 2 days" you can do that in these two ways: + +* `interval: 48h` or +* `cron: "0 0 */2 * *"` + +When these configurations trigger is very different. + +The `interval` configuration will trigger every 48 hours based on when the last trigger ran. + +The `cron` configuration will trigger every 2 calendar days at midnight. Cron also calculates "every 2 days" to be the 1st of each month and then every 2 days from then. So this cron schedule will trigger on the 1st, 3rd, 5th, 7th, etc. of every month. This also means if you're in a month with a 31st day, the resource will emit a version on the 31st and then again on the 1st of the next month, resulting in a trigger two days in a row. + +A similar convention is followed with minutes and hours. When trying to schedule cron intervals like "every x minute/hour", cron will actually trigger "every x minute/hour of the hour/day". For example: + +* `*/5 * * * *` "Every 5 minutes" is actually "every 5th minute of the hour" (00, 05, 10, 15, etc.) +* `0 */6 * * *` "Every 6 hours" is actually "every 6th hour of the day" (00, 06, 12, 18) + +**Recommendation:** If you want true elapsed-time intervals (e.g., "every 48 hours from the last run"), use `interval`. If you want calendar-aligned schedules (e.g., "at midnight on specific days"), use `cron`. + +### Cron Diagnostic Output + +When a cron-triggered version is emitted, the resource logs a human-readable explanation to stderr: + +``` +cron: emitting version at 2025-01-07T00:00:00Z (previous: 2025-01-05T00:00:00Z) + triggers every 2 days from 1st of month, at 00:00; note: 31st then 1st = back-to-back triggers +``` + +This includes warnings for common cron pitfalls: + +| Condition | Warning | +|-----------|---------| +| Day step lands on 31st (e.g., `*/2`, `*/3`, `*/5`) | `note: 31st then 1st = back-to-back triggers` | +| Day 31 specified | `note: only triggers in months with 31 days (Jan, Mar, May, Jul, Aug, Oct, Dec)` | +| Day 30 specified | `note: skips February` | +| Day 29 specified | `note: only triggers in leap years for February` | +| Both day-of-month and day-of-week set | `note: day-of-month AND day-of-week uses OR logic, not AND (triggers on EITHER match)` | +| Hour 1-3 specified | `note: may skip or double-trigger during DST transitions` | + ## Behavior -### `check`: Produce timestamps satisfying the interval. +### `check`: Produce timestamps satisfying the interval or cron schedule. Returns current version and new version only if it has been longer than `interval` since the -given version, or if there is no version given. - +given version, or if the time matches the specified cron expression, or if there is no version given. ### `in`: Report the given time. Fetches the given timestamp. Creates three files: 1. `input` which contains the request provided by Concourse -1. `timestamp` which contains the fetched version in the following format: `2006-01-02 15:04:05.999999999 -0700 MST` -1. `epoch` which contains the fetched version as a Unix epoch Timestamp (integer only) +2. `timestamp` which contains the fetched version in the following format: `2006-01-02 15:04:05.999999999 -0700 MST` +3. `epoch` which contains the fetched version as a Unix epoch Timestamp (integer only) #### Parameters *None.* - ### `out`: Produce the current time. Returns a version for the current timestamp. This can be used to record the @@ -145,7 +236,6 @@ time within a build plan, e.g. after running some long-running task. *None.* - ## Examples ### Periodic trigger @@ -165,6 +255,42 @@ jobs: config: # ... ``` +### Cron trigger + +```yaml +resources: +- name: nightly-build + type: time + source: + cron: "0 0 * * *" # Every day at midnight + +jobs: +- name: run-nightly-build + plan: + - get: nightly-build + trigger: true + - task: build + config: # ... +``` + +### Cron trigger using tags + +```yaml +resources: +- name: weekly-cleanup + type: time + source: + cron: "@weekly" # Every Sunday at midnight + +jobs: +- name: run-weekly-cleanup + plan: + - get: weekly-cleanup + trigger: true + - task: cleanup + config: # ... +``` + ### Trigger once within time range ```yaml @@ -225,13 +351,70 @@ jobs: config: # ... ``` +### Trigger only on specific days + +```yaml +resources: +- name: weekday-mornings + type: time + source: + interval: 1h + start: 9:00 AM + stop: 12:00 PM + days: [Monday, Tuesday, Wednesday, Thursday, Friday] + location: Europe/London + +jobs: +- name: something-every-hour-on-weekday-mornings + plan: + - get: weekday-mornings + trigger: true + - task: something + config: # ... +``` + +### Cron trigger with modifiers + +```yaml +resources: +- name: last-day-of-month + type: time + source: + cron: "0 9 L * *" # 9:00 AM on the last day of each month + location: America/New_York + +jobs: +- name: monthly-report + plan: + - get: last-day-of-month + trigger: true + - task: generate-report + config: # ... +``` +```yaml +resources: +- name: second-monday + type: time + source: + cron: "0 7 * * 1#2" # 7:00 AM on the second Monday of each month + location: Europe/Berlin + +jobs: +- name: bi-monthly-planning + plan: + - get: second-monday + trigger: true + - task: planning-meeting + config: # ... +``` + ## Development ### Prerequisites -* golang is *required* - version 1.9.x is tested; earlier versions may also +* golang is *required* - version 1.25.x is tested; earlier versions may also work. -* docker is *required* - version 17.06.x is tested; earlier versions may also +* docker is *required* - version 25.x is tested; earlier versions may also work. * go mod is used for dependency management of the golang packages. @@ -251,4 +434,4 @@ docker build -t time-resource --target tests . ### Contributing Please make all pull requests to the `master` branch and ensure tests pass -locally. +locally. \ No newline at end of file diff --git a/check_command.go b/check_command.go index a653514..4e96090 100644 --- a/check_command.go +++ b/check_command.go @@ -1,6 +1,10 @@ package resource import ( + "fmt" + "os" + "strconv" + "strings" "time" "github.com/concourse/time-resource/lord" @@ -10,6 +14,255 @@ import ( type CheckCommand struct { } +// DescribeCron returns a human-readable explanation of a cron expression +func DescribeCron(expr string) string { + // Handle common macros + switch expr { + case "@yearly", "@annually": + return "triggers once a year at midnight on January 1st" + case "@monthly": + return "triggers at midnight on the 1st of every month" + case "@weekly": + return "triggers at midnight every Sunday" + case "@daily", "@midnight": + return "triggers once a day at midnight" + case "@hourly": + return "triggers at the start of every hour" + } + + fields := strings.Fields(expr) + if len(fields) != 5 { + return fmt.Sprintf("schedule: %s", expr) + } + + minute, hour, dom, month, dow := fields[0], fields[1], fields[2], fields[3], fields[4] + var parts []string + var warnings []string + + // Day-of-week + if dow != "*" && dow != "?" { + dowDesc, dowWarnings := describeDOW(dow) + parts = append(parts, "on "+dowDesc) + warnings = append(warnings, dowWarnings...) + } + + // Day-of-month + domDesc, domWarnings := describeDOM(dom) + if domDesc != "" { + parts = append(parts, domDesc) + } + warnings = append(warnings, domWarnings...) + + // DOM + DOW = OR logic warning + if dom != "*" && dom != "?" && dow != "*" && dow != "?" { + warnings = append(warnings, "note: day-of-month AND day-of-week uses OR logic, not AND (triggers on EITHER match)") + } + + // Month + if month != "*" { + parts = append(parts, "in "+describeMonth(month)) + } + + // Time description + timeDesc := describeTime(minute, hour) + if timeDesc != "" { + parts = append(parts, timeDesc) + } + + // DST warning for specific hours + if hour != "*" && !strings.Contains(hour, "/") && !strings.Contains(hour, ",") { + h, err := strconv.Atoi(hour) + if err == nil && h >= 1 && h <= 3 { + warnings = append(warnings, "note: may skip or double-trigger during DST transitions") + } + } + + if len(parts) == 0 { + return fmt.Sprintf("schedule: %s", expr) + } + + result := "triggers " + strings.Join(parts, ", ") + if len(warnings) > 0 { + result += "; " + strings.Join(warnings, "; ") + } + return result +} + +func describeTime(minute, hour string) string { + // Every N minutes + if strings.HasPrefix(minute, "*/") { + step := strings.TrimPrefix(minute, "*/") + return fmt.Sprintf("every %s minutes", step) + } + + // Every N hours + if strings.HasPrefix(hour, "*/") { + step := strings.TrimPrefix(hour, "*/") + if minute == "0" { + return fmt.Sprintf("every %s hours at minute 0", step) + } + return fmt.Sprintf("every %s hours at minute %s", step, minute) + } + + // Specific time + if hour != "*" && minute != "*" { + h, _ := strconv.Atoi(hour) + m, _ := strconv.Atoi(minute) + return fmt.Sprintf("at %02d:%02d", h, m) + } + + if hour != "*" { + return fmt.Sprintf("during hour %s", hour) + } + + if minute != "*" && !strings.Contains(minute, "/") { + return fmt.Sprintf("at minute %s of every hour", minute) + } + + return "" +} + +func describeDOW(dow string) (string, []string) { + days := map[string]string{ + "0": "Sunday", "1": "Monday", "2": "Tuesday", "3": "Wednesday", + "4": "Thursday", "5": "Friday", "6": "Saturday", + "SUN": "Sunday", "MON": "Monday", "TUE": "Tuesday", "WED": "Wednesday", + "THU": "Thursday", "FRI": "Friday", "SAT": "Saturday", + } + + var warnings []string + dowUpper := strings.ToUpper(dow) + + if strings.Contains(dow, "#") { + parts := strings.Split(dow, "#") + if len(parts) == 2 { + dayName := parts[0] + if name, ok := days[strings.ToUpper(dayName)]; ok { + dayName = name + } + nth := parts[1] + ordinal := toOrdinal(nth) + if nth == "5" { + warnings = append(warnings, "note: 5th occurrence only exists in some months") + } + return fmt.Sprintf("%s %s of the month", ordinal, dayName), warnings + } + } + + if strings.HasSuffix(dowUpper, "L") { + dayNum := strings.TrimSuffix(dow, "L") + dayNum = strings.TrimSuffix(dayNum, "l") + if name, ok := days[dayNum]; ok { + return fmt.Sprintf("last %s of the month", name), warnings + } + return fmt.Sprintf("last %s of the month", dayNum), warnings + } + + if strings.Contains(dow, "-") { + parts := strings.Split(dow, "-") + if len(parts) == 2 { + startDay := parts[0] + endDay := parts[1] + if name, ok := days[strings.ToUpper(startDay)]; ok { + startDay = name + } + if name, ok := days[strings.ToUpper(endDay)]; ok { + endDay = name + } + return fmt.Sprintf("%s through %s", startDay, endDay), warnings + } + } + + if name, ok := days[dowUpper]; ok { + return name, warnings + } + return dow, warnings +} + +func toOrdinal(n string) string { + num, err := strconv.Atoi(n) + if err != nil { + return n + } + suffix := "th" + if num%100 < 11 || num%100 > 13 { + switch num % 10 { + case 1: + suffix = "st" + case 2: + suffix = "nd" + case 3: + suffix = "rd" + } + } + return fmt.Sprintf("%d%s", num, suffix) +} + +func describeDOM(dom string) (string, []string) { + var warnings []string + + if dom == "*" || dom == "?" { + return "", warnings + } + + if strings.HasPrefix(dom, "*/") { + step := strings.TrimPrefix(dom, "*/") + desc := fmt.Sprintf("every %s days from 1st of month", step) + stepNum, err := strconv.Atoi(step) + if err == nil && stepNum > 0 { + for day := 1; day <= 31; day += stepNum { + if day == 31 { + warnings = append(warnings, "note: 31st then 1st = back-to-back triggers") + break + } + } + } + return desc, warnings + } + + domUpper := strings.ToUpper(dom) + + if domUpper == "L" { + return "on the last day of the month", warnings + } + + if strings.HasSuffix(domUpper, "W") { + dayNum := strings.TrimSuffix(dom, "W") + dayNum = strings.TrimSuffix(dayNum, "w") + desc := fmt.Sprintf("on the nearest weekday to the %s", toOrdinal(dayNum)) + if dayNum == "31" { + warnings = append(warnings, "note: only triggers in months with 31 days") + } else if dayNum == "30" { + warnings = append(warnings, "note: skips February") + } else if dayNum == "29" { + warnings = append(warnings, "note: only triggers in leap years for February") + } + return desc, warnings + } + + if dom == "31" { + warnings = append(warnings, "note: only triggers in months with 31 days (Jan, Mar, May, Jul, Aug, Oct, Dec)") + } else if dom == "30" { + warnings = append(warnings, "note: skips February") + } else if dom == "29" { + warnings = append(warnings, "note: only triggers in leap years for February") + } + + return "on day " + dom + " of the month", warnings +} + +func describeMonth(month string) string { + months := map[string]string{ + "1": "January", "2": "February", "3": "March", "4": "April", + "5": "May", "6": "June", "7": "July", "8": "August", + "9": "September", "10": "October", "11": "November", "12": "December", + } + if name, ok := months[month]; ok { + return name + } + return "month " + month +} + func (*CheckCommand) Run(request models.CheckRequest) ([]models.Version, error) { err := request.Source.Validate() if err != nil { @@ -32,6 +285,7 @@ func (*CheckCommand) Run(request models.CheckRequest) ([]models.Version, error) Interval: request.Source.Interval, Days: request.Source.Days, StartAfter: request.Source.StartAfter, + Cron: request.Source.Cron, } var versions []models.Version @@ -39,12 +293,46 @@ func (*CheckCommand) Run(request models.CheckRequest) ([]models.Version, error) if !previousTime.IsZero() { versions = append(versions, models.Version{Time: previousTime}) } else if request.Source.InitialVersion { - versions = append(versions, models.Version{Time: currentTime}) + // For cron with initial_version, use the cron boundary time for consistency. + // For non-cron, use currentTime + versionTime := currentTime + if request.Source.Cron != nil { + cronTime := tl.Latest(currentTime) + if !cronTime.IsZero() { + versionTime = cronTime + fmt.Fprintf(os.Stderr, "cron: emitting initial version at %s\n %s\n", + versionTime.Format(time.RFC3339), DescribeCron(request.Source.Cron.Expression)) + } + } + versions = append(versions, models.Version{Time: versionTime}) + return versions, nil + } else if request.Source.Cron != nil { + // Cron with initial_version:false (or unset) and no previous version: + // don't emit any version until after the first cron trigger is observed. return versions, nil } if tl.Check(currentTime) { - versions = append(versions, models.Version{Time: currentTime}) + var versionTime time.Time + + // For cron expressions, use the actual scheduled cron time + // instead of the check time. This ensures versions are at cron boundaries. + // Example: cron @5minutes, check at 3:07pm → version time = 3:05pm + if request.Source.Cron != nil { + versionTime = tl.Latest(currentTime) + if !versionTime.IsZero() { + fmt.Fprintf(os.Stderr, "cron: emitting version at %s (previous: %s)\n %s\n", + versionTime.Format(time.RFC3339), previousTime.Format(time.RFC3339), + DescribeCron(request.Source.Cron.Expression)) + } + } + + // For non-cron (interval, start/stop ranges), use currentTime + if versionTime.IsZero() { + versionTime = currentTime + } + + versions = append(versions, models.Version{Time: versionTime}) } return versions, nil diff --git a/check_command_test.go b/check_command_test.go index b6f18b1..e182ecc 100644 --- a/check_command_test.go +++ b/check_command_test.go @@ -1,6 +1,7 @@ package resource_test import ( + "fmt" "time" resource "github.com/concourse/time-resource" @@ -11,9 +12,7 @@ import ( ) var _ = Describe("Check", func() { - var ( - now time.Time - ) + var now time.Time BeforeEach(func() { now = time.Now().UTC() @@ -32,7 +31,6 @@ var _ = Describe("Check", func() { JustBeforeEach(func() { command := resource.CheckCommand{} - var err error response, err = command.Run(models.CheckRequest{ Source: source, @@ -50,29 +48,25 @@ var _ = Describe("Check", func() { }) Context("when a version is given", func() { - var prev time.Time - - Context("when the resource has already triggered on the current day", func() { + Context("when the resource has already triggered today", func() { BeforeEach(func() { - prev = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, now.Second(), now.Nanosecond(), now.Location()) - version.Time = prev + version.Time = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, now.Second(), now.Nanosecond(), now.Location()) }) - It("outputs a supplied version", func() { + It("outputs only the supplied version", func() { Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1)) + Expect(response[0].Time.Unix()).To(BeNumerically("~", version.Time.Unix(), 1)) }) }) Context("when the resource was triggered yesterday", func() { BeforeEach(func() { - prev = now.Add(-24 * time.Hour) - version.Time = prev + version.Time = now.Add(-24 * time.Hour) }) - It("outputs a version containing the current time and supplied version", func() { + It("outputs both previous and current versions", func() { Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1)) + Expect(response[0].Time.Unix()).To(BeNumerically("~", version.Time.Unix(), 1)) Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) }) }) @@ -84,7 +78,6 @@ var _ = Describe("Check", func() { BeforeEach(func() { start := now.Add(-1 * time.Hour) stop := now.Add(1 * time.Hour) - source.Start = tod(start.Hour(), start.Minute(), 0) source.Stop = tod(stop.Hour(), stop.Minute(), 0) }) @@ -96,58 +89,26 @@ var _ = Describe("Check", func() { }) }) - Context("when a version is given", func() { - var prev time.Time - - Context("when the resource has already triggered with in the current time range", func() { - BeforeEach(func() { - prev = now.Add(-30 * time.Minute) - version.Time = prev - }) - - It("outputs a supplied version", func() { - Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1)) - }) + Context("when a version is given within the current time range", func() { + BeforeEach(func() { + version.Time = now.Add(-30 * time.Minute) }) - Context("when the resource was triggered yesterday near the end of the time frame", func() { - BeforeEach(func() { - prev = now.Add(-23 * time.Hour) - version.Time = prev - }) - - It("outputs a version containing the current time and supplied version", func() { - Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1)) - Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) + It("outputs only the supplied version", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Unix()).To(BeNumerically("~", version.Time.Unix(), 1)) }) + }) - Context("when the resource was triggered last year near the end of the time frame", func() { - BeforeEach(func() { - prev = now.AddDate(-1, 0, 0) - version.Time = prev - }) - - It("outputs a version containing the current time and supplied version", func() { - Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1)) - Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) + Context("when a version is given from yesterday", func() { + BeforeEach(func() { + version.Time = now.Add(-24 * time.Hour) }) - Context("when the resource was triggered yesterday in the current time frame", func() { - BeforeEach(func() { - prev = now.Add(-24 * time.Hour) - version.Time = prev - }) - - It("outputs a version containing the current time and supplied version", func() { - Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", prev.Unix(), 1)) - Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) + It("outputs both previous and current versions", func() { + Expect(response).To(HaveLen(2)) + Expect(response[0].Time.Unix()).To(BeNumerically("~", version.Time.Unix(), 1)) + Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) }) }) @@ -164,55 +125,33 @@ var _ = Describe("Check", func() { }) }) - Context("when a version is given", func() { - var prev time.Time - - Context("when the interval has not elapsed", func() { - BeforeEach(func() { - prev = now - version.Time = prev - }) + Context("when the interval has not elapsed", func() { + BeforeEach(func() { + version.Time = now + }) - It("outputs only the supplied version", func() { - Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) - }) + It("outputs only the supplied version", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix())) }) + }) - Context("when the interval has elapsed", func() { - BeforeEach(func() { - prev = now.Add(-1 * time.Minute) - version.Time = prev - }) - - It("outputs a version containing the current time and supplied version", func() { - Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) - Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) + Context("when the interval has elapsed", func() { + BeforeEach(func() { + version.Time = now.Add(-2 * time.Minute) }) - Context("with its time N intervals ago", func() { - BeforeEach(func() { - prev = now.Add(-5 * time.Minute) - version.Time = prev - }) - - It("outputs a version containing the current time and supplied version", func() { - Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) - Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) + It("outputs both previous and current versions", func() { + Expect(response).To(HaveLen(2)) + Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix())) + Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) }) }) }) Context("when the current day is specified", func() { BeforeEach(func() { - source.Days = []models.Weekday{ - models.Weekday(now.Weekday()), - models.Weekday(now.AddDate(0, 0, 2).Weekday()), - } + source.Days = []models.Weekday{models.Weekday(now.Weekday())} }) It("outputs a version containing the current time", func() { @@ -223,10 +162,7 @@ var _ = Describe("Check", func() { Context("when we are out of the specified day", func() { BeforeEach(func() { - source.Days = []models.Weekday{ - models.Weekday(now.AddDate(0, 0, 1).Weekday()), - models.Weekday(now.AddDate(0, 0, 2).Weekday()), - } + source.Days = []models.Weekday{models.Weekday(now.AddDate(0, 0, 1).Weekday())} }) It("does not output any versions", func() { @@ -239,25 +175,16 @@ var _ = Describe("Check", func() { BeforeEach(func() { start := now.Add(6 * time.Hour) stop := now.Add(7 * time.Hour) - source.Start = tod(start.Hour(), start.Minute(), 0) source.Stop = tod(stop.Hour(), stop.Minute(), 0) }) - Context("when no version is given", func() { - It("does not output any versions", func() { - Expect(response).To(BeEmpty()) - }) + It("does not output any versions", func() { + Expect(response).To(BeEmpty()) }) Context("when an interval is given", func() { BeforeEach(func() { - start := now.Add(6 * time.Hour) - stop := now.Add(7 * time.Hour) - - source.Start = tod(start.Hour(), start.Minute(), 0) - source.Stop = tod(stop.Hour(), stop.Minute(), 0) - interval := models.Interval(time.Minute) source.Interval = &interval }) @@ -269,351 +196,639 @@ var _ = Describe("Check", func() { }) Context("with a location configured", func() { - var loc *time.Location - BeforeEach(func() { - var err error - loc, err = time.LoadLocation("America/Indiana/Indianapolis") + loc, err := time.LoadLocation("America/Indiana/Indianapolis") Expect(err).ToNot(HaveOccurred()) - srcLoc := models.Location(*loc) source.Location = &srcLoc - now = now.In(loc) + + start := now.Add(-1 * time.Hour) + stop := now.Add(1 * time.Hour) + source.Start = tod(start.Hour(), start.Minute(), 0) + source.Stop = tod(stop.Hour(), stop.Minute(), 0) }) - Context("when we are in the specified time range", func() { - BeforeEach(func() { - start := now.Add(-1 * time.Hour) - stop := now.Add(1 * time.Hour) + It("outputs a version when in range", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) + }) + }) + }) - source.Start = tod(start.Hour(), start.Minute(), 0) - source.Stop = tod(stop.Hour(), stop.Minute(), 0) - }) + Context("when an interval is specified", func() { + BeforeEach(func() { + interval := models.Interval(time.Minute) + source.Interval = &interval + }) - Context("when no version is given", func() { - It("outputs a version containing the current time", func() { - Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) - }) + Context("when no version is given", func() { + It("outputs a version containing the current time", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) + }) + }) - Context("when a version is given", func() { - var prev time.Time + Context("when the interval has not elapsed", func() { + BeforeEach(func() { + version.Time = now + }) - Context("when the resource has already triggered with in the current time range", func() { - BeforeEach(func() { - prev = now.Add(-30 * time.Minute) - version.Time = prev - }) + It("outputs only the supplied version", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix())) + }) + }) - It("outputs a supplied version", func() { - Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) - }) - }) + Context("when the interval has elapsed", func() { + BeforeEach(func() { + version.Time = now.Add(-2 * time.Minute) + }) - Context("when the resource was triggered yesterday near the end of the time frame", func() { - BeforeEach(func() { - prev = now.Add(-23 * time.Hour) - version.Time = prev - }) - - It("outputs a version containing the current time and supplied version", func() { - Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) - Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) - }) + It("outputs both previous and current versions", func() { + Expect(response).To(HaveLen(2)) + Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix())) + Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) + }) + }) - Context("when the resource was triggered yesterday in the current time frame", func() { - BeforeEach(func() { - prev = now.AddDate(0, 0, -1) - version.Time = prev - }) - - It("outputs a version containing the current time and supplied version", func() { - Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) - Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) - }) + Context("with a longer interval", func() { + BeforeEach(func() { + interval := models.Interval(time.Hour) + source.Interval = &interval + }) + + Context("when within the interval", func() { + BeforeEach(func() { + version.Time = now.Add(-30 * time.Minute) }) - Context("when an interval is specified", func() { - BeforeEach(func() { - interval := models.Interval(time.Minute) - source.Interval = &interval - }) + It("outputs only the supplied version", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix())) + }) + }) - Context("when no version is given", func() { - It("outputs a version containing the current time", func() { - Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) - }) + Context("when beyond the interval", func() { + BeforeEach(func() { + version.Time = now.Add(-61 * time.Minute) + }) - Context("when a version is given", func() { - var prev time.Time - - Context("with its time within the interval", func() { - BeforeEach(func() { - prev = now - version.Time = prev - }) - - It("outputs the given version", func() { - Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) - }) - }) - - Context("with its time one interval ago", func() { - BeforeEach(func() { - prev = now.Add(-1 * time.Minute) - version.Time = prev - }) - - It("outputs a version containing the current time and supplied version", func() { - Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) - Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) - }) - - Context("with its time N intervals ago", func() { - BeforeEach(func() { - prev = now.Add(-5 * time.Minute) - version.Time = prev - }) - - It("outputs a version containing the current time and supplied version", func() { - Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) - Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) - }) - }) + It("outputs both versions", func() { + Expect(response).To(HaveLen(2)) + Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix())) + Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) }) + }) + }) + }) - Context("when the current day is specified", func() { - BeforeEach(func() { - source.Days = []models.Weekday{ - models.Weekday(now.Weekday()), - models.Weekday(now.AddDate(0, 0, 2).Weekday()), - } - }) + Context("when a cron expression is specified", func() { + Context("when no version is given", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "*/5 * * * *"} + source.Cron = &cronExpr + }) - It("outputs a version containing the current time", func() { - Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) - }) + It("does not output any versions", func() { + Expect(response).To(BeEmpty()) + }) + }) - Context("when we are out of the specified day", func() { - BeforeEach(func() { - source.Days = []models.Weekday{ - models.Weekday(now.AddDate(0, 0, 1).Weekday()), - models.Weekday(now.AddDate(0, 0, 2).Weekday()), - } - }) + Context("when no version is given and initial_version is true", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "*/5 * * * *"} + source.Cron = &cronExpr + source.InitialVersion = true + }) - It("does not output any versions", func() { - Expect(response).To(BeEmpty()) - }) - }) + It("outputs a version at the most recent cron boundary with zero seconds and nanoseconds", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Minute() % 5).To(Equal(0)) + Expect(response[0].Time.Second()).To(Equal(0)) + Expect(response[0].Time.Nanosecond()).To(Equal(0)) + Expect(response[0].Time.Unix()).To(BeNumerically("<=", time.Now().Unix())) }) + }) - Context("when we are not within the specified time range", func() { + Context("when a version is given", func() { + Context("and next cron time has passed", func() { BeforeEach(func() { - start := now.Add(6 * time.Hour) - stop := now.Add(7 * time.Hour) + cronExpr := models.Cron{Expression: "* * * * *"} + source.Cron = &cronExpr + version.Time = now.Add(-2 * time.Minute) + }) - source.Start = tod(start.Hour(), start.Minute(), 0) - source.Stop = tod(stop.Hour(), stop.Minute(), 0) + It("outputs both previous version and new version at cron boundary", func() { + Expect(response).To(HaveLen(2)) + Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix())) + Expect(response[1].Time.Second()).To(Equal(0)) + Expect(response[1].Time.Nanosecond()).To(Equal(0)) }) + }) - Context("when no version is given", func() { - It("does not output any versions", func() { - Expect(response).To(BeEmpty()) - }) + Context("and next cron time has not passed", func() { + BeforeEach(func() { + futureMinute := (now.Minute() + 30) % 60 + cronExpr := models.Cron{Expression: fmt.Sprintf("%d * * * *", futureMinute)} + source.Cron = &cronExpr + version.Time = now }) - Context("when an interval is given", func() { - BeforeEach(func() { - start := now.Add(6 * time.Hour) - stop := now.Add(7 * time.Hour) + It("outputs only the previous version", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Unix()).To(Equal(version.Time.Unix())) + }) + }) + }) - source.Start = tod(start.Hour(), start.Minute(), 0) - source.Stop = tod(stop.Hour(), stop.Minute(), 0) + Context("with L modifier (last day of month)", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "0 9 L * *"} + source.Cron = &cronExpr + source.InitialVersion = true + }) - interval := models.Interval(time.Minute) - source.Interval = &interval - }) + It("outputs a version on the last day of a month", func() { + Expect(response).To(HaveLen(1)) + nextDay := response[0].Time.AddDate(0, 0, 1) + Expect(nextDay.Day()).To(Equal(1)) + }) + }) - It("does not output any versions", func() { - Expect(response).To(BeEmpty()) - }) - }) + Context("with # modifier (second Monday)", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "0 7 * * 1#2"} + source.Cron = &cronExpr + source.InitialVersion = true + }) + + It("outputs a version on the second Monday (day 8-14)", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Weekday()).To(Equal(time.Monday)) + Expect(response[0].Time.Day()).To(BeNumerically(">=", 8)) + Expect(response[0].Time.Day()).To(BeNumerically("<=", 14)) }) }) - }) - Context("when an interval is specified", func() { - BeforeEach(func() { - interval := models.Interval(time.Minute) - source.Interval = &interval + Context("with # modifier (fourth Thursday)", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "0 9 * * 4#4"} + source.Cron = &cronExpr + source.InitialVersion = true + }) + + It("outputs a version on the fourth Thursday (day 22-28)", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Weekday()).To(Equal(time.Thursday)) + Expect(response[0].Time.Day()).To(BeNumerically(">=", 22)) + Expect(response[0].Time.Day()).To(BeNumerically("<=", 28)) + }) }) - Context("when no version is given", func() { - It("outputs a version containing the current time", func() { + Context("with W modifier (nearest weekday to 15th)", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "0 9 15W * *"} + source.Cron = &cronExpr + source.InitialVersion = true + }) + + It("outputs a version on a weekday within 2 days of the 15th", func() { Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) + Expect(response[0].Time.Weekday()).NotTo(Equal(time.Saturday)) + Expect(response[0].Time.Weekday()).NotTo(Equal(time.Sunday)) + Expect(response[0].Time.Day()).To(BeNumerically(">=", 13)) + Expect(response[0].Time.Day()).To(BeNumerically("<=", 17)) }) }) - Context("when a version is given", func() { - var prev time.Time + Context("with 1W modifier (nearest weekday to 1st)", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "0 9 1W * *"} + source.Cron = &cronExpr + source.InitialVersion = true + }) - Context("with its time within the interval", func() { - BeforeEach(func() { - prev = now - version.Time = prev - }) + It("outputs a version on a weekday day 1-3 (stays in month)", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Weekday()).NotTo(Equal(time.Saturday)) + Expect(response[0].Time.Weekday()).NotTo(Equal(time.Sunday)) + Expect(response[0].Time.Day()).To(BeNumerically(">=", 1)) + Expect(response[0].Time.Day()).To(BeNumerically("<=", 3)) + }) + }) - It("outputs a supplied version", func() { - Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) - }) + Context("with 5L modifier (last Friday of month)", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "0 17 * * 5L"} + source.Cron = &cronExpr + source.InitialVersion = true }) - Context("with its time one interval ago", func() { - BeforeEach(func() { - prev = now.Add(-1 * time.Minute) - version.Time = prev - }) + It("outputs a version on a Friday where next Friday is in a different month", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Weekday()).To(Equal(time.Friday)) + nextFriday := response[0].Time.AddDate(0, 0, 7) + Expect(nextFriday.Month()).NotTo(Equal(response[0].Time.Month())) + }) + }) - It("outputs a version containing the current time and supplied version", func() { - Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) - Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) + Context("with @yearly macro", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "@yearly"} + source.Cron = &cronExpr + source.InitialVersion = true }) - Context("with its time N intervals ago", func() { - BeforeEach(func() { - prev = now.Add(-5 * time.Minute) - version.Time = prev - }) + It("outputs a version at January 1st midnight", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Month()).To(Equal(time.January)) + Expect(response[0].Time.Day()).To(Equal(1)) + Expect(response[0].Time.Hour()).To(Equal(0)) + Expect(response[0].Time.Minute()).To(Equal(0)) + }) + }) - It("outputs a version containing the current time and supplied version", func() { - Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(Equal(prev.Unix())) - Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) + Context("with @monthly macro", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "@monthly"} + source.Cron = &cronExpr + source.InitialVersion = true + }) + + It("outputs a version on the 1st at midnight", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Day()).To(Equal(1)) + Expect(response[0].Time.Hour()).To(Equal(0)) + Expect(response[0].Time.Minute()).To(Equal(0)) }) }) - }) - Context("when start_after is specified", func() { - Context("when no version is provided", func() { - Context("and the current time is after start_after", func() { - BeforeEach(func() { - startAfter := now.Add(-1 * time.Hour) - source.StartAfter = (*models.StartAfter)(&startAfter) - }) + Context("with @weekly macro", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "@weekly"} + source.Cron = &cronExpr + source.InitialVersion = true + }) - It("outputs a version containing the current time", func() { - Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1)) - }) + It("outputs a version on Sunday at midnight", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Weekday()).To(Equal(time.Sunday)) + Expect(response[0].Time.Hour()).To(Equal(0)) + Expect(response[0].Time.Minute()).To(Equal(0)) }) + }) - Context("and the current time is before start_after", func() { - BeforeEach(func() { - startAfter := now.Add(1 * time.Hour) - source.StartAfter = (*models.StartAfter)(&startAfter) - }) + Context("with specific month cron expression", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "0 0 1 6 *"} + source.Cron = &cronExpr + source.InitialVersion = true + }) - It("does not output any versions", func() { - Expect(response).To(BeEmpty()) - }) + It("outputs a version in June", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Month()).To(Equal(time.June)) }) }) - Context("when a version is provided", func() { - var previousTime time.Time + Context("with location configured", func() { + BeforeEach(func() { + loc, err := time.LoadLocation("America/New_York") + Expect(err).ToNot(HaveOccurred()) + srcLoc := models.Location(*loc) + source.Location = &srcLoc - Context("when the current time is after start_after and a previous version exists", func() { - BeforeEach(func() { - previousTime = now.Add(-24 * time.Hour) - version.Time = previousTime - startAfter := now.Add(-25 * time.Hour) - source.StartAfter = (*models.StartAfter)(&startAfter) - }) + cronExpr := models.Cron{Expression: "*/5 * * * *"} + source.Cron = &cronExpr + source.InitialVersion = true + }) - It("outputs both the previous and current versions", func() { - Expect(response).To(HaveLen(2)) - Expect(response[0].Time.Unix()).To(Equal(previousTime.Unix())) - Expect(response[1].Time.Unix()).To(BeNumerically("~", time.Now().Unix(), 1)) - }) + It("outputs a version at a cron boundary", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Minute() % 5).To(Equal(0)) + Expect(response[0].Time.Second()).To(Equal(0)) }) }) - Context("when initial_version is specified", func() { - Context("when the current time is before start_after and initial_version is true", func() { - BeforeEach(func() { - startAfter := now.Add(1 * time.Hour) - source.StartAfter = (*models.StartAfter)(&startAfter) - source.InitialVersion = true - }) + Context("with range in hour field", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "0 9-17 * * *"} + source.Cron = &cronExpr + source.InitialVersion = true + }) - It("outputs a single version containing the initial version", func() { - Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1)) - }) + It("outputs a version with hour between 9 and 17", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Hour()).To(BeNumerically(">=", 9)) + Expect(response[0].Time.Hour()).To(BeNumerically("<=", 17)) }) + }) - Context("when the current time is after start_after and initial_version is true", func() { - BeforeEach(func() { - startAfter := now.Add(-1 * time.Hour) - source.StartAfter = (*models.StartAfter)(&startAfter) - source.InitialVersion = true - }) + Context("with list in day-of-week field", func() { + BeforeEach(func() { + cronExpr := models.Cron{Expression: "0 9 * * 1,3,5"} + source.Cron = &cronExpr + source.InitialVersion = true + }) - It("outputs a single version containing the current time", func() { - Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1)) - }) + It("outputs a version on Monday, Wednesday, or Friday", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Weekday()).To(BeElementOf(time.Monday, time.Wednesday, time.Friday)) }) + }) + }) - Context("when the current time is before start_after and initial_version is false", func() { - BeforeEach(func() { - startAfter := now.Add(1 * time.Hour) - source.StartAfter = (*models.StartAfter)(&startAfter) - source.InitialVersion = false - }) + Context("when start_after is specified", func() { + Context("when current time is after start_after", func() { + BeforeEach(func() { + startAfter := now.Add(-1 * time.Hour) + source.StartAfter = (*models.StartAfter)(&startAfter) + }) - It("does not output any versions", func() { - Expect(response).To(BeEmpty()) - }) + It("outputs a version containing the current time", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1)) }) + }) - Context("when the current time is after start_after and initial_version is false", func() { - BeforeEach(func() { - startAfter := now.Add(-1 * time.Hour) - source.StartAfter = (*models.StartAfter)(&startAfter) - source.InitialVersion = false - }) + Context("when current time is before start_after", func() { + BeforeEach(func() { + startAfter := now.Add(1 * time.Hour) + source.StartAfter = (*models.StartAfter)(&startAfter) + }) - It("outputs a single version containing the current time", func() { - Expect(response).To(HaveLen(1)) - Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1)) - }) + It("does not output any versions", func() { + Expect(response).To(BeEmpty()) + }) + }) + + Context("when current time is before start_after but initial_version is true", func() { + BeforeEach(func() { + startAfter := now.Add(1 * time.Hour) + source.StartAfter = (*models.StartAfter)(&startAfter) + source.InitialVersion = true + }) + + It("outputs a version", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1)) + }) + }) + }) + + Context("when initial_version is true without other configuration", func() { + BeforeEach(func() { + source.InitialVersion = true + }) + + It("outputs a version containing the current time", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1)) + }) + }) + + Context("when multiple configurations are combined", func() { + Context("with interval and matching day", func() { + BeforeEach(func() { + interval := models.Interval(time.Minute) + source.Interval = &interval + source.Days = []models.Weekday{models.Weekday(now.Weekday())} + }) + + It("outputs a version", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1)) + }) + }) + + Context("with interval and non-matching day", func() { + BeforeEach(func() { + interval := models.Interval(time.Minute) + source.Interval = &interval + source.Days = []models.Weekday{models.Weekday(now.AddDate(0, 0, 1).Weekday())} + }) + + It("does not output any versions", func() { + Expect(response).To(BeEmpty()) + }) + }) + + Context("with time range, interval, and matching day", func() { + BeforeEach(func() { + start := now.Add(-1 * time.Hour) + stop := now.Add(1 * time.Hour) + source.Start = tod(start.Hour(), start.Minute(), 0) + source.Stop = tod(stop.Hour(), stop.Minute(), 0) + + interval := models.Interval(time.Minute) + source.Interval = &interval + source.Days = []models.Weekday{models.Weekday(now.Weekday())} + }) + + It("outputs a version when all conditions match", func() { + Expect(response).To(HaveLen(1)) + Expect(response[0].Time.Unix()).To(BeNumerically("~", now.Unix(), 1)) }) }) }) }) + + Context("with validation errors", func() { + var source models.Source + var response models.CheckResponse + var cmdErr error + + JustBeforeEach(func() { + command := resource.CheckCommand{} + response, cmdErr = command.Run(models.CheckRequest{ + Source: source, + Version: models.Version{}, + }) + }) + + Context("when start is provided without stop", func() { + BeforeEach(func() { + source = models.Source{} + start := models.NewTimeOfDay(time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)) + source.Start = &start + }) + + It("returns a validation error", func() { + Expect(cmdErr).To(HaveOccurred()) + Expect(response).To(BeNil()) + }) + }) + + Context("when stop is provided without start", func() { + BeforeEach(func() { + source = models.Source{} + stop := models.NewTimeOfDay(time.Date(2020, 1, 1, 17, 0, 0, 0, time.UTC)) + source.Stop = &stop + }) + + It("returns a validation error", func() { + Expect(cmdErr).To(HaveOccurred()) + Expect(response).To(BeNil()) + }) + }) + }) +}) + +var _ = Describe("DescribeCron", func() { + Context("common macros", func() { + DescribeTable("describes macros correctly", + func(expr, expected string) { + Expect(resource.DescribeCron(expr)).To(Equal(expected)) + }, + Entry("@yearly", "@yearly", "triggers once a year at midnight on January 1st"), + Entry("@annually", "@annually", "triggers once a year at midnight on January 1st"), + Entry("@monthly", "@monthly", "triggers at midnight on the 1st of every month"), + Entry("@weekly", "@weekly", "triggers at midnight every Sunday"), + Entry("@daily", "@daily", "triggers once a day at midnight"), + Entry("@midnight", "@midnight", "triggers once a day at midnight"), + Entry("@hourly", "@hourly", "triggers at the start of every hour"), + ) + }) + + Context("time intervals", func() { + DescribeTable("describes intervals correctly", + func(expr, expected string) { + Expect(resource.DescribeCron(expr)).To(Equal(expected)) + }, + Entry("every 5 minutes", "*/5 * * * *", "triggers every 5 minutes"), + Entry("every 2 hours at minute 0", "0 */2 * * *", "triggers every 2 hours at minute 0"), + Entry("every 3 hours at minute 30", "30 */3 * * *", "triggers every 3 hours at minute 30"), + ) + }) + + Context("specific times", func() { + DescribeTable("describes specific times correctly", + func(expr, expected string) { + Expect(resource.DescribeCron(expr)).To(Equal(expected)) + }, + Entry("09:30", "30 9 * * *", "triggers at 09:30"), + Entry("00:00", "0 0 * * *", "triggers at 00:00"), + Entry("minute 30 of every hour", "30 * * * *", "triggers at minute 30 of every hour"), + Entry("during hour 9", "* 9 * * *", "triggers during hour 9"), + ) + }) + + Context("days of week", func() { + DescribeTable("describes days correctly", + func(expr, expected string) { + Expect(resource.DescribeCron(expr)).To(Equal(expected)) + }, + Entry("Monday (1)", "0 9 * * 1", "triggers on Monday, at 09:00"), + Entry("Sunday (0)", "0 9 * * 0", "triggers on Sunday, at 09:00"), + Entry("Friday (FRI)", "0 9 * * FRI", "triggers on Friday, at 09:00"), + ) + }) + + Context("months", func() { + DescribeTable("describes months correctly", + func(expr, expected string) { + Expect(resource.DescribeCron(expr)).To(Equal(expected)) + }, + Entry("January", "0 9 * 1 *", "triggers in January, at 09:00"), + Entry("June", "0 9 * 6 *", "triggers in June, at 09:00"), + Entry("December", "0 9 * 12 *", "triggers in December, at 09:00"), + ) + }) + + Context("day-of-month intervals", func() { + It("warns about back-to-back triggers when step lands on 31", func() { + result := resource.DescribeCron("0 0 */2 * *") + Expect(result).To(ContainSubstring("every 2 days from 1st of month")) + Expect(result).To(ContainSubstring("31st then 1st = back-to-back triggers")) + }) + + It("does not warn when step does not land on 31", func() { + result := resource.DescribeCron("0 0 */7 * *") + Expect(result).To(ContainSubstring("every 7 days from 1st of month")) + Expect(result).NotTo(ContainSubstring("back-to-back")) + }) + }) + + Context("warnings", func() { + It("warns about day 31", func() { + result := resource.DescribeCron("0 0 31 * *") + Expect(result).To(ContainSubstring("only triggers in months with 31 days")) + }) + + It("warns about day 30 skipping February", func() { + result := resource.DescribeCron("0 0 30 * *") + Expect(result).To(ContainSubstring("skips February")) + }) + + It("warns about day 29 and leap years", func() { + result := resource.DescribeCron("0 0 29 * *") + Expect(result).To(ContainSubstring("only triggers in leap years for February")) + }) + + It("warns about DOM + DOW OR logic", func() { + result := resource.DescribeCron("0 0 15 * 1") + Expect(result).To(ContainSubstring("OR logic")) + }) + + It("warns about DST for hours 1-3", func() { + result := resource.DescribeCron("0 2 * * *") + Expect(result).To(ContainSubstring("DST")) + }) + + It("does not warn about DST for hour 0 or 4+", func() { + Expect(resource.DescribeCron("0 0 * * *")).NotTo(ContainSubstring("DST")) + Expect(resource.DescribeCron("0 4 * * *")).NotTo(ContainSubstring("DST")) + }) + }) + + Context("modifiers", func() { + It("describes L (last day of month)", func() { + Expect(resource.DescribeCron("0 9 L * *")).To(Equal("triggers on the last day of the month, at 09:00")) + }) + + It("describes W (nearest weekday)", func() { + Expect(resource.DescribeCron("0 9 15W * *")).To(Equal("triggers on the nearest weekday to the 15th, at 09:00")) + }) + + It("warns about 31W in short months", func() { + result := resource.DescribeCron("0 9 31W * *") + Expect(result).To(ContainSubstring("nearest weekday to the 31st")) + Expect(result).To(ContainSubstring("only triggers in months with 31 days")) + }) + + It("describes 5L (last Friday)", func() { + Expect(resource.DescribeCron("0 17 * * 5L")).To(Equal("triggers on last Friday of the month, at 17:00")) + }) + + It("describes 1#2 (second Monday)", func() { + Expect(resource.DescribeCron("0 7 * * 1#2")).To(Equal("triggers on 2nd Monday of the month, at 07:00")) + }) + + It("warns about 5th occurrence", func() { + result := resource.DescribeCron("0 9 * * 2#5") + Expect(result).To(ContainSubstring("5th Tuesday of the month")) + Expect(result).To(ContainSubstring("5th occurrence only exists in some months")) + }) + }) + + Context("edge cases", func() { + It("returns raw expression for invalid field count", func() { + Expect(resource.DescribeCron("* * *")).To(Equal("schedule: * * *")) + }) + + It("returns raw expression for all wildcards", func() { + Expect(resource.DescribeCron("* * * * *")).To(Equal("schedule: * * * * *")) + }) + + It("describes DOW ranges 0-2", func() { + Expect(resource.DescribeCron("0 9 * * 0-2")).To(Equal("triggers on Sunday through Tuesday, at 09:00")) + }) + + It("describes DOW ranges 1-5", func() { + Expect(resource.DescribeCron("0 9 * * 1-5")).To(Equal("triggers on Monday through Friday, at 09:00")) + }) + }) }) func tod(hours, minutes, offset int) *models.TimeOfDay { @@ -621,9 +836,7 @@ func tod(hours, minutes, offset int) *models.TimeOfDay { if offset != 0 { loc = time.FixedZone("UnitTest", 60*60*offset) } - now := time.Now() tod := models.NewTimeOfDay(time.Date(now.Year(), now.Month(), now.Day(), hours, minutes, 0, 0, loc)) - return &tod } diff --git a/go.mod b/go.mod index 7e12d4f..5f6b129 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,24 @@ module github.com/concourse/time-resource -go 1.24 +go 1.25.1 require ( - github.com/onsi/ginkgo/v2 v2.23.4 - github.com/onsi/gomega v1.38.0 + github.com/adhocore/gronx v1.19.6 + github.com/onsi/ginkgo/v2 v2.25.3 + github.com/onsi/gomega v1.38.2 ) require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect + github.com/google/pprof v0.0.0-20250919162441-8b542baf5bcf // indirect go.uber.org/automaxprocs v1.6.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.35.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/tools v0.37.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 26154ba..0fd177f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= +github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -8,14 +12,20 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/pprof v0.0.0-20250919162441-8b542baf5bcf h1:ekSWtXn05ARVCly6JvQLXzAqHfVR4Jq14KjVL1f+1JY= +github.com/google/pprof v0.0.0-20250919162441-8b542baf5bcf/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw= +github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE= github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY= github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= @@ -24,14 +34,26 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/lord/time_lord.go b/lord/time_lord.go index 087c428..38acbe0 100644 --- a/lord/time_lord.go +++ b/lord/time_lord.go @@ -3,6 +3,7 @@ package lord import ( "time" + "github.com/adhocore/gronx" "github.com/concourse/time-resource/models" ) @@ -16,9 +17,51 @@ type TimeLord struct { Interval *models.Interval Days []models.Weekday StartAfter *models.StartAfter + Cron *models.Cron } func (tl TimeLord) Check(now time.Time) bool { + if tl.Cron != nil { + nowInLoc := now.In(tl.loc()) + + // Check start_after constraint first + if tl.StartAfter != nil { + startAfter := time.Time(*tl.StartAfter) + startInLoc := time.Date(startAfter.Year(), startAfter.Month(), startAfter.Day(), + startAfter.Hour(), startAfter.Minute(), startAfter.Second(), 0, tl.loc()) + + if !startInLoc.Before(nowInLoc) { + return false + } + } + + if tl.PreviousTime.IsZero() { + // No previous version exists. + // Find the most recent scheduled cron time <= now. + // This ensures the resource emits an initial version even if the check + // doesn't run at the exact cron minute. + // + // Example: @daily, check at 3pm → prevTick = today 00:00 → trigger + // Example: @5minutes, check at 3:07pm → prevTick = 3:05pm → trigger + prevTick, err := gronx.PrevTickBefore(tl.Cron.Expression, nowInLoc, true) + if err != nil { + return false + } + return !prevTick.IsZero() + } + + // Previous version exists. + // Check if the next scheduled cron time after prevTime has passed. + // This handles late checks - if cron is :30 and check runs at :31, + // we still trigger because :30 has passed. + prevInLoc := tl.PreviousTime.In(tl.loc()) + nextTime, err := tl.Cron.Next(prevInLoc) + if err != nil { + return false + } + + return !nextTime.IsZero() && !nextTime.After(nowInLoc) + } start, stop := tl.LatestRangeBefore(now) @@ -31,7 +74,7 @@ func (tl TimeLord) Check(now time.Time) bool { startInLoc := time.Date(startAfter.Year(), startAfter.Month(), startAfter.Day(), startAfter.Hour(), startAfter.Minute(), startAfter.Second(), 0, tl.loc()) - if !startInLoc.Before(now) { + if !startInLoc.Before(now.In(tl.loc())) { return false } } @@ -45,21 +88,64 @@ func (tl TimeLord) Check(now time.Time) bool { } if tl.Interval != nil { - if now.Sub(tl.PreviousTime) >= time.Duration(*tl.Interval) { - return true - } - } else if !start.IsZero() { - return tl.PreviousTime.Before(start) + return now.Sub(tl.PreviousTime) >= time.Duration(*tl.Interval) } - return false + return !start.IsZero() && tl.PreviousTime.Before(start) } func (tl TimeLord) Latest(reference time.Time) time.Time { + if tl.Cron != nil { + refInLoc := reference.In(tl.loc()) + + if tl.StartAfter != nil { + startAfter := time.Time(*tl.StartAfter) + startInLoc := time.Date(startAfter.Year(), startAfter.Month(), startAfter.Day(), + startAfter.Hour(), startAfter.Minute(), startAfter.Second(), 0, tl.loc()) + + if !startInLoc.Before(refInLoc) { + return time.Time{} + } + } + + if tl.PreviousTime.IsZero() { + // Return the most recent scheduled cron time <= reference + prevTick, err := gronx.PrevTickBefore(tl.Cron.Expression, refInLoc, true) + if err != nil || prevTick.IsZero() { + return time.Time{} + } + return prevTick.UTC() + } + + // Find the most recent cron time <= reference + // This handles cases where multiple cron times have passed since prev + latest, err := gronx.PrevTickBefore(tl.Cron.Expression, refInLoc, true) + if err != nil || latest.IsZero() { + return time.Time{} + } + + // Only return if this is after the previous time (a new trigger) + prevInLoc := tl.PreviousTime.In(tl.loc()) + if latest.After(prevInLoc) { + return latest.UTC() + } + + return time.Time{} + } + if tl.PreviousTime.After(reference) { return time.Time{} } + if tl.StartAfter != nil { + startAfter := time.Time(*tl.StartAfter) + startInLoc := time.Date(startAfter.Year(), startAfter.Month(), startAfter.Day(), + startAfter.Hour(), startAfter.Minute(), startAfter.Second(), 0, tl.loc()) + if !startInLoc.Before(reference.In(tl.loc())) { + return time.Time{} + } + } + refInLoc := reference.In(tl.loc()) for !tl.daysMatch(refInLoc) { refInLoc = refInLoc.AddDate(0, 0, -1) @@ -72,6 +158,10 @@ func (tl TimeLord) Latest(reference time.Time) time.Time { } if tl.Interval == nil { + // If we're past the stop time, return zero (no new version to emit) + if !reference.Before(stop) { + return time.Time{} + } if tl.PreviousTime.After(start) { return time.Time{} } @@ -88,10 +178,57 @@ func (tl TimeLord) Latest(reference time.Time) time.Time { } func (tl TimeLord) List(reference time.Time) []time.Time { + if tl.Cron != nil { + refInLoc := reference.In(tl.loc()) + + if tl.StartAfter != nil { + startAfter := time.Time(*tl.StartAfter) + startInLoc := time.Date(startAfter.Year(), startAfter.Month(), startAfter.Day(), + startAfter.Hour(), startAfter.Minute(), startAfter.Second(), 0, tl.loc()) + + if !startInLoc.Before(reference.In(tl.loc())) { + return []time.Time{} + } + } + + if tl.PreviousTime.IsZero() { + // Return the most recent scheduled cron time <= reference + prevTick, err := gronx.PrevTickBefore(tl.Cron.Expression, refInLoc, true) + if err != nil || prevTick.IsZero() { + return []time.Time{} + } + return []time.Time{prevTick.UTC()} + } + + // Get previous time in local timezone + start := tl.PreviousTime.In(tl.loc()) + + // Get all occurrences between previous and reference + maxOccurrences := 5 + times := tl.Cron.NextN(start, refInLoc, maxOccurrences) + + // Convert all times back to UTC + result := make([]time.Time, len(times)) + for i, t := range times { + result[i] = t.UTC() + } + + return result + } + start := tl.PreviousTime + versions := []time.Time{} + + if tl.StartAfter != nil { + startAfter := time.Time(*tl.StartAfter) + startInLoc := time.Date(startAfter.Year(), startAfter.Month(), startAfter.Day(), + startAfter.Hour(), startAfter.Minute(), startAfter.Second(), 0, tl.loc()) + if !startInLoc.Before(reference.In(tl.loc())) { + return versions + } + } var addForRange func(time.Time, time.Time) - versions := []time.Time{} if tl.Interval == nil { @@ -103,7 +240,11 @@ func (tl TimeLord) List(reference time.Time) []time.Time { start = refRangeStart } - addForRange = func(dailyStart, _ time.Time) { + addForRange = func(dailyStart, dailyEnd time.Time) { + // Don't add if reference is past the stop time + if !reference.Before(dailyEnd) { + return + } if !dailyStart.Before(start) && !dailyStart.After(reference) { versions = append(versions, dailyStart) } @@ -173,7 +314,7 @@ func (tl TimeLord) LatestRangeBefore(reference time.Time) (time.Time, time.Time) refInLoc := reference.In(tl.loc()) start := time.Date(refInLoc.Year(), refInLoc.Month(), refInLoc.Day(), - tlStart.Hour(), tlStart.Minute(), 0, 0, tl.loc()) + tlStart.Hour(), tlStart.Minute(), tlStart.Second(), 0, tl.loc()) if start.After(refInLoc) { start = start.AddDate(0, 0, -1) diff --git a/lord/time_lord_test.go b/lord/time_lord_test.go index 668a4b9..75a2127 100644 --- a/lord/time_lord_test.go +++ b/lord/time_lord_test.go @@ -28,8 +28,11 @@ type testCase struct { days []time.Weekday start_after string - prev string - prevDay time.Weekday + + cron string + + prev string + prevDay time.Weekday now string extraTime time.Duration @@ -93,6 +96,14 @@ func (tc testCase) Run() { tl.Interval = (*models.Interval)(&interval) } + if tc.cron != "" { + cronExpr := models.Cron{Expression: tc.cron} + err := cronExpr.Validate() + Expect(err).NotTo(HaveOccurred()) + + tl.Cron = &cronExpr + } + tl.Days = make([]models.Weekday, len(tc.days)) for i, d := range tc.days { tl.Days[i] = models.Weekday(d) @@ -105,6 +116,8 @@ func (tc testCase) Run() { now = now.AddDate(0, 0, 1) } + now = now.Add(tc.extraTime) + if tc.prev != "" { tc.prev += " 2018" prev, err := time.Parse(exampleFormatWithTZ, tc.prev) @@ -138,7 +151,7 @@ func (tc testCase) Run() { expected := tc.list[idx] Expect(actual.Hour()).To(Equal(expected.hour)) Expect(actual.Minute()).To(Equal(expected.minute)) - Expect(latest.Second()).To(Equal(0)) + Expect(actual.Second()).To(Equal(0)) Expect(actual.Weekday()).To(Equal(expected.weekday)) } } @@ -209,7 +222,6 @@ var _ = DescribeTable("A range without a previous time", (testCase).Run, result: false, latest: expectedTime{isZero: true}, }), - Entry("between the start and stop time but the stop time is before the start time, spanning more than a day", testCase{ start: "5:00 AM +0000", stop: "1:00 AM +0000", @@ -231,7 +243,6 @@ var _ = DescribeTable("A range without a previous time", (testCase).Run, result: true, latest: expectedTime{hour: 20, weekday: time.Saturday}, }), - Entry("between the start and stop time but the compare time is in a different timezone", testCase{ start: "2:00 AM -0600", stop: "6:00 AM -0600", @@ -239,7 +250,6 @@ var _ = DescribeTable("A range without a previous time", (testCase).Run, result: true, latest: expectedTime{hour: 8}, }), - Entry("covering almost a full day", testCase{ start: "12:01 AM -0700", stop: "11:59 PM -0700", @@ -273,108 +283,80 @@ var _ = DescribeTable("A range with a previous time", (testCase).Run, stop: "5:00 AM +0000", now: "4:00 AM +0000", nowDay: time.Tuesday, - prev: "9:00 AM +0000", + prev: "7:00 AM +0000", prevDay: time.Monday, result: true, latest: expectedTime{hour: 10, weekday: time.Monday}, }), - Entry("after now and in range on same day as now", testCase{ - start: "2:00 AM +0000", - stop: "4:00 AM +0000", - now: "3:00 AM +0000", - prev: "3:30 AM +0000", - result: false, - latest: expectedTime{isZero: true}, - }), - Entry("after now and out of range on same day as now", testCase{ - start: "2:00 AM +0000", - stop: "4:00 AM +0000", - now: "3:00 AM +0000", - prev: "5:00 AM +0000", - result: false, - latest: expectedTime{isZero: true}, + Entry("with stop before start and prev in the start day and now after the stop time", testCase{ + start: "10:00 AM +0000", + stop: "5:00 AM +0000", + now: "6:00 AM +0000", + nowDay: time.Tuesday, + prev: "11:00 AM +0000", + prevDay: time.Monday, + result: false, + latest: expectedTime{isZero: true}, }), -) - -var _ = DescribeTable("A range with a location and no previous time", (testCase).Run, - Entry("between the start and stop time in a given location", testCase{ - location: "America/Indiana/Indianapolis", - start: "1:00 PM", - stop: "3:00 PM", - now: "6:00 PM +0000", - result: true, - latest: expectedTime{hour: 13}, - }), - Entry("between the start and stop time in a given location on a matching day", testCase{ - location: "America/Indiana/Indianapolis", - start: "1:00 PM", - stop: "3:00 PM", - days: []time.Weekday{time.Wednesday}, - now: "6:00 PM +0000", - nowDay: time.Wednesday, - result: true, - latest: expectedTime{hour: 13, weekday: time.Wednesday}, + Entry("with stop before start and prev before the range and now after the start time", testCase{ + start: "10:00 AM +0000", + stop: "5:00 AM +0000", + now: "11:00 AM +0000", + nowDay: time.Tuesday, + prev: "8:00 AM +0000", + prevDay: time.Tuesday, + result: true, + latest: expectedTime{hour: 10, weekday: time.Tuesday}, }), - Entry("not between the start and stop time in a given location", testCase{ - location: "America/Indiana/Indianapolis", - start: "1:00 PM", - stop: "3:00 PM", - now: "8:00 PM +0000", - result: false, - latest: expectedTime{isZero: true}, + Entry("with different days where now is correct but prev is incorrect day", testCase{ + start: "10:00 AM +0000", + stop: "5:00 AM +0000", + now: "11:00 AM +0000", + nowDay: time.Tuesday, + prev: "11:00 AM +0000", + prevDay: time.Monday, + result: true, + latest: expectedTime{hour: 10, weekday: time.Tuesday}, }), - Entry("between the start and stop time in a given location but not on a matching day", testCase{ - location: "America/Indiana/Indianapolis", - start: "1:00 PM", - stop: "3:00 PM", - days: []time.Weekday{time.Wednesday}, - now: "6:00 PM +0000", - nowDay: time.Thursday, - result: false, - latest: expectedTime{isZero: true}, + Entry("with stop before start and prev in the stop day", testCase{ + start: "10:00 AM +0000", + stop: "5:00 AM +0000", + now: "6:00 AM +0000", + nowDay: time.Tuesday, + prev: "4:00 AM +0000", + prevDay: time.Tuesday, + result: false, + latest: expectedTime{isZero: true}, }), - Entry("between the start and stop time in a given location and on a matching day compared to UTC", testCase{ - location: "America/Indiana/Indianapolis", - start: "9:00 PM", - stop: "11:00 PM", - days: []time.Weekday{time.Wednesday}, - now: "2:00 AM +0000", - nowDay: time.Thursday, - result: true, - latest: expectedTime{hour: 21, weekday: time.Wednesday}, + Entry("with stop before start and prev in the stop day and now in the start day", testCase{ + start: "10:00 AM +0000", + stop: "5:00 AM +0000", + now: "11:00 AM +0000", + nowDay: time.Tuesday, + prev: "4:00 AM +0000", + prevDay: time.Tuesday, + result: true, + latest: expectedTime{hour: 10, weekday: time.Tuesday}, }), -) - -var _ = DescribeTable("A range with a location and a previous time", (testCase).Run, - Entry("between the start and stop time in a given location, on a new day", testCase{ - location: "America/Indiana/Indianapolis", - start: "1:00 PM", - stop: "3:00 PM", - - prev: "6:00 PM +0000", - prevDay: time.Wednesday, - now: "6:00 PM +0000", - nowDay: time.Thursday, - - result: true, - latest: expectedTime{hour: 13, weekday: time.Thursday}, - list: []expectedTime{ - {hour: 13, weekday: time.Wednesday}, - {hour: 13, weekday: time.Thursday}, - }, + Entry("in the range", testCase{ + start: "2:00 AM +0000", + stop: "4:00 AM +0000", + now: "3:00 AM +0000", + nowDay: time.Monday, + prev: "2:30 AM +0000", + prevDay: time.Monday, + result: false, + latest: expectedTime{isZero: true}, }), - Entry("not between the start and stop time in a given location, on the same day", testCase{ - location: "America/Indiana/Indianapolis", - start: "1:00 PM", - stop: "3:00 PM", - - prev: "6:00 PM +0000", - prevDay: time.Wednesday, - now: "6:01 PM +0000", - nowDay: time.Wednesday, - - result: false, - latest: expectedTime{hour: 13, weekday: time.Wednesday}, + Entry("behind the range", testCase{ + start: "2:00 AM +0000", + stop: "4:00 AM +0000", + now: "4:30 AM +0000", + nowDay: time.Monday, + prev: "2:00 AM +0000", + prevDay: time.Monday, + result: false, + latest: expectedTime{isZero: true}, }), ) @@ -408,17 +390,14 @@ var _ = DescribeTable("An interval", (testCase).Run, var _ = DescribeTable("A range with an interval and a previous time", (testCase).Run, Entry("between the start and stop time, on a new day", testCase{ interval: "2m", - - start: "1:00 PM +0000", - stop: "3:00 PM +0000", - - prev: "2:58 PM +0000", - prevDay: time.Wednesday, - now: "1:00 PM +0000", - nowDay: time.Thursday, - - result: true, - latest: expectedTime{hour: 13, weekday: time.Thursday}, + start: "1:00 PM +0000", + stop: "3:00 PM +0000", + prev: "2:58 PM +0000", + prevDay: time.Wednesday, + now: "1:00 PM +0000", + nowDay: time.Thursday, + result: true, + latest: expectedTime{hour: 13, weekday: time.Thursday}, list: []expectedTime{ {hour: 14, minute: 58, weekday: time.Wednesday}, {hour: 13, weekday: time.Thursday}, @@ -426,15 +405,12 @@ var _ = DescribeTable("A range with an interval and a previous time", (testCase) }), Entry("between the start and stop time, elapsed", testCase{ interval: "2m", - - start: "1:00 PM +0000", - stop: "3:00 PM +0000", - - prev: "1:02 PM +0000", - now: "1:04 PM +0000", - - result: true, - latest: expectedTime{hour: 13, minute: 4}, + start: "1:00 PM +0000", + stop: "3:00 PM +0000", + prev: "1:02 PM +0000", + now: "1:04 PM +0000", + result: true, + latest: expectedTime{hour: 13, minute: 4}, list: []expectedTime{ {hour: 13, minute: 2}, {hour: 13, minute: 4}, @@ -442,27 +418,21 @@ var _ = DescribeTable("A range with an interval and a previous time", (testCase) }), Entry("between the start and stop time, not elapsed", testCase{ interval: "2m", - - start: "1:00 PM +0000", - stop: "3:00 PM +0000", - - prev: "1:02 PM +0000", - now: "1:03 PM +0000", - - result: false, - latest: expectedTime{hour: 13, minute: 2}, + start: "1:00 PM +0000", + stop: "3:00 PM +0000", + prev: "1:02 PM +0000", + now: "1:03 PM +0000", + result: false, + latest: expectedTime{hour: 13, minute: 2}, }), Entry("not between the start and stop time, elapsed", testCase{ interval: "2m", - - start: "1:00 PM +0000", - stop: "3:00 PM +0000", - - prev: "2:58 PM +0000", - now: "3:02 PM +0000", - - result: false, - latest: expectedTime{hour: 14, minute: 58}, + start: "1:00 PM +0000", + stop: "3:00 PM +0000", + prev: "2:58 PM +0000", + now: "3:02 PM +0000", + result: false, + latest: expectedTime{hour: 14, minute: 58}, }), ) @@ -558,3 +528,594 @@ var _ = DescribeTable("Start time with a range and interval", (testCase).Run, latest: expectedTime{isZero: true}, }), ) + +var _ = DescribeTable("A cron expression without a previous time", (testCase).Run, + Entry("exactly at the cron time (minute)", testCase{ + cron: "30 * * * *", + now: "1:30 PM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 13, minute: 30, weekday: time.Monday}, + }), + Entry("one minute before the cron time", testCase{ + cron: "30 * * * *", + now: "1:29 PM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 12, minute: 30, weekday: time.Monday}, + }), + Entry("at a specific hour and minute", testCase{ + cron: "15 9 * * *", + now: "9:15 AM +0000", + nowDay: time.Tuesday, + result: true, + latest: expectedTime{hour: 9, minute: 15, weekday: time.Tuesday}, + }), + Entry("at a specific hour and minute on specific days", testCase{ + cron: "15 9 * * 1-5", + now: "9:15 AM +0000", + nowDay: time.Wednesday, + result: true, + latest: expectedTime{hour: 9, minute: 15, weekday: time.Wednesday}, + }), + Entry("at a specific hour and minute but not on specified days", testCase{ + cron: "15 9 * * 1-5", + now: "9:15 AM +0000", + nowDay: time.Sunday, + result: true, + latest: expectedTime{hour: 9, minute: 15, weekday: time.Friday}, + }), +) + +var _ = DescribeTable("A cron expression with a timezone", (testCase).Run, + Entry("at the cron time with fixed offset", testCase{ + cron: "0 9 * * *", + location: "Etc/GMT+5", + now: "2:00 PM +0000", + nowDay: time.Thursday, + result: true, + latest: expectedTime{hour: 14, minute: 0, weekday: time.Thursday}, + }), +) + +var _ = DescribeTable("A cron expression with complex patterns", (testCase).Run, + Entry("with range of hours", testCase{ + cron: "0 9-17 * * *", + now: "2:00 PM +0000", + nowDay: time.Thursday, + result: true, + latest: expectedTime{hour: 14, minute: 0, weekday: time.Thursday}, + }), + Entry("with step values", testCase{ + cron: "0 */2 * * *", + now: "2:00 PM +0000", + nowDay: time.Thursday, + result: true, + latest: expectedTime{hour: 14, minute: 0, weekday: time.Thursday}, + }), + Entry("with step values at odd hour", testCase{ + cron: "0 */2 * * *", + now: "3:00 PM +0000", + nowDay: time.Thursday, + result: true, + latest: expectedTime{hour: 14, minute: 0, weekday: time.Thursday}, + }), +) + +var _ = DescribeTable("A cron expression with edge cases", (testCase).Run, + Entry("at midnight", testCase{ + cron: "0 0 * * *", + now: "12:00 AM +0000", + nowDay: time.Friday, + result: true, + latest: expectedTime{hour: 0, minute: 0, weekday: time.Friday}, + }), + Entry("with last day of week", testCase{ + cron: "0 12 * * 6", + now: "12:00 PM +0000", + nowDay: time.Saturday, + result: true, + latest: expectedTime{hour: 12, minute: 0, weekday: time.Saturday}, + }), + Entry("with last minute of hour", testCase{ + cron: "59 * * * *", + now: "1:59 PM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 13, minute: 59, weekday: time.Monday}, + }), + Entry("with name of weekday", testCase{ + cron: "0 12 * * MON", + now: "12:00 PM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 12, minute: 0, weekday: time.Monday}, + }), +) + +var _ = DescribeTable("A cron expression with a previous time", (testCase).Run, + Entry("next cron time has passed", testCase{ + cron: "30 * * * *", + prev: "12:30 PM +0000", + prevDay: time.Monday, + now: "1:31 PM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 13, minute: 30, weekday: time.Monday}, + }), + Entry("next cron time has not passed", testCase{ + cron: "30 * * * *", + prev: "12:30 PM +0000", + prevDay: time.Monday, + now: "1:29 PM +0000", + nowDay: time.Monday, + result: false, + latest: expectedTime{isZero: true}, + }), + Entry("exactly at next cron time", testCase{ + cron: "30 * * * *", + prev: "12:30 PM +0000", + prevDay: time.Monday, + now: "1:30 PM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 13, minute: 30, weekday: time.Monday}, + }), + Entry("multiple cron times have passed", testCase{ + cron: "30 * * * *", + prev: "12:30 PM +0000", + prevDay: time.Monday, + now: "3:45 PM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 15, minute: 30, weekday: time.Monday}, + list: []expectedTime{ + {hour: 13, minute: 30, weekday: time.Monday}, + {hour: 14, minute: 30, weekday: time.Monday}, + {hour: 15, minute: 30, weekday: time.Monday}, + }, + }), +) + +var _ = DescribeTable("A cron expression with start_after", (testCase).Run, + Entry("start_after in future, no previous time", testCase{ + cron: "0 9 * * *", + start_after: "2025-01-01T00:00:00", + now: "9:00 AM +0000", + nowDay: time.Monday, + result: false, + latest: expectedTime{isZero: true}, + list: []expectedTime{}, + }), + Entry("start_after in past, no previous time, at cron time", testCase{ + cron: "0 9 * * *", + start_after: "2017-12-31T00:00:00", + now: "9:00 AM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 9, minute: 0, weekday: time.Monday}, + }), + Entry("start_after in past, with previous time, next cron passed", testCase{ + cron: "0 * * * *", + start_after: "2017-12-31T00:00:00", + prev: "9:00 AM +0000", + prevDay: time.Monday, + now: "10:05 AM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 10, minute: 0, weekday: time.Monday}, + }), + Entry("start_after one second before now", testCase{ + cron: "0 9 * * *", + start_after: "2018-01-01T08:59:59", + now: "9:00 AM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 9, minute: 0, weekday: time.Monday}, + }), +) + +var _ = DescribeTable("A cron expression with timezone and previous time", (testCase).Run, + Entry("timezone affects next calculation", testCase{ + cron: "0 9 * * *", + location: "America/New_York", + prev: "2:00 PM +0000", + prevDay: time.Monday, + now: "2:05 PM +0000", + nowDay: time.Tuesday, + result: true, + latest: expectedTime{hour: 14, minute: 0, weekday: time.Tuesday}, + }), + Entry("timezone with prev, next not passed", testCase{ + cron: "0 9 * * *", + location: "America/New_York", + prev: "2:00 PM +0000", + prevDay: time.Monday, + now: "1:00 PM +0000", + nowDay: time.Tuesday, + result: false, + latest: expectedTime{isZero: true}, + }), + Entry("timezone crossing midnight", testCase{ + cron: "0 23 * * *", + location: "America/Los_Angeles", + now: "7:00 AM +0000", + nowDay: time.Wednesday, + result: true, + latest: expectedTime{hour: 7, minute: 0, weekday: time.Wednesday}, + }), +) + +var _ = DescribeTable("A range with days filter", (testCase).Run, + Entry("on allowed day", testCase{ + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + days: []time.Weekday{time.Monday, time.Wednesday, time.Friday}, + now: "10:00 AM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 9, minute: 0, weekday: time.Monday}, + }), + Entry("not on allowed day", testCase{ + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + days: []time.Weekday{time.Monday, time.Wednesday, time.Friday}, + now: "10:00 AM +0000", + nowDay: time.Tuesday, + result: false, + latest: expectedTime{isZero: true}, + }), + Entry("empty days means all days allowed", testCase{ + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + days: []time.Weekday{}, + now: "10:00 AM +0000", + nowDay: time.Saturday, + result: true, + latest: expectedTime{hour: 9, minute: 0, weekday: time.Saturday}, + }), + Entry("days filter with previous time", testCase{ + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + days: []time.Weekday{time.Monday, time.Wednesday}, + prev: "10:00 AM +0000", + prevDay: time.Monday, + now: "10:00 AM +0000", + nowDay: time.Wednesday, + result: true, + latest: expectedTime{hour: 9, minute: 0, weekday: time.Wednesday}, + }), +) + +var _ = DescribeTable("Days filter with location", (testCase).Run, + Entry("timezone makes it different day", testCase{ + location: "Pacific/Auckland", + start: "9:00 AM", + stop: "5:00 PM", + days: []time.Weekday{time.Tuesday}, + now: "8:00 PM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 9, minute: 0, weekday: time.Tuesday}, + }), + Entry("timezone makes it wrong day", testCase{ + location: "Pacific/Auckland", + start: "9:00 AM", + stop: "5:00 PM", + days: []time.Weekday{time.Monday}, + now: "8:00 PM +0000", + nowDay: time.Monday, + result: false, + latest: expectedTime{isZero: true}, + }), +) + +var _ = DescribeTable("Interval with days filter", (testCase).Run, + Entry("interval on allowed day", testCase{ + interval: "1h", + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + days: []time.Weekday{time.Monday, time.Wednesday, time.Friday}, + now: "10:00 AM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 10, minute: 0, weekday: time.Monday}, + }), + Entry("interval on non-allowed day", testCase{ + interval: "1h", + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + days: []time.Weekday{time.Monday, time.Wednesday, time.Friday}, + now: "10:00 AM +0000", + nowDay: time.Tuesday, + result: false, + latest: expectedTime{isZero: true}, + }), +) + +var _ = DescribeTable("Every minute cron expression", (testCase).Run, + Entry("no previous, triggers immediately", testCase{ + cron: "* * * * *", + now: "3:47 PM +0000", + nowDay: time.Tuesday, + result: true, + latest: expectedTime{hour: 15, minute: 47, weekday: time.Tuesday}, + }), + Entry("with previous 1 minute ago", testCase{ + cron: "* * * * *", + prev: "3:46 PM +0000", + prevDay: time.Tuesday, + now: "3:47 PM +0000", + nowDay: time.Tuesday, + result: true, + latest: expectedTime{hour: 15, minute: 47, weekday: time.Tuesday}, + }), + Entry("with previous same minute - no trigger", testCase{ + cron: "* * * * *", + prev: "3:47 PM +0000", + prevDay: time.Tuesday, + now: "3:47 PM +0000", + extraTime: 30 * time.Second, + nowDay: time.Tuesday, + result: false, + latest: expectedTime{isZero: true}, + }), +) + +var _ = DescribeTable("Special cron expressions", (testCase).Run, + Entry("@hourly at exact hour", testCase{ + cron: "@hourly", + now: "3:00 PM +0000", + nowDay: time.Wednesday, + result: true, + latest: expectedTime{hour: 15, minute: 0, weekday: time.Wednesday}, + }), + Entry("@hourly between hours", testCase{ + cron: "@hourly", + now: "3:30 PM +0000", + nowDay: time.Wednesday, + result: true, + latest: expectedTime{hour: 15, minute: 0, weekday: time.Wednesday}, + }), + Entry("@daily at midnight", testCase{ + cron: "@daily", + now: "12:00 AM +0000", + nowDay: time.Thursday, + result: true, + latest: expectedTime{hour: 0, minute: 0, weekday: time.Thursday}, + }), + Entry("@daily in afternoon finds today's midnight", testCase{ + cron: "@daily", + now: "3:00 PM +0000", + nowDay: time.Thursday, + result: true, + latest: expectedTime{hour: 0, minute: 0, weekday: time.Thursday}, + }), + Entry("@weekly on Sunday midnight", testCase{ + cron: "@weekly", + now: "12:00 AM +0000", + nowDay: time.Sunday, + result: true, + latest: expectedTime{hour: 0, minute: 0, weekday: time.Sunday}, + }), +) + +var _ = DescribeTable("List edge cases", (testCase).Run, + Entry("list with no matches returns empty", testCase{ + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + now: "6:00 PM +0000", + nowDay: time.Monday, + result: false, + latest: expectedTime{isZero: true}, + list: []expectedTime{}, + }), +) + +var _ = DescribeTable("Previous time edge cases", (testCase).Run, + Entry("previous time after now returns zero latest", testCase{ + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + prev: "10:00 AM +0000", + prevDay: time.Tuesday, + now: "9:30 AM +0000", + nowDay: time.Monday, + result: false, + latest: expectedTime{isZero: true}, + }), +) + +var _ = DescribeTable("Multi-day scenarios", (testCase).Run, + Entry("overnight range with prev in evening, now in morning", testCase{ + start: "10:00 PM +0000", + stop: "6:00 AM +0000", + prev: "11:00 PM +0000", + prevDay: time.Monday, + now: "3:00 AM +0000", + nowDay: time.Tuesday, + result: false, + latest: expectedTime{isZero: true}, + }), + Entry("overnight range with prev before range, now in morning", testCase{ + start: "10:00 PM +0000", + stop: "6:00 AM +0000", + prev: "9:00 PM +0000", + prevDay: time.Monday, + now: "3:00 AM +0000", + nowDay: time.Tuesday, + result: true, + latest: expectedTime{hour: 22, minute: 0, weekday: time.Monday}, + }), +) + +var _ = DescribeTable("Cron with comma-separated values", (testCase).Run, + Entry("multiple minutes", testCase{ + cron: "0,15,30,45 * * * *", + now: "3:17 PM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 15, minute: 15, weekday: time.Monday}, + }), + Entry("multiple hours", testCase{ + cron: "0 9,12,15,18 * * *", + now: "1:00 PM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 12, minute: 0, weekday: time.Monday}, + }), + Entry("multiple days of week", testCase{ + cron: "0 12 * * 1,3,5", + now: "12:00 PM +0000", + nowDay: time.Wednesday, + result: true, + latest: expectedTime{hour: 12, minute: 0, weekday: time.Wednesday}, + }), +) + +var _ = DescribeTable("Only start time specified", (testCase).Run, + Entry("after start time, no prev", testCase{ + start: "9:00 AM +0000", + now: "10:00 AM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 9, minute: 0, weekday: time.Monday}, + }), + Entry("before start time, no prev", testCase{ + start: "9:00 AM +0000", + now: "8:00 AM +0000", + nowDay: time.Monday, + result: false, + latest: expectedTime{isZero: true}, + }), +) + +var _ = DescribeTable("Only stop time specified", (testCase).Run, + Entry("before stop time, no prev", testCase{ + stop: "5:00 PM +0000", + now: "3:00 PM +0000", + nowDay: time.Monday, + result: true, + latest: expectedTime{hour: 0, minute: 0, weekday: time.Monday}, + }), + Entry("after stop time, no prev", testCase{ + stop: "5:00 PM +0000", + now: "6:00 PM +0000", + nowDay: time.Monday, + result: false, + latest: expectedTime{isZero: true}, + }), +) + +var _ = DescribeTable("StartAfter with non-cron configurations", (testCase).Run, + // Bug #2 fix: Check() non-cron path with StartAfter + Entry("start_after with range, now before start_after", testCase{ + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + start_after: "2025-01-15T12:00:00", + now: "10:00 AM +0000", + nowDay: time.Wednesday, + // now (2018) is before start_after (2025), should not trigger + result: false, + latest: expectedTime{isZero: true}, + list: []expectedTime{}, + }), + Entry("start_after with range, now after start_after", testCase{ + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + start_after: "2017-01-01T00:00:00", + now: "10:00 AM +0000", + nowDay: time.Wednesday, + // now (2018) is after start_after (2017), should trigger + result: true, + latest: expectedTime{hour: 9, minute: 0, weekday: time.Wednesday}, + }), + Entry("start_after with interval, now before start_after", testCase{ + interval: "1h", + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + start_after: "2025-01-15T12:00:00", + now: "11:00 AM +0000", + nowDay: time.Wednesday, + result: false, + latest: expectedTime{isZero: true}, + list: []expectedTime{}, + }), + Entry("start_after with interval, now after start_after", testCase{ + interval: "1h", + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + start_after: "2017-01-01T00:00:00", + now: "11:00 AM +0000", + nowDay: time.Wednesday, + result: true, + latest: expectedTime{hour: 11, minute: 0, weekday: time.Wednesday}, + }), + + // Bug #2 fix: timezone handling in non-cron StartAfter check + Entry("start_after with location, boundary test", testCase{ + location: "America/New_York", + start: "9:00 AM", + stop: "5:00 PM", + start_after: "2018-01-03T10:00:00", // interpreted as 10:00 AM New York + now: "3:00 PM +0000", // 10:00 AM New York + nowDay: time.Wednesday, + // now equals start_after, should not trigger (needs to be strictly after) + result: false, + latest: expectedTime{isZero: true}, + list: []expectedTime{}, + }), + Entry("start_after with location, one minute after", testCase{ + location: "America/New_York", + start: "9:00 AM", + stop: "5:00 PM", + start_after: "2018-01-03T10:00:00", + now: "3:01 PM +0000", + nowDay: time.Wednesday, + result: true, + latest: expectedTime{hour: 9, minute: 0, weekday: time.Wednesday}, // local hour, not UTC + }), + + // With previous time + Entry("start_after with range and prev, now after start_after", testCase{ + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + start_after: "2017-01-01T00:00:00", + prev: "8:00 AM +0000", + prevDay: time.Tuesday, + now: "10:00 AM +0000", + nowDay: time.Wednesday, + result: true, + latest: expectedTime{hour: 9, minute: 0, weekday: time.Wednesday}, + }), + Entry("start_after with range and prev, now before start_after", testCase{ + start: "9:00 AM +0000", + stop: "5:00 PM +0000", + start_after: "2025-01-15T12:00:00", + prev: "8:00 AM +0000", + prevDay: time.Tuesday, + now: "10:00 AM +0000", + nowDay: time.Wednesday, + result: false, + latest: expectedTime{isZero: true}, + list: []expectedTime{}, + }), +) + +var _ = DescribeTable("StartAfter edge cases", (testCase).Run, + // start_after alone (no range, no interval, no cron) + Entry("start_after only, now before", testCase{ + start_after: "2025-01-15T00:00:00", + now: "10:00 AM +0000", + nowDay: time.Wednesday, + result: false, + latest: expectedTime{isZero: true}, + list: []expectedTime{}, + }), + Entry("start_after only, now after", testCase{ + start_after: "2017-01-01T00:00:00", + now: "10:00 AM +0000", + nowDay: time.Wednesday, + result: true, + latest: expectedTime{hour: 0, minute: 0, weekday: time.Wednesday}, + }), +) diff --git a/models/models.go b/models/models.go index d471177..c7e0782 100644 --- a/models/models.go +++ b/models/models.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "time" + + "github.com/adhocore/gronx" ) type Version struct { @@ -46,9 +48,17 @@ type Source struct { Days []Weekday `json:"days"` Location *Location `json:"location"` StartAfter *StartAfter `json:"start_after"` + Cron *Cron `json:"cron"` } func (source Source) Validate() error { + // Validate interval/start/stop/days and cron are not specified together + if source.Cron != nil { + if source.Interval != nil || source.Start != nil || source.Stop != nil || len(source.Days) > 0 { + return errors.New("cannot configure 'interval' or 'start'/'stop' or 'days' with 'cron'") + } + } + // Validate start and stop are both set or both unset if (source.Start != nil) != (source.Stop != nil) { if source.Start != nil { @@ -64,6 +74,13 @@ func (source Source) Validate() error { } } + // Validate cron expression if provided + if source.Cron != nil { + if err := source.Cron.Validate(); err != nil { + return fmt.Errorf("invalid cron expression: %v", err) + } + } + return nil } @@ -150,7 +167,7 @@ func (tod *TimeOfDay) UnmarshalJSON(payload []byte) error { var t time.Time for _, format := range timeFormats { - t, err = time.Parse(format, timeStr) + t, err = time.Parse(format, strings.ToUpper(timeStr)) if err == nil { break } @@ -176,6 +193,10 @@ func (tod TimeOfDay) Minute() int { return int(time.Duration(tod) % time.Hour / time.Minute) } +func (t TimeOfDay) Second() int { + return int(time.Duration(t).Seconds()) % 60 +} + func (tod TimeOfDay) String() string { return fmt.Sprintf("%d:%02d", tod.Hour(), tod.Minute()) } @@ -225,6 +246,8 @@ func (wd Weekday) MarshalJSON() ([]byte, error) { } var dateTimeFormats = []string{ + time.RFC3339, + "2006-01-02T15:04:05-07:00", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02T15", @@ -262,3 +285,105 @@ func (sa StartAfter) MarshalJSON() ([]byte, error) { StartAfterStr := time.Time(sa).Format("2006-01-02T15:04:05") return json.Marshal(StartAfterStr) } + +type Cron struct { + Expression string +} + +func (c *Cron) UnmarshalJSON(payload []byte) error { + var cronStr string + err := json.Unmarshal(payload, &cronStr) + if err != nil { + return err + } + + g := gronx.New() + if !g.IsValid(cronStr) { + return fmt.Errorf("invalid cron expression: %s", cronStr) + } + + c.Expression = cronStr + return nil +} + +func (c Cron) MarshalJSON() ([]byte, error) { + return json.Marshal(c.Expression) +} + +func (c Cron) Validate() error { + g := gronx.New() + if !g.IsValid(c.Expression) { + return fmt.Errorf("invalid cron expression: %s", c.Expression) + } + + // Check if the cron expression would run too frequently (less than 60 seconds) + if err := c.validateMinimumInterval(); err != nil { + return err + } + + return nil +} + +// validateMinimumInterval ensures no specific seconds are specified (to always run on minute boundaries) +func (c Cron) validateMinimumInterval() error { + if strings.HasPrefix(c.Expression, "@") { + if c.Expression == "@everysecond" { + return errors.New("@everysecond is not supported: cron expressions must not specify seconds") + } + + // Unknown macro - let it pass if gronx accepted it, assuming it doesn't operate at the second level + return nil + } + + // Check if the expression is a 6-field cron format (includes seconds) + fields := strings.Fields(c.Expression) + + // If there are 6 fields, it's a cron expression with seconds, which we want to disallow + if len(fields) == 6 { + return errors.New("cron expressions with seconds field are not supported: use 5-field format") + } + + return nil +} + +// parsePositiveInt parses a string to an integer and ensures it's positive +func parsePositiveInt(s string) (int, error) { + var value int + _, err := fmt.Sscanf(s, "%d", &value) + if err != nil { + return 0, err + } + + if value <= 0 { + return 0, errors.New("value must be positive") + } + + return value, nil +} + +func (c *Cron) Next(t time.Time) (time.Time, error) { + return gronx.NextTickAfter(c.Expression, t, false) +} + +// NextIncludingCurrent returns the next time including the reference time if it matches +func (c *Cron) NextIncludingCurrent(t time.Time) (time.Time, error) { + return gronx.NextTickAfter(c.Expression, t, true) +} + +// NextN returns all next cron times between after and before +func (c *Cron) NextN(after time.Time, before time.Time, n int) []time.Time { + var times []time.Time + next, err := c.Next(after) + if err != nil { + return nil + } + + for len(times) < n && !next.After(before) { + times = append(times, next) + next, err = c.Next(next) + if err != nil { + break + } + } + return times +} diff --git a/models/source_test.go b/models/source_test.go index df25aa1..41a7b5b 100644 --- a/models/source_test.go +++ b/models/source_test.go @@ -73,4 +73,260 @@ var _ = Describe("Source", func() { Expect(source.Stop.Hour()).To(Equal(source.Start.Hour() + 12)) }) }) + + Context("when using cron expressions", func() { + Context("with both interval and cron specified", func() { + BeforeEach(func() { + config = `{ "interval": "1h", "cron": "*/5 * * * *" }` + }) + + It("generates a validation error", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("cannot configure 'interval' or 'start'/'stop' or 'days' with 'cron'")) + }) + }) + + Context("with both interval and days and cron specified", func() { + BeforeEach(func() { + config = `{ "interval": "1h", "days": ["Monday"], "cron": "*/5 * * * *" }` + }) + + It("generates a validation error", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("cannot configure 'interval' or 'start'/'stop' or 'days' with 'cron'")) + }) + }) + + Context("with both interval and start/stop and cron specified", func() { + BeforeEach(func() { + config = `{ "interval": "1h", "start": "6AM", "stop": "7am", "cron": "*/5 * * * *" }` + }) + + It("generates a validation error", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("cannot configure 'interval' or 'start'/'stop' or 'days' with 'cron'")) + }) + }) + + Context("with an invalid cron expression", func() { + BeforeEach(func() { + config = `{ "cron": "invalid expression" }` + }) + + It("generates a validation error", func() { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid cron expression")) + }) + }) + + Context("with a cron expression that runs every second (6-field)", func() { + BeforeEach(func() { + config = `{ "cron": "* * * * * *" }` + }) + + It("generates a validation error for having seconds field", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported")) + }) + }) + + Context("with a cron expression that runs every 10 seconds", func() { + BeforeEach(func() { + config = `{ "cron": "*/10 * * * * *" }` + }) + + It("generates a validation error for having seconds field", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported")) + }) + }) + + Context("with a cron expression that runs every 30 seconds", func() { + BeforeEach(func() { + config = `{ "cron": "*/30 * * * * *" }` + }) + + It("generates a validation error for having seconds field", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported")) + }) + }) + + Context("with a cron expression that runs at specific seconds", func() { + BeforeEach(func() { + config = `{ "cron": "0,15,30,45 * * * * *" }` + }) + + It("generates a validation error for having seconds field", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported")) + }) + }) + + Context("with a cron expression using a seconds range", func() { + BeforeEach(func() { + config = `{ "cron": "0-30 * * * * *" }` + }) + + It("generates a validation error for having seconds field", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported")) + }) + }) + + Context("with a cron expression that runs exactly every minute (5-field)", func() { + BeforeEach(func() { + config = `{ "cron": "* * * * *" }` + }) + + It("is valid (minimum acceptable frequency)", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("with a cron expression that runs every 5 minutes", func() { + BeforeEach(func() { + config = `{ "cron": "*/5 * * * *" }` + }) + + It("is valid", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("with a cron expression that runs hourly", func() { + BeforeEach(func() { + config = `{ "cron": "0 * * * *" }` + }) + + It("is valid", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("with a cron expression that runs every 2 hours", func() { + BeforeEach(func() { + config = `{ "cron": "0 */2 * * *" }` + }) + + It("is valid", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("with a cron expression that runs daily", func() { + BeforeEach(func() { + config = `{ "cron": "0 0 * * *" }` + }) + + It("is valid", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("with a cron expression that runs weekly", func() { + BeforeEach(func() { + config = `{ "cron": "@weekly" }` + }) + + It("is valid", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("with a cron expression using seconds field but running only once per minute", func() { + BeforeEach(func() { + config = `{ "cron": "0 * * * * *" }` + }) + + It("generates a validation error for having seconds field", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported")) + }) + }) + + Context("with a cron expression that runs exactly every 60 seconds", func() { + BeforeEach(func() { + config = `{ "cron": "*/60 * * * * *" }` + }) + + It("generates a validation error for having seconds field", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported")) + }) + }) + + Context("with a cron expression that runs every 90 seconds", func() { + BeforeEach(func() { + config = `{ "cron": "*/90 * * * * *" }` + }) + + It("generates a validation error for having seconds field", func() { + Expect(err).ToNot(HaveOccurred()) + + err = source.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cron expressions with seconds field are not supported")) + }) + }) + + Context("with a cron expression with an invalid step value in seconds", func() { + BeforeEach(func() { + config = `{ "cron": "*/abc * * * * *" }` + }) + + It("is caught during initial cron validation", func() { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid cron expression")) + }) + }) + }) })