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)
118 lines
4.6 KiB
Go
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"])
|
|
}
|
|
}
|