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) } }