Bootstraps Vellum as a single Go binary with one SQLite database. - core: env-driven config, slog logging, SQLite (WAL) with pooled pragmas, embedded migration runner keyed on PRAGMA user_version - migrations/0001_core.sql: identity, media, user state, infra. Media items are global; all per-user state (progress, favorites, grants) is a join. One polymorphic progress table covers every media kind. - httpapi: request-ID, structured request logging, panic recovery, JSON endpoints for /healthz and /api/v1/version - multi-arch Dockerfile (pure-Go SQLite, CGO_ENABLED=0) with an entrypoint that honours PUID/PGID like the rest of the self-hosted stack - tests for migration idempotency, FK enforcement, WAL, config, middleware Media does not exist yet. Nothing is exposed beyond health and version.
124 lines
3.1 KiB
Go
124 lines
3.1 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"io"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/migrations"
|
|
)
|
|
|
|
func discardLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
func openTempDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := OpenDB(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("OpenDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return db
|
|
}
|
|
|
|
func TestMigrateAppliesAndIsIdempotent(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTempDB(t)
|
|
|
|
if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil {
|
|
t.Fatalf("first Migrate: %v", err)
|
|
}
|
|
|
|
// Running again must be a no-op, not an error.
|
|
if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil {
|
|
t.Fatalf("second Migrate (should be no-op): %v", err)
|
|
}
|
|
|
|
var version int
|
|
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
|
|
t.Fatalf("read user_version: %v", err)
|
|
}
|
|
if version != 1 {
|
|
t.Errorf("user_version = %d, want 1", version)
|
|
}
|
|
|
|
var tables int
|
|
if err := db.QueryRowContext(ctx,
|
|
"SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tables); err != nil {
|
|
t.Fatalf("count tables: %v", err)
|
|
}
|
|
if tables < 16 {
|
|
t.Errorf("table count = %d, want >= 16", tables)
|
|
}
|
|
|
|
// WAL must be active: progress writes during a scan depend on it.
|
|
var mode string
|
|
if err := db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&mode); err != nil {
|
|
t.Fatalf("read journal_mode: %v", err)
|
|
}
|
|
if !strings.EqualFold(mode, "wal") {
|
|
t.Errorf("journal_mode = %q, want wal", mode)
|
|
}
|
|
}
|
|
|
|
func TestForeignKeyEnforcementIsOn(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTempDB(t)
|
|
if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
|
|
// library_grants references users and libraries; neither 999 exists.
|
|
_, err := db.ExecContext(ctx,
|
|
"INSERT INTO library_grants(user_id, library_id) VALUES (999, 999)")
|
|
if err == nil {
|
|
t.Fatal("expected foreign key violation, got nil")
|
|
}
|
|
}
|
|
|
|
func TestMigrationNumber(t *testing.T) {
|
|
cases := map[string]int{
|
|
"0001_core.sql": 1,
|
|
"0042_audiobooks.sql": 42,
|
|
"7_shorthand.sql": 7,
|
|
"0010_no_ext": 10,
|
|
}
|
|
for name, want := range cases {
|
|
got, err := migrationNumber(name)
|
|
if err != nil {
|
|
t.Errorf("migrationNumber(%q): %v", name, err)
|
|
continue
|
|
}
|
|
if got != want {
|
|
t.Errorf("migrationNumber(%q) = %d, want %d", name, got, want)
|
|
}
|
|
}
|
|
|
|
if _, err := migrationNumber("core.sql"); err == nil {
|
|
t.Error("expected error for filename without a numeric prefix")
|
|
}
|
|
}
|
|
|
|
func TestSplitStatementsIgnoresComments(t *testing.T) {
|
|
in := `
|
|
-- a comment with a semicolon; which must not split
|
|
CREATE TABLE a (id INTEGER); -- trailing comment;
|
|
/* block; comment; here */
|
|
CREATE TABLE b (id INTEGER);
|
|
`
|
|
got := splitStatements(in)
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d statements, want 2: %#v", len(got), got)
|
|
}
|
|
if !strings.Contains(got[0], "CREATE TABLE a") {
|
|
t.Errorf("statement 0 = %q", got[0])
|
|
}
|
|
if !strings.Contains(got[1], "CREATE TABLE b") {
|
|
t.Errorf("statement 1 = %q", got[1])
|
|
}
|
|
}
|