Phase 5h slice 4 — adds the star button on each tile that flips Pinned on the projax item via POST /dashboard/pin. Backend: - store.SetPinned(ids, pinned bool) — minimal-write helper that mirrors SetPublic, only touching the pinned column. - web/dashboard_pin.go — handleDashboardPin parses id + pin from form, calls SetPinned, invalidates the entire dashboard cache (pin affects sort order across every view/scope/filter combo), then re-renders by delegating to handleDashboard so HTMX receives the updated #dashboard-section HTML. - Route: POST /dashboard/pin (sibling of /dashboard/task/*). Frontend: - Tile template now leads with a <form class="tile-pin-form"> that POSTs id + the inverted pin state. Button glyph is ☆ when unpinned, ★ when pinned; aria-label flips accordingly. - HTMX swaps the entire #dashboard-section so the tile moves to the pinned-first position (or back to alphabetical) without a full reload. - CSS: .tile-pin (transparent button, muted color, accent on hover); .tile-pin.pinned for the filled-star state. Test helper: server_test.go gains a post() helper paired with the existing get() — form-encoded POSTs for writeback tests. Tests (dashboard_pin_test.go): - TestDashboardPinTogglesItem — POST pin=true flips the row, and the re-render shows the .tile-pinned class on the tile <article>. - TestDashboardPinUnpinsItem — POST pin=false on a pinned row unpins. - TestDashboardPinRequiresID — missing id returns 400. - TestDashboardPinInvalidatesCache — primes with unpinned cache, POSTs pin, asserts the next GET reflects the pinned class (proving the prior cache entry was busted).
297 lines
9.2 KiB
Go
297 lines
9.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)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// post submits an application/x-www-form-urlencoded POST to the given
|
|
// path. Mirrors the get helper for form-flavored writeback tests.
|
|
func post(t *testing.T, h http.Handler, path string, form url.Values) (int, string) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
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{"<h1>Tree</h1>", "/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", "/calendar", "/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
|
|
_, body := get(t, h, path)
|
|
if !strings.Contains(body, `name="viewport"`) {
|
|
t.Errorf("GET %s: missing <meta name=\"viewport\">", 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: <sha>\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)
|
|
}
|
|
}
|
|
}
|