Repository: favonia/cloudflare-ddns
Pull request #607 (2023-10-15 13:49:06)

feat: introduce `UPDATE_CRON=@once`

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/607?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) Report
> Merging [#607](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/607?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (fbc7fec) into [main](https://app.codecov.io/gh/favonia/cloudflare-ddns/commit/e65531ae09e08a1b0f25e0d4d8287eb136cacf52?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (e65531a) will **decrease** coverage by `0.38%`.
> The diff coverage is `77.41%`.

```diff
@@            Coverage Diff             @@
##             main     #607      +/-   ##
==========================================
- Coverage   88.30%   87.92%   -0.38%     
==========================================
  Files          45       45              
  Lines        2300     2311      +11     
==========================================
+ Hits         2031     2032       +1     
- Misses        259      268       +9     
- Partials       10       11       +1     
```

| [Flag](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/607/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/607/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | `87.92% <77.41%> (-0.38%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Files](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/607?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Coverage Δ | |
|---|---|---|
| [internal/config/config\_read.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/607?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY29uZmlnL2NvbmZpZ19yZWFkLmdv) | `100.00% <100.00%> (ø)` | |
| [internal/cron/base.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/607?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY3Jvbi9iYXNlLmdv) | `70.00% <100.00%> (-30.00%)` | :arrow_down: |
| [internal/cron/schedule.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/607?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY3Jvbi9zY2hlZHVsZS5nbw==) | `100.00% <ø> (ø)` | |
| [internal/config/env\_base.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/607?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY29uZmlnL2Vudl9iYXNlLmdv) | `95.10% <58.82%> (-4.90%)` | :arrow_down: |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia)


Patch:
diff --git a/README.markdown b/README.markdown
index c54a6bf..03072de 100644
--- a/README.markdown
+++ b/README.markdown
@@ -255,20 +255,18 @@ _(Click to expand the following items.)_
 <details>
 <summary>⏳ Schedules, triggers, and timeouts</summary>
 
-| Name                | Valid Values                                                                                                                                                                        | Meaning                                                                        | Required? | Default Value                 |
-| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | --------- | ----------------------------- |
-| `CACHE_EXPIRATION`  | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration)                                                   | The expiration of cached Cloudflare API responses                              | No        | `6h0m0s` (6 hours)            |
-| `DELETE_ON_STOP`    | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool)                                                                 | Whether managed DNS records should be deleted on exit                          | No        | `false`                       |
-| `DETECTION_TIMEOUT` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration)                                                   | The timeout of each attempt to detect IP addresses                             | No        | `5s` (5 seconds)              |
-| `TZ`                | Recognized timezones, such as `UTC`                                                                                                                                                 | The timezone used for logging and parsing `UPDATE_CRON`                        | No        | `UTC`                         |
-| `UPDATE_CRON`       | Cron expressions. See the [documentation of cron](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format). 🧪 See below for the experimental mode to disable cron. | The schedule to re-check IP addresses and update DNS records (if necessary)    | No        | `@every 5m` (every 5 minutes) |
-| `UPDATE_ON_START`   | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool)                                                                 | Whether to check IP addresses on start regardless of `UPDATE_CRON`             | No        | `true`                        |
-| `UPDATE_TIMEOUT`    | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration)                                                   | The timeout of each attempt to update DNS records, per domain, per record type | No        | `30s` (30 seconds)            |
+| Name                | Valid Values                                                                                                                                                                                                        | Meaning                                                                                                                                                                                                        | Required? | Default Value                 |
+| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ----------------------------- |
+| `CACHE_EXPIRATION`  | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration)                                                                                   | The expiration of cached Cloudflare API responses                                                                                                                                                              | No        | `6h0m0s` (6 hours)            |
+| `DELETE_ON_STOP`    | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool)                                                                                                 | Whether managed DNS records should be deleted on exit                                                                                                                                                          | No        | `false`                       |
+| `DETECTION_TIMEOUT` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration)                                                                                   | The timeout of each attempt to detect IP addresses                                                                                                                                                             | No        | `5s` (5 seconds)              |
+| `TZ`                | Recognized timezones, such as `UTC`                                                                                                                                                                                 | The timezone used for logging and parsing `UPDATE_CRON`                                                                                                                                                        | No        | `UTC`                         |
+| `UPDATE_CRON`       | Cron expressions _and_ the special value `@once`. See the [documentation of cron](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format) for cron expressions. | The schedule to re-check IP addresses and update DNS records (if necessary). If the special value `@once` is used, cron is disabled and the updater will terminate immediately after updating the DNS records. | No        | `@every 5m` (every 5 minutes) |
+| `UPDATE_ON_START`   | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool)                                                                                                 | Whether to check IP addresses on start regardless of `UPDATE_CRON`                                                                                                                                             | No        | `true`                        |
+| `UPDATE_TIMEOUT`    | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration)                                                                                   | The timeout of each attempt to update DNS records, per domain, per record type                                                                                                                                 | No        | `30s` (30 seconds)            |
 
 > ⚠️ The update schedule _does not_ take the time to update records into consideration. For example, if the schedule is “for every 5 minutes”, and if the updating itself takes 2 minutes, then the actual interval between adjacent updates is 3 minutes, not 5 minutes.
 
-> 🧪 Experimental mode to disable cron: `UPDATE_CRON` can be set to `@disabled` (or `@nevermore` as an alias); the updater will terminate immediately after updating the DNS records. This is useful when you want to use other mechanisms to schedule the updating (_e.g._, [CronJob](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)).
-
 </details>
 
 <details>
diff --git a/internal/config/config_read.go b/internal/config/config_read.go
index b01be06..6143933 100644
--- a/internal/config/config_read.go
+++ b/internal/config/config_read.go
@@ -46,12 +46,20 @@ func (c *Config) NormalizeConfig(ppfmt pp.PP) bool {
 		ppfmt = ppfmt.IncIndent()
 	}
 
-	// Part 1: check DELETE_ON_STOP
-	if c.UpdateCron == nil && c.DeleteOnStop {
-		ppfmt.Errorf(
-			pp.EmojiUserError,
-			"DELETE_ON_STOP=true will immediately delete all DNS records when UPDATE_CRON=@disabled")
-		return false
+	// Part 1: check DELETE_ON_STOP and UpdateOnStart
+	if c.UpdateCron == nil {
+		if !c.UpdateOnStart {
+			ppfmt.Errorf(
+				pp.EmojiUserError,
+				"UPDATE_ON_START=false is incompatible with UPDATE_CRON=@once")
+			return false
+		}
+		if c.DeleteOnStop {
+			ppfmt.Errorf(
+				pp.EmojiUserError,
+				"DELETE_ON_STOP=true will immediately delete all updated DNS records when UPDATE_CRON=@once")
+			return false
+		}
 	}
 
 	// Part 2: normalize domain maps
diff --git a/internal/config/env_base.go b/internal/config/env_base.go
index 12e6a49..b3c3a56 100644
--- a/internal/config/env_base.go
+++ b/internal/config/env_base.go
@@ -16,7 +16,7 @@ func Getenv(key string) string {
 	return strings.TrimSpace(os.Getenv(key))
 }
 
-// ReadEmoji reads an environment variable as a plain string.
+// ReadString reads an environment variable as a plain string.
 func ReadString(ppfmt pp.PP, key string, field *string) bool {
 	val := Getenv(key)
 	if val == "" {
@@ -194,18 +194,27 @@ func ReadNonnegDuration(ppfmt pp.PP, key string, field *time.Duration) bool {
 
 // ReadCron reads an environment variable and parses it as a Cron expression.
 func ReadCron(ppfmt pp.PP, key string, field *cron.Schedule) bool {
-	val := Getenv(key)
-	if val == "" {
+	switch val := Getenv(key); val {
+	case "":
 		ppfmt.Infof(pp.EmojiBullet, "Use default %s=%s", key, cron.DescribeSchedule(*field))
 		return true
-	}
 
-	c, err := cron.New(val)
-	if err != nil {
-		ppfmt.Errorf(pp.EmojiUserError, "%s (%q) is not a cron expression: %v", key, val, err)
-		return false
-	}
+	case "@once":
+		*field = nil
+		return true
 
-	*field = c
-	return true
+	case "@disabled", "@nevermore":
+		ppfmt.Warningf(pp.EmojiUserWarning, "%s=%s is deprecated; use %s=@once", key, val, key)
+		*field = nil
+		return true
+
+	default:
+		c, err := cron.New(val)
+		if err != nil {
+			ppfmt.Errorf(pp.EmojiUserError, "%s (%q) is not a cron expression: %v", key, val, err)
+			return false
+		}
+		*field = c
+		return true
+	}
 }
diff --git a/internal/cron/base.go b/internal/cron/base.go
index edc9a47..4c394c5 100644
--- a/internal/cron/base.go
+++ b/internal/cron/base.go
@@ -18,10 +18,10 @@ func Next(s Schedule) time.Time {
 	return s.Next()
 }
 
-// String gives back the original cron string.
+// DescribeSchedule gives back the original cron string.
 func DescribeSchedule(s Schedule) string {
 	if s == nil {
-		return "@disabled"
+		return "@once"
 	}
 
 	return s.Describe()
diff --git a/internal/cron/schedule.go b/internal/cron/schedule.go
index fb4977e..c314013 100644
--- a/internal/cron/schedule.go
+++ b/internal/cron/schedule.go
@@ -16,10 +16,6 @@ type cronSchedule struct {
 
 // New creates a new Schedule.
 func New(spec string) (Schedule, error) {
-	if spec == "@disabled" || spec == "@nevermore" {
-		return (Schedule)(nil), nil
-	}
-
 	sche, err := cron.ParseStandard(spec)
 	if err != nil {
 		return nil, fmt.Errorf("parsing %q: %w", spec, err)


Test patch:
diff --git a/internal/config/config_print_test.go b/internal/config/config_print_test.go
index 8e0ddbe..f93d514 100644
--- a/internal/config/config_print_test.go
+++ b/internal/config/config_print_test.go
@@ -152,7 +152,7 @@ func TestPrintEmpty(t *testing.T) {
 		mockPP.EXPECT().Infof(pp.EmojiConfig, "Domains and IP providers:"),
 		mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"),
 		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Timezone:", Some("UTC (UTC+00 now)", "Local (UTC+00 now)")), //nolint:lll
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update frequency:", "@disabled"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update frequency:", "@once"),
 		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update on start?", "false"),
 		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Delete on stop?", "false"),
 		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Cache expiration:", "0s"),
diff --git a/internal/config/config_read_test.go b/internal/config/config_read_test.go
index 52892c7..bcfe6b7 100644
--- a/internal/config/config_read_test.go
+++ b/internal/config/config_read_test.go
@@ -38,7 +38,7 @@ func TestReadEnvWithOnlyToken(t *testing.T) {
 		mockPP.EXPECT().IncIndent().Return(innerMockPP),
 		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP4_PROVIDER", "none"),
 		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP6_PROVIDER", "none"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "UPDATE_CRON", "@disabled"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "UPDATE_CRON", "@once"),
 		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%t", "UPDATE_ON_START", false),
 		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%t", "DELETE_ON_STOP", false),
 		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%v", "CACHE_EXPIRATION", time.Duration(0)),
@@ -97,12 +97,31 @@ func TestNormalizeConfig(t *testing.T) {
 					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
 					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
 					m.EXPECT().IncIndent().Return(m),
-					m.EXPECT().Errorf(pp.EmojiUserError, "No domains were specified in DOMAINS, IP4_DOMAINS, or IP6_DOMAINS"),
+					m.EXPECT().Errorf(pp.EmojiUserError, "UPDATE_ON_START=false is incompatible with UPDATE_CRON=@once"),
+				)
+			},
+		},
+		"empty/1": {
+			input: &config.Config{ //nolint:exhaustruct
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP4: {},
+					ipnet.IP6: {},
+				},
+			},
+			ok:       false,
+			expected: nil,
+			prepareMockPP: func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
+					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
+					m.EXPECT().IncIndent().Return(m),
+					m.EXPECT().Errorf(pp.EmojiUserError, "UPDATE_ON_START=false is incompatible with UPDATE_CRON=@once"),
 				)
 			},
 		},
-		"empty": {
+		"empty/2": {
 			input: &config.Config{ //nolint:exhaustruct
+				UpdateOnStart: true,
 				Domains: map[ipnet.Type][]domain.Domain{
 					ipnet.IP4: {},
 					ipnet.IP6: {},
@@ -121,6 +140,7 @@ func TestNormalizeConfig(t *testing.T) {
 		},
 		"empty-ip6": {
 			input: &config.Config{ //nolint:exhaustruct
+				UpdateOnStart: true,
 				Provider: map[ipnet.Type]provider.Provider{
 					ipnet.IP4: provider.NewCloudflareTrace(),
 					ipnet.IP6: provider.NewCloudflareTrace(),
@@ -133,6 +153,7 @@ func TestNormalizeConfig(t *testing.T) {
 			},
 			ok: true,
 			expected: &config.Config{ //nolint:exhaustruct
+				UpdateOnStart: true,
 				Provider: map[ipnet.Type]provider.Provider{
 					ipnet.IP4: provider.NewCloudflareTrace(),
 				},
@@ -158,6 +179,7 @@ func TestNormalizeConfig(t *testing.T) {
 		},
 		"empty-ip6-none-ip4": {
 			input: &config.Config{ //nolint:exhaustruct
+				UpdateOnStart: true,
 				Provider: map[ipnet.Type]provider.Provider{
 					ipnet.IP6: provider.NewCloudflareTrace(),
 				},
@@ -184,6 +206,7 @@ func TestNormalizeConfig(t *testing.T) {
 		},
 		"ignored-ip4-domains": {
 			input: &config.Config{ //nolint:exhaustruct
+				UpdateOnStart: true,
 				Provider: map[ipnet.Type]provider.Provider{
 					ipnet.IP6: provider.NewCloudflareTrace(),
 				},
@@ -195,6 +218,7 @@ func TestNormalizeConfig(t *testing.T) {
 			},
 			ok: true,
 			expected: &config.Config{ //nolint:exhaustruct
+				UpdateOnStart: true,
 				Provider: map[ipnet.Type]provider.Provider{
 					ipnet.IP6: provider.NewCloudflareTrace(),
 				},
@@ -221,6 +245,7 @@ func TestNormalizeConfig(t *testing.T) {
 		},
 		"template": {
 			input: &config.Config{ //nolint:exhaustruct
+				UpdateOnStart: true,
 				Provider: map[ipnet.Type]provider.Provider{
 					ipnet.IP6: provider.NewCloudflareTrace(),
 				},
@@ -231,6 +256,7 @@ func TestNormalizeConfig(t *testing.T) {
 			},
 			ok: true,
 			expected: &config.Config{ //nolint:exhaustruct
+				UpdateOnStart: true,
 				Provider: map[ipnet.Type]provider.Provider{
 					ipnet.IP6: provider.NewCloudflareTrace(),
 				},
@@ -254,6 +280,7 @@ func TestNormalizeConfig(t *testing.T) {
 		},
 		"template/invalid/proxied": {
 			input: &config.Config{ //nolint:exhaustruct
+				UpdateOnStart: true,
 				Provider: map[ipnet.Type]provider.Provider{
 					ipnet.IP6: provider.NewCloudflareTrace(),
 				},
@@ -275,6 +302,7 @@ func TestNormalizeConfig(t *testing.T) {
 		},
 		"template/error/proxied": {
 			input: &config.Config{ //nolint:exhaustruct
+				UpdateOnStart: true,
 				Provider: map[ipnet.Type]provider.Provider{
 					ipnet.IP6: provider.NewCloudflareTrace(),
 				},
@@ -296,6 +324,7 @@ func TestNormalizeConfig(t *testing.T) {
 		},
 		"template/error/proxied/ill-formed": {
 			input: &config.Config{ //nolint:exhaustruct
+				UpdateOnStart: true,
 				Provider: map[ipnet.Type]provider.Provider{
 					ipnet.IP6: provider.NewCloudflareTrace(),
 				},
@@ -317,7 +346,8 @@ func TestNormalizeConfig(t *testing.T) {
 		},
 		"delete-on-stop/without-cron": {
 			input: &config.Config{ //nolint:exhaustruct
-				DeleteOnStop: true,
+				DeleteOnStop:  true,
+				UpdateOnStart: true,
 			},
 			ok:       false,
 			expected: nil,
@@ -326,7 +356,7 @@ func TestNormalizeConfig(t *testing.T) {
 					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
 					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
 					m.EXPECT().IncIndent().Return(m),
-					m.EXPECT().Errorf(pp.EmojiUserError, "DELETE_ON_STOP=true will immediately delete all DNS records when UPDATE_CRON=@disabled"), //nolint:lll
+					m.EXPECT().Errorf(pp.EmojiUserError, "DELETE_ON_STOP=true will immediately delete all updated DNS records when UPDATE_CRON=@once"), //nolint:lll
 				)
 			},
 		},
diff --git a/internal/config/env_base_test.go b/internal/config/env_base_test.go
index 06ab10c..ac60a6d 100644
--- a/internal/config/env_base_test.go
+++ b/internal/config/env_base_test.go
@@ -513,7 +513,7 @@ func TestReadNonnegDuration(t *testing.T) {
 	}
 }
 
-//nolint:paralleltest // environment vars are global
+//nolint:paralleltest,funlen // environment vars are global
 func TestReadCron(t *testing.T) {
 	key := keyPrefix + "CRON"
 
@@ -547,7 +547,20 @@ func TestReadCron(t *testing.T) {
 				)
 			},
 		},
-		"@": {true, " @daily  ", cron.MustNew("@yearly"), cron.MustNew("@daily"), true, nil},
+		"@daily": {true, " @daily  ", cron.MustNew("@yearly"), cron.MustNew("@daily"), true, nil},
+		"@disabled": {
+			true, " @disabled  ", cron.MustNew("@yearly"), nil, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiUserWarning, "%s=%s is deprecated; use %s=@once", key, "@disabled", gomock.Any())
+			},
+		},
+		"@nevermore": {
+			true, " @nevermore\t", cron.MustNew("@yearly"), nil, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiUserWarning, "%s=%s is deprecated; use %s=@once", key, "@nevermore", gomock.Any())
+			},
+		},
+		"@once": {true, "\t\t@once", cron.MustNew("@yearly"), nil, true, nil},
 		"illformed": {
 			true, " @ddddd  ", cron.MustNew("*/4 * * * *"), cron.MustNew("*/4 * * * *"), false,
 			func(m *mocks.MockPP) {
diff --git a/internal/cron/schedule_test.go b/internal/cron/schedule_test.go
index 6a8210f..2cc691f 100644
--- a/internal/cron/schedule_test.go
+++ b/internal/cron/schedule_test.go
@@ -24,20 +24,6 @@ func TestMustNewSuccessful(t *testing.T) {
 	}
 }
 
-func TestMustNewSuccessfulNil(t *testing.T) {
-	t.Parallel()
-	for _, tc := range [...]string{
-		"@disabled",
-		"@nevermore",
-	} {
-		tc := tc // capture range variable
-		t.Run(tc, func(t *testing.T) {
-			t.Parallel()
-			require.Nil(t, cron.MustNew(tc))
-		})
-	}
-}
-
 func TestMustNewPanicking(t *testing.T) {
 	t.Parallel()
 	for _, tc := range [...]string{
@@ -75,8 +61,6 @@ func TestNextNever(t *testing.T) {
 	t.Parallel()
 	for _, tc := range [...]string{
 		"* * 30 2 *",
-		"@disabled",
-		"@nevermore",
 	} {
 		tc := tc // capture range variable
 		t.Run(tc, func(t *testing.T) {
@@ -85,3 +69,8 @@ func TestNextNever(t *testing.T) {
 		})
 	}
 }
+
+func TestNextNil(t *testing.T) {
+	t.Parallel()
+	require.True(t, cron.Next(nil).IsZero())
+}