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.
107 lines
2.7 KiB
Go
107 lines
2.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/internal/core"
|
|
"github.com/mithral/vellum/migrations"
|
|
)
|
|
|
|
func newTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
db, err := core.OpenDB(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("OpenDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
return New(db, log, "test")
|
|
}
|
|
|
|
func TestHealth(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body["status"] != "ok" {
|
|
t.Errorf("status = %v, want ok", body["status"])
|
|
}
|
|
if body["version"] != "test" {
|
|
t.Errorf("version = %v, want test", body["version"])
|
|
}
|
|
}
|
|
|
|
func TestNotFoundIsJSON(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/nope", nil)
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct == "" {
|
|
t.Error("missing Content-Type on 404")
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("404 body is not JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRequestIDHeaderAndLog(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Header().Get("X-Request-ID") == "" {
|
|
t.Error("X-Request-ID header not set")
|
|
}
|
|
|
|
// A caller-supplied ID must be preserved.
|
|
rec2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
req2.Header.Set("X-Request-ID", "abc123")
|
|
srv.Handler().ServeHTTP(rec2, req2)
|
|
if got := rec2.Header().Get("X-Request-ID"); got != "abc123" {
|
|
t.Errorf("X-Request-ID = %q, want abc123", got)
|
|
}
|
|
}
|
|
|
|
func TestRecoveryMiddleware(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
srv.Handle("GET /panic", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
panic("boom")
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/panic", nil)
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", rec.Code)
|
|
}
|
|
}
|