Monorepo for Tangled
tangled.org
1package knotacl
2
3import (
4 "context"
5 "encoding/json"
6 "io"
7 "log/slog"
8 "net/http"
9 "net/http/httptest"
10 "slices"
11 "strings"
12 "sync"
13 "testing"
14
15 "tangled.org/core/api/tangled"
16)
17
18func testLogger() *slog.Logger {
19 return slog.New(slog.NewTextHandler(io.Discard, nil))
20}
21
22const (
23 testOwner = "did:plc:akshay"
24 testCollab = "did:plc:boltless"
25 testRepoDid = "did:plc:limpet"
26 testStrange = "did:plc:scallop"
27)
28
29type recordingKnot struct {
30 mu sync.Mutex
31 requests []string
32 handler func(w http.ResponseWriter, r *http.Request)
33}
34
35func (k *recordingKnot) ServeHTTP(w http.ResponseWriter, r *http.Request) {
36 k.mu.Lock()
37 k.requests = append(k.requests, r.URL.String())
38 k.mu.Unlock()
39 k.handler(w, r)
40}
41
42func (k *recordingKnot) calls() []string {
43 k.mu.Lock()
44 defer k.mu.Unlock()
45 return slices.Clone(k.requests)
46}
47
48func devClientFor(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) (*Client, *recordingKnot, string) {
49 t.Helper()
50 knot := &recordingKnot{handler: handler}
51 srv := httptest.NewServer(knot)
52 t.Cleanup(srv.Close)
53 host := strings.TrimPrefix(srv.URL, "http://")
54 return NewClient(true, testLogger()), knot, host
55}
56
57func memberPage(items []string, cursor string) tangled.KnotListMembers_Output {
58 out := tangled.KnotListMembers_Output{
59 Items: mapSlice(items, func(d string) *tangled.KnotListMembers_ListItem {
60 return &tangled.KnotListMembers_ListItem{Subject: d, AddedBy: testOwner, CreatedAt: "2026-06-03T00:00:00Z"}
61 }),
62 }
63 if cursor != "" {
64 out.Cursor = &cursor
65 }
66 return out
67}
68
69func collabPage(items []string, cursor string) tangled.RepoListCollaborators_Output {
70 out := tangled.RepoListCollaborators_Output{
71 Items: mapSlice(items, func(d string) *tangled.RepoListCollaborators_ListItem {
72 return &tangled.RepoListCollaborators_ListItem{Subject: d, AddedBy: testOwner, CreatedAt: "2026-06-03T00:00:00Z"}
73 }),
74 }
75 if cursor != "" {
76 out.Cursor = &cursor
77 }
78 return out
79}
80
81func TestGetKnotMembers_SinglePage(t *testing.T) {
82 c, _, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) {
83 json.NewEncoder(w).Encode(memberPage([]string{testCollab, testOwner, testCollab}, ""))
84 })
85
86 got, err := c.GetKnotMembers(context.Background(), host)
87 if err != nil {
88 t.Fatalf("GetKnotMembers: %v", err)
89 }
90 want := []string{testOwner, testCollab}
91 if !slices.Equal(got, want) {
92 t.Errorf("members = %v, want sorted+deduped %v", got, want)
93 }
94}
95
96func TestGetKnotMembers_Paginates(t *testing.T) {
97 c, knot, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) {
98 if r.URL.Query().Get("cursor") == "" {
99 json.NewEncoder(w).Encode(memberPage([]string{testOwner}, "page2"))
100 return
101 }
102 json.NewEncoder(w).Encode(memberPage([]string{testCollab}, ""))
103 })
104
105 got, err := c.GetKnotMembers(context.Background(), host)
106 if err != nil {
107 t.Fatalf("GetKnotMembers: %v", err)
108 }
109 if want := []string{testOwner, testCollab}; !slices.Equal(got, want) {
110 t.Errorf("members = %v, want union %v", got, want)
111 }
112 calls := knot.calls()
113 if len(calls) != 2 {
114 t.Fatalf("calls = %d, want 2 pages", len(calls))
115 }
116 if !strings.Contains(calls[1], "cursor=page2") {
117 t.Errorf("second call %q did not carry the page-1 cursor", calls[1])
118 }
119}
120
121func TestGetKnotMembers_KnotDown(t *testing.T) {
122 c, _, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) {
123 http.Error(w, "boom", http.StatusInternalServerError)
124 })
125
126 _, err := c.GetKnotMembers(context.Background(), host)
127 if err == nil {
128 t.Fatal("want error when knot is down; the Client must surface it for the Service to swallow")
129 }
130}
131
132func TestGetRepoCollaborators_PassesRepoDidAsSubject(t *testing.T) {
133 c, knot, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) {
134 json.NewEncoder(w).Encode(collabPage([]string{testCollab}, ""))
135 })
136
137 got, err := c.GetRepoCollaborators(context.Background(), host, testRepoDid)
138 if err != nil {
139 t.Fatalf("GetRepoCollaborators: %v", err)
140 }
141 if want := []string{testCollab}; !slices.Equal(got, want) {
142 t.Errorf("collaborators = %v, want %v", got, want)
143 }
144 if calls := knot.calls(); len(calls) != 1 || !strings.Contains(calls[0], "subject="+strings.ReplaceAll(testRepoDid, ":", "%3A")) {
145 t.Errorf("subject param missing repo DID: %v", calls)
146 }
147}
148
149func TestDrainStopsOnRepeatedCursor(t *testing.T) {
150 c, knot, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) {
151 json.NewEncoder(w).Encode(memberPage([]string{testRepoDid}, "stuck"))
152 })
153
154 got, err := c.GetKnotMembers(context.Background(), host)
155 if err != nil {
156 t.Fatalf("GetKnotMembers: %v", err)
157 }
158 if want := []string{testRepoDid}; !slices.Equal(got, want) {
159 t.Errorf("members = %v, want %v after dedup", got, want)
160 }
161 if calls := len(knot.calls()); calls != 2 {
162 t.Errorf("calls = %d, want 2; a knot echoing the same cursor must halt at once, not page to the cap", calls)
163 }
164}
165
166func TestDrainStopsAtPageCap(t *testing.T) {
167 c, knot, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) {
168 cursor := r.URL.Query().Get("cursor")
169 json.NewEncoder(w).Encode(memberPage([]string{testCollab}, cursor+"x"))
170 })
171
172 got, err := c.GetKnotMembers(context.Background(), host)
173 if err != nil {
174 t.Fatalf("GetKnotMembers: %v", err)
175 }
176 if want := []string{testCollab}; !slices.Equal(got, want) {
177 t.Errorf("members = %v, want %v after dedup", got, want)
178 }
179 if calls := len(knot.calls()); calls != maxListPages {
180 t.Errorf("calls = %d, want the %d-page cap to halt an ever-advancing cursor", calls, maxListPages)
181 }
182}