forked from
tangled.org/core
Monorepo for Tangled
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}