diff --git a/internal/forgejo/client_test.go b/internal/forgejo/client_test.go index 8c67c4e..984f2d5 100644 --- a/internal/forgejo/client_test.go +++ b/internal/forgejo/client_test.go @@ -5,8 +5,11 @@ import ( "net/http" "net/http/httptest" "testing" + "time" ) +var fixedTime = time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC) + func TestCreateIssue_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify request method and path @@ -165,3 +168,481 @@ func TestCreateComment_ServerError(t *testing.T) { t.Fatal("expected error for 500 response, got nil") } } + +// --- StripCommentFooter Tests --- + +func TestStripCommentFooter_SubmittedByEmail(t *testing.T) { + body, email := StripCommentFooter("Description\n\n---\n*Submitted by: user@example.com*") + if body != "Description" { + t.Errorf("expected body %q, got %q", "Description", body) + } + if email != "user@example.com" { + t.Errorf("expected email %q, got %q", "user@example.com", email) + } +} + +func TestStripCommentFooter_CustomerCommentByEmail(t *testing.T) { + body, email := StripCommentFooter("Reply\n\n---\n*Customer comment by: alice@corp.com*") + if body != "Reply" { + t.Errorf("expected body %q, got %q", "Reply", body) + } + if email != "alice@corp.com" { + t.Errorf("expected email %q, got %q", "alice@corp.com", email) + } +} + +func TestStripCommentFooter_NoFooter(t *testing.T) { + body, email := StripCommentFooter("Plain comment") + if body != "Plain comment" { + t.Errorf("expected body %q, got %q", "Plain comment", body) + } + if email != "" { + t.Errorf("expected empty email, got %q", email) + } +} + +func TestStripCommentFooter_EmptyBody(t *testing.T) { + body, email := StripCommentFooter("") + if body != "" { + t.Errorf("expected empty body, got %q", body) + } + if email != "" { + t.Errorf("expected empty email, got %q", email) + } +} + +func TestStripCommentFooter_FooterWithoutColon(t *testing.T) { + body, email := StripCommentFooter("Body\n\n---\n*NoColonHere*") + if body != "Body" { + t.Errorf("expected body %q, got %q", "Body", body) + } + if email != "" { + t.Errorf("expected empty email, got %q", email) + } +} + +func TestStripCommentFooter_MultipleFooters(t *testing.T) { + input := "First\n\n---\n*Submitted by: first@example.com*\n\nMiddle\n\n---\n*Submitted by: second@example.com*" + body, email := StripCommentFooter(input) + expectedBody := "First\n\n---\n*Submitted by: first@example.com*\n\nMiddle" + if body != expectedBody { + t.Errorf("expected body %q, got %q", expectedBody, body) + } + if email != "second@example.com" { + t.Errorf("expected email %q, got %q", "second@example.com", email) + } +} + +func TestStripCommentFooter_MarkdownHorizontalRule(t *testing.T) { + input := "Text\n\n---\n\nMore text" + body, email := StripCommentFooter(input) + if body != input { + t.Errorf("expected body unchanged %q, got %q", input, body) + } + if email != "" { + t.Errorf("expected empty email, got %q", email) + } +} + +// --- BuildTimelineViews Tests: Comment events --- + +func TestBuildTimelineViews_TeamComment(t *testing.T) { + events := []TimelineEvent{ + { + ID: 501, + Type: "comment", + Body: "Looks good, merging now.", + User: APIUser{Login: "alicedev", FullName: "Alice Dev"}, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", false) + if len(views) != 1 { + t.Fatalf("expected 1 view, got %d", len(views)) + } + v := views[0] + if v.Type != "comment" { + t.Errorf("expected type %q, got %q", "comment", v.Type) + } + if !v.IsTeam { + t.Errorf("expected IsTeam=true") + } + if v.CommentID != 501 { + t.Errorf("expected CommentID 501, got %d", v.CommentID) + } + if v.AuthorName != "Alice Dev" { + t.Errorf("expected AuthorName %q, got %q", "Alice Dev", v.AuthorName) + } + if v.Body != "Looks good, merging now." { + t.Errorf("expected body %q, got %q", "Looks good, merging now.", v.Body) + } +} + +func TestBuildTimelineViews_CustomerCommentWithFooter(t *testing.T) { + events := []TimelineEvent{ + { + ID: 602, + Type: "comment", + Body: "Please help\n\n---\n*Submitted by: customer@example.com*", + User: APIUser{Login: "ticket-bot", FullName: "Ticket Bot"}, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", false) + if len(views) != 1 { + t.Fatalf("expected 1 view, got %d", len(views)) + } + v := views[0] + if v.IsTeam { + t.Errorf("expected IsTeam=false") + } + if v.CommentID != 602 { + t.Errorf("expected CommentID 602, got %d", v.CommentID) + } + if v.AuthorName != "customer@example.com" { + t.Errorf("expected AuthorName %q, got %q", "customer@example.com", v.AuthorName) + } + if v.Body != "Please help" { + t.Errorf("expected body %q, got %q", "Please help", v.Body) + } +} + +func TestBuildTimelineViews_CustomerCommentBotNoFooter(t *testing.T) { + events := []TimelineEvent{ + { + ID: 603, + Type: "comment", + Body: "I need assistance", + User: APIUser{Login: "ticket-bot", FullName: "Ticket Bot"}, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", false) + if len(views) != 1 { + t.Fatalf("expected 1 view, got %d", len(views)) + } + v := views[0] + if v.IsTeam { + t.Errorf("expected IsTeam=false") + } + if v.AuthorName != "Customer" { + t.Errorf("expected AuthorName %q, got %q", "Customer", v.AuthorName) + } + if v.CommentID != 603 { + t.Errorf("expected CommentID 603, got %d", v.CommentID) + } +} + +func TestBuildTimelineViews_CommentWithAttachments(t *testing.T) { + attachments := []Attachment{ + {ID: 1, Name: "screenshot.png", Size: 1024}, + {ID: 2, Name: "log.txt", Size: 512}, + } + events := []TimelineEvent{ + { + ID: 700, + Type: "comment", + Body: "See attached", + User: APIUser{Login: "dev1", FullName: "Developer One"}, + Assets: attachments, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", false) + if len(views) != 1 { + t.Fatalf("expected 1 view, got %d", len(views)) + } + v := views[0] + if v.CommentID != 700 { + t.Errorf("expected CommentID 700, got %d", v.CommentID) + } + if len(v.Attachments) != 2 { + t.Fatalf("expected 2 attachments, got %d", len(v.Attachments)) + } + if v.Attachments[0].Name != "screenshot.png" { + t.Errorf("expected first attachment %q, got %q", "screenshot.png", v.Attachments[0].Name) + } + if v.Attachments[1].Name != "log.txt" { + t.Errorf("expected second attachment %q, got %q", "log.txt", v.Attachments[1].Name) + } +} + +func TestBuildTimelineViews_CommentIDAlwaysSet(t *testing.T) { + events := []TimelineEvent{ + { + ID: 9999999, + Type: "comment", + Body: "Large ID test", + User: APIUser{Login: "dev", FullName: "Dev"}, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", false) + if len(views) != 1 { + t.Fatalf("expected 1 view, got %d", len(views)) + } + if views[0].CommentID != 9999999 { + t.Errorf("expected CommentID 9999999, got %d", views[0].CommentID) + } +} + +// --- BuildTimelineViews Tests: Status change events --- + +func TestBuildTimelineViews_CloseEvent(t *testing.T) { + events := []TimelineEvent{ + { + ID: 10, + Type: "close", + User: APIUser{Login: "admin", FullName: "Admin User"}, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", false) + if len(views) != 1 { + t.Fatalf("expected 1 view, got %d", len(views)) + } + v := views[0] + if v.Type != "status_change" { + t.Errorf("expected type %q, got %q", "status_change", v.Type) + } + if v.EventText != "closed this ticket" { + t.Errorf("expected EventText %q, got %q", "closed this ticket", v.EventText) + } + if !v.IsTeam { + t.Errorf("expected IsTeam=true") + } +} + +func TestBuildTimelineViews_ReopenEvent(t *testing.T) { + events := []TimelineEvent{ + { + ID: 11, + Type: "reopen", + User: APIUser{Login: "admin", FullName: "Admin User"}, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", false) + if len(views) != 1 { + t.Fatalf("expected 1 view, got %d", len(views)) + } + if views[0].EventText != "reopened this ticket" { + t.Errorf("expected EventText %q, got %q", "reopened this ticket", views[0].EventText) + } +} + +// --- BuildTimelineViews Tests: Admin-gated events --- + +func TestBuildTimelineViews_LabelEvent_AdminVisible(t *testing.T) { + events := []TimelineEvent{ + { + ID: 20, + Type: "label", + User: APIUser{Login: "admin", FullName: "Admin User"}, + Label: &Label{ID: 5, Name: "priority/high"}, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", true) + if len(views) != 1 { + t.Fatalf("expected 1 view, got %d", len(views)) + } + v := views[0] + if v.Type != "label" { + t.Errorf("expected type %q, got %q", "label", v.Type) + } + if v.EventText != "added label priority/high" { + t.Errorf("expected EventText %q, got %q", "added label priority/high", v.EventText) + } +} + +func TestBuildTimelineViews_LabelEvent_NonAdminHidden(t *testing.T) { + events := []TimelineEvent{ + { + ID: 21, + Type: "label", + User: APIUser{Login: "admin", FullName: "Admin User"}, + Label: &Label{ID: 5, Name: "priority/high"}, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", false) + if len(views) != 0 { + t.Errorf("expected 0 views for non-admin label event, got %d", len(views)) + } +} + +func TestBuildTimelineViews_LabelEvent_NilLabel(t *testing.T) { + events := []TimelineEvent{ + { + ID: 22, + Type: "label", + User: APIUser{Login: "admin", FullName: "Admin User"}, + Label: nil, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", true) + if len(views) != 1 { + t.Fatalf("expected 1 view, got %d", len(views)) + } + if views[0].EventText != "added label " { + t.Errorf("expected EventText %q, got %q", "added label ", views[0].EventText) + } +} + +func TestBuildTimelineViews_AssigneeEvent_AdminVisible(t *testing.T) { + events := []TimelineEvent{ + { + ID: 30, + Type: "assignees", + User: APIUser{Login: "admin", FullName: "Admin User"}, + Assignee: &APIUser{Login: "dev2", FullName: "Developer Two"}, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", true) + if len(views) != 1 { + t.Fatalf("expected 1 view, got %d", len(views)) + } + v := views[0] + if v.Type != "assignment" { + t.Errorf("expected type %q, got %q", "assignment", v.Type) + } + if v.EventText != "assigned Developer Two" { + t.Errorf("expected EventText %q, got %q", "assigned Developer Two", v.EventText) + } +} + +func TestBuildTimelineViews_AssigneeEvent_NonAdminHidden(t *testing.T) { + events := []TimelineEvent{ + { + ID: 31, + Type: "assignees", + User: APIUser{Login: "admin", FullName: "Admin User"}, + Assignee: &APIUser{Login: "dev2", FullName: "Developer Two"}, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", false) + if len(views) != 0 { + t.Errorf("expected 0 views for non-admin assignees event, got %d", len(views)) + } +} + +func TestBuildTimelineViews_AssigneeEvent_NilAssignee(t *testing.T) { + events := []TimelineEvent{ + { + ID: 32, + Type: "assignees", + User: APIUser{Login: "admin", FullName: "Admin User"}, + Assignee: nil, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", true) + if len(views) != 1 { + t.Fatalf("expected 1 view, got %d", len(views)) + } + if views[0].EventText != "assigned " { + t.Errorf("expected EventText %q, got %q", "assigned ", views[0].EventText) + } +} + +// --- BuildTimelineViews Tests: Edge cases & integration --- + +func TestBuildTimelineViews_UnknownEventType(t *testing.T) { + events := []TimelineEvent{ + { + ID: 40, + Type: "milestone", + User: APIUser{Login: "admin", FullName: "Admin User"}, + CreatedAt: fixedTime, + }, + } + views := BuildTimelineViews(events, "ticket-bot", true) + if len(views) != 0 { + t.Errorf("expected 0 views for unknown event type, got %d", len(views)) + } +} + +func TestBuildTimelineViews_EmptyEvents(t *testing.T) { + views := BuildTimelineViews([]TimelineEvent{}, "ticket-bot", false) + if len(views) != 0 { + t.Errorf("expected 0 views for empty events, got %d", len(views)) + } +} + +func TestBuildTimelineViews_MixedEvents(t *testing.T) { + events := []TimelineEvent{ + { + ID: 100, + Type: "comment", + Body: "Team comment", + User: APIUser{Login: "dev1", FullName: "Developer One"}, + CreatedAt: fixedTime, + }, + { + ID: 101, + Type: "label", + User: APIUser{Login: "admin", FullName: "Admin User"}, + Label: &Label{ID: 5, Name: "bug"}, + CreatedAt: fixedTime.Add(1 * time.Minute), + }, + { + ID: 102, + Type: "comment", + Body: "Customer reply\n\n---\n*Submitted by: cust@test.com*", + User: APIUser{Login: "ticket-bot", FullName: "Ticket Bot"}, + CreatedAt: fixedTime.Add(2 * time.Minute), + }, + { + ID: 103, + Type: "close", + User: APIUser{Login: "dev1", FullName: "Developer One"}, + CreatedAt: fixedTime.Add(3 * time.Minute), + }, + { + ID: 104, + Type: "assignees", + User: APIUser{Login: "admin", FullName: "Admin User"}, + Assignee: &APIUser{Login: "dev1", FullName: "Developer One"}, + CreatedAt: fixedTime.Add(4 * time.Minute), + }, + } + + // Non-admin: label and assignees should be filtered + nonAdminViews := BuildTimelineViews(events, "ticket-bot", false) + if len(nonAdminViews) != 3 { + t.Fatalf("non-admin: expected 3 views, got %d", len(nonAdminViews)) + } + if nonAdminViews[0].Type != "comment" || nonAdminViews[0].CommentID != 100 { + t.Errorf("non-admin view[0]: expected comment with ID 100, got type=%q id=%d", nonAdminViews[0].Type, nonAdminViews[0].CommentID) + } + if nonAdminViews[1].Type != "comment" || nonAdminViews[1].CommentID != 102 { + t.Errorf("non-admin view[1]: expected comment with ID 102, got type=%q id=%d", nonAdminViews[1].Type, nonAdminViews[1].CommentID) + } + if nonAdminViews[2].Type != "status_change" { + t.Errorf("non-admin view[2]: expected status_change, got %q", nonAdminViews[2].Type) + } + + // Admin: all 5 events should produce views + adminViews := BuildTimelineViews(events, "ticket-bot", true) + if len(adminViews) != 5 { + t.Fatalf("admin: expected 5 views, got %d", len(adminViews)) + } + // Verify order preserved + expectedTypes := []string{"comment", "label", "comment", "status_change", "assignment"} + for i, et := range expectedTypes { + if adminViews[i].Type != et { + t.Errorf("admin view[%d]: expected type %q, got %q", i, et, adminViews[i].Type) + } + } + // Verify comment IDs + if adminViews[0].CommentID != 100 { + t.Errorf("admin view[0]: expected CommentID 100, got %d", adminViews[0].CommentID) + } + if adminViews[2].CommentID != 102 { + t.Errorf("admin view[2]: expected CommentID 102, got %d", adminViews[2].CommentID) + } +} diff --git a/internal/handlers/public/tickets.go b/internal/handlers/public/tickets.go index 7e109b0..1a5f79e 100644 --- a/internal/handlers/public/tickets.go +++ b/internal/handlers/public/tickets.go @@ -210,6 +210,13 @@ func (h *TicketHandler) Create(c *gin.Context) { return } + // Apply customer label after creation (CreateIssue labels field is unreliable) + if len(labelIDs) > 0 { + if err := h.deps.ForgejoClient.AddLabels(repo.ForgejoOwner, repo.ForgejoRepo, issue.Number, labelIDs); err != nil { + log.Error().Err(err).Msg("forgejo add customer label error") + } + } + // Upload attachments if any if form != nil && form.File["attachments"] != nil { for _, fh := range form.File["attachments"] {