386 lines
12 KiB
Go
386 lines
12 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"google.golang.org/api/calendar/v3"
|
|
)
|
|
|
|
func newCalendarServiceWithTimezone(t *testing.T, tz string) *calendar.Service {
|
|
t.Helper()
|
|
|
|
svc, closeServer := newTestCalendarService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/calendars/primary" && r.Method == http.MethodGet {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "primary",
|
|
"summary": "Test Calendar",
|
|
"timeZone": tz,
|
|
})
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
t.Cleanup(closeServer)
|
|
return svc
|
|
}
|
|
|
|
func TestResolveTimeRangeWithDefaultsToday(t *testing.T) {
|
|
svc := newCalendarServiceWithTimezone(t, "UTC")
|
|
flags := TimeRangeFlags{Today: true}
|
|
defaults := TimeRangeDefaults{
|
|
FromOffset: time.Hour,
|
|
ToOffset: 2 * time.Hour,
|
|
ToFromOffset: 3 * time.Hour,
|
|
}
|
|
|
|
tr, err := ResolveTimeRangeWithDefaults(context.Background(), svc, flags, defaults)
|
|
if err != nil {
|
|
t.Fatalf("ResolveTimeRangeWithDefaults: %v", err)
|
|
}
|
|
|
|
if tr.From.Hour() != 0 || tr.From.Minute() != 0 || tr.From.Second() != 0 {
|
|
t.Fatalf("expected start of day, got %v", tr.From)
|
|
}
|
|
|
|
// endOfDay now returns start of next day (midnight) so that RFC3339
|
|
// second-precision formatting doesn't truncate 23:59:59.999999999 to
|
|
// 23:59:59, which would exclude events at that second via exclusive timeMax.
|
|
if tr.To.Hour() != 0 || tr.To.Minute() != 0 || tr.To.Second() != 0 {
|
|
t.Fatalf("expected start of next day (end-of-day), got %v", tr.To)
|
|
}
|
|
|
|
if !tr.From.Before(tr.To) {
|
|
t.Fatalf("expected from before to: %v -> %v", tr.From, tr.To)
|
|
}
|
|
}
|
|
|
|
func TestResolveTimeRangeWithDefaultsFromTo(t *testing.T) {
|
|
svc := newCalendarServiceWithTimezone(t, "UTC")
|
|
flags := TimeRangeFlags{
|
|
From: "2025-01-05T10:00:00Z",
|
|
To: "2025-01-05T12:00:00Z",
|
|
}
|
|
tr, err := ResolveTimeRangeWithDefaults(context.Background(), svc, flags, TimeRangeDefaults{})
|
|
if err != nil {
|
|
t.Fatalf("ResolveTimeRangeWithDefaults: %v", err)
|
|
}
|
|
|
|
expectedFrom := time.Date(2025, 1, 5, 10, 0, 0, 0, time.UTC)
|
|
expectedTo := time.Date(2025, 1, 5, 12, 0, 0, 0, time.UTC)
|
|
if !tr.From.Equal(expectedFrom) || !tr.To.Equal(expectedTo) {
|
|
t.Fatalf("unexpected range: %v -> %v", tr.From, tr.To)
|
|
}
|
|
}
|
|
|
|
func TestResolveTimeRangeWithDefaultsToDateOnlyEndOfDay(t *testing.T) {
|
|
svc := newCalendarServiceWithTimezone(t, "UTC")
|
|
flags := TimeRangeFlags{
|
|
From: "2025-01-05T10:00:00Z",
|
|
To: "2025-01-05",
|
|
}
|
|
tr, err := ResolveTimeRangeWithDefaults(context.Background(), svc, flags, TimeRangeDefaults{})
|
|
if err != nil {
|
|
t.Fatalf("ResolveTimeRangeWithDefaults: %v", err)
|
|
}
|
|
|
|
expectedFrom := time.Date(2025, 1, 5, 10, 0, 0, 0, time.UTC)
|
|
expectedTo := time.Date(2025, 1, 6, 0, 0, 0, 0, time.UTC)
|
|
if !tr.From.Equal(expectedFrom) {
|
|
t.Fatalf("unexpected from: %v", tr.From)
|
|
}
|
|
if !tr.To.Equal(expectedTo) {
|
|
t.Fatalf("unexpected to: %v", tr.To)
|
|
}
|
|
}
|
|
|
|
func TestResolveTimeRangeWithDefaultsFromOffset(t *testing.T) {
|
|
svc := newCalendarServiceWithTimezone(t, "UTC")
|
|
flags := TimeRangeFlags{From: "2025-01-05T10:00:00Z"}
|
|
defaults := TimeRangeDefaults{ToFromOffset: 2 * time.Hour}
|
|
|
|
tr, err := ResolveTimeRangeWithDefaults(context.Background(), svc, flags, defaults)
|
|
if err != nil {
|
|
t.Fatalf("ResolveTimeRangeWithDefaults: %v", err)
|
|
}
|
|
|
|
expectedFrom := time.Date(2025, 1, 5, 10, 0, 0, 0, time.UTC)
|
|
if !tr.From.Equal(expectedFrom) {
|
|
t.Fatalf("unexpected from: %v", tr.From)
|
|
}
|
|
|
|
if tr.To.Sub(tr.From) != 2*time.Hour {
|
|
t.Fatalf("unexpected duration: %v", tr.To.Sub(tr.From))
|
|
}
|
|
}
|
|
|
|
func TestResolveTimeRangeWithDefaultsInvalidFrom(t *testing.T) {
|
|
svc := newCalendarServiceWithTimezone(t, "UTC")
|
|
flags := TimeRangeFlags{From: "nope"}
|
|
if _, err := ResolveTimeRangeWithDefaults(context.Background(), svc, flags, TimeRangeDefaults{}); err == nil {
|
|
t.Fatalf("expected error")
|
|
}
|
|
}
|
|
|
|
func TestResolveTimeRangeWithDefaultsWeekStartError(t *testing.T) {
|
|
svc := newCalendarServiceWithTimezone(t, "UTC")
|
|
flags := TimeRangeFlags{Week: true, WeekStart: "nope"}
|
|
if _, err := ResolveTimeRangeWithDefaults(context.Background(), svc, flags, TimeRangeDefaults{}); err == nil {
|
|
t.Fatalf("expected error")
|
|
}
|
|
}
|
|
|
|
func TestGetUserTimezoneFallback(t *testing.T) {
|
|
svc := newCalendarServiceWithTimezone(t, "")
|
|
loc, err := getUserTimezone(context.Background(), svc)
|
|
if err != nil {
|
|
t.Fatalf("getUserTimezone: %v", err)
|
|
}
|
|
|
|
if loc != time.UTC {
|
|
t.Fatalf("expected UTC, got %v", loc)
|
|
}
|
|
}
|
|
|
|
func TestGetUserTimezoneInvalid(t *testing.T) {
|
|
svc := newCalendarServiceWithTimezone(t, "Bad/Zone")
|
|
if _, err := getUserTimezone(context.Background(), svc); err == nil {
|
|
t.Fatalf("expected error")
|
|
}
|
|
}
|
|
|
|
func TestGetUserTimezonePrimaryAlias404FallsBackToCalendarsGet(t *testing.T) {
|
|
svc, closeServer := newTestCalendarService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.Contains(r.URL.Path, "/calendarList/primary") && r.Method == http.MethodGet:
|
|
http.NotFound(w, r)
|
|
case r.URL.Path == "/calendars/primary" && r.Method == http.MethodGet:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "primary",
|
|
"timeZone": "Europe/London",
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer closeServer()
|
|
|
|
loc, err := getUserTimezone(context.Background(), svc)
|
|
if err != nil {
|
|
t.Fatalf("getUserTimezone: %v", err)
|
|
}
|
|
if got := loc.String(); got != "Europe/London" {
|
|
t.Fatalf("expected direct fallback timezone, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestGetUserTimezoneCalendarListFallbackPrefersPrimaryAndSkipsInvalid(t *testing.T) {
|
|
svc, closeServer := newTestCalendarService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.Contains(r.URL.Path, "/calendarList/primary") && r.Method == http.MethodGet:
|
|
http.NotFound(w, r)
|
|
case r.URL.Path == "/calendars/primary" && r.Method == http.MethodGet:
|
|
http.NotFound(w, r)
|
|
case strings.Contains(r.URL.Path, "/users/me/calendarList") && r.Method == http.MethodGet:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"items": []map[string]any{
|
|
{"id": "broken", "timeZone": "Bad/Zone"},
|
|
{"id": "fallback", "timeZone": "America/New_York"},
|
|
{"id": "primary", "primary": true, "timeZone": "Europe/London"},
|
|
},
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer closeServer()
|
|
|
|
loc, err := getUserTimezone(context.Background(), svc)
|
|
if err != nil {
|
|
t.Fatalf("getUserTimezone: %v", err)
|
|
}
|
|
if got := loc.String(); got != "Europe/London" {
|
|
t.Fatalf("expected primary fallback timezone, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveTimeRangeWithDefaultsToTomorrowEndOfDay(t *testing.T) {
|
|
svc := newCalendarServiceWithTimezone(t, "UTC")
|
|
flags := TimeRangeFlags{
|
|
From: "2025-01-05T10:00:00Z",
|
|
To: "tomorrow",
|
|
}
|
|
|
|
// Capture now BEFORE calling the function to avoid midnight boundary flakiness
|
|
now := time.Now().In(time.UTC)
|
|
|
|
tr, err := ResolveTimeRangeWithDefaults(context.Background(), svc, flags, TimeRangeDefaults{})
|
|
if err != nil {
|
|
t.Fatalf("ResolveTimeRangeWithDefaults: %v", err)
|
|
}
|
|
|
|
expectedFrom := time.Date(2025, 1, 5, 10, 0, 0, 0, time.UTC)
|
|
if !tr.From.Equal(expectedFrom) {
|
|
t.Fatalf("unexpected from: %v", tr.From)
|
|
}
|
|
|
|
// "tomorrow" is relative to now, so we calculate expected tomorrow
|
|
expectedTomorrow := now.AddDate(0, 0, 1)
|
|
// endOfDay returns start of next day, so for tomorrow that's day after tomorrow
|
|
expectedTo := time.Date(expectedTomorrow.Year(), expectedTomorrow.Month(), expectedTomorrow.Day()+1, 0, 0, 0, 0, time.UTC)
|
|
|
|
if !tr.To.Equal(expectedTo) {
|
|
t.Fatalf("expected --to tomorrow to expand to end-of-day %v, got %v", expectedTo, tr.To)
|
|
}
|
|
}
|
|
|
|
func TestResolveTimeRangeWithDefaultsToNowNoExpansion(t *testing.T) {
|
|
svc := newCalendarServiceWithTimezone(t, "UTC")
|
|
flags := TimeRangeFlags{
|
|
From: "2025-01-05T10:00:00Z",
|
|
To: "now",
|
|
}
|
|
|
|
before := time.Now().In(time.UTC)
|
|
tr, err := ResolveTimeRangeWithDefaults(context.Background(), svc, flags, TimeRangeDefaults{})
|
|
if err != nil {
|
|
t.Fatalf("ResolveTimeRangeWithDefaults: %v", err)
|
|
}
|
|
after := time.Now().In(time.UTC)
|
|
|
|
expectedFrom := time.Date(2025, 1, 5, 10, 0, 0, 0, time.UTC)
|
|
if !tr.From.Equal(expectedFrom) {
|
|
t.Fatalf("unexpected from: %v", tr.From)
|
|
}
|
|
|
|
// "now" should NOT be expanded to end-of-day; it should be the current time
|
|
if tr.To.Before(before) || tr.To.After(after) {
|
|
t.Fatalf("expected --to now to be current time (between %v and %v), got %v", before, after, tr.To)
|
|
}
|
|
|
|
// Verify it's NOT midnight of next day (the new endOfDay)
|
|
if tr.To.Hour() == 0 && tr.To.Minute() == 0 && tr.To.Second() == 0 && tr.To.Nanosecond() == 0 {
|
|
t.Fatalf("expected --to now NOT to expand to end-of-day, but got %v", tr.To)
|
|
}
|
|
}
|
|
|
|
func TestResolveTimeRangeWithDefaultsToMondayEndOfDay(t *testing.T) {
|
|
svc := newCalendarServiceWithTimezone(t, "UTC")
|
|
flags := TimeRangeFlags{
|
|
From: "2025-01-05T10:00:00Z",
|
|
To: "monday",
|
|
}
|
|
|
|
// Capture now BEFORE calling the function to avoid midnight boundary flakiness
|
|
now := time.Now().In(time.UTC)
|
|
|
|
tr, err := ResolveTimeRangeWithDefaults(context.Background(), svc, flags, TimeRangeDefaults{})
|
|
if err != nil {
|
|
t.Fatalf("ResolveTimeRangeWithDefaults: %v", err)
|
|
}
|
|
|
|
expectedFrom := time.Date(2025, 1, 5, 10, 0, 0, 0, time.UTC)
|
|
if !tr.From.Equal(expectedFrom) {
|
|
t.Fatalf("unexpected from: %v", tr.From)
|
|
}
|
|
|
|
// "monday" is relative to now, so we calculate expected Monday
|
|
// parseWeekday returns the upcoming Monday (or today if already Monday)
|
|
currentDay := now.Weekday()
|
|
daysUntil := int(time.Monday) - int(currentDay)
|
|
if daysUntil < 0 {
|
|
daysUntil += 7
|
|
}
|
|
expectedMonday := now.AddDate(0, 0, daysUntil)
|
|
// endOfDay returns start of next day
|
|
expectedTo := time.Date(expectedMonday.Year(), expectedMonday.Month(), expectedMonday.Day()+1, 0, 0, 0, 0, time.UTC)
|
|
|
|
if !tr.To.Equal(expectedTo) {
|
|
t.Fatalf("expected --to monday to expand to end-of-day %v, got %v", expectedTo, tr.To)
|
|
}
|
|
}
|
|
|
|
func TestIsDayExpr(t *testing.T) {
|
|
loc := time.UTC
|
|
// Use a fixed reference time: Wednesday, January 15, 2025
|
|
now := time.Date(2025, 1, 15, 10, 30, 0, 0, loc)
|
|
|
|
tests := []struct {
|
|
name string
|
|
expr string
|
|
want bool
|
|
}{
|
|
// Relative day keywords -> true
|
|
{"today", "today", true},
|
|
{"tomorrow", "tomorrow", true},
|
|
{"yesterday", "yesterday", true},
|
|
{"today uppercase", "TODAY", true},
|
|
{"today mixed case", "ToDay", true},
|
|
|
|
// "now" is a precise moment -> false
|
|
{"now", "now", false},
|
|
{"now uppercase", "NOW", false},
|
|
|
|
// Weekday names -> true
|
|
{"monday", "monday", true},
|
|
{"tuesday", "tuesday", true},
|
|
{"wednesday", "wednesday", true},
|
|
{"thursday", "thursday", true},
|
|
{"friday", "friday", true},
|
|
{"saturday", "saturday", true},
|
|
{"sunday", "sunday", true},
|
|
{"mon abbreviation", "mon", true},
|
|
{"tue abbreviation", "tue", true},
|
|
{"wed abbreviation", "wed", true},
|
|
{"thu abbreviation", "thu", true},
|
|
{"fri abbreviation", "fri", true},
|
|
{"sat abbreviation", "sat", true},
|
|
{"sun abbreviation", "sun", true},
|
|
{"Monday uppercase", "MONDAY", true},
|
|
{"next monday", "next monday", true},
|
|
{"next tuesday", "next tuesday", true},
|
|
|
|
// ISO date (YYYY-MM-DD) -> true
|
|
{"iso date", "2025-01-05", true},
|
|
{"iso date future", "2026-12-31", true},
|
|
{"iso date past", "2020-01-01", true},
|
|
|
|
// RFC3339 timestamps -> false (precise moment, not a day)
|
|
{"rfc3339 utc", "2025-01-05T10:00:00Z", false},
|
|
{"rfc3339 offset", "2025-01-05T10:00:00-08:00", false},
|
|
{"rfc3339 positive offset", "2025-01-05T10:00:00+05:30", false},
|
|
|
|
// ISO 8601 with numeric timezone (no colon) -> false
|
|
{"iso8601 no colon", "2025-01-05T10:00:00-0800", false},
|
|
|
|
// Date with time but no timezone -> false (has time component)
|
|
{"datetime no tz", "2025-01-05T15:04:05", false},
|
|
{"datetime space separator", "2025-01-05 15:04", false},
|
|
|
|
// Empty string -> false
|
|
{"empty string", "", false},
|
|
{"whitespace only", " ", false},
|
|
|
|
// Invalid expressions -> false
|
|
{"invalid word", "notaday", false},
|
|
{"invalid format", "01-05-2025", false},
|
|
{"partial date", "2025-01", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := isDayExpr(tt.expr, now, loc)
|
|
if got != tt.want {
|
|
t.Errorf("isDayExpr(%q) = %v, want %v", tt.expr, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|