diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-13 06:54:13 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-13 06:54:13 +0000 |
| commit | 8310f802dd9fc6ef5dff0be7f640f79c5b39987f (patch) | |
| tree | d145bd7be5801f12563e99b6828b0f2facdc370c /internal/handlers | |
| parent | 73fbd05a1230552bcef9834d3ee999ef19957693 (diff) | |
feat(widget): editable task details, scrollable list, and overdue-task fixes
- Add description editing to the widget's task detail popup for
doot/gtasks/trello, backed by new GET /api/widget/detail and
POST /api/widget/update endpoints
- Make Google Tasks and Trello cards completable via the widget (Trello
completion archives the card); fix Trello description never being
fetched, which meant saving could silently wipe a card's real desc
- Fix google_tasks.due_date/updated_at (TEXT columns) never round-tripping
through sql.NullTime, which broke cached Google Tasks reads whenever
the cache was valid
- Fix native-task and Google-Task date-range queries excluding anything
due before the window start, which dropped incomplete tasks off the
widget the moment their due day passed (the "overdue tasks disappeared"
bug)
- Fix native task description edits blanking the task's title
- Make the widget's day list scroll (LazyColumn) instead of clipping
- Optimistically remove a task from the widget immediately on completion,
ahead of the authoritative background refresh
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'internal/handlers')
| -rw-r--r-- | internal/handlers/handlers.go | 6 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic_test.go | 1 | ||||
| -rw-r--r-- | internal/handlers/widget.go | 169 | ||||
| -rw-r--r-- | internal/handlers/widget_test.go | 362 |
4 files changed, 533 insertions, 5 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index d1a7512..aaf1d0d 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -685,7 +685,7 @@ func (h *Handler) HandleGetTaskDetail(w http.ResponseWriter, r *http.Request) { for _, b := range boards { for _, c := range b.Cards { if c.ID == id { - title = c.Name + title, description = c.Name, c.Description break } } @@ -727,7 +727,7 @@ func (h *Handler) HandleTaskDetailPage(w http.ResponseWriter, r *http.Request) { for _, b := range boards { for _, c := range b.Cards { if c.ID == id { - title = c.Name + title, description = c.Name, c.Description break } } @@ -772,7 +772,7 @@ func (h *Handler) HandleUpdateTask(w http.ResponseWriter, r *http.Request) { var err error switch source { case "doot": - err = h.store.UpdateNativeTask(id, "", description) + err = h.store.UpdateNativeTaskDescription(id, description) case "trello": err = h.trelloClient.UpdateCard(r.Context(), id, map[string]interface{}{"desc": description}) default: diff --git a/internal/handlers/timeline_logic_test.go b/internal/handlers/timeline_logic_test.go index 3678b62..02dd5fb 100644 --- a/internal/handlers/timeline_logic_test.go +++ b/internal/handlers/timeline_logic_test.go @@ -62,6 +62,7 @@ func setupTestStore(t *testing.T) *store.Store { CREATE TABLE IF NOT EXISTS cards ( id TEXT PRIMARY KEY, name TEXT NOT NULL, + description TEXT DEFAULT '', board_id TEXT NOT NULL, list_id TEXT, list_name TEXT, diff --git a/internal/handlers/widget.go b/internal/handlers/widget.go index 876aaad..3b94bf1 100644 --- a/internal/handlers/widget.go +++ b/internal/handlers/widget.go @@ -8,6 +8,7 @@ import ( "task-dashboard/internal/config" "task-dashboard/internal/models" + "task-dashboard/internal/store" ) // WidgetAuthMiddleware validates the static bearer token for widget API endpoints. @@ -42,9 +43,12 @@ func TimelineItemToWidgetItem(item models.TimelineItem) models.WidgetItem { wi.Type = "event" case models.TimelineItemTypeMeal: wi.Type = "event" - case models.TimelineItemTypeCard, models.TimelineItemTypeGTask: + case models.TimelineItemTypeCard: wi.Type = "task" - // not completable via widget API + wi.Completable = true + case models.TimelineItemTypeGTask: + wi.Type = "task" + wi.Completable = true default: wi.Type = "task" wi.Completable = item.Source == "doot" @@ -132,6 +136,135 @@ func (h *Handler) HandleWidgetReschedule(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusOK) } +// findGoogleTask looks up a cached Google Task by ID. +func (h *Handler) findGoogleTask(id string) (models.GoogleTask, bool) { + gTasks, err := h.store.GetGoogleTasks() + if err != nil { + return models.GoogleTask{}, false + } + for _, t := range gTasks { + if t.ID == id { + return t, true + } + } + return models.GoogleTask{}, false +} + +// findCard looks up a cached Trello card by ID. +func (h *Handler) findCard(id string) (models.Card, bool) { + boards, err := h.store.GetBoards() + if err != nil { + return models.Card{}, false + } + for _, b := range boards { + for _, c := range b.Cards { + if c.ID == id { + return c, true + } + } + } + return models.Card{}, false +} + +type widgetDetailResponse struct { + Title string `json:"title"` + Description string `json:"description"` + Editable bool `json:"editable"` +} + +// HandleWidgetDetail returns a task's title/description for the widget's edit popup. +func (h *Handler) HandleWidgetDetail(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + source := r.URL.Query().Get("source") + if id == "" || source == "" { + http.Error(w, "missing id or source", http.StatusBadRequest) + return + } + + var resp widgetDetailResponse + switch source { + case "doot": + tasks, err := h.store.GetNativeTasks() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + for _, t := range tasks { + if t.ID == id { + resp = widgetDetailResponse{Title: t.Content, Description: t.Description, Editable: true} + break + } + } + case "gtasks": + if t, ok := h.findGoogleTask(id); ok { + resp = widgetDetailResponse{Title: t.Title, Description: t.Notes, Editable: h.googleTasksClient != nil} + } + case "trello": + if c, ok := h.findCard(id); ok { + resp = widgetDetailResponse{Title: c.Name, Description: c.Description, Editable: h.trelloClient != nil} + } + default: + http.Error(w, "unsupported source", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +type widgetUpdateRequest struct { + ID string `json:"id"` + Source string `json:"source"` + Description string `json:"description"` +} + +// HandleWidgetUpdate saves an edited description from the widget's edit popup. +func (h *Handler) HandleWidgetUpdate(w http.ResponseWriter, r *http.Request) { + var req widgetUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + switch req.Source { + case "doot": + if err := h.store.UpdateNativeTaskDescription(req.ID, req.Description); err != nil { + http.Error(w, "failed to update task", http.StatusInternalServerError) + return + } + case "gtasks": + if h.googleTasksClient == nil { + http.Error(w, "google tasks not configured", http.StatusServiceUnavailable) + return + } + t, ok := h.findGoogleTask(req.ID) + if !ok { + http.Error(w, "task not found", http.StatusNotFound) + return + } + if err := h.googleTasksClient.UpdateTaskNotes(r.Context(), t.ListID, req.ID, req.Description); err != nil { + http.Error(w, "failed to update task", http.StatusInternalServerError) + return + } + _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks) + case "trello": + if h.trelloClient == nil { + http.Error(w, "trello not configured", http.StatusServiceUnavailable) + return + } + if err := h.trelloClient.UpdateCard(r.Context(), req.ID, map[string]interface{}{"desc": req.Description}); err != nil { + http.Error(w, "failed to update task", http.StatusInternalServerError) + return + } + _ = h.store.InvalidateCache(store.CacheKeyTrelloBoards) + default: + http.Error(w, "source not editable", http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) +} + // HandleWidgetComplete proxies a task completion to the source API or native store. func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) { var req widgetCompleteRequest @@ -147,6 +280,38 @@ func (h *Handler) HandleWidgetComplete(w http.ResponseWriter, r *http.Request) { return } _ = h.store.SaveCompletedTask("doot", req.ID, "", nil) + case "gtasks": + if h.googleTasksClient == nil { + http.Error(w, "google tasks not configured", http.StatusServiceUnavailable) + return + } + t, ok := h.findGoogleTask(req.ID) + if !ok { + http.Error(w, "task not found", http.StatusNotFound) + return + } + if err := h.googleTasksClient.CompleteTask(r.Context(), t.ListID, req.ID); err != nil { + http.Error(w, "failed to complete task", http.StatusInternalServerError) + return + } + _ = h.store.SaveCompletedTask("gtasks", req.ID, t.Title, t.DueDate) + _ = h.store.InvalidateCache(store.CacheKeyGoogleTasks) + case "trello": + if h.trelloClient == nil { + http.Error(w, "trello not configured", http.StatusServiceUnavailable) + return + } + c, ok := h.findCard(req.ID) + if !ok { + http.Error(w, "task not found", http.StatusNotFound) + return + } + if err := h.trelloClient.UpdateCard(r.Context(), req.ID, map[string]interface{}{"closed": true}); err != nil { + http.Error(w, "failed to complete task", http.StatusInternalServerError) + return + } + _ = h.store.SaveCompletedTask("trello", req.ID, c.Name, c.DueDate) + _ = h.store.DeleteCard(req.ID) default: http.Error(w, "source not completable", http.StatusBadRequest) return 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) |
