Files
projax/web/server_test.go
mAi dfa81fd58e feat(phase 3p): bake git SHA into binary + surface on /healthz
Closes the silent-deploy-rot gap caught by Phase 3n's triage. The
problem: a missing Gitea webhook left 11 commits stuck on an old
container while /healthz kept reporting 200 from the stale binary. With
no commit-level evidence on the wire, "deploy rolled" was unverifiable.

Mechanism:
- Dockerfile installs git, reads `git rev-parse --short HEAD` at build
  time, injects via `-ldflags="-X main.gitCommit=<sha>"`. Works under
  Dokploy's `git clone --depth 1` flow (the .git/ folder is in the
  build context) and under plain `docker build .` (same). Local
  `go run` falls back to "unknown".
- main.gitCommit assigns to web.Server.Version in main().
- /healthz now emits two lines: "ok" and "version: <sha>". Endpoint
  remains unauthenticated so any worker / monitor can verify "deploy
  rolled" without a session.

CLAUDE.md gets a mandatory "Post-deploy verification" section: after
every push, compare `git rev-parse --short HEAD` against
`curl /healthz | tail -1`. Mismatch = webhook broken; inspect Gitea
hook 172 (URL pattern `http://mlake.horse-ayu.ts.net:3000/api/deploy/
<refreshToken>` per the working webhooks on m/msbls.de + m/flexsiebels.de).

TestHealthzSurfacesVersion regression-guards the new line. Existing
TestHealthz updated to accept the multi-line body.
2026-05-16 15:35:28 +02:00

285 lines
8.7 KiB
Go

