Skip to content
quarry Artifact [f95d84da04]
Login

Artifact [f95d84da04]

Artifact f95d84da049ce84d97244bd299c7749124113a71667a0542645a06249c5e1d6c:


// Salvaged-from: github.com/danmestas/dagnats internal/cronexpr/cronexpr_test.go @ e1ca6a3 (Apache-2.0, same author)
package cronexpr

// Methodology: unit tests for cron expression parsing and matching.
// No NATS dependency — pure time logic.

import (
	"strings"
	"testing"
	"time"
)

func TestParseCronEveryMinute(t *testing.T) {
	expr, err := ParseCron("* * * * *")
	if err != nil {
		t.Fatalf("parse: %v", err)
	}

	// Positive: matches any time
	now := time.Date(2026, 3, 31, 14, 30, 0, 0, time.UTC)
	if !expr.Matches(now) {
		t.Fatalf("* * * * * should match any time")
	}

	// Positive: 60 minute values
	if len(expr.Minutes) != 60 {
		t.Fatalf("minutes = %d, want 60", len(expr.Minutes))
	}
}

func TestParseCronWeekdayMorning(t *testing.T) {
	expr, err := ParseCron("0 9 * * 1-5")
	if err != nil {
		t.Fatalf("parse: %v", err)
	}

	// Positive: Monday 9am matches
	mon9 := time.Date(2026, 3, 30, 9, 0, 0, 0, time.UTC) // Monday
	if !expr.Matches(mon9) {
		t.Fatalf("should match Monday 9am")
	}

	// Negative: Sunday 9am does not match
	sun9 := time.Date(2026, 3, 29, 9, 0, 0, 0, time.UTC) // Sunday
	if expr.Matches(sun9) {
		t.Fatalf("should not match Sunday")
	}

	// Negative: Monday 10am does not match
	mon10 := time.Date(2026, 3, 30, 10, 0, 0, 0, time.UTC)
	if expr.Matches(mon10) {
		t.Fatalf("should not match 10am")
	}
}

func TestParseCronEvery5Minutes(t *testing.T) {
	expr, err := ParseCron("*/5 * * * *")
	if err != nil {
		t.Fatalf("parse: %v", err)
	}

	// Positive: minute 0 matches
	if !expr.Matches(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) {
		t.Fatalf("minute 0 should match */5")
	}

	// Positive: minute 15 matches
	if !expr.Matches(time.Date(2026, 1, 1, 0, 15, 0, 0, time.UTC)) {
		t.Fatalf("minute 15 should match */5")
	}

	// Negative: minute 3 does not match
	if expr.Matches(time.Date(2026, 1, 1, 0, 3, 0, 0, time.UTC)) {
		t.Fatalf("minute 3 should not match */5")
	}
}

func TestParseCronCommaList(t *testing.T) {
	expr, err := ParseCron("0,30 * * * *")
	if err != nil {
		t.Fatalf("parse: %v", err)
	}

	// Positive: minute 0 and 30 match
	if len(expr.Minutes) != 2 {
		t.Fatalf("minutes = %d, want 2", len(expr.Minutes))
	}

	// Negative: minute 15 does not
	if expr.Matches(time.Date(2026, 1, 1, 0, 15, 0, 0, time.UTC)) {
		t.Fatalf("minute 15 should not match 0,30")
	}
}

func TestParseCronRejectsInvalid(t *testing.T) {
	bad := []string{
		"",
		"* * *",
		"* * * * * *",
		"60 * * * *",
		"* 25 * * *",
		"abc * * * *",
	}
	for _, expr := range bad {
		_, err := ParseCron(expr)
		if err == nil {
			t.Errorf("expected error for %q", expr)
		}
	}
}

// TestParseCronFieldCountErrorMentionsForm confirms the field-count
// error is operator-friendly (issue #172). Operators arriving from
// 6-field cron tooling (Quartz, robfig/cron-v3, k8s) need to know to
// drop the leading seconds field — the previous "expected 5 fields,
// got 6" said what was wrong but not how to fix it.
func TestParseCronFieldCountErrorMentionsForm(t *testing.T) {
	_, err := ParseCron("0 */3 * * * *")
	// Positive: errors on 6-field input.
	if err == nil {
		t.Fatal("expected error for 6-field expression")
	}
	msg := err.Error()
	// Positive: error names the 5-field shape.
	if !strings.Contains(msg, "5 fields") {
		t.Errorf("error should reference \"5 fields\", got: %v", err)
	}
	if !strings.Contains(msg, "minute hour day-of-month") {
		t.Errorf(
			"error should name the field order, got: %v", err)
	}
	// Positive: error tells the operator the next step.
	if !strings.Contains(msg, "Drop") {
		t.Errorf(
			"error should mention dropping the seconds field, got: %v",
			err)
	}
}

func TestMatchesExactMinute(t *testing.T) {
	expr, err := ParseCron("30 14 * * *")
	if err != nil {
		t.Fatalf("parse: %v", err)
	}

	// Positive: exact match at 14:30
	at := time.Date(2026, 6, 15, 14, 30, 0, 0, time.UTC)
	if !expr.Matches(at) {
		t.Fatalf("should match 14:30")
	}

	// Negative: one minute off
	off := time.Date(2026, 6, 15, 14, 31, 0, 0, time.UTC)
	if expr.Matches(off) {
		t.Fatalf("should not match 14:31")
	}
}

