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