mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-14 13:49:18 +02:00
Bundles the MUL-3404 disk-usage feature with the two Preflight BLOCK fixes. - feat(daemon): `disk-usage --all-profiles` aggregates across every workspace root (default + each ~/.multica/profiles/* root, incl. the Desktop app's), with a per-root breakdown and combined grand total; the cross-root hint now also fires when the current root is non-empty. - fix(db): drop DB-level foreign keys/cascades from the new autopilot_subscriber and comment.source_task_id migrations (resolved in the app layer — autopilot delete now removes subscribers in a transaction); the autopilot_subscriber down-migration relabels reason='autopilot' to 'manual' instead of deleting. - fix(server): readiness verifies every required migration is applied, not just the lexically-last one, so an out-of-order migration can't be masked. MUL-3404.
670 lines
24 KiB
Go
670 lines
24 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
func installAutopilotSubscriberInsertFailure(t *testing.T) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
suffix := time.Now().UnixNano()
|
|
functionName := fmt.Sprintf("autopilot_subscriber_fail_fn_%d", suffix)
|
|
triggerName := fmt.Sprintf("autopilot_subscriber_fail_%d", suffix)
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, fmt.Sprintf(`DROP TRIGGER IF EXISTS %s ON autopilot_subscriber`, triggerName))
|
|
testPool.Exec(ctx, fmt.Sprintf(`DROP FUNCTION IF EXISTS %s()`, functionName))
|
|
})
|
|
|
|
if _, err := testPool.Exec(ctx, fmt.Sprintf(`
|
|
CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN
|
|
RAISE EXCEPTION 'forced autopilot subscriber insert failure';
|
|
END;
|
|
$$;
|
|
`, functionName)); err != nil {
|
|
t.Fatalf("install failure function: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, fmt.Sprintf(`
|
|
CREATE TRIGGER %s
|
|
BEFORE INSERT ON autopilot_subscriber
|
|
FOR EACH ROW EXECUTE FUNCTION %s();
|
|
`, triggerName, functionName)); err != nil {
|
|
t.Fatalf("install failure trigger: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCreateAutopilotPersistsMemberSubscribers covers the happy path:
|
|
// supplying a non-empty `subscribers` array on POST /api/autopilots stores
|
|
// the rows and the response echoes them back. This is the create half of the
|
|
// MUL-2533 RFC ("autopilot default subscriber template").
|
|
func TestCreateAutopilotPersistsMemberSubscribers(t *testing.T) {
|
|
ctx := context.Background()
|
|
var autopilotID string
|
|
defer func() {
|
|
if autopilotID != "" {
|
|
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
|
|
}
|
|
}()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Subscriber template autopilot",
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
"subscribers": []map[string]any{
|
|
{"user_type": "member", "user_id": testUserID},
|
|
},
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode autopilot: %v", err)
|
|
}
|
|
autopilotID = resp.ID
|
|
if len(resp.Subscribers) != 1 {
|
|
t.Fatalf("subscribers in response = %d, want 1", len(resp.Subscribers))
|
|
}
|
|
if resp.Subscribers[0].UserType != "member" || resp.Subscribers[0].UserID != testUserID {
|
|
t.Fatalf("subscribers[0] = %+v, want member/%s", resp.Subscribers[0], testUserID)
|
|
}
|
|
|
|
// Confirm the row landed in the DB. Belt-and-braces: the response could
|
|
// in principle be assembled from the request without writing.
|
|
var count int
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT count(*) FROM autopilot_subscriber WHERE autopilot_id = $1
|
|
`, autopilotID).Scan(&count); err != nil {
|
|
t.Fatalf("count subscribers: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("autopilot_subscriber rows = %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
// TestCreateAutopilotRejectsNonMemberSubscriberType locks in the first-version
|
|
// constraint: only user_type='member' is accepted on the API. The DB CHECK
|
|
// would also reject anything else; the 400 here exists so the client gets a
|
|
// clear message instead of a 500 with a constraint-name leak.
|
|
func TestCreateAutopilotRejectsNonMemberSubscriberType(t *testing.T) {
|
|
var agentID string
|
|
if err := testPool.QueryRow(context.Background(), `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Bad subscriber type",
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
"subscribers": []map[string]any{
|
|
{"user_type": "agent", "user_id": agentID},
|
|
},
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateAutopilot: expected 400 for non-member subscriber, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateAutopilotRejectsForeignSubscriber covers the boundary check:
|
|
// supplying a UUID that does not belong to this workspace must 400, not
|
|
// silently leak inside the autopilot row.
|
|
func TestCreateAutopilotRejectsForeignSubscriber(t *testing.T) {
|
|
var agentID string
|
|
if err := testPool.QueryRow(context.Background(), `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Foreign subscriber",
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
"subscribers": []map[string]any{
|
|
{"user_type": "member", "user_id": "00000000-0000-0000-0000-000000000000"},
|
|
},
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateAutopilot: expected 400 for foreign member subscriber, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateAutopilotRollsBackWhenSubscriberInsertFails(t *testing.T) {
|
|
ctx := context.Background()
|
|
title := fmt.Sprintf("Subscriber rollback create %d", time.Now().UnixNano())
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
installAutopilotSubscriberInsertFailure(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
"subscribers": []map[string]any{
|
|
{"user_type": "member", "user_id": testUserID},
|
|
},
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("CreateAutopilot: expected 500 for forced subscriber insert failure, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var count int
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT count(*) FROM autopilot
|
|
WHERE workspace_id = $1 AND title = $2
|
|
`, testWorkspaceID, title).Scan(&count); err != nil {
|
|
t.Fatalf("count rolled-back autopilots: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("autopilot rows after failed subscriber insert = %d, want 0", count)
|
|
}
|
|
}
|
|
|
|
// TestUpdateAutopilotFullReplaceSubscribers covers the PATCH semantics from
|
|
// the RFC: sending `subscribers` wipes whatever was there and re-inserts the
|
|
// new set. Omitting the field would leave the previous template untouched;
|
|
// that branch is exercised separately by TestUpdateAutopilotPreservesSubscribersWhenOmitted.
|
|
func TestUpdateAutopilotFullReplaceSubscribers(t *testing.T) {
|
|
ctx := context.Background()
|
|
var autopilotID string
|
|
defer func() {
|
|
if autopilotID != "" {
|
|
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
|
|
}
|
|
}()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Replace subscribers autopilot",
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
"subscribers": []map[string]any{
|
|
{"user_type": "member", "user_id": testUserID},
|
|
},
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var created AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode created: %v", err)
|
|
}
|
|
autopilotID = created.ID
|
|
|
|
// PATCH with an empty array → expect zero subscribers afterward.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PATCH", "/api/autopilots/"+autopilotID+"?workspace_id="+testWorkspaceID, map[string]any{
|
|
"subscribers": []map[string]any{},
|
|
})
|
|
req = withURLParam(req, "id", autopilotID)
|
|
testHandler.UpdateAutopilot(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAutopilot: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var updated AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("decode updated: %v", err)
|
|
}
|
|
if len(updated.Subscribers) != 0 {
|
|
t.Fatalf("subscribers after empty replace = %d, want 0", len(updated.Subscribers))
|
|
}
|
|
|
|
var count int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM autopilot_subscriber WHERE autopilot_id = $1`, autopilotID).Scan(&count); err != nil {
|
|
t.Fatalf("count after replace: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("DB rows after empty replace = %d, want 0", count)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAutopilotRollsBackWhenSubscriberInsertFails(t *testing.T) {
|
|
ctx := context.Background()
|
|
originalTitle := fmt.Sprintf("Subscriber rollback update %d", time.Now().UnixNano())
|
|
updatedTitle := originalTitle + " changed"
|
|
var autopilotID string
|
|
defer func() {
|
|
if autopilotID != "" {
|
|
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
|
|
}
|
|
}()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": originalTitle,
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
"subscribers": []map[string]any{
|
|
{"user_type": "member", "user_id": testUserID},
|
|
},
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var created AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode created: %v", err)
|
|
}
|
|
autopilotID = created.ID
|
|
|
|
installAutopilotSubscriberInsertFailure(t)
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PATCH", "/api/autopilots/"+autopilotID+"?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": updatedTitle,
|
|
"subscribers": []map[string]any{
|
|
{"user_type": "member", "user_id": testUserID},
|
|
},
|
|
})
|
|
req = withURLParam(req, "id", autopilotID)
|
|
testHandler.UpdateAutopilot(w, req)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("UpdateAutopilot: expected 500 for forced subscriber insert failure, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var gotTitle string
|
|
if err := testPool.QueryRow(ctx, `SELECT title FROM autopilot WHERE id = $1`, autopilotID).Scan(&gotTitle); err != nil {
|
|
t.Fatalf("load autopilot title after rollback: %v", err)
|
|
}
|
|
if gotTitle != originalTitle {
|
|
t.Fatalf("autopilot title after failed subscriber replace = %q, want %q", gotTitle, originalTitle)
|
|
}
|
|
|
|
var count int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM autopilot_subscriber WHERE autopilot_id = $1`, autopilotID).Scan(&count); err != nil {
|
|
t.Fatalf("count subscribers after rollback: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("subscriber rows after failed replace = %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
// TestUpdateAutopilotPreservesSubscribersWhenOmitted asserts the
|
|
// "omit the field to leave it alone" contract — a previously-set template
|
|
// must NOT be wiped just because the client sent a partial PATCH.
|
|
func TestUpdateAutopilotPreservesSubscribersWhenOmitted(t *testing.T) {
|
|
ctx := context.Background()
|
|
var autopilotID string
|
|
defer func() {
|
|
if autopilotID != "" {
|
|
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
|
|
}
|
|
}()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Preserve subscribers autopilot",
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
"subscribers": []map[string]any{
|
|
{"user_type": "member", "user_id": testUserID},
|
|
},
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var created AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode created: %v", err)
|
|
}
|
|
autopilotID = created.ID
|
|
|
|
// PATCH a different field, leave subscribers out → row count unchanged.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PATCH", "/api/autopilots/"+autopilotID+"?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Preserve subscribers autopilot (renamed)",
|
|
})
|
|
req = withURLParam(req, "id", autopilotID)
|
|
testHandler.UpdateAutopilot(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAutopilot: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var count int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM autopilot_subscriber WHERE autopilot_id = $1`, autopilotID).Scan(&count); err != nil {
|
|
t.Fatalf("count after omitted PATCH: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("DB rows after omitted PATCH = %d, want 1 (subscribers must not have been touched)", count)
|
|
}
|
|
}
|
|
|
|
// TestAutopilotDispatchFansOutSubscribersToIssue is the integration check
|
|
// for the dispatch path: an autopilot with a default subscriber list must
|
|
// auto-subscribe each entry to the issue it spawns, with reason='autopilot'.
|
|
// Belt-and-braces: also confirms that the creator-of-the-issue (the assignee
|
|
// agent — see TestAutopilotCreatedIssueCreatorIsAssigneeAgent) gets a row
|
|
// with reason='creator', and the two reasons don't fight (PK is one row per
|
|
// (issue, user_type, user_id), so the first one wins on conflict).
|
|
func TestAutopilotDispatchFansOutSubscribersToIssue(t *testing.T) {
|
|
ctx := context.Background()
|
|
title := fmt.Sprintf("Autopilot subscriber fanout %d", time.Now().UnixNano())
|
|
var autopilotID, issueID string
|
|
defer func() {
|
|
if issueID != "" {
|
|
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
|
}
|
|
if autopilotID != "" {
|
|
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
|
|
}
|
|
}()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Subscriber fanout autopilot",
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
"issue_title_template": title,
|
|
"subscribers": []map[string]any{
|
|
{"user_type": "member", "user_id": testUserID},
|
|
},
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var autopilot AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&autopilot); err != nil {
|
|
t.Fatalf("decode autopilot: %v", err)
|
|
}
|
|
autopilotID = autopilot.ID
|
|
|
|
queries := db.New(testPool)
|
|
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilotID))
|
|
if err != nil {
|
|
t.Fatalf("GetAutopilot: %v", err)
|
|
}
|
|
run, err := testHandler.AutopilotService.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "manual", nil)
|
|
if err != nil {
|
|
t.Fatalf("DispatchAutopilot: %v", err)
|
|
}
|
|
if run == nil || !run.IssueID.Valid {
|
|
t.Fatalf("dispatch run = %+v, want linked issue", run)
|
|
}
|
|
issueID = uuidToString(run.IssueID)
|
|
|
|
var subscriberReason string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT reason
|
|
FROM issue_subscriber
|
|
WHERE issue_id = $1 AND user_type = 'member' AND user_id = $2
|
|
`, issueID, testUserID).Scan(&subscriberReason); err != nil {
|
|
t.Fatalf("query autopilot-fanned subscriber: %v", err)
|
|
}
|
|
if subscriberReason != "autopilot" {
|
|
t.Fatalf("subscriber reason = %q, want %q", subscriberReason, "autopilot")
|
|
}
|
|
}
|
|
|
|
// TestAutopilotDispatchNotifiesSubscribersOnCreate locks in the OQ3 promise
|
|
// from the RFC ("reason='autopilot' 与 reason='manual' 一致,订阅事件全收"):
|
|
// when an autopilot creates an issue, each template subscriber must land in
|
|
// the recipient's inbox with type='issue_subscribed' pointing at the new
|
|
// issue. Without this, subscribers would only see comment/status updates
|
|
// after the fact and miss the creation event itself — flagged in PR #3060
|
|
// review by the Emacs agent.
|
|
func TestAutopilotDispatchNotifiesSubscribersOnCreate(t *testing.T) {
|
|
ctx := context.Background()
|
|
title := fmt.Sprintf("Autopilot subscriber inbox %d", time.Now().UnixNano())
|
|
var autopilotID, issueID string
|
|
defer func() {
|
|
if issueID != "" {
|
|
testPool.Exec(ctx, `DELETE FROM inbox_item WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
|
}
|
|
if autopilotID != "" {
|
|
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
|
|
}
|
|
}()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Subscriber inbox autopilot",
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
"issue_title_template": title,
|
|
"subscribers": []map[string]any{
|
|
{"user_type": "member", "user_id": testUserID},
|
|
},
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var autopilot AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&autopilot); err != nil {
|
|
t.Fatalf("decode autopilot: %v", err)
|
|
}
|
|
autopilotID = autopilot.ID
|
|
|
|
queries := db.New(testPool)
|
|
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilotID))
|
|
if err != nil {
|
|
t.Fatalf("GetAutopilot: %v", err)
|
|
}
|
|
run, err := testHandler.AutopilotService.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "manual", nil)
|
|
if err != nil {
|
|
t.Fatalf("DispatchAutopilot: %v", err)
|
|
}
|
|
if run == nil || !run.IssueID.Valid {
|
|
t.Fatalf("dispatch run = %+v, want linked issue", run)
|
|
}
|
|
issueID = uuidToString(run.IssueID)
|
|
|
|
var inboxCount int
|
|
var inboxType, inboxTitle string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT count(*) FROM inbox_item
|
|
WHERE issue_id = $1 AND recipient_id = $2 AND type = 'issue_subscribed'
|
|
`, issueID, testUserID).Scan(&inboxCount); err != nil {
|
|
t.Fatalf("count inbox rows: %v", err)
|
|
}
|
|
if inboxCount != 1 {
|
|
t.Fatalf("inbox_item rows for subscriber = %d, want 1", inboxCount)
|
|
}
|
|
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT type, title FROM inbox_item
|
|
WHERE issue_id = $1 AND recipient_id = $2 AND type = 'issue_subscribed'
|
|
`, issueID, testUserID).Scan(&inboxType, &inboxTitle); err != nil {
|
|
t.Fatalf("load inbox row: %v", err)
|
|
}
|
|
if inboxType != "issue_subscribed" {
|
|
t.Fatalf("inbox type = %q, want issue_subscribed", inboxType)
|
|
}
|
|
if inboxTitle != title {
|
|
t.Fatalf("inbox title = %q, want %q (issue title)", inboxTitle, title)
|
|
}
|
|
}
|
|
|
|
// TestAutopilotDispatchSkipsInboxWhenNoSubscribers asserts the no-op path:
|
|
// an autopilot with an empty subscriber template must NOT create any inbox
|
|
// rows on dispatch — otherwise we'd be paging the workspace on every quiet
|
|
// autopilot run. The corresponding issue_subscriber rows are also expected
|
|
// to be absent (other-reason rows like creator/assignee are filtered out by
|
|
// the WHERE type = 'issue_subscribed' clause).
|
|
func TestAutopilotDispatchSkipsInboxWhenNoSubscribers(t *testing.T) {
|
|
ctx := context.Background()
|
|
title := fmt.Sprintf("Autopilot no-subscriber inbox %d", time.Now().UnixNano())
|
|
var autopilotID, issueID string
|
|
defer func() {
|
|
if issueID != "" {
|
|
testPool.Exec(ctx, `DELETE FROM inbox_item WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
|
}
|
|
if autopilotID != "" {
|
|
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
|
|
}
|
|
}()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "No-subscriber autopilot",
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
"issue_title_template": title,
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var autopilot AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&autopilot); err != nil {
|
|
t.Fatalf("decode autopilot: %v", err)
|
|
}
|
|
autopilotID = autopilot.ID
|
|
|
|
queries := db.New(testPool)
|
|
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilotID))
|
|
if err != nil {
|
|
t.Fatalf("GetAutopilot: %v", err)
|
|
}
|
|
run, err := testHandler.AutopilotService.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "manual", nil)
|
|
if err != nil {
|
|
t.Fatalf("DispatchAutopilot: %v", err)
|
|
}
|
|
if run == nil || !run.IssueID.Valid {
|
|
t.Fatalf("dispatch run = %+v, want linked issue", run)
|
|
}
|
|
issueID = uuidToString(run.IssueID)
|
|
|
|
var inboxCount int
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT count(*) FROM inbox_item
|
|
WHERE issue_id = $1 AND type = 'issue_subscribed'
|
|
`, issueID).Scan(&inboxCount); err != nil {
|
|
t.Fatalf("count inbox rows: %v", err)
|
|
}
|
|
if inboxCount != 0 {
|
|
t.Fatalf("issue_subscribed inbox rows = %d, want 0 (no subscribers)", inboxCount)
|
|
}
|
|
}
|
|
|
|
// TestDeleteAutopilotRemovesSubscribers guards the app-layer cleanup that
|
|
// replaced the dropped autopilot_subscriber → autopilot ON DELETE CASCADE:
|
|
// deleting an autopilot must also delete its subscriber template rows in the
|
|
// same transaction, leaving no orphans behind.
|
|
func TestDeleteAutopilotRemovesSubscribers(t *testing.T) {
|
|
ctx := context.Background()
|
|
var autopilotID string
|
|
defer func() {
|
|
if autopilotID != "" {
|
|
testPool.Exec(ctx, `DELETE FROM autopilot_subscriber WHERE autopilot_id = $1`, autopilotID)
|
|
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
|
|
}
|
|
}()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Delete-with-subscribers autopilot",
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
"subscribers": []map[string]any{
|
|
{"user_type": "member", "user_id": testUserID},
|
|
},
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var created AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode created: %v", err)
|
|
}
|
|
autopilotID = created.ID
|
|
|
|
var before int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM autopilot_subscriber WHERE autopilot_id = $1`, autopilotID).Scan(&before); err != nil {
|
|
t.Fatalf("count subscribers before delete: %v", err)
|
|
}
|
|
if before != 1 {
|
|
t.Fatalf("subscriber rows before delete = %d, want 1", before)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("DELETE", "/api/autopilots/"+autopilotID+"?workspace_id="+testWorkspaceID, nil)
|
|
req = withURLParam(req, "id", autopilotID)
|
|
testHandler.DeleteAutopilot(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteAutopilot: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var after int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM autopilot_subscriber WHERE autopilot_id = $1`, autopilotID).Scan(&after); err != nil {
|
|
t.Fatalf("count subscribers after delete: %v", err)
|
|
}
|
|
if after != 0 {
|
|
t.Fatalf("subscriber rows after delete = %d, want 0 (app-layer cleanup)", after)
|
|
}
|
|
|
|
var autopilotRows int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM autopilot WHERE id = $1`, autopilotID).Scan(&autopilotRows); err != nil {
|
|
t.Fatalf("count autopilot after delete: %v", err)
|
|
}
|
|
if autopilotRows != 0 {
|
|
t.Fatalf("autopilot rows after delete = %d, want 0", autopilotRows)
|
|
}
|
|
}
|