Monorepo for Tangled
tangled.org
1package appview
2
3import (
4 "bytes"
5 "encoding/json"
6 "log/slog"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 jmodels "github.com/bluesky-social/jetstream/pkg/models"
13 "github.com/ipfs/go-cid"
14 "github.com/multiformats/go-multihash"
15 cbg "github.com/whyrusleeping/cbor-gen"
16 "tangled.org/core/api/tangled"
17 "tangled.org/core/appview/db"
18 "tangled.org/core/appview/models"
19 "tangled.org/core/appview/validator"
20 "tangled.org/core/orm"
21)
22
23func newStringIngester(t *testing.T) *Ingester {
24 t.Helper()
25 path := filepath.Join(t.TempDir(), "test.db")
26 d, err := db.Make(t.Context(), path)
27 if err != nil {
28 t.Fatalf("db.Make: %v", err)
29 }
30 t.Cleanup(func() { d.Close() })
31 return &Ingester{
32 Db: d,
33 Logger: slog.New(slog.DiscardHandler),
34 Validator: &validator.Validator{},
35 }
36}
37
38func makeStringEvent(t *testing.T, op, did, rkey string, record tangled.String) *jmodels.Event {
39 t.Helper()
40 raw, err := json.Marshal(record)
41 if err != nil {
42 t.Fatalf("marshal record: %v", err)
43 }
44 return &jmodels.Event{
45 Did: did,
46 Kind: jmodels.EventKindCommit,
47 Commit: &jmodels.Commit{
48 Operation: op,
49 Collection: tangled.StringNSID,
50 RKey: rkey,
51 Record: raw,
52 CID: makeCID(t, &record),
53 },
54 }
55}
56
57func makeCID(t *testing.T, record cbg.CBORMarshaler) string {
58 buf := new(bytes.Buffer)
59 if err := record.MarshalCBOR(buf); err != nil {
60 t.Fatalf("cbor marshal record: %v", err)
61 }
62 c, err := cid.NewPrefixV1(cid.DagCBOR, multihash.SHA2_256).Sum(buf.Bytes())
63 if err != nil {
64 t.Fatalf("cid: sum: %v", err)
65 }
66 return c.String()
67}
68
69func loadString(t *testing.T, ing *Ingester, did, rkey string) (models.String, bool) {
70 t.Helper()
71 rows, err := db.GetStrings(ing.Db, 0,
72 orm.FilterEq("did", did),
73 orm.FilterEq("rkey", rkey),
74 )
75 if err != nil {
76 t.Fatalf("GetStrings: %v", err)
77 }
78 if len(rows) == 0 {
79 return models.String{}, false
80 }
81 if len(rows) > 1 {
82 t.Fatalf("expected at most one row for (%s,%s), got %d", did, rkey, len(rows))
83 }
84 return rows[0], true
85}
86
87func TestIngestString_CreateRoundTrip(t *testing.T) {
88 ing := newStringIngester(t)
89 created := time.Date(2025, 9, 14, 10, 30, 0, 0, time.UTC)
90
91 e := makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "rk1", tangled.String{
92 Filename: ptr("hello.txt"),
93 Description: ptr("a greeting"),
94 Contents: ptr("hello world\n"),
95 CreatedAt: created.Format(time.RFC3339),
96 })
97
98 if err := ing.ingestString(t.Context(), e, ing.Logger); err != nil {
99 t.Fatalf("ingestString: %v", err)
100 }
101
102 s, ok := loadString(t, ing, "did:plc:boltless", "rk1")
103 if !ok {
104 t.Fatal("row not inserted")
105 }
106 if s.FileName != "hello.txt" {
107 t.Errorf("filename = %q, want hello.txt", s.FileName)
108 }
109 if s.Description == nil {
110 t.Errorf("description = nil")
111 }
112 if *s.Description != "a greeting" {
113 t.Errorf("description = %q", *s.Description)
114 }
115 if s.FileContent != "hello world\n" {
116 t.Errorf("contents = %q", s.FileContent)
117 }
118 if !s.Created.Equal(created) {
119 t.Errorf("created = %v, want %v (record CreatedAt must round-trip)", s.Created, created)
120 }
121 if s.Edited != nil {
122 t.Errorf("edited = %v, want nil on create", s.Edited)
123 }
124}
125
126func TestIngestString_UpdateBumpsEditedOnContentChange(t *testing.T) {
127 ing := newStringIngester(t)
128 created := time.Date(2025, 9, 14, 10, 30, 0, 0, time.UTC)
129 base := tangled.String{
130 Filename: ptr("hello.txt"),
131 Description: ptr("a greeting"),
132 Contents: ptr("hello world\n"),
133 CreatedAt: created.Format(time.RFC3339),
134 }
135
136 if err := ing.ingestString(t.Context(), makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "rk1", base), ing.Logger); err != nil {
137 t.Fatalf("ingestString create: %v", err)
138 }
139
140 updated := base
141 updated.Contents = ptr("hello, world!\n")
142 if err := ing.ingestString(t.Context(), makeStringEvent(t, jmodels.CommitOperationUpdate, "did:plc:boltless", "rk1", updated), ing.Logger); err != nil {
143 t.Fatalf("ingestString update: %v", err)
144 }
145
146 s, ok := loadString(t, ing, "did:plc:boltless", "rk1")
147 if !ok {
148 t.Fatal("row missing after update")
149 }
150 if s.FileContent != "hello, world!\n" {
151 t.Errorf("contents = %q, want updated value", s.FileContent)
152 }
153 if !s.Created.Equal(created) {
154 t.Errorf("update overwrote created: got %v, want %v", s.Created, created)
155 }
156 if s.Edited == nil {
157 t.Fatal("edited not set after content change")
158 }
159}
160
161func TestIngestString_UpdateNoChangeKeepsEditedNil(t *testing.T) {
162 ing := newStringIngester(t)
163 rec := tangled.String{
164 Filename: ptr("hello.txt"),
165 Description: ptr("a greeting"),
166 Contents: ptr("hello world\n"),
167 CreatedAt: time.Date(2025, 9, 14, 10, 30, 0, 0, time.UTC).Format(time.RFC3339),
168 }
169
170 if err := ing.ingestString(t.Context(), makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "rk2", rec), ing.Logger); err != nil {
171 t.Fatalf("create: %v", err)
172 }
173 if err := ing.ingestString(t.Context(), makeStringEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "rk2", rec), ing.Logger); err != nil {
174 t.Fatalf("update: %v", err)
175 }
176
177 s, _ := loadString(t, ing, "did:plc:akshay", "rk2")
178 if s.Edited != nil {
179 t.Errorf("edited = %v, want nil when no field changed", s.Edited)
180 }
181}
182
183func TestIngestString_DeleteRemovesRow(t *testing.T) {
184 ing := newStringIngester(t)
185 rec := tangled.String{
186 Filename: ptr("hello.txt"),
187 Contents: ptr("x"),
188 CreatedAt: time.Now().UTC().Format(time.RFC3339),
189 }
190 if err := ing.ingestString(t.Context(), makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "rk1", rec), ing.Logger); err != nil {
191 t.Fatalf("create: %v", err)
192 }
193
194 del := &jmodels.Event{
195 Did: "did:plc:boltless",
196 Kind: jmodels.EventKindCommit,
197 Commit: &jmodels.Commit{
198 Operation: jmodels.CommitOperationDelete,
199 Collection: tangled.StringNSID,
200 RKey: "rk1",
201 CID: makeCID(t, &rec),
202 },
203 }
204 if err := ing.ingestString(t.Context(), del, ing.Logger); err != nil {
205 t.Fatalf("delete: %v", err)
206 }
207
208 if _, ok := loadString(t, ing, "did:plc:boltless", "rk1"); ok {
209 t.Fatal("row still present after delete")
210 }
211}
212
213func TestIngestString_ValidatorRejects(t *testing.T) {
214 ing := newStringIngester(t)
215 now := time.Now().UTC().Format(time.RFC3339)
216
217 cases := []struct {
218 name string
219 rec tangled.String
220 }{
221 {"long filename", tangled.String{Filename: ptr(strings.Repeat("a", 141)), Contents: ptr("x"), CreatedAt: now}},
222 {"long description", tangled.String{Filename: ptr("x"), Description: ptr(strings.Repeat("d", 281)), Contents: ptr("x"), CreatedAt: now}},
223 }
224
225 for _, tc := range cases {
226 t.Run(tc.name, func(t *testing.T) {
227 e := makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "bad", tc.rec)
228 if err := ing.ingestString(t.Context(), e, ing.Logger); err == nil {
229 t.Fatal("expected validator error, got nil")
230 }
231 if _, ok := loadString(t, ing, "did:plc:akshay", "bad"); ok {
232 t.Fatal("row inserted despite validator failure")
233 }
234 })
235 }
236}
237
238func TestIngestString_ColdReplayPreservesCreated(t *testing.T) {
239 created := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
240 rec := tangled.String{
241 Filename: ptr("snippet.go"),
242 Description: ptr("first cut"),
243 Contents: ptr("package main\n"),
244 CreatedAt: created.Format(time.RFC3339),
245 }
246 event := makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "rkcold", rec)
247
248 first := newStringIngester(t)
249 if err := first.ingestString(t.Context(), event, first.Logger); err != nil {
250 t.Fatalf("first ingest: %v", err)
251 }
252 live, _ := loadString(t, first, "did:plc:boltless", "rkcold")
253
254 second := newStringIngester(t)
255 if err := second.ingestString(t.Context(), event, second.Logger); err != nil {
256 t.Fatalf("replay ingest: %v", err)
257 }
258 replayed, _ := loadString(t, second, "did:plc:boltless", "rkcold")
259
260 if !live.Created.Equal(replayed.Created) {
261 t.Fatalf("cold replay drifted: live=%v replayed=%v", live.Created, replayed.Created)
262 }
263 if !replayed.Created.Equal(created) {
264 t.Fatalf("replay lost record CreatedAt: got %v, want %v", replayed.Created, created)
265 }
266}