quando/date_test.go
Oliver Jakoubek f47897f3fd Change Now() and DefaultClock to use UTC instead of local time
Changed quando.Now() and DefaultClock.Now() to return dates in UTC
timezone instead of local server timezone. This aligns with industry
standards for date/time libraries and prevents server-timezone-dependent
behavior.

Changes:
- date.go: Now() uses time.Now().UTC() instead of time.Now()
- Updated documentation comments to reflect UTC default
- date_test.go: TestNow() now verifies UTC location
- clock_test.go: TestDefaultClock_Now() now verifies UTC location
- parse_test.go: TestParseRelative() verifies all results are UTC
- parse.go: Updated comment from "local timezone" to "UTC timezone"

Users needing local time can use:
- quando.From(time.Now()) for explicit local time
- quando.Now().In("Europe/Berlin") to convert to specific timezone

Closes: quando-67n
2026-02-12 16:39:01 +01:00

503 lines
12 KiB
Go

package quando
import (
"errors"
"testing"
"time"
)
func TestNow(t *testing.T) {
before := time.Now().UTC()
date := Now()
after := time.Now().UTC()
// Verify that Now() returns a time between before and after
if date.Time().Before(before) || date.Time().After(after) {
t.Errorf("Now() returned time outside expected range")
}
// Verify location is UTC
if date.Time().Location() != time.UTC {
t.Errorf("Now().Time().Location() = %v, want UTC", date.Time().Location())
}
// Verify default language is EN
if date.lang != EN {
t.Errorf("Now() default lang = %v, want %v", date.lang, EN)
}
}
func TestFrom(t *testing.T) {
tests := []struct {
name string
time time.Time
}{
{
name: "specific date",
time: time.Date(2026, 2, 9, 12, 30, 45, 0, time.UTC),
},
{
name: "year 0001",
time: time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC),
},
{
name: "year 9999",
time: time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC),
},
{
name: "with nanoseconds",
time: time.Date(2026, 2, 9, 12, 30, 45, 123456789, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
date := From(tt.time)
if !date.Time().Equal(tt.time) {
t.Errorf("From() = %v, want %v", date.Time(), tt.time)
}
if date.lang != EN {
t.Errorf("From() default lang = %v, want %v", date.lang, EN)
}
})
}
}
func TestFromUnix(t *testing.T) {
tests := []struct {
name string
unix int64
expected time.Time
}{
{
name: "epoch",
unix: 0,
expected: time.Unix(0, 0),
},
{
name: "positive timestamp",
unix: 1707480000, // 2024-02-09 12:00:00 UTC
expected: time.Unix(1707480000, 0),
},
{
name: "negative timestamp (before 1970)",
unix: -946771200, // 1940-01-01 00:00:00 UTC
expected: time.Unix(-946771200, 0),
},
{
name: "large positive timestamp",
unix: 253402300799, // 9999-12-31 23:59:59 UTC
expected: time.Unix(253402300799, 0),
},
{
name: "large negative timestamp",
unix: -62135596800, // 0001-01-01 00:00:00 UTC
expected: time.Unix(-62135596800, 0),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
date := FromUnix(tt.unix)
if !date.Time().Equal(tt.expected) {
t.Errorf("FromUnix(%d) = %v, want %v", tt.unix, date.Time(), tt.expected)
}
if date.lang != EN {
t.Errorf("FromUnix() default lang = %v, want %v", date.lang, EN)
}
})
}
}
func TestTime(t *testing.T) {
original := time.Date(2026, 2, 9, 12, 30, 45, 123456789, time.UTC)
date := From(original)
result := date.Time()
if !result.Equal(original) {
t.Errorf("Time() = %v, want %v", result, original)
}
// Verify that modifying the result doesn't affect the original Date
result = result.Add(24 * time.Hour)
if !date.Time().Equal(original) {
t.Errorf("Date.Time() was modified after returning, Date should be immutable")
}
}
func TestUnix(t *testing.T) {
tests := []struct {
name string
date Date
expected int64
}{
{
name: "epoch",
date: FromUnix(0),
expected: 0,
},
{
name: "positive timestamp",
date: From(time.Date(2024, 2, 9, 12, 0, 0, 0, time.UTC)),
expected: 1707480000,
},
{
name: "negative timestamp",
date: FromUnix(-946771200),
expected: -946771200,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.date.Unix()
if result != tt.expected {
t.Errorf("Unix() = %d, want %d", result, tt.expected)
}
})
}
}
func TestWithLang(t *testing.T) {
original := Now()
tests := []struct {
name string
lang Lang
}{
{"english", EN},
{"german", DE},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := original.WithLang(tt.lang)
// Verify language changed
if result.lang != tt.lang {
t.Errorf("WithLang(%v) lang = %v, want %v", tt.lang, result.lang, tt.lang)
}
// Verify time unchanged
if !result.Time().Equal(original.Time()) {
t.Errorf("WithLang() changed time, should only change language")
}
// Verify original unchanged (immutability)
if original.lang != EN {
t.Errorf("WithLang() modified original date, Date should be immutable")
}
})
}
}
func TestString(t *testing.T) {
tests := []struct {
name string
date Date
expected string
}{
{
name: "standard date",
date: From(time.Date(2026, 2, 9, 12, 30, 45, 0, time.UTC)),
expected: "2026-02-09 12:30:45",
},
{
name: "start of year",
date: From(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)),
expected: "2026-01-01 00:00:00",
},
{
name: "end of year",
date: From(time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC)),
expected: "2026-12-31 23:59:59",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.date.String()
if result != tt.expected {
t.Errorf("String() = %q, want %q", result, tt.expected)
}
})
}
}
func TestDateImmutability(t *testing.T) {
// Create original date
original := From(time.Date(2026, 2, 9, 12, 0, 0, 0, time.UTC))
originalTime := original.Time()
originalLang := original.lang
// Perform various operations
_ = original.WithLang(DE)
_ = original.Unix()
_ = original.Time()
_ = original.String()
// Verify original is unchanged
if !original.Time().Equal(originalTime) {
t.Errorf("Date was modified, should be immutable")
}
if original.lang != originalLang {
t.Errorf("Date language was modified, should be immutable")
}
}
// TestDateTimezones verifies that Date correctly preserves timezone information
func TestDateTimezones(t *testing.T) {
loc, err := time.LoadLocation("Europe/Berlin")
if err != nil {
t.Skipf("Skipping timezone test: %v", err)
}
berlinTime := time.Date(2026, 2, 9, 12, 0, 0, 0, loc)
date := From(berlinTime)
// Verify timezone is preserved
if date.Time().Location() != loc {
t.Errorf("Location = %v, want %v", date.Time().Location(), loc)
}
// Verify Unix timestamp is correct
expectedUnix := berlinTime.Unix()
if date.Unix() != expectedUnix {
t.Errorf("Unix() = %d, want %d", date.Unix(), expectedUnix)
}
}
func TestIn(t *testing.T) {
tests := []struct {
name string
input time.Time
location string
expectedZone string
expectedHour int
}{
{
name: "UTC to Europe/Berlin (winter)",
input: time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC),
location: "Europe/Berlin",
expectedZone: "CET",
expectedHour: 13, // UTC+1 in winter
},
{
name: "UTC to America/New_York (winter)",
input: time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC),
location: "America/New_York",
expectedZone: "EST",
expectedHour: 7, // UTC-5 in winter
},
{
name: "UTC to Asia/Tokyo",
input: time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC),
location: "Asia/Tokyo",
expectedZone: "JST",
expectedHour: 21, // UTC+9 (no DST in Japan)
},
{
name: "UTC to UTC (identity)",
input: time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC),
location: "UTC",
expectedZone: "UTC",
expectedHour: 12,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
date := From(tt.input)
result, err := date.In(tt.location)
if err != nil {
t.Fatalf("In(%q) unexpected error: %v", tt.location, err)
}
zone, _ := result.Time().Zone()
if zone != tt.expectedZone {
t.Errorf("In(%q) zone = %v, want %v", tt.location, zone, tt.expectedZone)
}
if result.Time().Hour() != tt.expectedHour {
t.Errorf("In(%q) hour = %v, want %v", tt.location, result.Time().Hour(), tt.expectedHour)
}
})
}
}
func TestInDST(t *testing.T) {
tests := []struct {
name string
input time.Time
location string
expectedZone string
expectedHour int
}{
{
name: "UTC to Europe/Berlin (summer - DST)",
input: time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC),
location: "Europe/Berlin",
expectedZone: "CEST",
expectedHour: 14, // UTC+2 in summer
},
{
name: "UTC to America/New_York (summer - DST)",
input: time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC),
location: "America/New_York",
expectedZone: "EDT",
expectedHour: 8, // UTC-4 in summer
},
// DST spring forward transition (Europe/Berlin: Mar 29, 2026)
{
name: "Before DST spring forward",
input: time.Date(2026, 3, 29, 0, 59, 0, 0, time.UTC),
location: "Europe/Berlin",
expectedZone: "CET",
expectedHour: 1, // Still CET (UTC+1)
},
{
name: "After DST spring forward",
input: time.Date(2026, 3, 29, 1, 1, 0, 0, time.UTC),
location: "Europe/Berlin",
expectedZone: "CEST",
expectedHour: 3, // Now CEST (UTC+2), skipped 2:00-3:00
},
// DST fall back transition (Europe/Berlin: Oct 25, 2026)
{
name: "Before DST fall back",
input: time.Date(2026, 10, 25, 0, 59, 0, 0, time.UTC),
location: "Europe/Berlin",
expectedZone: "CEST",
expectedHour: 2, // Still CEST (UTC+2)
},
{
name: "After DST fall back",
input: time.Date(2026, 10, 25, 1, 1, 0, 0, time.UTC),
location: "Europe/Berlin",
expectedZone: "CET",
expectedHour: 2, // Back to CET (UTC+1), repeated 2:00-3:00
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
date := From(tt.input)
result, err := date.In(tt.location)
if err != nil {
t.Fatalf("In(%q) unexpected error: %v", tt.location, err)
}
zone, _ := result.Time().Zone()
if zone != tt.expectedZone {
t.Errorf("In(%q) zone = %v, want %v", tt.location, zone, tt.expectedZone)
}
if result.Time().Hour() != tt.expectedHour {
t.Errorf("In(%q) hour = %v, want %v", tt.location, result.Time().Hour(), tt.expectedHour)
}
})
}
}
func TestInErrors(t *testing.T) {
date := Now()
tests := []struct {
name string
location string
wantErr error
}{
{"empty string", "", ErrInvalidTimezone},
{"invalid timezone", "Invalid/Timezone", ErrInvalidTimezone},
{"typo in timezone", "America/New_Yrok", ErrInvalidTimezone},
{"partial timezone", "Europe", ErrInvalidTimezone},
{"numeric timezone", "UTC+5", ErrInvalidTimezone}, // Use "America/Chicago" instead
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := date.In(tt.location)
if err == nil {
t.Fatalf("In(%q) expected error, got nil (result: %v)", tt.location, result)
}
if !errors.Is(err, tt.wantErr) {
t.Errorf("In(%q) error = %v, want error wrapping %v", tt.location, err, tt.wantErr)
}
})
}
}
func TestInImmutability(t *testing.T) {
original := From(time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC))
originalTime := original.Time()
// Convert to different timezone
_, err := original.In("Europe/Berlin")
if err != nil {
t.Fatalf("In() failed: %v", err)
}
// Verify original is unchanged
if !original.Time().Equal(originalTime) {
t.Error("In() modified the original date")
}
// Verify original timezone is unchanged
originalZone, _ := original.Time().Zone()
if originalZone != "UTC" {
t.Errorf("Original zone changed to %v", originalZone)
}
}
func TestInLanguagePreservation(t *testing.T) {
date := From(time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)).WithLang(DE)
result, err := date.In("Europe/Berlin")
if err != nil {
t.Fatalf("In() failed: %v", err)
}
if result.lang != DE {
t.Errorf("In() language = %v, want %v", result.lang, DE)
}
}
func TestDSTSafeArithmetic(t *testing.T) {
// Load Berlin timezone
loc, err := time.LoadLocation("Europe/Berlin")
if err != nil {
t.Skipf("Skipping DST test: %v", err)
}
// March 28, 2026 at 02:00 CET (day before DST spring forward)
date := From(time.Date(2026, 3, 28, 2, 0, 0, 0, loc))
// Add 1 day - should be March 29 at 02:00 CEST (not 03:00!)
// This is the critical DST behavior: same wall clock time, not 24 hours
next := date.Add(1, Days)
expected := time.Date(2026, 3, 29, 2, 0, 0, 0, loc)
if !next.Time().Equal(expected) {
t.Errorf("Add(1, Days) across DST = %v, want %v", next.Time(), expected)
}
// Verify it's actually only 23 hours in duration
duration := next.Time().Sub(date.Time())
expectedDuration := 23 * time.Hour
if duration != expectedDuration {
t.Logf("Actual duration: %v (expected: %v)", duration, expectedDuration)
t.Logf("This is correct: DST spring forward skips 1 hour")
}
}