mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* feat(cli): add `issue subscriber` commands
Wrap the existing /subscribers, /subscribe, and /unsubscribe endpoints as
`multica issue subscriber list|add|remove`, mirroring the comment subcommand
shape. `--user <name>` reuses resolveAssignee to resolve a member or agent;
without the flag, the action targets the caller.
* fix(issues): default subscribe target to resolveActor, not X-User-ID
When no user_id is posted, subscribe/unsubscribe hardcoded the target as
("member", X-User-ID). A CLI caller running as an agent (X-Agent-ID set)
then subscribed the underlying member rather than the agent itself,
which contradicts the "defaults to the caller" contract.
Derive the default via resolveActor so the endpoint mirrors caller
identity consistently — agent caller → agent row, member caller →
member row. Adds a regression test covering the agent caller path.
327 lines
10 KiB
Go
327 lines
10 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
func TestSubscriberAPI(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Helper: create an issue for subscriber tests
|
|
createIssue := func(t *testing.T) string {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Subscriber test issue",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
return issue.ID
|
|
}
|
|
|
|
// Helper: delete an issue
|
|
deleteIssue := func(t *testing.T, issueID string) {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/issues/"+issueID, nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.DeleteIssue(w, req)
|
|
}
|
|
|
|
t.Run("Subscribe", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SubscribeToIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]bool
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if !resp["subscribed"] {
|
|
t.Fatal("SubscribeToIssue: expected subscribed=true")
|
|
}
|
|
|
|
// Verify in DB
|
|
subscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
|
IssueID: parseUUID(issueID),
|
|
UserType: "member",
|
|
UserID: parseUUID(testUserID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IsIssueSubscriber: %v", err)
|
|
}
|
|
if !subscribed {
|
|
t.Fatal("expected user to be subscribed in DB")
|
|
}
|
|
})
|
|
|
|
t.Run("SubscribeIdempotent", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
// Subscribe first time
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SubscribeToIssue (1st): expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Subscribe second time — should also succeed
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SubscribeToIssue (2nd): expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("ListSubscribers", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
// Subscribe first
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SubscribeToIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// List
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/issues/"+issueID+"/subscribers", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.ListIssueSubscribers(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListIssueSubscribers: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var subscribers []SubscriberResponse
|
|
json.NewDecoder(w.Body).Decode(&subscribers)
|
|
if len(subscribers) == 0 {
|
|
t.Fatal("ListIssueSubscribers: expected at least 1 subscriber")
|
|
}
|
|
found := false
|
|
for _, s := range subscribers {
|
|
if s.UserID == testUserID && s.UserType == "member" && s.Reason == "manual" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("ListIssueSubscribers: expected to find test user subscriber, got %+v", subscribers)
|
|
}
|
|
})
|
|
|
|
t.Run("Unsubscribe", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
// Subscribe first
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SubscribeToIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Unsubscribe
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.UnsubscribeFromIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UnsubscribeFromIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]bool
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if resp["subscribed"] {
|
|
t.Fatal("UnsubscribeFromIssue: expected subscribed=false")
|
|
}
|
|
|
|
// Verify in DB
|
|
subscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
|
IssueID: parseUUID(issueID),
|
|
UserType: "member",
|
|
UserID: parseUUID(testUserID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IsIssueSubscriber: %v", err)
|
|
}
|
|
if subscribed {
|
|
t.Fatal("expected user to NOT be subscribed in DB")
|
|
}
|
|
})
|
|
|
|
t.Run("SubscribeCrossWorkspaceUser", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
foreignUserID := "00000000-0000-0000-0000-000000000099"
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", map[string]any{
|
|
"user_id": foreignUserID,
|
|
"user_type": "member",
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("SubscribeToIssue with cross-workspace user: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
subscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
|
IssueID: parseUUID(issueID),
|
|
UserType: "member",
|
|
UserID: parseUUID(foreignUserID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IsIssueSubscriber: %v", err)
|
|
}
|
|
if subscribed {
|
|
t.Fatal("cross-workspace user should NOT be subscribed in DB")
|
|
}
|
|
})
|
|
|
|
t.Run("UnsubscribeCrossWorkspaceUser", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
foreignUserID := "00000000-0000-0000-0000-000000000099"
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", map[string]any{
|
|
"user_id": foreignUserID,
|
|
"user_type": "member",
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.UnsubscribeFromIssue(w, req)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("UnsubscribeFromIssue with cross-workspace user: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("AgentCallerSubscribesItself", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
// Look up the agent created by the handler test fixture.
|
|
var agentID string
|
|
err := testPool.QueryRow(ctx,
|
|
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
|
|
testWorkspaceID, "Handler Test Agent",
|
|
).Scan(&agentID)
|
|
if err != nil {
|
|
t.Fatalf("failed to find test agent: %v", err)
|
|
}
|
|
|
|
// Subscribe with X-Agent-ID set — no body, so the handler must default
|
|
// to subscribing the agent itself (not the member behind X-User-ID).
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
req.Header.Set("X-Agent-ID", agentID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("SubscribeToIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
agentSubscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
|
IssueID: parseUUID(issueID),
|
|
UserType: "agent",
|
|
UserID: parseUUID(agentID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IsIssueSubscriber (agent): %v", err)
|
|
}
|
|
if !agentSubscribed {
|
|
t.Fatal("expected agent to be subscribed in DB when X-Agent-ID is set")
|
|
}
|
|
|
|
memberSubscribed, err := testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
|
IssueID: parseUUID(issueID),
|
|
UserType: "member",
|
|
UserID: parseUUID(testUserID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IsIssueSubscriber (member): %v", err)
|
|
}
|
|
if memberSubscribed {
|
|
t.Fatal("member must not be auto-subscribed when caller is an agent")
|
|
}
|
|
|
|
// Unsubscribe with X-Agent-ID set — same default-to-caller expectation.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
req.Header.Set("X-Agent-ID", agentID)
|
|
testHandler.UnsubscribeFromIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UnsubscribeFromIssue (agent caller): expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
agentSubscribed, err = testHandler.Queries.IsIssueSubscriber(ctx, db.IsIssueSubscriberParams{
|
|
IssueID: parseUUID(issueID),
|
|
UserType: "agent",
|
|
UserID: parseUUID(agentID),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("IsIssueSubscriber (agent, after unsubscribe): %v", err)
|
|
}
|
|
if agentSubscribed {
|
|
t.Fatal("expected agent to be unsubscribed in DB when X-Agent-ID is set")
|
|
}
|
|
})
|
|
|
|
t.Run("ListAfterUnsubscribe", func(t *testing.T) {
|
|
issueID := createIssue(t)
|
|
defer deleteIssue(t, issueID)
|
|
|
|
// Subscribe
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/subscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.SubscribeToIssue(w, req)
|
|
|
|
// Unsubscribe
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/unsubscribe", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.UnsubscribeFromIssue(w, req)
|
|
|
|
// List should be empty
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/issues/"+issueID+"/subscribers", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.ListIssueSubscribers(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListIssueSubscribers: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var subscribers []SubscriberResponse
|
|
json.NewDecoder(w.Body).Decode(&subscribers)
|
|
if len(subscribers) != 0 {
|
|
t.Fatalf("ListIssueSubscribers: expected 0 subscribers after unsubscribe, got %d", len(subscribers))
|
|
}
|
|
})
|
|
}
|