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}