Replace the comment-only list with a Linear-style unified timeline that
interleaves field changes and comments chronologically.
Backend:
- activity_listeners.go: records field changes (status, assignee, description,
task completed/failed) to activity_log table on domain events
- Timeline API: GET /api/issues/{id}/timeline merges activity_log + comments
sorted by created_at
- Comment reply: parent_id column + handler support for threading
Frontend:
- Unified timeline replaces comment list: activity entries as compact muted
lines, comments as Card components with reply threading
- Filter toggle (All / Comments / Activity)
- Reply UI: inline editor under comments with Cancel/Reply buttons
- Real-time sync for activity:created + comment events
- 10 new Go tests, all passing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
275 lines
8.4 KiB
Go
275 lines
8.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
func TestListTimeline_MergedAndSorted(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Create an issue
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Timeline test issue",
|
|
"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)
|
|
issueID := issue.ID
|
|
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
// Create an activity record directly in DB
|
|
_, err := testHandler.Queries.CreateActivity(ctx, db.CreateActivityParams{
|
|
WorkspaceID: parseUUID(testWorkspaceID),
|
|
IssueID: parseUUID(issueID),
|
|
ActorType: strToText("member"),
|
|
ActorID: parseUUID(testUserID),
|
|
Action: "created",
|
|
Details: []byte("{}"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateActivity: %v", err)
|
|
}
|
|
|
|
// Create a comment
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "Timeline test comment",
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.CreateComment(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Fetch timeline
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/issues/"+issueID+"/timeline", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.ListTimeline(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListTimeline: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var timeline []TimelineEntry
|
|
json.NewDecoder(w.Body).Decode(&timeline)
|
|
if len(timeline) != 2 {
|
|
t.Fatalf("expected 2 timeline entries, got %d", len(timeline))
|
|
}
|
|
|
|
// First entry should be the activity (created earlier)
|
|
if timeline[0].Type != "activity" {
|
|
t.Fatalf("expected first entry type 'activity', got %q", timeline[0].Type)
|
|
}
|
|
if *timeline[0].Action != "created" {
|
|
t.Fatalf("expected action 'created', got %q", *timeline[0].Action)
|
|
}
|
|
|
|
// Second entry should be the comment
|
|
if timeline[1].Type != "comment" {
|
|
t.Fatalf("expected second entry type 'comment', got %q", timeline[1].Type)
|
|
}
|
|
if *timeline[1].Content != "Timeline test comment" {
|
|
t.Fatalf("expected comment content 'Timeline test comment', got %q", *timeline[1].Content)
|
|
}
|
|
}
|
|
|
|
func TestListTimeline_ChronologicalOrder(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Create an issue
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Timeline order test issue",
|
|
"status": "todo",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
issueID := issue.ID
|
|
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
// Create comment first
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "First comment",
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.CreateComment(w, req)
|
|
|
|
// Then create an activity after the comment
|
|
_, err := testHandler.Queries.CreateActivity(ctx, db.CreateActivityParams{
|
|
WorkspaceID: parseUUID(testWorkspaceID),
|
|
IssueID: parseUUID(issueID),
|
|
ActorType: strToText("member"),
|
|
ActorID: parseUUID(testUserID),
|
|
Action: "status_changed",
|
|
Details: []byte(`{"from":"todo","to":"in_progress"}`),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateActivity: %v", err)
|
|
}
|
|
|
|
// Fetch timeline
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/issues/"+issueID+"/timeline", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.ListTimeline(w, req)
|
|
|
|
var timeline []TimelineEntry
|
|
json.NewDecoder(w.Body).Decode(&timeline)
|
|
if len(timeline) != 2 {
|
|
t.Fatalf("expected 2 entries, got %d", len(timeline))
|
|
}
|
|
|
|
// Entries should be in chronological order
|
|
if timeline[0].CreatedAt > timeline[1].CreatedAt {
|
|
t.Fatalf("timeline not in chronological order: %s > %s", timeline[0].CreatedAt, timeline[1].CreatedAt)
|
|
}
|
|
}
|
|
|
|
func TestCreateComment_WithParentID(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Create an issue
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Reply test issue",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
issueID := issue.ID
|
|
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
// Create parent comment
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "Parent comment",
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.CreateComment(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateComment (parent): expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var parentComment CommentResponse
|
|
json.NewDecoder(w.Body).Decode(&parentComment)
|
|
|
|
// Create reply with parent_id
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "Reply to parent",
|
|
"parent_id": parentComment.ID,
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.CreateComment(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateComment (reply): expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var replyComment CommentResponse
|
|
json.NewDecoder(w.Body).Decode(&replyComment)
|
|
|
|
if replyComment.ParentID == nil {
|
|
t.Fatal("expected reply to have parent_id set")
|
|
}
|
|
if *replyComment.ParentID != parentComment.ID {
|
|
t.Fatalf("expected parent_id %q, got %q", parentComment.ID, *replyComment.ParentID)
|
|
}
|
|
|
|
// Verify parent comment has no parent_id
|
|
if parentComment.ParentID != nil {
|
|
t.Fatalf("expected parent comment to have nil parent_id, got %q", *parentComment.ParentID)
|
|
}
|
|
}
|
|
|
|
func TestCommentWithParentID_AppearsInTimeline(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Create an issue
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Timeline reply test",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
issueID := issue.ID
|
|
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
// Create parent comment
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "Parent in timeline",
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.CreateComment(w, req)
|
|
var parent CommentResponse
|
|
json.NewDecoder(w.Body).Decode(&parent)
|
|
|
|
// Create reply
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "Reply in timeline",
|
|
"parent_id": parent.ID,
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.CreateComment(w, req)
|
|
|
|
// Fetch timeline
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/issues/"+issueID+"/timeline", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.ListTimeline(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListTimeline: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var timeline []TimelineEntry
|
|
json.NewDecoder(w.Body).Decode(&timeline)
|
|
if len(timeline) != 2 {
|
|
t.Fatalf("expected 2 timeline entries, got %d", len(timeline))
|
|
}
|
|
|
|
// Find the reply entry
|
|
var found bool
|
|
for _, entry := range timeline {
|
|
if entry.Type == "comment" && entry.ParentID != nil && *entry.ParentID == parent.ID {
|
|
found = true
|
|
if *entry.Content != "Reply in timeline" {
|
|
t.Fatalf("expected reply content 'Reply in timeline', got %q", *entry.Content)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("expected to find reply with parent_id in timeline")
|
|
}
|
|
}
|