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>
This commit is contained in:
parent
382356c61c
commit
df2253398b
10 changed files with 738 additions and 8 deletions
|
|
@ -1,6 +0,0 @@
|
|||
// Package store opens the SQLite-backed persistence layer used by the broker
|
||||
// for OAuth clients, authorization codes, access tokens, and refresh tokens.
|
||||
// Migrations are embedded via embed.FS and applied on open.
|
||||
//
|
||||
// Implementation lands in forgejo-mcp-broker-9jh.
|
||||
package store
|
||||
158
internal/store/migrate.go
Normal file
158
internal/store/migrate.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// migration is a parsed migration file: an integer version extracted from
|
||||
// the filename (e.g. "0001_initial.sql" → 1) plus the raw SQL body.
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
sql string
|
||||
}
|
||||
|
||||
// migrate brings the database up to the latest migration found in fsys
|
||||
// under dir. The schema_migrations table tracks applied versions so
|
||||
// re-running is a no-op. Each migration runs inside its own transaction:
|
||||
// partial progress is never committed.
|
||||
//
|
||||
// fsys and dir are parameters (rather than using the package-level embedded
|
||||
// FS directly) so tests can inject synthetic migration sets.
|
||||
func (s *Store) migrate(ctx context.Context, fsys fs.FS, dir string) error {
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
applied, err := loadAppliedVersions(ctx, s.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrations, err := loadMigrations(fsys, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if applied[m.version] {
|
||||
continue
|
||||
}
|
||||
if err := applyMigration(ctx, s.db, m); err != nil {
|
||||
return fmt.Errorf("apply migration %s: %w", m.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadAppliedVersions(ctx context.Context, db *sql.DB) (map[int]bool, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read schema_migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := make(map[int]bool)
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, fmt.Errorf("scan schema_migrations: %w", err)
|
||||
}
|
||||
applied[v] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iter schema_migrations: %w", err)
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// loadMigrations reads every *.sql file from dir inside fsys, parses its
|
||||
// version prefix, and returns the migrations sorted ascending by version.
|
||||
// Taking an fs.FS makes the function trivially testable with fstest.MapFS.
|
||||
func loadMigrations(fsys fs.FS, dir string) ([]migration, error) {
|
||||
entries, err := fs.ReadDir(fsys, dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir %q: %w", dir, err)
|
||||
}
|
||||
|
||||
var out []migration
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
v, err := parseMigrationVersion(e.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := fs.ReadFile(fsys, dir+"/"+e.Name())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration %s: %w", e.Name(), err)
|
||||
}
|
||||
out = append(out, migration{version: v, name: e.Name(), sql: string(body)})
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
|
||||
|
||||
// Sanity: reject duplicate versions — a typo in filenames could otherwise
|
||||
// silently skip a migration.
|
||||
for i := 1; i < len(out); i++ {
|
||||
if out[i].version == out[i-1].version {
|
||||
return nil, fmt.Errorf("duplicate migration version %d in %s and %s",
|
||||
out[i].version, out[i-1].name, out[i].name)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseMigrationVersion(name string) (int, error) {
|
||||
// Expect "NNNN_name.sql" — strip the suffix and take the leading numeric
|
||||
// prefix. Be strict so typos aren't silently accepted.
|
||||
base := strings.TrimSuffix(name, ".sql")
|
||||
underscore := strings.IndexByte(base, '_')
|
||||
if underscore <= 0 {
|
||||
return 0, fmt.Errorf("migration %q: expected NNNN_name.sql", name)
|
||||
}
|
||||
v, err := strconv.Atoi(base[:underscore])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("migration %q: non-numeric version: %w", name, err)
|
||||
}
|
||||
if v <= 0 {
|
||||
return 0, fmt.Errorf("migration %q: version must be > 0", name)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, db *sql.DB, m migration) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, m.sql); err != nil {
|
||||
return errors.Join(fmt.Errorf("exec: %w", err), tx.Rollback())
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO schema_migrations (version, name) VALUES (?, ?)`,
|
||||
m.version, m.name); err != nil {
|
||||
return errors.Join(fmt.Errorf("record: %w", err), tx.Rollback())
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
100
internal/store/migrate_internal_test.go
Normal file
100
internal/store/migrate_internal_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestParseMigrationVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
want int
|
||||
wantErr string
|
||||
}{
|
||||
{"0001_initial.sql", 1, ""},
|
||||
{"0042_add_oauth_clients.sql", 42, ""},
|
||||
{"0001.sql", 0, "expected NNNN_name.sql"}, // no underscore
|
||||
{"_orphan.sql", 0, "expected NNNN_name.sql"}, // empty version
|
||||
{"abc_notnumeric.sql", 0, "non-numeric version"}, // not a number
|
||||
{"0000_zero.sql", 0, "version must be > 0"}, // zero rejected
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseMigrationVersion(tc.name)
|
||||
switch {
|
||||
case tc.wantErr == "" && err != nil:
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
case tc.wantErr != "" && err == nil:
|
||||
t.Errorf("expected error containing %q, got nil", tc.wantErr)
|
||||
case tc.wantErr != "" && !strings.Contains(err.Error(), tc.wantErr):
|
||||
t.Errorf("error %q does not contain %q", err, tc.wantErr)
|
||||
case tc.wantErr == "" && got != tc.want:
|
||||
t.Errorf("got %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMigrations_SortsByVersion(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"m/0003_c.sql": {Data: []byte("-- c")},
|
||||
"m/0001_a.sql": {Data: []byte("-- a")},
|
||||
"m/0002_b.sql": {Data: []byte("-- b")},
|
||||
}
|
||||
got, err := loadMigrations(fsys, "m")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d migrations, want 3", len(got))
|
||||
}
|
||||
for i, want := range []int{1, 2, 3} {
|
||||
if got[i].version != want {
|
||||
t.Errorf("migrations[%d].version = %d, want %d", i, got[i].version, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMigrations_SkipsNonSQL(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"m/0001_a.sql": {Data: []byte("-- a")},
|
||||
"m/README.md": {Data: []byte("ignore me")},
|
||||
"m/notes.txt": {Data: []byte("ignore me too")},
|
||||
}
|
||||
got, err := loadMigrations(fsys, "m")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Errorf("got %d migrations, want 1 (non-.sql must be skipped)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMigrations_RejectsDuplicateVersion(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"m/0001_a.sql": {Data: []byte("-- a")},
|
||||
"m/0001_b.sql": {Data: []byte("-- b")},
|
||||
}
|
||||
_, err := loadMigrations(fsys, "m")
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate migration version 1") {
|
||||
t.Errorf("want duplicate-version error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMigrations_RejectsBadFilename(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"m/nogood.sql": {Data: []byte("-- bad")},
|
||||
}
|
||||
_, err := loadMigrations(fsys, "m")
|
||||
if err == nil || !strings.Contains(err.Error(), "expected NNNN_name.sql") {
|
||||
t.Errorf("want bad-filename error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMigrations_MissingDir(t *testing.T) {
|
||||
_, err := loadMigrations(fstest.MapFS{}, "nope")
|
||||
if err == nil || !strings.Contains(err.Error(), "read migrations dir") {
|
||||
t.Errorf("want missing-dir error, got %v", err)
|
||||
}
|
||||
}
|
||||
11
internal/store/migrations/0001_initial.sql
Normal file
11
internal/store/migrations/0001_initial.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- 0001_initial.sql
|
||||
-- Bootstraps the broker metadata table. Real OAuth schema (clients,
|
||||
-- auth_codes, access_tokens, refresh_tokens) lands in phase 2.
|
||||
|
||||
CREATE TABLE broker_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO broker_meta (key, value) VALUES
|
||||
('schema_version', '1');
|
||||
83
internal/store/store.go
Normal file
83
internal/store/store.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Package store opens the SQLite-backed persistence layer used by the broker
|
||||
// for OAuth clients, authorization codes, access tokens, and refresh tokens.
|
||||
//
|
||||
// Migrations are embedded under migrations/NNNN_name.sql and applied in
|
||||
// lexicographic order on Open. A schema_migrations table tracks which
|
||||
// versions have been applied; re-opening an up-to-date database is a no-op.
|
||||
//
|
||||
// The current schema (phase 1) only contains a broker_meta table. The real
|
||||
// OAuth tables ship with phase 2.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite" // registers the "sqlite" database/sql driver
|
||||
)
|
||||
|
||||
// Store wraps a *sql.DB opened against a SQLite file, with schema migrations
|
||||
// already applied. It is safe for concurrent use.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
// Open opens (or creates) the SQLite database at path, enables WAL mode and
|
||||
// foreign-key enforcement, then applies any pending schema migrations. The
|
||||
// returned Store must be closed with Close to release file handles.
|
||||
func Open(ctx context.Context, path string) (*Store, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: resolve path %q: %w", path, err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", buildDSN(abs))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: open %q: %w", abs, err)
|
||||
}
|
||||
|
||||
// Fail early if the path is unreachable (e.g. parent dir missing). sql.Open
|
||||
// is lazy and won't touch the file until first use.
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("store: ping %q: %w", abs, err)
|
||||
}
|
||||
|
||||
s := &Store{db: db, path: abs}
|
||||
if err := s.migrate(ctx, migrationsFS, "migrations"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("store: migrate %q: %w", abs, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// DB exposes the underlying *sql.DB for packages that need to issue queries.
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
// Path returns the absolute path of the SQLite file.
|
||||
func (s *Store) Path() string { return s.path }
|
||||
|
||||
// Ping verifies connectivity. Suitable for use in /healthz.
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.db.PingContext(ctx)
|
||||
}
|
||||
|
||||
// Close releases the underlying database handle.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// buildDSN constructs a modernc.org/sqlite DSN with the pragmas we always
|
||||
// want: WAL for better reader/writer concurrency, foreign-key enforcement
|
||||
// (off by default in SQLite), and a 5-second busy timeout to absorb brief
|
||||
// contention without surfacing SQLITE_BUSY to the app.
|
||||
func buildDSN(path string) string {
|
||||
q := url.Values{}
|
||||
q.Add("_pragma", "journal_mode(WAL)")
|
||||
q.Add("_pragma", "foreign_keys(ON)")
|
||||
q.Add("_pragma", "busy_timeout(5000)")
|
||||
q.Add("_pragma", "synchronous(NORMAL)")
|
||||
return "file:" + path + "?" + q.Encode()
|
||||
}
|
||||
170
internal/store/store_internal_test.go
Normal file
170
internal/store/store_internal_test.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// openRawDB opens a raw *sql.DB to a fresh temp SQLite file and wraps it in
|
||||
// a Store — without applying the real embedded migrations. Tests use it to
|
||||
// exercise migrate() with synthetic migration sets.
|
||||
func openRawDB(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "broker.db")
|
||||
db, err := sql.Open("sqlite", buildDSN(path))
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return &Store{db: db, path: path}
|
||||
}
|
||||
|
||||
func TestMigrate_AppliesOnlyPendingVersions(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
s := openRawDB(t)
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"m/0001_a.sql": {Data: []byte("CREATE TABLE t1 (x INTEGER);")},
|
||||
"m/0002_b.sql": {Data: []byte("CREATE TABLE t2 (y INTEGER);")},
|
||||
}
|
||||
|
||||
if err := s.migrate(ctx, fsys, "m"); err != nil {
|
||||
t.Fatalf("first migrate: %v", err)
|
||||
}
|
||||
|
||||
// Re-running is a no-op; both migrations already recorded.
|
||||
if err := s.migrate(ctx, fsys, "m"); err != nil {
|
||||
t.Fatalf("second migrate: %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM schema_migrations`).Scan(&count); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("applied count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_BadSQLRollsBack(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
s := openRawDB(t)
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"m/0001_good.sql": {Data: []byte("CREATE TABLE ok (x INTEGER);")},
|
||||
"m/0002_bad.sql": {Data: []byte("THIS IS NOT SQL")},
|
||||
}
|
||||
|
||||
err := s.migrate(ctx, fsys, "m")
|
||||
if err == nil {
|
||||
t.Fatal("expected migrate to fail on bad SQL")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "0002_bad.sql") {
|
||||
t.Errorf("error should mention failing migration: %v", err)
|
||||
}
|
||||
|
||||
// 0001 should have been applied and committed; 0002 should not.
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM schema_migrations`).Scan(&count); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("after failed migrate, applied count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_MalformedFilenameFails(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
s := openRawDB(t)
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"m/no_number.sql": {Data: []byte("SELECT 1;")},
|
||||
}
|
||||
err := s.migrate(ctx, fsys, "m")
|
||||
if err == nil || !strings.Contains(err.Error(), "non-numeric") {
|
||||
t.Errorf("want malformed-filename error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_LoadAppliedVersionsOnClosedDBFails(t *testing.T) {
|
||||
// Exercise the error path in loadAppliedVersions by closing the DB
|
||||
// before migrate reads schema_migrations.
|
||||
ctx := t.Context()
|
||||
s := openRawDB(t)
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
// Close so the subsequent query fails.
|
||||
if err := s.db.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
_, err := loadAppliedVersions(ctx, s.db)
|
||||
if err == nil {
|
||||
t.Error("expected error from loadAppliedVersions on closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_RecordStepFails_OnPKConflict(t *testing.T) {
|
||||
// A migration that manually inserts its own version row forces
|
||||
// applyMigration's own INSERT into schema_migrations to fail with a
|
||||
// primary-key conflict. Exercises the record-step error branch.
|
||||
ctx := t.Context()
|
||||
s := openRawDB(t)
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
"m/0001_hijack.sql": {Data: []byte(
|
||||
`INSERT INTO schema_migrations (version, name) VALUES (1, 'hijack');`)},
|
||||
}
|
||||
err := s.migrate(ctx, fsys, "m")
|
||||
if err == nil || !strings.Contains(err.Error(), "record") {
|
||||
t.Errorf("want record-fail error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliedVersions_ScanError(t *testing.T) {
|
||||
// Seed a schema_migrations table with a non-integer version so Scan
|
||||
// into *int fails. Exercises loadAppliedVersions' scan-error branch.
|
||||
ctx := t.Context()
|
||||
s := openRawDB(t)
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `CREATE TABLE schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO schema_migrations VALUES ('not-a-number', 'x', 'now')`); err != nil {
|
||||
t.Fatalf("seed row: %v", err)
|
||||
}
|
||||
|
||||
if _, err := loadAppliedVersions(ctx, s.db); err == nil {
|
||||
t.Error("expected scan error on non-int version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrate_CanceledContext(t *testing.T) {
|
||||
s := openRawDB(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
err := s.migrate(ctx, fstest.MapFS{"m/0001_a.sql": {Data: []byte("SELECT 1;")}}, "m")
|
||||
if err == nil {
|
||||
t.Error("expected error on canceled context")
|
||||
}
|
||||
}
|
||||
149
internal/store/store_test.go
Normal file
149
internal/store/store_test.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue