Files
projax/mcp/public_listing_test.go
mAi f6cf050c3f feat(phase 4d): public-listing fields so projax becomes the portfolio source of truth
Adds five additive columns on projax.items and propagates them through
every read/write path. flexsiebels.de (and any future portfolio renderer)
can now pull the public set via the MCP `list_items(public=true)` filter
and stop hard-coding project lists.

## Schema (migration 0014)
- public               boolean       default false (partial index when true)
- public_description   text          default ''
- public_live_url      text          default ''
- public_source_url    text          default ''
- public_screenshots   text[]        default '{}'
- items_unified view rebuilt to include the five new columns
- items_public_idx     PARTIAL INDEX WHERE public = true (5% of rows)

## Store
- Item struct + scan/scanItems extended (5 cols)
- UpdateInput accepts the new fields with full-replace semantics
- new SetPublic(ids, bool) for bulk write
- SearchFilters gains Public *bool — nil = no filter

## MCP
- list_items: new `public` boolean filter (input schema + handler)
- update_item: 5 new partial-update fields (nil pointer = leave alone)
- itemView always emits the 5 fields (even when public=false) so consumers
  can preview "what would publish" without a second round-trip
- 2 new integration tests against the DB

## Web
- /i/{path} grows a "Public listing" fieldset: toggle + textarea + 2 URL
  inputs + screenshot list editor with add/remove rows + inline JS for
  the editor. Values persist when public is off so toggling never
  destroys typed-in content.
- /admin/bulk action bar gains "Make public" / "Make private" via a new
  select; SQL update is a single statement per action.
- /?public=1 and /?public=0 chip parameters narrow the tree page.
  Active() + QueryString() + TogglePublic() round-trip the state.
- parseScreenshotList helper trims + drops empties + preserves order
- 5 integration tests: migration landed, form round-trip, bulk action
  round-trip, detail-page affordances, tree-filter narrowing

## docs/design.md §15
Documents the schema, MCP contract, UI surfaces, flexsiebels consumption
pattern, and what's NOT in scope (flexsiebels-side render, asset hosting,
approval workflows).

## Out of scope (per task brief)
- Flexsiebels rendering — separate task in m/flexsiebels.de after this ships
- Asset hosting (projax stores URLs, never bytes — same PER discipline)
- Multi-stage publish workflow (boolean is enough)
2026-05-17 19:11:26 +02:00

118 lines
4.6 KiB
Go

package mcp
import (
"context"
"strings"
"testing"
"time"
)
// TestPublic_UpdateItem_RoundTripsAllFields seeds a row, calls update_item
// over MCP with every public_* field, then re-fetches and asserts they all
// stuck. Closes the worry that the SQL UPDATE list silently drops a column.
func TestPublic_UpdateItem_RoundTripsAllFields(t *testing.T) {
srv, pool := mustDBServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "mcp-pub-" + 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[], 'MCP pub', $1, ARRAY[$2]::uuid[])
returning id`, slug, dev).Scan(&id); err != nil {
t.Fatalf("seed: %v", err)
}
t.Cleanup(func() { pool.Exec(context.Background(), `delete from projax.items where id=$1`, id) })
out := callTool(t, srv, "update_item", map[string]any{
"id": id,
"public": true,
"public_description": "Hello flexsiebels",
"public_live_url": "https://example.com",
"public_source_url": "https://mgit.msbls.de/m/example",
"public_screenshots": []string{"https://example.com/a.png", "https://example.com/b.png"},
}, "tok")
if pub, _ := out["public"].(bool); !pub {
t.Errorf("update_item return missing public=true, got: %+v", out)
}
if d, _ := out["public_description"].(string); d != "Hello flexsiebels" {
t.Errorf("update_item return missing public_description, got: %q", d)
}
shots, _ := out["public_screenshots"].([]any)
if len(shots) != 2 {
t.Errorf("update_item return missing 2 screenshots, got: %v", shots)
}
// Cross-check against the DB.
var pub bool
var desc, live, src string
var stored []string
if err := pool.QueryRow(ctx,
`select public, public_description, public_live_url, public_source_url, public_screenshots
from projax.items where id=$1`, id,
).Scan(&pub, &desc, &live, &src, &stored); err != nil {
t.Fatalf("re-read: %v", err)
}
if !pub || desc != "Hello flexsiebels" || live != "https://example.com" || src != "https://mgit.msbls.de/m/example" {
t.Errorf("DB state diverged after MCP update_item: pub=%v desc=%q live=%q src=%q", pub, desc, live, src)
}
if len(stored) != 2 {
t.Errorf("DB screenshots count mismatch: %v", stored)
}
}
// TestPublic_ListItems_FilterOnPublic seeds one public + one private item,
// then asserts list_items(public=true) returns only the public one and
// list_items(public=false) only the private one.
func TestPublic_ListItems_FilterOnPublic(t *testing.T) {
srv, pool := mustDBServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
var dev 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)
}
var pubID, prvID string
pubSlug := "mcp-pubfilt-yes-" + stamp
prvSlug := "mcp-pubfilt-no-" + stamp
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, public)
values (array['project']::text[], 'mcp pub yes', $1, ARRAY[$2]::uuid[], true)
returning id`, pubSlug, dev).Scan(&pubID); err != nil {
t.Fatalf("seed public: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, public)
values (array['project']::text[], 'mcp pub no', $1, ARRAY[$2]::uuid[], false)
returning id`, prvSlug, dev).Scan(&prvID); err != nil {
t.Fatalf("seed private: %v", err)
}
t.Cleanup(func() { pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, pubID, prvID) })
pubOut := callTool(t, srv, "list_items", map[string]any{"public": true, "q": "mcp-pubfilt-"}, "tok")
items, _ := pubOut["items"].([]any)
if len(items) != 1 {
t.Fatalf("list_items(public=true) expected 1 row, got %d", len(items))
}
got, _ := items[0].(map[string]any)
if got["slug"] != pubSlug {
t.Errorf("public=true returned wrong row: %v", got["slug"])
}
prvOut := callTool(t, srv, "list_items", map[string]any{"public": false, "q": "mcp-pubfilt-"}, "tok")
items, _ = prvOut["items"].([]any)
if len(items) != 1 {
t.Fatalf("list_items(public=false) expected 1 row, got %d", len(items))
}
got, _ = items[0].(map[string]any)
if got["slug"] != prvSlug {
t.Errorf("public=false returned wrong row: %v", got["slug"])
}
}