mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* feat(issues): preserve source context for sub-issues * fix(issues): address source context review findings * fix(migrations): guard source context rollback preflight
313 lines
14 KiB
Go
313 lines
14 KiB
Go
package handler
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
)
|
|
|
|
func sourceContextStateFixture() service.SourceContextSnapshot {
|
|
description := "Captured description"
|
|
return service.SourceContextSnapshot{
|
|
SourceIssue: service.SourceContextIssueSnapshot{
|
|
ID: "issue-1", Identifier: "MUL-1", Number: 1, Title: "Captured title", Description: &description,
|
|
Attachments: []service.SourceContextAttachment{{
|
|
ID: "clone-1", SourceAttachmentID: "attachment-1", Filename: "issue.txt", ContentType: "text/plain", SizeBytes: 12,
|
|
}},
|
|
},
|
|
CommentThread: []service.SourceContextCommentSnapshot{{
|
|
ID: "comment-1", Type: "comment", Content: "Captured comment",
|
|
Author: service.SourceContextAuthor{Type: "member", ID: "member-1", Name: "Alice"},
|
|
Attachments: []service.SourceContextAttachment{{
|
|
ID: "clone-2", SourceAttachmentID: "attachment-2", Filename: "comment.txt", ContentType: "text/plain", SizeBytes: 13,
|
|
}},
|
|
}},
|
|
AnchorCommentID: "comment-1",
|
|
}
|
|
}
|
|
|
|
func TestSourceContextChangeReasonsSeparateContentFromLiveIdentityMetadata(t *testing.T) {
|
|
captured := sourceContextStateFixture()
|
|
current := sourceContextStateFixture()
|
|
current.SourceIssue.Attachments[0].ID = "attachment-1"
|
|
current.SourceIssue.Attachments[0].SourceAttachmentID = ""
|
|
current.CommentThread[0].Attachments[0].ID = "attachment-2"
|
|
current.CommentThread[0].Attachments[0].SourceAttachmentID = ""
|
|
current.CommentThread[0].Author.Name = "Alice Renamed"
|
|
|
|
if got := sourceContextIssueChangeReasons(captured.SourceIssue, current.SourceIssue); len(got) != 0 {
|
|
t.Fatalf("identity-only issue reasons = %v, want none", got)
|
|
}
|
|
if got := sourceContextThreadChangeReasons(captured, current); len(got) != 0 {
|
|
t.Fatalf("author-rename path reasons = %v, want none", got)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*service.SourceContextSnapshot)
|
|
want []string
|
|
}{
|
|
{"issue title", func(value *service.SourceContextSnapshot) { value.SourceIssue.Title = "Changed" }, []string{sourceContextChangeIssueTitle}},
|
|
{"issue description", func(value *service.SourceContextSnapshot) {
|
|
changed := "Changed"
|
|
value.SourceIssue.Description = &changed
|
|
}, []string{sourceContextChangeIssueDescription}},
|
|
{"issue attachment inventory only", func(value *service.SourceContextSnapshot) { value.SourceIssue.Attachments = nil }, []string{}},
|
|
{"comment structure", func(value *service.SourceContextSnapshot) {
|
|
parent := "other"
|
|
value.CommentThread[0].ParentID = &parent
|
|
}, []string{sourceContextChangeCommentThread}},
|
|
{"comment structure and content", func(value *service.SourceContextSnapshot) {
|
|
parent := "other"
|
|
value.CommentThread[0].ParentID = &parent
|
|
value.CommentThread[0].Content = "Changed"
|
|
}, []string{sourceContextChangeCommentThread}},
|
|
{"comment content", func(value *service.SourceContextSnapshot) { value.CommentThread[0].Content = "Changed" }, []string{sourceContextChangeCommentThread}},
|
|
{"comment attachments", func(value *service.SourceContextSnapshot) { value.CommentThread[0].Attachments = nil }, []string{sourceContextChangeCommentThread}},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
value := sourceContextStateFixture()
|
|
test.mutate(&value)
|
|
got := sourceContextIssueChangeReasons(captured.SourceIssue, value.SourceIssue)
|
|
got = append(got, sourceContextThreadChangeReasons(captured, value)...)
|
|
if !reflect.DeepEqual(got, test.want) {
|
|
t.Fatalf("reasons = %v, want %v", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
contentChanged := sourceContextStateFixture()
|
|
contentChanged.CommentThread[0].Content = "Changed"
|
|
if got := sourceContextThreadChangeDetails(captured, contentChanged).ChangedCommentIDs; !reflect.DeepEqual(got, []string{"comment-1"}) {
|
|
t.Fatalf("changed comment ids = %v, want comment-1", got)
|
|
}
|
|
|
|
}
|
|
|
|
func TestSourceContextAttachmentReferencesCompareBySourceIdentity(t *testing.T) {
|
|
const sourceID = "11111111-2222-4333-8444-555555555555"
|
|
const cloneID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
|
stableURL := func(id string) string { return "/api/attachments/" + id + "/download" }
|
|
captured := sourceContextStateFixture()
|
|
current := sourceContextStateFixture()
|
|
captured.SourceIssue.Attachments = []service.SourceContextAttachment{{
|
|
ID: cloneID, SourceAttachmentID: sourceID, Filename: "issue.txt",
|
|
}}
|
|
current.SourceIssue.Attachments = []service.SourceContextAttachment{{
|
|
ID: sourceID, Filename: "issue.txt",
|
|
}}
|
|
capturedDescription := "Body\n\n!file[issue.txt](" + stableURL(cloneID) + ")"
|
|
currentDescription := "Body\n\n!file[issue.txt](https://cdn.test/workspaces/ws/" + sourceID + ".txt)"
|
|
captured.SourceIssue.Description = &capturedDescription
|
|
current.SourceIssue.Description = ¤tDescription
|
|
captured.CommentThread[0].Attachments = []service.SourceContextAttachment{{
|
|
ID: cloneID, SourceAttachmentID: sourceID, Filename: "comment.txt",
|
|
}}
|
|
current.CommentThread[0].Attachments = []service.SourceContextAttachment{{
|
|
ID: sourceID, Filename: "comment.txt",
|
|
}}
|
|
captured.CommentThread[0].Content = "See  + ")"
|
|
current.CommentThread[0].Content = "See "
|
|
|
|
if got := sourceContextIssueChangeReasons(captured.SourceIssue, current.SourceIssue); len(got) != 0 {
|
|
t.Fatalf("equivalent issue attachment references reasons = %v, want none", got)
|
|
}
|
|
if got := sourceContextThreadChangeReasons(captured, current); len(got) != 0 {
|
|
t.Fatalf("equivalent comment attachment references reasons = %v, want none", got)
|
|
}
|
|
|
|
current.CommentThread[0].Content = "See "
|
|
if got := sourceContextThreadChangeReasons(captured, current); !reflect.DeepEqual(got, []string{sourceContextChangeCommentThread}) {
|
|
t.Fatalf("attachment label edit reasons = %v, want comment thread", got)
|
|
}
|
|
}
|
|
|
|
func TestSourceContextChangeReasonsTreatEveryCommentAsPartOfTheThread(t *testing.T) {
|
|
fixture := func() (service.SourceContextSnapshot, service.SourceContextSnapshot) {
|
|
captured := sourceContextStateFixture()
|
|
selected := captured.CommentThread[0]
|
|
history := selected
|
|
history.ID = "comment-history"
|
|
history.Content = "Earlier thread comment"
|
|
captured.CommentThread = []service.SourceContextCommentSnapshot{history, selected}
|
|
current := captured
|
|
current.CommentThread = append([]service.SourceContextCommentSnapshot(nil), captured.CommentThread...)
|
|
return captured, current
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*service.SourceContextSnapshot)
|
|
want []string
|
|
}{
|
|
{
|
|
name: "other comment content",
|
|
mutate: func(value *service.SourceContextSnapshot) {
|
|
value.CommentThread[0].Content = "Edited thread comment"
|
|
},
|
|
want: []string{sourceContextChangeCommentThread},
|
|
},
|
|
{
|
|
name: "other comment attachments",
|
|
mutate: func(value *service.SourceContextSnapshot) {
|
|
value.CommentThread[0].Attachments = nil
|
|
},
|
|
want: []string{sourceContextChangeCommentThread},
|
|
},
|
|
{
|
|
name: "anchor comment content",
|
|
mutate: func(value *service.SourceContextSnapshot) {
|
|
value.CommentThread[1].Content = "Edited selected comment"
|
|
},
|
|
want: []string{sourceContextChangeCommentThread},
|
|
},
|
|
{
|
|
name: "anchor comment attachments",
|
|
mutate: func(value *service.SourceContextSnapshot) {
|
|
value.CommentThread[1].Attachments = nil
|
|
},
|
|
want: []string{sourceContextChangeCommentThread},
|
|
},
|
|
{
|
|
name: "multiple comments",
|
|
mutate: func(value *service.SourceContextSnapshot) {
|
|
value.CommentThread[0].Content = "Edited thread comment"
|
|
value.CommentThread[1].Content = "Edited selected comment"
|
|
},
|
|
want: []string{sourceContextChangeCommentThread},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
captured, current := fixture()
|
|
test.mutate(¤t)
|
|
if got := sourceContextThreadChangeReasons(captured, current); !reflect.DeepEqual(got, test.want) {
|
|
t.Fatalf("reasons = %v, want %v", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSourceContextThreadChangeDetailsClassifyAddedChangedAndRemovedComments(t *testing.T) {
|
|
captured := sourceContextStateFixture()
|
|
second := captured.CommentThread[0]
|
|
second.ID = "comment-2"
|
|
second.Content = "Removed comment"
|
|
third := captured.CommentThread[0]
|
|
third.ID = "comment-3"
|
|
third.Content = "Unchanged comment"
|
|
captured.CommentThread = []service.SourceContextCommentSnapshot{captured.CommentThread[0], second, third}
|
|
|
|
changed := captured.CommentThread[0]
|
|
changed.Content = "Changed comment"
|
|
added := captured.CommentThread[0]
|
|
added.ID = "comment-4"
|
|
added.Content = "Added comment"
|
|
current := captured
|
|
current.CommentThread = []service.SourceContextCommentSnapshot{changed, added, third}
|
|
|
|
details := sourceContextThreadChangeDetails(captured, current)
|
|
if !reflect.DeepEqual(details.ChangedCommentIDs, []string{"comment-1"}) {
|
|
t.Fatalf("changed comment ids = %v, want comment-1", details.ChangedCommentIDs)
|
|
}
|
|
if len(details.AddedComments) != 1 || details.AddedComments[0].ID != "comment-4" {
|
|
t.Fatalf("added comments = %#v, want comment-4", details.AddedComments)
|
|
}
|
|
if !reflect.DeepEqual(details.RemovedCommentIDs, []string{"comment-2"}) {
|
|
t.Fatalf("removed comment ids = %v, want comment-2", details.RemovedCommentIDs)
|
|
}
|
|
}
|
|
|
|
func TestSourceContextIssueDescriptionSeparatesBodyFromAttachmentReferences(t *testing.T) {
|
|
const firstID = "11111111-2222-4333-8444-555555555555"
|
|
const secondID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
|
pointer := func(value string) *string { return &value }
|
|
attachment := func(id, filename string) service.SourceContextAttachment {
|
|
return service.SourceContextAttachment{ID: id, Filename: filename, ContentType: "text/plain", SizeBytes: 12}
|
|
}
|
|
issue := func(description string, attachments ...service.SourceContextAttachment) service.SourceContextIssueSnapshot {
|
|
return service.SourceContextIssueSnapshot{Title: "Title", Description: pointer(description), Attachments: attachments}
|
|
}
|
|
stableURL := func(id string) string { return "/api/attachments/" + id + "/download" }
|
|
|
|
tests := []struct {
|
|
name string
|
|
captured service.SourceContextIssueSnapshot
|
|
current service.SourceContextIssueSnapshot
|
|
wantReasons []string
|
|
wantAttachments []sourceContextDescriptionAttachmentChange
|
|
}{
|
|
{
|
|
name: "removing an image node is an attachment-reference change only",
|
|
captured: issue("Body\n\n+")", attachment(firstID, "github.png")),
|
|
current: issue("Body", attachment(firstID, "github.png")),
|
|
wantReasons: []string{sourceContextChangeIssueDescriptionAttachments},
|
|
wantAttachments: []sourceContextDescriptionAttachmentChange{{
|
|
Kind: "removed", AttachmentID: firstID, Filename: "github.png",
|
|
}},
|
|
},
|
|
{
|
|
name: "adding a file card is an attachment-reference change only",
|
|
captured: issue("Body", attachment(firstID, "github.png"), attachment(secondID, "report.pdf")),
|
|
current: issue("Body\n\n!file[report.pdf]("+stableURL(secondID)+")", attachment(firstID, "github.png"), attachment(secondID, "report.pdf")),
|
|
wantReasons: []string{sourceContextChangeIssueDescriptionAttachments},
|
|
wantAttachments: []sourceContextDescriptionAttachmentChange{{
|
|
Kind: "added", AttachmentID: secondID, Filename: "report.pdf",
|
|
}},
|
|
},
|
|
{
|
|
name: "ordinary body edit remains an issue-description change",
|
|
captured: issue("Old\n\n+")", attachment(firstID, "github.png")),
|
|
current: issue("New\n\n+")", attachment(firstID, "github.png")),
|
|
wantReasons: []string{sourceContextChangeIssueDescription},
|
|
wantAttachments: []sourceContextDescriptionAttachmentChange{},
|
|
},
|
|
{
|
|
name: "external image remains ordinary description content",
|
|
captured: issue("Body\n\n"),
|
|
current: issue("Body"),
|
|
wantReasons: []string{sourceContextChangeIssueDescription},
|
|
wantAttachments: []sourceContextDescriptionAttachmentChange{},
|
|
},
|
|
{
|
|
name: "database attachment inventory change without markdown change is ignored",
|
|
captured: issue("Body", attachment(firstID, "github.png")),
|
|
current: issue("Body"),
|
|
wantReasons: []string{},
|
|
wantAttachments: []sourceContextDescriptionAttachmentChange{},
|
|
},
|
|
{
|
|
name: "attachment label rename is explicit",
|
|
captured: issue("!file[old.pdf]("+stableURL(firstID)+")", attachment(firstID, "old.pdf")),
|
|
current: issue("!file[new.pdf]("+stableURL(firstID)+")", attachment(firstID, "old.pdf")),
|
|
wantReasons: []string{sourceContextChangeIssueDescriptionAttachments},
|
|
wantAttachments: []sourceContextDescriptionAttachmentChange{{
|
|
Kind: "replaced", AttachmentID: firstID, Filename: "new.pdf", PreviousFilename: "old.pdf",
|
|
}},
|
|
},
|
|
{
|
|
name: "legacy public storage URL is matched through captured attachment id",
|
|
captured: issue("Body\n\n", attachment(firstID, "github.png")),
|
|
current: issue("Body", attachment(firstID, "github.png")),
|
|
wantReasons: []string{sourceContextChangeIssueDescriptionAttachments},
|
|
wantAttachments: []sourceContextDescriptionAttachmentChange{{
|
|
Kind: "removed", AttachmentID: firstID, Filename: "github.png",
|
|
}},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got := sourceContextIssueChangeDetails(test.captured, test.current)
|
|
if !reflect.DeepEqual(got.Reasons, test.wantReasons) {
|
|
t.Fatalf("reasons = %v, want %v", got.Reasons, test.wantReasons)
|
|
}
|
|
if !reflect.DeepEqual(got.DescriptionAttachmentChanges, test.wantAttachments) {
|
|
t.Fatalf("attachment changes = %#v, want %#v", got.DescriptionAttachmentChanges, test.wantAttachments)
|
|
}
|
|
})
|
|
}
|
|
}
|