summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/google_tasks_test.go187
-rw-r--r--internal/handlers/handlers_test.go383
-rw-r--r--internal/store/sqlite_test.go37
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
// =============================================================================