Files
projax/web/server_test.go
mAi 9f905de461 feat: Go HTTP server with tree / detail / new / classify
cmd/projax/main.go boots a pgxpool against PROJAX_DB_URL (falls back to
SUPABASE_DATABASE_URL), auto-applies embedded migrations on start
(disable with PROJAX_AUTO_MIGRATE=off), and serves on PROJAX_LISTEN_ADDR
(default :8080).

store package wraps the unified view + projax.items writes. Item has
helper methods for templates: IsArea, Editable, SourceRefDeref. The
Promote() flow runs the insert + item_links link inside a single
transaction so the source row drops out of items_unified atomically.

web package: per-page html/template instances parsed against a shared
layout.tmpl, embedded static/style.css, HTMX from CDN. Pages:
  GET  /                   tree of items_unified
  GET  /i/{path}           detail (editable for projax, read-only +
                           promote form for mai.projects)
  POST /i/{path}           update projax-native item
  POST /i/{path}/promote   one-page promote (HTMX-aware fragment for
                           inline classify)
  GET  /new?parent={path}  create form
  POST /new                create projax-native item
  GET  /admin/classify     orphan list with inline HTMX promote
  GET  /healthz            DB ping
  GET  /static/*           embedded assets

Auth is intentionally out of scope for v1 — service binds to whatever
PROJAX_LISTEN_ADDR points at, deploy guidance pins it to the Tailscale
interface (covered in 1d README).

Tests (skip when DB env is unset):
  TestTreeRenders, TestHealthz,
  TestDetailProjaxNativeEditable, TestDetailMaiProjectsReadOnly,
  TestClassifyListsOrphans, TestPromoteRoundTrip.
2026-05-15 13:24:44 +02:00

191 lines
5.2 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)
}
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)
}
for _, want := range []string{"<h1>Tree</h1>", "/i/dev", "/i/home", "/admin/classify"} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
}
func TestHealthz(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/healthz")
if code != 200 || strings.TrimSpace(body) != "ok" {
t.Fatalf("healthz: %d %q", code, body)
}
}
func TestDetailProjaxNativeEditable(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", code)
}
if !strings.Contains(body, `form method="post" action="/i/dev"`) {
t.Errorf("editable form missing for /i/dev")
}
}
func TestDetailMaiProjectsReadOnly(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
code, body := get(t, srv.Routes(), "/i/mai.dotfiles")
if code != 200 {
t.Fatalf("status %d", code)
}
if !strings.Contains(body, "Promote to projax") {
t.Errorf("Promote section missing for mai.projects row")
}
if !strings.Contains(body, `action="/i/mai.dotfiles/promote"`) {
t.Errorf("promote form missing")
}
}
func TestClassifyListsOrphans(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, "unclassified rows") {
t.Errorf("classify missing summary")
}
}
func TestPromoteRoundTrip(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// Pick an orphan to promote.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var maiID, maiPath string
if err := pool.QueryRow(ctx,
`select source_ref_id, path from projax.items_unified where source='mai.projects' limit 1`,
).Scan(&maiID, &maiPath); err != nil {
t.Fatalf("pick orphan: %v", err)
}
if maiID == "" {
t.Skip("no mai.projects orphans available")
}
var devID string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
t.Fatalf("dev: %v", err)
}
promoSlug := "test-promo-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
form := url.Values{}
form.Set("parent_id", devID)
form.Set("slug", promoSlug)
form.Set("title", "Promo "+maiID)
req := httptest.NewRequest(http.MethodPost, "/i/"+maiPath+"/promote", 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("promote status %d body=%s", w.Result().StatusCode, body)
}
loc := w.Result().Header.Get("Location")
wantLoc := "/i/dev." + promoSlug
if loc != wantLoc {
t.Errorf("redirect Location = %q, want %q", loc, wantLoc)
}
// The mai row should be hidden from items_unified now.
var still int
if err := pool.QueryRow(ctx,
`select count(*) from projax.items_unified where source='mai.projects' and source_ref_id=$1`, maiID,
).Scan(&still); err != nil {
t.Fatalf("post-promote count: %v", err)
}
if still != 0 {
t.Errorf("expected mai source row hidden after promote, got count=%d", still)
}
// Clean up to keep test idempotent.
if _, err := pool.Exec(ctx, `delete from projax.item_links where ref_type='mai-project' and ref_id=$1`, maiID); err != nil {
t.Fatalf("cleanup link: %v", err)
}
if _, err := pool.Exec(ctx, `delete from projax.items where slug=$1 and parent_id=$2`, promoSlug, devID); err != nil {
t.Fatalf("cleanup item: %v", err)
}
}