From e72305237e16a8c32fb017261cce129a25d70e65 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Tue, 17 Mar 2026 20:03:54 +0000 Subject: test: add Google Calendar unit tests for pure functions 11 tests covering deduplicateEvents (dedup key, ordering, empty input), parseEventTime (RFC3339, date-only, empty, no-timezone), and GetUpcomingEvents (normal, empty, API error fallback) via httptest mock. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/google_calendar_test.go | 274 +++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 internal/api/google_calendar_test.go (limited to 'internal') diff --git a/internal/api/google_calendar_test.go b/internal/api/google_calendar_test.go new file mode 100644 index 0000000..3cf0dbd --- /dev/null +++ b/internal/api/google_calendar_test.go @@ -0,0 +1,274 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "task-dashboard/internal/models" + + "google.golang.org/api/calendar/v3" + "google.golang.org/api/option" +) + +// redirectingTransport rewrites all outgoing requests to a local test server, +// preserving the original path and query so the Google API library stays unaware. +type redirectingTransport struct { + server *httptest.Server +} + +func (rt *redirectingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := req.Clone(req.Context()) + req2.URL.Scheme = "http" + req2.URL.Host = rt.server.Listener.Addr().String() + return http.DefaultTransport.RoundTrip(req2) +} + +func newTestGoogleCalendarClient(t *testing.T, server *httptest.Server, calendarIDs []string, tz *time.Location) *GoogleCalendarClient { + t.Helper() + if tz == nil { + tz = time.UTC + } + httpClient := &http.Client{Transport: &redirectingTransport{server: server}} + srv, err := calendar.NewService(context.Background(), + option.WithHTTPClient(httpClient), + option.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("failed to create test calendar service: %v", err) + } + return &GoogleCalendarClient{ + srv: srv, + calendarIDs: calendarIDs, + displayTZ: tz, + } +} + +// newCalendarClientWithTZ builds a minimal client for testing parseEventTime. +// No srv needed since parseEventTime is a pure transformation. +func newCalendarClientWithTZ(tz *time.Location) *GoogleCalendarClient { + if tz == nil { + tz = time.UTC + } + return &GoogleCalendarClient{displayTZ: tz} +} + +// --- deduplicateEvents --- + +func TestDeduplicateEvents_RemovesDuplicates(t *testing.T) { + start := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) + events := []models.CalendarEvent{ + {ID: "event1", Summary: "Meeting", Start: start}, + {ID: "event2", Summary: "Meeting", Start: start}, // same summary+start → duplicate + } + got := deduplicateEvents(events) + if len(got) != 1 { + t.Fatalf("expected 1 event, got %d", len(got)) + } + // First occurrence is kept + if got[0].ID != "event1" { + t.Errorf("expected first occurrence kept (ID %q), got ID %q", "event1", got[0].ID) + } +} + +func TestDeduplicateEvents_DifferentSummaryOrTimeNotDeduplicated(t *testing.T) { + t1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) + t2 := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC) + events := []models.CalendarEvent{ + {ID: "a", Summary: "Meeting", Start: t1}, + {ID: "b", Summary: "Lunch", Start: t1}, // different summary + {ID: "c", Summary: "Meeting", Start: t2}, // different start time + } + got := deduplicateEvents(events) + if len(got) != 3 { + t.Fatalf("expected 3 events, got %d", len(got)) + } +} + +func TestDeduplicateEvents_SortsByStartTime(t *testing.T) { + later := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC) + earlier := time.Date(2026, 3, 17, 8, 0, 0, 0, time.UTC) + events := []models.CalendarEvent{ + {ID: "later", Summary: "Later", Start: later}, + {ID: "earlier", Summary: "Earlier", Start: earlier}, + } + got := deduplicateEvents(events) + if len(got) != 2 { + t.Fatalf("expected 2 events, got %d", len(got)) + } + if got[0].ID != "earlier" { + t.Errorf("expected earlier event first, got ID %q", got[0].ID) + } + if got[1].ID != "later" { + t.Errorf("expected later event second, got ID %q", got[1].ID) + } +} + +func TestDeduplicateEvents_EmptyInput(t *testing.T) { + if got := deduplicateEvents(nil); len(got) != 0 { + t.Errorf("expected empty result for nil input, got %d events", len(got)) + } +} + +// --- parseEventTime --- + +func TestParseEventTime_RFC3339DateTime(t *testing.T) { + c := newCalendarClientWithTZ(time.UTC) + item := &calendar.Event{ + Start: &calendar.EventDateTime{DateTime: "2026-03-17T10:00:00-10:00"}, + End: &calendar.EventDateTime{DateTime: "2026-03-17T11:00:00-10:00"}, + } + start, end := c.parseEventTime(item) + + // UTC-10 offset: 10:00 local = 20:00 UTC + wantStart := time.Date(2026, 3, 17, 20, 0, 0, 0, time.UTC) + wantEnd := time.Date(2026, 3, 17, 21, 0, 0, 0, time.UTC) + if !start.Equal(wantStart) { + t.Errorf("start: got %v, want %v", start, wantStart) + } + if !end.Equal(wantEnd) { + t.Errorf("end: got %v, want %v", end, wantEnd) + } +} + +func TestParseEventTime_AllDayEvent(t *testing.T) { + tz, _ := time.LoadLocation("Pacific/Honolulu") // UTC-10 + c := newCalendarClientWithTZ(tz) + item := &calendar.Event{ + Start: &calendar.EventDateTime{Date: "2026-03-17"}, + End: &calendar.EventDateTime{Date: "2026-03-18"}, + } + start, end := c.parseEventTime(item) + + wantStart := time.Date(2026, 3, 17, 0, 0, 0, 0, tz) + wantEnd := time.Date(2026, 3, 18, 0, 0, 0, 0, tz) + if !start.Equal(wantStart) { + t.Errorf("start: got %v, want %v", start, wantStart) + } + if !end.Equal(wantEnd) { + t.Errorf("end: got %v, want %v", end, wantEnd) + } +} + +func TestParseEventTime_EmptyDateTime(t *testing.T) { + c := newCalendarClientWithTZ(time.UTC) + item := &calendar.Event{ + Start: &calendar.EventDateTime{}, + End: &calendar.EventDateTime{}, + } + start, end := c.parseEventTime(item) + if !start.IsZero() { + t.Errorf("expected zero start time for empty event, got %v", start) + } + if !end.IsZero() { + t.Errorf("expected zero end time for empty event, got %v", end) + } +} + +func TestParseEventTime_DateTimeWithoutTimezone_UsesEventTZ(t *testing.T) { + tz, _ := time.LoadLocation("America/New_York") + c := newCalendarClientWithTZ(tz) + item := &calendar.Event{ + // DateTime without offset — library passes TimeZone alongside it + Start: &calendar.EventDateTime{DateTime: "2026-03-17T10:00:00", TimeZone: "America/New_York"}, + End: &calendar.EventDateTime{DateTime: "2026-03-17T11:00:00", TimeZone: "America/New_York"}, + } + start, end := c.parseEventTime(item) + + wantStart := time.Date(2026, 3, 17, 10, 0, 0, 0, tz) + wantEnd := time.Date(2026, 3, 17, 11, 0, 0, 0, tz) + if !start.Equal(wantStart) { + t.Errorf("start: got %v, want %v", start, wantStart) + } + if !end.Equal(wantEnd) { + t.Errorf("end: got %v, want %v", end, wantEnd) + } +} + +// --- GetUpcomingEvents --- + +func calendarAPIResponse(items []map[string]interface{}) string { + b, _ := json.Marshal(map[string]interface{}{ + "kind": "calendar#events", + "items": items, + }) + return string(b) +} + +func TestGetUpcomingEvents_NormalResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/events") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + body := calendarAPIResponse([]map[string]interface{}{ + { + "id": "event1", + "summary": "Team Meeting", + "htmlLink": "https://calendar.google.com/event?id=event1", + "start": map[string]string{"dateTime": "2026-03-17T10:00:00Z"}, + "end": map[string]string{"dateTime": "2026-03-17T11:00:00Z"}, + }, + }) + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + client := newTestGoogleCalendarClient(t, server, []string{"primary"}, time.UTC) + events, err := client.GetUpcomingEvents(context.Background(), 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Summary != "Team Meeting" { + t.Errorf("expected summary %q, got %q", "Team Meeting", events[0].Summary) + } + if events[0].ID != "event1" { + t.Errorf("expected ID %q, got %q", "event1", events[0].ID) + } + wantStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) + if !events[0].Start.Equal(wantStart) { + t.Errorf("start: got %v, want %v", events[0].Start, wantStart) + } +} + +func TestGetUpcomingEvents_EmptyResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"kind":"calendar#events","items":[]}`)) + })) + defer server.Close() + + client := newTestGoogleCalendarClient(t, server, []string{"primary"}, time.UTC) + events, err := client.GetUpcomingEvents(context.Background(), 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 0 { + t.Errorf("expected 0 events, got %d", len(events)) + } +} + +func TestGetUpcomingEvents_APIError_ReturnsEmptyNotError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + })) + defer server.Close() + + client := newTestGoogleCalendarClient(t, server, []string{"primary"}, time.UTC) + events, err := client.GetUpcomingEvents(context.Background(), 10) + // Calendar errors are logged and skipped — the function never returns an error. + if err != nil { + t.Fatalf("expected nil error on calendar fetch failure, got: %v", err) + } + if len(events) != 0 { + t.Errorf("expected 0 events on API error, got %d", len(events)) + } +} -- cgit v1.2.3