package database import ( "context" "os" "path/filepath" "sync" "testing" "time" "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/system/jobs" ) func newTestLogger(t *testing.T) *logger.Logger { t.Helper() log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stderr"}) if err != nil { t.Fatalf("logger: %v", err) } return log } // stubBus is a minimal in-memory EventBus that records published events. type stubBus struct { mu sync.Mutex events []*bus.Event } func (s *stubBus) Publish(_ context.Context, _ string, event *bus.Event) error { s.mu.Lock() defer s.mu.Unlock() s.events = append(s.events, event) return nil } func (s *stubBus) Subscribe(string, bus.EventHandler) (bus.Subscription, error) { return nil, nil } func (s *stubBus) QueueSubscribe(string, string, bus.EventHandler) (bus.Subscription, error) { return nil, nil } func (s *stubBus) Request(context.Context, string, *bus.Event, time.Duration) (*bus.Event, error) { return nil, nil } func (s *stubBus) Close() {} func (s *stubBus) IsConnected() bool { return true } var _ bus.EventBus = (*stubBus)(nil) var _ = events.SystemJobUpdate // newTestPool opens a temp SQLite at /kandev.db and seeds it with a // kandev_meta row plus a couple of user tables and rows so VACUUM has // something to reclaim. func newTestPool(t *testing.T, dataDir string) (*db.Pool, string) { t.Helper() dbPath := filepath.Join(dataDir, "kandev.db") writerRaw, err := db.OpenSQLite(dbPath) if err != nil { t.Fatalf("OpenSQLite: %v", err) } readerRaw, err := db.OpenSQLiteReader(dbPath) if err != nil { t.Fatalf("OpenSQLiteReader: %v", err) } writer := sqlx.NewDb(writerRaw, "sqlite3") reader := sqlx.NewDb(readerRaw, "sqlite3") pool := db.NewPool(writer, reader) stmts := []string{ `CREATE TABLE IF NOT EXISTS kandev_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '')`, `INSERT OR REPLACE INTO kandev_meta (key, value) VALUES ('kandev_version', 'v0.99.0')`, `CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, payload BLOB)`, `CREATE TABLE IF NOT EXISTS sessions_t (id INTEGER PRIMARY KEY, data TEXT)`, } for _, s := range stmts { if _, err := writer.Exec(s); err != nil { t.Fatalf("seed %q: %v", s, err) } } // Insert ~100KB of churn to give VACUUM something to reclaim. for i := 0; i < 200; i++ { blob := make([]byte, 1024) for j := range blob { blob[j] = byte(j % 256) } if _, err := writer.Exec(`INSERT INTO users (name, payload) VALUES (?, ?)`, "row", blob); err != nil { t.Fatalf("insert: %v", err) } } if _, err := writer.Exec(`DELETE FROM users WHERE id % 2 = 0`); err != nil { t.Fatalf("delete: %v", err) } return pool, dbPath } // newTestService wires a Service with a Tracker over the stubBus. func newTestService(t *testing.T) (*Service, *jobs.Tracker, *stubBus, string) { t.Helper() tmp := t.TempDir() dataDir := filepath.Join(tmp, "data") if err := os.MkdirAll(dataDir, 0o755); err != nil { t.Fatalf("mkdir data: %v", err) } pool, _ := newTestPool(t, dataDir) t.Cleanup(func() { _ = pool.Close() }) stub := &stubBus{} log := newTestLogger(t) tracker := jobs.NewTracker(stub, log) dirs := ResetDirs{ Worktrees: filepath.Join(tmp, "worktrees"), Repos: filepath.Join(tmp, "repos"), Sessions: filepath.Join(tmp, "sessions"), Tasks: filepath.Join(tmp, "tasks"), QuickChat: filepath.Join(tmp, "quick-chat"), } for _, d := range []string{dirs.Worktrees, dirs.Repos, dirs.Sessions, dirs.Tasks, dirs.QuickChat} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatalf("mkdir %s: %v", d, err) } // Write a sentinel file so we can prove RemoveAll wiped it. if err := os.WriteFile(filepath.Join(d, "sentinel"), []byte("x"), 0o644); err != nil { t.Fatalf("write sentinel: %v", err) } } svc := NewService(pool, dataDir, dirs, tracker, log) return svc, tracker, stub, dataDir } // waitForState mirrors jobs.waitForState — wait until the tracker reports // the target state for the given id, or fail after 2s. func waitForState(t *testing.T, tracker *jobs.Tracker, id string, target jobs.State) *jobs.Job { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { j := tracker.Get(id) if j != nil && j.State == target { return j } time.Sleep(5 * time.Millisecond) } t.Fatalf("job %s did not reach state %s within 2s; last = %+v", id, target, tracker.Get(id)) return nil } func TestStats_ReturnsPathSizeAndSchemaVersion(t *testing.T) { svc, _, _, dataDir := newTestService(t) stats, err := svc.Stats() if err != nil { t.Fatalf("Stats: %v", err) } wantPath := filepath.Join(dataDir, "kandev.db") if stats.Path != wantPath { t.Errorf("Path = %q, want %q", stats.Path, wantPath) } if stats.SizeBytes <= 0 { t.Errorf("SizeBytes = %d, want > 0", stats.SizeBytes) } if stats.SchemaVersion != "v0.99.0" { t.Errorf("SchemaVersion = %q, want v0.99.0", stats.SchemaVersion) } if stats.LastBackupAt != nil { t.Errorf("LastBackupAt = %v, want nil (no backups yet)", *stats.LastBackupAt) } } func TestStats_LastBackupAtPicksNewestFile(t *testing.T) { svc, _, _, dataDir := newTestService(t) backupDir := filepath.Join(dataDir, "backups") if err := os.MkdirAll(backupDir, 0o755); err != nil { t.Fatalf("mkdir backups: %v", err) } older := filepath.Join(backupDir, "kandev-old.db") newer := filepath.Join(backupDir, "kandev-new.db") if err := os.WriteFile(older, []byte("a"), 0o644); err != nil { t.Fatalf("write older: %v", err) } earlier := time.Now().Add(-2 * time.Hour) if err := os.Chtimes(older, earlier, earlier); err != nil { t.Fatalf("chtimes: %v", err) } if err := os.WriteFile(newer, []byte("b"), 0o644); err != nil { t.Fatalf("write newer: %v", err) } stats, err := svc.Stats() if err != nil { t.Fatalf("Stats: %v", err) } if stats.LastBackupAt == nil { t.Fatalf("LastBackupAt should be set when a backup file exists") } if !stats.LastBackupAt.After(earlier.Add(time.Hour)) { t.Errorf("LastBackupAt %v should be more recent than %v", *stats.LastBackupAt, earlier) } } func TestHandleStats_Returns200JSON(t *testing.T) { svc, _, _, _ := newTestService(t) gin.SetMode(gin.TestMode) r := gin.New() r.GET("/db", HandleStats(svc)) req := httpGet(t, "/db") w := serveHTTP(r, req) if w.Code != 200 { t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) } body := w.Body.String() if !contains(body, `"path"`) || !contains(body, `"schema_version"`) { t.Errorf("body missing fields: %s", body) } }