summaryrefslogtreecommitdiff
path: root/internal/handlers/widget_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/handlers/widget_test.go')
-rw-r--r--internal/handlers/widget_test.go362
1 files changed, 362 insertions, 0 deletions
diff --git a/internal/handlers/widget_test.go b/internal/handlers/widget_test.go
index f56fe07..1d8dba9 100644
--- a/internal/handlers/widget_test.go
+++ b/internal/handlers/widget_test.go
@@ -1,6 +1,8 @@
package handlers
import (
+ "context"
+ "encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -8,8 +10,65 @@ import (
"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 })
@@ -121,6 +180,309 @@ func TestHandleWidgetComplete_NonCompletable(t *testing.T) {
}
}
+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)
+ }
+}
+
+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")
+ }
+ }
+ }
+}
+
func TestWidgetAuthMiddleware_EmptyToken(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h := WidgetAuthMiddleware("", inner)