Files
multica/server/internal/handler/source_context_integration_test.go
LinYushen d13e76a557 fix(billing): prompt upgrade before quick create (MUL-6746) (#7625)
* fix(billing): prompt upgrade before quick create

* fix(billing): use public core export

* fix(billing): keep issue-limit recovery actionable

* test(handler): provide retry workspace context

* fix(billing): respect issue-limit prompt dismissal

* fix(billing): clarify issue-limit recovery paths
2026-08-27 16:55:03 +08:00

766 lines
37 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/multica-ai/multica/server/internal/entitlement"
"github.com/multica-ai/multica/server/internal/entitlement/entitlementtest"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/middleware"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
func TestWriteSourceContextErrorHidesInternalDetails(t *testing.T) {
recorder := httptest.NewRecorder()
(&Handler{}).writeSourceContextError(recorder, errors.New("postgres password=do-not-leak"), service.SourceContextLimitUsage{})
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError)
}
if strings.Contains(recorder.Body.String(), "do-not-leak") {
t.Fatalf("internal error leaked in response: %s", recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "failed to capture source context") {
t.Fatalf("generic source-context error missing: %s", recorder.Body.String())
}
}
func TestRetrySourceContextQuickCreateReturnsIssueLimitRecovery(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
var agentID string
if err := testPool.QueryRow(ctx, `
SELECT id FROM agent
WHERE workspace_id = $1 AND runtime_id IS NOT NULL AND archived_at IS NULL
ORDER BY created_at ASC LIMIT 1
`, testWorkspaceID).Scan(&agentID); err != nil {
t.Fatalf("load retry test agent: %v", err)
}
contextID := uuid.NewString()
payload, err := json.Marshal(service.QuickCreateContext{
Type: service.QuickCreateContextType,
Prompt: "retry source context at issue limit",
RequesterID: testUserID,
WorkspaceID: testWorkspaceID,
SourceContextID: contextID,
})
if err != nil {
t.Fatalf("marshal retry context: %v", err)
}
number := int(time.Now().UnixNano()%100000) + 9_200_000
var sourceIssueID, taskID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, creator_type, creator_id, number)
VALUES ($1, 'retry limit source', 'member', $2, $3)
RETURNING id
`, testWorkspaceID, testUserID, number).Scan(&sourceIssueID); err != nil {
t.Fatalf("insert retry source issue: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue_source_context WHERE id = $1`, contextID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE rerun_of_task_id = $1 OR id = $1`, taskID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, sourceIssueID)
})
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (
agent_id, runtime_id, status, priority, context,
originator_user_id, accountable_user_id
)
SELECT $1, runtime_id, 'failed', 0, $2, $3, $3
FROM agent WHERE id = $1
RETURNING id
`, agentID, payload, testUserID).Scan(&taskID); err != nil {
t.Fatalf("insert failed source-context task: %v", err)
}
if _, err := testPool.Exec(ctx, `
INSERT INTO issue_source_context (
id, workspace_id, origin_task_id, source_issue_id, anchor_comment_id,
captured_by_user_id, snapshot_version, snapshot, capture_digest, state
) VALUES ($1, $2, $3, $4, gen_random_uuid(), $5, 1, '{}'::jsonb, 'digest', 'pending')
`, contextID, testWorkspaceID, taskID, sourceIssueID, testUserID); err != nil {
t.Fatalf("insert pending source context: %v", err)
}
var issueCount int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&issueCount); err != nil {
t.Fatalf("count issues before retry: %v", err)
}
stub := entitlementtest.New()
stub.Set(uuid.MustParse(testWorkspaceID), entitlement.GateIssueCount, entitlement.Decision{
Gate: entitlement.Gate{Action: entitlement.ActionEnforce, Limit: &issueCount},
PolicyRevision: 43,
})
priorProvider := testHandler.TaskService.Entitlements
testHandler.TaskService.Entitlements = stub
t.Cleanup(func() {
testHandler.TaskService.Entitlements = priorProvider
})
recorder := httptest.NewRecorder()
request := withURLParam(newRequest(http.MethodPost, "/api/tasks/"+taskID+"/retry-source-context", nil), "taskId", taskID)
member, err := testHandler.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: util.MustParseUUID(testUserID),
WorkspaceID: util.MustParseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("load retry caller membership: %v", err)
}
request = request.WithContext(middleware.SetMemberContext(request.Context(), testWorkspaceID, member))
testHandler.RetrySourceContextQuickCreate(recorder, request)
if recorder.Code != http.StatusPaymentRequired {
t.Fatalf("source-context retry at limit = %d: %s", recorder.Code, recorder.Body.String())
}
var body struct {
Code string `json:"code"`
Limit int `json:"limit"`
PolicyRevision int `json:"policy_revision"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode retry issue-limit response: %v", err)
}
if body.Code != "issue_limit_reached" || body.Limit != issueCount || body.PolicyRevision != 43 {
t.Fatalf("retry issue-limit response = %+v", body)
}
var children int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_task_queue WHERE rerun_of_task_id = $1`, taskID).Scan(&children); err != nil {
t.Fatalf("count retry children: %v", err)
}
if children != 0 {
t.Fatalf("retry child count = %d, want 0", children)
}
var originTaskID string
if err := testPool.QueryRow(ctx, `SELECT origin_task_id FROM issue_source_context WHERE id = $1`, contextID).Scan(&originTaskID); err != nil {
t.Fatalf("load pending context after rejection: %v", err)
}
if originTaskID != taskID {
t.Fatalf("pending context moved to %s, want original task %s", originTaskID, taskID)
}
}
func TestCommentSourceContextLifecycle(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
// Go runs different packages concurrently against the same integration
// database. Serialize the two tests that deliberately invoke the global
// source-context intent sweeper; otherwise the service-package fake store
// can claim this test's rows (or vice versa) and invalidate both oracles.
cleanupLockConn, err := testPool.Acquire(ctx)
if err != nil {
t.Fatalf("acquire source-context cleanup test lock connection: %v", err)
}
if _, err := cleanupLockConn.Exec(ctx, `SELECT pg_advisory_lock($1)`, int64(0x53434f4e54455854)); err != nil {
cleanupLockConn.Release()
t.Fatalf("lock source-context cleanup tests: %v", err)
}
t.Cleanup(func() {
_, _ = cleanupLockConn.Exec(context.Background(), `SELECT pg_advisory_unlock($1)`, int64(0x53434f4e54455854))
cleanupLockConn.Release()
})
originalStorage := testHandler.Storage
originalDownloadMode := testHandler.cfg.AttachmentDownloadMode
originalBus := testHandler.Bus
store := &mockStorage{}
eventBus := events.New()
testHandler.Storage = store
testHandler.Bus = eventBus
testHandler.cfg.AttachmentDownloadMode = string(attachmentDownloadModeProxy)
t.Cleanup(func() {
testHandler.Storage = originalStorage
testHandler.Bus = originalBus
testHandler.cfg.AttachmentDownloadMode = originalDownloadMode
})
var updatedEvents []events.Event
eventBus.Subscribe(protocol.EventIssueUpdated, func(event events.Event) {
updatedEvents = append(updatedEvents, event)
})
number := int(time.Now().UnixNano()%100000) + 9_100_000
var sourceIssueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, description, creator_type, creator_id, number)
VALUES ($1, 'source issue', 'frozen issue body', 'member', $2, $3)
RETURNING id
`, testWorkspaceID, testUserID, number).Scan(&sourceIssueID); err != nil {
t.Fatalf("insert source issue: %v", err)
}
insertComment := func(content string, parent any) string {
t.Helper()
var id string
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, parent_id)
VALUES ($1, $2, 'member', $3, $4, $5)
RETURNING id
`, sourceIssueID, testWorkspaceID, testUserID, content, parent).Scan(&id); err != nil {
t.Fatalf("insert comment %q: %v", content, err)
}
return id
}
rootID := insertComment("root context", nil)
earlierSiblingID := insertComment("earlier sibling context", rootID)
earlierNestedID := insertComment("earlier nested context", earlierSiblingID)
replyID := insertComment("reply context", rootID)
selectedID := insertComment("selected context", rootID)
laterSiblingID := insertComment("later sibling excluded", rootID)
laterDescendantID := insertComment("later descendant excluded", selectedID)
insertAttachment := func(ownerCommentID any, filename, key, body string) string {
t.Helper()
store.put(key, []byte(body))
var id string
if err := testPool.QueryRow(ctx, `
INSERT INTO attachment (
workspace_id, issue_id, comment_id, uploader_type, uploader_id,
filename, url, content_type, size_bytes
) VALUES ($1, $2, $3, 'member', $4, $5, $6, 'text/plain', $7)
RETURNING id
`, testWorkspaceID, sourceIssueID, ownerCommentID, testUserID, filename, store.ObjectURL(key), len(body)).Scan(&id); err != nil {
t.Fatalf("insert attachment %q: %v", filename, err)
}
return id
}
issueAttachmentID := insertAttachment(nil, "issue.txt", "source/issue.txt", "issue attachment")
selectedAttachmentID := insertAttachment(selectedID, "selected.txt", "source/selected.txt", "selected attachment")
earlierSiblingAttachmentID := insertAttachment(earlierSiblingID, "earlier-sibling.txt", "source/earlier-sibling.txt", "earlier sibling attachment")
laterSiblingAttachmentID := insertAttachment(laterSiblingID, "later-sibling.txt", "source/later-sibling.txt", "later sibling attachment")
if _, err := testPool.Exec(ctx, `UPDATE issue SET description = $1 WHERE id = $2`,
"frozen issue body\n\n!file[issue.txt](/api/attachments/"+issueAttachmentID+"/download)", sourceIssueID); err != nil {
t.Fatalf("embed issue attachment in source description: %v", err)
}
var targetIssueID string
t.Cleanup(func() {
if targetIssueID != "" {
_, _ = testPool.Exec(context.Background(), `DELETE FROM attachment WHERE source_context_id IN (SELECT id FROM issue_source_context WHERE issue_id = $1)`, targetIssueID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue_source_context WHERE issue_id = $1`, targetIssueID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, targetIssueID)
}
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, sourceIssueID)
})
previewRecorder := httptest.NewRecorder()
previewRequest := withURLParam(newRequest(http.MethodGet, "/api/comments/"+selectedID+"/sub-issue-preview", nil), "commentId", selectedID)
testHandler.PreviewCommentSubIssue(previewRecorder, previewRequest)
if previewRecorder.Code != http.StatusOK {
t.Fatalf("preview = %d: %s", previewRecorder.Code, previewRecorder.Body.String())
}
var preview sourceContextPreviewResponse
if err := json.Unmarshal(previewRecorder.Body.Bytes(), &preview); err != nil {
t.Fatalf("decode preview: %v", err)
}
wantThreadHistory := []string{rootID, earlierSiblingID, earlierNestedID, replyID, selectedID}
if len(preview.CommentThread) != len(wantThreadHistory) {
t.Fatalf("preview thread history length = %d, want %d: %#v", len(preview.CommentThread), len(wantThreadHistory), preview.CommentThread)
}
for i, comment := range preview.CommentThread {
if comment.ID != wantThreadHistory[i] {
t.Fatalf("preview thread history[%d] = %s, want %s", i, comment.ID, wantThreadHistory[i])
}
if comment.Attachments == nil {
t.Fatalf("preview comment %s encoded attachments as null, want an array", comment.ID)
}
if comment.ID == laterSiblingID || comment.ID == laterDescendantID {
t.Fatalf("preview included comment after the selected boundary: %s", comment.ID)
}
}
if len(preview.SourceIssue.Attachments) != 1 || preview.SourceIssue.Attachments[0].ID != issueAttachmentID {
t.Fatalf("preview issue attachments = %#v, want only %s", preview.SourceIssue.Attachments, issueAttachmentID)
}
selectedPreview := preview.CommentThread[len(preview.CommentThread)-1]
if len(selectedPreview.Attachments) != 1 || selectedPreview.Attachments[0].ID != selectedAttachmentID {
t.Fatalf("preview selected attachments = %#v, want only %s", selectedPreview.Attachments, selectedAttachmentID)
}
var foundEarlierSiblingAttachment bool
for _, comment := range preview.CommentThread {
for _, attachment := range comment.Attachments {
if attachment.ID == earlierSiblingAttachmentID {
foundEarlierSiblingAttachment = true
}
if attachment.ID == laterSiblingAttachmentID {
t.Fatal("preview included attachment from a comment after the selected boundary")
}
}
}
if !foundEarlierSiblingAttachment {
t.Fatal("preview omitted attachment from earlier sibling in the selected thread")
}
// Revision-only churn is metadata, not canonical source content.
if _, err := testPool.Exec(ctx, `UPDATE comment SET revision = revision + 1 WHERE id = $1`, selectedID); err != nil {
t.Fatalf("bump revision: %v", err)
}
rebuilt, err := service.BuildSourceContext(ctx, testHandler.Queries, util.MustParseUUID(testWorkspaceID), util.MustParseUUID(selectedID))
if err != nil {
t.Fatalf("rebuild after revision-only change: %v", err)
}
if rebuilt.Token != preview.CaptureToken {
t.Fatalf("revision-only change altered token: %q != %q", rebuilt.Token, preview.CaptureToken)
}
// Canonical content changes reject the stale preview without creating a
// target. Restoring the exact content restores the original digest.
if _, err := testPool.Exec(ctx, `UPDATE comment SET content = 'changed before submit', revision = revision + 1, updated_at = now() WHERE id = $1`, earlierSiblingID); err != nil {
t.Fatalf("edit earlier thread comment before submit: %v", err)
}
staleRecorder := httptest.NewRecorder()
staleRequest := withURLParam(newRequest(http.MethodPost, "/api/comments/"+selectedID+"/sub-issues", map[string]any{
"mode": "manual", "capture_token": preview.CaptureToken,
"issue": map[string]any{"title": "must not be created", "status": "todo", "priority": "none"},
}), "commentId", selectedID)
testHandler.CreateCommentSubIssue(staleRecorder, staleRequest)
if staleRecorder.Code != http.StatusConflict {
t.Fatalf("stale source create = %d: %s", staleRecorder.Code, staleRecorder.Body.String())
}
var staleBody map[string]any
if err := json.Unmarshal(staleRecorder.Body.Bytes(), &staleBody); err != nil || staleBody["code"] != "source_context_changed" {
t.Fatalf("stale source body = %#v, err=%v", staleBody, err)
}
if _, err := testPool.Exec(ctx, `UPDATE comment SET content = 'earlier sibling context', revision = revision + 1, updated_at = now() WHERE id = $1`, earlierSiblingID); err != nil {
t.Fatalf("restore earlier thread comment: %v", err)
}
t.Run("full workspace skips source attachment copies and enqueue", func(t *testing.T) {
var agentID string
if err := testPool.QueryRow(ctx, `
SELECT id FROM agent
WHERE workspace_id = $1 AND runtime_id = $2 AND archived_at IS NULL
ORDER BY created_at ASC LIMIT 1
`, testWorkspaceID, testRuntimeID).Scan(&agentID); err != nil {
t.Fatalf("load source-context test agent: %v", err)
}
var originalMetadata string
if err := testPool.QueryRow(ctx, `SELECT metadata::text FROM agent_runtime WHERE id = $1`, testRuntimeID).Scan(&originalMetadata); err != nil {
t.Fatalf("load original runtime metadata: %v", err)
}
if _, err := testPool.Exec(ctx, `
UPDATE agent_runtime
SET metadata = '{"cli_version":"0.4.3","capabilities":["source_context_quick_create_v1"]}'::jsonb
WHERE id = $1
`, testRuntimeID); err != nil {
t.Fatalf("enable source-context capability: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `UPDATE agent_runtime SET metadata = $1::jsonb WHERE id = $2`, originalMetadata, testRuntimeID)
})
var issueCount int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&issueCount); err != nil {
t.Fatalf("count issues before source-context limit check: %v", err)
}
stub := entitlementtest.New()
stub.Set(uuid.MustParse(testWorkspaceID), entitlement.GateIssueCount, entitlement.Decision{
Gate: entitlement.Gate{Action: entitlement.ActionEnforce, Limit: &issueCount},
PolicyRevision: 41,
})
priorHandlerProvider := testHandler.Entitlements
priorTaskProvider := testHandler.TaskService.Entitlements
testHandler.Entitlements = stub
testHandler.TaskService.Entitlements = stub
t.Cleanup(func() {
testHandler.Entitlements = priorHandlerProvider
testHandler.TaskService.Entitlements = priorTaskProvider
})
beforeReads, beforeUploads := store.streamCopyCalls()
prompt := "source context must not enqueue while the workspace is full"
fullRecorder := httptest.NewRecorder()
fullRequest := withURLParam(newRequest(http.MethodPost, "/api/comments/"+selectedID+"/sub-issues", map[string]any{
"mode": "agent",
"capture_token": preview.CaptureToken,
"quick_create": map[string]any{
"agent_id": agentID,
"prompt": prompt,
},
}), "commentId", selectedID)
testHandler.CreateCommentSubIssue(fullRecorder, fullRequest)
if fullRecorder.Code != http.StatusPaymentRequired {
t.Fatalf("full source-context create = %d: %s", fullRecorder.Code, fullRecorder.Body.String())
}
var body struct {
Code string `json:"code"`
}
if err := json.Unmarshal(fullRecorder.Body.Bytes(), &body); err != nil || body.Code != "issue_limit_reached" {
t.Fatalf("full source-context response = %+v, err=%v", body, err)
}
afterReads, afterUploads := store.streamCopyCalls()
if afterReads != beforeReads || afterUploads != beforeUploads {
t.Fatalf("source attachments copied before rejection: reads %d->%d uploads %d->%d", beforeReads, afterReads, beforeUploads, afterUploads)
}
var queued int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_task_queue WHERE context->>'prompt' = $1`, prompt).Scan(&queued); err != nil {
t.Fatalf("count full source-context tasks: %v", err)
}
if queued != 0 {
t.Fatalf("full source-context task count = %d, want 0", queued)
}
})
// A damaged cross-issue parent chain is rejected instead of truncated.
var foreignIssueID, foreignRootID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, creator_type, creator_id, number)
VALUES ($1, 'foreign chain issue', 'member', $2, $3)
RETURNING id
`, testWorkspaceID, testUserID, number+1).Scan(&foreignIssueID); err != nil {
t.Fatalf("insert foreign issue: %v", err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content)
VALUES ($1, $2, 'member', $3, 'foreign root') RETURNING id
`, foreignIssueID, testWorkspaceID, testUserID).Scan(&foreignRootID); err != nil {
t.Fatalf("insert foreign root: %v", err)
}
if _, err := testPool.Exec(ctx, `UPDATE comment SET parent_id = $1 WHERE id = $2`, foreignRootID, selectedID); err != nil {
t.Fatalf("damage parent chain: %v", err)
}
if _, err := service.BuildSourceContext(ctx, testHandler.Queries, util.MustParseUUID(testWorkspaceID), util.MustParseUUID(selectedID)); !errors.Is(err, service.ErrSourceContextInvalid) {
t.Fatalf("damaged chain error = %v, want ErrSourceContextInvalid", err)
}
if _, err := testPool.Exec(ctx, `UPDATE comment SET parent_id = $1 WHERE id = $2`, rootID, selectedID); err != nil {
t.Fatalf("restore parent chain: %v", err)
}
if _, err := testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, foreignIssueID); err != nil {
t.Fatalf("delete foreign issue: %v", err)
}
createRecorder := httptest.NewRecorder()
createRequest := withURLParam(newRequest(http.MethodPost, "/api/comments/"+selectedID+"/sub-issues", map[string]any{
"mode": "manual",
"capture_token": preview.CaptureToken,
"issue": map[string]any{
"title": "new independent task", "description": "new instructions", "status": "todo", "priority": "none", "stage": 2,
},
}), "commentId", selectedID)
testHandler.CreateCommentSubIssue(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("manual source create = %d: %s", createRecorder.Code, createRecorder.Body.String())
}
var created IssueResponse
if err := json.Unmarshal(createRecorder.Body.Bytes(), &created); err != nil {
t.Fatalf("decode created issue: %v", err)
}
targetIssueID = created.ID
if created.ParentIssueID == nil || *created.ParentIssueID != sourceIssueID || created.Stage == nil || *created.Stage != 2 {
t.Fatalf("created relation = parent %#v stage %#v", created.ParentIssueID, created.Stage)
}
var contextID string
var storedSnapshot []byte
if err := testPool.QueryRow(ctx, `SELECT id, snapshot FROM issue_source_context WHERE issue_id = $1 AND state = 'attached'`, targetIssueID).Scan(&contextID, &storedSnapshot); err != nil {
t.Fatalf("load attached context: %v", err)
}
var snapshot service.SourceContextSnapshot
if err := json.Unmarshal(storedSnapshot, &snapshot); err != nil {
t.Fatalf("decode stored snapshot: %v", err)
}
if snapshot.Version != service.SourceContextVersion || snapshot.CapturedByUserID != testUserID || snapshot.CapturedAt == "" {
t.Fatalf("stored capture metadata = version %d user %q at %q", snapshot.Version, snapshot.CapturedByUserID, snapshot.CapturedAt)
}
selectedSnapshot := snapshot.CommentThread[len(snapshot.CommentThread)-1]
if len(snapshot.SourceIssue.Attachments) != 1 || len(selectedSnapshot.Attachments) != 1 {
t.Fatalf("stored clone ownership = issue %#v selected %#v", snapshot.SourceIssue.Attachments, selectedSnapshot.Attachments)
}
cloneID := selectedSnapshot.Attachments[0].ID
if cloneID == selectedAttachmentID || selectedSnapshot.Attachments[0].SourceAttachmentID != selectedAttachmentID {
t.Fatalf("stored clone ids = %#v", selectedSnapshot.Attachments[0])
}
cloneKey := "workspaces/" + testWorkspaceID + "/source-context/" + cloneID + ".txt"
deleteCloneRecorder := httptest.NewRecorder()
deleteCloneRequest := withURLParam(newRequest(http.MethodDelete, "/api/attachments/"+cloneID, nil), "id", cloneID)
testHandler.DeleteAttachment(deleteCloneRecorder, deleteCloneRequest)
if deleteCloneRecorder.Code != http.StatusNotFound {
t.Fatalf("delete immutable source-context clone = %d: %s", deleteCloneRecorder.Code, deleteCloneRecorder.Body.String())
}
if _, err := store.GetReader(ctx, cloneKey); err != nil {
t.Fatalf("immutable source-context clone missing after direct delete: %v", err)
}
// Editing live source changes detail state without mutating the stored bytes.
if _, err := testPool.Exec(ctx, `UPDATE comment SET content = 'edited after capture', revision = revision + 1, updated_at = now() WHERE id = $1`, rootID); err != nil {
t.Fatalf("edit source root: %v", err)
}
issue, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(targetIssueID))
if err != nil {
t.Fatalf("load target issue: %v", err)
}
detail, err := testHandler.issueSourceContextDetail(ctx, issue)
if err != nil {
t.Fatalf("load changed source detail: %v", err)
}
if detail.DisplayState != "changed" || detail.CommentThreadState != "changed" || !detail.CanOpenCurrentSource || detail.Snapshot.CommentThread[0].Content != "root context" {
t.Fatalf("changed detail = display %q path %q snapshot root %q", detail.DisplayState, detail.CommentThreadState, detail.Snapshot.CommentThread[0].Content)
}
if len(detail.ChangeReasons) != 1 || detail.ChangeReasons[0] != sourceContextChangeCommentThread {
t.Fatalf("changed detail reasons = %v, want comment thread history", detail.ChangeReasons)
}
if !reflect.DeepEqual(detail.ChangeDetails.ChangedCommentIDs, []string{rootID}) {
t.Fatalf("changed comment ids = %v, want root comment", detail.ChangeDetails.ChangedCommentIDs)
}
selectedDetailSnapshot := detail.Snapshot.CommentThread[len(detail.Snapshot.CommentThread)-1]
if got := selectedDetailSnapshot.Attachments[0].ID; got != cloneID {
t.Fatalf("detail snapshot attachment id = %q, want clone id %q", got, cloneID)
}
if selectedDetailSnapshot.Revision == 0 || selectedDetailSnapshot.UpdatedAt == "" {
t.Fatalf("detail snapshot lost capture metadata: %#v", selectedDetailSnapshot)
}
var storedSnapshotAfter []byte
if err := testPool.QueryRow(ctx, `SELECT snapshot FROM issue_source_context WHERE issue_id = $1`, targetIssueID).Scan(&storedSnapshotAfter); err != nil {
t.Fatalf("reload snapshot: %v", err)
}
if string(storedSnapshotAfter) != string(storedSnapshot) {
t.Fatal("stored snapshot changed after live source edit")
}
// Thread drift and anchor-comment existence are independent. Re-rooting an
// earlier comment keeps the anchor openable while changing the thread.
if _, err := testPool.Exec(ctx, `UPDATE comment SET parent_id = NULL WHERE id = $1`, replyID); err != nil {
t.Fatalf("re-root source reply: %v", err)
}
changedPathDetail, err := testHandler.issueSourceContextDetail(ctx, issue)
if err != nil {
t.Fatalf("load changed-path detail: %v", err)
}
if changedPathDetail.CommentThreadState != "changed" || !changedPathDetail.CanOpenCurrentSource || changedPathDetail.CurrentSource == nil {
t.Fatalf("changed-thread detail = thread %q open=%v current=%#v", changedPathDetail.CommentThreadState, changedPathDetail.CanOpenCurrentSource, changedPathDetail.CurrentSource)
}
foundThreadHistoryReason := false
for _, reason := range changedPathDetail.ChangeReasons {
if reason == sourceContextChangeCommentThread {
foundThreadHistoryReason = true
}
}
if !foundThreadHistoryReason {
t.Fatalf("changed-thread reasons = %v, want comment thread", changedPathDetail.ChangeReasons)
}
if _, err := testPool.Exec(ctx, `UPDATE comment SET parent_id = $1 WHERE id = $2`, rootID, replyID); err != nil {
t.Fatalf("restore source reply parent: %v", err)
}
// Captured attachments are independent clones. Removing the attachment node
// from the live description reports the user-visible reference change even
// though the issue-owned attachment row remains. The cloned bytes remain
// downloadable from the captured context.
issueCloneID := snapshot.SourceIssue.Attachments[0].ID
if _, err := testPool.Exec(ctx, `UPDATE issue SET description = 'frozen issue body', revision = revision + 1, updated_at = now() WHERE id = $1`, sourceIssueID); err != nil {
t.Fatalf("remove live issue attachment reference: %v", err)
}
attachmentDetail, err := testHandler.issueSourceContextDetail(ctx, issue)
if err != nil {
t.Fatalf("load attachment-changed detail: %v", err)
}
if attachmentDetail.SourceIssueState != "changed" || !attachmentDetail.CanOpenCurrentSource {
t.Fatalf("attachment detail = issue %q open=%v", attachmentDetail.SourceIssueState, attachmentDetail.CanOpenCurrentSource)
}
foundAttachmentReason := false
for _, reason := range attachmentDetail.ChangeReasons {
if reason == sourceContextChangeIssueDescriptionAttachments {
foundAttachmentReason = true
}
}
if !foundAttachmentReason {
t.Fatalf("attachment reasons = %v, want issue description attachments", attachmentDetail.ChangeReasons)
}
if !reflect.DeepEqual(attachmentDetail.ChangeDetails.DescriptionAttachmentChanges, []sourceContextDescriptionAttachmentChange{{
Kind: "removed", AttachmentID: issueAttachmentID, Filename: "issue.txt",
}}) {
t.Fatalf("attachment changes = %#v, want removed issue.txt from description", attachmentDetail.ChangeDetails.DescriptionAttachmentChanges)
}
issueDownloadRecorder := httptest.NewRecorder()
issueDownloadRequest := withURLParam(newRequest(http.MethodGet, "/api/attachments/"+issueCloneID+"/download", nil), "id", issueCloneID)
testHandler.DownloadAttachment(issueDownloadRecorder, issueDownloadRequest)
if issueDownloadRecorder.Code != http.StatusOK || issueDownloadRecorder.Body.String() != "issue attachment" {
t.Fatalf("snapshot issue clone download after live deletion = %d %q", issueDownloadRecorder.Code, issueDownloadRecorder.Body.String())
}
// Once the anchor comment is deleted, issue drift is still reported
// independently. The viewer must not claim the source issue is unchanged
// merely because it can no longer rebuild the comment thread history.
if _, err := testPool.Exec(ctx, `UPDATE issue SET title = 'source issue changed after capture', revision = revision + 1, updated_at = now() WHERE id = $1`, sourceIssueID); err != nil {
t.Fatalf("edit source issue: %v", err)
}
if _, err := testPool.Exec(ctx, `DELETE FROM comment WHERE id = $1`, selectedID); err != nil {
t.Fatalf("delete anchor comment: %v", err)
}
deletedPathDetail, err := testHandler.issueSourceContextDetail(ctx, issue)
if err != nil {
t.Fatalf("load deleted-path detail: %v", err)
}
if deletedPathDetail.CommentThreadState != "changed" || deletedPathDetail.AnchorCommentState != "deleted" || deletedPathDetail.SourceIssueState != "changed" || deletedPathDetail.CanOpenCurrentSource {
t.Fatalf("deleted-anchor detail = thread %q anchor %q issue %q open=%v", deletedPathDetail.CommentThreadState, deletedPathDetail.AnchorCommentState, deletedPathDetail.SourceIssueState, deletedPathDetail.CanOpenCurrentSource)
}
if !reflect.DeepEqual(deletedPathDetail.ChangeDetails.RemovedCommentIDs, []string{selectedID}) {
t.Fatalf("deleted-anchor comment ids = %v, want anchor comment", deletedPathDetail.ChangeDetails.RemovedCommentIDs)
}
if !reflect.DeepEqual(deletedPathDetail.ChangeDetails.ChangedCommentIDs, []string{rootID}) {
t.Fatalf("deleted-anchor changed comment ids = %v, want edited root comment", deletedPathDetail.ChangeDetails.ChangedCommentIDs)
}
// Deleting the source explicitly detaches the target, clears stage, bumps
// revision, and leaves its immutable context readable.
beforeRevision := issue.Revision
deleteRecorder := httptest.NewRecorder()
testHandler.DeleteIssue(deleteRecorder, withURLParam(newRequest(http.MethodDelete, "/api/issues/"+sourceIssueID, nil), "id", sourceIssueID))
if deleteRecorder.Code != http.StatusNoContent {
t.Fatalf("delete source issue = %d: %s", deleteRecorder.Code, deleteRecorder.Body.String())
}
sourceIssueID = ""
detached, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(targetIssueID))
if err != nil {
t.Fatalf("load detached target: %v", err)
}
if detached.ParentIssueID.Valid || detached.Stage.Valid || detached.Revision != beforeRevision+1 {
t.Fatalf("detached target = parent %v stage %v revision %d, want null/null/%d", detached.ParentIssueID.Valid, detached.Stage.Valid, detached.Revision, beforeRevision+1)
}
if len(updatedEvents) != 1 {
t.Fatalf("detached child update events = %d, want 1", len(updatedEvents))
}
updatePayload, ok := updatedEvents[0].Payload.(map[string]any)
if !ok {
t.Fatalf("detached child update payload = %#v", updatedEvents[0].Payload)
}
updatedIssue, ok := updatePayload["issue"].(IssueResponse)
if !ok || updatedIssue.ID != targetIssueID || updatedIssue.ParentIssueID != nil || updatedIssue.Stage != nil {
t.Fatalf("detached child update issue = %#v", updatePayload["issue"])
}
deletedDetail, err := testHandler.issueSourceContextDetail(ctx, detached)
if err != nil {
t.Fatalf("load deleted-source detail: %v", err)
}
if deletedDetail.SourceIssueState != "deleted" || deletedDetail.CanOpenCurrentSource {
t.Fatalf("deleted-source detail = state %q open=%v", deletedDetail.SourceIssueState, deletedDetail.CanOpenCurrentSource)
}
downloadRecorder := httptest.NewRecorder()
downloadRequest := withURLParam(newRequest(http.MethodGet, "/api/attachments/"+cloneID+"/download", nil), "id", cloneID)
testHandler.DownloadAttachment(downloadRecorder, downloadRequest)
if downloadRecorder.Code != http.StatusOK || downloadRecorder.Body.String() != "selected attachment" {
t.Fatalf("snapshot clone download after source deletion = %d %q", downloadRecorder.Code, downloadRecorder.Body.String())
}
// Deleting the target removes DB ownership atomically and leaves durable
// intents for the legacy no-result bulk object deletion path. The sweeper
// then reclaims exactly those now-unreferenced clone objects.
targetDeleteRecorder := httptest.NewRecorder()
testHandler.DeleteIssue(targetDeleteRecorder, withURLParam(newRequest(http.MethodDelete, "/api/issues/"+targetIssueID, nil), "id", targetIssueID))
if targetDeleteRecorder.Code != http.StatusNoContent {
t.Fatalf("delete target issue = %d: %s", targetDeleteRecorder.Code, targetDeleteRecorder.Body.String())
}
targetIssueID = ""
var contextExists, cloneExists bool
if err := testPool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM issue_source_context WHERE id = $1)`, contextID).Scan(&contextExists); err != nil {
t.Fatalf("check deleted context: %v", err)
}
if err := testPool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM attachment WHERE source_context_id = $1)`, contextID).Scan(&cloneExists); err != nil {
t.Fatalf("check deleted clones: %v", err)
}
var intentCount int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM issue_source_context_object_intent WHERE source_context_id = $1`, contextID).Scan(&intentCount); err != nil {
t.Fatalf("count target delete intents: %v", err)
}
if contextExists || cloneExists || intentCount != 3 {
t.Fatalf("target cleanup = context=%v clones=%v intents=%d, want false/false/3", contextExists, cloneExists, intentCount)
}
// Keep the due intents and the sweep behind a table lock. A developer
// server may be running against this worktree database and its background
// sweeper does not participate in the test-only advisory lock above.
cleanupTx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("begin isolated target intent cleanup: %v", err)
}
defer cleanupTx.Rollback(ctx)
if _, err := cleanupTx.Exec(ctx, `LOCK TABLE issue_source_context_object_intent IN SHARE ROW EXCLUSIVE MODE`); err != nil {
t.Fatalf("lock target intent table: %v", err)
}
if _, err := cleanupTx.Exec(ctx, `
UPDATE issue_source_context_object_intent
SET created_at = now() - interval '2 hours', next_attempt_at = '-infinity'::timestamptz
WHERE source_context_id = $1
`, contextID); err != nil {
t.Fatalf("age target delete intents: %v", err)
}
originalObjectStore := testHandler.TaskService.SourceContextStorage
originalQueries := testHandler.TaskService.Queries
testHandler.TaskService.SourceContextStorage = store
testHandler.TaskService.Queries = originalQueries.WithTx(cleanupTx)
cleaned, err := testHandler.TaskService.CleanupSourceContextObjectIntents(ctx, 10)
testHandler.TaskService.SourceContextStorage = originalObjectStore
testHandler.TaskService.Queries = originalQueries
if err != nil || cleaned < 3 {
t.Fatalf("cleanup target delete intents = %d, err=%v", cleaned, err)
}
if err := cleanupTx.Commit(ctx); err != nil {
t.Fatalf("commit isolated target intent cleanup: %v", err)
}
if _, err := store.GetReader(ctx, cloneKey); err == nil {
t.Fatal("target clone object still readable after durable cleanup")
}
}
func TestBatchDeleteParentAndChildPublishesOnlySurvivingDetach(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
number := int(time.Now().UnixNano()%100000) + 9_200_000
insertIssue := func(title string, parent any, stage any) string {
t.Helper()
var id string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, creator_type, creator_id, number, parent_issue_id, stage)
VALUES ($1, $2, 'member', $3, $4, $5, $6) RETURNING id
`, testWorkspaceID, title, testUserID, number, parent, stage).Scan(&id); err != nil {
t.Fatalf("insert %s: %v", title, err)
}
number++
return id
}
parentID := insertIssue("batch detach parent", nil, nil)
childID := insertIssue("batch deleted child", parentID, 1)
grandchildID := insertIssue("batch surviving grandchild", childID, 2)
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = ANY($1::uuid[])`, []string{grandchildID, childID, parentID})
})
originalBus := testHandler.Bus
eventBus := events.New()
testHandler.Bus = eventBus
t.Cleanup(func() { testHandler.Bus = originalBus })
var updatedIDs []string
eventBus.Subscribe(protocol.EventIssueUpdated, func(event events.Event) {
payload, _ := event.Payload.(map[string]any)
issue, _ := payload["issue"].(IssueResponse)
updatedIDs = append(updatedIDs, issue.ID)
})
recorder := httptest.NewRecorder()
testHandler.BatchDeleteIssues(recorder, newRequest(http.MethodPost, "/api/issues/batch-delete", map[string]any{
"issue_ids": []string{parentID, childID, parentID},
}))
if recorder.Code != http.StatusOK {
t.Fatalf("batch delete = %d: %s", recorder.Code, recorder.Body.String())
}
var response map[string]int
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil || response["deleted"] != 2 {
t.Fatalf("batch delete response = %#v, err=%v", response, err)
}
grandchild, err := testHandler.Queries.GetIssue(ctx, util.MustParseUUID(grandchildID))
if err != nil {
t.Fatalf("load surviving grandchild: %v", err)
}
if grandchild.ParentIssueID.Valid || grandchild.Stage.Valid {
t.Fatalf("surviving grandchild relation = parent %v stage %v", grandchild.ParentIssueID.Valid, grandchild.Stage.Valid)
}
if len(updatedIDs) != 1 || updatedIDs[0] != grandchildID {
t.Fatalf("batch detach update ids = %#v, want only %s", updatedIDs, grandchildID)
}
}