Files
multica/server/internal/handler/subscriber_subtree_endpoint_test.go
Jiayuan Zhang 28b6105edc fix(subscribers): notify the human an agent files sub-issues for (MUL-5483) (#6209)
When an agent created a sub-issue while working on a human's behalf, that human
received no notifications for it at all. issue_subscriber modelled ACTOR
identity, so an agent-created, agent-assigned issue had a full subscriber list
and zero members to deliver to. The platform already knew who the work was for
(agent_task_queue.originator_user_id, MUL-4302); notification never asked.

- attribution.DelegatedSubscriber: one shared rule over the same origin
  waterfall ClassifyDirect uses. agent_create subscribes the originator as
  'delegated'; quick_create keeps the direct 'creator' tier; autopilot and
  degraded attribution subscribe nobody.
- Delegated is a reduced delivery tier: in_review/done/cancelled/blocked plus
  failures and mentions. Routine churn is suppressed, and the parent bubble
  cannot re-deliver what the tier dropped.
- Unsubscribe becomes stateful: an unsubscribed_at tombstone survives later
  rule passes, and opt_out_scope distinguishes "this issue" from "this subtree"
  so a narrow opt-out no longer silently suppresses future children.
- Subtree unsubscribe is its own endpoint. A body flag cannot fail loudly
  against an older backend (Go drops unknown fields); an unknown route 404s,
  which the UI now surfaces with a distinct message.
- Eligibility and the write share one statement under a (workspace, user)
  advisory lock that subtree unsubscribe and member revoke also take, closing
  the check-then-insert races. Revoke additionally clears the departing
  member's subscriptions in the same tx.
- UI explains a delegated subscription and offers both unsubscribe scopes.

Migrations 249/250 add the delegated reason, the opt-out tombstone, and the
opt-out scope, using NOT VALID + VALIDATE CONSTRAINT so the widened CHECK does
not scan issue_subscriber under an exclusive lock.

Reviewed across eight rounds; an earlier write-time subtree roll-up was built
and then removed in full once it proved unfixable without serializing every
topology mutation. The parent's own status transition already carries that
signal.

Closes MUL-5483.
2026-07-31 16:52:17 +08:00

143 lines
4.9 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Mixed-version contract for subtree unsubscribe (MUL-5483 review round 6,
// finding 1).
//
// Web/desktop staging deploys automatically when a PR merges; the backend is
// deployed by hand. A new client therefore routinely runs against a server
// that predates whatever it just learned to send. Subtree unsubscribe was
// originally a `subtree: true` FIELD on POST /unsubscribe — and a field is
// exactly the wrong carrier for a capability across that skew, because Go's
// json.Decode ignores unknown fields: the old server would unsubscribe the
// root, answer 200, and the user would be told the whole tree was muted while
// every existing and future child kept notifying them.
//
// Moving it to its own ROUTE converts that silent wrong answer into a 404 the
// client can surface. These tests pin both halves of that contract.
// TestUnsubscribeEndpoints_SubtreeIsASeparateRoute pins the shape the fix
// depends on: the shared endpoint must have NO subtree behavior of its own, so
// that an old server and a new server answer a plain /unsubscribe identically.
func TestUnsubscribeEndpoints_SubtreeIsASeparateRoute(t *testing.T) {
parentID, childID := createSubscriberTreeFixture(t)
subscribeTo(t, parentID)
subscribeTo(t, childID)
// A body field named "subtree" must be inert on the shared endpoint. If it
// ever regains meaning here, an old backend and a new backend disagree
// about what the same request does — which is the whole defect.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+parentID+"/unsubscribe", map[string]any{
"subtree": true,
})
req = withURLParam(req, "id", parentID)
testHandler.UnsubscribeFromIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UnsubscribeFromIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
if isSubscriberOf(t, parentID) {
t.Fatal("the plain endpoint must still unsubscribe the issue it targets")
}
if !isSubscriberOf(t, childID) {
t.Fatal("a 'subtree' body field must be INERT on POST /unsubscribe: " +
"if it works here, an older backend silently ignoring it is indistinguishable from success")
}
}
// TestUnsubscribeEndpoints_SubtreeRouteLeavesDescendants is the positive half:
// the dedicated route does reach descendants, so the capability really did
// move rather than disappear.
func TestUnsubscribeEndpoints_SubtreeRouteLeavesDescendants(t *testing.T) {
parentID, childID := createSubscriberTreeFixture(t)
subscribeTo(t, parentID)
subscribeTo(t, childID)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+parentID+"/unsubscribe/subtree", nil)
req = withURLParam(req, "id", parentID)
testHandler.UnsubscribeFromIssueSubtree(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UnsubscribeFromIssueSubtree: expected 200, got %d: %s", w.Code, w.Body.String())
}
if isSubscriberOf(t, parentID) {
t.Fatal("subtree unsubscribe must leave the root issue")
}
if isSubscriberOf(t, childID) {
t.Fatal("subtree unsubscribe must leave descendants too")
}
}
// --- fixtures ---
// createSubscriberTreeFixture builds a parent issue with one child and returns
// both ids, cleaning up after the test.
func createSubscriberTreeFixture(t *testing.T) (parentID, childID string) {
t.Helper()
create := func(title string, parent *string) string {
w := httptest.NewRecorder()
body := map[string]any{"title": title}
if parent != nil {
body["parent_issue_id"] = *parent
}
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, body)
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue(%s): expected 201, got %d: %s", title, w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
return issue.ID
}
parentID = create("subtree endpoint parent", nil)
childID = create("subtree endpoint child", &parentID)
t.Cleanup(func() {
for _, id := range []string{childID, parentID} {
w := httptest.NewRecorder()
req := newRequest("DELETE", "/api/issues/"+id, nil)
req = withURLParam(req, "id", id)
testHandler.DeleteIssue(w, req)
}
})
return parentID, childID
}
func subscribeTo(t *testing.T, issueID string) {
t.Helper()
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(%s): expected 200, got %d: %s", issueID, w.Code, w.Body.String())
}
}
func isSubscriberOf(t *testing.T, issueID string) bool {
t.Helper()
subscribed, err := testHandler.Queries.IsIssueSubscriber(t.Context(), db.IsIssueSubscriberParams{
IssueID: parseUUID(issueID),
UserType: "member",
UserID: parseUUID(testUserID),
})
if err != nil {
t.Fatalf("IsIssueSubscriber(%s): %v", issueID, err)
}
return subscribed
}