package web_test
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/m/projax/db"
"github.com/m/projax/store"
"github.com/m/projax/web"
)
var (
migrateOnce sync.Once
migrateErr error
)
func mustServer(t *testing.T) (*web.Server, *pgxpool.Pool) {
t.Helper()
dbURL := os.Getenv("PROJAX_DB_URL")
if dbURL == "" {
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
}
if dbURL == "" {
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping HTTP integration test")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
t.Fatalf("pool: %v", err)
}
if err := pool.Ping(ctx); err != nil {
t.Skipf("DB unreachable: %v", err)
}
if os.Getenv("PROJAX_SKIP_MIGRATE") != "1" {
migrateOnce.Do(func() { migrateErr = db.ApplyMigrations(ctx, pool) })
if migrateErr != nil {
t.Fatalf("migrate: %v", migrateErr)
}
}
srv, err := web.New(store.New(pool), slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("server: %v", err)
}
return srv, pool
}
func get(t *testing.T, h http.Handler, url string) (int, string) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, url, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
body, _ := io.ReadAll(w.Result().Body)
return w.Result().StatusCode, string(body)
}
func TestTreeRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/")
if code != 200 {
t.Fatalf("GET / status %d body=%s", code, body)
}
// /admin/classify used to live in the nav; Phase 3o consolidated all
// admin links under the new /admin index. Assert /admin instead.
for _, want := range []string{"<h1>Tree</h1>", "/i/dev", "/i/home", `href="/admin"`} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
}
// TestLayoutHasViewportMeta proves every chrome-bearing page carries the
// viewport meta tag added in Phase 3i. Without it iOS Safari renders pages
// at 980px and the user must pinch-zoom to read anything. We probe one
// representative GET on each layout-rendered route.
func TestLayoutHasViewportMeta(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
for _, path := range []string{"/", "/dashboard", "/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
_, body := get(t, h, path)
if !strings.Contains(body, `name="viewport"`) {
t.Errorf("GET %s: missing <meta name=\"viewport\">", path)
}
if !strings.Contains(body, `width=device-width`) {
t.Errorf("GET %s: viewport meta does not set width=device-width", path)
}
}
}
// TestHealthzSurfacesVersion proves /healthz returns the version line as
// well as the ok marker. Phase 3p — closes the silent-deploy-rot gap so a
// worker can verify "deploy actually rolled" with an unauthenticated curl
// (compare against `git rev-parse --short HEAD` before assuming the latest
// merge is live).
func TestHealthzSurfacesVersion(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
srv.Version = "abc1234"
h := srv.Routes()
code, body := get(t, h, "/healthz")
if code != 200 {
t.Fatalf("GET /healthz → %d", code)
}
if !strings.Contains(body, "ok") {
t.Errorf("body should contain 'ok', got %q", body)
}
if !strings.Contains(body, "version: abc1234") {
t.Errorf("body should contain 'version: abc1234', got %q", body)
}
}
func TestHealthz(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/healthz")
// Body is two lines now (Phase 3p): "ok\nversion: <sha>\n". Assert the
// 200 status + "ok" leader, not exact equality, so the version line can
// grow without breaking this guard.
if code != 200 || !strings.HasPrefix(body, "ok") {
t.Fatalf("healthz: %d %q", code, body)
}
}
func TestDetailRendersEditableForm(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/i/dev")
if code != 200 {
t.Fatalf("status %d body=%s", code, body)
}
if !strings.Contains(body, `form method="post" action="/i/dev"`) {
t.Errorf("edit form missing for /i/dev")
}
if !strings.Contains(body, `name="tags"`) {
t.Errorf("tags input missing")
}
if !strings.Contains(body, `name="management"`) {
t.Errorf("management input missing")
}
}
func TestDetailShowsManagementChips(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
// dev.projax is the manually-promoted item from before Phase 1.5 — should
// already carry management=['mai'] after the backfill+sync pass.
code, body := get(t, srv.Routes(), "/i/dev.projax")
if code != 200 {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, "mgmt-mai") {
t.Errorf("expected mgmt-mai chip on /i/dev.projax, body did not include it")
}
}
func TestClassifyListsMaiRoots(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/admin/classify")
if code != 200 {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, "Classify root mai-managed items") &&
!strings.Contains(body, "No unclassified roots") {
t.Errorf("classify page body unexpected: %q", body)
}
}
func TestReparentRoundTrip(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Create a fresh root mai-managed item via the reverse-sync path so the
// test never collides with another project. The sync trigger drops the
// mirror at parent_id=NULL — exactly the case /admin/classify handles.
maiID := "phase15-test-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
defer func() {
_, _ = pool.Exec(context.Background(), `delete from mai.projects where id=$1`, maiID)
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, maiID)
}()
if _, err := pool.Exec(ctx,
`insert into mai.projects (id, name, status) values ($1, $2, 'active')`,
maiID, "Reparent test "+maiID,
); err != nil {
t.Fatalf("seed mai.projects: %v", err)
}
var nParents int
if err := pool.QueryRow(ctx,
`select cardinality(parent_ids) from projax.items where slug=$1`, maiID,
).Scan(&nParents); err != nil {
t.Fatalf("read mirror: %v", err)
}
if nParents != 0 {
t.Fatalf("expected mirror at root (no parents), got %d parents", nParents)
}
var devID string
if err := pool.QueryRow(ctx,
`select id from projax.items where slug='dev' and cardinality(parent_ids) = 0`,
).Scan(&devID); err != nil {
t.Fatalf("dev: %v", err)
}
form := url.Values{}
form.Set("parent_id", devID)
req := httptest.NewRequest(http.MethodPost, "/i/"+maiID+"/reparent", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != http.StatusSeeOther {
body, _ := io.ReadAll(w.Result().Body)
t.Fatalf("reparent status %d body=%s", w.Result().StatusCode, body)
}
if loc := w.Result().Header.Get("Location"); loc != "/i/dev."+maiID {
t.Errorf("Location = %q, want /i/dev.%s", loc, maiID)
}
var parents []string
if err := pool.QueryRow(ctx,
`select array(select unnest(parent_ids)::text) from projax.items where slug=$1`, maiID,
).Scan(&parents); err != nil {
t.Fatalf("post-reparent read: %v", err)
}
if len(parents) != 1 || parents[0] != devID {
t.Errorf("parent_ids after reparent = %v, want [%s]", parents, devID)
}
}
func TestMultiParentBothPathsRouteToSameRow(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
slug := "p15-multi-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
defer func() {
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, slug)
}()
var dev, work string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx, `select id from projax.items where slug='work' and cardinality(parent_ids)=0`).Scan(&work); err != nil {
t.Fatalf("work: %v", err)
}
if _, err := pool.Exec(ctx,
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'Multi', $1, ARRAY[$2,$3]::uuid[])`,
slug, dev, work,
); err != nil {
t.Fatalf("insert multi: %v", err)
}
for _, p := range []string{"dev." + slug, "work." + slug} {
code, body := get(t, h, "/i/"+p)
if code != 200 {
t.Fatalf("GET /i/%s → %d", p, code)
}
if !strings.Contains(body, "Multi") {
t.Errorf("body for /i/%s missing item title 'Multi'", p)
}
}
}