feat: add gated housekeeping foundation preview

This commit is contained in:
Simo committed 2026-08-25 20:45:40 +02:00
1 parent cc241f0b7e
commit d19ba88005
9 files changed
+516

No files matched your search

@@ -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))(?:\/|["'])/,
);
}
});
});