This repository has no description
0

Configure Feed

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

core / appview / ingester_repo_test.go
13 kB 457 lines
1package appview 2 3import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "errors" 8 "io" 9 "log/slog" 10 "path/filepath" 11 "testing" 12 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 jmodels "github.com/bluesky-social/jetstream/pkg/models" 15 "tangled.org/core/api/tangled" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/notify" 19 "tangled.org/core/orm" 20) 21 22type spyNotifier struct { 23 notify.BaseNotifier 24 creates int 25 deletes int 26 renames int 27} 28 29func (s *spyNotifier) NewRepo(_ context.Context, _ *models.Repo) { s.creates++ } 30func (s *spyNotifier) DeleteRepo(_ context.Context, _ *models.Repo) { s.deletes++ } 31func (s *spyNotifier) RenameRepo(_ context.Context, _ syntax.DID, _, _ *models.Repo) { 32 s.renames++ 33} 34 35func newTestIngester(t *testing.T) (*Ingester, *spyNotifier) { 36 t.Helper() 37 path := filepath.Join(t.TempDir(), "test.db") 38 d, err := db.Make(context.Background(), path) 39 if err != nil { 40 t.Fatalf("db.Make: %v", err) 41 } 42 t.Cleanup(func() { d.Close() }) 43 44 spy := &spyNotifier{} 45 ing := &Ingester{ 46 Db: d, 47 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 48 Notifier: spy, 49 } 50 return ing, spy 51} 52 53func seedRepoRow(t *testing.T, ing *Ingester, did, knot, name, rkey, repoDid string) *models.Repo { 54 t.Helper() 55 tx, err := ing.Db.Begin() 56 if err != nil { 57 t.Fatalf("Begin: %v", err) 58 } 59 repo := &models.Repo{ 60 Did: did, 61 Name: name, 62 Knot: knot, 63 Rkey: rkey, 64 RepoDid: repoDid, 65 } 66 if err := db.AddRepo(tx, repo); err != nil { 67 t.Fatalf("AddRepo: %v", err) 68 } 69 if err := tx.Commit(); err != nil { 70 t.Fatalf("Commit: %v", err) 71 } 72 return repo 73} 74 75func ptr[T any](v T) *T { return &v } 76 77func makeEvent(t *testing.T, op string, did, rkey string, record tangled.Repo) *jmodels.Event { 78 t.Helper() 79 raw, err := json.Marshal(record) 80 if err != nil { 81 t.Fatalf("marshal record: %v", err) 82 } 83 return &jmodels.Event{ 84 Did: did, 85 Kind: jmodels.EventKindCommit, 86 Commit: &jmodels.Commit{ 87 Operation: op, 88 Collection: tangled.RepoNSID, 89 RKey: rkey, 90 Record: raw, 91 }, 92 } 93} 94 95func makeDeleteEvent(did, rkey string) *jmodels.Event { 96 return &jmodels.Event{ 97 Did: did, 98 Kind: jmodels.EventKindCommit, 99 Commit: &jmodels.Commit{ 100 Operation: jmodels.CommitOperationDelete, 101 Collection: tangled.RepoNSID, 102 RKey: rkey, 103 }, 104 } 105} 106 107func loadRepo(t *testing.T, ing *Ingester, did, rkey string) *models.Repo { 108 t.Helper() 109 r, err := db.GetRepo(ing.Db, 110 orm.FilterEq("did", did), 111 orm.FilterEq("rkey", rkey), 112 ) 113 if err != nil { 114 t.Fatalf("GetRepo: %v", err) 115 } 116 return r 117} 118 119func TestIngestRepo_CreateInsertsNewRow(t *testing.T) { 120 ing, spy := newTestIngester(t) 121 122 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 123 Knot: "knot.example", 124 Name: ptr("MyRepo"), 125 Description: ptr("a test repo"), 126 RepoDid: ptr("did:plc:repo1"), 127 }) 128 129 if err := ing.ingestRepo(context.Background(), e); err != nil { 130 t.Fatalf("ingestRepo: %v", err) 131 } 132 133 r := loadRepo(t, ing, "did:plc:akshay", "myrepo") 134 if r.Name != "MyRepo" { 135 t.Errorf("name = %q, want %q", r.Name, "MyRepo") 136 } 137 if r.Description != "a test repo" { 138 t.Errorf("description = %q", r.Description) 139 } 140 if r.RepoDid != "did:plc:repo1" { 141 t.Errorf("repoDid = %q", r.RepoDid) 142 } 143 if spy.creates != 1 { 144 t.Errorf("NewRepo called %d times, want 1", spy.creates) 145 } 146} 147 148func TestIngestRepo_CreateSkipsIfRowExists(t *testing.T) { 149 ing, spy := newTestIngester(t) 150 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "myrepo", "did:plc:repo1") 151 152 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 153 Knot: "knot.example", 154 Name: ptr("myrepo"), 155 RepoDid: ptr("did:plc:repo1"), 156 }) 157 158 if err := ing.ingestRepo(context.Background(), e); err != nil { 159 t.Fatalf("ingestRepo: %v", err) 160 } 161 if spy.creates != 0 { 162 t.Errorf("row already exists, NewRepo should not be called but was called %d times", spy.creates) 163 } 164} 165 166func TestIngestRepo_CreateCascadesRename(t *testing.T) { 167 ing, spy := newTestIngester(t) 168 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "oldname", "oldname", "did:plc:repo1") 169 170 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "newname", tangled.Repo{ 171 Knot: "knot.example", 172 Name: ptr("NewName"), 173 RepoDid: ptr("did:plc:repo1"), 174 }) 175 176 if err := ing.ingestRepo(context.Background(), e); err != nil { 177 t.Fatalf("ingestRepo: %v", err) 178 } 179 180 _, err := db.GetRepo(ing.Db, 181 orm.FilterEq("did", "did:plc:akshay"), 182 orm.FilterEq("rkey", "oldname"), 183 ) 184 if !errors.Is(err, sql.ErrNoRows) { 185 t.Errorf("old rkey row should be gone, got err = %v", err) 186 } 187 188 r := loadRepo(t, ing, "did:plc:akshay", "newname") 189 if r.Name != "NewName" { 190 t.Errorf("name = %q, want %q", r.Name, "NewName") 191 } 192 if r.RepoDid != "did:plc:repo1" { 193 t.Errorf("repoDid = %q", r.RepoDid) 194 } 195 196 hint, err := db.LookupRepoRename(ing.Db, "did:plc:akshay", "oldname") 197 if err != nil { 198 t.Fatalf("LookupRepoRename: %v", err) 199 } 200 if hint == nil { 201 t.Fatal("expected rename history, got nil") 202 } 203 204 if spy.renames != 1 { 205 t.Errorf("RenameRepo called %d times, want 1", spy.renames) 206 } 207 if spy.creates != 0 { 208 t.Errorf("rename should not create: NewRepo called %d times, want 0", spy.creates) 209 } 210} 211 212func TestIngestRepo_CreateNoRepoDidSkipped(t *testing.T) { 213 ing, spy := newTestIngester(t) 214 215 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 216 Knot: "knot.example", 217 Name: ptr("myrepo"), 218 }) 219 220 if err := ing.ingestRepo(context.Background(), e); err != nil { 221 t.Fatalf("ingestRepo: %v", err) 222 } 223 if spy.creates != 0 { 224 t.Errorf("NewRepo called %d times, want 0", spy.creates) 225 } 226} 227 228func TestIngestRepo_UpdateMetadata(t *testing.T) { 229 ing, _ := newTestIngester(t) 230 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") 231 232 e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ 233 Knot: "knot.example", 234 Name: ptr("foo"), 235 Description: ptr("updated description"), 236 Website: ptr("https://example.com"), 237 Topics: []string{"go", "test"}, 238 RepoDid: ptr("did:plc:repo1"), 239 }) 240 241 if err := ing.ingestRepo(context.Background(), e); err != nil { 242 t.Fatalf("ingestRepo: %v", err) 243 } 244 245 r := loadRepo(t, ing, "did:plc:akshay", "foo") 246 if r.Description != "updated description" { 247 t.Errorf("description = %q", r.Description) 248 } 249 if r.Website != "https://example.com" { 250 t.Errorf("website = %q", r.Website) 251 } 252 if got := r.TopicStr(); got != "go test" { 253 t.Errorf("topics = %q", got) 254 } 255} 256 257func TestIngestRepo_UpdateDisplayName(t *testing.T) { 258 ing, _ := newTestIngester(t) 259 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") 260 261 e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ 262 Knot: "knot.example", 263 Name: ptr("Foo"), 264 RepoDid: ptr("did:plc:repo1"), 265 }) 266 267 if err := ing.ingestRepo(context.Background(), e); err != nil { 268 t.Fatalf("ingestRepo: %v", err) 269 } 270 271 r := loadRepo(t, ing, "did:plc:akshay", "foo") 272 if r.Name != "Foo" { 273 t.Errorf("name = %q, want %q", r.Name, "Foo") 274 } 275 if r.Rkey != "foo" { 276 t.Errorf("rkey should be unchanged but got %q, want %q", r.Rkey, "foo") 277 } 278} 279 280func TestIngestRepo_UpdateNothingChangedNoOp(t *testing.T) { 281 ing, _ := newTestIngester(t) 282 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") 283 284 e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ 285 Knot: "knot.example", 286 Name: ptr("foo"), 287 RepoDid: ptr("did:plc:repo1"), 288 }) 289 290 if err := ing.ingestRepo(context.Background(), e); err != nil { 291 t.Fatalf("ingestRepo: %v", err) 292 } 293 294 r := loadRepo(t, ing, "did:plc:akshay", "foo") 295 if r.Name != "foo" { 296 t.Errorf("name = %q, want unchanged %q", r.Name, "foo") 297 } 298} 299 300func TestIngestRepo_UnknownRowSkipped(t *testing.T) { 301 ops := []string{jmodels.CommitOperationUpdate, jmodels.CommitOperationDelete} 302 for _, op := range ops { 303 t.Run(op, func(t *testing.T) { 304 ing, _ := newTestIngester(t) 305 306 var e *jmodels.Event 307 switch op { 308 case jmodels.CommitOperationUpdate: 309 e = makeEvent(t, op, "did:plc:nobody", "ghost", tangled.Repo{ 310 Knot: "knot.example", 311 Name: ptr("ghost"), 312 RepoDid: ptr("did:plc:nope"), 313 }) 314 case jmodels.CommitOperationDelete: 315 e = makeDeleteEvent("did:plc:nobody", "ghost") 316 } 317 318 if err := ing.ingestRepo(context.Background(), e); err != nil { 319 t.Fatalf("ingestRepo: %v", err) 320 } 321 }) 322 } 323} 324 325func TestIngestRepo_UpdateNoRepoDidSkipped(t *testing.T) { 326 ing, _ := newTestIngester(t) 327 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") 328 329 e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "foo", tangled.Repo{ 330 Knot: "knot.example", 331 Name: ptr("bar"), 332 }) 333 334 if err := ing.ingestRepo(context.Background(), e); err != nil { 335 t.Fatalf("ingestRepo: %v", err) 336 } 337 338 r := loadRepo(t, ing, "did:plc:akshay", "foo") 339 if r.Name != "foo" { 340 t.Errorf("name = %q, want unchanged %q", r.Name, "foo") 341 } 342} 343 344func TestIngestRepo_DeleteRemovesRow(t *testing.T) { 345 ing, _ := newTestIngester(t) 346 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1") 347 348 e := makeDeleteEvent("did:plc:akshay", "foo") 349 if err := ing.ingestRepo(context.Background(), e); err != nil { 350 t.Fatalf("ingestRepo: %v", err) 351 } 352 353 _, err := db.GetRepo(ing.Db, 354 orm.FilterEq("did", "did:plc:akshay"), 355 orm.FilterEq("rkey", "foo"), 356 ) 357 if !errors.Is(err, sql.ErrNoRows) { 358 t.Errorf("expected row to be deleted, got err = %v", err) 359 } 360} 361 362func TestIngestRepo_MalformedRecord(t *testing.T) { 363 ing, _ := newTestIngester(t) 364 365 e := &jmodels.Event{ 366 Did: "did:plc:akshay", 367 Kind: jmodels.EventKindCommit, 368 Commit: &jmodels.Commit{ 369 Operation: jmodels.CommitOperationUpdate, 370 Collection: tangled.RepoNSID, 371 RKey: "rkey1", 372 Record: json.RawMessage("{not json"), 373 }, 374 } 375 376 if err := ing.ingestRepo(context.Background(), e); err == nil { 377 t.Errorf("ingestRepo with malformed record: err = nil, want error") 378 } 379} 380 381func TestIngestRepo_RenameDeleteSequenceNoTornState(t *testing.T) { 382 ing, spy := newTestIngester(t) 383 seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "oldname", "oldname", "did:plc:repo1") 384 385 if _, err := ing.Db.Exec( 386 `insert into stars (did, rkey, subject_type, subject) values (?, ?, ?, ?)`, 387 "did:plc:boltless", "star1", "repo", "did:plc:repo1", 388 ); err != nil { 389 t.Fatalf("seed star: %v", err) 390 } 391 392 createEvt := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "newname", tangled.Repo{ 393 Knot: "knot.example", 394 Name: ptr("NewName"), 395 RepoDid: ptr("did:plc:repo1"), 396 }) 397 if err := ing.ingestRepo(context.Background(), createEvt); err != nil { 398 t.Fatalf("ingest create: %v", err) 399 } 400 401 deleteEvt := makeDeleteEvent("did:plc:akshay", "oldname") 402 if err := ing.ingestRepo(context.Background(), deleteEvt); err != nil { 403 t.Fatalf("ingest delete: %v", err) 404 } 405 406 r := loadRepo(t, ing, "did:plc:akshay", "newname") 407 if r.Name != "NewName" { 408 t.Errorf("name = %q, want %q", r.Name, "NewName") 409 } 410 if r.RepoDid != "did:plc:repo1" { 411 t.Errorf("repoDid = %q, want %q", r.RepoDid, "did:plc:repo1") 412 } 413 414 _, err := db.GetRepo(ing.Db, 415 orm.FilterEq("did", "did:plc:akshay"), 416 orm.FilterEq("rkey", "oldname"), 417 ) 418 if !errors.Is(err, sql.ErrNoRows) { 419 t.Errorf("old rkey should be gone, got err = %v", err) 420 } 421 422 var starSubject string 423 if err := ing.Db.QueryRow(`select subject from stars where did = ?`, "did:plc:boltless").Scan(&starSubject); err != nil { 424 t.Fatalf("query star: %v", err) 425 } 426 if starSubject != "did:plc:repo1" { 427 t.Errorf("star subject = %q, want %q", starSubject, "did:plc:repo1") 428 } 429 430 if spy.renames != 1 { 431 t.Errorf("RenameRepo called %d times, want 1", spy.renames) 432 } 433 if spy.creates != 0 { 434 t.Errorf("rename should not create: NewRepo called %d times, want 0", spy.creates) 435 } 436 if spy.deletes != 0 { 437 t.Errorf("old rkey already gone, DeleteRepo should not be called but was called %d times", spy.deletes) 438 } 439} 440 441func TestIngestRepo_CreateFallsBackToRkeyForName(t *testing.T) { 442 ing, _ := newTestIngester(t) 443 444 e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{ 445 Knot: "knot.example", 446 RepoDid: ptr("did:plc:repo1"), 447 }) 448 449 if err := ing.ingestRepo(context.Background(), e); err != nil { 450 t.Fatalf("ingestRepo: %v", err) 451 } 452 453 r := loadRepo(t, ing, "did:plc:akshay", "myrepo") 454 if r.Name != "myrepo" { 455 t.Errorf("name should fall back to rkey: got %q, want %q", r.Name, "myrepo") 456 } 457}