Stitch any CI into Tangled
2

Configure Feed

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

1package main 2 3// Tests for the SQLite store. We use t.TempDir() for an isolated database 4// per test, which both keeps tests independent and exercises the open + 5// migrate path on every run. 6// 7// Where the store doesn't expose a query method (it's intentionally 8// write-mostly today) we drop down to raw SQL via s.db to verify the 9// row is what we expect. 10 11import ( 12 "context" 13 "path/filepath" 14 "testing" 15) 16 17// newTestStore opens a fresh store in a per-test temp dir and registers 18// cleanup. Centralized so test bodies stay focused on behavior. 19func newTestStore(t *testing.T) *store { 20 t.Helper() 21 path := filepath.Join(t.TempDir(), "tack.db") 22 s, err := openStore(path) 23 if err != nil { 24 t.Fatalf("openStore: %v", err) 25 } 26 t.Cleanup(func() { 27 if err := s.Close(); err != nil { 28 t.Errorf("close store: %v", err) 29 } 30 }) 31 return s 32} 33 34// TestOpenStoreIdempotent makes sure re-opening an existing database 35// (which is what happens on every restart) succeeds and leaves the 36// schema intact. 37func TestOpenStoreIdempotent(t *testing.T) { 38 path := filepath.Join(t.TempDir(), "tack.db") 39 s1, err := openStore(path) 40 if err != nil { 41 t.Fatalf("first open: %v", err) 42 } 43 if err := s1.Close(); err != nil { 44 t.Fatalf("first close: %v", err) 45 } 46 s2, err := openStore(path) 47 if err != nil { 48 t.Fatalf("second open: %v", err) 49 } 50 defer s2.Close() 51 52 // Sanity check: the schema is in place by writing and reading back 53 // a cursor through the freshly re-opened handle. 54 ctx := context.Background() 55 if err := s2.SaveCursor(ctx, 42); err != nil { 56 t.Fatalf("save cursor: %v", err) 57 } 58 got, err := s2.LoadCursor(ctx) 59 if err != nil { 60 t.Fatalf("load cursor: %v", err) 61 } 62 if got == nil || *got != 42 { 63 t.Fatalf("cursor = %v, want 42", got) 64 } 65} 66 67// TestCursorRoundtrip covers the three states a cursor can be in: 68// missing (nil), present, and overwritten. Together they exercise the 69// INSERT and the ON CONFLICT branch of SaveCursor. 70func TestCursorRoundtrip(t *testing.T) { 71 s := newTestStore(t) 72 ctx := context.Background() 73 74 got, err := s.LoadCursor(ctx) 75 if err != nil { 76 t.Fatalf("load cursor (empty): %v", err) 77 } 78 if got != nil { 79 t.Fatalf("expected nil cursor on fresh store, got %d", *got) 80 } 81 82 if err := s.SaveCursor(ctx, 1234567890); err != nil { 83 t.Fatalf("save cursor: %v", err) 84 } 85 got, err = s.LoadCursor(ctx) 86 if err != nil { 87 t.Fatalf("load cursor: %v", err) 88 } 89 if got == nil || *got != 1234567890 { 90 t.Fatalf("cursor after save = %v, want 1234567890", got) 91 } 92 93 // Overwrite path — exercises ON CONFLICT DO UPDATE. 94 if err := s.SaveCursor(ctx, 9999); err != nil { 95 t.Fatalf("overwrite cursor: %v", err) 96 } 97 got, err = s.LoadCursor(ctx) 98 if err != nil { 99 t.Fatalf("load cursor (overwrite): %v", err) 100 } 101 if got == nil || *got != 9999 { 102 t.Fatalf("cursor after overwrite = %v, want 9999", got) 103 } 104} 105 106// TestSpindleMemberLifecycle covers insert, update-on-conflict, and 107// delete for spindle.member rows. We read back via raw SQL since the 108// store doesn't expose a query helper. 109func TestSpindleMemberLifecycle(t *testing.T) { 110 s := newTestStore(t) 111 ctx := context.Background() 112 113 const did, rkey = "did:plc:owner", "abc123" 114 115 if err := s.UpsertSpindleMember(ctx, did, rkey, "https://spindle.example", "did:plc:alice", "2026-01-01T00:00:00Z"); err != nil { 116 t.Fatalf("insert: %v", err) 117 } 118 119 var instance, subject, createdAt string 120 err := s.db.QueryRowContext(ctx, 121 `SELECT instance, subject, created_at FROM spindle_members WHERE did = ? AND rkey = ?`, 122 did, rkey, 123 ).Scan(&instance, &subject, &createdAt) 124 if err != nil { 125 t.Fatalf("query after insert: %v", err) 126 } 127 if instance != "https://spindle.example" || subject != "did:plc:alice" || createdAt != "2026-01-01T00:00:00Z" { 128 t.Fatalf("after insert got (%q,%q,%q)", instance, subject, createdAt) 129 } 130 131 // Update path — same primary key, different fields. 132 if err := s.UpsertSpindleMember(ctx, did, rkey, "https://spindle.example", "did:plc:bob", "2026-02-02T00:00:00Z"); err != nil { 133 t.Fatalf("update: %v", err) 134 } 135 err = s.db.QueryRowContext(ctx, 136 `SELECT subject, created_at FROM spindle_members WHERE did = ? AND rkey = ?`, 137 did, rkey, 138 ).Scan(&subject, &createdAt) 139 if err != nil { 140 t.Fatalf("query after update: %v", err) 141 } 142 if subject != "did:plc:bob" || createdAt != "2026-02-02T00:00:00Z" { 143 t.Fatalf("after update got (%q,%q)", subject, createdAt) 144 } 145 146 // Delete and verify the row is gone. 147 if err := s.DeleteSpindleMember(ctx, did, rkey); err != nil { 148 t.Fatalf("delete: %v", err) 149 } 150 if n := countRows(t, s, "spindle_members"); n != 0 { 151 t.Fatalf("after delete, spindle_members has %d rows, want 0", n) 152 } 153 154 // Deleting a row that doesn't exist must succeed silently — the 155 // jetstream stream can replay deletes after a restart. 156 if err := s.DeleteSpindleMember(ctx, did, rkey); err != nil { 157 t.Fatalf("delete missing: %v", err) 158 } 159} 160 161// TestRepoLifecycle exercises Repo upsert/delete and confirms that 162// optional fields (spindle, repo_did) round-trip as the empty string 163// rather than NULL — the store schema treats them uniformly. 164func TestRepoLifecycle(t *testing.T) { 165 s := newTestStore(t) 166 ctx := context.Background() 167 168 const did, rkey = "did:plc:owner", "repo1" 169 170 if err := s.UpsertRepo(ctx, did, rkey, "knot.example", "myrepo", "https://spindle.example", "did:plc:repo", "2026-01-01T00:00:00Z"); err != nil { 171 t.Fatalf("insert: %v", err) 172 } 173 174 var knot, name, spindle, repoDid string 175 err := s.db.QueryRowContext(ctx, 176 `SELECT knot, name, spindle, repo_did FROM repos WHERE did = ? AND rkey = ?`, 177 did, rkey, 178 ).Scan(&knot, &name, &spindle, &repoDid) 179 if err != nil { 180 t.Fatalf("query after insert: %v", err) 181 } 182 if knot != "knot.example" || name != "myrepo" || spindle != "https://spindle.example" || repoDid != "did:plc:repo" { 183 t.Fatalf("after insert got (%q,%q,%q,%q)", knot, name, spindle, repoDid) 184 } 185 186 // Upsert with cleared optional fields — verifies UPDATE actually 187 // overwrites them rather than preserving the old non-empty values. 188 if err := s.UpsertRepo(ctx, did, rkey, "knot.example", "myrepo", "", "", "2026-01-01T00:00:00Z"); err != nil { 189 t.Fatalf("update: %v", err) 190 } 191 err = s.db.QueryRowContext(ctx, 192 `SELECT spindle, repo_did FROM repos WHERE did = ? AND rkey = ?`, 193 did, rkey, 194 ).Scan(&spindle, &repoDid) 195 if err != nil { 196 t.Fatalf("query after update: %v", err) 197 } 198 if spindle != "" || repoDid != "" { 199 t.Fatalf("after update got (%q,%q), want both empty", spindle, repoDid) 200 } 201 202 if err := s.DeleteRepo(ctx, did, rkey); err != nil { 203 t.Fatalf("delete: %v", err) 204 } 205 if n := countRows(t, s, "repos"); n != 0 { 206 t.Fatalf("after delete, repos has %d rows, want 0", n) 207 } 208} 209 210// TestRepoCollaboratorLifecycle mirrors the repo and member tests for 211// the collaborator table. 212func TestRepoCollaboratorLifecycle(t *testing.T) { 213 s := newTestStore(t) 214 ctx := context.Background() 215 216 const did, rkey = "did:plc:owner", "collab1" 217 218 if err := s.UpsertRepoCollaborator(ctx, did, rkey, "myrepo", "did:plc:repo", "did:plc:carol", "2026-01-01T00:00:00Z"); err != nil { 219 t.Fatalf("insert: %v", err) 220 } 221 222 var repo, repoDid, subject string 223 err := s.db.QueryRowContext(ctx, 224 `SELECT repo, repo_did, subject FROM repo_collaborators WHERE did = ? AND rkey = ?`, 225 did, rkey, 226 ).Scan(&repo, &repoDid, &subject) 227 if err != nil { 228 t.Fatalf("query: %v", err) 229 } 230 if repo != "myrepo" || repoDid != "did:plc:repo" || subject != "did:plc:carol" { 231 t.Fatalf("got (%q,%q,%q)", repo, repoDid, subject) 232 } 233 234 if err := s.DeleteRepoCollaborator(ctx, did, rkey); err != nil { 235 t.Fatalf("delete: %v", err) 236 } 237 if n := countRows(t, s, "repo_collaborators"); n != 0 { 238 t.Fatalf("after delete, repo_collaborators has %d rows, want 0", n) 239 } 240} 241 242// TestKnotsForSpindle verifies the query returns only knots from repos 243// whose .spindle field matches the given hostname, and that duplicate 244// knots collapse to a single entry. 245func TestKnotsForSpindle(t *testing.T) { 246 s := newTestStore(t) 247 ctx := context.Background() 248 249 const ours = "tack.example" 250 const other = "other.example" 251 252 // Two repos on the same knot pointing at us — should collapse to 1. 253 if err := s.UpsertRepo(ctx, "did:plc:a", "rk1", "knot1.example", "repo-a", ours, "", "t"); err != nil { 254 t.Fatal(err) 255 } 256 if err := s.UpsertRepo(ctx, "did:plc:b", "rk2", "knot1.example", "repo-b", ours, "", "t"); err != nil { 257 t.Fatal(err) 258 } 259 // A second knot pointing at us. 260 if err := s.UpsertRepo(ctx, "did:plc:c", "rk3", "knot2.example", "repo-c", ours, "", "t"); err != nil { 261 t.Fatal(err) 262 } 263 // A repo pointing at a different spindle — must be excluded. 264 if err := s.UpsertRepo(ctx, "did:plc:d", "rk4", "knot3.example", "repo-d", other, "", "t"); err != nil { 265 t.Fatal(err) 266 } 267 // A repo with no spindle declared — must be excluded. 268 if err := s.UpsertRepo(ctx, "did:plc:e", "rk5", "knot4.example", "repo-e", "", "", "t"); err != nil { 269 t.Fatal(err) 270 } 271 272 got, err := s.KnotsForSpindle(ctx, ours) 273 if err != nil { 274 t.Fatalf("KnotsForSpindle: %v", err) 275 } 276 want := map[string]struct{}{"knot1.example": {}, "knot2.example": {}} 277 if len(got) != len(want) { 278 t.Fatalf("got %v, want %v", got, want) 279 } 280 for _, k := range got { 281 if _, ok := want[k]; !ok { 282 t.Fatalf("unexpected knot %q in %v", k, got) 283 } 284 } 285} 286 287// countRows is a small SELECT COUNT(*) helper used by lifecycle tests 288// to verify deletes actually removed the row. Table name is interpolated 289// directly because callers pass a constant from the schema, not user 290// input — SQLite doesn't allow parameterized table names anyway. 291func countRows(t *testing.T, s *store, table string) int { 292 t.Helper() 293 var n int 294 if err := s.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n); err != nil { 295 t.Fatalf("count %s: %v", table, err) 296 } 297 return n 298}