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)
}
for _, want := range []string{"
Tree
", "/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)
}
}