Files
projax/web/dashboard_pin_test.go
mAi 2925c43a1e feat(dashboard): pin toggle on tiles + handleDashboardPin handler
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).
2026-05-26 12:31:24 +02:00

170 lines
5.9 KiB
Go

package web_test
import (
"context"
"net/url"
"strings"
"testing"
"time"
)
// TestDashboardPinTogglesItem seeds an item with pinned=false, POSTs to
// /dashboard/pin with pin=true, then asserts the row in projax.items is
// now pinned and the Tiles view renders the tile with the .pinned class.
func TestDashboardPinTogglesItem(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "pin-target-" + stamp
var dev, id 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,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], 'pin target', $1, ARRAY[$2]::uuid[])
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
// POST pin=true.
form := url.Values{"id": {id}, "pin": {"true"}}
code, _ := post(t, h, "/dashboard/pin", form)
if code != 200 {
t.Fatalf("POST /dashboard/pin → %d", code)
}
var pinned bool
if err := pool.QueryRow(ctx, `select pinned from projax.items where id=$1`, id).Scan(&pinned); err != nil {
t.Fatalf("read pinned: %v", err)
}
if !pinned {
t.Errorf("expected pinned=true after POST")
}
// The re-render should mark the tile as .tile-pinned.
_, body := get(t, h, "/dashboard")
tileIdx := strings.Index(body, `data-item-id="`+id+`"`)
if tileIdx < 0 {
t.Fatalf("pinned tile not found in re-render")
}
openTag := body[strings.LastIndex(body[:tileIdx], "<article"):tileIdx]
if !strings.Contains(openTag, "tile-pinned") {
t.Errorf("tile should carry 'tile-pinned' class — got %q", openTag)
}
}
// TestDashboardPinUnpinsItem seeds a pinned item, POSTs pin=false, and
// asserts the row is no longer pinned.
func TestDashboardPinUnpinsItem(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "unpin-target-" + stamp
var dev, id 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,
`insert into projax.items (kind, title, slug, parent_ids, pinned)
values (array['project']::text[], 'unpin target', $1, ARRAY[$2]::uuid[], true)
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
form := url.Values{"id": {id}, "pin": {"false"}}
code, _ := post(t, h, "/dashboard/pin", form)
if code != 200 {
t.Fatalf("POST /dashboard/pin → %d", code)
}
var pinned bool
if err := pool.QueryRow(ctx, `select pinned from projax.items where id=$1`, id).Scan(&pinned); err != nil {
t.Fatalf("read pinned: %v", err)
}
if pinned {
t.Errorf("expected pinned=false after POST pin=false")
}
}
// TestDashboardPinRequiresID asserts the handler rejects missing-id
// requests with 400 rather than silently no-op'ing.
func TestDashboardPinRequiresID(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
form := url.Values{"pin": {"true"}}
code, _ := post(t, h, "/dashboard/pin", form)
if code != 400 {
t.Errorf("expected 400 for missing id, got %d", code)
}
}
// TestDashboardPinInvalidatesCache asserts a pin flip busts the
// dashboard cache so subsequent renders reflect the new pinned state.
// The pin handler invalidates then re-renders, which re-populates the
// cache with FRESH data — so the next external GET serves the new
// state (the assertion is on data correctness, not the cached label).
func TestDashboardPinInvalidatesCache(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "cache-pin-" + stamp
var dev, id 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,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], 'cache pin', $1, ARRAY[$2]::uuid[])
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
// Prime the cache — first GET caches an unpinned tile state.
_, primed := get(t, h, "/dashboard")
tileIdx := strings.Index(primed, `data-item-id="`+id+`"`)
if tileIdx < 0 {
t.Fatalf("seeded tile missing from primed dashboard")
}
openTag := primed[strings.LastIndex(primed[:tileIdx], "<article"):tileIdx]
if strings.Contains(openTag, "tile-pinned") {
t.Fatalf("setup: fresh tile should not be pinned yet — got %q", openTag)
}
// Flip pin.
form := url.Values{"id": {id}, "pin": {"true"}}
_, _ = post(t, h, "/dashboard/pin", form)
// Next GET must reflect the new pinned state — proves the cache
// entry for the previous (unpinned) state was invalidated.
_, after := get(t, h, "/dashboard")
tileIdx2 := strings.Index(after, `data-item-id="`+id+`"`)
if tileIdx2 < 0 {
t.Fatalf("tile missing from post-pin dashboard")
}
openTag2 := after[strings.LastIndex(after[:tileIdx2], "<article"):tileIdx2]
if !strings.Contains(openTag2, "tile-pinned") {
t.Errorf("pin flip should invalidate cache so next GET shows pinned tile — got %q", openTag2)
}
}