diff options
| -rw-r--r-- | internal/api/google_tasks_test.go | 187 | ||||
| -rw-r--r-- | internal/handlers/handlers_test.go | 383 | ||||
| -rw-r--r-- | internal/store/sqlite_test.go | 37 |
3 files changed, 607 insertions, 0 deletions
diff --git a/internal/api/google_tasks_test.go b/internal/api/google_tasks_test.go new file mode 100644 index 0000000..fbfdd63 --- /dev/null +++ b/internal/api/google_tasks_test.go @@ -0,0 +1,187 @@ +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) + } +} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 9a2287f..e30c6d5 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -2955,3 +2955,386 @@ func TestHandleSyncSources_AddsLogEntry(t *testing.T) { t.Errorf("Expected event_type 'sync', got %q", entries[0].EventType) } } + +// ============================================================================= +// HandleTabPlanning data tests +// ============================================================================= + +// TestHandleTabPlanning_HappyPath verifies that tasks, events, and cards are +// placed into the correct planning sections (scheduled/unscheduled/upcoming). +func TestHandleTabPlanning_HappyPath(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + today := config.Today() + + // Task at 10am today (has time) → goes to Scheduled + taskWithTime := today.Add(10 * time.Hour) + // Task at midnight today (no time component) → goes to Unscheduled + taskNoTime := today + // Task 2 days from now → goes to Upcoming (today+4 is the upper bound) + taskUpcoming := today.AddDate(0, 0, 2) + + tasks := []models.Task{ + {ID: "t-sched", Content: "Scheduled Task", DueDate: &taskWithTime, Labels: []string{}, CreatedAt: time.Now()}, + {ID: "t-unsched", Content: "Unscheduled Task", DueDate: &taskNoTime, Labels: []string{}, CreatedAt: time.Now()}, + {ID: "t-upcoming", Content: "Upcoming Task", DueDate: &taskUpcoming, Labels: []string{}, CreatedAt: time.Now()}, + } + if err := db.SaveTasks(tasks); err != nil { + t.Fatalf("Failed to save tasks: %v", err) + } + + // Card at 2pm today → goes to Scheduled + cardWithTime := today.Add(14 * time.Hour) + boards := []models.Board{{ + ID: "board1", + Name: "Board 1", + Cards: []models.Card{ + {ID: "c-sched", Name: "Scheduled Card", DueDate: &cardWithTime, URL: "http://trello.com/c1"}, + }, + }} + if err := db.SaveBoards(boards); err != nil { + t.Fatalf("Failed to save boards: %v", err) + } + + renderer := NewMockRenderer() + h := &Handler{ + store: db, + renderer: renderer, + config: &config.Config{CacheTTLMinutes: 5}, + } + + req := httptest.NewRequest("GET", "/tabs/planning", nil) + w := httptest.NewRecorder() + h.HandleTabPlanning(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", w.Code) + } + + var planningCall *RenderCall + for i, call := range renderer.Calls { + if call.Name == "planning-tab" { + c := renderer.Calls[i] + planningCall = &c + break + } + } + if planningCall == nil { + t.Fatal("Expected planning-tab to be rendered") + } + + data, ok := planningCall.Data.(struct { + Scheduled []ScheduledItem + Unscheduled []models.Atom + Upcoming []ScheduledItem + Boards []models.Board + Today string + }) + if !ok { + t.Fatalf("Expected planning data struct, got %T", planningCall.Data) + } + + // t-sched (10am) and c-sched (2pm) should be in Scheduled + var foundTaskSched, foundCardSched bool + for _, s := range data.Scheduled { + if s.ID == "t-sched" { + foundTaskSched = true + } + if s.ID == "c-sched" { + foundCardSched = true + } + } + if !foundTaskSched { + t.Error("Expected t-sched (task with time today) in Scheduled") + } + if !foundCardSched { + t.Error("Expected c-sched (card with time today) in Scheduled") + } + + // t-unsched (midnight, no time) should be in Unscheduled + var foundUnsched bool + for _, u := range data.Unscheduled { + if u.ID == "t-unsched" { + foundUnsched = true + } + } + if !foundUnsched { + t.Error("Expected t-unsched (midnight task) in Unscheduled") + } + + // t-upcoming (2 days out) should be in Upcoming + var foundUpcoming bool + for _, u := range data.Upcoming { + if u.ID == "t-upcoming" { + foundUpcoming = true + } + } + if !foundUpcoming { + t.Error("Expected t-upcoming (task in 2 days) in Upcoming") + } +} + +// TestHandleTabPlanning_TomorrowBoundary verifies that a task due exactly at +// midnight of "tomorrow" is NOT before tomorrow, so it lands in Upcoming. +func TestHandleTabPlanning_TomorrowBoundary(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + tomorrow := config.Today().AddDate(0, 0, 1) // midnight tomorrow + tasks := []models.Task{ + {ID: "t-boundary", Content: "Midnight Tomorrow", DueDate: &tomorrow, Labels: []string{}, CreatedAt: time.Now()}, + } + if err := db.SaveTasks(tasks); err != nil { + t.Fatalf("Failed to save tasks: %v", err) + } + + renderer := NewMockRenderer() + h := &Handler{ + store: db, + renderer: renderer, + config: &config.Config{CacheTTLMinutes: 5}, + } + + req := httptest.NewRequest("GET", "/tabs/planning", nil) + w := httptest.NewRecorder() + h.HandleTabPlanning(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d", w.Code) + } + + var planningCall *RenderCall + for i, call := range renderer.Calls { + if call.Name == "planning-tab" { + c := renderer.Calls[i] + planningCall = &c + break + } + } + if planningCall == nil { + t.Fatal("Expected planning-tab to be rendered") + } + + data, ok := planningCall.Data.(struct { + Scheduled []ScheduledItem + Unscheduled []models.Atom + Upcoming []ScheduledItem + Boards []models.Board + Today string + }) + if !ok { + t.Fatalf("Expected planning data struct, got %T", planningCall.Data) + } + + // midnight-tomorrow: !dueDate.Before(tomorrow) → not in scheduled/unscheduled + for _, s := range data.Scheduled { + if s.ID == "t-boundary" { + t.Error("Midnight-tomorrow task must not appear in Scheduled") + } + } + for _, u := range data.Unscheduled { + if u.ID == "t-boundary" { + t.Error("Midnight-tomorrow task must not appear in Unscheduled") + } + } + // dueDate.Before(in3Days=today+4) → lands in Upcoming + var foundUpcoming bool + for _, u := range data.Upcoming { + if u.ID == "t-boundary" { + foundUpcoming = true + } + } + if !foundUpcoming { + t.Error("Expected midnight-tomorrow task in Upcoming") + } +} + +// ============================================================================= +// HandleTabMeals grouping test +// ============================================================================= + +// TestHandleTabMeals_GroupingMergesRecipes verifies that multiple meals sharing +// the same date+mealType are combined into a single CombinedMeal entry whose +// RecipeNames contains all merged recipe names. +func TestHandleTabMeals_GroupingMergesRecipes(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + today := config.Today() + meals := []models.Meal{ + {ID: "m1", RecipeName: "Pasta", Date: today, MealType: "dinner", RecipeURL: "http://example.com/pasta"}, + {ID: "m2", RecipeName: "Salad", Date: today, MealType: "dinner", RecipeURL: "http://example.com/salad"}, + {ID: "m3", RecipeName: "Oatmeal", Date: today, MealType: "breakfast", RecipeURL: "http://example.com/oatmeal"}, + } + if err := db.SaveMeals(meals); err != nil { + t.Fatalf("Failed to save meals: %v", err) + } + + renderer := NewMockRenderer() + h := &Handler{ + store: db, + renderer: renderer, + config: &config.Config{CacheTTLMinutes: 5}, + } + + req := httptest.NewRequest("GET", "/tabs/meals", nil) + w := httptest.NewRecorder() + h.HandleTabMeals(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", w.Code) + } + + var mealsCall *RenderCall + for i, call := range renderer.Calls { + if call.Name == "meals-tab" { + c := renderer.Calls[i] + mealsCall = &c + break + } + } + if mealsCall == nil { + t.Fatal("Expected meals-tab to be rendered") + } + + data, ok := mealsCall.Data.(struct{ Meals []CombinedMeal }) + if !ok { + t.Fatalf("Expected meals data struct, got %T", mealsCall.Data) + } + + // m1 + m2 share date+dinner → 1 CombinedMeal; m3 is breakfast → 1 CombinedMeal + if len(data.Meals) != 2 { + t.Errorf("Expected 2 combined meals, got %d", len(data.Meals)) + } + + var dinner *CombinedMeal + for i := range data.Meals { + if data.Meals[i].MealType == "dinner" { + dinner = &data.Meals[i] + break + } + } + if dinner == nil { + t.Fatal("Expected a dinner CombinedMeal") + } + if len(dinner.RecipeNames) != 2 { + t.Errorf("Expected dinner to have 2 merged recipes, got %d: %v", len(dinner.RecipeNames), dinner.RecipeNames) + } +} + +// ============================================================================= +// HandleGetBugs and HandleReportBug data tests +// ============================================================================= + +// TestHandleGetBugs_ReturnsBugList verifies that the bugs template receives a +// non-empty bug list when bugs exist in the store. +func TestHandleGetBugs_ReturnsBugList(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + _ = h.store.SaveBug("login button not working") + _ = h.store.SaveBug("dashboard shows wrong date") + + req := httptest.NewRequest("GET", "/bugs", nil) + w := httptest.NewRecorder() + h.HandleGetBugs(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", w.Code) + } + + mr := h.renderer.(*MockRenderer) + var found bool + for _, call := range mr.Calls { + if call.Name == "bugs" { + found = true + bugs, ok := call.Data.([]store.Bug) + if !ok { + t.Fatalf("Expected []store.Bug data, got %T", call.Data) + } + if len(bugs) != 2 { + t.Errorf("Expected 2 bugs in template data, got %d", len(bugs)) + } + break + } + } + if !found { + t.Error("Expected renderer called with 'bugs' template") + } +} + +// TestHandleGetBugs_EmptyList verifies that the bugs template receives an +// empty (non-nil) slice when no bugs exist. +func TestHandleGetBugs_EmptyList(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + req := httptest.NewRequest("GET", "/bugs", nil) + w := httptest.NewRecorder() + h.HandleGetBugs(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", w.Code) + } + + mr := h.renderer.(*MockRenderer) + var found bool + for _, call := range mr.Calls { + if call.Name == "bugs" { + found = true + bugs, ok := call.Data.([]store.Bug) + if !ok { + t.Fatalf("Expected []store.Bug data, got %T", call.Data) + } + if len(bugs) != 0 { + t.Errorf("Expected 0 bugs in template data, got %d", len(bugs)) + } + break + } + } + if !found { + t.Error("Expected renderer called with 'bugs' template") + } +} + +// TestHandleReportBug_Success verifies that a valid bug report is saved and the +// updated bugs list is re-rendered. +func TestHandleReportBug_Success(t *testing.T) { + h, cleanup := setupTestHandler(t) + defer cleanup() + + req := httptest.NewRequest("POST", "/report-bug", strings.NewReader("description=button+broken")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.HandleReportBug(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", w.Code) + } + + // Verify bug was saved to store + bugs, err := h.store.GetBugs() + if err != nil { + t.Fatalf("GetBugs failed: %v", err) + } + if len(bugs) != 1 { + t.Fatalf("Expected 1 bug saved, got %d", len(bugs)) + } + if bugs[0].Description != "button broken" { + t.Errorf("Expected description 'button broken', got %q", bugs[0].Description) + } + + // Verify renderer was called with "bugs" template (HandleGetBugs is called internally) + mr := h.renderer.(*MockRenderer) + var bugsRendered bool + for _, call := range mr.Calls { + if call.Name == "bugs" { + bugsRendered = true + break + } + } + if !bugsRendered { + t.Error("Expected 'bugs' template to be rendered after successful report") + } +} diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index 9c56252..c6c428a 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -1084,6 +1084,43 @@ func TestCompletedTasks_Limit(t *testing.T) { } } +// TestGetCompletedTasks_CompletedAtIsParsed inserts a row with a known +// completed_at string and verifies that CompletedAt is parsed correctly (not +// left as the zero value). +func TestGetCompletedTasks_CompletedAtIsParsed(t *testing.T) { + s := setupTestStoreWithCompletedTasks(t) + defer func() { _ = s.Close() }() + + // Insert directly with a known timestamp in the format GetCompletedTasks parses. + knownTimestamp := "2026-03-15 14:30:00" + _, err := s.db.Exec( + `INSERT INTO completed_tasks (source, source_id, title, completed_at) VALUES (?, ?, ?, ?)`, + "todoist", "task-ts-test", "Timestamp Test Task", knownTimestamp, + ) + if err != nil { + t.Fatalf("Failed to insert task: %v", err) + } + + tasks, err := s.GetCompletedTasks(10) + if err != nil { + t.Fatalf("GetCompletedTasks failed: %v", err) + } + if len(tasks) != 1 { + t.Fatalf("Expected 1 task, got %d", len(tasks)) + } + + got := tasks[0].CompletedAt + if got.IsZero() { + t.Fatal("Expected CompletedAt to be parsed, got zero time") + } + + // GetCompletedTasks parses with "2006-01-02 15:04:05" (no timezone → UTC). + want := time.Date(2026, 3, 15, 14, 30, 0, 0, time.UTC) + if !got.Equal(want) { + t.Errorf("CompletedAt: got %v, want %v", got, want) + } +} + // ============================================================================= // Source Configuration Tests // ============================================================================= |
