mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
ae62c097ca
* refactor(mcp): consolidate project next/standup/changelog onto REST endpoints (TASK-1916) dispatchProjectNext/Standup/Changelog were a second server-side copy of the next/standup/changelog reshaping contract, written before TASK-1894 shipped dedicated REST endpoints for the same data. Replace the ~200 lines of duplicate reshaping with thin proxies that validate workspace (preserving the pad_set_workspace hint) and forward to GET /next|/standup|/changelog, relying on packageHTTPResponse's existing array-wrap (BUG-985) and the REST handlers' own days-default and per-status best-effort semantics rather than replicating them. dispatch_http_slice4.go is deleted; its unrelated dispatchLibraryActivate moves to dispatch_http_library.go now that the file's other three methods are gone. KEEP IN SYNC comments across handlers_project_intel.go/server.go/tests collapse from "three reproductions" to "CLI + REST, MCP proxies to REST." * fix(server): pass nav-lenient visibleIDs to changelog's parent enrichment (codex R1 P1, TASK-1916) handleGetProjectChangelog passed guestResourceFilter's narrowed collIDs into enrichItemsWithParent instead of the nav-lenient visibleCollectionIDs set handleListItems uses for the same enrichment call. For a guest whose granted item's parent lives in an item-grant-only collection (nav-visible but excluded from the narrowed full-access set), this silently dropped the parent link fields, causing itemMatchesParentFilter to exclude the item from ?parent= results even though the guest can otherwise see it. The root cause predates TASK-1916 (introduced alongside the REST endpoint in TASK-1894), but this consolidation imports it into MCP wire behavior via dispatchProjectChangelog's proxy, so it's in scope to fix here. projectIntelVisibility now returns the unnarrowed visibleCollectionIDs result (navVisibleIDs) alongside the existing (collIDs, itemIDs) pair; handleGetProjectChangelog uses navVisibleIDs for enrichItemsWithParent while keeping collIDs for the list query, mirroring handleListItems' pattern exactly. handleGetProjectStandup and handleGetProjectNext have no parallel enrichItemsWithParent call (verified by reading both, and buildDashboardResponse) so neither needed the same treatment. Added TestProjectChangelogEndpoint_GuestParentFilter_ItemGrantOnlyCollection, confirmed to fail against the pre-fix code and pass against the fix.
241 lines
8.3 KiB
Go
241 lines
8.3 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// --- library activate ---
|
|
|
|
func TestDispatch_LibraryActivate_ConventionByTitle(t *testing.T) {
|
|
// Pick a known convention from the seed library — tied to the
|
|
// convention_library.go constants.
|
|
const wantTitle = "Conventional commit format"
|
|
|
|
mux := http.NewServeMux()
|
|
posted := ""
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/collections/conventions/items", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST; got %s", r.Method)
|
|
}
|
|
buf := make([]byte, r.ContentLength)
|
|
_, _ = r.Body.Read(buf)
|
|
posted = string(buf)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"id":"item-1","title":"Conventional commit format"}`))
|
|
})
|
|
// Playbook endpoint MUST NOT be hit (we found a convention).
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/collections/playbooks/items", func(_ http.ResponseWriter, _ *http.Request) {
|
|
t.Errorf("playbook endpoint should not be hit when convention matches")
|
|
})
|
|
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})}
|
|
res, err := d.Dispatch(
|
|
WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp",
|
|
"title": wantTitle,
|
|
}),
|
|
[]string{"library", "activate"}, nil,
|
|
)
|
|
if err != nil || res.IsError {
|
|
t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res)
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal([]byte(posted), &got); err != nil {
|
|
t.Fatalf("decode posted body: %v\n%s", err, posted)
|
|
}
|
|
if got["title"] != wantTitle {
|
|
t.Errorf("posted title = %v, want %v", got["title"], wantTitle)
|
|
}
|
|
// Convention fields must include the canonical metadata.
|
|
fieldsStr, _ := got["fields"].(string)
|
|
var fields map[string]any
|
|
if err := json.Unmarshal([]byte(fieldsStr), &fields); err != nil {
|
|
t.Fatalf("decode fields: %v", err)
|
|
}
|
|
if fields["status"] != "active" {
|
|
t.Errorf("status = %v, want active", fields["status"])
|
|
}
|
|
if fields["category"] != "git" {
|
|
t.Errorf("category = %v, want git (from seed library)", fields["category"])
|
|
}
|
|
if fields["trigger"] != "on-commit" {
|
|
t.Errorf("trigger = %v, want on-commit", fields["trigger"])
|
|
}
|
|
}
|
|
|
|
func TestDispatch_LibraryActivate_PlaybookByTitle_FallsThroughConventionLookup(t *testing.T) {
|
|
// A playbook title — the dispatcher should NOT find it in
|
|
// conventions, then fall through to the playbook library.
|
|
//
|
|
// PLAN-1397 (TASK-1403) retired the pre-PLAN-1377 trigger-only
|
|
// entries; "Ship tasks" is the headline invokable that still
|
|
// resolves through the library and still carries trigger + scope
|
|
// in its activation payload (plus invocation_slug + arguments,
|
|
// which this test doesn't assert on — those are covered separately).
|
|
const wantTitle = "Ship tasks"
|
|
|
|
mux := http.NewServeMux()
|
|
posted := ""
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/collections/playbooks/items", func(w http.ResponseWriter, r *http.Request) {
|
|
buf := make([]byte, r.ContentLength)
|
|
_, _ = r.Body.Read(buf)
|
|
posted = string(buf)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"id":"pb-1","title":"Ship tasks"}`))
|
|
})
|
|
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})}
|
|
res, err := d.Dispatch(
|
|
WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp",
|
|
"title": wantTitle,
|
|
}),
|
|
[]string{"library", "activate"}, nil,
|
|
)
|
|
if err != nil || res.IsError {
|
|
t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res)
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal([]byte(posted), &got); err != nil {
|
|
t.Fatalf("decode posted body: %v\n%s", err, posted)
|
|
}
|
|
if got["title"] != wantTitle {
|
|
t.Errorf("posted title = %v", got["title"])
|
|
}
|
|
fieldsStr, _ := got["fields"].(string)
|
|
var fields map[string]any
|
|
if err := json.Unmarshal([]byte(fieldsStr), &fields); err != nil {
|
|
t.Fatalf("decode fields: %v", err)
|
|
}
|
|
if fields["status"] != "active" {
|
|
t.Errorf("status = %v, want active", fields["status"])
|
|
}
|
|
if _, ok := fields["trigger"]; !ok {
|
|
t.Errorf("playbook fields should include trigger: %v", fields)
|
|
}
|
|
if _, ok := fields["scope"]; !ok {
|
|
t.Errorf("playbook fields should include scope: %v", fields)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_LibraryActivate_NotFoundReturnsError(t *testing.T) {
|
|
d := &HTTPHandlerDispatcher{
|
|
Handler: errorHandler(t, "must not POST when title is unmatched"),
|
|
UserResolver: fixedUserResolver(&models.User{ID: "u"}),
|
|
}
|
|
res, err := d.Dispatch(
|
|
WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp",
|
|
"title": "NoSuchLibraryEntryEverShouldExist",
|
|
}),
|
|
[]string{"library", "activate"}, nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Dispatch err: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Errorf("expected IsError when title not in either library")
|
|
}
|
|
if !containsToolText(res, "not found in convention or playbook library") {
|
|
t.Errorf("error should mention library lookup; got %#v", res)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_LibraryActivate_RequiresWorkspaceAndTitle(t *testing.T) {
|
|
d := &HTTPHandlerDispatcher{
|
|
Handler: errorHandler(t, "must not POST when args missing"),
|
|
UserResolver: fixedUserResolver(&models.User{ID: "u"}),
|
|
}
|
|
for _, missing := range []string{"workspace", "title"} {
|
|
t.Run("missing-"+missing, func(t *testing.T) {
|
|
input := map[string]any{
|
|
"workspace": "docapp", "title": "Anything",
|
|
}
|
|
delete(input, missing)
|
|
res, err := d.Dispatch(
|
|
WithDispatchInput(context.Background(), input),
|
|
[]string{"library", "activate"}, nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Dispatch err: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Errorf("expected IsError when %s missing", missing)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Integration smoke ---
|
|
|
|
// TestHTTPHandlerDispatcher_Integration_ProjectIntelAndLibrary exercises
|
|
// project standup/changelog (proxied to the REST project-intel
|
|
// endpoints as of TASK-1916) and library activate against a real
|
|
// in-process server + store, guarding against 500s on a workspace with
|
|
// no data yet.
|
|
func TestHTTPHandlerDispatcher_Integration_ProjectIntelAndLibrary(t *testing.T) {
|
|
srv, st := newPadServer(t)
|
|
|
|
wsRec := doJSONReq(t, srv, http.MethodPost, "/api/v1/workspaces",
|
|
map[string]any{"name": "DocApp"})
|
|
if wsRec.Code != http.StatusCreated {
|
|
t.Fatalf("create workspace: %d %s", wsRec.Code, wsRec.Body.String())
|
|
}
|
|
var ws models.Workspace
|
|
if err := json.Unmarshal(wsRec.Body.Bytes(), &ws); err != nil {
|
|
t.Fatalf("decode workspace: %v", err)
|
|
}
|
|
owner, err := st.CreateUser(models.UserCreate{Email: "dave@example.com", Name: "Dave", Password: "x"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
if err := st.AddWorkspaceMember(ws.ID, owner.ID, "owner"); err != nil {
|
|
t.Fatalf("add owner: %v", err)
|
|
}
|
|
|
|
d := &HTTPHandlerDispatcher{Handler: srv, UserResolver: fixedUserResolver(owner)}
|
|
|
|
// project standup against a fresh workspace — should not 500
|
|
// even though there's no completed work.
|
|
standupRes, err := d.Dispatch(
|
|
WithDispatchInput(context.Background(), map[string]any{"workspace": ws.Slug}),
|
|
[]string{"project", "standup"}, nil,
|
|
)
|
|
if err != nil || standupRes.IsError {
|
|
t.Fatalf("standup: err=%v IsError=%v: %#v", err, standupRes != nil && standupRes.IsError, standupRes)
|
|
}
|
|
|
|
// project changelog same — empty workspace, no error.
|
|
clRes, err := d.Dispatch(
|
|
WithDispatchInput(context.Background(), map[string]any{"workspace": ws.Slug}),
|
|
[]string{"project", "changelog"}, nil,
|
|
)
|
|
if err != nil || clRes.IsError {
|
|
t.Fatalf("changelog: err=%v IsError=%v: %#v", err, clRes != nil && clRes.IsError, clRes)
|
|
}
|
|
|
|
// library activate with a known seed convention. The default
|
|
// "startup" template seeds Conventions; this should succeed.
|
|
actRes, err := d.Dispatch(
|
|
WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": ws.Slug,
|
|
"title": "Conventional commit format",
|
|
}),
|
|
[]string{"library", "activate"}, nil,
|
|
)
|
|
if err != nil || actRes.IsError {
|
|
t.Fatalf("library activate: err=%v IsError=%v: %#v", err, actRes != nil && actRes.IsError, actRes)
|
|
}
|
|
created, _ := actRes.StructuredContent.(map[string]any)
|
|
if title, _ := created["title"].(string); title != "Conventional commit format" {
|
|
t.Errorf("activated item title = %v", title)
|
|
}
|
|
}
|