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