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