package handlers import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "task-dashboard/internal/models" "task-dashboard/internal/store" ) // mockGoogleTasksClient is a minimal api.GoogleTasksAPI stub for widget handler tests. type mockGoogleTasksClient struct { completedListID, completedTaskID string notesListID, notesTaskID, notes string } func (m *mockGoogleTasksClient) GetTasks(ctx context.Context) ([]models.GoogleTask, error) { return nil, nil } func (m *mockGoogleTasksClient) GetTasksByDateRange(ctx context.Context, start, end time.Time) ([]models.GoogleTask, error) { return nil, nil } func (m *mockGoogleTasksClient) CompleteTask(ctx context.Context, listID, taskID string) error { m.completedListID, m.completedTaskID = listID, taskID return nil } func (m *mockGoogleTasksClient) UncompleteTask(ctx context.Context, listID, taskID string) error { return nil } func (m *mockGoogleTasksClient) UpdateTaskNotes(ctx context.Context, listID, taskID, notes string) error { m.notesListID, m.notesTaskID, m.notes = listID, taskID, notes return nil } func (m *mockGoogleTasksClient) GetTaskLists(ctx context.Context) ([]models.TaskListInfo, error) { return nil, nil } func (m *mockGoogleTasksClient) SetTaskListID(id string) {} // mockTrelloWidgetClient is a minimal api.TrelloAPI stub for widget handler tests // that records UpdateCard calls (the shared mockTrelloClient in handlers_test.go doesn't). type mockTrelloWidgetClient struct { updatedCardID string updates map[string]interface{} } func (m *mockTrelloWidgetClient) GetBoards(ctx context.Context) ([]models.Board, error) { return nil, nil } func (m *mockTrelloWidgetClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) { return nil, nil } func (m *mockTrelloWidgetClient) GetLists(ctx context.Context, boardID string) ([]models.List, error) { return nil, nil } func (m *mockTrelloWidgetClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, error) { return nil, nil } func (m *mockTrelloWidgetClient) CreateCard(ctx context.Context, listID, name, description string, dueDate *time.Time) (*models.Card, error) { return nil, nil } func (m *mockTrelloWidgetClient) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error { m.updatedCardID = cardID m.updates = updates return nil } func TestWidgetAuthMiddleware_NoToken(t *testing.T) { called := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }) h := WidgetAuthMiddleware("secret", inner) req := httptest.NewRequest("GET", "/api/widget", nil) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } if called { t.Fatal("inner handler should not have been called") } } func TestWidgetAuthMiddleware_WrongToken(t *testing.T) { inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) h := WidgetAuthMiddleware("secret", inner) req := httptest.NewRequest("GET", "/api/widget", nil) req.Header.Set("Authorization", "Bearer wrong") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } func TestWidgetAuthMiddleware_CorrectToken(t *testing.T) { called := false inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }) h := WidgetAuthMiddleware("secret", inner) req := httptest.NewRequest("GET", "/api/widget", nil) req.Header.Set("Authorization", "Bearer secret") w := httptest.NewRecorder() h.ServeHTTP(w, req) if !called { t.Fatal("inner handler should have been called") } } func TestTimelineItemToWidgetItem_Task(t *testing.T) { now := time.Now() item := models.TimelineItem{ ID: "abc", Title: "Write notes", Source: "doot", Type: models.TimelineItemTypeTask, Time: now, IsAllDay: true, URL: "https://example.com/task/abc", } wi := TimelineItemToWidgetItem(item) if wi.ID != "abc" { t.Errorf("ID: got %q, want %q", wi.ID, "abc") } if wi.Type != "task" { t.Errorf("Type: got %q, want %q", wi.Type, "task") } if !wi.Completable { t.Error("doot task should be completable") } if wi.Start != nil { t.Error("all-day item should have nil Start") } } func TestTimelineItemToWidgetItem_Event(t *testing.T) { start := time.Now() end := start.Add(time.Hour) item := models.TimelineItem{ ID: "cal1", Title: "Team sync", Source: "calendar", Type: models.TimelineItemTypeEvent, Time: start, EndTime: &end, } wi := TimelineItemToWidgetItem(item) if wi.Type != "event" { t.Errorf("Type: got %q, want %q", wi.Type, "event") } if wi.Completable { t.Error("calendar event should not be completable") } if wi.Start == nil { t.Error("timed event should have non-nil Start") } } // TestTimelineItemToWidgetItem_AllDayEvent proves the 2026-07-12 fix: an // all-day CALENDAR EVENT (Type == event, IsAllDay == true) must get Start // populated with its real date -- previously Start was nil for every // IsAllDay item regardless of type, which meant the widget client's // hourly-slot packer had no date information to pin all-day events to the // top of the correct day and they could silently fall outside the visible // grid range instead. This fixture's End stays nil only because it has no // EndTime set -- see TestTimelineItemToWidgetItem_MultiDayAllDayEvent_ForwardsEnd // for the case where a multi-day all-day event has a real EndTime. func TestTimelineItemToWidgetItem_AllDayEvent(t *testing.T) { day := time.Date(2026, 7, 12, 0, 0, 0, 0, time.Local) item := models.TimelineItem{ ID: "cal-holiday", Title: "Company Holiday", Source: "calendar", Type: models.TimelineItemTypeEvent, Time: day, IsAllDay: true, } wi := TimelineItemToWidgetItem(item) if wi.Type != "event" { t.Errorf("Type: got %q, want %q", wi.Type, "event") } if !wi.IsAllDay { t.Error("expected IsAllDay to be true") } if wi.Start == nil { t.Fatal("all-day event should have non-nil Start (needed to pin it to the correct day)") } if !wi.Start.Equal(day) { t.Errorf("Start = %v, want %v", *wi.Start, day) } if wi.End != nil { t.Error("all-day event should have nil End") } } // TestTimelineItemToWidgetItem_MultiDayAllDayEvent_ForwardsEnd proves that a // genuine all-day CALENDAR EVENT with a real EndTime (e.g. a 3-day // conference with no specific time) still gets End forwarded to the widget // client, even though IsAllDay is true -- needed so the client can detect // the multi-day span and render "starts"/"ends" labels per day. func TestTimelineItemToWidgetItem_MultiDayAllDayEvent_ForwardsEnd(t *testing.T) { start := time.Date(2026, 7, 12, 0, 0, 0, 0, time.Local) end := time.Date(2026, 7, 15, 0, 0, 0, 0, time.Local) item := models.TimelineItem{ ID: "cal-conference", Title: "Offsite", Source: "calendar", Type: models.TimelineItemTypeEvent, Time: start, EndTime: &end, IsAllDay: true, } wi := TimelineItemToWidgetItem(item) if wi.End == nil { t.Fatal("expected End to be forwarded for a multi-day all-day event") } if !wi.End.Equal(end) { t.Errorf("End = %v, want %v", *wi.End, end) } } // TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior proves the // same fix does NOT change behavior for undated doot/gtask tasks, which are // also flagged IsAllDay as a "no specific time" fallback (see // TimelineItem.ComputeDaySection) but are a different concept from a real // all-day calendar event -- they must keep the existing nil-Start // "floating" treatment so the hourly-slot packer still places them. func TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior(t *testing.T) { item := models.TimelineItem{ ID: "undated-task", Title: "Someday task", Source: "doot", Type: models.TimelineItemTypeTask, Time: time.Now(), IsAllDay: true, } wi := TimelineItemToWidgetItem(item) if wi.Start != nil { t.Error("an undated task (IsAllDay as a fallback, not a real all-day event) should still have nil Start") } } // TestTimelineItemToWidgetItem_ForwardsIsOverdue proves the 2026-07-12 // overdue-badge fix: TimelineItem.IsOverdue (already computed correctly by // ComputeDaySection, confirmed by the earlier fix that made overdue tasks // appear in the timeline at all) must be forwarded onto WidgetItem so the // Android client can render it distinctly -- previously it was silently // dropped, so an overdue task looked identical to a normal one on the // widget. func TestTimelineItemToWidgetItem_ForwardsIsOverdue(t *testing.T) { item := models.TimelineItem{ ID: "overdue-1", Title: "Pay the water bill", Source: "doot", Type: models.TimelineItemTypeTask, Time: time.Now(), IsOverdue: true, } wi := TimelineItemToWidgetItem(item) if !wi.IsOverdue { t.Error("expected IsOverdue to be forwarded as true") } } func TestTimelineItemToWidgetItem_NotOverdueByDefault(t *testing.T) { item := models.TimelineItem{ ID: "today-1", Title: "Water the plants", Source: "doot", Type: models.TimelineItemTypeTask, Time: time.Now(), } wi := TimelineItemToWidgetItem(item) if wi.IsOverdue { t.Error("expected IsOverdue to be false when the source item isn't overdue") } } // TestTimelineItemToWidgetItem_DootTaskGetsDueDate proves the 2026-07-12 // clickable-reschedule fix: a doot task's raw due date must reach the // client via a NEW field (DueDate) that is independent of Start/IsAllDay -- // Start is deliberately left nil for doot tasks (see // TestTimelineItemToWidgetItem_AllDayTask_KeepsFloatingBehavior) so the // client's floating-task SlotPacker can position it, and that must keep // working unchanged. Before this fix there was no way for the Android // detail popup to know a doot task's current due date at all. func TestTimelineItemToWidgetItem_DootTaskGetsDueDate(t *testing.T) { due := time.Date(2026, 7, 15, 0, 0, 0, 0, time.Local) item := models.TimelineItem{ ID: "doot-1", Title: "Pay the water bill", Source: "doot", Type: models.TimelineItemTypeTask, Time: due, IsAllDay: true, } wi := TimelineItemToWidgetItem(item) if wi.DueDate == nil { t.Fatal("expected DueDate to be set for a doot task with a real due date") } if !wi.DueDate.Equal(due) { t.Errorf("DueDate = %v, want %v", *wi.DueDate, due) } // Start must stay nil -- this is the pre-existing floating-task // behavior and this feature must not change it. if wi.Start != nil { t.Error("Start must remain nil for a doot task -- DueDate is a separate field, not a replacement") } } func TestTimelineItemToWidgetItem_UndatedDootTask_NilDueDate(t *testing.T) { item := models.TimelineItem{ ID: "doot-undated", Title: "Someday task", Source: "doot", Type: models.TimelineItemTypeTask, Time: time.Now(), IsAllDay: true, } // Zero Time simulates the "no real due date" case at the field level; // TimelineItemToWidgetItem's DueDate logic must guard on !Time.IsZero(). item.Time = time.Time{} wi := TimelineItemToWidgetItem(item) if wi.DueDate != nil { t.Error("expected DueDate to be nil when the source item has a zero Time") } } func TestTimelineItemToWidgetItem_CalendarEvent_NilDueDate(t *testing.T) { start := time.Now() item := models.TimelineItem{ ID: "cal-1", Title: "Team sync", Source: "calendar", Type: models.TimelineItemTypeEvent, Time: start, } wi := TimelineItemToWidgetItem(item) if wi.DueDate != nil { t.Error("expected DueDate to stay nil for a non-doot item (calendar event)") } } func TestHandleWidgetComplete_NonCompletable(t *testing.T) { h := &Handler{} body := `{"id":"x","source":"calendar"}` req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestHandleWidgetDetail_NativeTask(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() task := models.Task{ID: "t1", Content: "Buy milk", Description: "2%, not skim"} if err := db.CreateNativeTask(task); err != nil { t.Fatalf("failed to seed native task: %v", err) } h := &Handler{store: db} req := httptest.NewRequest("GET", "/api/widget/detail?id=t1&source=doot", nil) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetDetail).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp widgetDetailResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.Title != "Buy milk" || resp.Description != "2%, not skim" || !resp.Editable { t.Errorf("unexpected detail: %+v", resp) } } func TestHandleWidgetDetail_UnsupportedSource(t *testing.T) { h := &Handler{} req := httptest.NewRequest("GET", "/api/widget/detail?id=x&source=calendar", nil) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetDetail).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestHandleWidgetUpdate_NativeTask_PreservesContent(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() task := models.Task{ID: "t2", Content: "Original title", Description: "old desc"} if err := db.CreateNativeTask(task); err != nil { t.Fatalf("failed to seed native task: %v", err) } h := &Handler{store: db} body := `{"id":"t2","source":"doot","description":"new desc"}` req := httptest.NewRequest("POST", "/api/widget/update", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetUpdate).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } tasks, err := db.GetNativeTasks() if err != nil { t.Fatalf("failed to read back native tasks: %v", err) } if len(tasks) != 1 { t.Fatalf("expected 1 native task, got %d", len(tasks)) } if tasks[0].Content != "Original title" { t.Errorf("title should be preserved, got %q", tasks[0].Content) } if tasks[0].Description != "new desc" { t.Errorf("description should be updated, got %q", tasks[0].Description) } } func TestHandleWidgetUpdate_UnsupportedSource(t *testing.T) { h := &Handler{} body := `{"id":"x","source":"calendar","description":"anything"}` req := httptest.NewRequest("POST", "/api/widget/update", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetUpdate).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestTimelineItemToWidgetItem_GTask_Completable(t *testing.T) { item := models.TimelineItem{ ID: "g1", Title: "Renew passport", Source: "gtasks", Type: models.TimelineItemTypeGTask, } wi := TimelineItemToWidgetItem(item) if wi.Type != "task" { t.Errorf("Type: got %q, want %q", wi.Type, "task") } if !wi.Completable { t.Error("gtask should be completable") } } func TestTimelineItemToWidgetItem_Card_Completable(t *testing.T) { item := models.TimelineItem{ ID: "c1", Title: "Trello card", Source: "trello", Type: models.TimelineItemTypeCard, } wi := TimelineItemToWidgetItem(item) if !wi.Completable { t.Error("trello card should be completable via widget") } } func TestHandleWidgetComplete_GoogleTask(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() if err := db.SaveGoogleTasks([]models.GoogleTask{ {ID: "g1", Title: "Renew passport", ListID: "list-a", UpdatedAt: time.Now()}, }); err != nil { t.Fatalf("failed to seed google task: %v", err) } mock := &mockGoogleTasksClient{} h := &Handler{store: db, googleTasksClient: mock} body := `{"id":"g1","source":"gtasks"}` req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if mock.completedListID != "list-a" || mock.completedTaskID != "g1" { t.Errorf("unexpected complete call: listID=%q taskID=%q", mock.completedListID, mock.completedTaskID) } } // TestHandleWidgetComplete_UnknownID_Returns404 proves the 2026-07-12 fix at // the handler layer: a "doot" completion for an id that doesn't exist must // surface as 404, not the previous silent 200 (see // store.ErrNativeTaskNotFound's doc comment for the underlying bug this // closes -- a real production incident where the widget's completeTask tap // intermittently looked like it worked but changed nothing). func TestHandleWidgetComplete_UnknownID_Returns404(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} body := `{"id":"does-not-exist","source":"doot"}` req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } func TestHandleWidgetComplete_GoogleTask_NotConfigured(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} body := `{"id":"g1","source":"gtasks"}` req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req) if w.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", w.Code) } } func TestHandleWidgetDetail_GoogleTask(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() if err := db.SaveGoogleTasks([]models.GoogleTask{ {ID: "g1", Title: "Renew passport", Notes: "bring photo", ListID: "list-a", UpdatedAt: time.Now()}, }); err != nil { t.Fatalf("failed to seed google task: %v", err) } h := &Handler{store: db, googleTasksClient: &mockGoogleTasksClient{}} req := httptest.NewRequest("GET", "/api/widget/detail?id=g1&source=gtasks", nil) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetDetail).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp widgetDetailResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.Title != "Renew passport" || resp.Description != "bring photo" || !resp.Editable { t.Errorf("unexpected detail: %+v", resp) } } func TestHandleWidgetUpdate_GoogleTask(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() if err := db.SaveGoogleTasks([]models.GoogleTask{ {ID: "g1", Title: "Renew passport", ListID: "list-a", UpdatedAt: time.Now()}, }); err != nil { t.Fatalf("failed to seed google task: %v", err) } mock := &mockGoogleTasksClient{} h := &Handler{store: db, googleTasksClient: mock} body := `{"id":"g1","source":"gtasks","description":"bring photo and $170"}` req := httptest.NewRequest("POST", "/api/widget/update", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetUpdate).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if mock.notesListID != "list-a" || mock.notesTaskID != "g1" || mock.notes != "bring photo and $170" { t.Errorf("unexpected update call: listID=%q taskID=%q notes=%q", mock.notesListID, mock.notesTaskID, mock.notes) } } func seedTestCard(t *testing.T, db *store.Store) { t.Helper() err := db.SaveBoards([]models.Board{ { ID: "board1", Name: "Test Board", Cards: []models.Card{ {ID: "c1", Name: "Ship the widget", Description: "don't forget the checkbox", ListName: "Doing"}, }, }, }) if err != nil { t.Fatalf("failed to seed card: %v", err) } } func TestHandleWidgetDetail_TrelloCard(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() seedTestCard(t, db) h := &Handler{store: db, trelloClient: &mockTrelloWidgetClient{}} req := httptest.NewRequest("GET", "/api/widget/detail?id=c1&source=trello", nil) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetDetail).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp widgetDetailResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.Title != "Ship the widget" || resp.Description != "don't forget the checkbox" || !resp.Editable { t.Errorf("unexpected detail: %+v", resp) } } func TestHandleWidgetUpdate_TrelloCard(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() seedTestCard(t, db) mock := &mockTrelloWidgetClient{} h := &Handler{store: db, trelloClient: mock} body := `{"id":"c1","source":"trello","description":"updated desc"}` req := httptest.NewRequest("POST", "/api/widget/update", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetUpdate).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if mock.updatedCardID != "c1" || mock.updates["desc"] != "updated desc" { t.Errorf("unexpected update call: id=%q updates=%v", mock.updatedCardID, mock.updates) } } func TestHandleWidgetComplete_TrelloCard_Archives(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() seedTestCard(t, db) mock := &mockTrelloWidgetClient{} h := &Handler{store: db, trelloClient: mock} body := `{"id":"c1","source":"trello"}` req := httptest.NewRequest("POST", "/api/widget/complete", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetComplete).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if mock.updatedCardID != "c1" || mock.updates["closed"] != true { t.Errorf("expected card to be archived (closed=true), got id=%q updates=%v", mock.updatedCardID, mock.updates) } boards, err := db.GetBoards() if err != nil { t.Fatalf("failed to read back boards: %v", err) } for _, b := range boards { for _, c := range b.Cards { if c.ID == "c1" { t.Error("completed card should have been removed from local cache") } } } } // TestHandleWidgetAdd_CreatesTask proves the quick-add feature: POSTing a // title to /api/widget/add creates an undated native task the same way the // web UI's HandleUnifiedAdd does, but via the widget's bearer-token JSON // API instead of a session-authenticated HTML form. func TestHandleWidgetAdd_CreatesTask(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} body := `{"title":"Buy milk"}` req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp widgetAddResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.ID == "" { t.Fatal("expected a non-empty id in the response") } tasks, err := s.GetUndatedNativeTasks() if err != nil { t.Fatalf("failed to read back tasks: %v", err) } found := false for _, task := range tasks { if task.Content == "Buy milk" && task.ID == resp.ID { found = true } } if !found { t.Error("expected a task with content 'Buy milk' and matching id to have been created") } } func TestHandleWidgetAdd_EmptyTitle_Returns400(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} body := `{"title":""}` req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestHandleWidgetAdd_WhitespaceOnlyTitle_Returns400(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} body := `{"title":" "}` req := httptest.NewRequest("POST", "/api/widget/add", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetAdd).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestWidgetAuthMiddleware_EmptyToken(t *testing.T) { inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) h := WidgetAuthMiddleware("", inner) req := httptest.NewRequest("GET", "/api/widget", nil) req.Header.Set("Authorization", "Bearer ") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401 with empty token, got %d", w.Code) } } // TestTimelineItemToWidgetItem_ForwardsRecurringEventID proves the // 2026-07-12 recurrence-display fix's data plumbing: a calendar event's // RecurringEventId (captured from Google's API, which only puts the RRULE // itself on the master event, not on expanded instances) must reach the // client so it can look up the human-readable schedule on demand. func TestTimelineItemToWidgetItem_ForwardsRecurringEventID(t *testing.T) { item := models.TimelineItem{ ID: "cal-1", Title: "Team sync", Source: "calendar", Type: models.TimelineItemTypeEvent, Time: time.Now(), RecurringEventID: "master-123", } wi := TimelineItemToWidgetItem(item) if wi.RecurringEventID != "master-123" { t.Errorf("RecurringEventID = %q, want %q", wi.RecurringEventID, "master-123") } } func TestTimelineItemToWidgetItem_NonRecurring_EmptyRecurringEventID(t *testing.T) { item := models.TimelineItem{ ID: "cal-2", Title: "One-off meeting", Source: "calendar", Type: models.TimelineItemTypeEvent, Time: time.Now(), } wi := TimelineItemToWidgetItem(item) if wi.RecurringEventID != "" { t.Errorf("expected empty RecurringEventID for a non-recurring event, got %q", wi.RecurringEventID) } } func TestHandleWidgetTaskDetail_ReturnsFullDetail(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} if _, err := s.DB().Exec(` INSERT INTO native_tasks (id, content, description, due_date, recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id) VALUES ('rec-1', 'Water plants', 'Use the blue can', '2026-07-13', 'weekly', 1, '1,3,5', 'series-1') `); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/api/widget/task?id=rec-1&source=doot", nil) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskDetail).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp taskDetailResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.Title != "Water plants" { t.Errorf("Title = %q, want %q", resp.Title, "Water plants") } if resp.Description != "Use the blue can" { t.Errorf("Description = %q, want %q", resp.Description, "Use the blue can") } if resp.Recurrence == nil { t.Fatal("expected non-nil Recurrence") } if resp.Recurrence.Freq != "weekly" || len(resp.Recurrence.Weekdays) != 3 { t.Errorf("Recurrence = %+v, want freq=weekly with 3 weekdays", resp.Recurrence) } if resp.NextDate == nil { t.Fatal("expected computed NextDate for a recurring task") } } func TestHandleWidgetTaskDetail_NonRecurring_NilRecurrenceAndNextDate(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} if _, err := s.DB().Exec(`INSERT INTO native_tasks (id, content) VALUES ('plain-1', 'Buy milk')`); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/api/widget/task?id=plain-1&source=doot", nil) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskDetail).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp taskDetailResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.Recurrence != nil { t.Errorf("expected nil Recurrence for a non-recurring task, got %+v", resp.Recurrence) } if resp.NextDate != nil { t.Errorf("expected nil NextDate for a non-recurring task, got %v", resp.NextDate) } } func TestHandleWidgetTaskDetail_UnknownID_Returns404(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} req := httptest.NewRequest("GET", "/api/widget/task?id=does-not-exist&source=doot", nil) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskDetail).ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d", w.Code) } } func TestHandleWidgetTaskUpdate_UpdatesTitleAndDescription(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} if _, err := s.DB().Exec(`INSERT INTO native_tasks (id, content) VALUES ('plain-1', 'Old title')`); err != nil { t.Fatal(err) } body := `{"id":"plain-1","title":"New title","description":"New description"}` req := httptest.NewRequest("POST", "/api/widget/task/update", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskUpdate).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } task, err := s.GetNativeTaskByID("plain-1") if err != nil { t.Fatal(err) } if task.Content != "New title" || task.Description != "New description" { t.Errorf("task = %+v, want title/description updated", task) } } func TestHandleWidgetTaskUpdate_UnknownID_Returns404(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} body := `{"id":"does-not-exist","title":"New title","description":""}` req := httptest.NewRequest("POST", "/api/widget/task/update", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskUpdate).ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("expected 404 for an unknown task id, got %d", w.Code) } } func TestHandleWidgetTaskRecurrence_SetsPattern(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} if _, err := s.DB().Exec(`INSERT INTO native_tasks (id, content) VALUES ('plain-1', 'Water plants')`); err != nil { t.Fatal(err) } body := `{"id":"plain-1","freq":"weekly","interval":2,"weekdays":[1,3]}` req := httptest.NewRequest("POST", "/api/widget/task/recurrence", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskRecurrence).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } task, err := s.GetNativeTaskByID("plain-1") if err != nil { t.Fatal(err) } if task.RecurrenceFreq != "weekly" || task.RecurrenceInterval != 2 || task.RecurrenceSeriesID == "" { t.Errorf("task = %+v, want recurrence set with a generated series id", task) } } func TestHandleWidgetTaskRecurrence_InvalidFreq_Returns400(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} body := `{"id":"plain-1","freq":"bogus","interval":1}` req := httptest.NewRequest("POST", "/api/widget/task/recurrence", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskRecurrence).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for an invalid freq, got %d", w.Code) } } func TestHandleWidgetTaskNextDate_SetsOverride(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} if _, err := s.DB().Exec(` INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_series_id) VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 'series-1') `); err != nil { t.Fatal(err) } body := `{"id":"rec-1","date":"2026-08-01"}` req := httptest.NewRequest("POST", "/api/widget/task/next-date", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskNextDate).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } task, err := s.GetNativeTaskByID("rec-1") if err != nil { t.Fatal(err) } if task.NextOccurrenceOverride == nil || task.NextOccurrenceOverride.Format("2006-01-02") != "2026-08-01" { t.Errorf("NextOccurrenceOverride = %v, want 2026-08-01", task.NextOccurrenceOverride) } } func TestHandleWidgetTaskNextDate_NonRecurringTask_Returns400(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} if _, err := s.DB().Exec(`INSERT INTO native_tasks (id, content) VALUES ('plain-1', 'Buy milk')`); err != nil { t.Fatal(err) } body := `{"id":"plain-1","date":"2026-08-01"}` req := httptest.NewRequest("POST", "/api/widget/task/next-date", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskNextDate).ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for a non-recurring task, got %d", w.Code) } } func TestHandleWidgetProjectsGet_ReturnsNonArchivedProjects(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} if _, err := s.CreateProject("Sailing prep", "#3B82F6"); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/api/widget/projects", nil) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetProjectsGet).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var projects []projectResponse if err := json.NewDecoder(w.Body).Decode(&projects); err != nil { t.Fatalf("failed to decode response: %v", err) } if len(projects) != 1 || projects[0].Name != "Sailing prep" { t.Fatalf("projects = %+v, want 1 entry named 'Sailing prep'", projects) } } func TestHandleWidgetProjectsCreate_CreatesProject(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} body := `{"name":"Sailing prep","color":"#3B82F6"}` req := httptest.NewRequest("POST", "/api/widget/projects", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetProjectsCreate).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp projectResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.Name != "Sailing prep" || resp.Color != "#3B82F6" || resp.ID == "" { t.Errorf("resp = %+v, want a created project with name/color/id set", resp) } } func TestHandleWidgetTaskProject_SetsProjectID(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} project, err := s.CreateProject("Sailing prep", "#3B82F6") if err != nil { t.Fatal(err) } if _, err := s.DB().Exec(`INSERT INTO native_tasks (id, content) VALUES ('plain-1', 'Buy line')`); err != nil { t.Fatal(err) } body := `{"id":"plain-1","project_id":"` + project.ID + `"}` req := httptest.NewRequest("POST", "/api/widget/task/project", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskProject).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } task, err := s.GetNativeTaskByID("plain-1") if err != nil { t.Fatal(err) } if task.ProjectID != project.ID { t.Errorf("ProjectID = %q, want %q", task.ProjectID, project.ID) } } func TestHandleWidgetTaskLabels_SetsLabels(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} if _, err := s.DB().Exec(`INSERT INTO native_tasks (id, content) VALUES ('plain-1', 'Buy line')`); err != nil { t.Fatal(err) } body := `{"id":"plain-1","labels":["chore","urgent"]}` req := httptest.NewRequest("POST", "/api/widget/task/labels", strings.NewReader(body)) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskLabels).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } task, err := s.GetNativeTaskByID("plain-1") if err != nil { t.Fatal(err) } if len(task.Labels) != 2 || task.Labels[0] != "chore" || task.Labels[1] != "urgent" { t.Errorf("Labels = %v, want [chore urgent]", task.Labels) } } func TestHandleWidgetLabelsColorSet_ThenGet_ReturnsIt(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} body := `{"name":"chore","color":"#F59E0B"}` setReq := httptest.NewRequest("POST", "/api/widget/labels/color", strings.NewReader(body)) setW := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetLabelsColorSet).ServeHTTP(setW, setReq) if setW.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", setW.Code, setW.Body.String()) } getReq := httptest.NewRequest("GET", "/api/widget/labels", nil) getW := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetLabelsGet).ServeHTTP(getW, getReq) if getW.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", getW.Code, getW.Body.String()) } var colors []labelColorResponse if err := json.NewDecoder(getW.Body).Decode(&colors); err != nil { t.Fatalf("failed to decode response: %v", err) } if len(colors) != 1 || colors[0].Name != "chore" || colors[0].Color != "#F59E0B" { t.Fatalf("colors = %+v, want [{chore #F59E0B}]", colors) } } func TestHandleWidgetTaskDetail_IncludesProjectAndLabels(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: s} project, err := s.CreateProject("Sailing prep", "#3B82F6") if err != nil { t.Fatal(err) } if _, err := s.DB().Exec(` INSERT INTO native_tasks (id, content, project_id, labels) VALUES ('rec-1', 'Water plants', ?, '["chore"]') `, project.ID); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/api/widget/task?id=rec-1&source=doot", nil) w := httptest.NewRecorder() http.HandlerFunc(h.HandleWidgetTaskDetail).ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp taskDetailResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.Project == nil || resp.Project.ID != project.ID || resp.Project.Name != "Sailing prep" { t.Fatalf("Project = %+v, want the created project", resp.Project) } if len(resp.Labels) != 1 || resp.Labels[0] != "chore" { t.Errorf("Labels = %v, want [chore]", resp.Labels) } } func TestTimelineItemToWidgetItem_CopiesProjectColor(t *testing.T) { item := models.TimelineItem{ ID: "rec-1", Type: models.TimelineItemTypeTask, Source: "doot", ProjectColor: "#3B82F6", } wi := TimelineItemToWidgetItem(item) if wi.ProjectColor == nil || *wi.ProjectColor != "#3B82F6" { t.Errorf("ProjectColor = %v, want #3B82F6", wi.ProjectColor) } } func TestTimelineItemToWidgetItem_NoProject_NilProjectColor(t *testing.T) { item := models.TimelineItem{ID: "rec-1", Type: models.TimelineItemTypeTask, Source: "doot"} wi := TimelineItemToWidgetItem(item) if wi.ProjectColor != nil { t.Errorf("ProjectColor = %v, want nil", wi.ProjectColor) } } func TestHandleWidgetGet_NoBudgetTrackedTasks_OmitsBudgetStatus(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} req := httptest.NewRequest("GET", "/api/widget", nil) w := httptest.NewRecorder() h.HandleWidgetGet(w, req) var resp models.WidgetResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatal(err) } if resp.BudgetStatus != nil { t.Errorf("expected nil BudgetStatus with no tracked tasks, got %+v", resp.BudgetStatus) } } func TestHandleWidgetGet_BudgetTrackedTaskDueToday_IncludesBudgetStatus(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} project, err := h.store.CreateProject("Tracked", "#111111") if err != nil { t.Fatal(err) } if err := h.store.SetProjectBudgetTracked(project.ID, true); err != nil { t.Fatal(err) } due := time.Now() if err := h.store.CreateNativeTask(models.Task{ID: "t-tracked", Content: "Tracked task", ProjectID: project.ID, DueDate: &due, EstimatedMinutes: 45}); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/api/widget", nil) w := httptest.NewRecorder() h.HandleWidgetGet(w, req) var resp models.WidgetResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatal(err) } if resp.BudgetStatus == nil { t.Fatal("expected non-nil BudgetStatus") } if resp.BudgetStatus.Today.ScheduledMinutes != 45 { t.Errorf("Today.ScheduledMinutes = %d, want 45", resp.BudgetStatus.Today.ScheduledMinutes) } if resp.BudgetStatus.Week.ScheduledMinutes != 45 { t.Errorf("Week.ScheduledMinutes = %d, want 45", resp.BudgetStatus.Week.ScheduledMinutes) } } func TestHandleWidgetAvailabilityGet_ReturnsBlocks(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} if _, err := h.store.CreateAvailabilityBlock(1, "18:00", "20:00", "evening"); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/api/widget/availability", nil) w := httptest.NewRecorder() h.HandleWidgetAvailabilityGet(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } var blocks []models.AvailabilityBlock if err := json.NewDecoder(w.Body).Decode(&blocks); err != nil { t.Fatal(err) } if len(blocks) != 1 || blocks[0].Label != "evening" { t.Errorf("blocks = %+v", blocks) } } func TestHandleWidgetAvailabilityCreate_CreatesBlock(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} body := `{"weekday":2,"start_time":"07:00","end_time":"08:00","label":"morning walk"}` req := httptest.NewRequest("POST", "/api/widget/availability", strings.NewReader(body)) w := httptest.NewRecorder() h.HandleWidgetAvailabilityCreate(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } blocks, err := h.store.GetAvailabilityBlocks() if err != nil { t.Fatal(err) } if len(blocks) != 1 || blocks[0].Weekday != 2 { t.Errorf("blocks = %+v", blocks) } } func TestHandleWidgetAvailabilityDelete_RemovesBlock(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} block, err := h.store.CreateAvailabilityBlock(3, "09:00", "10:00", "") if err != nil { t.Fatal(err) } body := `{"id":"` + block.ID + `"}` req := httptest.NewRequest("POST", "/api/widget/availability/delete", strings.NewReader(body)) w := httptest.NewRecorder() h.HandleWidgetAvailabilityDelete(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } blocks, err := h.store.GetAvailabilityBlocks() if err != nil { t.Fatal(err) } if len(blocks) != 0 { t.Errorf("expected block deleted, got %+v", blocks) } } func TestHandleWidgetAvailabilityDelete_UnknownID_Returns404(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} req := httptest.NewRequest("POST", "/api/widget/availability/delete", strings.NewReader(`{"id":"nope"}`)) w := httptest.NewRecorder() h.HandleWidgetAvailabilityDelete(w, req) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } } func TestHandleWidgetTaskEstimate_SetsEstimate(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} if err := h.store.CreateNativeTask(models.Task{ID: "t-1", Content: "task"}); err != nil { t.Fatal(err) } body := `{"id":"t-1","estimated_minutes":25}` req := httptest.NewRequest("POST", "/api/widget/task/estimate", strings.NewReader(body)) w := httptest.NewRecorder() h.HandleWidgetTaskEstimate(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } task, err := h.store.GetNativeTaskByID("t-1") if err != nil { t.Fatal(err) } if task.EstimatedMinutes != 25 { t.Errorf("EstimatedMinutes = %d, want 25", task.EstimatedMinutes) } } func TestHandleWidgetProjectsBudgetTracked_SetsFlag(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} project, err := h.store.CreateProject("P", "#111111") if err != nil { t.Fatal(err) } body := `{"id":"` + project.ID + `","tracked":true}` req := httptest.NewRequest("POST", "/api/widget/projects/budget-tracked", strings.NewReader(body)) w := httptest.NewRecorder() h.HandleWidgetProjectsBudgetTracked(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } got, err := h.store.GetProjectByID(project.ID) if err != nil { t.Fatal(err) } if !got.BudgetTracked { t.Error("expected BudgetTracked = true") } } func TestHandleWidgetLabelsBudgetTracked_SetsFlag(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} body := `{"name":"errands","tracked":true}` req := httptest.NewRequest("POST", "/api/widget/labels/budget-tracked", strings.NewReader(body)) w := httptest.NewRecorder() h.HandleWidgetLabelsBudgetTracked(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } names, err := h.store.GetBudgetTrackedLabelNames() if err != nil { t.Fatal(err) } if !names["errands"] { t.Error("expected 'errands' tracked") } } func TestHandleWidgetTaskDetail_SuggestsEstimateFromProjectAverage(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} project, err := h.store.CreateProject("Sailing prep", "#3B82F6") if err != nil { t.Fatal(err) } if err := h.store.CreateNativeTask(models.Task{ID: "t-past", Content: "past", ProjectID: project.ID, EstimatedMinutes: 40}); err != nil { t.Fatal(err) } if err := h.store.CreateNativeTask(models.Task{ID: "t-new", Content: "new", ProjectID: project.ID}); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/api/widget/task?id=t-new&source=doot", nil) w := httptest.NewRecorder() h.HandleWidgetTaskDetail(w, req) var resp taskDetailResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatal(err) } if resp.SuggestedEstimateMinutes == nil || *resp.SuggestedEstimateMinutes != 40 { t.Errorf("SuggestedEstimateMinutes = %v, want pointer to 40", resp.SuggestedEstimateMinutes) } } func TestHandleWidgetTaskDetail_AlreadyEstimated_NoSuggestion(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() h := &Handler{store: db} project, err := h.store.CreateProject("P", "#111111") if err != nil { t.Fatal(err) } if err := h.store.CreateNativeTask(models.Task{ID: "t-past", Content: "past", ProjectID: project.ID, EstimatedMinutes: 40}); err != nil { t.Fatal(err) } if err := h.store.CreateNativeTask(models.Task{ID: "t-estimated", Content: "already estimated", ProjectID: project.ID, EstimatedMinutes: 15}); err != nil { t.Fatal(err) } req := httptest.NewRequest("GET", "/api/widget/task?id=t-estimated&source=doot", nil) w := httptest.NewRecorder() h.HandleWidgetTaskDetail(w, req) var resp taskDetailResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatal(err) } if resp.SuggestedEstimateMinutes != nil { t.Errorf("expected no suggestion for an already-estimated task, got %v", *resp.SuggestedEstimateMinutes) } if resp.EstimatedMinutes != 15 { t.Errorf("EstimatedMinutes = %d, want 15", resp.EstimatedMinutes) } }