package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "google.golang.org/api/option" gtasks "google.golang.org/api/tasks/v1" ) func newTestGoogleTasksClient(t *testing.T, server *httptest.Server, tasklistID string, tz *time.Location) *GoogleTasksClient { t.Helper() if tz == nil { tz = time.UTC } httpClient := &http.Client{Transport: &redirectingTransport{server: server}} srv, err := gtasks.NewService(context.Background(), option.WithHTTPClient(httpClient), option.WithoutAuthentication(), ) if err != nil { t.Fatalf("failed to create test tasks service: %v", err) } return &GoogleTasksClient{ srv: srv, tasklistID: tasklistID, displayTZ: tz, } } func tasksAPIResponse(items []map[string]interface{}) string { b, _ := json.Marshal(map[string]interface{}{ "kind": "tasks#tasks", "items": items, }) return string(b) } // newTasksServer returns an httptest.Server that serves a fixed tasks JSON body. func newTasksServer(body string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.URL.Path, "/lists/") { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(body)) })) } // --- GetTasksByDateRange boundary tests --- func TestGetTasksByDateRange_StartBoundaryIncluded(t *testing.T) { // A task due exactly on the start date should be included (inclusive lower bound). start := time.Date(2026, 3, 17, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) body := tasksAPIResponse([]map[string]interface{}{ { "id": "task-start", "title": "Task on start date", "status": "needsAction", "due": "2026-03-17T00:00:00Z", "updated": "2026-03-17T00:00:00Z", }, }) server := newTasksServer(body) defer server.Close() client := newTestGoogleTasksClient(t, server, "@default", time.UTC) tasks, err := client.GetTasksByDateRange(context.Background(), start, end) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(tasks) != 1 { t.Fatalf("expected 1 task (start boundary included), got %d", len(tasks)) } if tasks[0].ID != "task-start" { t.Errorf("expected task-start, got %s", tasks[0].ID) } } func TestGetTasksByDateRange_EndBoundaryExcluded(t *testing.T) { // A task due exactly on the end date should be excluded (exclusive upper bound). start := time.Date(2026, 3, 17, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) body := tasksAPIResponse([]map[string]interface{}{ { "id": "task-end", "title": "Task on end date", "status": "needsAction", "due": "2026-03-20T00:00:00Z", "updated": "2026-03-17T00:00:00Z", }, }) server := newTasksServer(body) defer server.Close() client := newTestGoogleTasksClient(t, server, "@default", time.UTC) tasks, err := client.GetTasksByDateRange(context.Background(), start, end) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(tasks) != 0 { t.Fatalf("expected 0 tasks (end boundary excluded), got %d", len(tasks)) } } func TestGetTasksByDateRange_NoDueDateAlwaysIncluded(t *testing.T) { // Tasks without a due date are always included regardless of the range. start := time.Date(2026, 3, 17, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) body := tasksAPIResponse([]map[string]interface{}{ { "id": "task-no-due", "title": "No due date", "status": "needsAction", "updated": "2026-03-17T00:00:00Z", // no "due" field }, }) server := newTasksServer(body) defer server.Close() client := newTestGoogleTasksClient(t, server, "@default", time.UTC) tasks, err := client.GetTasksByDateRange(context.Background(), start, end) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(tasks) != 1 { t.Fatalf("expected 1 task (no due date always included), got %d", len(tasks)) } if tasks[0].DueDate != nil { t.Errorf("expected nil DueDate, got %v", tasks[0].DueDate) } } func TestGetTasksByDateRange_OutOfRangeExcluded(t *testing.T) { // A task before start or after end should be excluded. start := time.Date(2026, 3, 17, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) body := tasksAPIResponse([]map[string]interface{}{ { "id": "task-before", "title": "Task before range", "status": "needsAction", "due": "2026-03-16T00:00:00Z", "updated": "2026-03-17T00:00:00Z", }, { "id": "task-after", "title": "Task after range", "status": "needsAction", "due": "2026-03-21T00:00:00Z", "updated": "2026-03-17T00:00:00Z", }, { "id": "task-in-range", "title": "Task in range", "status": "needsAction", "due": "2026-03-18T00:00:00Z", "updated": "2026-03-17T00:00:00Z", }, }) server := newTasksServer(body) defer server.Close() client := newTestGoogleTasksClient(t, server, "@default", time.UTC) tasks, err := client.GetTasksByDateRange(context.Background(), start, end) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(tasks) != 1 { t.Fatalf("expected 1 task (only in-range), got %d", len(tasks)) } if tasks[0].ID != "task-in-range" { t.Errorf("expected task-in-range, got %s", tasks[0].ID) } } // TestGetTasksFromList_DueDateNotShiftedByNegativeOffsetTZ guards against a // regression where a task due "tomorrow" showed up "today" on the widget. // Google Tasks' due field is date-only but always serialized as midnight // UTC. Converting that instant into a negative-offset display timezone // (e.g. Pacific/Honolulu, UTC-10) instead of re-anchoring the same calendar // date to local midnight shifted every dated task back by one day. func TestGetTasksFromList_DueDateNotShiftedByNegativeOffsetTZ(t *testing.T) { honolulu, err := time.LoadLocation("Pacific/Honolulu") if err != nil { t.Skipf("Pacific/Honolulu tzdata not available: %v", err) } body := tasksAPIResponse([]map[string]interface{}{ { "id": "task-tomorrow", "title": "Due tomorrow", "status": "needsAction", "due": "2026-07-29T00:00:00.000Z", "updated": "2026-07-28T00:00:00Z", }, }) server := newTasksServer(body) defer server.Close() client := newTestGoogleTasksClient(t, server, "@default", honolulu) tasks, err := client.GetTasks(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(tasks) != 1 { t.Fatalf("expected 1 task, got %d", len(tasks)) } if tasks[0].DueDate == nil { t.Fatal("expected DueDate to be set") } if y, m, d := tasks[0].DueDate.Date(); y != 2026 || m != time.July || d != 29 { t.Errorf("expected due date 2026-07-29, got %04d-%02d-%02d (task shifted a day earlier than Google reported)", y, m, d) } }