feat: add gated housekeeping foundation preview
This commit is contained in:
1 parent
cc241f0b7e
commit
d19ba88005
9 files changed
+516
No files matched your search
@@ -17,6 +17,8 @@ NODE_ENV=production
|
|||||||
PORT=3002
|
PORT=3002
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
UV_THREADPOOL_SIZE=16
|
UV_THREADPOOL_SIZE=16
|
||||||
|
# Non-production preview only; production always returns 404.
|
||||||
|
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
|
||||||
|
|
||||||
# --- HOTEL & URLS ---
|
# --- HOTEL & URLS ---
|
||||||
HOTEL_NAME=EPIC WEB CONTROL
|
HOTEL_NAME=EPIC WEB CONTROL
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
|
||||||
|
import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation";
|
||||||
|
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||||
|
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||||
|
import { HousekeepingShell } from "@/features/housekeeping/foundation/shell/housekeeping-shell";
|
||||||
|
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||||
|
|
||||||
|
const MESSAGE_PREFIX = "pages.housekeeping.";
|
||||||
|
|
||||||
|
function namespaceKey(key: string): string {
|
||||||
|
if (!key.startsWith(MESSAGE_PREFIX)) {
|
||||||
|
throw new Error(`invalid housekeeping message key: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return key.slice(MESSAGE_PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminNextDomainLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
params: Promise<{ domain: string }>;
|
||||||
|
}) {
|
||||||
|
const { domain } = await params;
|
||||||
|
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||||
|
const activeDomain = registry.domains.find((entry) => entry.id === domain);
|
||||||
|
|
||||||
|
if (!activeDomain) notFound();
|
||||||
|
|
||||||
|
const context = await getHousekeepingCapabilityContext();
|
||||||
|
if (!satisfiesCapability(context, activeDomain.capability)) notFound();
|
||||||
|
|
||||||
|
const translate = await getTranslations("pages.housekeeping");
|
||||||
|
const navigation = buildHousekeepingNavigation(registry, context, (key) =>
|
||||||
|
translate(namespaceKey(key) as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HousekeepingShell
|
||||||
|
actor={context.actor}
|
||||||
|
activeDomainId={activeDomain.id}
|
||||||
|
domains={navigation}
|
||||||
|
labels={{
|
||||||
|
skipToContent: translate("navigation.skipToContent"),
|
||||||
|
primaryNavigation: translate("navigation.primary"),
|
||||||
|
contextualNavigation: translate("navigation.contextual"),
|
||||||
|
command: translate("preview.commandDisabled"),
|
||||||
|
preview: translate("preview.badge"),
|
||||||
|
backToSite: translate("preview.backToSite"),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</HousekeepingShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { HousekeepingPageShell } from "@/features/housekeeping/foundation/page/housekeeping-page-shell";
|
||||||
|
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
|
||||||
|
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||||
|
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||||
|
|
||||||
|
const MESSAGE_PREFIX = "pages.housekeeping.";
|
||||||
|
|
||||||
|
function namespaceKey(key: string): string {
|
||||||
|
if (!key.startsWith(MESSAGE_PREFIX)) {
|
||||||
|
throw new Error(`invalid housekeeping message key: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return key.slice(MESSAGE_PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminNextDomainPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ domain: string }>;
|
||||||
|
}) {
|
||||||
|
const { domain } = await params;
|
||||||
|
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||||
|
const activeDomain = registry.domains.find((entry) => entry.id === domain);
|
||||||
|
|
||||||
|
if (!activeDomain) notFound();
|
||||||
|
|
||||||
|
const translate = await getTranslations("pages.housekeeping");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HousekeepingPageShell
|
||||||
|
title={translate(namespaceKey(activeDomain.labelKey) as never)}
|
||||||
|
description={translate(
|
||||||
|
namespaceKey(activeDomain.descriptionKey) as never,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HousekeepingPageState
|
||||||
|
state="empty"
|
||||||
|
title={translate("states.empty.title")}
|
||||||
|
description={translate("states.empty.description")}
|
||||||
|
/>
|
||||||
|
</HousekeepingPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { env } from "@/env";
|
||||||
|
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
|
||||||
|
|
||||||
|
export default function AdminNextLayout({ children }: { children: ReactNode }) {
|
||||||
|
if (
|
||||||
|
!isHousekeepingPreviewEnabled({
|
||||||
|
nodeEnv: env.NODE_ENV,
|
||||||
|
flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
|
||||||
|
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||||
|
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||||
|
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||||
|
|
||||||
|
export default async function AdminNextPage() {
|
||||||
|
const context = await getHousekeepingCapabilityContext();
|
||||||
|
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||||
|
const firstVisibleDomain = registry.domains.find((domain) =>
|
||||||
|
satisfiesCapability(context, domain.capability),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!firstVisibleDomain) notFound();
|
||||||
|
|
||||||
|
redirect(firstVisibleDomain.previewHref);
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ const schema = z
|
|||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "test", "production"])
|
.enum(["development", "test", "production"])
|
||||||
.default("development"),
|
.default("development"),
|
||||||
|
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value === "true" || value === "1"),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(10),
|
DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(10),
|
||||||
DATABASE_IDLE_TIMEOUT_MS: z.coerce
|
DATABASE_IDLE_TIMEOUT_MS: z.coerce
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { isHousekeepingPreviewEnabled } from "./preview-gate";
|
||||||
|
|
||||||
|
describe("isHousekeepingPreviewEnabled", () => {
|
||||||
|
it.each([
|
||||||
|
["development", true, true],
|
||||||
|
["test", true, true],
|
||||||
|
["development", false, false],
|
||||||
|
["production", true, false],
|
||||||
|
["production", false, false],
|
||||||
|
] as const)("NODE_ENV=%s flag=%s => %s", (nodeEnv, flag, expected) => {
|
||||||
|
expect(isHousekeepingPreviewEnabled({ nodeEnv, flag })).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => {
|
||||||
|
const originalSkipValidation = process.env.SKIP_ENV_VALIDATION;
|
||||||
|
const originalPreviewFlag = process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
delete process.env.SKIP_ENV_VALIDATION;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalSkipValidation === undefined) {
|
||||||
|
delete process.env.SKIP_ENV_VALIDATION;
|
||||||
|
} else {
|
||||||
|
process.env.SKIP_ENV_VALIDATION = originalSkipValidation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalPreviewFlag === undefined) {
|
||||||
|
delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
|
||||||
|
} else {
|
||||||
|
process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = originalPreviewFlag;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["true", true],
|
||||||
|
["1", true],
|
||||||
|
["false", false],
|
||||||
|
["yes", false],
|
||||||
|
[undefined, false],
|
||||||
|
] as const)("normalizes %s to %s", async (value, expected) => {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
|
||||||
|
} else {
|
||||||
|
process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await import("@/env");
|
||||||
|
|
||||||
|
expect(env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function isHousekeepingPreviewEnabled(input: {
|
||||||
|
nodeEnv: "development" | "test" | "production";
|
||||||
|
flag: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return input.nodeEnv !== "production" && input.flag;
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { createElement, type ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingCapabilityContext } from "./contracts";
|
||||||
|
|
||||||
|
const routeMocks = vi.hoisted(() => {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
"preview.badge": "Localized preview",
|
||||||
|
"preview.commandDisabled": "Localized disabled command",
|
||||||
|
"preview.backToSite": "Localized back to site",
|
||||||
|
"navigation.skipToContent": "Localized skip to content",
|
||||||
|
"navigation.primary": "Localized primary navigation",
|
||||||
|
"navigation.contextual": "Localized contextual navigation",
|
||||||
|
"domains.people.title": "Localized People",
|
||||||
|
"domains.people.description": "Localized People description",
|
||||||
|
"domains.economy.title": "Localized Economy",
|
||||||
|
"domains.economy.description": "Localized Economy description",
|
||||||
|
"states.empty.title": "Localized empty title",
|
||||||
|
"states.empty.description": "Localized empty description",
|
||||||
|
};
|
||||||
|
const translate = vi.fn((key: string) => {
|
||||||
|
const message = messages[key];
|
||||||
|
if (message === undefined)
|
||||||
|
throw new Error(`Unexpected translation: ${key}`);
|
||||||
|
return message;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
env: {
|
||||||
|
NODE_ENV: "test" as "development" | "test" | "production",
|
||||||
|
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: true,
|
||||||
|
},
|
||||||
|
getHousekeepingCapabilityContext: vi.fn(),
|
||||||
|
getTranslations: vi.fn(async (namespace: string) => {
|
||||||
|
if (namespace !== "pages.housekeeping") {
|
||||||
|
throw new Error(`Unexpected namespace: ${namespace}`);
|
||||||
|
}
|
||||||
|
return translate;
|
||||||
|
}),
|
||||||
|
notFound: vi.fn((): never => {
|
||||||
|
throw new Error("NEXT_NOT_FOUND");
|
||||||
|
}),
|
||||||
|
redirect: vi.fn((href: string): never => {
|
||||||
|
throw new Error(`NEXT_REDIRECT:${href}`);
|
||||||
|
}),
|
||||||
|
translate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/env", () => ({ env: routeMocks.env }));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
notFound: routeMocks.notFound,
|
||||||
|
redirect: routeMocks.redirect,
|
||||||
|
}));
|
||||||
|
vi.mock("next-intl/server", () => ({
|
||||||
|
getTranslations: routeMocks.getTranslations,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||||
|
getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", () => {
|
||||||
|
throw new Error("preview routes must not import the database");
|
||||||
|
});
|
||||||
|
vi.mock("@/lib/auth", () => {
|
||||||
|
throw new Error("preview routes must not call auth directly");
|
||||||
|
});
|
||||||
|
vi.mock("@/lib/permissions", () => {
|
||||||
|
throw new Error("preview routes must not reload permissions directly");
|
||||||
|
});
|
||||||
|
vi.mock("@/actions", () => {
|
||||||
|
throw new Error("preview routes must not import actions");
|
||||||
|
});
|
||||||
|
vi.mock("@/app/actions", () => {
|
||||||
|
throw new Error("preview routes must not import actions");
|
||||||
|
});
|
||||||
|
|
||||||
|
import AdminNextDomainLayout from "@/app/admin-next/[domain]/layout";
|
||||||
|
import AdminNextDomainPage from "@/app/admin-next/[domain]/page";
|
||||||
|
import AdminNextLayout from "@/app/admin-next/layout";
|
||||||
|
import AdminNextPage from "@/app/admin-next/page";
|
||||||
|
|
||||||
|
const routeFiles = [
|
||||||
|
"src/app/admin-next/layout.tsx",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
"src/app/admin-next/[domain]/layout.tsx",
|
||||||
|
"src/app/admin-next/[domain]/page.tsx",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function capabilityContext(
|
||||||
|
granted: readonly string[],
|
||||||
|
actor = { id: 42, username: "refreshed-moderator", rank: 3 },
|
||||||
|
): HousekeepingCapabilityContext {
|
||||||
|
const capabilities = new Set(granted);
|
||||||
|
|
||||||
|
return {
|
||||||
|
actor,
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: (slug) => capabilities.has(slug),
|
||||||
|
hasAny: (...slugs) => slugs.some((slug) => capabilities.has(slug)),
|
||||||
|
hasAll: (...slugs) => slugs.every((slug) => capabilities.has(slug)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRoute(route: ReactNode | Promise<ReactNode>) {
|
||||||
|
return renderToStaticMarkup(await route);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("/admin-next preview gate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
routeMocks.env.NODE_ENV = "test";
|
||||||
|
routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["production", true],
|
||||||
|
["production", false],
|
||||||
|
["development", false],
|
||||||
|
] as const)("returns 404 for NODE_ENV=%s flag=%s", async (nodeEnv, flag) => {
|
||||||
|
routeMocks.env.NODE_ENV = nodeEnv;
|
||||||
|
routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = flag;
|
||||||
|
|
||||||
|
await expect(async () =>
|
||||||
|
renderRoute(
|
||||||
|
AdminNextLayout({
|
||||||
|
children: createElement("p", null, "Preview child"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||||
|
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["development", "test"] as const)(
|
||||||
|
"renders children in %s when explicitly enabled",
|
||||||
|
async (nodeEnv) => {
|
||||||
|
routeMocks.env.NODE_ENV = nodeEnv;
|
||||||
|
|
||||||
|
const html = await renderRoute(
|
||||||
|
AdminNextLayout({
|
||||||
|
children: createElement("p", null, "Preview child"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Preview child");
|
||||||
|
expect(routeMocks.notFound).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/admin-next first visible domain", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects an administrator to Operations in locked registry order", async () => {
|
||||||
|
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||||
|
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(AdminNextPage()).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT:/admin-next/operations",
|
||||||
|
);
|
||||||
|
expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/operations");
|
||||||
|
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects a moderator with only an approved mod view capability to People", async () => {
|
||||||
|
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||||
|
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(AdminNextPage()).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT:/admin-next/people",
|
||||||
|
);
|
||||||
|
expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/people");
|
||||||
|
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the operator has no visible domain", async () => {
|
||||||
|
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||||
|
capabilityContext([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
|
||||||
|
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
|
||||||
|
expect(routeMocks.redirect).not.toHaveBeenCalled();
|
||||||
|
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/admin-next/[domain] layout", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown domain before loading capability context", async () => {
|
||||||
|
await expect(
|
||||||
|
AdminNextDomainLayout({
|
||||||
|
children: createElement("p", null, "Unknown body"),
|
||||||
|
params: Promise.resolve({ domain: "unknown" }),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||||
|
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
|
||||||
|
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a known domain that the operator cannot access", async () => {
|
||||||
|
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||||
|
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
AdminNextDomainLayout({
|
||||||
|
children: createElement("p", null, "Economy body"),
|
||||||
|
params: Promise.resolve({ domain: "economy" }),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||||
|
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders localized People shell from one refreshed capability context", async () => {
|
||||||
|
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||||
|
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = await renderRoute(
|
||||||
|
AdminNextDomainLayout({
|
||||||
|
children: createElement("p", null, "People body"),
|
||||||
|
params: Promise.resolve({ domain: "people" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("refreshed-moderator");
|
||||||
|
expect(html).toContain("Localized preview");
|
||||||
|
expect(html).toContain("Localized primary navigation");
|
||||||
|
expect(html).toContain("Localized People");
|
||||||
|
expect(html).toContain("People body");
|
||||||
|
expect(html).not.toContain("Localized Economy");
|
||||||
|
expect(routeMocks.translate).not.toHaveBeenCalledWith(
|
||||||
|
"domains.economy.title",
|
||||||
|
);
|
||||||
|
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/admin-next/[domain] page", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the real localized manifest and empty state without reloading access", async () => {
|
||||||
|
const html = await renderRoute(
|
||||||
|
AdminNextDomainPage({
|
||||||
|
params: Promise.resolve({ domain: "people" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Localized People");
|
||||||
|
expect(html).toContain("Localized People description");
|
||||||
|
expect(html).toContain("Localized empty title");
|
||||||
|
expect(html).toContain("Localized empty description");
|
||||||
|
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
|
||||||
|
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown domain before translating", async () => {
|
||||||
|
await expect(
|
||||||
|
AdminNextDomainPage({
|
||||||
|
params: Promise.resolve({ domain: "unknown" }),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||||
|
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
|
||||||
|
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("preview route import boundary", () => {
|
||||||
|
it("rejects data, actions, direct auth, old chrome, and legacy route imports", () => {
|
||||||
|
for (const path of routeFiles) {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), path), "utf8");
|
||||||
|
|
||||||
|
expect(source, path).not.toMatch(
|
||||||
|
/@\/lib\/db|@\/(?:app\/)?actions(?:\/|["'])/,
|
||||||
|
);
|
||||||
|
expect(source, path).not.toMatch(/AdminSidebarNav|AdminHubChrome/);
|
||||||
|
expect(source, path).not.toMatch(/@\/lib\/(?:auth|permissions)/);
|
||||||
|
expect(source, path).not.toMatch(
|
||||||
|
/(?:@\/app\/(?:admin|mod)|\.\.\/+(?:admin|mod))(?:\/|["'])/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user