Files
multica/server/internal/handler/issue_revision_test.go
d40585a004 MUL-7250: fix(server): suppress no-op issue metadata writes (#8287)
* fix(server): suppress no-op issue metadata writes

Co-authored-by: multica-agent <[email protected]>

* fix(server): narrow issue metadata no-op reload

Co-authored-by: multica-agent <[email protected]>

* test(server): trim metadata no-op coverage

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-11 13:34:33 +08:00

999 lines
40 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/testutil"
"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"
)
var errInjectedIssueAttachmentLink = errors.New("injected issue attachment link failure")
type failIssueAttachmentLinkTxStarter struct {
inner txStarter
}
func (s failIssueAttachmentLinkTxStarter) Begin(ctx context.Context) (pgx.Tx, error) {
tx, err := s.inner.Begin(ctx)
if err != nil {
return nil, err
}
return &failIssueAttachmentLinkTx{Tx: tx}, nil
}
type failIssueAttachmentLinkTx struct {
pgx.Tx
}
func (tx *failIssueAttachmentLinkTx) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
if strings.Contains(sql, "LinkAttachmentsToIssue") {
return failingIssueAttachmentLinkRow{}
}
return tx.Tx.QueryRow(ctx, sql, args...)
}
type failingIssueAttachmentLinkRow struct{}
func (failingIssueAttachmentLinkRow) Scan(...any) error {
return errInjectedIssueAttachmentLink
}
type pauseIssueAttachmentLinkTxStarter struct {
inner txStarter
reached chan<- struct{}
release <-chan struct{}
}
func (s pauseIssueAttachmentLinkTxStarter) Begin(ctx context.Context) (pgx.Tx, error) {
tx, err := s.inner.Begin(ctx)
if err != nil {
return nil, err
}
return &pauseIssueAttachmentLinkTx{
Tx: tx,
reached: s.reached,
release: s.release,
}, nil
}
type pauseIssueAttachmentLinkTx struct {
pgx.Tx
reached chan<- struct{}
release <-chan struct{}
}
func (tx *pauseIssueAttachmentLinkTx) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
if strings.Contains(sql, "LinkAttachmentsToIssue") {
tx.reached <- struct{}{}
select {
case <-tx.release:
case <-ctx.Done():
return failingIssueAttachmentLinkRow{}
}
}
return tx.Tx.QueryRow(ctx, sql, args...)
}
func insertWorkflowTestIssue(t *testing.T, title string, number int) string {
t.Helper()
var id string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO issue (workspace_id, title, creator_type, creator_id, number)
VALUES ($1, $2, 'member', $3, $4)
RETURNING id
`, testWorkspaceID, title, testUserID, number).Scan(&id); err != nil {
t.Fatalf("insert workflow issue: %v", err)
}
return id
}
func TestRevisionConflictsPreserveLatestIssueAndComment(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
issueID := insertWorkflowTestIssue(t, "revision original", int(time.Now().UnixNano()%100000)+8_750_000)
var commentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content)
VALUES ($1, $2, 'member', $3, 'comment original')
RETURNING id
`, issueID, testWorkspaceID, testUserID).Scan(&commentID); err != nil {
t.Fatalf("insert revision comment: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM comment WHERE id = $1`, commentID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
updateIssue := func(title, base string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := withURLParam(newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{
"title": title, "title_base": base,
}), "id", issueID)
testHandler.UpdateIssue(w, req)
return w
}
if w := updateIssue("revision latest", "revision original"); w.Code != http.StatusOK {
t.Fatalf("first issue update = %d: %s", w.Code, w.Body.String())
}
if w := updateIssue("revision latest", "revision latest"); w.Code != http.StatusOK {
t.Fatalf("no-op issue update = %d: %s", w.Code, w.Body.String())
}
if w := updateIssue("revision stale overwrite", "revision original"); w.Code != http.StatusConflict {
t.Fatalf("stale issue update = %d, want 409: %s", w.Code, w.Body.String())
}
var title string
var issueRevision int64
if err := testPool.QueryRow(ctx, `SELECT title, revision FROM issue WHERE id = $1`, issueID).Scan(&title, &issueRevision); err != nil {
t.Fatalf("reload revision issue: %v", err)
}
if title != "revision latest" || issueRevision != 2 {
t.Fatalf("issue after stale write = (%q, %d), want latest/2", title, issueRevision)
}
legacyIssue := httptest.NewRecorder()
testHandler.UpdateIssue(legacyIssue, withURLParam(newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{
"title": "legacy issue update",
}), "id", issueID))
if legacyIssue.Code != http.StatusOK {
t.Fatalf("legacy issue update = %d: %s", legacyIssue.Code, legacyIssue.Body.String())
}
if err := testPool.QueryRow(ctx, `SELECT title, revision FROM issue WHERE id = $1`, issueID).Scan(&title, &issueRevision); err != nil {
t.Fatalf("reload legacy issue update: %v", err)
}
if title != "legacy issue update" || issueRevision != 3 {
t.Fatalf("legacy issue update = (%q, %d), want legacy issue update/3", title, issueRevision)
}
updateComment := func(content, base string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := withURLParam(newRequest(http.MethodPut, "/api/comments/"+commentID, map[string]any{
"content": content, "content_base": base,
}), "commentId", commentID)
testHandler.UpdateComment(w, req)
return w
}
if w := updateComment("comment latest", "comment original"); w.Code != http.StatusOK {
t.Fatalf("first comment update = %d: %s", w.Code, w.Body.String())
}
if w := updateComment("comment latest", "comment latest"); w.Code != http.StatusOK {
t.Fatalf("no-op comment update = %d: %s", w.Code, w.Body.String())
}
if w := updateComment("comment stale overwrite", "comment original"); w.Code != http.StatusConflict {
t.Fatalf("stale comment update = %d, want 409: %s", w.Code, w.Body.String())
}
var content string
var commentRevision int64
if err := testPool.QueryRow(ctx, `SELECT content, revision FROM comment WHERE id = $1`, commentID).Scan(&content, &commentRevision); err != nil {
t.Fatalf("reload revision comment: %v", err)
}
if content != "comment latest" || commentRevision != 2 {
t.Fatalf("comment after stale write = (%q, %d), want latest/2", content, commentRevision)
}
var attachmentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO attachment (
workspace_id, issue_id, uploader_type, uploader_id,
filename, url, content_type, size_bytes
)
VALUES ($1, $2, 'member', $3, 'revision.txt', '/revision.txt', 'text/plain', 1)
RETURNING id
`, testWorkspaceID, issueID, testUserID).Scan(&attachmentID); err != nil {
t.Fatalf("insert revision attachment: %v", err)
}
updateAttachments := func() *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := withURLParam(newRequest(http.MethodPut, "/api/comments/"+commentID, map[string]any{
"content": "comment latest",
"attachment_ids": []string{attachmentID},
"content_base": "comment latest",
}), "commentId", commentID)
testHandler.UpdateComment(w, req)
return w
}
if w := updateAttachments(); w.Code != http.StatusOK {
t.Fatalf("attachment-only comment update = %d: %s", w.Code, w.Body.String())
}
if w := updateAttachments(); w.Code != http.StatusOK {
t.Fatalf("attachment no-op comment update = %d: %s", w.Code, w.Body.String())
}
if err := testPool.QueryRow(ctx, `SELECT revision FROM comment WHERE id = $1`, commentID).Scan(&commentRevision); err != nil {
t.Fatalf("reload attachment comment revision: %v", err)
}
if commentRevision != 3 {
t.Fatalf("comment revision after attachment update = %d, want 3", commentRevision)
}
legacyComment := httptest.NewRecorder()
testHandler.UpdateComment(legacyComment, withURLParam(newRequest(http.MethodPut, "/api/comments/"+commentID, map[string]any{
"content": "legacy comment update",
}), "commentId", commentID))
if legacyComment.Code != http.StatusOK {
t.Fatalf("legacy comment update = %d: %s", legacyComment.Code, legacyComment.Body.String())
}
if err := testPool.QueryRow(ctx, `SELECT content, revision FROM comment WHERE id = $1`, commentID).Scan(&content, &commentRevision); err != nil {
t.Fatalf("reload legacy comment update: %v", err)
}
if content != "legacy comment update" || commentRevision != 4 {
t.Fatalf("legacy comment update = (%q, %d), want legacy comment update/4", content, commentRevision)
}
}
func TestTextBaselinesIgnoreUnrelatedAggregateRevisionChanges(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
issueID := insertWorkflowTestIssue(t, "baseline title", int(time.Now().UnixNano()%100000)+8_755_000)
if _, err := testPool.Exec(ctx, `UPDATE issue SET description = 'baseline description' WHERE id = $1`, issueID); err != nil {
t.Fatalf("seed issue description: %v", err)
}
var commentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content)
VALUES ($1, $2, 'member', $3, 'baseline comment')
RETURNING id
`, issueID, testWorkspaceID, testUserID).Scan(&commentID); err != nil {
t.Fatalf("insert baseline comment: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM comment WHERE id = $1`, commentID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
// An unrelated priority write advances the aggregate owner revision.
priority := httptest.NewRecorder()
testHandler.UpdateIssue(priority, withURLParam(newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{
"priority": "high",
}), "id", issueID))
if priority.Code != http.StatusOK {
t.Fatalf("unrelated issue update = %d: %s", priority.Code, priority.Body.String())
}
text := httptest.NewRecorder()
testHandler.UpdateIssue(text, withURLParam(newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{
"title": "edited title",
"title_base": "baseline title",
"description": "edited description",
"description_base": "baseline description",
}), "id", issueID))
if text.Code != http.StatusOK {
t.Fatalf("text update after unrelated revision = %d: %s", text.Code, text.Body.String())
}
staleTitle := httptest.NewRecorder()
testHandler.UpdateIssue(staleTitle, withURLParam(newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{
"title": "stale title overwrite",
"title_base": "baseline title",
}), "id", issueID))
if staleTitle.Code != http.StatusConflict {
t.Fatalf("true issue title conflict = %d, want 409: %s", staleTitle.Code, staleTitle.Body.String())
}
// Descriptions are last-write-wins (MUL-6971): the baseline is merge
// metadata only, so a stale base overwrites instead of returning 409.
staleDescription := httptest.NewRecorder()
testHandler.UpdateIssue(staleDescription, withURLParam(newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{
"description": "stale description overwrite",
"description_base": "baseline description",
}), "id", issueID))
if staleDescription.Code != http.StatusOK {
t.Fatalf("stale issue description overwrite = %d, want 200: %s", staleDescription.Code, staleDescription.Body.String())
}
var storedDescription string
if err := testPool.QueryRow(ctx, `SELECT description FROM issue WHERE id = $1`, issueID).Scan(&storedDescription); err != nil {
t.Fatalf("reload description after stale overwrite: %v", err)
}
if storedDescription != "stale description overwrite" {
t.Fatalf("description after stale overwrite = %q, want the overwrite", storedDescription)
}
// A reaction/resolve-style aggregate bump on the comment must likewise not
// reject a body edit whose content baseline is still current.
if _, err := testPool.Exec(ctx, `UPDATE comment SET revision = revision + 1 WHERE id = $1`, commentID); err != nil {
t.Fatalf("bump comment revision: %v", err)
}
comment := httptest.NewRecorder()
testHandler.UpdateComment(comment, withURLParam(newRequest(http.MethodPut, "/api/comments/"+commentID, map[string]any{
"content": "edited comment",
"content_base": "baseline comment",
}), "commentId", commentID))
if comment.Code != http.StatusOK {
t.Fatalf("comment edit after unrelated revision = %d: %s", comment.Code, comment.Body.String())
}
stale := httptest.NewRecorder()
testHandler.UpdateComment(stale, withURLParam(newRequest(http.MethodPut, "/api/comments/"+commentID, map[string]any{
"content": "stale overwrite",
"content_base": "baseline comment",
}), "commentId", commentID))
if stale.Code != http.StatusConflict {
t.Fatalf("true comment content conflict = %d, want 409: %s", stale.Code, stale.Body.String())
}
}
// A description stored with surrounding whitespace — every CLI/integration
// write that keeps a trailing newline — used to reject EVERY edit from the web
// editor, which sends its base trimmed. No second writer was involved, and the
// rejection repeated for the life of the session (MUL-6971).
func TestDescriptionEditAcceptsATrimmedBaseOfUntrimmedStoredMarkdown(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
// Stored with a trailing newline — what every CLI/integration write that
// keeps one leaves behind.
issueID := dbfx.Issue(t, "untrimmed baseline", testutil.Cols{
"description": "roadmap body\n",
})
testutil.Call(t, testHandler.UpdateIssue, testutil.WithURLParams(
newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{
"description": "roadmap body edited",
// What the editor actually sends: the stored Markdown, trimmed.
"description_base": "roadmap body",
}),
"id", issueID,
)).Want(http.StatusOK)
var stored string
dbfx.QueryRow(t, `SELECT description FROM issue WHERE id = $1`, issueID).Scan(&stored)
if stored != "roadmap body edited" {
t.Fatalf("stored description = %q, want the edit", stored)
}
}
func TestConcurrentRevisionWritesHaveExactlyOneWinner(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
issueID := insertWorkflowTestIssue(t, "concurrent revision", int(time.Now().UnixNano()%100000)+8_760_000)
var commentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content)
VALUES ($1, $2, 'member', $3, 'concurrent original')
RETURNING id
`, issueID, testWorkspaceID, testUserID).Scan(&commentID); err != nil {
t.Fatalf("insert concurrent comment: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM comment WHERE id = $1`, commentID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
assertOneWinner := func(run func(value string) int) {
t.Helper()
start := make(chan struct{})
results := make(chan int, 2)
for _, value := range []string{"writer-a", "writer-b"} {
go func() {
<-start
results <- run(value)
}()
}
close(start)
okCount, conflictCount := 0, 0
for range 2 {
switch <-results {
case http.StatusOK:
okCount++
case http.StatusConflict:
conflictCount++
}
}
if okCount != 1 || conflictCount != 1 {
t.Fatalf("concurrent outcomes = ok:%d conflict:%d, want 1/1", okCount, conflictCount)
}
}
assertOneWinner(func(title string) int {
w := httptest.NewRecorder()
req := withURLParam(newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{
"title": title, "title_base": "concurrent revision",
}), "id", issueID)
testHandler.UpdateIssue(w, req)
return w.Code
})
assertOneWinner(func(content string) int {
w := httptest.NewRecorder()
req := withURLParam(newRequest(http.MethodPut, "/api/comments/"+commentID, map[string]any{
"content": content, "content_base": "concurrent original",
}), "commentId", commentID)
testHandler.UpdateComment(w, req)
return w.Code
})
var issueRevision, commentRevision int64
if err := testPool.QueryRow(ctx, `SELECT revision FROM issue WHERE id = $1`, issueID).Scan(&issueRevision); err != nil {
t.Fatalf("reload concurrent issue: %v", err)
}
if err := testPool.QueryRow(ctx, `SELECT revision FROM comment WHERE id = $1`, commentID).Scan(&commentRevision); err != nil {
t.Fatalf("reload concurrent comment: %v", err)
}
// The winning comment edit is semantic issue activity, so it advances the
// parent issue revision once in addition to the winning issue title edit.
if issueRevision != 3 || commentRevision != 2 {
t.Fatalf("concurrent revisions = issue:%d comment:%d, want 3/2", issueRevision, commentRevision)
}
}
func TestConcurrentCommentRevisionConflictCancelsTaskBatchOnce(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
fixture := createCommentDeliveryFixture(t, "revision task side effects")
makeCommentDeliverySingleThread(t, fixture)
if _, err := testPool.Exec(ctx, `
UPDATE issue SET assignee_type = 'agent', assignee_id = $2 WHERE id = $1
`, fixture.issueID, fixture.agentID); err != nil {
t.Fatalf("assign revision test issue: %v", err)
}
cancelEvents := make(chan events.Event, 4)
testHandler.Bus.Subscribe(protocol.EventTaskCancelled, func(event events.Event) {
payload, ok := event.Payload.(map[string]any)
if ok && payload["issue_id"] == fixture.issueID {
cancelEvents <- event
}
})
start := make(chan struct{})
results := make(chan int, 2)
for _, content := range []string{"concurrent edit a", "concurrent edit b"} {
go func() {
<-start
w := httptest.NewRecorder()
req := withURLParam(newRequest(http.MethodPut, "/api/comments/"+fixture.commentID[2], map[string]any{
"content": content,
"content_base": fixture.content[2],
}), "commentId", fixture.commentID[2])
testHandler.UpdateComment(w, req)
results <- w.Code
}()
}
close(start)
okCount, conflictCount := 0, 0
for range 2 {
switch <-results {
case http.StatusOK:
okCount++
case http.StatusConflict:
conflictCount++
}
}
if okCount != 1 || conflictCount != 1 {
t.Fatalf("concurrent comment outcomes = ok:%d conflict:%d, want 1/1", okCount, conflictCount)
}
var originalStatus string
if err := testPool.QueryRow(ctx, `SELECT status FROM agent_task_queue WHERE id = $1`, fixture.taskID).Scan(&originalStatus); err != nil {
t.Fatalf("load original task: %v", err)
}
if originalStatus != "cancelled" {
t.Fatalf("original task status = %q, want cancelled", originalStatus)
}
if got := len(cancelEvents); got != 1 {
t.Fatalf("task cancellation events = %d, want exactly one winning edit side effect", got)
}
var activeCount int
if err := testPool.QueryRow(ctx, `
SELECT count(*) FROM agent_task_queue
WHERE issue_id = $1 AND status IN ('queued', 'dispatched', 'running', 'waiting_local_directory', 'deferred')
`, fixture.issueID).Scan(&activeCount); err != nil {
t.Fatalf("count active replacement tasks: %v", err)
}
if activeCount != 1 {
t.Fatalf("active replacement tasks = %d, want one batch from the winning edit", activeCount)
}
}
func TestNoOpIssueUpdateDoesNotAdvanceRevisionOrUpdatedAt(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
issueID := insertWorkflowTestIssue(t, "no-op issue update", int(time.Now().UnixNano()%100000)+8_768_000)
oldUpdatedAt := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
if _, err := testPool.Exec(ctx, `UPDATE issue SET updated_at = $2 WHERE id = $1`, issueID, oldUpdatedAt); err != nil {
t.Fatalf("seed old issue timestamp: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
w := httptest.NewRecorder()
testHandler.UpdateIssue(w, withURLParam(newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{
"title": "no-op issue update",
"expected_revision": 1,
}), "id", issueID))
if w.Code != http.StatusOK {
t.Fatalf("no-op update = %d: %s", w.Code, w.Body.String())
}
var revision int64
var updatedAt time.Time
if err := testPool.QueryRow(ctx, `SELECT revision, updated_at FROM issue WHERE id = $1`, issueID).Scan(&revision, &updatedAt); err != nil {
t.Fatalf("load no-op issue: %v", err)
}
if revision != 1 || !updatedAt.Equal(oldUpdatedAt) {
t.Fatalf("no-op issue = revision %d, updated_at %s; want 1 and %s", revision, updatedAt, oldUpdatedAt)
}
}
func TestNoOpIssueWithAttachmentPublishesFreshOwnerBeforeAttachmentEvent(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
issueID := insertWorkflowTestIssue(t, "ordered attachment events", int(time.Now().UnixNano()%100000)+8_770_000)
if _, err := testPool.Exec(ctx, `UPDATE issue SET updated_at = '2000-01-01T00:00:00Z' WHERE id = $1`, issueID); err != nil {
t.Fatalf("seed old issue timestamp: %v", err)
}
var attachmentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO attachment (
workspace_id, uploader_type, uploader_id,
filename, url, content_type, size_bytes
)
VALUES ($1, 'member', $2, 'ordered.txt', '/ordered.txt', 'text/plain', 1)
RETURNING id
`, testWorkspaceID, testUserID).Scan(&attachmentID); err != nil {
t.Fatalf("insert unbound attachment: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM attachment WHERE id = $1`, attachmentID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
h := *testHandler
h.Bus = events.New()
var ordered []events.Event
h.Bus.SubscribeAll(func(event events.Event) {
if event.Type == protocol.EventIssueUpdated || event.Type == protocol.EventIssueAttachmentsChanged {
ordered = append(ordered, event)
}
})
w := httptest.NewRecorder()
h.UpdateIssue(w, withURLParam(newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{
"title": "ordered attachment events",
"attachment_ids": []string{attachmentID},
"expected_revision": 1,
}), "id", issueID))
if w.Code != http.StatusOK {
t.Fatalf("combined update = %d: %s", w.Code, w.Body.String())
}
if len(ordered) != 2 || ordered[0].Type != protocol.EventIssueUpdated || ordered[1].Type != protocol.EventIssueAttachmentsChanged {
t.Fatalf("event order = %#v, want issue:updated then issue_attachments:changed", ordered)
}
updatedPayload, ok := ordered[0].Payload.(map[string]any)
if !ok {
t.Fatalf("updated payload = %#v", ordered[0].Payload)
}
updatedIssue, ok := updatedPayload["issue"].(IssueResponse)
if !ok {
t.Fatalf("updated issue = %#v", updatedPayload["issue"])
}
if updatedIssue.Revision != 2 {
t.Fatalf("no-op owner revision = %d, want 2", updatedIssue.Revision)
}
responseUpdatedAt, err := time.Parse(time.RFC3339Nano, updatedIssue.UpdatedAt)
if err != nil {
t.Fatalf("parse response updated_at %q: %v", updatedIssue.UpdatedAt, err)
}
if !responseUpdatedAt.After(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("response updated_at = %s, want refreshed timestamp", responseUpdatedAt)
}
var updatedAt time.Time
if err := testPool.QueryRow(ctx, `SELECT updated_at FROM issue WHERE id = $1`, issueID).Scan(&updatedAt); err != nil {
t.Fatalf("load no-op updated_at: %v", err)
}
if !updatedAt.After(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("no-op updated_at = %s, want refreshed timestamp", updatedAt)
}
attachmentPayload, ok := ordered[1].Payload.(map[string]any)
if !ok || attachmentPayload["issue_revision"] != updatedIssue.Revision {
t.Fatalf("event revisions = updated:%#v attachment:%#v", updatedIssue.Revision, attachmentPayload["issue_revision"])
}
}
func TestIssueUpdateAndAttachmentBindingExcludeInterleavingMutation(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
issueID := insertWorkflowTestIssue(t, "atomic attachment binding", int(time.Now().UnixNano()%100000)+8_772_000)
var attachmentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO attachment (
workspace_id, uploader_type, uploader_id,
filename, url, content_type, size_bytes
)
VALUES ($1, 'member', $2, 'atomic.txt', '/atomic.txt', 'text/plain', 1)
RETURNING id
`, testWorkspaceID, testUserID).Scan(&attachmentID); err != nil {
t.Fatalf("insert atomic attachment: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, attachmentID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
reachedLink := make(chan struct{}, 1)
releaseLink := make(chan struct{})
defer func() {
select {
case <-releaseLink:
default:
close(releaseLink)
}
}()
h := *testHandler
h.TxStarter = pauseIssueAttachmentLinkTxStarter{
inner: testHandler.TxStarter,
reached: reachedLink,
release: releaseLink,
}
handlerDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
w := httptest.NewRecorder()
h.UpdateIssue(w, withURLParam(newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{
"title": "atomic attachment binding",
"attachment_ids": []string{attachmentID},
"expected_revision": 1,
}), "id", issueID))
handlerDone <- w
}()
select {
case <-reachedLink:
case <-time.After(5 * time.Second):
t.Fatal("issue update did not reach the attachment link")
}
concurrentDone := make(chan error, 1)
go func() {
_, execErr := testPool.Exec(ctx, `
/* issue_revision_interleaving */
UPDATE issue
SET priority = 'high', revision = revision + 1
WHERE id = $1
`, issueID)
concurrentDone <- execErr
}()
deadline := time.Now().Add(5 * time.Second)
for {
var waiting bool
if err := testPool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM pg_stat_activity
WHERE datname = current_database()
AND wait_event_type = 'Lock'
AND query LIKE '%issue_revision_interleaving%'
)
`).Scan(&waiting); err != nil {
t.Fatalf("observe blocked concurrent mutation: %v", err)
}
if waiting {
break
}
if time.Now().After(deadline) {
t.Fatal("concurrent mutation was not blocked by the issue update transaction")
}
time.Sleep(10 * time.Millisecond)
}
close(releaseLink)
response := <-handlerDone
if response.Code != http.StatusOK {
t.Fatalf("combined update = %d: %s", response.Code, response.Body.String())
}
var updated IssueResponse
if err := json.NewDecoder(response.Body).Decode(&updated); err != nil {
t.Fatalf("decode combined update: %v", err)
}
if updated.Revision != 2 {
t.Fatalf("combined update revision = %d, want 2", updated.Revision)
}
if err := <-concurrentDone; err != nil {
t.Fatalf("concurrent mutation after commit: %v", err)
}
var linkedIssueID, priority string
var revision int64
if err := testPool.QueryRow(ctx, `
SELECT a.issue_id, i.priority, i.revision
FROM attachment AS a
JOIN issue AS i ON i.id = a.issue_id
WHERE a.id = $1
`, attachmentID).Scan(&linkedIssueID, &priority, &revision); err != nil {
t.Fatalf("load atomic result: %v", err)
}
if linkedIssueID != issueID || priority != "high" || revision != 3 {
t.Fatalf("atomic result = issue %s, priority %s, revision %d; want %s/high/3", linkedIssueID, priority, revision, issueID)
}
}
func TestIssueUpdateRollsBackWhenAttachmentBindingFails(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
issueID := insertWorkflowTestIssue(t, "attachment rollback original", int(time.Now().UnixNano()%100000)+8_774_000)
var attachmentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO attachment (
workspace_id, uploader_type, uploader_id,
filename, url, content_type, size_bytes
)
VALUES ($1, 'member', $2, 'rollback.txt', '/rollback.txt', 'text/plain', 1)
RETURNING id
`, testWorkspaceID, testUserID).Scan(&attachmentID); err != nil {
t.Fatalf("insert rollback attachment: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, attachmentID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
h := *testHandler
h.TxStarter = failIssueAttachmentLinkTxStarter{inner: testHandler.TxStarter}
w := httptest.NewRecorder()
h.UpdateIssue(w, withURLParam(newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{
"title": "attachment rollback changed",
"attachment_ids": []string{attachmentID},
}), "id", issueID))
if w.Code != http.StatusInternalServerError {
t.Fatalf("injected link failure = %d, want 500: %s", w.Code, w.Body.String())
}
var title string
var revision int64
if err := testPool.QueryRow(ctx, `SELECT title, revision FROM issue WHERE id = $1`, issueID).Scan(&title, &revision); err != nil {
t.Fatalf("load rolled-back issue: %v", err)
}
var linked bool
if err := testPool.QueryRow(ctx, `SELECT issue_id IS NOT NULL FROM attachment WHERE id = $1`, attachmentID).Scan(&linked); err != nil {
t.Fatalf("load rolled-back attachment: %v", err)
}
if title != "attachment rollback original" || revision != 1 || linked {
t.Fatalf("rollback result = title %q, revision %d, linked %t", title, revision, linked)
}
}
func TestAuxiliaryVisibleMutationsAdvanceOwnerRevisionExactlyOnce(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
issueID := insertWorkflowTestIssue(t, "revision auxiliary projections", int(time.Now().UnixNano()%100000)+8_775_000)
issueUUID, _ := util.ParseUUID(issueID)
workspaceUUID, _ := util.ParseUUID(testWorkspaceID)
userUUID, _ := util.ParseUUID(testUserID)
var commentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content)
VALUES ($1, $2, 'member', $3, 'auxiliary revision comment')
RETURNING id
`, issueID, testWorkspaceID, testUserID).Scan(&commentID); err != nil {
t.Fatalf("insert auxiliary comment: %v", err)
}
commentUUID, _ := util.ParseUUID(commentID)
labelID := uuid.New()
if _, err := testPool.Exec(ctx, `
INSERT INTO issue_label (id, workspace_id, resource_type, name, color)
VALUES ($1, $2, 'issue', $3, '#3b82f6')
`, labelID, testWorkspaceID, "workflow-aux-"+labelID.String()); err != nil {
t.Fatalf("insert auxiliary label: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM attachment WHERE issue_id = $1 OR comment_id = $2`, issueID, commentID)
testPool.Exec(ctx, `DELETE FROM comment_reaction WHERE comment_id = $1`, commentID)
testPool.Exec(ctx, `DELETE FROM issue_reaction WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue_to_label WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue_label WHERE id = $1`, labelID)
testPool.Exec(ctx, `DELETE FROM comment WHERE id = $1`, commentID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
labelParams := db.AttachLabelToIssueParams{IssueID: issueUUID, LabelID: pgtype.UUID{Bytes: labelID, Valid: true}, WorkspaceID: workspaceUUID}
attached, err := testHandler.Queries.AttachLabelToIssue(ctx, labelParams)
if err != nil || !attached.Changed || attached.IssueRevision != 2 {
t.Fatalf("attach label = (%+v, %v), want changed revision 2", attached, err)
}
attached, err = testHandler.Queries.AttachLabelToIssue(ctx, labelParams)
if err != nil || attached.Changed || attached.IssueRevision != 0 {
t.Fatalf("duplicate label attach = (%+v, %v), want no-op", attached, err)
}
labelsResponse := httptest.NewRecorder()
testHandler.ListLabelsForIssue(labelsResponse, withURLParam(newRequest(http.MethodGet, "/api/issues/"+issueID+"/labels", nil), "id", issueID))
if labelsResponse.Code != http.StatusOK {
t.Fatalf("list issue labels = %d: %s", labelsResponse.Code, labelsResponse.Body.String())
}
var labelPayload struct {
IssueRevision int64 `json:"issue_revision"`
}
if err := json.Unmarshal(labelsResponse.Body.Bytes(), &labelPayload); err != nil || labelPayload.IssueRevision != 2 {
t.Fatalf("list issue labels revision = (%d, %v), want 2", labelPayload.IssueRevision, err)
}
detached, err := testHandler.Queries.DetachLabelFromIssue(ctx, db.DetachLabelFromIssueParams(labelParams))
if err != nil || !detached.Changed || detached.IssueRevision != 3 {
t.Fatalf("detach label = (%+v, %v), want changed revision 3", detached, err)
}
detached, err = testHandler.Queries.DetachLabelFromIssue(ctx, db.DetachLabelFromIssueParams(labelParams))
if err != nil || detached.Changed || detached.IssueRevision != 0 {
t.Fatalf("duplicate label detach = (%+v, %v), want no-op", detached, err)
}
issueReaction := db.AddIssueReactionParams{IssueID: issueUUID, WorkspaceID: workspaceUUID, ActorType: "member", ActorID: userUUID, Emoji: "eyes"}
addedIssueReaction, err := testHandler.Queries.AddIssueReaction(ctx, issueReaction)
if err != nil || addedIssueReaction.IssueRevision != 4 {
t.Fatalf("add issue reaction = (%+v, %v), want revision 4", addedIssueReaction, err)
}
addedIssueReaction, err = testHandler.Queries.AddIssueReaction(ctx, issueReaction)
if err != nil || addedIssueReaction.IssueRevision != 0 {
t.Fatalf("duplicate issue reaction = (%+v, %v), want no-op", addedIssueReaction, err)
}
removedIssueReaction, err := testHandler.Queries.RemoveIssueReaction(ctx, db.RemoveIssueReactionParams{
IssueID: issueUUID, ActorType: "member", ActorID: userUUID, Emoji: "eyes",
})
if err != nil || !removedIssueReaction.Changed || removedIssueReaction.IssueRevision != 5 {
t.Fatalf("remove issue reaction = (%+v, %v), want revision 5", removedIssueReaction, err)
}
removedIssueReaction, err = testHandler.Queries.RemoveIssueReaction(ctx, db.RemoveIssueReactionParams{
IssueID: issueUUID, ActorType: "member", ActorID: userUUID, Emoji: "eyes",
})
if err != nil || removedIssueReaction.Changed || removedIssueReaction.IssueRevision != 0 {
t.Fatalf("duplicate issue reaction removal = (%+v, %v), want no-op", removedIssueReaction, err)
}
commentReaction := db.AddReactionParams{CommentID: commentUUID, WorkspaceID: workspaceUUID, ActorType: "member", ActorID: userUUID, Emoji: "thumbs_up"}
addedCommentReaction, err := testHandler.Queries.AddReaction(ctx, commentReaction)
if err != nil || addedCommentReaction.CommentRevision != 2 {
t.Fatalf("add comment reaction = (%+v, %v), want revision 2", addedCommentReaction, err)
}
addedCommentReaction, err = testHandler.Queries.AddReaction(ctx, commentReaction)
if err != nil || addedCommentReaction.CommentRevision != 0 {
t.Fatalf("duplicate comment reaction = (%+v, %v), want no-op", addedCommentReaction, err)
}
removedCommentReaction, err := testHandler.Queries.RemoveReaction(ctx, db.RemoveReactionParams{
CommentID: commentUUID, ActorType: "member", ActorID: userUUID, Emoji: "thumbs_up",
})
if err != nil || !removedCommentReaction.Changed || removedCommentReaction.CommentRevision != 3 {
t.Fatalf("remove comment reaction = (%+v, %v), want revision 3", removedCommentReaction, err)
}
removedCommentReaction, err = testHandler.Queries.RemoveReaction(ctx, db.RemoveReactionParams{
CommentID: commentUUID, ActorType: "member", ActorID: userUUID, Emoji: "thumbs_up",
})
if err != nil || removedCommentReaction.Changed || removedCommentReaction.CommentRevision != 0 {
t.Fatalf("duplicate comment reaction removal = (%+v, %v), want no-op", removedCommentReaction, err)
}
attachmentID := uuid.New()
createdAttachment, err := testHandler.Queries.CreateAttachment(ctx, db.CreateAttachmentParams{
ID: pgtype.UUID{Bytes: attachmentID, Valid: true}, WorkspaceID: workspaceUUID,
IssueID: issueUUID, CommentID: commentUUID, UploaderType: "member", UploaderID: userUUID,
Filename: "aux.txt", Url: "/aux.txt", ContentType: "text/plain", SizeBytes: 1,
})
if err != nil || createdAttachment.IssueRevision != 6 || createdAttachment.CommentRevision != 4 {
t.Fatalf("create attachment = (%+v, %v), want issue/comment revisions 6/4", createdAttachment, err)
}
deletedAttachment, err := testHandler.Queries.DeleteAttachment(ctx, db.DeleteAttachmentParams{
ID: pgtype.UUID{Bytes: attachmentID, Valid: true}, WorkspaceID: workspaceUUID,
})
if err != nil || !deletedAttachment.Changed || deletedAttachment.IssueRevision != 7 || deletedAttachment.CommentRevision != 5 {
t.Fatalf("delete attachment = (%+v, %v), want issue/comment revisions 7/5", deletedAttachment, err)
}
unboundAttachmentID := pgtype.UUID{Bytes: uuid.New(), Valid: true}
if _, err := testHandler.Queries.CreateAttachment(ctx, db.CreateAttachmentParams{
ID: unboundAttachmentID, WorkspaceID: workspaceUUID,
UploaderType: "member", UploaderID: userUUID,
Filename: "linked.txt", Url: "/linked.txt", ContentType: "text/plain", SizeBytes: 1,
}); err != nil {
t.Fatalf("create unbound attachment: %v", err)
}
linkedAttachment, err := testHandler.Queries.LinkAttachmentsToIssue(ctx, db.LinkAttachmentsToIssueParams{
IssueID: issueUUID, WorkspaceID: workspaceUUID, AttachmentIds: []pgtype.UUID{unboundAttachmentID}, BumpRevision: true,
})
if err != nil || linkedAttachment.LinkedCount != 1 || linkedAttachment.IssueRevision != 8 {
t.Fatalf("link attachment = (%+v, %v), want one link and issue revision 8", linkedAttachment, err)
}
linkedAttachment, err = testHandler.Queries.LinkAttachmentsToIssue(ctx, db.LinkAttachmentsToIssueParams{
IssueID: issueUUID, WorkspaceID: workspaceUUID, AttachmentIds: []pgtype.UUID{unboundAttachmentID}, BumpRevision: true,
})
if err != nil || linkedAttachment.LinkedCount != 0 || linkedAttachment.IssueRevision != 0 {
t.Fatalf("duplicate attachment link = (%+v, %v), want no-op", linkedAttachment, err)
}
metadataParams := db.SetIssueMetadataKeyParams{
Key: "revision-test", Value: []byte(`"value"`), ID: issueUUID, WorkspaceID: workspaceUUID,
}
metadataIssue, err := testHandler.Queries.SetIssueMetadataKey(ctx, metadataParams)
if err != nil || metadataIssue.Revision != 9 {
t.Fatalf("set metadata = (%+v, %v), want revision 9", metadataIssue, err)
}
_, err = testHandler.Queries.SetIssueMetadataKey(ctx, metadataParams)
if !errors.Is(err, pgx.ErrNoRows) {
t.Fatalf("duplicate metadata set error = %v, want pgx.ErrNoRows", err)
}
currentMetadataIssue, err := testHandler.Queries.GetIssueInWorkspace(ctx, db.GetIssueInWorkspaceParams{
ID: issueUUID, WorkspaceID: workspaceUUID,
})
if err != nil || currentMetadataIssue.Revision != 9 {
t.Fatalf("issue after duplicate metadata set = (%+v, %v), want revision 9", currentMetadataIssue, err)
}
deletedMetadataIssue, err := testHandler.Queries.DeleteIssueMetadataKey(ctx, db.DeleteIssueMetadataKeyParams{
Key: metadataParams.Key, ID: issueUUID, WorkspaceID: workspaceUUID,
})
if err != nil || deletedMetadataIssue.Revision != 10 {
t.Fatalf("delete metadata = (%+v, %v), want revision 10", deletedMetadataIssue, err)
}
propertyParams := db.SetIssuePropertyValueParams{
Key: "revision-test", Value: []byte(`42`), ID: issueUUID, WorkspaceID: workspaceUUID,
}
propertyIssue, err := testHandler.Queries.SetIssuePropertyValue(ctx, propertyParams)
if err != nil || propertyIssue.Revision != 11 {
t.Fatalf("set property = (%+v, %v), want revision 11", propertyIssue, err)
}
propertyIssue, err = testHandler.Queries.SetIssuePropertyValue(ctx, propertyParams)
if err != nil || propertyIssue.Revision != 11 {
t.Fatalf("duplicate property set = (%+v, %v), want unchanged revision 11", propertyIssue, err)
}
propertyIssue, err = testHandler.Queries.DeleteIssuePropertyValue(ctx, db.DeleteIssuePropertyValueParams{
Key: propertyParams.Key, ID: issueUUID, WorkspaceID: workspaceUUID,
})
if err != nil || propertyIssue.Revision != 12 {
t.Fatalf("delete property = (%+v, %v), want revision 12", propertyIssue, err)
}
resolved, err := testHandler.Queries.ResolveComment(ctx, db.ResolveCommentParams{
ID: commentUUID, ResolvedByType: pgtype.Text{String: "member", Valid: true}, ResolvedByID: userUUID,
})
if err != nil || resolved.Revision != 6 {
t.Fatalf("resolve comment = (%+v, %v), want revision 6", resolved, err)
}
resolved, err = testHandler.Queries.ResolveComment(ctx, db.ResolveCommentParams{
ID: commentUUID, ResolvedByType: pgtype.Text{String: "member", Valid: true}, ResolvedByID: userUUID,
})
if err != nil || resolved.Revision != 6 {
t.Fatalf("duplicate resolve = (%+v, %v), want unchanged revision 6", resolved, err)
}
unresolved, err := testHandler.Queries.UnresolveComment(ctx, commentUUID)
if err != nil || unresolved.Revision != 7 {
t.Fatalf("unresolve comment = (%+v, %v), want revision 7", unresolved, err)
}
unresolved, err = testHandler.Queries.UnresolveComment(ctx, commentUUID)
if err != nil || unresolved.Revision != 7 {
t.Fatalf("duplicate unresolve = (%+v, %v), want unchanged revision 7", unresolved, err)
}
statusIssue, err := testHandler.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
ID: issueUUID, Status: "in_progress", WorkspaceID: workspaceUUID,
})
if err != nil || statusIssue.Revision != 13 {
t.Fatalf("update status = (%+v, %v), want revision 13", statusIssue, err)
}
statusIssue, err = testHandler.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
ID: issueUUID, Status: "in_progress", WorkspaceID: workspaceUUID,
})
if err != nil || statusIssue.Revision != 13 {
t.Fatalf("duplicate status update = (%+v, %v), want unchanged revision 13", statusIssue, err)
}
}