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/*.
108 lines
2.7 KiB
Go
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)
|
|
}
|
|
}
|