fix(memory-v2): namespace dropdown labels use display names not UUID prefixes (#2988)
User feedback on the v2 Memory tab redesign: on a root workspace, the
namespace dropdown showed three indistinguishable entries:
Workspace (30ba7f0b)
Team (30ba7f0b) (team)
Org (30ba7f0b-b303-4a20-aefe-3a4a675b8aa4) (org)
For a root workspace, the resolver collapses workspace==team==org IDs
(resolver.go:113-122 derive() degenerate case). The previous
shortID(8)-truncated UUID label scheme made all three look identical
even though the three concepts (private / team-shared / org-wide)
remain semantically distinct.
## Backend — Resolver returns DisplayName
- SQL chain query now SELECTs workspaces.name (COALESCE → "" on NULL)
- chainNode carries .name through walk
- deriveNames() computes the display name for each namespace,
mirroring derive():
workspace: self.name
team: parent.name (or self.name if root — degenerate)
org: chain[end].name (root of tree)
- Namespace struct gets a new DisplayName field, omitempty wire-shape
## Backend — Handler renders label from DisplayName when present
- memories_v2.go:namespaceLabelWithName(name, kind, displayName) is
the new SSOT label generator. Falls back to the UUID-prefix shape
when displayName is empty so callers without name plumbing keep
working unchanged.
- namespacesToViews now plumbs Namespace.DisplayName into the label.
- Old namespaceLabel(name, kind) is preserved as a thin wrapper
around namespaceLabelWithName(_, _, "") for back-compat.
- Custom namespaces ignore displayName by design — operator-defined
suffixes ARE the chosen label; a name override would surprise.
## Frontend — drop redundant `(kind)` suffix
Pre-fix: "Team (mac laptop) (team)" — kind shown twice.
Post-fix: "Team (mac laptop)" — the prefix already conveys the kind.
## Test coverage
Resolver (3 new tests):
- DisplayName_Root: workspace name propagates to all 3 namespaces
- DisplayName_Child: workspace=self.name, team=parent.name, org=root.name
- DisplayName_EmptyOnNULL: COALESCE → "" → empty fallback
Handler (3 new tests):
- NamespaceLabelWithName_PrefersDisplayName: workspace/team/org/custom paths
- NamespaceLabelWithName_FallsBackToUUIDPrefix: empty displayName → legacy shape
- NamespacesToViews_PassesDisplayNameThrough: full integration on root case
Canvas: existing 30 tests still pass; suffix drop is rendering-only.
memories_v2.go function coverage: **14/14 = 100%**
- namespaceLabelWithName: 100%
- namespacesToViews: 100%
- (all 11 pre-existing functions stay at 100%)
## SSOT
The "what is this namespace called" question now has one source of
truth: namespace.Resolver.ReadableNamespaces sets DisplayName from the
canonical workspace.name column. The handler is a renderer; the
canvas is a consumer. No name-lookup logic duplicated across the
three layers.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
parent
0886dbc923
commit
b6310d7ebf
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user