tinyrelayfile
tinyrelay/tinyclient/collaboration_test.go/

tinyclient/collaboration_test.go raw
1package tinyclient
2
3import (
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
15func 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
33type collaborationBackend struct{ *fakeBackend }
34
35func (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
56func 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
89const (
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.
101type proposalBackend struct {
102 fakeBackend
103 state string
104 approver bool
105}
106
107func (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
148func 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
158func 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
195func 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
207func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs