diff --git a/CLAUDE.md b/CLAUDE.md index e82d30f..59d2750 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,5 +45,6 @@ Coverage target: maintain ≥90.9%. - Explicit types on all function signatures and struct fields - `go test ./...` and `go vet ./...` must pass before commit - SPDX header on all source files: `// SPDX-Licence-Identifier: EUPL-1.2` +- Error handling: all errors must use `coreerr.E(op, msg, err)` from `forge.lthn.ai/core/go-log`, never `fmt.Errorf` or `errors.New` - Conventional commits: `type(scope): description` - Co-Author trailer: `Co-Authored-By: Virgil ` diff --git a/parser_test.go b/parser_test.go index 7ec5326..cf8ed2a 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1311,6 +1311,127 @@ func TestParseTranscript_AllBadLines_Good(t *testing.T) { // --- ListSessions with truncated files --- +// --- PruneSessions tests --- + +func TestPruneSessions_DeletesOldFiles_Good(t *testing.T) { + dir := t.TempDir() + + // Create a session file with an old modification time. + path := writeJSONL(t, dir, "old-session.jsonl", + userTextEntry(ts(0), "old"), + ) + // Backdate the file's mtime by 2 hours. + oldTime := time.Now().Add(-2 * time.Hour) + require.NoError(t, os.Chtimes(path, oldTime, oldTime)) + + // Create a recent session file. + writeJSONL(t, dir, "new-session.jsonl", + userTextEntry(ts(0), "new"), + ) + + // Prune sessions older than 1 hour. + deleted, err := PruneSessions(dir, 1*time.Hour) + require.NoError(t, err) + assert.Equal(t, 1, deleted) + + // Verify only the new file remains. + sessions, err := ListSessions(dir) + require.NoError(t, err) + require.Len(t, sessions, 1) + assert.Equal(t, "new-session", sessions[0].ID) +} + +func TestPruneSessions_NothingToDelete_Good(t *testing.T) { + dir := t.TempDir() + + writeJSONL(t, dir, "recent.jsonl", + userTextEntry(ts(0), "fresh"), + ) + + deleted, err := PruneSessions(dir, 24*time.Hour) + require.NoError(t, err) + assert.Equal(t, 0, deleted) +} + +func TestPruneSessions_EmptyDir_Good(t *testing.T) { + dir := t.TempDir() + + deleted, err := PruneSessions(dir, 1*time.Hour) + require.NoError(t, err) + assert.Equal(t, 0, deleted) +} + +// --- IsExpired tests --- + +func TestIsExpired_RecentSession_Good(t *testing.T) { + sess := &Session{ + EndTime: time.Now().Add(-5 * time.Minute), + } + assert.False(t, sess.IsExpired(1*time.Hour)) +} + +func TestIsExpired_OldSession_Good(t *testing.T) { + sess := &Session{ + EndTime: time.Now().Add(-2 * time.Hour), + } + assert.True(t, sess.IsExpired(1*time.Hour)) +} + +func TestIsExpired_ZeroEndTime_Bad(t *testing.T) { + sess := &Session{} + assert.False(t, sess.IsExpired(1*time.Hour)) +} + +// --- FetchSession tests --- + +func TestFetchSession_ValidID_Good(t *testing.T) { + dir := t.TempDir() + writeJSONL(t, dir, "abc123.jsonl", + userTextEntry(ts(0), "hello"), + ) + + sess, stats, err := FetchSession(dir, "abc123") + require.NoError(t, err) + require.NotNil(t, sess) + require.NotNil(t, stats) + assert.Equal(t, "abc123", sess.ID) + assert.Len(t, sess.Events, 1) +} + +func TestFetchSession_PathTraversal_Ugly(t *testing.T) { + dir := t.TempDir() + + _, _, err := FetchSession(dir, "../etc/passwd") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid session id") +} + +func TestFetchSession_BackslashTraversal_Ugly(t *testing.T) { + dir := t.TempDir() + + _, _, err := FetchSession(dir, `foo\bar`) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid session id") +} + +func TestFetchSession_ForwardSlash_Ugly(t *testing.T) { + dir := t.TempDir() + + _, _, err := FetchSession(dir, "foo/bar") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid session id") +} + +func TestFetchSession_NotFound_Bad(t *testing.T) { + dir := t.TempDir() + + _, _, err := FetchSession(dir, "nonexistent") + require.Error(t, err) + assert.Contains(t, err.Error(), "open transcript") +} + +// --- ListSessions with truncated files --- + func TestListSessions_TruncatedFile_Good(t *testing.T) { dir := t.TempDir() // A .jsonl file where some lines are truncated — ListSessions should