Scaffolds the server module and everything Phase 0 of PLAN.md calls for: a binary that boots, serves its discovery endpoints and nothing else yet. - config: one YAML file plus per-field WARP_* environment overrides, unknown fields rejected, validation that reports every problem at once - logging and metrics: slog, a Prometheus registry, per-route HTTP instrumentation labelled by pattern rather than path - httputil: the Matrix error envelope, error-returning handlers, CORS, request logging, panic recovery, and a router over http.ServeMux - endpoints: /_matrix/client/versions, /.well-known/matrix/client, /.well-known/matrix/server (gated), /healthz, /metrics, and M_UNRECOGNIZED for everything else - storage: driver registry, shared database/sql base, checksummed migration runner, sqlite and postgres drivers, pocketbase stub - settings: server_settings table behind a cached service, so the federation and registration switches change without a restart - federation: the gate that makes federation a runtime mode - cmd/warp: serve, migrate, generate-keys, version - Makefile, golangci-lint v2, GitHub Actions CI (Linux and Windows tests, race on Linux, storage tests against PostgreSQL, lint, boot smoke test), Dockerfile, example config and docs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
4.9 KiB
Go
176 lines
4.9 KiB
Go
package migrations_test
|
|
|
|
import (
|
|
"database/sql"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/storage/migrations"
|
|
)
|
|
|
|
var dialect = migrations.Dialect{
|
|
Name: "sqlite",
|
|
CreateTable: `CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY, name TEXT NOT NULL,
|
|
checksum TEXT NOT NULL, applied_at TIMESTAMP NOT NULL)`,
|
|
SelectApplied: `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`,
|
|
InsertApplied: `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`,
|
|
}
|
|
|
|
func openDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
path := filepath.ToSlash(filepath.Join(t.TempDir(), "test.db"))
|
|
db, err := sql.Open("sqlite", "file:"+path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return db
|
|
}
|
|
|
|
func fsWith(files map[string]string) fstest.MapFS {
|
|
out := fstest.MapFS{}
|
|
for name, body := range files {
|
|
out["migrations/"+name] = &fstest.MapFile{Data: []byte(body)}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestLoadOrdersByVersionAndParsesNames(t *testing.T) {
|
|
ms, err := migrations.Load(fsWith(map[string]string{
|
|
"0002_devices.sql": "CREATE TABLE devices (id TEXT);",
|
|
"0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT);",
|
|
"notes.txt": "ignored",
|
|
}), "migrations")
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if len(ms) != 2 {
|
|
t.Fatalf("loaded %d migrations, want 2", len(ms))
|
|
}
|
|
if ms[0].Version != 1 || ms[0].Name != "server_settings" {
|
|
t.Errorf("first migration = %d/%s", ms[0].Version, ms[0].Name)
|
|
}
|
|
if ms[1].Version != 2 || ms[1].Name != "devices" {
|
|
t.Errorf("second migration = %d/%s", ms[1].Version, ms[1].Name)
|
|
}
|
|
if ms[0].Checksum == "" || ms[0].Checksum == ms[1].Checksum {
|
|
t.Error("each migration needs its own checksum")
|
|
}
|
|
}
|
|
|
|
func TestLoadRejectsBadFilenames(t *testing.T) {
|
|
for _, name := range []string{"nope.sql", "abc_x.sql", "0000_zero.sql"} {
|
|
if _, err := migrations.Load(fsWith(map[string]string{name: "SELECT 1;"}), "migrations"); err == nil {
|
|
t.Errorf("Load(%s) = nil error, want a complaint", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunIsIdempotent(t *testing.T) {
|
|
db := openDB(t)
|
|
fsys := fsWith(map[string]string{
|
|
"0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT PRIMARY KEY);",
|
|
})
|
|
ms, err := migrations.Load(fsys, "migrations")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
n, err := migrations.Run(t.Context(), db, dialect, ms, nil)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("applied %d migrations, want 1", n)
|
|
}
|
|
|
|
n, err = migrations.Run(t.Context(), db, dialect, ms, nil)
|
|
if err != nil {
|
|
t.Fatalf("second Run: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("second Run applied %d migrations, want 0", n)
|
|
}
|
|
}
|
|
|
|
func TestRunDetectsEditedMigration(t *testing.T) {
|
|
db := openDB(t)
|
|
before, err := migrations.Load(fsWith(map[string]string{
|
|
"0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT PRIMARY KEY);",
|
|
}), "migrations")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := migrations.Run(t.Context(), db, dialect, before, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
after, err := migrations.Load(fsWith(map[string]string{
|
|
"0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT PRIMARY KEY, v TEXT);",
|
|
}), "migrations")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = migrations.Run(t.Context(), db, dialect, after, nil)
|
|
if err == nil {
|
|
t.Fatal("expected an error for an edited migration")
|
|
}
|
|
if !strings.Contains(err.Error(), "has changed since it was applied") {
|
|
t.Errorf("unhelpful error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunRollsBackFailedMigration(t *testing.T) {
|
|
db := openDB(t)
|
|
ms, err := migrations.Load(fsWith(map[string]string{
|
|
"0001_ok.sql": "CREATE TABLE a (k TEXT);",
|
|
"0002_broken.sql": "CREATE TABLE b (k TEXT); THIS IS NOT SQL;",
|
|
}), "migrations")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
n, err := migrations.Run(t.Context(), db, dialect, ms, nil)
|
|
if err == nil {
|
|
t.Fatal("expected the broken migration to fail")
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("applied %d migrations before failing, want 1", n)
|
|
}
|
|
|
|
// The failed migration must leave nothing behind.
|
|
var count int
|
|
if err := db.QueryRowContext(t.Context(),
|
|
`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'b'`).Scan(&count); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 0 {
|
|
t.Error("table from the failed migration was not rolled back")
|
|
}
|
|
|
|
pending, err := migrations.Pending(t.Context(), db, dialect, ms)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(pending) != 1 || pending[0].Version != 2 {
|
|
t.Errorf("pending = %v, want just migration 2", pending)
|
|
}
|
|
}
|
|
|
|
func TestCheckNotAhead(t *testing.T) {
|
|
applied := []migrations.Applied{{Version: 7, Name: "future"}}
|
|
ms := []migrations.Migration{{Version: 1, Name: "server_settings"}}
|
|
|
|
if err := migrations.CheckNotAhead(applied, ms); err == nil {
|
|
t.Fatal("expected a dirty-schema error")
|
|
}
|
|
if err := migrations.CheckNotAhead(nil, ms); err != nil {
|
|
t.Errorf("fresh database must be fine, got %v", err)
|
|
}
|
|
}
|