1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package session_test
6
7import (
8 "context"
9 "errors"
10 "os"
11 "path/filepath"
12 "testing"
13 "time"
14
15 "git.secluded.site/np/internal/db"
16 "git.secluded.site/np/internal/session"
17 "git.secluded.site/np/internal/testutil"
18)
19
20func TestStoreStartAndArchive(t *testing.T) {
21 ctx := context.Background()
22 database := testutil.OpenDB(t)
23
24 clock := &testutil.SequenceClock{
25 Times: []time.Time{
26 time.Date(2025, time.April, 4, 9, 15, 0, 0, time.FixedZone("A", 3600)),
27 time.Date(2025, time.April, 4, 11, 45, 0, 0, time.FixedZone("B", -3600)),
28 time.Date(2025, time.April, 4, 12, 0, 0, 0, time.UTC),
29 },
30 }
31
32 store := session.NewStore(database, clock)
33
34 workdir := t.TempDir()
35 canonical, hash, err := db.CanonicalizeAndHash(workdir)
36 if err != nil {
37 t.Fatalf("canonicalise temp dir: %v", err)
38 }
39
40 started, err := store.Start(ctx, workdir)
41 if err != nil {
42 t.Fatalf("Start: %v", err)
43 }
44
45 if started.SID == "" {
46 t.Fatalf("expected SID to be populated")
47 }
48 if len(started.SID) != 26 {
49 t.Fatalf("expected ULID SID length 26, got %d", len(started.SID))
50 }
51 if started.DirPath != canonical {
52 t.Fatalf("DirPath mismatch: want %q got %q", canonical, started.DirPath)
53 }
54 if started.DirHash != hash {
55 t.Fatalf("DirHash mismatch: want %q got %q", hash, started.DirHash)
56 }
57 if started.State != session.StateActive {
58 t.Fatalf("expected state active, got %s", started.State)
59 }
60 if started.ArchivedAt != nil {
61 t.Fatalf("expected ArchivedAt to be nil")
62 }
63
64 createdAt := clock.Times[0].UTC()
65 if !started.CreatedAt.Equal(createdAt) || !started.LastUpdatedAt.Equal(createdAt) {
66 t.Fatalf("timestamp mismatch: created=%v updated=%v want=%v", started.CreatedAt, started.LastUpdatedAt, createdAt)
67 }
68
69 err = database.View(ctx, func(txn *db.Txn) error {
70 active, err := txn.Get(db.KeyDirActive(hash))
71 if err != nil {
72 return err
73 }
74 if string(active) != started.SID {
75 t.Fatalf("active SID mismatch: got %s want %s", string(active), started.SID)
76 }
77
78 idxActive, err := txn.Get(db.KeyIdxActive(started.SID))
79 if err != nil {
80 return err
81 }
82 if string(idxActive) != hash {
83 t.Fatalf("idx active value mismatch: got %s want %s", string(idxActive), hash)
84 }
85
86 seq, err := txn.Get(db.KeySessionEventSeq(started.SID))
87 if err != nil {
88 return err
89 }
90 if len(seq) != 8 {
91 t.Fatalf("expected event seq zero buffer length 8, got %d", len(seq))
92 }
93 for i, b := range seq {
94 if b != 0 {
95 t.Fatalf("expected zeroed event seq, byte %d was %d", i, b)
96 }
97 }
98 return nil
99 })
100 if err != nil {
101 t.Fatalf("validate stored keys: %v", err)
102 }
103
104 _, err = store.Start(ctx, workdir)
105 if err == nil {
106 t.Fatalf("expected AlreadyActiveError")
107 }
108 var already session.AlreadyActiveError
109 if !errors.As(err, &already) {
110 t.Fatalf("expected AlreadyActiveError, got %v", err)
111 }
112 if already.Session.SID != started.SID {
113 t.Fatalf("AlreadyActiveError returned unexpected SID: %s", already.Session.SID)
114 }
115
116 childDir := filepath.Join(workdir, "nested")
117 if err := os.MkdirAll(childDir, 0o755); err != nil {
118 t.Fatalf("mkdir nested: %v", err)
119 }
120
121 found, ok, err := store.ActiveByPath(ctx, childDir)
122 if err != nil {
123 t.Fatalf("ActiveByPath child: %v", err)
124 }
125 if !ok {
126 t.Fatalf("expected ActiveByPath to find parent session")
127 }
128 if found.SID != started.SID {
129 t.Fatalf("ActiveByPath returned different SID: %s", found.SID)
130 }
131
132 archived, err := store.Archive(ctx, started.SID)
133 if err != nil {
134 t.Fatalf("Archive: %v", err)
135 }
136 if archived.State != session.StateArchived {
137 t.Fatalf("expected archived state, got %s", archived.State)
138 }
139 wantArchivedAt := clock.Times[1].UTC()
140 if archived.ArchivedAt == nil || !archived.ArchivedAt.Equal(wantArchivedAt) {
141 t.Fatalf("ArchivedAt mismatch: got %v want %v", archived.ArchivedAt, wantArchivedAt)
142 }
143 if !archived.LastUpdatedAt.Equal(wantArchivedAt) {
144 t.Fatalf("LastUpdatedAt mismatch: got %v want %v", archived.LastUpdatedAt, wantArchivedAt)
145 }
146
147 err = database.View(ctx, func(txn *db.Txn) error {
148 if exists, err := txn.Exists(db.KeyDirActive(hash)); err != nil {
149 return err
150 } else if exists {
151 t.Fatalf("expected dir active key to be removed")
152 }
153 if exists, err := txn.Exists(db.KeyIdxActive(started.SID)); err != nil {
154 return err
155 } else if exists {
156 t.Fatalf("expected idx active key to be removed")
157 }
158
159 tsHex := db.Uint64Hex(uint64(wantArchivedAt.UnixNano()))
160 idxVal, err := txn.Get(db.KeyIdxArchived(tsHex, started.SID))
161 if err != nil {
162 return err
163 }
164 if string(idxVal) != hash {
165 t.Fatalf("idx archived value mismatch: got %s want %s", string(idxVal), hash)
166 }
167
168 dirArchivedKey := db.KeyDirArchived(hash, tsHex, started.SID)
169 if exists, err := txn.Exists(dirArchivedKey); err != nil {
170 return err
171 } else if !exists {
172 t.Fatalf("expected dir archived key to exist")
173 }
174 return nil
175 })
176 if err != nil {
177 t.Fatalf("validate archival keys: %v", err)
178 }
179
180 repeat, err := store.Archive(ctx, started.SID)
181 if err != nil {
182 t.Fatalf("Archive second time: %v", err)
183 }
184 if repeat.State != session.StateArchived {
185 t.Fatalf("expected archived state on second archive")
186 }
187
188 if _, found, err := store.ActiveByPath(ctx, workdir); err != nil {
189 t.Fatalf("post-archive ActiveByPath error: %v", err)
190 } else if found {
191 t.Fatalf("ActiveByPath should not find archived session")
192 }
193}
194
195func TestTxnStoreTouchAt(t *testing.T) {
196 ctx := context.Background()
197 database := testutil.OpenDB(t)
198
199 startTime := time.Date(2025, time.May, 5, 9, 0, 0, 0, time.FixedZone("A", 3600))
200 autoTime := time.Date(2025, time.May, 5, 10, 30, 0, 0, time.FixedZone("B", -3600))
201
202 clock := &testutil.SequenceClock{
203 Times: []time.Time{startTime, autoTime},
204 }
205
206 store := session.NewStore(database, clock)
207
208 dir := t.TempDir()
209
210 started, err := store.Start(ctx, dir)
211 if err != nil {
212 t.Fatalf("Start: %v", err)
213 }
214
215 custom := time.Date(2025, time.May, 6, 8, 15, 0, 0, time.UTC)
216 err = database.Update(ctx, func(txn *db.Txn) error {
217 _, err := store.WithTxn(txn).TouchAt(started.SID, custom)
218 return err
219 })
220 if err != nil {
221 t.Fatalf("TouchAt(custom): %v", err)
222 }
223
224 updated, err := store.Get(ctx, started.SID)
225 if err != nil {
226 t.Fatalf("Get after custom touch: %v", err)
227 }
228 if !updated.LastUpdatedAt.Equal(custom) {
229 t.Fatalf("LastUpdatedAt mismatch: got %v want %v", updated.LastUpdatedAt, custom)
230 }
231
232 err = database.Update(ctx, func(txn *db.Txn) error {
233 _, err := store.WithTxn(txn).TouchAt(started.SID, time.Time{})
234 return err
235 })
236 if err != nil {
237 t.Fatalf("TouchAt(clock): %v", err)
238 }
239
240 autoUpdated, err := store.Get(ctx, started.SID)
241 if err != nil {
242 t.Fatalf("Get after clock touch: %v", err)
243 }
244
245 if want := autoTime.UTC(); !autoUpdated.LastUpdatedAt.Equal(want) {
246 t.Fatalf("Expected LastUpdatedAt %v, got %v", want, autoUpdated.LastUpdatedAt)
247 }
248}
249
250func TestLatestArchivedByPath(t *testing.T) {
251 ctx := context.Background()
252 database := testutil.OpenDB(t)
253
254 clock := &testutil.SequenceClock{
255 Times: []time.Time{
256 time.Date(2025, time.April, 1, 9, 0, 0, 0, time.UTC),
257 time.Date(2025, time.April, 1, 10, 0, 0, 0, time.UTC),
258 time.Date(2025, time.April, 2, 9, 0, 0, 0, time.UTC),
259 time.Date(2025, time.April, 2, 10, 0, 0, 0, time.UTC),
260 time.Date(2025, time.April, 3, 9, 0, 0, 0, time.UTC),
261 time.Date(2025, time.April, 3, 10, 0, 0, 0, time.UTC),
262 },
263 }
264
265 store := session.NewStore(database, clock)
266 workdir := t.TempDir()
267
268 // Create and archive first session
269 session1, err := store.Start(ctx, workdir)
270 if err != nil {
271 t.Fatalf("Start session1: %v", err)
272 }
273 _, err = store.Archive(ctx, session1.SID)
274 if err != nil {
275 t.Fatalf("Archive session1: %v", err)
276 }
277
278 // Create and archive second session (more recent)
279 session2, err := store.Start(ctx, workdir)
280 if err != nil {
281 t.Fatalf("Start session2: %v", err)
282 }
283 _, err = store.Archive(ctx, session2.SID)
284 if err != nil {
285 t.Fatalf("Archive session2: %v", err)
286 }
287
288 // Create and archive third session (most recent)
289 session3, err := store.Start(ctx, workdir)
290 if err != nil {
291 t.Fatalf("Start session3: %v", err)
292 }
293 archived3, err := store.Archive(ctx, session3.SID)
294 if err != nil {
295 t.Fatalf("Archive session3: %v", err)
296 }
297
298 // LatestArchivedByPath should return the most recent archived session
299 latest, found, err := store.LatestArchivedByPath(ctx, workdir)
300 if err != nil {
301 t.Fatalf("LatestArchivedByPath: %v", err)
302 }
303 if !found {
304 t.Fatalf("expected to find archived session")
305 }
306 if latest.SID != archived3.SID {
307 t.Fatalf("expected latest SID %s, got %s", archived3.SID, latest.SID)
308 }
309 if latest.State != session.StateArchived {
310 t.Fatalf("expected archived state, got %s", latest.State)
311 }
312
313 // Test with child directory (parent walk)
314 childDir := filepath.Join(workdir, "nested", "deep")
315 if err := os.MkdirAll(childDir, 0o755); err != nil {
316 t.Fatalf("mkdir nested: %v", err)
317 }
318
319 latestFromChild, found, err := store.LatestArchivedByPath(ctx, childDir)
320 if err != nil {
321 t.Fatalf("LatestArchivedByPath from child: %v", err)
322 }
323 if !found {
324 t.Fatalf("expected to find archived session from child dir")
325 }
326 if latestFromChild.SID != archived3.SID {
327 t.Fatalf("expected latest SID %s from child, got %s", archived3.SID, latestFromChild.SID)
328 }
329
330 // Test with directory that has no archived sessions
331 emptyDir := t.TempDir()
332 _, found, err = store.LatestArchivedByPath(ctx, emptyDir)
333 if err != nil {
334 t.Fatalf("LatestArchivedByPath on empty dir: %v", err)
335 }
336 if found {
337 t.Fatalf("expected not to find archived session in empty dir")
338 }
339}
340
341func TestLatestArchivedByPathWithMissingDoc(t *testing.T) {
342 ctx := context.Background()
343 database := testutil.OpenDB(t)
344
345 clock := &testutil.SequenceClock{
346 Times: []time.Time{
347 time.Date(2025, time.April, 1, 9, 0, 0, 0, time.UTC),
348 time.Date(2025, time.April, 1, 10, 0, 0, 0, time.UTC),
349 time.Date(2025, time.April, 2, 9, 0, 0, 0, time.UTC),
350 time.Date(2025, time.April, 2, 10, 0, 0, 0, time.UTC),
351 },
352 }
353
354 store := session.NewStore(database, clock)
355 workdir := t.TempDir()
356
357 // Create and archive first session
358 session1, err := store.Start(ctx, workdir)
359 if err != nil {
360 t.Fatalf("Start session1: %v", err)
361 }
362 archived1, err := store.Archive(ctx, session1.SID)
363 if err != nil {
364 t.Fatalf("Archive session1: %v", err)
365 }
366
367 // Create and archive second session (most recent)
368 session2, err := store.Start(ctx, workdir)
369 if err != nil {
370 t.Fatalf("Start session2: %v", err)
371 }
372 archived2, err := store.Archive(ctx, session2.SID)
373 if err != nil {
374 t.Fatalf("Archive session2: %v", err)
375 }
376
377 // Corrupt the most recent session by deleting its meta key
378 // This simulates a missing/corrupted document
379 err = database.Update(ctx, func(txn *db.Txn) error {
380 metaKey := db.KeySessionMeta(archived2.SID)
381 return txn.Delete(metaKey)
382 })
383 if err != nil {
384 t.Fatalf("Delete meta: %v", err)
385 }
386
387 // LatestArchivedByPath should skip the corrupted session and find the older valid one
388 latest, found, err := store.LatestArchivedByPath(ctx, workdir)
389 if err != nil {
390 t.Fatalf("LatestArchivedByPath: %v", err)
391 }
392 if !found {
393 t.Fatalf("expected to find archived session (should skip corrupted and find older)")
394 }
395 if latest.SID != archived1.SID {
396 t.Fatalf("expected to find session1 %s, got %s", archived1.SID, latest.SID)
397 }
398}