func TestMatchesBoundaryMinuteZeroAndFiftyNine(t *testing.T) {
	expr, err := ParseCron("0,59 * * * *")
	if err != nil {
		t.Fatalf("parse: %v", err)
	}

	// Positive: minute 0 matches
	m0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
	if !expr.Matches(m0) {
		t.Fatalf("minute 0 should match")
	}

	// Positive: minute 59 matches
	m59 := time.Date(2026, 1, 1, 12, 59, 0, 0, time.UTC)
	if !expr.Matches(m59) {
		t.Fatalf("minute 59 should match")
	}

	// Negative: minute 30 does not match
	m30 := time.Date(2026, 1, 1, 12, 30, 0, 0, time.UTC)
	if expr.Matches(m30) {
		t.Fatalf("minute 30 should not match 0,59")
	}
}

func TestMatchesBoundaryHourZeroAndTwentyThree(t *testing.T) {
	expr, err := ParseCron("0 0,23 * * *")
	if err != nil {
		t.Fatalf("parse: %v", err)
	}

	// Positive: hour 0 matches
	h0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
	if !expr.Matches(h0) {
		t.Fatalf("hour 0 should match")
	}

	// Positive: hour 23 matches
	h23 := time.Date(2026, 1, 1, 23, 0, 0, 0, time.UTC)
	if !expr.Matches(h23) {
		t.Fatalf("hour 23 should match")
	}

	// Negative: hour 12 does not match
	h12 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
	if expr.Matches(h12) {
		t.Fatalf("hour 12 should not match 0,23")
	}
}

func TestMatchesDayOfWeekBoundary(t *testing.T) {
	// Sunday=0
	expr, err := ParseCron("0 0 * * 0")
	if err != nil {
		t.Fatalf("parse: %v", err)
	}

	// Positive: Sunday matches (2026-03-29 is Sunday)
	sun := time.Date(2026, 3, 29, 0, 0, 0, 0, time.UTC)
	if !expr.Matches(sun) {
		t.Fatalf("Sunday should match day-of-week 0")
	}

	// Negative: Monday does not match
	mon := time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC)
	if expr.Matches(mon) {
		t.Fatalf("Monday should not match day-of-week 0")
	}
}

func TestMatchesDayOfWeekSaturday(t *testing.T) {
	// Saturday=6
	expr, err := ParseCron("0 0 * * 6")
	if err != nil {
		t.Fatalf("parse: %v", err)
	}

	// Positive: Saturday matches (2026-03-28 is Saturday)
	sat := time.Date(2026, 3, 28, 0, 0, 0, 0, time.UTC)
	if !expr.Matches(sat) {
		t.Fatalf("Saturday should match day-of-week 6")
	}

	// Negative: Friday does not match
	fri := time.Date(2026, 3, 27, 0, 0, 0, 0, time.UTC)
	if expr.Matches(fri) {
		t.Fatalf("Friday should not match day-of-week 6")
	}
}

func TestParseFieldRejectsInvalidStep(t *testing.T) {
	// Invalid step value (not a number)
	_, err := ParseCron("*/abc * * * *")
	if err == nil {
		t.Fatalf("expected error for */abc")
	}

	// Negative step
	_, err = ParseCron("*/0 * * * *")
	if err == nil {
		t.Fatalf("expected error for */0")
	}
}

func TestParseFieldRejectsInvalidRange(t *testing.T) {
	// Range start not a number
	_, err := ParseCron("a-5 * * * *")
	if err == nil {
		t.Fatalf("expected error for a-5")
	}

	// Range end not a number
	_, err = ParseCron("0-b * * * *")
	if err == nil {
		t.Fatalf("expected error for 0-b")
	}

	// Range out of bounds (lo > hi)
	_, err = ParseCron("30-10 * * * *")
	if err == nil {
		t.Fatalf("expected error for 30-10")
	}
}

func TestParseCronRejectsInvalidFields(t *testing.T) {
	// Invalid hour field
	_, err := ParseCron("0 abc * * *")
	if err == nil {
		t.Fatalf("expected error for invalid hour field")
	}

	// Invalid day-of-month field
	_, err = ParseCron("0 0 32 * *")
	if err == nil {
		t.Fatalf("expected error for day-of-month 32")
	}

	// Invalid month field
	_, err = ParseCron("0 0 * 13 *")
	if err == nil {
		t.Fatalf("expected error for month 13")
	}

	// Invalid day-of-week field
	_, err = ParseCron("0 0 * * 7")
	if err == nil {
		t.Fatalf("expected error for day-of-week 7")
	}
}

func TestMatchesWildcardAllFields(t *testing.T) {
	expr, err := ParseCron("* * * * *")
	if err != nil {
		t.Fatalf("parse: %v", err)
	}

	// Positive: midnight Jan 1
	t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
	if !expr.Matches(t1) {
		t.Fatalf("wildcard should match midnight Jan 1")
	}

	// Positive: last minute of year
	t2 := time.Date(2026, 12, 31, 23, 59, 0, 0, time.UTC)
	if !expr.Matches(t2) {
		t.Fatalf("wildcard should match 23:59 Dec 31")
	}
}