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_SubmittedByNameAndEmail(t *testing.T) { body, attribution := StripCommentFooter("Description\n\n---\n*Submitted by: John Doe *") if body != "Description" { t.Errorf("expected body %q, got %q", "Description", body) } if attribution != "John Doe " { t.Errorf("expected attribution %q, got %q", "John Doe ", attribution) } } func TestStripCommentFooter_CustomerCommentByNameAndEmail(t *testing.T) { body, attribution := StripCommentFooter("Reply\n\n---\n*Customer comment by: Alice Smith *") if body != "Reply" { t.Errorf("expected body %q, got %q", "Reply", body) } if attribution != "Alice Smith " { t.Errorf("expected attribution %q, got %q", "Alice Smith ", attribution) } } 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) } }