1package tinyclient23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "net/url"9 "strings"10 "testing"1112 "github.com/FelineStateMachine/tinyrelay/internal/policy"13)1415const reviewTestDiff = "diff --git a/README b/README\nindex 1111111..2222222 100644\n--- a/README\n+++ b/README\n@@ -1,3 +1,4 @@\n first\n-second\n+two\n+three\n fourth\ndiff --git a/docs/new.md b/docs/new.md\nnew file mode 100644\n--- /dev/null\n+++ b/docs/new.md\n@@ -0,0 +1,2 @@\n+# New\n+body\n\\ No newline at end of file\n"1617func TestDiffLinesNumberBothSidesAndTrackFiles(t *testing.T) {18 lines, _ := previewLines(reviewTestDiff)19 rows := diffLines(lines)20 want := map[int]diffLine{21 3: {Wrapper: "del", File: "README"},22 5: {Wrapper: "b", File: "README"},23 6: {File: "README", Old: 1, New: 1},24 7: {Wrapper: "del", File: "README", Old: 2},25 8: {Wrapper: "ins", File: "README", New: 2},26 9: {Wrapper: "ins", File: "README", New: 3},27 10: {File: "README", Old: 3, New: 4},28 14: {Wrapper: "ins", File: "docs/new.md"},29 16: {Wrapper: "ins", File: "docs/new.md", New: 1},30 17: {Wrapper: "ins", File: "docs/new.md", New: 2},31 18: {File: "docs/new.md"},32 }33 for number, expected := range want {34 got := rows[number-1]35 expected.Number, expected.Text = number, got.Text36 if got != expected {37 t.Fatalf("row %d = %+v, want %+v", number, got, expected)38 }39 }40 if got := rows[11]; got.File != "" || got.Old != 0 || got.New != 0 {41 t.Fatalf("a new diff header kept the previous file: %+v", got)42 }43}4445type reviewBackend struct{ *fakeBackend }4647func (b *reviewBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {48 b.call = method49 b.params = params50 owner := b.policy.Owner51 root := map[string]any{"id": strings.Repeat("b", 64), "title": "Rename second", "kind": 1618, "author": owner, "content": "Why", "status": "open"}52 reply := func(id, content string, extra ...[]string) map[string]any {53 tags := append([][]string{{"a", "30617:" + owner + ":test"}, {"E", strings.Repeat("b", 64), "", owner}, {"K", "1618"}, {"P", owner}}, extra...)54 return map[string]any{"id": id, "kind": 1111, "pubkey": strings.Repeat("c", 64), "created_at": 1700000000, "content": content, "tags": tags}55 }56 anchored := reply(strings.Repeat("1", 64), "Prefer a longer word.", []string{"file", "README"}, []string{"line", "2", "new"})57 anchored["file"], anchored["line"], anchored["side"] = "README", 2, "new"58 lost := reply(strings.Repeat("2", 64), "Outside the diff.", []string{"file", "missing.txt"}, []string{"line", "9", "old"})59 lost["file"], lost["line"], lost["side"] = "missing.txt", 9, "old"60 plain := reply(strings.Repeat("3", 64), "Looks good overall.")61 return map[string]any{"item": root, "replies": []any{plain, lost, anchored}, "can_status": true, "diff": reviewTestDiff, "repository": map[string]any{"owner": owner, "clone": []string{"https://git.example/test.git"}}}, nil62}6364func TestPullRequestPagePlacesAnchoredCommentsUnderDiffLines(t *testing.T) {65 for _, viewer := range []string{"", strings.Repeat("a", 64)} {66 backend := &reviewBackend{&fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}67 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return viewer, nil }})68 if err != nil {69 t.Fatal(err)70 }71 query := url.Values{"owner": {backend.policy.Owner}, "repo": {"test"}}72 link := repoURL(query, "pr", strings.Repeat("b", 64))73 w := httptest.NewRecorder()74 app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link, nil))75 body := w.Body.String()76 if w.Code != http.StatusOK || backend.call != "browsepull" {77 t.Fatalf("viewer %q: %d %s", viewer, w.Code, body)78 }79 if strings.Contains(body, "class=") {80 t.Fatalf("viewer %q: markup carries class attributes", viewer)81 }82 // The anchored comment sits in the row after new line 2 of README,83 // which is rendered diff row 8; the plain and unplaced comments stay84 // in the thread, the unplaced one with its anchor shown.85 row := strings.Index(body, `<tr id="D8">`)86 nextRow := strings.Index(body, `<tr id="D9">`)87 if row < 0 || nextRow < row || !strings.Contains(body[row:nextRow], "Prefer a longer word.") || !strings.Contains(body[row:nextRow], `<nostr-event id="event-`+strings.Repeat("1", 64)+`">`) {88 t.Fatalf("viewer %q: anchored comment not placed under its line: %s", viewer, body[row:nextRow])89 }90 thread := body[strings.Index(body, `<ol id="events">`):]91 if strings.Contains(thread, "Prefer a longer word.") || !strings.Contains(thread, "Looks good overall.") || !strings.Contains(thread, "on missing.txt line 9 (old)") {92 t.Fatalf("viewer %q: thread = %s", viewer, thread)93 }94 commentLink := strings.Contains(body, `file=README&line=2&side=new#reply-compose">comment</a>`)95 removedLink := strings.Contains(body, `file=README&line=2&side=old#reply-compose">comment</a>`)96 if viewer == "" {97 if commentLink || strings.Contains(body, "<nostr-compose") {98 t.Fatal("guest received comment links or a compose")99 }100 continue101 }102 if !commentLink || !removedLink || !strings.Contains(body, `&file=docs%2Fnew.md&line=1&side=new#reply-compose">comment</a>`) {103 t.Fatalf("comment links missing: %s", body)104 }105 if strings.Contains(body, `<input type="hidden" name="file"`) {106 t.Fatal("compose was preset without a query anchor")107 }108 w = httptest.NewRecorder()109 app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link+"?file=README&line=2&side=new", nil))110 body = w.Body.String()111 for _, want := range []string{`<nostr-compose id="reply-compose" kind="1111"`, `<input type="hidden" name="file" value="README">`, `<input type="hidden" name="line" value="2">`, `<input type="hidden" name="side" value="new">`, `<p id="reply-anchor">Commenting on <code>README</code> line 2 (new side).`} {112 if !strings.Contains(body, want) {113 t.Fatalf("preset compose missing %s: %s", want, body)114 }115 }116 w = httptest.NewRecorder()117 app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link+"?file=README&line=0&side=left", nil))118 if strings.Contains(w.Body.String(), `name="file"`) {119 t.Fatal("an invalid query anchor preset the compose")120 }121 }122}