package handlers import ( "net/http" "net/http/httptest" "strings" "testing" "time" "task-dashboard/internal/models" ) 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. End stays nil since there's no meaningful end time to // show for an all-day item. 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_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) } } // 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) } } // 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) } 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" { found = true } } if !found { t.Error("expected a task with content 'Buy milk' 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) } }