forgejo-mcp-broker/internal/store/store_test.go
Ole-Morten Duesund df2253398b feat(store): SQLite with embedded migrations (forgejo-mcp-broker-9jh)
Implements internal/store on top of modernc.org/sqlite (pure-Go, no CGO).
Open applies any pending migrations, Close releases the handle, Ping
underpins /healthz.

Migration design:
- Files embedded via embed.FS under migrations/NNNN_name.sql
- schema_migrations table tracks applied versions; re-open is a no-op
- Each migration runs in its own transaction: no partial commits
- loadMigrations takes an fs.FS so tests can inject synthetic migration
  sets to exercise rollback and conflict paths

Connection pragmas (set via DSN so they apply to every pooled conn):
- journal_mode=WAL — better reader/writer concurrency
- foreign_keys=ON — off by default in SQLite, we always want them
- busy_timeout=5000 — absorb brief contention without surfacing SQLITE_BUSY
- synchronous=NORMAL — standard WAL pairing

Phase 1 schema (0001_initial.sql) is minimal: a broker_meta table with a
schema_version row. Real OAuth tables ship in phase 2.

Tests: 90.1% coverage across public API and internal migration runner,
including bad SQL rollback, PK-conflict record-step failure, and scan
errors on malformed schema_migrations rows.

Closes forgejo-mcp-broker-9jh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:22:47 +02:00

149 lines
3.6 KiB
Go

package store_test
import (
"context"
"errors"
"path/filepath"
"testing"
"kode.naiv.no/olemd/forgejo-mcp-broker/internal/store"
)
func tempStorePath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "broker.db")
}
func TestOpen_FreshDB_AppliesMigrations(t *testing.T) {
ctx := t.Context()
s, err := store.Open(ctx, tempStorePath(t))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer s.Close()
// broker_meta should exist and carry the schema_version row.
var version string
row := s.DB().QueryRowContext(ctx,
`SELECT value FROM broker_meta WHERE key = ?`, "schema_version")
if err := row.Scan(&version); err != nil {
t.Fatalf("reading schema_version: %v", err)
}
if version != "1" {
t.Errorf("schema_version = %q, want %q", version, "1")
}
// schema_migrations should have recorded the initial migration.
var count int
row = s.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations`)
if err := row.Scan(&count); err != nil {
t.Fatalf("counting schema_migrations: %v", err)
}
if count != 1 {
t.Errorf("schema_migrations row count = %d, want 1", count)
}
}
func TestOpen_ReopenIsIdempotent(t *testing.T) {
ctx := t.Context()
path := tempStorePath(t)
s1, err := store.Open(ctx, path)
if err != nil {
t.Fatalf("first Open: %v", err)
}
if err := s1.Close(); err != nil {
t.Fatalf("first Close: %v", err)
}
s2, err := store.Open(ctx, path)
if err != nil {
t.Fatalf("second Open: %v", err)
}
defer s2.Close()
// Still exactly one applied migration.
var count int
if err := s2.DB().QueryRowContext(ctx,
`SELECT COUNT(*) FROM schema_migrations`).Scan(&count); err != nil {
t.Fatalf("counting schema_migrations: %v", err)
}
if count != 1 {
t.Errorf("after reopen, schema_migrations count = %d, want 1", count)
}
}
func TestOpen_NonexistentParent(t *testing.T) {
// Config.Validate normally catches this, but the store must still fail
// clearly when called with an unreachable path.
ctx := t.Context()
path := filepath.Join(t.TempDir(), "does", "not", "exist", "broker.db")
s, err := store.Open(ctx, path)
if err == nil {
_ = s.Close()
t.Fatal("Open should fail for nonexistent parent directory")
}
}
func TestPing_AfterOpen(t *testing.T) {
ctx := t.Context()
s, err := store.Open(ctx, tempStorePath(t))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer s.Close()
if err := s.Ping(ctx); err != nil {
t.Errorf("Ping after Open failed: %v", err)
}
}
func TestPing_AfterClose(t *testing.T) {
ctx := t.Context()
s, err := store.Open(ctx, tempStorePath(t))
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := s.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if err := s.Ping(ctx); err == nil {
t.Error("Ping after Close should fail")
}
}
func TestPing_CanceledContext(t *testing.T) {
ctx := t.Context()
s, err := store.Open(ctx, tempStorePath(t))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer s.Close()
cctx, cancel := context.WithCancel(ctx)
cancel()
if err := s.Ping(cctx); err == nil {
t.Error("Ping with canceled context should fail")
} else if !errors.Is(err, context.Canceled) {
// PingContext should surface the cancellation — not a hard requirement
// since driver behavior varies slightly, so log rather than fail.
t.Logf("Ping error not context.Canceled (ok): %v", err)
}
}
func TestPath_ReturnsAbsolute(t *testing.T) {
ctx := t.Context()
// Use a relative path to confirm Store.Path returns an absolute one.
dir := t.TempDir()
t.Chdir(dir)
s, err := store.Open(ctx, "broker.db")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer s.Close()
if !filepath.IsAbs(s.Path()) {
t.Errorf("Path = %q, want absolute", s.Path())
}
}