clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / tinyclient / collaboration_test.go
collaboration_test.go 11.4K
1package tinyclient23import (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)1415func 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}3233type collaborationBackend struct{ *fakeBackend }3435func (b *collaborationBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {36 b.call = method37 b.params = params38 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}}, nil49 }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"}}}}, nil52 }53 return map[string]any{}, nil54}5556func 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]any81 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}8889const (90 proposalAgent = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"91 proposalMember = "6666666666666666666666666666666666666666666666666666666666666666"92 proposalIssue = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"93 proposalComment = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"94 proposalPull = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"95 proposalReview = "1111111111111111111111111111111111111111111111111111111111111111"96)9798// proposalBackend serves one repository with an issue and a pull request99// proposed by an agent, each with a proposed comment. state is the100// proposals' approval and approver marks the caller as allowed to decide.101type proposalBackend struct {102 fakeBackend103 state string104 approver bool105}106107func (b *proposalBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {108 b.call = method109 b.params = params110 proposal := func(m map[string]any) map[string]any {111 m["proposal"] = true112 m["approval"] = b.state113 if b.state != "pending" {114 m["approval_event"], m["approval_at"], m["approval_by"] = strings.Repeat("9", 64), float64(1788882000), b.policy.Owner115 }116 return m117 }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}, nil134 case "browsepulls":135 return map[string]any{"repository": repository, "items": []any{agentPull}, "can_approve": b.approver}, nil136 case "browseissue":137 item := ownerIssue138 if request.Event == proposalIssue {139 item = agentIssue140 }141 return map[string]any{"repository": repository, "item": item, "replies": []any{comment, plain}, "can_approve": b.approver, "can_status": false}, nil142 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}, nil144 }145 return map[string]any{}, nil146}147148func 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}157158func TestCollaborationProposalBarOffersTheDecisionToApprovers(t *testing.T) {159 b := &proposalBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}, state: "pending", approver: true}160 owner := b.policy.Owner161 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 = state187 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}194195func 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}206207func 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}