Monorepo for Tangled tangled.org
6

Configure Feed

Select the types of activity you want to include in your feed.

appview: string ingester time bug

Lewis: May this revision serve well! <lewis@tangled.org>

author
Lewis
committer
Tangled
date (May 21, 2026, 9:29 AM +0300) commit 410348ca parent 9849f353 change-id pxrkvtxx
+248 -1
+247
appview/ingester_string_test.go
··· 1 + package appview 2 + 3 + import ( 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 + 20 + func 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 + 35 + func 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 + 53 + func 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 + 71 + func 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 + 107 + func 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 + 142 + func 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 + 164 + func 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 + 193 + func 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 + 219 + func 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 + }
+1 -1
appview/models/string.go
··· 36 36 } 37 37 38 38 func StringFromRecord(did, rkey string, record tangled.String) String { 39 - created, err := time.Parse(record.CreatedAt, time.RFC3339) 39 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 40 40 if err != nil { 41 41 created = time.Now() 42 42 }