a love letter to tangled (android, iOS, and a search API)
at main 454 lines 12 kB view raw
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}