Stitch any CI into Tangled
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}