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)) } }