diff options
Diffstat (limited to 'internal/handlers/widget_test.go')
| -rw-r--r-- | internal/handlers/widget_test.go | 362 |
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) |
