a love letter to tangled (android, iOS, and a search API)
1package normalize_test
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "tangled.org/desertthunder.dev/twister/internal/normalize"
10)
11
12func loadFixture(t *testing.T, name string) normalize.TapRecordEvent {
13 t.Helper()
14 path := filepath.Join("testdata", name)
15 data, err := os.ReadFile(path)
16 if err != nil {
17 t.Fatalf("read fixture %s: %v", name, err)
18 }
19 var event normalize.TapRecordEvent
20 if err := json.Unmarshal(data, &event); err != nil {
21 t.Fatalf("decode fixture %s: %v", name, err)
22 }
23 return event
24}
25
26// TestStableID verifies deterministic ID generation.
27func TestStableID(t *testing.T) {
28 id := normalize.StableID("did:plc:abc", "sh.tangled.repo", "3kb3fge5lm32x")
29 want := "did:plc:abc|sh.tangled.repo|3kb3fge5lm32x"
30 if id != want {
31 t.Errorf("StableID = %q, want %q", id, want)
32 }
33}
34
35// TestParseATURI covers the happy path and malformed input.
36func TestParseATURI(t *testing.T) {
37 t.Run("valid", func(t *testing.T) {
38 did, col, rkey, err := normalize.ParseATURI("at://did:plc:abc123/sh.tangled.repo/3kb3fge5lm32x")
39 if err != nil {
40 t.Fatal(err)
41 }
42 if did != "did:plc:abc123" || col != "sh.tangled.repo" || rkey != "3kb3fge5lm32x" {
43 t.Errorf("got (%s, %s, %s)", did, col, rkey)
44 }
45 })
46
47 t.Run("invalid", func(t *testing.T) {
48 _, _, _, err := normalize.ParseATURI("not-an-at-uri")
49 if err == nil {
50 t.Error("expected error for malformed AT-URI")
51 }
52 })
53
54 t.Run("missing rkey", func(t *testing.T) {
55 _, _, _, err := normalize.ParseATURI("at://did:plc:abc/sh.tangled.repo")
56 if err == nil {
57 t.Error("expected error for AT-URI missing rkey segment")
58 }
59 })
60}
61
62// TestRepoAdapter verifies field mapping for sh.tangled.repo.
63func TestRepoAdapter(t *testing.T) {
64 event := loadFixture(t, "repo.json")
65 adapter := &normalize.RepoAdapter{}
66
67 if adapter.Collection() != "sh.tangled.repo" {
68 t.Errorf("Collection = %q", adapter.Collection())
69 }
70 if adapter.RecordType() != "repo" {
71 t.Errorf("RecordType = %q", adapter.RecordType())
72 }
73
74 doc, err := adapter.Normalize(event)
75 if err != nil {
76 t.Fatalf("Normalize: %v", err)
77 }
78
79 if doc.ID != "did:plc:abc123|sh.tangled.repo|3kb3fge5lm32x" {
80 t.Errorf("ID = %q", doc.ID)
81 }
82 if doc.Title != "my-project" {
83 t.Errorf("Title = %q", doc.Title)
84 }
85 if doc.Body != "A cool project for searching things" {
86 t.Errorf("Body = %q", doc.Body)
87 }
88 if doc.RepoName != "my-project" {
89 t.Errorf("RepoName = %q", doc.RepoName)
90 }
91 if doc.RepoDID != "did:plc:abc123" {
92 t.Errorf("RepoDID = %q", doc.RepoDID)
93 }
94 if doc.TagsJSON != `["go","search","atproto"]` {
95 t.Errorf("TagsJSON = %q", doc.TagsJSON)
96 }
97 if doc.RecordType != "repo" {
98 t.Errorf("RecordType = %q", doc.RecordType)
99 }
100 if doc.ATURI != "at://did:plc:abc123/sh.tangled.repo/3kb3fge5lm32x" {
101 t.Errorf("ATURI = %q", doc.ATURI)
102 }
103
104 if !adapter.Searchable(event.Record.Record) {
105 t.Error("Searchable returned false for a named repo")
106 }
107 if adapter.Searchable(map[string]any{"name": ""}) {
108 t.Error("Searchable returned true for empty name")
109 }
110}
111
112// TestIssueAdapter verifies field mapping for sh.tangled.repo.issue.
113func TestIssueAdapter(t *testing.T) {
114 event := loadFixture(t, "issue.json")
115 adapter := &normalize.IssueAdapter{}
116
117 doc, err := adapter.Normalize(event)
118 if err != nil {
119 t.Fatalf("Normalize: %v", err)
120 }
121
122 if doc.Title != "Fix search ranking for repos" {
123 t.Errorf("Title = %q", doc.Title)
124 }
125 if doc.RepoDID != "did:plc:repoowner" {
126 t.Errorf("RepoDID = %q, want did:plc:repoowner", doc.RepoDID)
127 }
128 if doc.RecordType != "issue" {
129 t.Errorf("RecordType = %q", doc.RecordType)
130 }
131 if len(doc.Summary) > 200 {
132 t.Errorf("Summary too long: %d chars", len(doc.Summary))
133 }
134 if doc.TagsJSON != "[]" {
135 t.Errorf("TagsJSON = %q, want []", doc.TagsJSON)
136 }
137
138 doc2, _ := adapter.Normalize(event)
139 if doc.ID != doc2.ID {
140 t.Error("Normalize is not deterministic")
141 }
142}
143
144// TestIssueAdapter_BadATURI ensures malformed repo AT-URI returns an error.
145func TestIssueAdapter_BadATURI(t *testing.T) {
146 event := normalize.TapRecordEvent{
147 ID: 9999,
148 Type: "record",
149 Record: &normalize.TapRecord{
150 DID: "did:plc:abc",
151 Collection: "sh.tangled.repo.issue",
152 RKey: "rkey1",
153 CID: "cid1",
154 Record: map[string]any{
155 "title": "Test",
156 "body": "Body",
157 "repo": "not-an-at-uri",
158 },
159 },
160 }
161 adapter := &normalize.IssueAdapter{}
162 _, err := adapter.Normalize(event)
163 if err == nil {
164 t.Error("expected error for invalid repo AT-URI")
165 }
166}
167
168// TestPullAdapter verifies field mapping for sh.tangled.repo.pull.
169func TestPullAdapter(t *testing.T) {
170 event := loadFixture(t, "pull.json")
171 adapter := &normalize.PullAdapter{}
172
173 doc, err := adapter.Normalize(event)
174 if err != nil {
175 t.Fatalf("Normalize: %v", err)
176 }
177
178 if doc.Title != "Add star-based ranking signal" {
179 t.Errorf("Title = %q", doc.Title)
180 }
181 if doc.RepoDID != "did:plc:repoowner" {
182 t.Errorf("RepoDID = %q, want did:plc:repoowner", doc.RepoDID)
183 }
184 if doc.RecordType != "pull" {
185 t.Errorf("RecordType = %q", doc.RecordType)
186 }
187}
188
189// TestPullAdapter_NoTarget verifies that a missing target is handled gracefully.
190func TestPullAdapter_NoTarget(t *testing.T) {
191 event := normalize.TapRecordEvent{
192 ID: 9998,
193 Type: "record",
194 Record: &normalize.TapRecord{
195 DID: "did:plc:abc",
196 Collection: "sh.tangled.repo.pull",
197 RKey: "rkey2",
198 CID: "cid2",
199 Record: map[string]any{
200 "title": "PR without target",
201 "body": "Body",
202 },
203 },
204 }
205 adapter := &normalize.PullAdapter{}
206 doc, err := adapter.Normalize(event)
207 if err != nil {
208 t.Fatalf("unexpected error: %v", err)
209 }
210 if doc.RepoDID != "" {
211 t.Errorf("RepoDID = %q, want empty", doc.RepoDID)
212 }
213}
214
215// TestStringAdapter verifies field mapping for sh.tangled.string.
216func TestStringAdapter(t *testing.T) {
217 event := loadFixture(t, "string.json")
218 adapter := &normalize.StringAdapter{}
219
220 doc, err := adapter.Normalize(event)
221 if err != nil {
222 t.Fatalf("Normalize: %v", err)
223 }
224
225 if doc.Title != "search.go" {
226 t.Errorf("Title = %q", doc.Title)
227 }
228 if doc.Summary != "BM25 scoring function for full-text search" {
229 t.Errorf("Summary = %q", doc.Summary)
230 }
231 if doc.RecordType != "string" {
232 t.Errorf("RecordType = %q", doc.RecordType)
233 }
234
235 if !adapter.Searchable(event.Record.Record) {
236 t.Error("Searchable = false for non-empty contents")
237 }
238 if adapter.Searchable(map[string]any{"contents": ""}) {
239 t.Error("Searchable = true for empty contents")
240 }
241}
242
243// TestProfileAdapter verifies field mapping for sh.tangled.actor.profile.
244func TestProfileAdapter(t *testing.T) {
245 event := loadFixture(t, "profile.json")
246 adapter := &normalize.ProfileAdapter{}
247
248 doc, err := adapter.Normalize(event)
249 if err != nil {
250 t.Fatalf("Normalize: %v", err)
251 }
252
253 if doc.Body != "Building search infrastructure for the open social web. Go enthusiast." {
254 t.Errorf("Body = %q", doc.Body)
255 }
256 if doc.Summary == "" {
257 t.Error("Summary is empty; expected description + location")
258 }
259 if doc.RecordType != "profile" {
260 t.Errorf("RecordType = %q", doc.RecordType)
261 }
262
263 if doc.Title != "" {
264 t.Errorf("Title = %q, want empty (handle resolved externally)", doc.Title)
265 }
266
267 if !adapter.Searchable(event.Record.Record) {
268 t.Error("Searchable = false for non-empty description")
269 }
270 if adapter.Searchable(map[string]any{"description": ""}) {
271 t.Error("Searchable = true for empty description")
272 }
273}
274
275func TestFollowAdapter(t *testing.T) {
276 event := loadFixture(t, "follow.json")
277 adapter := &normalize.FollowAdapter{}
278
279 doc, err := adapter.Normalize(event)
280 if err != nil {
281 t.Fatalf("Normalize: %v", err)
282 }
283
284 if doc.RecordType != "follow" {
285 t.Errorf("RecordType = %q", doc.RecordType)
286 }
287 if doc.RepoDID != "did:plc:bob" {
288 t.Errorf("RepoDID = %q, want did:plc:bob", doc.RepoDID)
289 }
290 if adapter.Searchable(event.Record.Record) {
291 t.Error("Searchable = true, want false")
292 }
293}
294
295func TestIssueCommentAdapter(t *testing.T) {
296 event := loadFixture(t, "issue_comment.json")
297 adapter := &normalize.IssueCommentAdapter{}
298
299 doc, err := adapter.Normalize(event)
300 if err != nil {
301 t.Fatalf("Normalize: %v", err)
302 }
303
304 if doc.RecordType != "issue_comment" {
305 t.Errorf("RecordType = %q", doc.RecordType)
306 }
307 if doc.RepoDID != "did:plc:repoowner" {
308 t.Errorf("RepoDID = %q, want did:plc:repoowner", doc.RepoDID)
309 }
310 if !adapter.Searchable(event.Record.Record) {
311 t.Error("Searchable = false for non-empty comment body")
312 }
313}
314
315func TestPullCommentAdapter(t *testing.T) {
316 event := loadFixture(t, "pull_comment.json")
317 adapter := &normalize.PullCommentAdapter{}
318
319 doc, err := adapter.Normalize(event)
320 if err != nil {
321 t.Fatalf("Normalize: %v", err)
322 }
323
324 if doc.RecordType != "pull_comment" {
325 t.Errorf("RecordType = %q", doc.RecordType)
326 }
327 if doc.RepoDID != "did:plc:repoowner" {
328 t.Errorf("RepoDID = %q, want did:plc:repoowner", doc.RepoDID)
329 }
330 if !adapter.Searchable(event.Record.Record) {
331 t.Error("Searchable = false for non-empty comment body")
332 }
333}
334
335// TestIssueStateHandler verifies record_state extraction.
336func TestIssueStateHandler(t *testing.T) {
337 event := loadFixture(t, "issue_state.json")
338 handler := &normalize.IssueStateHandler{}
339
340 if handler.Collection() != "sh.tangled.repo.issue.state" {
341 t.Errorf("Collection = %q", handler.Collection())
342 }
343
344 update, err := handler.HandleState(event)
345 if err != nil {
346 t.Fatalf("HandleState: %v", err)
347 }
348 if update.SubjectURI != "at://did:plc:abc123/sh.tangled.repo.issue/3kb3fge5lm32y" {
349 t.Errorf("SubjectURI = %q", update.SubjectURI)
350 }
351 if update.State != "closed" {
352 t.Errorf("State = %q, want closed", update.State)
353 }
354}
355
356// TestPullStatusHandler verifies record_state extraction for PRs.
357func TestPullStatusHandler(t *testing.T) {
358 event := loadFixture(t, "pull_status.json")
359 handler := &normalize.PullStatusHandler{}
360
361 if handler.Collection() != "sh.tangled.repo.pull.status" {
362 t.Errorf("Collection = %q", handler.Collection())
363 }
364
365 update, err := handler.HandleState(event)
366 if err != nil {
367 t.Fatalf("HandleState: %v", err)
368 }
369 if update.State != "merged" {
370 t.Errorf("State = %q, want merged", update.State)
371 }
372}
373
374// TestIssueStateHandler_MissingFields ensures errors on missing required fields.
375func TestIssueStateHandler_MissingFields(t *testing.T) {
376 handler := &normalize.IssueStateHandler{}
377
378 t.Run("missing subject", func(t *testing.T) {
379 event := normalize.TapRecordEvent{
380 Record: &normalize.TapRecord{
381 Record: map[string]any{"status": "closed"},
382 },
383 }
384 _, err := handler.HandleState(event)
385 if err == nil {
386 t.Error("expected error for missing subject")
387 }
388 })
389
390 t.Run("missing status", func(t *testing.T) {
391 event := normalize.TapRecordEvent{
392 Record: &normalize.TapRecord{
393 Record: map[string]any{"issue": "at://did:plc:x/col/rkey"},
394 },
395 }
396 _, err := handler.HandleState(event)
397 if err == nil {
398 t.Error("expected error for missing status")
399 }
400 })
401
402 t.Run("legacy field names still work", func(t *testing.T) {
403 event := normalize.TapRecordEvent{
404 Record: &normalize.TapRecord{
405 Record: map[string]any{
406 "subject": "at://did:plc:x/col/rkey",
407 "status": "closed",
408 },
409 },
410 }
411 update, err := handler.HandleState(event)
412 if err != nil {
413 t.Fatalf("HandleState legacy: %v", err)
414 }
415 if update.SubjectURI != "at://did:plc:x/col/rkey" || update.State != "closed" {
416 t.Fatalf("legacy update = %#v", update)
417 }
418 })
419}
420
421// TestRegistry verifies adapter and state handler lookup.
422func TestRegistry(t *testing.T) {
423 reg := normalize.NewRegistry()
424
425 collections := []string{
426 "sh.tangled.repo",
427 "sh.tangled.repo.issue",
428 "sh.tangled.repo.pull",
429 "sh.tangled.repo.issue.comment",
430 "sh.tangled.repo.pull.comment",
431 "sh.tangled.graph.follow",
432 "sh.tangled.string",
433 "sh.tangled.actor.profile",
434 }
435 for _, col := range collections {
436 if _, ok := reg.Adapter(col); !ok {
437 t.Errorf("no adapter registered for %q", col)
438 }
439 }
440
441 stateCollections := []string{
442 "sh.tangled.repo.issue.state",
443 "sh.tangled.repo.pull.status",
444 }
445 for _, col := range stateCollections {
446 if _, ok := reg.StateHandler(col); !ok {
447 t.Errorf("no state handler registered for %q", col)
448 }
449 }
450
451 if _, ok := reg.Adapter("sh.tangled.unknown"); ok {
452 t.Error("expected no adapter for unknown collection")
453 }
454}