Repository: go-co-op/gocron
Pull request #156 (2021-04-08 21:26:14)

set the scheduler location in the cron expression and add CronWithSeconds()

### What does this do?

- explicitly sets the location for the crontab expression using `CRON_TZ` - the parse expression defaults to time.Local, which doesn't handle when a caller is setting a different timezone for the scheduler
- add a new method `CronWithSeconds()` that the parsing of seconds which robfig exposes
  - i made this a separate method because standard cron expressions don't support seconds, so rather than have the `Cron()` func optionally support seconds i thought making explicit was better

### Which issue(s) does this PR fix/relate to?
<!--- Put `Resolves #XXX` here to auto-close the issue that your PR fixes (if such) --->


### List any changes that modify/break current functionality


### Have you included tests for your changes?


### Did you document any new/modified functionality?

- [x] Updated `example_test.go`
- [ ] Updated `README.md`

### Notes



Patch:
diff --git a/scheduler.go b/scheduler.go
index ecfca32..1274cde 100644
--- a/scheduler.go
+++ b/scheduler.go
@@ -1,6 +1,7 @@
 package gocron
 
 import (
+	"fmt"
 	"math"
 	"reflect"
 	"sort"
@@ -831,10 +832,31 @@ func (s *Scheduler) Update() (*Job, error) {
 	return s.Do(job.function, job.parameters...)
 }
 
-func (s *Scheduler) Cron(c string) *Scheduler {
+func (s *Scheduler) Cron(cronExpression string) *Scheduler {
+	return s.cron(cronExpression, false)
+}
+
+func (s *Scheduler) CronWithSeconds(cronExpression string) *Scheduler {
+	return s.cron(cronExpression, true)
+}
+
+func (s *Scheduler) cron(cronExpression string, withSeconds bool) *Scheduler {
 	job := NewJob(0)
 
-	cronSchedule, err := cron.ParseStandard(c)
+	withLocation := fmt.Sprintf("CRON_TZ=%s %s", s.location.String(), cronExpression)
+
+	var (
+		cronSchedule cron.Schedule
+		err          error
+	)
+
+	if withSeconds {
+		p := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
+		cronSchedule, err = p.Parse(withLocation)
+	} else {
+		cronSchedule, err = cron.ParseStandard(withLocation)
+	}
+
 	if err != nil {
 		job.error = wrapOrError(err, ErrCronParseFailure)
 	}


Test patch:
diff --git a/example_test.go b/example_test.go
index b0a915b..010d6f5 100644
--- a/example_test.go
+++ b/example_test.go
@@ -175,11 +175,22 @@ func ExampleScheduler_Clear() {
 func ExampleScheduler_Cron() {
 	s := gocron.NewScheduler(time.UTC)
 
+	// parsing handled by https://pkg.go.dev/github.com/robfig/cron/v3
+	// which follows https://en.wikipedia.org/wiki/Cron
 	_, _ = s.Cron("*/1 * * * *").Do(task) // every minute
 	_, _ = s.Cron("0 1 * * *").Do(task)   // every day at 1 am
 	_, _ = s.Cron("0 0 * * 6,0").Do(task) // weekends only
 }
 
+func ExampleScheduler_CronWithSeconds() {
+	s := gocron.NewScheduler(time.UTC)
+
+	// parsing handled by https://pkg.go.dev/github.com/robfig/cron/v3
+	// which follows https://en.wikipedia.org/wiki/Cron
+	_, _ = s.CronWithSeconds("*/1 * * * * *").Do(task)  // every second
+	_, _ = s.CronWithSeconds("0-30 * * * * *").Do(task) // every second 0-30
+}
+
 func ExampleScheduler_Day() {
 	s := gocron.NewScheduler(time.UTC)
 
diff --git a/scheduler_test.go b/scheduler_test.go
index c6e83c5..0bc690d 100644
--- a/scheduler_test.go
+++ b/scheduler_test.go
@@ -1207,3 +1207,57 @@ func TestScheduler_Cron(t *testing.T) {
 		assert.EqualError(t, err, wrapOrError(ErrInvalidIntervalUnitsSelection, ErrWeekdayNotSupported).Error())
 	})
 }
+
+func TestScheduler_CronWithSeconds(t *testing.T) {
+	ft := fakeTime{onNow: func(l *time.Location) time.Time {
+		// January 1st, 12 noon, Thursday, 1970
+		return time.Date(1970, 1, 1, 12, 0, 0, 0, l)
+	}}
+
+	s := NewScheduler(time.UTC)
+	s.time = ft
+
+	testCases := []struct {
+		description     string
+		cronTab         string
+		expectedNextRun time.Time
+		expectedError   error
+	}{
+		// https://crontab.guru/
+		{"every second", "*/1 * * * * *", ft.onNow(time.UTC).Add(1 * time.Second), nil},
+		{"every second from 0-30", "0-30 * * * * *", ft.onNow(time.UTC).Add(1 * time.Second), nil},
+		{"every minute", "0 */1 * * * *", ft.onNow(time.UTC).Add(1 * time.Minute), nil},
+		{"every day 1am", "* 0 1 * * *", ft.onNow(time.UTC).Add(13 * time.Hour), nil},
+		{"weekends only", "* 0 0 * * 6,0", ft.onNow(time.UTC).Add(36 * time.Hour), nil},
+		{"at time monday thru friday", "* 0 22 * * 1-5", ft.onNow(time.UTC).Add(10 * time.Hour), nil},
+		{"every minute in range, monday thru friday", "* 15-30 * * * 1-5", ft.onNow(time.UTC).Add(15 * time.Minute), nil},
+		{"at every minute past every hour from 1 through 5 on every day-of-week from Monday through Friday.", "* * 1-5 * * 1-5", ft.onNow(time.UTC).Add(13 * time.Hour), nil},
+		{"hourly", "@hourly", ft.onNow(time.UTC).Add(1 * time.Hour), nil},
+		{"bad expression", "bad", time.Time{}, wrapOrError(fmt.Errorf("expected exactly 6 fields, found 1: [bad]"), ErrCronParseFailure)},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.description, func(t *testing.T) {
+			j, err := s.CronWithSeconds(tc.cronTab).Do(func() {})
+			if tc.expectedError == nil {
+				require.NoError(t, err)
+
+				s.scheduleNextRun(j)
+
+				assert.Exactly(t, tc.expectedNextRun, j.NextRun())
+			} else {
+				assert.EqualError(t, err, tc.expectedError.Error())
+			}
+		})
+	}
+
+	t.Run("error At() called with Cron()", func(t *testing.T) {
+		_, err := s.Cron("@hourly").At("1:00").Do(func() {})
+		assert.EqualError(t, err, ErrAtTimeNotSupported.Error())
+	})
+
+	t.Run("error Weekday() called with Cron()", func(t *testing.T) {
+		_, err := s.Cron("@hourly").Sunday().Do(func() {})
+		assert.EqualError(t, err, wrapOrError(ErrInvalidIntervalUnitsSelection, ErrWeekdayNotSupported).Error())
+	})
+}