Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

at icy/ytnwlw 5.5 kB View raw
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}