fix(demo): populate admin data, fix settings crash and mobile editor icon (#463)

Rewrite the demo mock API around a seeded in-memory dataset with correct response shapes so the People/Teams/Roles/Audit/Usage/API-keys tabs stop crashing (the /auth/users vs /v1/users mismatch caused users.filter() on undefined) and show realistic sample data. In-memory CRUD makes the settings buttons work. Copy edit-image.png into the demo so the mobile editor icon renders. Adds unit shape guards and an e2e admin-settings walkthrough.
This commit is contained in:
SnapOtter
2026-07-07 20:13:20 +08:00
committed by GitHub
parent e468cef6c0
commit 7329bc6a13
4 changed files with 1022 additions and 85 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+849 -85
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -59,3 +59,61 @@ test("demo preview uses the real app theme and reaches a tool page", async ({ pa
expect(consoleErrors).toEqual([]);
expect(pageErrors).toEqual([]);
});
test("admin settings sections render sample data without crashing", async ({ page }) => {
const pageErrors: string[] = [];
const crashLog: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
page.on("console", (message) => {
if (message.type() !== "error") return;
const text = message.text();
// The regression this guards is the "can't access property filter, X is
// undefined" render crash caught by the error boundary. Flag that class of
// error specifically rather than every benign console noise.
if (/filter|is undefined|is not a function|Cannot read|Something went wrong/i.test(text)) {
crashLog.push(text);
}
});
// Seed an authenticated session (past the forced change-password) so we land
// straight in the app, then open Settings from the avatar menu.
await page.addInitScript(() => {
localStorage.setItem("snapotter-token", "demo-token");
localStorage.setItem("snapotter-demo-state", JSON.stringify({ passwordChanged: true }));
});
await page.goto("/");
await page.getByTestId("user-menu").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
// Each section fetches from the mock API and maps arrays; a shape mismatch
// would blank the section (sample text missing) or throw. Assert the seeded
// content shows up so both failure modes are caught.
const sections: Array<[string, string]> = [
["People", "emma.whitfield"],
["Teams", "Marketing"],
["Roles", "Auditor"],
["Audit Log", "LOGIN_SUCCESS"],
["Usage", "compress-image"],
["API Keys", "CI/CD Pipeline"],
];
for (const [tab, sample] of sections) {
await page.getByRole("button", { name: tab, exact: true }).click();
await expect(page.getByText(sample).first()).toBeVisible();
}
expect(crashLog).toEqual([]);
expect(pageErrors).toEqual([]);
});
test("serves the editor icon asset so the mobile nav icon renders", async ({ request }) => {
// The mobile bottom-nav editor icon is a CSS mask over /edit-image.png. When
// that asset was missing from the demo build the mask resolved to nothing and
// the icon vanished on phones. Guard the asset so it can't regress.
const response = await request.get("/edit-image.png");
expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toContain("image");
});
+115
View File
@@ -35,4 +35,119 @@ describe("demo mock API", () => {
ssoEnforced: false,
});
});
// These lock in the exact response shapes the admin settings screens read.
// A missing array here is what produced the "can't access property filter"
// crash: the People tab calls /auth/users, and the mock used to answer only
// /v1/users, so `data.users` was undefined and `users.filter()` threw.
it("serves the People list at /auth/users with a populated users array", async () => {
const response = matchDemoRoute("/api/auth/users", "GET");
expect(response?.status).toBe(200);
const data = (await readJson(response as Response)) as {
users: Array<Record<string, unknown>>;
maxUsers: number;
};
expect(Array.isArray(data.users)).toBe(true);
expect(data.users.length).toBeGreaterThan(3);
expect(typeof data.maxUsers).toBe("number");
for (const user of data.users) {
expect(typeof user.username).toBe("string");
expect(typeof user.role).toBe("string");
expect(typeof user.team).toBe("string");
expect(typeof user.createdAt).toBe("string");
}
});
it("serves teams, roles, and api keys as real arrays", async () => {
const teams = (await readJson(matchDemoRoute("/api/v1/teams", "GET") as Response)) as {
teams: unknown[];
};
const roles = (await readJson(matchDemoRoute("/api/v1/roles", "GET") as Response)) as {
roles: unknown[];
};
const keys = (await readJson(matchDemoRoute("/api/v1/api-keys", "GET") as Response)) as {
apiKeys: unknown[];
};
expect(Array.isArray(teams.teams)).toBe(true);
expect(teams.teams.length).toBeGreaterThan(0);
expect(Array.isArray(roles.roles)).toBe(true);
expect(roles.roles.length).toBeGreaterThan(0);
expect(Array.isArray(keys.apiKeys)).toBe(true);
expect(keys.apiKeys.length).toBeGreaterThan(0);
});
it("paginates the audit log and honours the action filter", async () => {
const page1 = (await readJson(
matchDemoRoute("/api/v1/audit-log?page=1&limit=25", "GET") as Response,
)) as { entries: unknown[]; total: number };
expect(Array.isArray(page1.entries)).toBe(true);
expect(page1.entries.length).toBeGreaterThan(0);
expect(page1.entries.length).toBeLessThanOrEqual(25);
expect(page1.total).toBeGreaterThanOrEqual(page1.entries.length);
const filtered = (await readJson(
matchDemoRoute("/api/v1/audit-log?page=1&limit=25&action=LOGIN_SUCCESS", "GET") as Response,
)) as { entries: Array<{ action: string }> };
for (const entry of filtered.entries) {
expect(entry.action).toBe("LOGIN_SUCCESS");
}
});
it("returns a complete usage payload with every array the dashboard maps", async () => {
const usage = (await readJson(
matchDemoRoute("/api/v1/admin/usage?days=30", "GET") as Response,
)) as Record<string, unknown>;
for (const key of ["jobsPerDay", "topTools", "perUser", "durations", "teamStorage"]) {
expect(Array.isArray(usage[key])).toBe(true);
expect((usage[key] as unknown[]).length).toBeGreaterThan(0);
}
const storage = usage.storage as { libraryBytes: string; libraryFiles: number };
expect(typeof storage.libraryBytes).toBe("string");
expect(typeof storage.libraryFiles).toBe("number");
});
it("exposes settings and preferences objects the settings tabs read", async () => {
const settings = (await readJson(matchDemoRoute("/api/v1/settings", "GET") as Response)) as {
settings: Record<string, string>;
};
expect(settings.settings.fileUploadLimitMb).toBeTruthy();
expect(settings.settings.passwordMinLength).toBeTruthy();
const prefs = (await readJson(matchDemoRoute("/api/v1/preferences", "GET") as Response)) as {
preferences: Record<string, unknown>;
};
expect(typeof prefs.preferences).toBe("object");
});
it("creates an API key and returns the one-time raw key", async () => {
const response = matchDemoRoute(
"/api/v1/api-keys",
"POST",
JSON.stringify({ name: "Demo test key" }),
);
expect(response?.status).toBe(200);
const data = (await readJson(response as Response)) as { key: string; name: string };
expect(data.name).toBe("Demo test key");
expect(data.key.startsWith("si_")).toBe(true);
});
it("adds a user via /auth/register so the People tab reload shows it", async () => {
const created = matchDemoRoute(
"/api/auth/register",
"POST",
JSON.stringify({ username: "test.newbie", role: "user", team: "Design" }),
);
expect(created?.status).toBe(200);
const list = (await readJson(matchDemoRoute("/api/auth/users", "GET") as Response)) as {
users: Array<{ username: string }>;
};
expect(list.users.some((u) => u.username === "test.newbie")).toBe(true);
});
it("disables file processing with a clear demo message", async () => {
const response = matchDemoRoute("/api/v1/tools/compress-image", "POST", "{}");
expect(response?.status).toBe(403);
const data = (await readJson(response as Response)) as { error: string };
expect(data.error).toContain("demo");
});
});