package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// commentJSON builds a single comment node as a generic map. Helpers below
// compose these into a paginated list response.
type commentJSON map[string]any
func footerComment(id, body, displayName string, replies ...commentJSON) commentJSON {
return commentJSON{
"id": id,
"type": "comment",
"status": "current",
"body": map[string]any{
"storage": map[string]any{
"value": body,
"representation": "storage",
},
},
"version": map[string]any{
"number": 1,
"when": "2025-12-02T09:00:00.000Z",
},
"history": map[string]any{
"createdDate": "2025-12-02T09:00:00.000Z",
"createdBy": map[string]any{
"username": "u-" + id,
"displayName": displayName,
"userKey": "key-" + id,
},
},
"extensions": map[string]any{
"location": "footer",
},
"children": childrenWrap(replies),
}
}
func inlineComment(id, body, markerRef, resolution, displayName string, replies ...commentJSON) commentJSON {
ext := map[string]any{
"location": "inline",
"inlineProperties": map[string]any{
"markerRef": markerRef,
},
}
if resolution != "" {
ext["resolution"] = map[string]any{"status": resolution}
}
return commentJSON{
"id": id,
"type": "comment",
"status": "current",
"body": map[string]any{
"storage": map[string]any{
"value": body,
"representation": "storage",
},
},
"version": map[string]any{
"number": 1,
"when": "2025-12-01T11:00:00.000Z",
},
"history": map[string]any{
"createdDate": "2025-12-01T10:15:00.000Z",
"createdBy": map[string]any{
"username": "u-" + id,
"displayName": displayName,
},
},
"extensions": ext,
"children": childrenWrap(replies),
}
}
func childrenWrap(replies []commentJSON) map[string]any {
results := make([]any, 0, len(replies))
for _, r := range replies {
results = append(results, r)
}
return map[string]any{
"comment": map[string]any{
"results": results,
"size": len(results),
},
}
}
func listResponse(results []commentJSON, next string) map[string]any {
out := make([]any, 0, len(results))
for _, r := range results {
out = append(out, r)
}
resp := map[string]any{
"results": out,
"size": len(out),
"limit": 200,
"start": 0,
"_links": map[string]any{},
}
if next != "" {
resp["_links"] = map[string]any{"next": next}
}
return resp
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(payload))
}
func TestGetComments_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/rest/api/content/12345/child/comment", r.URL.Path)
writeJSON(t, w, listResponse(nil, ""))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.GetComments("12345")
require.NoError(t, err)
assert.Empty(t, result)
}
func TestGetComments_FooterOnly(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, listResponse([]commentJSON{
footerComment("100", "<p>LGTM</p>", "Bob"),
footerComment("101", "<p>nice</p>", "Eve"),
}, ""))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.GetComments("12345")
require.NoError(t, err)
require.Len(t, result, 2)
assert.Equal(t, "footer", result[0].Location)
assert.Empty(t, result[0].MarkerRef)
assert.Equal(t, "<p>LGTM</p>", result[0].BodyStorage)
assert.Equal(t, "Bob", result[0].Author.DisplayName)
}
func TestGetComments_InlineWithMarker(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, listResponse([]commentJSON{
inlineComment("200", "<p>clarify?</p>", "uuid-A", "open", "Jane"),
}, ""))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.GetComments("12345")
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "inline", result[0].Location)
assert.Equal(t, "uuid-A", result[0].MarkerRef)
assert.False(t, result[0].Resolved)
assert.Equal(t, "open", result[0].Resolution)
assert.Equal(t, "Jane", result[0].Author.DisplayName)
}
func TestGetComments_Resolved(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, listResponse([]commentJSON{
inlineComment("300", "<p>fixed</p>", "uuid-B", "resolved", "Jane"),
}, ""))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.GetComments("12345")
require.NoError(t, err)
require.Len(t, result, 1)
assert.True(t, result[0].Resolved)
assert.Equal(t, "resolved", result[0].Resolution)
}
func TestGetComments_NestedReplies(t *testing.T) {
grandchild := inlineComment("403", "<p>thanks</p>", "uuid-C", "open", "Jane")
child1 := inlineComment("401", "<p>agreed</p>", "uuid-C", "open", "Alice", grandchild)
child2 := inlineComment("402", "<p>also</p>", "uuid-C", "open", "Bob")
parent := inlineComment("400", "<p>q?</p>", "uuid-C", "open", "Jane", child1, child2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, listResponse([]commentJSON{parent}, ""))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.GetComments("12345")
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "400", result[0].ID)
require.Len(t, result[0].Replies, 2)
assert.Equal(t, "401", result[0].Replies[0].ID)
require.Len(t, result[0].Replies[0].Replies, 1)
assert.Equal(t, "403", result[0].Replies[0].Replies[0].ID)
assert.Equal(t, "402", result[0].Replies[1].ID)
}
func TestGetComments_PaginationRelative(t *testing.T) {
var hits int32
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&hits, 1)
switch n {
case 1:
writeJSON(t, w, listResponse([]commentJSON{
footerComment("1", "<p>a</p>", "A"),
}, "/rest/api/content/12345/child/comment?start=1&limit=200"))
case 2:
assert.Equal(t, "1", r.URL.Query().Get("start"))
writeJSON(t, w, listResponse([]commentJSON{
footerComment("2", "<p>b</p>", "B"),
}, ""))
default:
t.Fatalf("unexpected request to %s", r.URL.String())
}
_ = server
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.GetComments("12345")
require.NoError(t, err)
require.Len(t, result, 2)
assert.Equal(t, int32(2), atomic.LoadInt32(&hits))
}
func TestGetComments_PaginationAbsolute(t *testing.T) {
var hits int32
var serverURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&hits, 1)
switch n {
case 1:
next := serverURL + "/rest/api/content/12345/child/comment?start=1"
writeJSON(t, w, listResponse([]commentJSON{
footerComment("1", "<p>a</p>", "A"),
}, next))
case 2:
assert.Equal(t, "1", r.URL.Query().Get("start"))
writeJSON(t, w, listResponse([]commentJSON{
footerComment("2", "<p>b</p>", "B"),
}, ""))
}
}))
defer server.Close()
serverURL = server.URL
client := NewClient(server.URL, "test-token")
result, err := client.GetComments("12345")
require.NoError(t, err)
require.Len(t, result, 2)
assert.Equal(t, int32(2), atomic.LoadInt32(&hits))
}
func TestGetComments_ExpandParam(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "all", r.URL.Query().Get("depth"))
assert.Equal(t, fmt.Sprintf("%d", commentLimit), r.URL.Query().Get("limit"))
expand := r.URL.Query().Get("expand")
assert.Equal(t, commentExpand, expand)
assert.True(t, strings.Contains(expand, "extensions.inlineProperties"))
assert.True(t, strings.Contains(expand, "children.comment.children.comment"))
writeJSON(t, w, listResponse(nil, ""))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.GetComments("12345")
require.NoError(t, err)
}
func TestGetComments_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.GetComments("12345")
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestGetComments_MissingOptionalFields(t *testing.T) {
// Footer comment without resolution/inlineProperties; minimum viable shape.
c := commentJSON{
"id": "999",
"type": "comment",
"status": "current",
"body": map[string]any{
"storage": map[string]any{"value": "<p>hi</p>", "representation": "storage"},
},
"extensions": map[string]any{"location": "footer"},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, listResponse([]commentJSON{c}, ""))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.GetComments("12345")
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "footer", result[0].Location)
assert.Empty(t, result[0].MarkerRef)
assert.False(t, result[0].Resolved)
assert.Empty(t, result[0].Resolution)
assert.Empty(t, result[0].Author.DisplayName)
}