Monorepo for Tangled
0

Configure Feed

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

tangled-core / appview / ingester_label_test.go
15 kB 391 lines
1package appview 2 3import ( 4 "context" 5 "encoding/json" 6 "slices" 7 "testing" 8 "time" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 jmodels "github.com/bluesky-social/jetstream/pkg/models" 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" 15 "tangled.org/core/orm" 16) 17 18const ( 19 labelOwner = "did:plc:boltless" 20 labelRepoDid = "did:plc:anemone" 21 labelPerform = "2026-06-01T00:00:00Z" 22) 23 24func newLabelIngester(t *testing.T) *Ingester { 25 t.Helper() 26 ing := newStateIngester(t) 27 ing.Acl = stubAcl{allow: true} 28 return ing 29} 30 31func seedLabelDef(t *testing.T, d *db.DB, repoDid, defDid, defRkey, name string, scope []string, multiple, subscribe bool) syntax.ATURI { 32 t.Helper() 33 def := &models.LabelDefinition{ 34 Did: defDid, Rkey: defRkey, Name: name, 35 ValueType: models.ValueType{Type: models.ConcreteTypeString}, 36 Scope: scope, Multiple: multiple, Created: time.Now(), 37 } 38 if _, err := db.AddLabelDefinition(d, def); err != nil { 39 t.Fatalf("AddLabelDefinition: %v", err) 40 } 41 if subscribe { 42 if err := db.SubscribeLabel(d, &models.RepoLabel{RepoDid: syntax.DID(repoDid), LabelAt: def.AtUri()}); err != nil { 43 t.Fatalf("SubscribeLabel: %v", err) 44 } 45 } 46 return def.AtUri() 47} 48 49func labelOpEvent(t *testing.T, op, did, rkey, subject, performedAt string, add, del [][2]string) *jmodels.Event { 50 t.Helper() 51 mk := func(pairs [][2]string) []*tangled.LabelOp_Operand { 52 out := make([]*tangled.LabelOp_Operand, 0, len(pairs)) 53 for _, p := range pairs { 54 out = append(out, &tangled.LabelOp_Operand{Key: p[0], Value: p[1]}) 55 } 56 return out 57 } 58 raw, err := json.Marshal(tangled.LabelOp{Subject: subject, PerformedAt: performedAt, Add: mk(add), Delete: mk(del)}) 59 if err != nil { 60 t.Fatalf("marshal: %v", err) 61 } 62 return &jmodels.Event{Did: did, Kind: jmodels.EventKindCommit, Commit: &jmodels.Commit{ 63 Operation: op, Collection: tangled.LabelOpNSID, RKey: rkey, Record: raw}} 64} 65 66func labelDefEvent(t *testing.T, did, rkey, name string, scope []string, multiple bool) *jmodels.Event { 67 t.Helper() 68 vt := tangled.LabelDefinition_ValueType{Type: string(models.ConcreteTypeString), Format: string(models.ValueTypeFormatAny)} 69 raw, err := json.Marshal(tangled.LabelDefinition{Name: name, Scope: scope, Multiple: &multiple, CreatedAt: labelPerform, ValueType: &vt}) 70 if err != nil { 71 t.Fatalf("marshal: %v", err) 72 } 73 return &jmodels.Event{Did: did, Kind: jmodels.EventKindCommit, Commit: &jmodels.Commit{ 74 Operation: jmodels.CommitOperationCreate, Collection: tangled.LabelDefinitionNSID, RKey: rkey, Record: raw}} 75} 76 77func mustIngestOp(t *testing.T, ing *Ingester, e *jmodels.Event) { 78 t.Helper() 79 if err := ing.ingestLabelOp(context.Background(), e, ing.Logger); err != nil { 80 t.Fatalf("ingestLabelOp: %v", err) 81 } 82} 83 84func issueAt(owner, rkey string) syntax.ATURI { 85 return syntax.ATURI("at://" + owner + "/" + tangled.RepoIssueNSID + "/" + rkey) 86} 87 88func subjectLabels(t *testing.T, d *db.DB, subject syntax.ATURI) []string { 89 t.Helper() 90 states, err := db.GetLabels(d, orm.FilterEq("subject", subject)) 91 if err != nil { 92 t.Fatalf("GetLabels: %v", err) 93 } 94 vals := states[subject].LabelNameValues() 95 slices.Sort(vals) 96 return vals 97} 98 99func labelOpValues(t *testing.T, d *db.DB, did, rkey string) []string { 100 t.Helper() 101 ops, err := db.GetLabelOps(d, orm.FilterEq("did", did), orm.FilterEq("rkey", rkey)) 102 if err != nil { 103 t.Fatalf("GetLabelOps: %v", err) 104 } 105 out := make([]string, 0, len(ops)) 106 for _, op := range ops { 107 out = append(out, op.OperandValue) 108 } 109 slices.Sort(out) 110 return out 111} 112 113func pendingCount(t *testing.T, d *db.DB, subject syntax.ATURI) int { 114 t.Helper() 115 p, err := db.PendingStateRecordsForSubject(d, subject) 116 if err != nil { 117 t.Fatalf("PendingStateRecordsForSubject: %v", err) 118 } 119 return len(p) 120} 121 122type labelStep struct { 123 op, rkey, subj, perform string 124 add, del []string 125} 126 127type foldCase struct { 128 name string 129 defRkey string 130 defName string 131 scope []string 132 multiple bool 133 unsub bool 134 steps []labelStep 135 want map[string][]string 136 reversible bool 137} 138 139func runFold(t *testing.T, tc foldCase, steps []labelStep) map[string][]string { 140 t.Helper() 141 ing := newLabelIngester(t) 142 defUri := "at://" + labelOwner + "/" + tangled.LabelDefinitionNSID + "/" + tc.defRkey 143 144 seedRepo(t, ing.Db, labelOwner, labelRepoDid) 145 seeded := map[string]bool{} 146 for _, s := range steps { 147 if !seeded[s.subj] { 148 seedIssue(t, ing.Db, labelOwner, labelRepoDid, s.subj) 149 seeded[s.subj] = true 150 } 151 } 152 seedLabelDef(t, ing.Db, labelRepoDid, labelOwner, tc.defRkey, tc.defName, tc.scope, tc.multiple, !tc.unsub) 153 154 pairs := func(vals []string) [][2]string { 155 out := make([][2]string, 0, len(vals)) 156 for _, v := range vals { 157 out = append(out, [2]string{defUri, v}) 158 } 159 return out 160 } 161 kind := map[string]string{"create": jmodels.CommitOperationCreate, "update": jmodels.CommitOperationUpdate, "delete": jmodels.CommitOperationDelete} 162 for _, s := range steps { 163 perform := s.perform 164 if perform == "" { 165 perform = labelPerform 166 } 167 mustIngestOp(t, ing, labelOpEvent(t, kind[s.op], labelOwner, s.rkey, string(issueAt(labelOwner, s.subj)), perform, pairs(s.add), pairs(s.del))) 168 } 169 170 got := map[string][]string{} 171 for subj := range tc.want { 172 at := issueAt(labelOwner, subj) 173 got[subj] = subjectLabels(t, ing.Db, at) 174 if n := pendingCount(t, ing.Db, at); n != 0 { 175 t.Fatalf("fold case %q must not park %s, pending=%d", tc.name, subj, n) 176 } 177 } 178 return got 179} 180 181func TestIngestLabelOp_Fold(t *testing.T) { 182 issue := []string{tangled.RepoIssueNSID} 183 pull := []string{tangled.RepoPullNSID} 184 185 tests := []foldCase{{ 186 name: "delete removes the label", defRkey: "prio", defName: "priority", scope: issue, multiple: true, 187 steps: []labelStep{{op: "create", rkey: "op1", subj: "issue1", add: []string{"high"}}, {op: "delete", rkey: "op1", subj: "issue1"}}, 188 want: map[string][]string{"issue1": nil}, 189 }, { 190 name: "update drops operands the new record no longer carries", defRkey: "prio", defName: "priority", scope: issue, multiple: true, 191 steps: []labelStep{{op: "create", rkey: "op1", subj: "issue1", add: []string{"high", "low"}}, {op: "update", rkey: "op1", subj: "issue1", add: []string{"high"}}}, 192 want: map[string][]string{"issue1": {"priority:high"}}, 193 }, { 194 name: "update moving subject leaves no stale label", defRkey: "prio", defName: "priority", scope: issue, multiple: true, 195 steps: []labelStep{{op: "create", rkey: "op1", subj: "issue1", add: []string{"high"}}, {op: "update", rkey: "op1", subj: "issue2", add: []string{"high"}}}, 196 want: map[string][]string{"issue1": nil, "issue2": {"priority:high"}}, 197 }, { 198 name: "a pull-scoped label on an issue is dropped by the fold", defRkey: "prio", defName: "priority", scope: pull, multiple: true, 199 steps: []labelStep{{op: "create", rkey: "op1", subj: "issue1", add: []string{"high"}}}, 200 want: map[string][]string{"issue1": nil}, reversible: true, 201 }, { 202 name: "a globally-defined but unsubscribed def still applies", defRkey: "prio", defName: "priority", scope: issue, multiple: true, unsub: true, 203 steps: []labelStep{{op: "create", rkey: "op1", subj: "issue1", add: []string{"high"}}}, 204 want: map[string][]string{"issue1": {"priority:high"}}, reversible: true, 205 }, { 206 name: "a same-instant tie resolves by source rkey", defRkey: "st", defName: "status", scope: issue, multiple: false, 207 steps: []labelStep{{op: "create", rkey: "aaa", subj: "issue1", add: []string{"open"}}, {op: "create", rkey: "bbb", subj: "issue1", add: []string{"closed"}}}, 208 want: map[string][]string{"issue1": {"status:closed"}}, reversible: true, 209 }} 210 211 for _, tc := range tests { 212 t.Run(tc.name, func(t *testing.T) { 213 orders := [][]labelStep{tc.steps} 214 if tc.reversible { 215 rev := slices.Clone(tc.steps) 216 slices.Reverse(rev) 217 orders = append(orders, rev) 218 } 219 for _, steps := range orders { 220 got := runFold(t, tc, steps) 221 for subj, want := range tc.want { 222 if !slices.Equal(got[subj], want) { 223 t.Fatalf("subject %s: got %v want %v", subj, got[subj], want) 224 } 225 } 226 } 227 }) 228 } 229} 230 231func TestIngestLabelOp_ReingestIsIdempotent(t *testing.T) { 232 ing := newLabelIngester(t) 233 subject := seedRepoAndIssue(t, ing.Db, labelOwner, labelRepoDid, "issue1") 234 def := seedLabelDef(t, ing.Db, labelRepoDid, labelOwner, "prio", "priority", []string{tangled.RepoIssueNSID}, true, true) 235 e := labelOpEvent(t, jmodels.CommitOperationCreate, labelOwner, "op1", string(subject), labelPerform, 236 [][2]string{{def.String(), "high"}, {def.String(), "low"}}, nil) 237 238 mustIngestOp(t, ing, e) 239 first := labelOpValues(t, ing.Db, labelOwner, "op1") 240 mustIngestOp(t, ing, e) 241 if second := labelOpValues(t, ing.Db, labelOwner, "op1"); !slices.Equal(first, second) { 242 t.Fatalf("re-ingesting an unchanged record changed its stored ops: %v -> %v", first, second) 243 } 244 if got := subjectLabels(t, ing.Db, subject); !slices.Equal(got, []string{"priority:high", "priority:low"}) { 245 t.Fatalf("re-ingest changed the derived set: %v", got) 246 } 247} 248 249func TestIngestLabelOp_Parking(t *testing.T) { 250 defUri := "at://" + labelOwner + "/" + tangled.LabelDefinitionNSID + "/prio" 251 issue := []string{tangled.RepoIssueNSID} 252 seedDef := func(ing *Ingester) syntax.ATURI { 253 return seedLabelDef(t, ing.Db, labelRepoDid, labelOwner, "prio", "priority", issue, true, true) 254 } 255 256 tests := []struct { 257 name string 258 missing string 259 acl bool 260 author string 261 trigger string 262 wantLabels []string 263 }{ 264 {"missing subject parks, drains on reconcile", "subject", true, labelOwner, "seed-subject", []string{"priority:high"}}, 265 {"missing def parks, drains on reconcile", "def", true, labelOwner, "seed-def", []string{"priority:high"}}, 266 {"def create drains a parked op without the sweep", "def", true, labelOwner, "def-event", []string{"priority:high"}}, 267 {"deleting a parked op unparks it and a later def does not resurrect it", "def", true, labelOwner, "delete-then-def", nil}, 268 {"a parked op that fails auth on drain is dropped", "subject", false, "did:plc:squid", "seed-subject", nil}, 269 {"a missing-def park is evicted by TTL though its subject exists", "def", true, labelOwner, "evict", nil}, 270 } 271 272 for _, tt := range tests { 273 t.Run(tt.name, func(t *testing.T) { 274 ing := newLabelIngester(t) 275 if !tt.acl { 276 ing.Acl = stubAcl{} 277 } 278 subject := issueAt(labelOwner, "issue1") 279 seedRepo(t, ing.Db, labelOwner, labelRepoDid) 280 if tt.missing != "subject" { 281 seedIssue(t, ing.Db, labelOwner, labelRepoDid, "issue1") 282 } 283 if tt.missing != "def" { 284 seedDef(ing) 285 } 286 287 mustIngestOp(t, ing, labelOpEvent(t, jmodels.CommitOperationCreate, tt.author, "op1", string(subject), labelPerform, 288 [][2]string{{defUri, "high"}}, nil)) 289 if n := pendingCount(t, ing.Db, subject); n != 1 { 290 t.Fatalf("op must park while its %s is missing, pending=%d", tt.missing, n) 291 } 292 293 switch tt.trigger { 294 case "seed-subject": 295 seedIssue(t, ing.Db, labelOwner, labelRepoDid, "issue1") 296 ing.ReconcilePendingState() 297 case "seed-def": 298 seedDef(ing) 299 ing.ReconcilePendingState() 300 case "def-event": 301 if err := ing.ingestLabelDefinition(labelDefEvent(t, labelOwner, "prio", "priority", issue, true), ing.Logger); err != nil { 302 t.Fatalf("ingest def: %v", err) 303 } 304 case "delete-then-def": 305 mustIngestOp(t, ing, labelOpEvent(t, jmodels.CommitOperationDelete, tt.author, "op1", string(subject), "", nil, nil)) 306 if n := pendingCount(t, ing.Db, subject); n != 0 { 307 t.Fatalf("deleting a parked op must unpark it, pending=%d", n) 308 } 309 seedDef(ing) 310 ing.ReconcilePendingState() 311 case "evict": 312 if n, err := db.EvictStalePendingStateRecords(ing.Db, "2999-01-01T00:00:00Z"); err != nil { 313 t.Fatalf("evict: %v", err) 314 } else if n != 1 { 315 t.Fatalf("a stale missing-def park must be evicted, got %d", n) 316 } 317 } 318 319 if got := subjectLabels(t, ing.Db, subject); !slices.Equal(got, tt.wantLabels) { 320 t.Fatalf("after %q: got %v want %v", tt.trigger, got, tt.wantLabels) 321 } 322 if n := pendingCount(t, ing.Db, subject); n != 0 { 323 t.Fatalf("no record must remain parked, pending=%d", n) 324 } 325 }) 326 } 327} 328 329func TestIngestLabelOp_DeauthorizedUpdatePreservesPriorOps(t *testing.T) { 330 ing := newLabelIngester(t) 331 author := "did:plc:squid" 332 subject := seedRepoAndIssue(t, ing.Db, labelOwner, labelRepoDid, "issue1") 333 def := seedLabelDef(t, ing.Db, labelRepoDid, labelOwner, "prio", "priority", []string{tangled.RepoIssueNSID}, true, true) 334 335 mustIngestOp(t, ing, labelOpEvent(t, jmodels.CommitOperationCreate, author, "op1", string(subject), labelPerform, 336 [][2]string{{def.String(), "high"}}, nil)) 337 if got := subjectLabels(t, ing.Db, subject); !slices.Equal(got, []string{"priority:high"}) { 338 t.Fatalf("authorized create: got %v", got) 339 } 340 341 ing.Acl = stubAcl{} 342 mustIngestOp(t, ing, labelOpEvent(t, jmodels.CommitOperationUpdate, author, "op1", string(subject), labelPerform, 343 [][2]string{{def.String(), "low"}}, nil)) 344 if got := subjectLabels(t, ing.Db, subject); !slices.Equal(got, []string{"priority:high"}) { 345 t.Fatalf("a now-unauthorized update must neither apply nor erase the prior authorized op, got %v", got) 346 } 347} 348 349func TestIngestLabelOp_DeauthorizedRedeliveryPreservesPriorOps(t *testing.T) { 350 ing := newLabelIngester(t) 351 author := "did:plc:squid" 352 subject := seedRepoAndIssue(t, ing.Db, labelOwner, labelRepoDid, "issue1") 353 def := seedLabelDef(t, ing.Db, labelRepoDid, labelOwner, "prio", "priority", []string{tangled.RepoIssueNSID}, true, true) 354 355 create := labelOpEvent(t, jmodels.CommitOperationCreate, author, "op1", string(subject), labelPerform, 356 [][2]string{{def.String(), "high"}}, nil) 357 mustIngestOp(t, ing, create) 358 if got := subjectLabels(t, ing.Db, subject); !slices.Equal(got, []string{"priority:high"}) { 359 t.Fatalf("authorized create: got %v", got) 360 } 361 362 ing.Acl = stubAcl{} 363 mustIngestOp(t, ing, create) 364 if got := subjectLabels(t, ing.Db, subject); !slices.Equal(got, []string{"priority:high"}) { 365 t.Fatalf("a redelivered create must not erase a label applied while authorized, got %v", got) 366 } 367} 368 369func TestIngestLabelOp_DefDeleteDropsOps(t *testing.T) { 370 ing := newLabelIngester(t) 371 subject := seedRepoAndIssue(t, ing.Db, labelOwner, labelRepoDid, "issue1") 372 def := seedLabelDef(t, ing.Db, labelRepoDid, labelOwner, "prio", "priority", []string{tangled.RepoIssueNSID}, true, true) 373 374 mustIngestOp(t, ing, labelOpEvent(t, jmodels.CommitOperationCreate, labelOwner, "op1", string(subject), labelPerform, 375 [][2]string{{def.String(), "high"}}, nil)) 376 if got := subjectLabels(t, ing.Db, subject); !slices.Equal(got, []string{"priority:high"}) { 377 t.Fatalf("after create: got %v", got) 378 } 379 380 del := &jmodels.Event{Did: labelOwner, Kind: jmodels.EventKindCommit, Commit: &jmodels.Commit{ 381 Operation: jmodels.CommitOperationDelete, Collection: tangled.LabelDefinitionNSID, RKey: "prio"}} 382 if err := ing.ingestLabelDefinition(del, ing.Logger); err != nil { 383 t.Fatalf("ingest def delete: %v", err) 384 } 385 if rows := labelOpValues(t, ing.Db, labelOwner, "op1"); len(rows) != 0 { 386 t.Fatalf("deleting a def must cascade-delete its label_ops rows, survived: %v", rows) 387 } 388 if got := subjectLabels(t, ing.Db, subject); len(got) != 0 { 389 t.Fatalf("deleting a def must drop its labels, got %v", got) 390 } 391}