313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import { NavigateFunction } from "react-router";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { getCommandRegistryActions, getSidebarCommandActions } from "./commandRegistry";
|
|
|
|
const t = vi.fn().mockImplementation((value: string) => value) as any;
|
|
|
|
const routing = {
|
|
protocol: "https",
|
|
webAppHost: "app.example.test",
|
|
adminAppHost: "admin.example.test",
|
|
apiHost: "api.example.test",
|
|
appBaseDomain: "example.test",
|
|
};
|
|
|
|
function rootData(tenantApiKeys = true, admin = false, routingConfig?: typeof routing, crm = false) {
|
|
return {
|
|
appConfiguration: {
|
|
app: { features: { tenantApiKeys, crm } },
|
|
portals: { enabled: false, forTenants: false },
|
|
},
|
|
user: { admin },
|
|
routing: routingConfig,
|
|
} as any;
|
|
}
|
|
|
|
function appData(options?: { admin?: boolean; permissions?: string[]; tenantApiKeys?: boolean }) {
|
|
return {
|
|
currentTenant: { id: "tenant_1", slug: "acme" },
|
|
entities: [
|
|
{ name: "company", titlePlural: "models.company.plural", slug: "companies", showInSidebar: true },
|
|
{ name: "contact", titlePlural: "models.contact.plural", slug: "contacts", showInSidebar: true },
|
|
{ name: "opportunity", titlePlural: "models.opportunity.plural", slug: "opportunities", showInSidebar: true },
|
|
],
|
|
myTenants: [{ id: "tenant_2", slug: "other" }],
|
|
permissions: options?.permissions ?? [
|
|
"app.settings.members.view",
|
|
"app.settings.subscription.view",
|
|
"app.settings.account.view",
|
|
"app.settings.apiKeys.view",
|
|
"app.settings.auditTrails.view",
|
|
"entity.company.view",
|
|
"entity.contact.view",
|
|
"entity.opportunity.view",
|
|
],
|
|
isSuperAdmin: false,
|
|
user: { id: "user_1", admin: options?.admin ?? true },
|
|
} as any;
|
|
}
|
|
|
|
function navigateMock() {
|
|
return vi.fn() as unknown as NavigateFunction & { mock: ReturnType<typeof vi.fn>["mock"] };
|
|
}
|
|
|
|
function perform(action: { perform?: (action: any) => void; id: string }) {
|
|
action.perform?.({ id: action.id });
|
|
}
|
|
|
|
function expectNoLegacyPrefixes(targets: string[]) {
|
|
targets.forEach((target) => {
|
|
expect(target).not.toMatch(/^\/app\//);
|
|
expect(target).not.toMatch(/^\/admin(?:\/|$)/);
|
|
});
|
|
}
|
|
|
|
describe("command registry", () => {
|
|
it("builds app tenant commands with host-aware tenant URLs and scope metadata", () => {
|
|
const navigate = navigateMock();
|
|
const actions = getCommandRegistryActions({ layout: "app", t, navigate, appData: appData(), rootData: rootData() });
|
|
|
|
perform(actions.find((action) => action.id === "dashboard")!);
|
|
perform(actions.find((action) => action.id === "members")!);
|
|
perform(actions.find((action) => action.id === "apiKeys")!);
|
|
|
|
expect(navigate).toHaveBeenNthCalledWith(1, "/acme/dashboard");
|
|
expect(navigate).toHaveBeenNthCalledWith(2, "/acme/settings/members");
|
|
expect(navigate).toHaveBeenNthCalledWith(3, "/acme/settings/api");
|
|
expect(actions.find((action) => action.id === "members")?.scope).toMatchObject({
|
|
layout: "app",
|
|
tenantSlug: "acme",
|
|
permission: "app.settings.members.view",
|
|
});
|
|
expect(actions.find((action) => action.id === "apiKeys")?.scope).toMatchObject({ featureFlag: "tenantApiKeys", permission: "app.settings.apiKeys.view" });
|
|
expectNoLegacyPrefixes(navigate.mock.calls.map(([target]) => target));
|
|
});
|
|
|
|
it("omits app commands for empty app data", () => {
|
|
expect(getCommandRegistryActions({ layout: "app", t, navigate: navigateMock(), rootData: rootData() })).toEqual([]);
|
|
});
|
|
|
|
it("filters tenant settings commands by permissions and feature flags", () => {
|
|
const actions = getCommandRegistryActions({
|
|
layout: "app",
|
|
t,
|
|
navigate: navigateMock(),
|
|
appData: appData({ permissions: ["app.settings.account.view"] }),
|
|
rootData: rootData(false),
|
|
});
|
|
|
|
expect(actions.some((action) => action.id === "account")).toBe(true);
|
|
expect(actions.some((action) => action.id === "members")).toBe(false);
|
|
expect(actions.some((action) => action.id === "subscription")).toBe(false);
|
|
expect(actions.some((action) => action.id === "apiKeys")).toBe(false);
|
|
expect(actions.some((action) => action.id === "auditTrails")).toBe(false);
|
|
expect(actions.some((action) => action.id === "events")).toBe(false);
|
|
});
|
|
|
|
it("filters tenant entity commands through permission-aware scope metadata", () => {
|
|
const actions = getCommandRegistryActions({
|
|
layout: "app",
|
|
t,
|
|
navigate: navigateMock(),
|
|
appData: appData({ permissions: ["entity.company.view"] }),
|
|
rootData: rootData(),
|
|
});
|
|
|
|
expect(actions.find((action) => action.id === "entity-company")?.scope).toMatchObject({
|
|
layout: "app",
|
|
tenantSlug: "acme",
|
|
permission: "entity.company.view",
|
|
});
|
|
expect(actions.some((action) => action.id === "entity-contact")).toBe(false);
|
|
expect(actions.some((action) => action.id === "entity-opportunity")).toBe(false);
|
|
});
|
|
|
|
it("omits CRM command while the CRM route tree is disabled", () => {
|
|
const disabled = getCommandRegistryActions({
|
|
layout: "app",
|
|
t,
|
|
navigate: navigateMock(),
|
|
appData: appData(),
|
|
rootData: rootData(true, true, routing, false),
|
|
});
|
|
const enabled = getCommandRegistryActions({
|
|
layout: "app",
|
|
t,
|
|
navigate: navigateMock(),
|
|
appData: appData(),
|
|
rootData: rootData(true, true, routing, true),
|
|
});
|
|
|
|
expect(disabled.some((action) => action.id === "crm")).toBe(false);
|
|
expect(enabled.some((action) => action.id === "crm")).toBe(false);
|
|
});
|
|
|
|
it("keeps platform commands available when scoped tenant commands are denied", () => {
|
|
const actions = getCommandRegistryActions({
|
|
layout: "app",
|
|
t,
|
|
navigate: navigateMock(),
|
|
appData: appData({ admin: false, permissions: [] }),
|
|
rootData: rootData(),
|
|
});
|
|
|
|
expect(actions.some((action) => action.id === "entity-company")).toBe(false);
|
|
expect(actions.some((action) => action.id === "create account")).toBe(true);
|
|
expect(actions.some((action) => action.id === "logout")).toBe(true);
|
|
});
|
|
|
|
it("omits switch-to-admin for non-admin app users", () => {
|
|
const actions = getCommandRegistryActions({ layout: "app", t, navigate: navigateMock(), appData: appData({ admin: false }), rootData: rootData() });
|
|
|
|
expect(actions.some((action) => action.id === "switch to admin")).toBe(false);
|
|
});
|
|
|
|
it("switches app admins to the admin dashboard instead of the admin host root", () => {
|
|
const navigate = navigateMock();
|
|
const actions = getCommandRegistryActions({ layout: "app", t, navigate, appData: appData({ admin: true }), rootData: rootData(true, true, routing) });
|
|
|
|
vi.stubGlobal("window", undefined);
|
|
try {
|
|
perform(actions.find((action) => action.id === "switch to admin")!);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
|
|
expect(navigate).toHaveBeenCalledWith("https://admin.example.test/dashboard");
|
|
});
|
|
|
|
it("filters admin sidebar commands for non-superadmin contexts while preserving platform commands", () => {
|
|
const actions = getCommandRegistryActions({ layout: "admin", t, navigate: navigateMock(), rootData: rootData() });
|
|
|
|
expect(actions.some((action) => action.id === "/dashboard")).toBe(false);
|
|
expect(actions.some((action) => action.id === "create account")).toBe(true);
|
|
expect(actions.some((action) => action.id === "logout")).toBe(true);
|
|
});
|
|
|
|
it("builds admin commands when app data has no current tenant", () => {
|
|
const actions = getCommandRegistryActions({
|
|
layout: "admin",
|
|
t,
|
|
navigate: navigateMock(),
|
|
appData: { ...appData({ admin: true }), currentTenant: null, isSuperAdmin: true } as any,
|
|
rootData: rootData(true, true),
|
|
});
|
|
|
|
expect(actions.some((action) => action.id === "/dashboard")).toBe(true);
|
|
expect(actions.every((action) => action.scope.layout === "admin")).toBe(true);
|
|
});
|
|
|
|
it("builds admin commands with host-aware paths and app fallback switch", () => {
|
|
const navigate = navigateMock();
|
|
const actions = getCommandRegistryActions({ layout: "admin", t, navigate, rootData: rootData(true, true) });
|
|
|
|
perform(actions.find((action) => action.id === "/dashboard")!);
|
|
perform(actions.find((action) => action.id === "/settings/profile")!);
|
|
perform(actions.find((action) => action.name === "admin.switchToApp")!);
|
|
|
|
expect(navigate).toHaveBeenNthCalledWith(1, "/dashboard");
|
|
expect(navigate).toHaveBeenNthCalledWith(2, "/settings/profile");
|
|
expect(navigate).toHaveBeenNthCalledWith(3, "/");
|
|
expect(actions.find((action) => action.id === "/dashboard")?.scope).toMatchObject({ layout: "admin", platform: true, adminOnly: true });
|
|
expect(actions.find((action) => action.name === "admin.switchToApp")?.scope).toMatchObject({ layout: "admin", platform: true, adminOnly: true });
|
|
expectNoLegacyPrefixes(navigate.mock.calls.map(([target]) => target));
|
|
});
|
|
|
|
it("does not expose expandable admin sidebar parents as command destinations", () => {
|
|
const actions = getCommandRegistryActions({
|
|
layout: "admin",
|
|
t,
|
|
navigate: navigateMock(),
|
|
appData: {
|
|
...appData({
|
|
admin: true,
|
|
permissions: ["admin.accounts.view", "admin.settings.view"],
|
|
}),
|
|
currentTenant: null,
|
|
isSuperAdmin: true,
|
|
} as any,
|
|
rootData: rootData(true, true),
|
|
});
|
|
|
|
expect(actions.some((action) => action.id === "/accounts" && action.name === "app.sidebar.accountsAndUsers")).toBe(false);
|
|
expect(actions.some((action) => action.id === "/settings" && action.name === "app.sidebar.settings")).toBe(false);
|
|
expect(actions.some((action) => action.id === "/accounts" && action.name === "admin.tenants.title")).toBe(true);
|
|
expect(actions.some((action) => action.id === "/settings/profile")).toBe(true);
|
|
});
|
|
|
|
it("does not expose disabled sidebar items as command destinations", () => {
|
|
const actions = getSidebarCommandActions(
|
|
[
|
|
{
|
|
title: "Settings",
|
|
path: "/settings",
|
|
items: [
|
|
{ title: "Profile", path: "/settings/profile" },
|
|
{ title: "Disabled Plugin", path: "/settings/plugin", disabled: true },
|
|
],
|
|
},
|
|
],
|
|
"admin"
|
|
);
|
|
|
|
expect(actions.some((action) => action.id === "/settings/profile")).toBe(true);
|
|
expect(actions.some((action) => action.id === "/settings/plugin")).toBe(false);
|
|
});
|
|
|
|
it("uses parent sidebar titles as command subtitles", () => {
|
|
const actions = getSidebarCommandActions(
|
|
[
|
|
{
|
|
title: "Settings",
|
|
path: "/settings",
|
|
items: [{ title: "Profile", path: "/settings/profile" }],
|
|
},
|
|
],
|
|
"admin"
|
|
);
|
|
|
|
expect(actions.find((action) => action.id === "/settings/profile")?.subtitle).toBe("Settings");
|
|
});
|
|
|
|
it("builds admin public-app commands with absolute app-host URLs when routing is configured", () => {
|
|
const navigate = navigateMock();
|
|
const actions = getCommandRegistryActions({ layout: "admin", t, navigate, rootData: rootData(true, true, routing) });
|
|
|
|
expect(actions.find((action) => action.name === "admin.switchToApp")?.id).toBe("https://app.example.test/");
|
|
|
|
vi.stubGlobal("window", undefined);
|
|
try {
|
|
perform(actions.find((action) => action.name === "admin.switchToApp")!);
|
|
perform(actions.find((action) => action.id === "create account")!);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
|
|
expect(navigate).toHaveBeenNthCalledWith(1, "https://app.example.test/");
|
|
expect(navigate).toHaveBeenNthCalledWith(2, "https://app.example.test/new-account");
|
|
});
|
|
|
|
it("passes docs commands through with registry scope and navigation", () => {
|
|
const navigate = navigateMock();
|
|
const actions = getCommandRegistryActions({ layout: "docs", t, navigate });
|
|
const firstAction = actions[0];
|
|
|
|
perform(firstAction);
|
|
|
|
expect(firstAction.scope).toMatchObject({ layout: "docs", platform: true });
|
|
expect(navigate).toHaveBeenCalledWith(firstAction.id);
|
|
});
|
|
|
|
it("exposes app, admin, and docs commands directly through the registry", () => {
|
|
expect(getCommandRegistryActions({ layout: "app", t, navigate: navigateMock(), appData: appData(), rootData: rootData() })[0]).toMatchObject({
|
|
id: "dashboard",
|
|
scope: { layout: "app" },
|
|
});
|
|
expect(getCommandRegistryActions({ layout: "admin", t, navigate: navigateMock(), rootData: rootData(true, true) })[0]).toMatchObject({
|
|
id: "/dashboard",
|
|
scope: { layout: "admin" },
|
|
});
|
|
expect(getCommandRegistryActions({ layout: "docs", t, navigate: navigateMock() })[0]).toMatchObject({ scope: { layout: "docs" } });
|
|
});
|
|
});
|