mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
+849
-85
File diff suppressed because it is too large
Load Diff
@@ -59,3 +59,61 @@ test("demo preview uses the real app theme and reaches a tool page", async ({ pa
|
|||||||
expect(consoleErrors).toEqual([]);
|
expect(consoleErrors).toEqual([]);
|
||||||
expect(pageErrors).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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -35,4 +35,119 @@ describe("demo mock API", () => {
|
|||||||
ssoEnforced: false,
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user