summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/api/google_calendar_test.go274
1 files changed, 274 insertions, 0 deletions
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))
+ }
+}