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.
191 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|