| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 7 | "html" |
| 8 | "net/http" |
| 9 | "net/http/httptest" |
| 10 | "net/url" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | ) |
| 14 | |
| 15 | func TestCollaborationTypedBackendResults(t *testing.T) { |
| 16 | type item struct { |
| 17 | ID string `json:"id"` |
| 18 | Labels []string `json:"labels"` |
| 19 | } |
| 20 | value := map[string]any{"items" : []item{{ID: "issue" , Labels: []string{"bug" }}}, "replies" : []item{{ID: "reply" }}} |
| 21 | items := collaborationItems(value) |
| 22 | if len(items) != 1 || valueMap(items[0])["id" ] != "issue" { |
| 23 | t.Fatalf("typed items lost: %v" , items) |
| 24 | } |
| 25 | if labels := collaborationLabels(items[0]); len(labels) != 1 || labels[0] != "bug" { |
| 26 | t.Fatalf("labels lost: %v" , labels) |
| 27 | } |
| 28 | if replies := collaborationReplies(value); len(replies) != 1 || valueMap(replies[0])["id" ] != "reply" { |
| 29 | t.Fatalf("typed replies lost: %v" , replies) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | type collaborationBackend struct{ *fakeBackend } |
| 34 | |
| 35 | func (b *collaborationBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 36 | b.call = method |
| 37 | b.params = params |
| 38 | type item struct { |
| 39 | ID string `json:"id"` |
| 40 | Title string `json:"title"` |
| 41 | Kind int `json:"kind"` |
| 42 | Author string `json:"author"` |
| 43 | Content string `json:"content"` |
| 44 | Status string `json:"status"` |
| 45 | } |
| 46 | root := item{ID: strings.Repeat("b" , 64), Title: "A visible issue" , Kind: 1621, Author: b.policy.Owner, Content: "Issue body" , Status: "closed" } |
| 47 | if method == "browseissues" { |
| 48 | return map[string]any{"items" : []item{root}}, nil |
| 49 | } |
| 50 | if method == "browseissue" { |
| 51 | return map[string]any{"item" : root, "replies" : []any{}, "can_status" : true, "repository" : map[string]any{"owner" : b.policy.Owner, "clone" : []string{"https://git.example/test.git" }, "private" : true, "maintainers" : []map[string]any{{"pubkey" : b.policy.Owner, "role" : "owner" }, {"pubkey" : strings.Repeat("c" , 64), "role" : "maintainer" }, {"pubkey" : strings.Repeat("d" , 64), "role" : "agent" , "name" : "release-notes" }}}}, nil |
| 52 | } |
| 53 | return map[string]any{}, nil |
| 54 | } |
| 55 | |
| 56 | func TestCollaborationPagesUseTypedResultsAndWorkingDetailLinks(t *testing.T) { |
| 57 | backend := &collaborationBackend{&fakeBackend{policy: policy.Defaults(strings.Repeat("a" , 64))}} |
| 58 | app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }}) |
| 59 | if err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | query := url.Values{"owner" : {backend.policy.Owner}, "repo" : {"test" }} |
| 63 | w := httptest.NewRecorder() |
| 64 | app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, repoURL(query, "issues" , "" ), nil)) |
| 65 | if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "A visible issue" ) { |
| 66 | t.Fatalf("list failed: %d %s" , w.Code, w.Body.String()) |
| 67 | } |
| 68 | link := repoURL(query, "issue" , strings.Repeat("b" , 64)) |
| 69 | if !strings.Contains(w.Body.String(), html.EscapeString(link)) { |
| 70 | t.Fatalf("detail link missing: %s" , link) |
| 71 | } |
| 72 | w = httptest.NewRecorder() |
| 73 | app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link, nil)) |
| 74 | if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Issue body" ) || !strings.Contains(w.Body.String(), `kind="status"` ) || !strings.Contains(w.Body.String(), "https://git.example/test.git" ) || !strings.Contains(w.Body.String(), "<td>private</td>" ) { |
| 75 | t.Fatalf("detail failed: %d %s" , w.Code, w.Body.String()) |
| 76 | } |
| 77 | if body := w.Body.String(); !strings.Contains(body, `<ul id="maintainers">` ) || !strings.Contains(body, `pubkey="` +strings.Repeat("d" , 64)+`"` ) || !strings.Contains(body, "<small>agent release-notes</small>" ) || strings.Count(body, "<small>owner</small>" ) != 1 || strings.Contains(body, "<small>maintainer</small>" ) { |
| 78 | t.Fatalf("maintainer panel: %s" , body) |
| 79 | } |
| 80 | var request map[string]any |
| 81 | if err := json.Unmarshal(backend.params[0], &request); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if backend.call != "browseissue" || request["event" ] != strings.Repeat("b" , 64) { |
| 85 | t.Fatalf("wrong detail request: %s %v" , backend.call, request) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | const ( |
| 90 | proposalAgent = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" |
| 91 | proposalMember = "6666666666666666666666666666666666666666666666666666666666666666" |
| 92 | proposalIssue = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" |
| 93 | proposalComment = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" |
| 94 | proposalPull = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" |
| 95 | proposalReview = "1111111111111111111111111111111111111111111111111111111111111111" |
| 96 | ) |
| 97 | |
| 98 | // proposalBackend serves one repository with an issue and a pull request |
| 99 | // proposed by an agent, each with a proposed comment. state is the |
| 100 | // proposals' approval and approver marks the caller as allowed to decide. |
| 101 | type proposalBackend struct { |
| 102 | fakeBackend |
| 103 | state string |
| 104 | approver bool |
| 105 | } |
| 106 | |
| 107 | func (b *proposalBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) { |
| 108 | b.call = method |
| 109 | b.params = params |
| 110 | proposal := func(m map[string]any) map[string]any { |
| 111 | m["proposal" ] = true |
| 112 | m["approval" ] = b.state |
| 113 | if b.state != "pending" { |
| 114 | m["approval_event" ], m["approval_at" ], m["approval_by" ] = strings.Repeat("9" , 64), float64(1788882000), b.policy.Owner |
| 115 | } |
| 116 | return m |
| 117 | } |
| 118 | ownerIssue := map[string]any{"id" : strings.Repeat("b" , 64), "title" : "Owner issue" , "kind" : float64(1621), "author" : b.policy.Owner, "content" : "Owner body" , "status" : "open" , "created_at" : float64(1788800000)} |
| 119 | agentIssue := proposal(map[string]any{"id" : proposalIssue, "title" : "Agent issue" , "kind" : float64(1621), "author" : proposalAgent, "content" : "Agent body" , "status" : "open" , "created_at" : float64(1788800100), "labels" : []any{"bug" }}) |
| 120 | agentPull := proposal(map[string]any{"id" : proposalPull, "title" : "Agent pull" , "kind" : float64(1618), "author" : proposalAgent, "content" : "Pull body" , "status" : "open" , "created_at" : float64(1788800200)}) |
| 121 | comment := proposal(map[string]any{"id" : proposalComment, "kind" : float64(1111), "pubkey" : proposalAgent, "content" : "Agent comment" , "created_at" : float64(1788800300), "tags" : []any{}}) |
| 122 | plain := map[string]any{"id" : strings.Repeat("2" , 64), "kind" : float64(1111), "pubkey" : proposalMember, "content" : "Member comment" , "created_at" : float64(1788800400), "tags" : []any{}} |
| 123 | review := proposal(map[string]any{"id" : proposalReview, "kind" : float64(1111), "pubkey" : proposalAgent, "content" : "Agent review" , "created_at" : float64(1788800500), "tags" : []any{}, "file" : "f.txt" , "line" : float64(1), "side" : "new" }) |
| 124 | repository := map[string]any{"owner" : b.policy.Owner, "clone" : []any{}, "maintainers" : []any{}} |
| 125 | var request struct { |
| 126 | Event string `json:"event"` |
| 127 | } |
| 128 | if len(params) > 0 { |
| 129 | _ = json.Unmarshal(params[0], &request) |
| 130 | } |
| 131 | switch method { |
| 132 | case "browseissues" : |
| 133 | return map[string]any{"repository" : repository, "items" : []any{agentIssue, ownerIssue}, "can_approve" : b.approver}, nil |
| 134 | case "browsepulls" : |
| 135 | return map[string]any{"repository" : repository, "items" : []any{agentPull}, "can_approve" : b.approver}, nil |
| 136 | case "browseissue" : |
| 137 | item := ownerIssue |
| 138 | if request.Event == proposalIssue { |
| 139 | item = agentIssue |
| 140 | } |
| 141 | return map[string]any{"repository" : repository, "item" : item, "replies" : []any{comment, plain}, "can_approve" : b.approver, "can_status" : false}, nil |
| 142 | case "browsepull" : |
| 143 | return map[string]any{"repository" : repository, "item" : agentPull, "replies" : []any{review, plain}, "diff" : "diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n@@ -1 +1 @@\n-old\n+new\n" , "can_approve" : b.approver, "can_status" : false}, nil |
| 144 | } |
| 145 | return map[string]any{}, nil |
| 146 | } |
| 147 | |
| 148 | func proposalPage(t *testing.T, b *proposalBackend, actor, view, id string) string { |
| 149 | t.Helper() |
| 150 | app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }}) |
| 151 | if err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | path := repoURL(url.Values{"owner" : {b.policy.Owner}, "repo" : {"test" }}, view, id) |
| 155 | return wikiGet(t, app, path) |
| 156 | } |
| 157 | |
| 158 | func TestCollaborationProposalBarOffersTheDecisionToApprovers(t *testing.T) { |
| 159 | b := &proposalBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a" , 64))}, state: "pending" , approver: true} |
| 160 | owner := b.policy.Owner |
| 161 | body := proposalPage(t, b, owner, "issue" , proposalIssue) |
| 162 | wantAll(t, "issue" , body, |
| 163 | `<collaboration-proposal data-state="pending">Proposed by <nostr-name pubkey="` +proposalAgent+`"` , |
| 164 | `| pending<nostr-react event="` +proposalIssue+`" pubkey="` +proposalAgent+`" kind="1621"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react></collaboration-proposal> |
| 165 | <article id="collaboration-body" >`, |
| 166 | `<li><collaboration-proposal data-state="pending">Proposed by <nostr-name pubkey="` +proposalAgent+`"` , |
| 167 | `<nostr-react event="` +proposalComment+`" pubkey="` +proposalAgent+`" kind="1111">` , |
| 168 | `<li><nostr-event id="event-` +strings.Repeat("2" , 64)+`">` ) |
| 169 | if strings.Count(body, "<collaboration-proposal" ) != 2 { |
| 170 | t.Fatalf("issue page bars: %s" , body) |
| 171 | } |
| 172 | // A comment placed on a diff line carries its bar inside the diff. |
| 173 | body = proposalPage(t, b, owner, "pr" , proposalPull) |
| 174 | wantAll(t, "pr" , body, |
| 175 | `<nostr-react event="` +proposalPull+`" pubkey="` +proposalAgent+`" kind="1618">` , |
| 176 | `<td></td><td><collaboration-proposal data-state="pending">Proposed by <nostr-name pubkey="` +proposalAgent+`"` , |
| 177 | `<nostr-react event="` +proposalReview+`" pubkey="` +proposalAgent+`" kind="1111">` ) |
| 178 | // The lists mark the rows for those who may see them. |
| 179 | body = proposalPage(t, b, owner, "issues" , "" ) |
| 180 | wantAll(t, "issues" , body, `<tr data-state="pending"><td>open</td><td><a href="` , `">Agent issue</a> <small>proposed | pending</small> <small>bug</small></td>` , `<tr><td>open</td><td><a href="` ) |
| 181 | body = proposalPage(t, b, owner, "prs" , "" ) |
| 182 | wantAll(t, "prs" , body, `<tr data-state="pending"><td>open</td><td><a href="` , `">Agent pull</a> <small>proposed | pending</small></td>` ) |
| 183 | // A decided proposal names the decision, its time and the deciding key, |
| 184 | // and offers no buttons. |
| 185 | for _, state := range []string{"approved" , "rejected" } { |
| 186 | b.state = state |
| 187 | body = proposalPage(t, b, owner, "issue" , proposalIssue) |
| 188 | wantAll(t, state, body, `<collaboration-proposal data-state="` +state+`">Proposed by <nostr-name pubkey="` +proposalAgent+`"` , `| ` +state+` 2026-09-08 15:40 UTC by <nostr-name pubkey="` +owner+`"` ) |
| 189 | wantNone(t, state, body, "<nostr-react" , ">Accept<" ) |
| 190 | body = proposalPage(t, b, owner, "issues" , "" ) |
| 191 | wantAll(t, state, body, `<tr data-state="` +state+`">` , `<small>proposed | ` +state+`</small>` ) |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | func TestCollaborationProposalAuthorSeesTheStateWithoutButtons(t *testing.T) { |
| 196 | b := &proposalBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a" , 64))}, state: "pending" } |
| 197 | body := proposalPage(t, b, proposalAgent, "issue" , proposalIssue) |
| 198 | wantAll(t, "issue" , body, `<collaboration-proposal data-state="pending">Proposed by <nostr-name pubkey="` +proposalAgent+`"` , `| pending</collaboration-proposal>` ) |
| 199 | wantNone(t, "issue" , body, "<nostr-react" , ">Accept<" , ">Reject<" ) |
| 200 | if strings.Count(body, "<collaboration-proposal" ) != 2 { |
| 201 | t.Fatalf("author bars: %s" , body) |
| 202 | } |
| 203 | body = proposalPage(t, b, proposalAgent, "issues" , "" ) |
| 204 | wantAll(t, "issues" , body, `<tr data-state="pending">` , `">Agent issue</a> <small>proposed | pending</small>` ) |
| 205 | } |
| 206 | |
| 207 | func TestCollaborationProposalStateIsHiddenFromMembers(t *testing.T) { |
| 208 | b := &proposalBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a" , 64))}, state: "approved" } |
| 209 | for _, page := range [][2]string{{"issue" , proposalIssue}, {"pr" , proposalPull}, {"issues" , "" }, {"prs" , "" }} { |
| 210 | body := proposalPage(t, b, proposalMember, page[0], page[1]) |
| 211 | wantAll(t, page[0], body, "Agent " ) |
| 212 | wantNone(t, page[0], body, "<collaboration-proposal" , "<tr data-state=" , "proposed | " , "<nostr-react" ) |
| 213 | } |
| 214 | } |