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{"

Tree

", "/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 ", 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: \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) } } }