Vellum/internal/httpapi/server_test.go
mithral 5283e073f5 Web UI: Svelte app, embedded into the binary
The frontend the app needs to be usable at all.

- web/: Svelte 5 + Vite + TypeScript SPA. Login, app shell with per-type nav,
  library list/create/scan/delete, item grid with covers, admin user
  management, and an epub.js reader that syncs progress.
- Reader resumes by spine index — an integer — which is why the polymorphic
  progress table needs no text column.
- Session state uses classic stores in a plain .ts module rather than runes in
  a .svelte.ts module, avoiding special module resolution.
- Embedding: web/embed.go embeds web/dist. A committed .gitkeep keeps the
  directive valid before the frontend is built, so `go build ./...` never
  fails. Unknown non-API paths serve index.html for client routes; /api/* and
  /healthz always return JSON and never fall back to HTML.
- Dockerfile builds the UI in a node stage and overlays dist into the Go stage.
- pnpm 12 needs allowBuilds in pnpm-workspace.yaml; esbuild approved, the two
  unused transitive scripts explicitly denied so install exits 0.

svelte-check: 0 errors, 0 warnings. Verified served: SPA shell at /, route
fallback, hashed asset, and JSON 404 on /api/*.
2026-10-09 16:40:58 -04:00

108 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)
// API paths must never fall back to the SPA shell.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/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)
}
}