picoclaw/pkg/session/migrate_test.go
dj-oyu 6062b17ba5 feat(session): implement SQLite SessionStore + LegacyAdapter (TASKS-3 Phase 0)
Replace JSON file-based session storage with SQLite backend while
maintaining full backward compatibility through LegacyAdapter.

New files:
- pkg/session/types.go: Turn, SessionInfo, CreateOpts, ListFilter types
- pkg/session/store.go: SessionStore interface (15 methods)
- pkg/session/sqlite.go: SQLite implementation (WAL mode, modernc.org/sqlite)
- pkg/session/legacy_adapter.go: wraps SessionStore with SessionManager API
- pkg/session/migrate.go: JSON → SQLite migration at startup

Changes:
- pkg/agent/instance.go: Sessions type changed to *LegacyAdapter
- loop.go / loop_test.go: zero changes (method signatures identical)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:55:13 +09:00

276 lines
5.2 KiB
Go

package session
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/providers"
)
func writeJSONSession(t *testing.T, dir string, sess Session) {
t.Helper()
data, err := json.MarshalIndent(sess, "", " ")
if err != nil {
t.Fatal(err)
}
filename := sanitizeFilename(sess.Key) + ".json"
if err := os.WriteFile(filepath.Join(dir, filename), data, 0o644); err != nil {
t.Fatal(err)
}
}
func TestMigrate_Basic(t *testing.T) {
jsonDir := t.TempDir()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := OpenSQLiteStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
writeJSONSession(t, jsonDir, Session{
Key: "telegram:123",
Messages: []providers.Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "hi"},
},
Summary: "greeting",
})
writeJSONSession(t, jsonDir, Session{
Key: "discord:456",
Messages: []providers.Message{
{Role: "user", Content: "test"},
},
})
migrated, err := MigrateJSONSessions(jsonDir, store)
if err != nil {
t.Fatalf("MigrateJSONSessions: %v", err)
}
if migrated != 2 {
t.Errorf("expected 2 migrated, got %d", migrated)
}
// Verify sessions exist
info, _ := store.Get("telegram:123")
if info == nil || info.Summary != "greeting" {
t.Errorf("telegram:123 not found or wrong summary")
}
turns, _ := store.Turns("telegram:123", 0)
if len(turns) != 1 || len(turns[0].Messages) != 2 {
t.Errorf("expected 1 turn with 2 messages, got %+v", turns)
}
info2, _ := store.Get("discord:456")
if info2 == nil {
t.Error("discord:456 not found")
}
// Verify .json files were renamed
entries, _ := os.ReadDir(jsonDir)
for _, e := range entries {
if filepath.Ext(e.Name()) == ".json" {
t.Errorf("expected .json.migrated, found %s", e.Name())
}
}
}
func TestMigrate_EmptyMessages(t *testing.T) {
jsonDir := t.TempDir()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, _ := OpenSQLiteStore(dbPath)
defer store.Close()
writeJSONSession(t, jsonDir, Session{
Key: "empty:1",
Messages: []providers.Message{},
})
migrated, err := MigrateJSONSessions(jsonDir, store)
if err != nil {
t.Fatal(err)
}
if migrated != 1 {
t.Errorf("expected 1, got %d", migrated)
}
// Should exist but have no turns
count, _ := store.TurnCount("empty:1")
if count != 0 {
t.Errorf("expected 0 turns, got %d", count)
}
}
func TestMigrate_EmptySummary(t *testing.T) {
jsonDir := t.TempDir()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, _ := OpenSQLiteStore(dbPath)
defer store.Close()
writeJSONSession(t, jsonDir, Session{
Key: "nosummary:1",
Messages: []providers.Message{{Role: "user", Content: "hi"}},
})
MigrateJSONSessions(jsonDir, store)
info, _ := store.Get("nosummary:1")
if info.Summary != "" {
t.Errorf("expected empty summary, got %q", info.Summary)
}
}
func TestMigrate_InvalidJSON(t *testing.T) {
jsonDir := t.TempDir()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, _ := OpenSQLiteStore(dbPath)
defer store.Close()
// Write invalid JSON
os.WriteFile(filepath.Join(jsonDir, "bad.json"), []byte("{invalid"), 0o644)
// Write a valid one too
writeJSONSession(t, jsonDir, Session{
Key: "good:1",
Messages: []providers.Message{{Role: "user", Content: "hi"}},
})
migrated, err := MigrateJSONSessions(jsonDir, store)
if err != nil {
t.Fatal(err)
}
if migrated != 1 {
t.Errorf("expected 1 (skipped bad), got %d", migrated)
}
// Bad file should still be .json (not renamed)
if _, err := os.Stat(filepath.Join(jsonDir, "bad.json")); os.IsNotExist(err) {
t.Error("bad.json should still exist")
}
}
func TestMigrate_Idempotent(t *testing.T) {
jsonDir := t.TempDir()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, _ := OpenSQLiteStore(dbPath)
defer store.Close()
writeJSONSession(t, jsonDir, Session{
Key: "k1",
Messages: []providers.Message{{Role: "user", Content: "hello"}},
})
n1, _ := MigrateJSONSessions(jsonDir, store)
if n1 != 1 {
t.Fatalf("first run: expected 1, got %d", n1)
}
// Second run should find no .json files (all renamed)
n2, _ := MigrateJSONSessions(jsonDir, store)
if n2 != 0 {
t.Errorf("second run: expected 0, got %d", n2)
}
}
func TestMigrate_NonExistentDir(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
store, _ := OpenSQLiteStore(dbPath)
defer store.Close()
n, err := MigrateJSONSessions("/nonexistent/path", store)
if err != nil {
t.Fatalf("expected nil error for non-existent dir, got %v", err)
}
if n != 0 {
t.Errorf("expected 0, got %d", n)
}
}
func TestMigrate_AlreadyExistsInStore(t *testing.T) {
jsonDir := t.TempDir()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, _ := OpenSQLiteStore(dbPath)
defer store.Close()
// Pre-create session in store
store.Create("k1", nil)
// Write JSON for same key
writeJSONSession(t, jsonDir, Session{
Key: "k1",
Messages: []providers.Message{{Role: "user", Content: "hello"}},
})
n, _ := MigrateJSONSessions(jsonDir, store)
if n != 1 {
t.Errorf("expected 1, got %d", n)
}
// File should still be renamed
entries, _ := os.ReadDir(jsonDir)
for _, e := range entries {
if filepath.Ext(e.Name()) == ".json" {
t.Errorf("expected .json.migrated, found %s", e.Name())
}
}
}