Files
multica/server/internal/handler/project_resource_test.go
Bohan Jiang 44608713bb feat(projects): typed project resources + agent runtime injection (#1926)
* feat(projects): typed project resources + agent runtime injection

Adds a `project_resource` table that lets a project carry typed pointers
(github_repo today, more later) and surfaces them at agent runtime.

Server
- migration 065: project_resource (resource_type TEXT + resource_ref JSONB)
- sqlc CRUD + handler at /api/projects/{id}/resources
- claim handler attaches project_id/title + resources to issue tasks

Daemon
- TaskContextForEnv carries project context
- writes .multica/project/resources.json into workdir
- adds "## Project Context" block to CLAUDE.md / AGENTS.md / GEMINI.md
  via type-dispatched formatter so new resource types just add a case

CLI
- multica project create --repo <url> attaches repos in one step
- multica project resource add/list/remove

Frontend
- Project create modal: Repos pill (workspace repos + ad-hoc URL)
- Project detail sidebar: collapsible Resources section with attach/remove

Docs
- New "Project Resources" chapter explaining the abstraction and
  exactly what code to touch when adding a new resource type

Co-authored-by: multica-agent <github@multica.ai>

* fix(projects): transactional resources[] on create + generic CLI ref + test fix

Addresses review feedback on PR #1926:

1. CI red: TestProjectResourceLifecycle delete step called withURLParam
   twice, which replaced the chi route context and dropped the project id.
   Switched to the existing withURLParams helper from daemon_test.go.

2. POST /api/projects now accepts resources[] and attaches them in the
   same transaction as the project. Invalid refs roll back the whole
   create — no more half-attached projects on failure. Web modal + CLI
   `project create --repo` both use the new bundled payload.

3. CLI `project resource add` now accepts a generic --ref '<json>' flag
   so a new resource_type works without a CLI change. Per-type
   shortcuts (--url for github_repo) remain as a convenience but are no
   longer the only way in. Docs updated to drop the CLI from the
   "files you must touch" list.

Adds two new server handler tests:
- TestCreateProjectAttachesResources (resources[] happy path)
- TestCreateProjectRollsBackOnInvalidResource (transactional rollback)

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-04-30 14:00:43 +08:00

208 lines
7.2 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestProjectResourceLifecycle(t *testing.T) {
// Create a project to attach resources to.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Resource lifecycle project",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProject: expected 201, got %d: %s", w.Code, w.Body.String())
}
var project ProjectResponse
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
t.Fatalf("decode CreateProject: %v", err)
}
defer func() {
req := newRequest("DELETE", "/api/projects/"+project.ID, nil)
req = withURLParam(req, "id", project.ID)
testHandler.DeleteProject(httptest.NewRecorder(), req)
}()
// Attach a github_repo resource.
w = httptest.NewRecorder()
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": "https://github.com/multica-ai/multica"},
})
req = withURLParam(req, "id", project.ID)
testHandler.CreateProjectResource(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProjectResource: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created ProjectResourceResponse
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatalf("decode CreateProjectResource: %v", err)
}
if created.ResourceType != "github_repo" {
t.Errorf("created.ResourceType = %q, want github_repo", created.ResourceType)
}
var ref struct {
URL string `json:"url"`
}
if err := json.Unmarshal(created.ResourceRef, &ref); err != nil {
t.Fatalf("decode resource_ref: %v", err)
}
if ref.URL != "https://github.com/multica-ai/multica" {
t.Errorf("created.ResourceRef.url = %q", ref.URL)
}
// Listing must include the new resource.
w = httptest.NewRecorder()
req = newRequest("GET", "/api/projects/"+project.ID+"/resources", nil)
req = withURLParam(req, "id", project.ID)
testHandler.ListProjectResources(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListProjectResources: expected 200, got %d: %s", w.Code, w.Body.String())
}
var listResp struct {
Resources []ProjectResourceResponse `json:"resources"`
Total int `json:"total"`
}
if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil {
t.Fatalf("decode list: %v", err)
}
if listResp.Total != 1 || len(listResp.Resources) != 1 {
t.Fatalf("list returned %d resources, want 1", listResp.Total)
}
if listResp.Resources[0].ID != created.ID {
t.Errorf("list[0].ID = %q, want %q", listResp.Resources[0].ID, created.ID)
}
// Duplicate attach must conflict (UNIQUE on project_id + type + ref).
w = httptest.NewRecorder()
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": "https://github.com/multica-ai/multica"},
})
req = withURLParam(req, "id", project.ID)
testHandler.CreateProjectResource(w, req)
if w.Code != http.StatusConflict {
t.Errorf("duplicate CreateProjectResource: expected 409, got %d: %s", w.Code, w.Body.String())
}
// Invalid URL must reject at the validator level.
w = httptest.NewRecorder()
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": "not-a-url"},
})
req = withURLParam(req, "id", project.ID)
testHandler.CreateProjectResource(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("invalid URL: expected 400, got %d: %s", w.Code, w.Body.String())
}
// Unknown resource_type must reject.
w = httptest.NewRecorder()
req = newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
"resource_type": "unknown_type",
"resource_ref": map[string]any{"foo": "bar"},
})
req = withURLParam(req, "id", project.ID)
testHandler.CreateProjectResource(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("unknown type: expected 400, got %d: %s", w.Code, w.Body.String())
}
// Delete the resource.
w = httptest.NewRecorder()
req = newRequest("DELETE", "/api/projects/"+project.ID+"/resources/"+created.ID, nil)
req = withURLParams(req, "id", project.ID, "resourceId", created.ID)
testHandler.DeleteProjectResource(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("DeleteProjectResource: expected 204, got %d: %s", w.Code, w.Body.String())
}
// After deletion the list should be empty.
w = httptest.NewRecorder()
req = newRequest("GET", "/api/projects/"+project.ID+"/resources", nil)
req = withURLParam(req, "id", project.ID)
testHandler.ListProjectResources(w, req)
if err := json.NewDecoder(w.Body).Decode(&listResp); err != nil {
t.Fatalf("decode post-delete list: %v", err)
}
if listResp.Total != 0 {
t.Errorf("post-delete list: total = %d, want 0", listResp.Total)
}
}
func TestCreateProjectAttachesResources(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Project with bundled resources",
"resources": []map[string]any{
{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": "https://github.com/multica-ai/multica"},
},
},
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProject with resources: expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
ID string `json:"id"`
Resources []ProjectResourceResponse `json:"resources"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
defer func() {
r := newRequest("DELETE", "/api/projects/"+resp.ID, nil)
r = withURLParam(r, "id", resp.ID)
testHandler.DeleteProject(httptest.NewRecorder(), r)
}()
if len(resp.Resources) != 1 || resp.Resources[0].ResourceType != "github_repo" {
t.Fatalf("response resources mismatch: %+v", resp.Resources)
}
}
func TestCreateProjectRollsBackOnInvalidResource(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Project that should not exist",
"resources": []map[string]any{
{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": "not-a-url"},
},
},
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("CreateProject with invalid resource: expected 400, got %d: %s", w.Code, w.Body.String())
}
// Confirm no project survived (transactional rollback). Listing all projects
// in the workspace and checking for the title is enough.
w = httptest.NewRecorder()
req = newRequest("GET", "/api/projects?workspace_id="+testWorkspaceID, nil)
testHandler.ListProjects(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListProjects: %d %s", w.Code, w.Body.String())
}
var list struct {
Projects []ProjectResponse `json:"projects"`
}
if err := json.NewDecoder(w.Body).Decode(&list); err != nil {
t.Fatalf("decode list: %v", err)
}
for _, p := range list.Projects {
if p.Title == "Project that should not exist" {
t.Errorf("invalid resource should have rolled back project create, but found %s", p.ID)
}
}
}