From 77e37cb21b024133bc8f08846dddb1b81d602f53 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 18:14:26 +0200 Subject: [PATCH] feat(07-01): embed.go + RunMigrations + HealthzHandler()/ReadyzHandler() split - backend/embed.go: package assets with //go:embed all:static and //go:embed migrations - backend/internal/db/migrate.go: RunMigrations using pgx/v5/stdlib bridge to goose.Up() - backend/internal/web/handlers.go: HealthzHandler() no-arg liveness + ReadyzHandler(pinger) readiness - backend/internal/web/handlers_test.go: TestHealthz_OK (no pinger), TestReadyz_OK, TestReadyz_Down added; TestHealthz_Down deleted --- backend/embed.go | 22 ++++++++++++++ backend/internal/db/migrate.go | 44 +++++++++++++++++++++++++++ backend/internal/web/handlers.go | 19 ++++++++++-- backend/internal/web/handlers_test.go | 40 +++++++++++++++++++----- 4 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 backend/embed.go create mode 100644 backend/internal/db/migrate.go diff --git a/backend/embed.go b/backend/embed.go new file mode 100644 index 0000000..2570bf5 --- /dev/null +++ b/backend/embed.go @@ -0,0 +1,22 @@ +// Package assets exposes the embedded static assets and SQL migrations that are +// baked into the binary at build time. Importing packages reference these via: +// +// import assets "backend" +// +// and then use assets.Static (for file serving) and assets.Migrations (for goose). +package assets + +import "embed" + +// Static holds all files under the static/ directory, embedded at build time. +// The all: prefix includes files whose names begin with . or _ (e.g. .gitkeep). +// +//go:embed all:static +var Static embed.FS + +// Migrations holds all .sql files under the migrations/ directory, embedded at +// build time. goose.SetBaseFS(Migrations) + goose.Up(sqlDB, "migrations") uses +// this FS so the binary has zero runtime file dependencies. +// +//go:embed migrations +var Migrations embed.FS diff --git a/backend/internal/db/migrate.go b/backend/internal/db/migrate.go new file mode 100644 index 0000000..34e077f --- /dev/null +++ b/backend/internal/db/migrate.go @@ -0,0 +1,44 @@ +package db + +import ( + "context" + "database/sql" + "embed" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/jackc/pgx/v5/stdlib" // pgx/v5 driver for database/sql + "github.com/pressly/goose/v3" +) + +// RunMigrations applies all pending goose migrations from the embedded FS. +// It uses the pgx/v5/stdlib bridge so that goose's database/sql interface +// can speak to the same Postgres as the rest of the application. +// +// Call RunMigrations once at startup, before constructing the router. +// goose.Up is idempotent — already-applied migrations are skipped. +func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error { + // Extract the DSN from the pool's connection config. + connConfig := pool.Config().ConnConfig + dsn := connConfig.ConnString() + + // Open a database/sql connection for goose. The pgx/v5 stdlib driver is + // registered by the blank import above. + sqlDB, err := sql.Open("pgx/v5", dsn) + if err != nil { + return fmt.Errorf("migrate: open sql.DB: %w", err) + } + defer sqlDB.Close() + + // Point goose at the embedded FS so no on-disk files are needed. + goose.SetBaseFS(migrationsFS) + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("migrate: set dialect: %w", err) + } + + if err := goose.Up(sqlDB, "migrations"); err != nil { + return fmt.Errorf("migrate: goose up: %w", err) + } + + return nil +} diff --git a/backend/internal/web/handlers.go b/backend/internal/web/handlers.go index bad9dca..391ca0a 100644 --- a/backend/internal/web/handlers.go +++ b/backend/internal/web/handlers.go @@ -9,11 +9,24 @@ import ( "backend/templates" ) -// HealthzHandler returns an HTTP handler that probes the supplied Pinger -// with a 2-second timeout and responds per CONTEXT D-20: 200 + JSON +// HealthzHandler returns a pure liveness probe handler (D-12). It responds +// immediately with 200 + `{"status":"ok"}` and never contacts the database. +// Use /readyz for a DB-aware readiness probe. +func HealthzHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + }) + } +} + +// ReadyzHandler returns an HTTP handler that probes the supplied Pinger with a +// 2-second timeout and responds per CONTEXT D-13: 200 + JSON // `{"status":"ok","db":"ok"}` when reachable, 503 + JSON // `{"status":"degraded","db":"down"}` otherwise. -func HealthzHandler(pinger Pinger) http.HandlerFunc { +func ReadyzHandler(pinger Pinger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() diff --git a/backend/internal/web/handlers_test.go b/backend/internal/web/handlers_test.go index 54cf20d..e3676f8 100644 --- a/backend/internal/web/handlers_test.go +++ b/backend/internal/web/handlers_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "os" "regexp" "strings" "testing" @@ -25,7 +26,32 @@ func TestHealthz_OK(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) - HealthzHandler(stubPinger{err: nil}).ServeHTTP(rec, req) + // HealthzHandler takes no args — pure liveness, no DB ping (D-12). + HealthzHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Errorf("Content-Type = %q; want application/json", ct) + } + body := rec.Body.String() + if !strings.Contains(body, `"status":"ok"`) { + t.Errorf("body missing status:ok; got: %s", body) + } + // Liveness endpoint must NOT expose db field (T-07-01: no internal info in responses). + if strings.Contains(body, `"db"`) { + t.Errorf("liveness body must not contain db field; got: %s", body) + } +} + +// TestHealthz_Down is deleted — new HealthzHandler has no failure mode (D-12). + +func TestReadyz_OK(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/readyz", nil) + + ReadyzHandler(stubPinger{err: nil}).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d; want 200", rec.Code) @@ -42,11 +68,11 @@ func TestHealthz_OK(t *testing.T) { } } -func TestHealthz_Down(t *testing.T) { +func TestReadyz_Down(t *testing.T) { rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + req := httptest.NewRequest(http.MethodGet, "/readyz", nil) - HealthzHandler(stubPinger{err: errors.New("conn refused")}).ServeHTTP(rec, req) + ReadyzHandler(stubPinger{err: errors.New("conn refused")}).ServeHTTP(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d; want 503", rec.Code) @@ -66,7 +92,7 @@ func TestHealthz_Down(t *testing.T) { // was public. The HTMX demo content is tested by // TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go. func TestIndex_UnauthRedirects(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -81,7 +107,7 @@ func TestIndex_UnauthRedirects(t *testing.T) { } func TestDemoTime_Fragment(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/demo/time", nil) @@ -104,7 +130,7 @@ func TestDemoTime_Fragment(t *testing.T) { } func TestRequestID_HeaderSet(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/healthz", nil)