Files
multica/server/internal/handler/activity_test.go
Jiang Bohan ccaa7dc6cf test(handler): fix timeline test assertions for handler-package isolation
The TestListTimeline_* assertions assumed CreateIssue would seed an
"issue_created" activity_log row, but the activity listener that publishes
those rows is registered in cmd/server/main.go — handler-package tests
don't wire it up. CI saw 5 entries (3 comments + 2 activities) where the
test expected ≥6.

Drop the auto-activity assumption: assert exactly 5 entries in
TestListTimeline_MergesCommentsAndActivities, and tighten
TestListTimeline_EmptyIssue to assert a fully-empty timeline.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 16:02:05 +08:00

222 lines
7.9 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// fetchTimeline issues a GET /timeline request and returns the decoded entries
// + HTTP status. The endpoint returns a flat array of TimelineEntry sorted by
// (created_at, id) ascending (oldest first); see ListTimeline / #1929.
func fetchTimeline(t *testing.T, issueID string) ([]TimelineEntry, int) {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("GET", "/api/issues/"+issueID+"/timeline", nil)
req = withURLParam(req, "id", issueID)
testHandler.ListTimeline(w, req)
var entries []TimelineEntry
if w.Code == http.StatusOK {
json.NewDecoder(w.Body).Decode(&entries)
}
return entries, w.Code
}
// createIssueForTimeline returns a freshly-created issue id and registers a
// cleanup so its timeline rows are deleted after the test.
func createIssueForTimeline(t *testing.T, title string) string {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "todo",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
t.Cleanup(func() {
ctx := context.Background()
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issue.ID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issue.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issue.ID)
})
return issue.ID
}
// seedTimelineEntries inserts <commentN> comments + <activityN> activities for
// the given issue with ascending timestamps. Returns the inserted ids in the
// order they were inserted (chronologically ascending).
func seedTimelineEntries(t *testing.T, issueID string, commentN, activityN int) (commentIDs, activityIDs []string) {
t.Helper()
ctx := context.Background()
base := time.Now().UTC().Add(-time.Duration(commentN+activityN) * time.Minute)
for i := 0; i < commentN; i++ {
var id string
ts := base.Add(time.Duration(i) * time.Minute)
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, created_at, updated_at)
VALUES ($1, $2, 'member', $3, $4, 'comment', $5, $5)
RETURNING id
`, issueID, testWorkspaceID, testUserID, fmt.Sprintf("comment %d", i), ts).Scan(&id); err != nil {
t.Fatalf("seed comment %d: %v", i, err)
}
commentIDs = append(commentIDs, id)
}
for i := 0; i < activityN; i++ {
var id string
ts := base.Add(time.Duration(commentN+i) * time.Minute)
if err := testPool.QueryRow(ctx, `
INSERT INTO activity_log (workspace_id, issue_id, actor_type, actor_id, action, details, created_at)
VALUES ($1, $2, 'member', $3, 'status_changed', '{"from":"todo","to":"in_progress"}'::jsonb, $4)
RETURNING id
`, testWorkspaceID, issueID, testUserID, ts).Scan(&id); err != nil {
t.Fatalf("seed activity %d: %v", i, err)
}
activityIDs = append(activityIDs, id)
}
return
}
func TestListTimeline_ReturnsAllEntriesAscending(t *testing.T) {
issueID := createIssueForTimeline(t, "All entries test")
commentIDs, _ := seedTimelineEntries(t, issueID, 5, 0)
entries, status := fetchTimeline(t, issueID)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
// Handler tests don't register the activity listener (that lives in
// cmd/server), so issue creation does not seed an auto-activity here.
// We assert directly on the seeded comments.
commentEntries := []TimelineEntry{}
for _, e := range entries {
if e.Type == "comment" {
commentEntries = append(commentEntries, e)
}
}
if got, want := len(commentEntries), len(commentIDs); got != want {
t.Fatalf("comment count = %d, want %d", got, want)
}
for i, e := range commentEntries {
if e.ID != commentIDs[i] {
t.Errorf("entry %d: id = %s, want %s", i, e.ID, commentIDs[i])
}
}
}
func TestListTimeline_MergesCommentsAndActivities(t *testing.T) {
issueID := createIssueForTimeline(t, "Merged entries test")
seedTimelineEntries(t, issueID, 3, 2)
entries, status := fetchTimeline(t, issueID)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
// Verify chronological non-decreasing order across types.
for i := 1; i < len(entries); i++ {
if entries[i-1].CreatedAt > entries[i].CreatedAt {
t.Errorf("not chronological at %d: %q then %q",
i, entries[i-1].CreatedAt, entries[i].CreatedAt)
}
}
// 3 seeded comments + 2 seeded activities = 5. Handler tests don't
// register the activity listener, so there is no auto issue-created row.
if got, want := len(entries), 5; got != want {
t.Fatalf("entries = %d, want %d", got, want)
}
}
// fetchTimelineWrapped exercises the legacy wrapped response shape that
// stale Multica.app v0.2.26+ builds still expect — sending any of
// limit/before/after/around makes the server emit a TimelinePage-style
// object (entries DESC, null cursors, has_more_*=false) instead of the new
// flat array. Used to verify the boundary-compat path doesn't regress.
func fetchTimelineWrapped(t *testing.T, issueID, query string) (timelinePaginatedResponse, int) {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("GET", "/api/issues/"+issueID+"/timeline?"+query, nil)
req = withURLParam(req, "id", issueID)
testHandler.ListTimeline(w, req)
var resp timelinePaginatedResponse
if w.Code == http.StatusOK {
json.NewDecoder(w.Body).Decode(&resp)
}
return resp, w.Code
}
// Boundary-compat: a stale client between #2128 and #1929 sends ?limit=50
// and parses the response with TimelinePageSchema. The handler must keep
// returning the wrapped object so that path doesn't fall back to an empty
// timeline.
func TestListTimeline_LegacyWrappedShape_OnPaginationParams(t *testing.T) {
issueID := createIssueForTimeline(t, "Legacy wrapped shape test")
commentIDs, _ := seedTimelineEntries(t, issueID, 3, 0)
resp, status := fetchTimelineWrapped(t, issueID, "limit=50")
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
if resp.HasMoreBefore || resp.HasMoreAfter {
t.Errorf("has_more_*: want false/false, got before=%v after=%v",
resp.HasMoreBefore, resp.HasMoreAfter)
}
if resp.NextCursor != nil || resp.PrevCursor != nil {
t.Errorf("cursors: want nil/nil, got next=%v prev=%v", resp.NextCursor, resp.PrevCursor)
}
// DESC order: most recent comment first; activity from issue-creation
// sits at the bottom.
commentEntries := []TimelineEntry{}
for _, e := range resp.Entries {
if e.Type == "comment" {
commentEntries = append(commentEntries, e)
}
}
if got, want := len(commentEntries), len(commentIDs); got != want {
t.Fatalf("comment count = %d, want %d", got, want)
}
for i, e := range commentEntries {
want := commentIDs[len(commentIDs)-1-i]
if e.ID != want {
t.Errorf("DESC entry %d: id = %s, want %s", i, e.ID, want)
}
}
}
func TestListTimeline_LegacyWrappedShape_AroundFillsTargetIndex(t *testing.T) {
issueID := createIssueForTimeline(t, "Around target index test")
commentIDs, _ := seedTimelineEntries(t, issueID, 5, 0)
anchor := commentIDs[2] // pick a middle comment
resp, status := fetchTimelineWrapped(t, issueID, "around="+anchor)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
if resp.TargetIndex == nil {
t.Fatalf("target_index: want non-nil for around mode")
}
if got := resp.Entries[*resp.TargetIndex].ID; got != anchor {
t.Errorf("target_index points at %s, want anchor %s", got, anchor)
}
}
func TestListTimeline_EmptyIssue(t *testing.T) {
issueID := createIssueForTimeline(t, "Empty timeline test")
entries, status := fetchTimeline(t, issueID)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200", status)
}
// Handler tests don't wire the activity listener, so a freshly-created
// issue with no comments has an empty timeline.
if got := len(entries); got != 0 {
t.Fatalf("entries = %d, want 0", got)
}
}