This repository has no description
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}