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.
285 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|