forgejo-tickets/internal/forgejo/client_test.go

649 lines
19 KiB
Go

package forgejo
import (
"encoding/json"
"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
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
expectedPath := "/api/v1/repos/testowner/testrepo/issues"
if r.URL.Path != expectedPath {
t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path)
}
// Verify auth header
authHeader := r.Header.Get("Authorization")
if authHeader != "token test-token" {
t.Errorf("expected Authorization header %q, got %q", "token test-token", authHeader)
}
// Verify content type
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("expected Content-Type application/json, got %s", ct)
}
// Decode request body
var req CreateIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
if req.Title != "Test Issue" {
t.Errorf("expected title %q, got %q", "Test Issue", req.Title)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(Issue{
Number: 7,
Title: "Test Issue",
State: "open",
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
issue, err := client.CreateIssue("testowner", "testrepo", CreateIssueRequest{
Title: "Test Issue",
Body: "This is a test issue body.",
})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if issue.Number != 7 {
t.Errorf("expected issue number 7, got %d", issue.Number)
}
if issue.Title != "Test Issue" {
t.Errorf("expected issue title %q, got %q", "Test Issue", issue.Title)
}
if issue.State != "open" {
t.Errorf("expected issue state %q, got %q", "open", issue.State)
}
}
func TestCreateIssue_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.CreateIssue("testowner", "testrepo", CreateIssueRequest{
Title: "Test Issue",
Body: "Body",
})
if err == nil {
t.Fatal("expected error for 500 response, got nil")
}
}
func TestCreateIssue_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.CreateIssue("testowner", "testrepo", CreateIssueRequest{
Title: "Test Issue",
Body: "Body",
})
if err == nil {
t.Fatal("expected error for 404 response, got nil")
}
}
func TestCreateComment_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
expectedPath := "/api/v1/repos/owner/repo/issues/5/comments"
if r.URL.Path != expectedPath {
t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path)
}
authHeader := r.Header.Get("Authorization")
if authHeader != "token my-api-token" {
t.Errorf("expected Authorization header %q, got %q", "token my-api-token", authHeader)
}
var req CreateCommentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
if req.Body != "A comment" {
t.Errorf("expected body %q, got %q", "A comment", req.Body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(Comment{
ID: 99,
Body: "A comment",
})
}))
defer server.Close()
client := NewClient(server.URL, "my-api-token")
comment, err := client.CreateComment("owner", "repo", 5, CreateCommentRequest{
Body: "A comment",
})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if comment.ID != 99 {
t.Errorf("expected comment ID 99, got %d", comment.ID)
}
if comment.Body != "A comment" {
t.Errorf("expected comment body %q, got %q", "A comment", comment.Body)
}
}
func TestCreateComment_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("error"))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.CreateComment("owner", "repo", 1, CreateCommentRequest{
Body: "A comment",
})
if err == nil {
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)
}
}