forked from emdash-cms/emdash
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSidebar.test.tsx
More file actions
260 lines (231 loc) · 9.65 KB
/
Copy pathSidebar.test.tsx
File metadata and controls
260 lines (231 loc) · 9.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
/**
* Sidebar nav invariants — Phase 5 visibility regression guard for the
* "Byline Schema" entry (Discussion #1174).
*
* AC: "Admin sees the 'Byline Schema' sidebar entry; Editor does not."
*
* The full SidebarNav component is hard to test against because Kumo's
* Sidebar primitive portals its rendered content to `document.body`,
* applies collapse-state CSS that hides labels at narrow viewports
* (the vitest-browser-react default), and runs Radix-style provider
* choreography that doesn't surface anchors via `screen.container`.
* Mounting tests against it produced inconsistent results across role
* cases that should have been symmetric.
*
* Instead, the source `Sidebar.tsx` exports two pure artefacts:
*
* - `BYLINE_SCHEMA_NAV_ITEM` — the route + minRole pairing used
* verbatim inside the runtime `adminItems` array.
* - `filterNavItemsByRole` — the pure role filter applied to every
* nav group.
*
* Together they cover the AC without DOM coupling: the constant pins
* the contract, the filter pins the gate.
*/
import {
Plug,
Gear,
Trophy,
ClockCounterClockwise,
IdentificationCard,
} from "@phosphor-icons/react";
import * as React from "react";
import { describe, it, expect } from "vitest";
import {
BYLINE_SCHEMA_NAV_ITEM,
filterNavItemsByRole,
getSidebarTaxonomies,
isItemActive,
resolveItemPath,
resolveNavIcon,
resolvePluginPageLabel,
toPhosphorIconName,
visibleCollectionEntries,
} from "../../src/components/Sidebar";
import { render } from "../utils/render.tsx";
// Mirror @emdash-cms/auth Role levels. Kept inline (matching Sidebar.tsx)
// to avoid a runtime dependency just to read two numeric constants.
const ROLE_SUBSCRIBER = 10;
const ROLE_CONTRIBUTOR = 20;
const ROLE_AUTHOR = 30;
const ROLE_EDITOR = 40;
const ROLE_ADMIN = 50;
describe("getSidebarTaxonomies", () => {
const taxonomies = [
{ id: "course-en", name: "course", label: "Courses", locale: "en", translationGroup: "course" },
{ id: "course-de", name: "course", label: "Gänge", locale: "de", translationGroup: "course" },
{
id: "course-fr",
name: "course",
label: "Types de plats",
locale: "fr",
translationGroup: "course",
},
];
it("renders one logical taxonomy using the active route locale", () => {
expect(getSidebarTaxonomies(taxonomies, "de").map((taxonomy) => taxonomy.label)).toEqual([
"Gänge",
]);
});
it("falls back to the configured default locale, then deterministically", () => {
expect(getSidebarTaxonomies(taxonomies, "it", "fr")[0]?.label).toBe("Types de plats");
expect(getSidebarTaxonomies(taxonomies, "it")[0]?.label).toBe("Gänge");
});
});
describe("resolveItemPath", () => {
it("preserves the active locale on taxonomy-management links", () => {
expect(
resolveItemPath({
to: "/taxonomies/$taxonomy",
label: "Gänge",
icon: Gear,
params: { taxonomy: "course" },
search: { locale: "de" },
}),
).toBe("/taxonomies/course?locale=de");
});
});
describe("isItemActive", () => {
it("matches taxonomy links independently of their locale query", () => {
expect(isItemActive("/taxonomies/course?locale=de", "/taxonomies/course")).toBe(true);
});
});
describe("BYLINE_SCHEMA_NAV_ITEM invariants", () => {
it("points to the /byline-schema route", () => {
expect(BYLINE_SCHEMA_NAV_ITEM.to).toBe("/byline-schema");
expect(BYLINE_SCHEMA_NAV_ITEM.icon).toBe(IdentificationCard);
});
it("gates on ROLE_ADMIN — editors and below must not see it", () => {
// If anyone drops this to ROLE_EDITOR (40), editors gain
// access to admin-only schema management via the sidebar.
// Keep this asserting the literal 50 (not the constant) so a
// rename like `ROLE_ADMIN = 40` would also fail the test.
expect(BYLINE_SCHEMA_NAV_ITEM.minRole).toBe(50);
});
});
describe("filterNavItemsByRole", () => {
const items = [
{ to: "/", minRole: undefined },
{ to: "/bylines", minRole: ROLE_EDITOR },
{ to: "/byline-schema", minRole: ROLE_ADMIN },
];
it("passes items without minRole at every role", () => {
for (const role of [ROLE_SUBSCRIBER, ROLE_CONTRIBUTOR, ROLE_AUTHOR, ROLE_EDITOR, ROLE_ADMIN]) {
expect(filterNavItemsByRole(items, role).map((i) => i.to)).toContain("/");
}
});
it("excludes /byline-schema for EDITOR", () => {
// Direct check of the AC: an Editor must not see the entry.
const visible = filterNavItemsByRole(items, ROLE_EDITOR).map((i) => i.to);
expect(visible).not.toContain("/byline-schema");
});
it("excludes /byline-schema for AUTHOR, CONTRIBUTOR, SUBSCRIBER", () => {
for (const role of [ROLE_SUBSCRIBER, ROLE_CONTRIBUTOR, ROLE_AUTHOR]) {
const visible = filterNavItemsByRole(items, role).map((i) => i.to);
expect(visible).not.toContain("/byline-schema");
}
});
it("includes /byline-schema for ADMIN", () => {
const visible = filterNavItemsByRole(items, ROLE_ADMIN).map((i) => i.to);
expect(visible).toContain("/byline-schema");
});
it("treats role=0 (unauthenticated / pre-fetch) as below every gate", () => {
// SidebarNav falls back to `userRole ?? 0` during the brief
// load window before `useCurrentUser` resolves. The filter
// must strip every gated entry at role=0.
const visible = filterNavItemsByRole(items, 0).map((i) => i.to);
expect(visible).toEqual(["/"]);
});
});
describe("visibleCollectionEntries", () => {
const collections = {
posts: { label: "Posts" },
contact_submissions: { label: "Contact Submissions", hidden: true },
lead_notes: { label: "Lead Notes", hidden: true },
pages: { label: "Pages", hidden: false },
};
it("drops collections flagged hidden", () => {
expect(visibleCollectionEntries(collections).map(([slug]) => slug)).toEqual(["posts", "pages"]);
});
it("keeps manifest order for visible collections", () => {
expect(visibleCollectionEntries({ b: { label: "B" }, a: { label: "A" } })).toEqual([
["b", { label: "B" }],
["a", { label: "A" }],
]);
});
it("treats a missing hidden flag as visible", () => {
expect(visibleCollectionEntries({ posts: { label: "Posts" } })).toHaveLength(1);
});
});
describe("resolvePluginPageLabel", () => {
// Simulates a plugin that loaded its Lingui catalog into the shared i18n
// instance: known msgids translate, unknown ones return the msgid itself
// (exactly i18n._'s fallback behavior).
const translate = (id: string) => (id === "Orders" ? "Bestellungen" : id);
it("translates a declared label through the shared i18n instance", () => {
expect(resolvePluginPageLabel("Orders", "my-shop", translate)).toBe("Bestellungen");
});
it("falls back to the literal label when no catalog entry exists", () => {
// Plugins without a Lingui catalog must render exactly what they
// declared — the identity lookup keeps this fully backwards compatible.
expect(resolvePluginPageLabel("Products", "my-shop", translate)).toBe("Products");
});
it("prettifies the plugin id when no label is declared (untranslated)", () => {
// The fallback is derived from the package id, not author-provided
// English — never run it through the catalog.
expect(resolvePluginPageLabel(undefined, "my-shop", translate)).toBe("My Shop");
expect(resolvePluginPageLabel(undefined, "orders", translate)).toBe("Orders");
});
});
describe("toPhosphorIconName", () => {
it("converts kebab/snake/space names to PascalCase (the lazy-path key)", () => {
// Any Phosphor icon is reachable by its own kebab name.
expect(toPhosphorIconName("chart-bar")).toBe("ChartBar");
expect(toPhosphorIconName("clock-counter-clockwise")).toBe("ClockCounterClockwise");
expect(toPhosphorIconName("magnifying-glass")).toBe("MagnifyingGlass");
expect(toPhosphorIconName("heart")).toBe("Heart");
});
});
describe("resolveNavIcon", () => {
it("falls back to Plug when no icon is provided", () => {
// `icon` is optional on adminPages; an omitted value is the
// common case and must resolve synchronously to the default
// (no Suspense boundary needed for the icon-less page).
expect(resolveNavIcon(undefined)).toBe(Plug);
expect(resolveNavIcon("")).toBe(Plug);
});
it("resolves common/documented names synchronously from the static map", () => {
// These ship in the main bundle and must NOT be lazy — the
// everyday case never loads the full Phosphor set. Includes a
// lucide-style alias (`settings` → Gear) and a Phosphor-named one.
expect(resolveNavIcon("settings")).toBe(Gear);
expect(resolveNavIcon("trophy")).toBe(Trophy);
expect(resolveNavIcon("history")).toBe(ClockCounterClockwise);
});
it("returns a stable (memoized) lazy component for a name outside the map", () => {
// `heart` isn't in the static map, so it takes the lazy path.
// React.lazy identity must be stable across renders, otherwise the
// icon remounts and re-suspends — repeated calls return the same ref.
const first = resolveNavIcon("heart");
const second = resolveNavIcon("heart");
expect(first).toBe(second);
expect(first).not.toBe(Plug);
expect((first as { $$typeof?: symbol }).$$typeof).toBe(Symbol.for("react.lazy"));
});
it("renders the Plug fallback for a name that doesn't exist in Phosphor", async () => {
// The lazy path resolves an unknown icon name to Plug. Drive
// it through a real render (not the Kumo Sidebar — just the icon) and
// confirm the rendered glyph IS Plug by comparing the SVG body
// against a directly-rendered reference.
const Unknown = resolveNavIcon("definitely-not-a-real-icon-xyz");
const screen = await render(
<React.Suspense fallback={<span>loading</span>}>
<Unknown data-testid="resolved" />
<Plug data-testid="expected" />
</React.Suspense>,
);
const resolved = screen.getByTestId("resolved");
await expect.element(resolved).toBeInTheDocument();
expect(resolved.element().innerHTML).toBe(screen.getByTestId("expected").element().innerHTML);
});
});