package lagoon import ( "encoding/json" "errors" "testing" "time" ) func TestDateSmoke(t *testing.T) { d, err := ParseDate("2026-10-02") if err != nil || d.String() != "2026-10-02" || d.IsZero() { t.Fatalf("parse: %v %v", d, err) } if _, err := ParseDate("2026-10-02T00:00:00Z"); err == nil { t.Fatal("ParseDate must accept only YYYY-MM-DD") } b, err := json.Marshal(struct { Day Date `json:"day"` None *Date `json:"none"` Zero Date `json:"zero"` }{Day: d}) if err != nil || string(b) != `{"day":"2026-10-02","none":null,"zero":null}` { t.Fatalf("marshal %s %v", b, err) } var back struct { Day Date `json:"day"` } if err := json.Unmarshal([]byte(`{"day":"2026-10-02"}`), &back); err != nil || back.Day != d { t.Fatalf("unmarshal %v %v", back.Day, err) } // pgx returns DATE as UTC midnight; the calendar date is kept whatever // the process time zone. var scanned Date if err := scanned.Scan(time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC)); err != nil || scanned != d { t.Fatalf("scan time %v %v", scanned, err) } if err := scanned.Scan("2026-10-03"); err != nil || scanned.String() != "2026-10-03" { t.Fatalf("scan string %v %v", scanned, err) } if err := scanned.Scan(nil); err != nil || !scanned.IsZero() { t.Fatalf("scan nil %v %v", scanned, err) } if v, err := (Date{}).Value(); v != nil || err != nil { t.Fatalf("zero value %v %v", v, err) } if v, err := d.Value(); v != "2026-10-02" || err != nil { t.Fatalf("value %v %v", v, err) } if got := NewDate(2026, 9, 31).String(); got != "2026-10-01" { t.Fatalf("NewDate normalises: %s", got) } if got := DateOf(time.Date(2026, 10, 2, 23, 30, 0, 0, time.FixedZone("x", -7*3600))).String(); got != "2026-10-02" { t.Fatalf("DateOf keeps the location's date: %s", got) } } func TestTimeOfDaySmoke(t *testing.T) { tod, err := ParseTimeOfDay("14:30") if err != nil || tod.String() != "14:30:00" { t.Fatalf("parse: %v %v", tod, err) } if tod, err := ParseTimeOfDay("14:30:15.987654"); err != nil || tod.String() != "14:30:15" { t.Fatalf("fraction: %v %v", tod, err) } if _, err := ParseTimeOfDay("25:00"); err == nil { t.Fatal("25:00 must fail") } midnight := NewTimeOfDay(0, 0, 0) if midnight.IsZero() { t.Fatal("midnight is a set value") } b, err := json.Marshal([]TimeOfDay{tod, {}}) if err != nil || string(b) != `["14:30:00",null]` { t.Fatalf("marshal %s %v", b, err) } var scanned TimeOfDay if err := scanned.Scan("09:05:07"); err != nil || scanned.Hour() != 9 || scanned.Minute() != 5 || scanned.Second() != 7 { t.Fatalf("scan %v %v", scanned, err) } if v, err := (TimeOfDay{}).Value(); v != nil || err != nil { t.Fatalf("zero value %v %v", v, err) } } type fillDates struct { At time.Time `gorm:"column:at"` AtPtr *time.Time `gorm:"column:at_ptr"` Day Date `gorm:"column:day"` DayPtr *Date `gorm:"column:day_ptr"` Clock TimeOfDay `gorm:"column:clock"` ClkPtr *TimeOfDay `gorm:"column:clock_ptr"` } func TestFillTextSmoke(t *testing.T) { var m fillDates allowed := []string{"at", "at_ptr", "day", "day_ptr", "clock", "clock_ptr"} err := Fill(&m, allowed, map[string]any{ "at": "2026-10-02T12:30:00Z", "at_ptr": "2026-10-02T14:30:00+02:00", "day": "2026-10-02", "day_ptr": "2026-10-03", "clock": "14:30", "clock_ptr": "08:15:00", }, true) if err != nil { t.Fatal(err) } want := time.Date(2026, 10, 2, 12, 30, 0, 0, time.UTC) if !m.At.Equal(want) || m.AtPtr == nil || !m.AtPtr.Equal(want) { t.Fatalf("times %v %v", m.At, m.AtPtr) } if m.Day.String() != "2026-10-02" || m.DayPtr == nil || m.DayPtr.String() != "2026-10-03" { t.Fatalf("dates %v %v", m.Day, m.DayPtr) } if m.Clock.String() != "14:30:00" || m.ClkPtr == nil || m.ClkPtr.String() != "08:15:00" { t.Fatalf("clocks %v %v", m.Clock, m.ClkPtr) } err = Fill(&m, allowed, map[string]any{"day": "next tuesday"}, true) var fte *FillTypeError if !errors.As(err, &fte) || fte.Key != "day" { t.Fatalf("garbage date: %v", err) } } func TestValidateRequiredZeroDateSmoke(t *testing.T) { rules := map[string]string{"day": "required"} errs, err := Validate(t.Context(), nil, &fillDates{}, rules, map[string]any{"day": Date{}}, nil) if err != nil || len(errs["day"]) != 1 { t.Fatalf("zero date: %v %v", errs, err) } errs, err = Validate(t.Context(), nil, &fillDates{}, rules, map[string]any{"day": NewDate(2026, 10, 2)}, nil) if err != nil || errs != nil { t.Fatalf("set date: %v %v", errs, err) } } // TestDateParseAndConstructors covers the constructors and the strict // YYYY-MM-DD parser. func TestDateParseAndConstructors(t *testing.T) { for _, bad := range []string{"", "2026-1-2", "2026/10/02", "02.10.2026", "2026-13-01", "2026-02-30", "2026-10-02 ", "2026-10-02T00:00:00Z", "next tuesday"} { if d, err := ParseDate(bad); err == nil { t.Errorf("ParseDate(%q) = %v, want an error", bad, d) } } d := NewDate(2024, time.February, 29) if d.String() != "2024-02-29" || d.IsZero() { t.Fatalf("leap day %v", d) } if got := NewDate(2025, time.February, 29).String(); got != "2025-03-01" { t.Fatalf("NewDate normalises a non-leap 29th to %s", got) } if !DateOf(time.Time{}).IsZero() { t.Fatal("DateOf(zero time) must be the zero Date") } if got := d.Time(nil); !got.Equal(time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC)) { t.Fatalf("Time(nil) = %v", got) } warsaw := time.FixedZone("CET", 3600) if got := d.Time(warsaw); got.Location() != warsaw || got.Hour() != 0 || got.Day() != 29 { t.Fatalf("Time(loc) = %v", got) } if !(Date{}).Time(time.UTC).IsZero() || (Date{}).String() != "" { t.Fatal("zero Date must give the zero time and an empty string") } } // TestDateTextAndJSON round-trips Date through JSON and text, including // null and the empty string. func TestDateTextAndJSON(t *testing.T) { d := NewDate(2026, 10, 2) text, err := d.MarshalText() if err != nil || string(text) != "2026-10-02" { t.Fatalf("MarshalText %q %v", text, err) } var back Date if err := back.UnmarshalText(text); err != nil || back != d { t.Fatalf("UnmarshalText %v %v", back, err) } if err := back.UnmarshalText([]byte(" ")); err != nil || !back.IsZero() { t.Fatalf("empty text %v %v", back, err) } if err := back.UnmarshalText([]byte("2026-10-32")); err == nil { t.Fatal("invalid text must fail") } for raw, wantZero := range map[string]bool{`null`: true, `""`: true, `"2026-10-02"`: false} { var got Date if err := json.Unmarshal([]byte(raw), &got); err != nil || got.IsZero() != wantZero { t.Fatalf("UnmarshalJSON(%s) = %v %v", raw, got, err) } } for _, raw := range []string{`20261002`, `true`, `"2026-10-02T10:00:00Z"`, `{}`} { var got Date if err := json.Unmarshal([]byte(raw), &got); err == nil { t.Fatalf("UnmarshalJSON(%s) accepted %v", raw, got) } } var ptr *Date if err := json.Unmarshal([]byte(`"2026-10-02"`), &ptr); err != nil || ptr == nil || *ptr != d { t.Fatalf("pointer unmarshal %v %v", ptr, err) } if err := json.Unmarshal([]byte(`null`), &ptr); err != nil || ptr != nil { t.Fatalf("pointer null %v %v", ptr, err) } } // TestDateScan covers every Scan source. func TestDateScan(t *testing.T) { var d Date for src, want := range map[any]string{ "2026-10-02": "2026-10-02", "2026-10-02T13:14:15Z": "2026-10-02", "2026-10-02 13:14:15": "2026-10-02", time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC): "2026-10-02", // The calendar date is taken as the driver returned it: no zone // conversion moves it to the previous or next day. time.Date(2026, 10, 2, 23, 0, 0, 0, time.FixedZone("x", -10*3600)): "2026-10-02", } { if err := d.Scan(src); err != nil || d.String() != want { t.Fatalf("Scan(%v) = %v %v, want %s", src, d, err, want) } } if err := d.Scan([]byte("2026-10-03")); err != nil || d.String() != "2026-10-03" { t.Fatalf("Scan([]byte) = %v %v", d, err) } for _, bad := range []any{"garbage", 42, 3.5, true} { if err := d.Scan(bad); err == nil { t.Fatalf("Scan(%v) must fail", bad) } } var nilDate *Date if err := nilDate.Scan("2026-10-02"); err == nil { t.Fatal("Scan on a nil receiver must fail") } } // TestTimeOfDayParseAndText covers the parser, the accessors and the text // and JSON forms. func TestTimeOfDayParseAndText(t *testing.T) { for in, want := range map[string]string{ "00:00": "00:00:00", "23:59": "23:59:00", "23:59:59": "23:59:59", "07:08:09.000001": "07:08:09", } { got, err := ParseTimeOfDay(in) if err != nil || got.String() != want { t.Fatalf("ParseTimeOfDay(%q) = %v %v, want %s", in, got, err, want) } } for _, bad := range []string{"", "7", "24:00", "12:60", "12:00:60", "12:00:00.", "12:00:00.1a", "12:00.5", "noon", "12-00"} { if got, err := ParseTimeOfDay(bad); err == nil { t.Errorf("ParseTimeOfDay(%q) = %v, want an error", bad, got) } } if got := NewTimeOfDay(25, 61, 0).String(); got != "02:01:00" { t.Fatalf("NewTimeOfDay wraps to one day: %s", got) } tod := NewTimeOfDay(9, 5, 7) if tod.Hour() != 9 || tod.Minute() != 5 || tod.Second() != 7 { t.Fatalf("accessors %d %d %d", tod.Hour(), tod.Minute(), tod.Second()) } text, err := tod.MarshalText() if err != nil || string(text) != "09:05:07" { t.Fatalf("MarshalText %q %v", text, err) } var back TimeOfDay if err := back.UnmarshalText(text); err != nil || back != tod { t.Fatalf("UnmarshalText %v %v", back, err) } if err := back.UnmarshalText(nil); err != nil || !back.IsZero() { t.Fatalf("empty text %v %v", back, err) } if (TimeOfDay{}).String() != "" { t.Fatal("zero TimeOfDay string") } for raw, wantZero := range map[string]bool{`null`: true, `""`: true, `"09:05"`: false} { var got TimeOfDay if err := json.Unmarshal([]byte(raw), &got); err != nil || got.IsZero() != wantZero { t.Fatalf("UnmarshalJSON(%s) = %v %v", raw, got, err) } } var bad TimeOfDay if err := json.Unmarshal([]byte(`930`), &bad); err == nil { t.Fatal("a JSON number must fail") } if err := json.Unmarshal([]byte(`"25:00"`), &bad); err == nil { t.Fatal("an invalid JSON string must fail") } } // TestTimeOfDayScan covers every Scan source. func TestTimeOfDayScan(t *testing.T) { var tod TimeOfDay for src, want := range map[any]string{ "14:30": "14:30:00", "14:30:15": "14:30:15", time.Date(0, 1, 1, 6, 7, 8, 0, time.UTC): "06:07:08", } { if err := tod.Scan(src); err != nil || tod.String() != want { t.Fatalf("Scan(%v) = %v %v", src, tod, err) } } if err := tod.Scan([]byte("01:02:03")); err != nil || tod.String() != "01:02:03" { t.Fatalf("Scan([]byte) = %v %v", tod, err) } if err := tod.Scan(nil); err != nil || !tod.IsZero() { t.Fatalf("Scan(nil) = %v %v", tod, err) } for _, bad := range []any{"x", []byte("99:00"), 5, false} { if err := tod.Scan(bad); err == nil { t.Fatalf("Scan(%v) must fail", bad) } } var nilTOD *TimeOfDay if err := nilTOD.Scan("10:00"); err == nil { t.Fatal("Scan on a nil receiver must fail") } if v, err := NewTimeOfDay(0, 0, 0).Value(); v != "00:00:00" || err != nil { t.Fatalf("midnight Value = %v %v", v, err) } } // dateRoundTrip spells the TIME columns as "time without time zone": GORM // reads a bare `type:time` tag as its own time data type and AutoMigrate // would create a timestamptz column. type dateRoundTrip struct { ID uint `gorm:"column:id;primaryKey"` Day Date `gorm:"column:day;type:date"` DayPtr *Date `gorm:"column:day_ptr;type:date"` Clock TimeOfDay `gorm:"column:clock;type:time without time zone"` ClockPtr *TimeOfDay `gorm:"column:clock_ptr;type:time without time zone"` At *time.Time `gorm:"column:at"` } func (dateRoundTrip) TableName() string { return "lagoon_date_round_trip" } // TestDatePostgresRoundTrip writes Date and TimeOfDay through real DATE and // TIME columns: set values come back unchanged, the zero values store NULL // and read back as zero, and nil pointers stay nil. func TestDatePostgresRoundTrip(t *testing.T) { db, _ := dedicatedDB(t, "lagoon_date_round_trip") gdb, err := Use(t.Context(), db) if err != nil { t.Fatal(err) } if err := gdb.AutoMigrate(&dateRoundTrip{}); err != nil { t.Fatal(err) } day := NewDate(2026, 10, 2) clock := NewTimeOfDay(23, 59, 58) at := time.Date(2026, 10, 2, 10, 30, 0, 0, time.FixedZone("CEST", 2*3600)) set := dateRoundTrip{Day: day, DayPtr: &day, Clock: clock, ClockPtr: &clock, At: &at} zero := dateRoundTrip{} if err := gdb.Create(&set).Error; err != nil { t.Fatal(err) } if err := gdb.Create(&zero).Error; err != nil { t.Fatal(err) } var nulls int64 if err := gdb.Raw(`SELECT COUNT(*) FROM lagoon_date_round_trip WHERE id = ? AND day IS NULL AND day_ptr IS NULL AND clock IS NULL AND clock_ptr IS NULL`, zero.ID).Scan(&nulls).Error; err != nil || nulls != 1 { t.Fatalf("zero values did not store NULL (%d, %v)", nulls, err) } var text string if err := gdb.Raw(`SELECT day::text || ' ' || clock::text FROM lagoon_date_round_trip WHERE id = ?`, set.ID).Scan(&text).Error; err != nil || text != "2026-10-02 23:59:58" { t.Fatalf("stored text %q (%v)", text, err) } var got dateRoundTrip if err := gdb.First(&got, set.ID).Error; err != nil { t.Fatal(err) } if got.Day != day || got.DayPtr == nil || *got.DayPtr != day || got.Clock != clock || got.ClockPtr == nil || *got.ClockPtr != clock || got.At == nil || !got.At.Equal(at) { t.Fatalf("round trip = %+v", got) } var empty dateRoundTrip if err := gdb.First(&empty, zero.ID).Error; err != nil { t.Fatal(err) } if !empty.Day.IsZero() || empty.DayPtr != nil || !empty.Clock.IsZero() || empty.ClockPtr != nil || empty.At != nil { t.Fatalf("NULL round trip = %+v", empty) } }