220 lines
6.7 KiB
Go
220 lines
6.7 KiB
Go
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 <dataDir>/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)
|
|
}
|
|
}
|