| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "net/url" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 13 | ) |
| 14 | |
| 15 | const 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" |
| 16 | |
| 17 | func 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.Text |
| 36 | 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 | } |
| 44 | |
| 45 | type reviewBackend struct{ *fakeBackend } |
| 46 | |
| 47 | func (b *reviewBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 48 | b.call = method |
| 49 | b.params = params |
| 50 | owner := b.policy.Owner |
| 51 | 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" }}}, nil |
| 62 | } |
| 63 | |
| 64 | func 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 stay |
| 84 | // 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 | continue |
| 101 | } |
| 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 | } |