mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
* feat(issues): bump issue updated_at when a comment is added (MUL-5009) A new comment now counts as activity on its issue and advances updated_at, so the "Updated date" Kanban/list sort surfaces recently-discussed cards — not only cards whose status changed. Applies to all three comment-creation paths (user/agent HTTP, agent task delivery, and the child-done system comment) via a best-effort TouchIssue query. The bump never fails an already- persisted comment; it self-heals on the next activity if it errors. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): make comment updated_at bump atomic (MUL-5009 review) Address Elon's review. Move the updated_at bump into CreateComment as a leading data-modifying CTE so the comment insert and the timestamp bump commit or roll back together — closing the non-atomic window where a comment could persist while updated_at stayed stale. That window also skewed the daemon GC TTL, which reads issue.updated_at to reclaim done/cancelled workdirs. Centralizing the bump in the query drops the three per-caller TouchIssue calls and guarantees any future comment entrypoint inherits it. Also refresh the now-stale gc.go / gc_test.go comments that asserted 'CreateComment does not bump issue.updated_at'. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): make comment/issue workspace match a query-level guarantee (MUL-5009 nit2) The touch CTE now RETURNING id, workspace_id and the INSERT SELECTs from it, so the comment insert depends on the issue actually existing in the passed workspace. A mismatched (issue, workspace) pair matches 0 rows in the CTE, the dependent INSERT selects nothing, and the :one query returns pgx.ErrNoRows — no mis-attributed comment is written and the issue is not touched. CreateComment is now the single carrier of the 'a comment belongs to an issue in the same workspace and always bumps it' invariant, so no future caller can break it by passing the wrong workspace. Signature unchanged; no migration or foreign key. Add TestCreateComment_WorkspaceMismatchPersistsNothing (error returned, no comment persisted, updated_at unchanged). Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
108 lines
3.8 KiB
Go
108 lines
3.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// TestCreateComment_BumpsIssueUpdatedAt pins MUL-5009: a new comment counts as
|
|
// activity on the issue, so updated_at advances. This is what lets the
|
|
// "Updated date" Kanban/list sort surface recently-discussed cards, not only
|
|
// cards whose status changed.
|
|
func TestCreateComment_BumpsIssueUpdatedAt(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
issueID := createCommentTriggerPreviewIssue(t, "comment bumps updated_at", "", "")
|
|
|
|
var before time.Time
|
|
if err := testPool.QueryRow(ctx, `SELECT updated_at FROM issue WHERE id = $1`, issueID).Scan(&before); err != nil {
|
|
t.Fatalf("read updated_at before: %v", err)
|
|
}
|
|
|
|
// Guarantee a measurable wall-clock gap so the bump is unambiguous; now() is
|
|
// evaluated per statement and the issue was inserted moments ago.
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
w := httptest.NewRecorder()
|
|
r := withURLParam(
|
|
newRequest(http.MethodPost, "/api/issues/"+issueID+"/comments", map[string]any{"content": "a fresh comment"}),
|
|
"id", issueID,
|
|
)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var after time.Time
|
|
if err := testPool.QueryRow(ctx, `SELECT updated_at FROM issue WHERE id = $1`, issueID).Scan(&after); err != nil {
|
|
t.Fatalf("read updated_at after: %v", err)
|
|
}
|
|
|
|
if !after.After(before) {
|
|
t.Fatalf("issue updated_at was not bumped by a new comment: before=%s after=%s", before, after)
|
|
}
|
|
}
|
|
|
|
// TestCreateComment_WorkspaceMismatchPersistsNothing pins the tenant-integrity
|
|
// guarantee of the CreateComment CTE (MUL-5009 nit2): CreateComment is the
|
|
// single carrier of "a comment always belongs to an issue in the same
|
|
// workspace and always bumps it". If the passed workspace does not match the
|
|
// target issue's workspace, the leading UPDATE matches no issue row, the
|
|
// dependent INSERT selects nothing, and the :one query returns pgx.ErrNoRows —
|
|
// so no mis-attributed comment is written and the issue is not touched.
|
|
func TestCreateComment_WorkspaceMismatchPersistsNothing(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
issueID := createCommentTriggerPreviewIssue(t, "comment workspace guard", "", "")
|
|
|
|
var before time.Time
|
|
if err := testPool.QueryRow(ctx, `SELECT updated_at FROM issue WHERE id = $1`, issueID).Scan(&before); err != nil {
|
|
t.Fatalf("read updated_at before: %v", err)
|
|
}
|
|
|
|
// A workspace that is NOT the issue's workspace: the issue exists, but the
|
|
// (issue, workspace) pair matches no row.
|
|
wrongWorkspace := parseUUID("11111111-1111-1111-1111-111111111111")
|
|
_, err := testHandler.Queries.CreateComment(ctx, db.CreateCommentParams{
|
|
IssueID: parseUUID(issueID),
|
|
WorkspaceID: wrongWorkspace,
|
|
AuthorType: "member",
|
|
AuthorID: parseUUID(testUserID),
|
|
Content: "should never persist",
|
|
Type: "comment",
|
|
})
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
t.Fatalf("expected pgx.ErrNoRows on workspace mismatch, got %v", err)
|
|
}
|
|
|
|
var commentCount int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM comment WHERE issue_id = $1`, issueID).Scan(&commentCount); err != nil {
|
|
t.Fatalf("count comments: %v", err)
|
|
}
|
|
if commentCount != 0 {
|
|
t.Fatalf("workspace mismatch must persist no comment, found %d", commentCount)
|
|
}
|
|
|
|
var after time.Time
|
|
if err := testPool.QueryRow(ctx, `SELECT updated_at FROM issue WHERE id = $1`, issueID).Scan(&after); err != nil {
|
|
t.Fatalf("read updated_at after: %v", err)
|
|
}
|
|
if !after.Equal(before) {
|
|
t.Fatalf("workspace mismatch must not bump updated_at: before=%s after=%s", before, after)
|
|
}
|
|
}
|