Merge pull request #2990 from Molecule-AI/fix/memory-v2-namespace-labels-2988

fix(memory-v2): namespace labels use display names not UUID prefixes (#2988)
This commit is contained in:
hongmingwang-moleculeai 2026-05-06 02:13:30 +00:00 committed by GitHub
commit bd0580f4af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 312 additions and 50 deletions

View File

@ -325,7 +325,6 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{dropdownOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
{opt.kind ? ` (${opt.kind})` : ''}
</option>
))}
</select>

View File

@ -331,43 +331,84 @@ func memoryToView(m contract.Memory) MemoryView {
}
// namespacesToViews converts resolver namespaces into UI-friendly
// views. Stable sort: workspace → team → org → custom, then by name.
// views. Prefers `DisplayName` from the resolver (workspace.name from
// the DB) when present; falls back to a UUID-prefix label.
//
// Issue #2988: pre-fix, every namespace used a shortID-truncated UUID
// label. On a root workspace where workspace==team==org IDs collide
// (resolver derive() degenerate case), all three labels rendered
// identically. DisplayName disambiguates by surfacing real workspace
// names — the canvas dropdown now reads "Workspace (mac laptop)" /
// "Team (mac laptop)" / "Org (mac laptop)" for a root workspace
// rather than three identical UUID prefixes. The `kind` prefix
// "Workspace/Team/Org" still carries the semantic distinction.
func namespacesToViews(in []namespace.Namespace) []NamespaceView {
views := make([]NamespaceView, 0, len(in))
for _, n := range in {
views = append(views, NamespaceView{
Name: n.Name,
Kind: n.Kind,
Label: namespaceLabel(n.Name, n.Kind),
Label: namespaceLabelWithName(n.Name, n.Kind, n.DisplayName),
})
}
return views
}
// namespaceLabel renders a human-friendly label for a namespace. The
// canvas displays this directly; we keep the formatting server-side
// so the shape stays consistent across UIs (canvas, future TUI, etc.).
// namespaceLabel renders a human-friendly label for a namespace using
// the UUID-prefix fallback only. Kept for back-compat with callers
// that don't yet plumb a display name. New callers should use
// namespaceLabelWithName which prefers the workspace's display name
// when available.
//
// Format:
// workspace:abc-123 → "Workspace (abc-123)" (UUID short-prefixed)
// Format (UUID-prefix fallback):
// workspace:abc-123 → "Workspace (abc-123)"
// team:t-1 → "Team (t-1)"
// org:acme → "Org (acme)"
// custom:foo → "foo" (operator-defined; raw)
// custom:foo → "foo"
func namespaceLabel(name string, kind contract.NamespaceKind) string {
return namespaceLabelWithName(name, kind, "")
}
// namespaceLabelWithName renders the human-friendly label, preferring
// `displayName` when non-empty.
//
// When displayName is set:
// Workspace, "mac laptop" → "Workspace (mac laptop)"
// Team, "Engineering team" → "Team (Engineering team)"
// Org, "Hongming's Org" → "Org (Hongming's Org)"
//
// When displayName is empty (lookup miss, future-migration drop, etc.),
// falls back to the UUID-prefix shape for back-compat.
//
// Custom namespaces ignore displayName because they're operator-defined
// — the operator chose the raw suffix as the label, surfacing a
// different "name" would be a UX surprise.
func namespaceLabelWithName(name string, kind contract.NamespaceKind, displayName string) string {
suffix := ""
if i := indexOfColon(name); i >= 0 && i+1 < len(name) {
suffix = name[i+1:]
}
switch kind {
case contract.NamespaceKindWorkspace:
if displayName != "" {
return "Workspace (" + displayName + ")"
}
return "Workspace (" + shortID(suffix) + ")"
case contract.NamespaceKindTeam:
if displayName != "" {
return "Team (" + displayName + ")"
}
return "Team (" + shortID(suffix) + ")"
case contract.NamespaceKindOrg:
if displayName != "" {
return "Org (" + displayName + ")"
}
return "Org (" + suffix + ")"
case contract.NamespaceKindCustom:
// Custom namespaces are operator-defined; surface the raw
// suffix so they can label them however they want.
// Operator-defined; the suffix IS the label they chose.
// displayName is ignored — surfacing a different name would
// be a UX surprise for an operator who deliberately named
// the namespace.
if suffix == "" {
return name
}

View File

@ -507,6 +507,92 @@ func TestMemoriesV2_Forget_MissingMemoryID_400(t *testing.T) {
// View-shaping unit tests — pin individual helpers
// ─────────────────────────────────────────────────────────────────────────────
// namespaceLabelWithName tests — the new code path that prefers
// DisplayName over UUID-prefix fallback (issue #2988).
func TestNamespaceLabelWithName_PrefersDisplayNameWhenSet(t *testing.T) {
cases := []struct {
name string
raw string
kind contract.NamespaceKind
display string
want string
}{
{"workspace with name", "workspace:abc-1234", contract.NamespaceKindWorkspace, "mac laptop", "Workspace (mac laptop)"},
{"team with name", "team:abc-1234", contract.NamespaceKindTeam, "Engineering", "Team (Engineering)"},
{"org with name", "org:acme", contract.NamespaceKindOrg, "Hongming's Org", "Org (Hongming's Org)"},
// Custom ignores displayName by design — operator chose the suffix.
{"custom ignores displayName", "custom:ops-shared", contract.NamespaceKindCustom, "FancyName", "ops-shared"},
{"unknown kind falls through", "weird:x", contract.NamespaceKind("future"), "WhoCares", "weird:x"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := namespaceLabelWithName(tc.raw, tc.kind, tc.display)
if got != tc.want {
t.Errorf("namespaceLabelWithName(%q, %q, %q) = %q, want %q",
tc.raw, tc.kind, tc.display, got, tc.want)
}
})
}
}
func TestNamespaceLabelWithName_FallsBackToUUIDPrefixWhenEmpty(t *testing.T) {
// When displayName is empty (NULL in DB, lookup miss, etc.), the
// label shape MUST match the legacy UUID-prefix shape exactly so
// existing canvas behaviour is unchanged for callers that don't
// plumb a name.
cases := []struct {
raw string
kind contract.NamespaceKind
want string
}{
{"workspace:abcdefghij", contract.NamespaceKindWorkspace, "Workspace (abcdefgh)"},
{"team:t-99", contract.NamespaceKindTeam, "Team (t-99)"},
{"org:acme", contract.NamespaceKindOrg, "Org (acme)"},
}
for _, tc := range cases {
got := namespaceLabelWithName(tc.raw, tc.kind, "")
if got != tc.want {
t.Errorf("displayName=\"\" path: got %q, want %q", got, tc.want)
}
}
}
func TestNamespacesToViews_PassesDisplayNameThrough(t *testing.T) {
in := []namespace.Namespace{
{Name: "workspace:root-1", Kind: contract.NamespaceKindWorkspace, DisplayName: "mac laptop"},
{Name: "team:root-1", Kind: contract.NamespaceKindTeam, DisplayName: "mac laptop"}, // root → team aliases self
{Name: "org:root-1", Kind: contract.NamespaceKindOrg, DisplayName: "mac laptop"},
}
out := namespacesToViews(in)
if len(out) != 3 {
t.Fatalf("len = %d, want 3", len(out))
}
wantLabels := []string{
"Workspace (mac laptop)",
"Team (mac laptop)",
"Org (mac laptop)",
}
for i, v := range out {
if v.Label != wantLabels[i] {
t.Errorf("[%d] label = %q, want %q", i, v.Label, wantLabels[i])
}
}
}
func TestNamespacesToViews_FallsBackToUUIDLabelWhenDisplayNameEmpty(t *testing.T) {
// Exercises the back-compat path — DisplayName="" plumbs through
// to namespaceLabelWithName which returns the legacy UUID-prefix
// label. This is what callers see when the workspaces table
// has a NULL name (defensive — workspaces.name is NOT NULL today).
in := []namespace.Namespace{
{Name: "workspace:root-1", Kind: contract.NamespaceKindWorkspace}, // no DisplayName
}
out := namespacesToViews(in)
if out[0].Label != "Workspace (root-1)" {
t.Errorf("fallback label = %q, want %q", out[0].Label, "Workspace (root-1)")
}
}
func TestNamespaceLabel_AllKinds(t *testing.T) {
cases := []struct {
name string

View File

@ -213,10 +213,15 @@ func setupSwapEnv(t *testing.T) (*handlers.MCPHandler, *flatPlugin, sqlmock.Sqlm
// expectChainQuery sets up the recursive-CTE expectation matching
// the resolver for a root workspace. Reusable across tests.
//
// The resolver SELECTs `name` so it can populate Namespace.DisplayName
// (#2988); we pass an empty string here because the e2e tests don't
// assert on label rendering — the namespace string ("workspace:root-1"
// etc) is what the plugin sees.
func expectChainQueryRoot(mock sqlmock.Sqlmock) {
mock.ExpectQuery("WITH RECURSIVE chain").
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("root-1", nil, 0))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("root-1", "", nil, 0))
}
// --- The actual E2E ---

View File

@ -33,6 +33,25 @@ type Namespace struct {
Kind contract.NamespaceKind `json:"kind"`
Description string `json:"description"`
Writable bool `json:"writable"`
// DisplayName is the human-readable label for this namespace,
// derived from the workspace tree:
// - workspace: this workspace's own name (`workspaces.name`)
// - team: parent's name if child, this workspace's name if root
// (degenerate case — team semantically means "memories
// shared with peers in this team", so for a root workspace
// with no peers, "your team" is conceptually correct.)
// - org: the root workspace's name (org-wide memories — every
// workspace under this root sees them)
//
// Empty string when the lookup failed (workspace row missing). The
// canvas uses DisplayName for the dropdown; falls back to a short
// UUID prefix when it's empty.
//
// Issue #2988: pre-fix, the canvas labelled all three namespaces
// with the SAME shortID-truncated UUID prefix on a root workspace
// because workspace==team==org IDs collide. The display name
// disambiguates them by surfacing real workspace names.
DisplayName string `json:"display_name,omitempty"`
}
// ErrWorkspaceNotFound is returned when the input workspace ID does
@ -54,6 +73,7 @@ func New(db *sql.DB) *Resolver {
// chainNode is one row from the recursive CTE.
type chainNode struct {
id string
name string // workspaces.name (display label for the namespace)
parentID *string
depth int
}
@ -64,16 +84,16 @@ type chainNode struct {
func (r *Resolver) walkChain(ctx context.Context, workspaceID string) ([]chainNode, error) {
const query = `
WITH RECURSIVE chain AS (
SELECT id, parent_id, 0 AS depth
SELECT id, name, parent_id, 0 AS depth
FROM workspaces
WHERE id = $1
UNION ALL
SELECT w.id, w.parent_id, c.depth + 1
SELECT w.id, w.name, w.parent_id, c.depth + 1
FROM workspaces w
JOIN chain c ON w.id = c.parent_id
WHERE c.depth < $2
)
SELECT id::text, parent_id::text, depth FROM chain ORDER BY depth ASC
SELECT id::text, COALESCE(name, ''), parent_id::text, depth FROM chain ORDER BY depth ASC
`
rows, err := r.db.QueryContext(ctx, query, workspaceID, maxChainDepth)
if err != nil {
@ -85,7 +105,7 @@ func (r *Resolver) walkChain(ctx context.Context, workspaceID string) ([]chainNo
for rows.Next() {
var n chainNode
var parentStr sql.NullString
if err := rows.Scan(&n.id, &parentStr, &n.depth); err != nil {
if err := rows.Scan(&n.id, &n.name, &parentStr, &n.depth); err != nil {
return nil, fmt.Errorf("scan chain: %w", err)
}
if parentStr.Valid && parentStr.String != "" {
@ -122,6 +142,33 @@ func derive(chain []chainNode) (workspace, team, org string) {
return
}
// deriveNames computes the display name for each of the three
// canonical namespaces. Mirrors derive() — same lookup logic, but
// returns workspace/parent/root NAMES instead of IDs.
//
// For a root workspace (no parent), team and org both alias to self;
// callers should still render them as semantically distinct (the
// `kind` field on the Namespace carries that distinction). The name
// itself collides on a depth-1 tree — that's expected; the kind
// prefix in the canvas label disambiguates.
//
// Returns the empty string for any name that's missing on the chain
// row (defensive — workspaces.name is NOT NULL today, but a future
// migration could change that). Callers fall back to UUID prefix
// when DisplayName is empty.
func deriveNames(chain []chainNode) (workspace, team, org string) {
self := chain[0]
workspace = self.name
if self.parentID != nil && len(chain) > 1 {
// Parent is the next node in the chain (depth 1).
team = chain[1].name
} else {
team = self.name
}
org = chain[len(chain)-1].name
return
}
// ReadableNamespaces returns the namespaces the workspace can read
// from. Order is deterministic (workspace, team, org) so callers can
// reason about precedence.
@ -131,6 +178,7 @@ func (r *Resolver) ReadableNamespaces(ctx context.Context, workspaceID string) (
return nil, err
}
wsID, teamID, orgID := derive(chain)
wsName, teamName, orgName := deriveNames(chain)
isRoot := chain[0].parentID == nil
out := []Namespace{
@ -139,12 +187,14 @@ func (r *Resolver) ReadableNamespaces(ctx context.Context, workspaceID string) (
Kind: contract.NamespaceKindWorkspace,
Description: "This workspace's private memories",
Writable: true,
DisplayName: wsName,
},
{
Name: "team:" + teamID,
Kind: contract.NamespaceKindTeam,
Description: "Memories shared across team members (parent + siblings)",
Writable: true,
DisplayName: teamName,
},
}
// Org namespace is readable by every workspace in the tree, but
@ -155,6 +205,7 @@ func (r *Resolver) ReadableNamespaces(ctx context.Context, workspaceID string) (
Kind: contract.NamespaceKindOrg,
Description: "Org-wide memories visible to every workspace under this root",
Writable: isRoot,
DisplayName: orgName,
})
return out, nil
}

View File

@ -46,8 +46,8 @@ func TestWalkChain_RootOnly(t *testing.T) {
// Root workspace: parent_id is NULL, depth 0, single row.
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ws-root", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("ws-root", nil, 0))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("ws-root", "", nil, 0))
chain, err := r.walkChain(context.Background(), "ws-root")
if err != nil {
@ -68,9 +68,9 @@ func TestWalkChain_ChildToParent(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ws-child", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("ws-child", "ws-root", 0).
AddRow("ws-root", nil, 1))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("ws-child", "", "ws-root", 0).
AddRow("ws-root", "", nil, 1))
chain, err := r.walkChain(context.Background(), "ws-child")
if err != nil {
@ -93,7 +93,7 @@ func TestWalkChain_DeepTreeRespectsMaxDepth(t *testing.T) {
r := New(db)
// Simulate a 51-deep chain: should be capped at maxChainDepth.
rows := sqlmock.NewRows([]string{"id", "parent_id", "depth"})
rows := sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"})
for i := 0; i <= maxChainDepth; i++ {
var parent interface{}
if i < maxChainDepth {
@ -101,7 +101,7 @@ func TestWalkChain_DeepTreeRespectsMaxDepth(t *testing.T) {
} else {
parent = nil // would be the cap point
}
rows.AddRow("ws-"+itoa(i), parent, i)
rows.AddRow("ws-"+itoa(i), "", parent, i)
}
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ws-0", maxChainDepth).
@ -127,7 +127,7 @@ func TestWalkChain_WorkspaceNotFound(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ws-missing", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}))
_, err := r.walkChain(context.Background(), "ws-missing")
if !errors.Is(err, ErrWorkspaceNotFound) {
@ -172,8 +172,8 @@ func TestWalkChain_RowsErr(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ws-x", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("ws-x", nil, 0).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("ws-x", "", nil, 0).
RowError(0, errors.New("mid-iteration")))
_, err := r.walkChain(context.Background(), "ws-x")
@ -238,8 +238,8 @@ func TestReadableNamespaces_Root(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("root-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("root-1", nil, 0))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("root-1", "", nil, 0))
got, err := r.ReadableNamespaces(context.Background(), "root-1")
if err != nil {
@ -274,9 +274,9 @@ func TestReadableNamespaces_Child(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("child-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("child-1", "root-1", 0).
AddRow("root-1", nil, 1))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("child-1", "", "root-1", 0).
AddRow("root-1", "", nil, 1))
got, err := r.ReadableNamespaces(context.Background(), "child-1")
if err != nil {
@ -297,13 +297,93 @@ func TestReadableNamespaces_Child(t *testing.T) {
}
}
func TestReadableNamespaces_DisplayName_Root(t *testing.T) {
// Root workspace with a real name. All three derived namespaces
// (workspace/team/org) should carry the workspace's display name —
// for a root workspace they collapse on UUID but the name is the
// disambiguator surfaced in the canvas dropdown (issue #2988).
db, mock := setupMockDB(t)
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("root-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("root-1", "mac laptop", nil, 0))
got, err := r.ReadableNamespaces(context.Background(), "root-1")
if err != nil {
t.Fatalf("ReadableNamespaces: %v", err)
}
for i, ns := range got {
if ns.DisplayName != "mac laptop" {
t.Errorf("[%d] %q DisplayName = %q, want %q", i, ns.Name, ns.DisplayName, "mac laptop")
}
}
}
func TestReadableNamespaces_DisplayName_Child(t *testing.T) {
// Child has its own workspace name; team should pick up the
// PARENT's name (not the child's), and org follows the chain root.
db, mock := setupMockDB(t)
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("child-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("child-1", "Hongming Personal Brand", "root-1", 0).
AddRow("root-1", "mac laptop", nil, 1))
got, err := r.ReadableNamespaces(context.Background(), "child-1")
if err != nil {
t.Fatalf("ReadableNamespaces: %v", err)
}
want := map[string]string{
"workspace:child-1": "Hongming Personal Brand", // self
"team:root-1": "mac laptop", // parent
"org:root-1": "mac laptop", // root
}
for _, ns := range got {
w, ok := want[ns.Name]
if !ok {
t.Errorf("unexpected namespace %q", ns.Name)
continue
}
if ns.DisplayName != w {
t.Errorf("%q DisplayName = %q, want %q", ns.Name, ns.DisplayName, w)
}
}
}
func TestReadableNamespaces_DisplayName_EmptyOnNULL(t *testing.T) {
// COALESCE in the query produces "" when name is NULL. The
// resolver must propagate that as DisplayName="" so the handler's
// label shaper can fall back to the UUID-prefix shape.
db, mock := setupMockDB(t)
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("root-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("root-1", "", nil, 0))
got, err := r.ReadableNamespaces(context.Background(), "root-1")
if err != nil {
t.Fatalf("ReadableNamespaces: %v", err)
}
for _, ns := range got {
if ns.DisplayName != "" {
t.Errorf("%q DisplayName = %q, want empty (NULL fallback)", ns.Name, ns.DisplayName)
}
}
}
func TestReadableNamespaces_NotFound(t *testing.T) {
db, mock := setupMockDB(t)
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ghost", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}))
_, err := r.ReadableNamespaces(context.Background(), "ghost")
if !errors.Is(err, ErrWorkspaceNotFound) {
@ -319,8 +399,8 @@ func TestWritableNamespaces_RootSeesAll(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("root-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("root-1", nil, 0))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("root-1", "", nil, 0))
got, err := r.WritableNamespaces(context.Background(), "root-1")
if err != nil {
@ -337,9 +417,9 @@ func TestWritableNamespaces_ChildExcludesOrg(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("child-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("child-1", "root-1", 0).
AddRow("root-1", nil, 1))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("child-1", "", "root-1", 0).
AddRow("root-1", "", nil, 1))
got, err := r.WritableNamespaces(context.Background(), "child-1")
if err != nil {
@ -361,7 +441,7 @@ func TestWritableNamespaces_NotFound(t *testing.T) {
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ghost", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}))
_, err := r.WritableNamespaces(context.Background(), "ghost")
if !errors.Is(err, ErrWorkspaceNotFound) {
@ -390,9 +470,9 @@ func TestCanWrite(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
db, mock := setupMockDB(t)
r := New(db)
rows := sqlmock.NewRows([]string{"id", "parent_id", "depth"})
rows := sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"})
if tc.isRoot {
rows.AddRow("root-1", nil, 0)
rows.AddRow("root-1", "", nil, 0)
mock.ExpectQuery(chainQuerySnippet).WithArgs("root-1", maxChainDepth).WillReturnRows(rows)
ok, err := r.CanWrite(context.Background(), "root-1", tc.namespace)
if err != nil {
@ -402,7 +482,7 @@ func TestCanWrite(t *testing.T) {
t.Errorf("CanWrite(%q) = %v, want %v", tc.namespace, ok, tc.want)
}
} else {
rows.AddRow("child-1", "root-1", 0).AddRow("root-1", nil, 1)
rows.AddRow("child-1", "", "root-1", 0).AddRow("root-1", "", nil, 1)
mock.ExpectQuery(chainQuerySnippet).WithArgs("child-1", maxChainDepth).WillReturnRows(rows)
ok, err := r.CanWrite(context.Background(), "child-1", tc.namespace)
if err != nil {
@ -435,9 +515,9 @@ func TestIntersectReadable_DefaultAll(t *testing.T) {
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("child-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("child-1", "root-1", 0).
AddRow("root-1", nil, 1))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("child-1", "", "root-1", 0).
AddRow("root-1", "", nil, 1))
// Empty requested → return everything readable.
got, err := r.IntersectReadable(context.Background(), "child-1", nil)
@ -455,9 +535,9 @@ func TestIntersectReadable_Filters(t *testing.T) {
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("child-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("child-1", "root-1", 0).
AddRow("root-1", nil, 1))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("child-1", "", "root-1", 0).
AddRow("root-1", "", nil, 1))
// Requested: one allowed, one disallowed (foreign workspace), one allowed
requested := []string{"workspace:child-1", "workspace:foreign", "team:root-1"}
@ -476,8 +556,8 @@ func TestIntersectReadable_AllFiltered(t *testing.T) {
r := New(db)
mock.ExpectQuery(chainQuerySnippet).
WithArgs("ws-1", maxChainDepth).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id", "depth"}).
AddRow("ws-1", nil, 0))
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "parent_id", "depth"}).
AddRow("ws-1", "", nil, 0))
// Request only namespaces the caller cannot read.
got, err := r.IntersectReadable(context.Background(), "ws-1", []string{"workspace:other", "team:other"})