forked from baron/baron-sso
chore: snapshot local state before dev merge
This commit is contained in:
402
adminfront/tests/tenant-performance.spec.ts
Normal file
402
adminfront/tests/tenant-performance.spec.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const tenantCount = 3500;
|
||||
const userCount = 3500;
|
||||
|
||||
type TenantFixture = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
type: string;
|
||||
memberCount: number;
|
||||
recursiveMemberCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type UserFixture = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
loginId: string;
|
||||
role: string;
|
||||
status: string;
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
tenantName: string;
|
||||
department: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function buildTenants(): TenantFixture[] {
|
||||
const baseTime = Date.UTC(2026, 0, 1, 0, 0, 0);
|
||||
|
||||
return Array.from({ length: tenantCount }, (_, index) => {
|
||||
const sequence = index + 1;
|
||||
const padded = String(sequence).padStart(4, "0");
|
||||
const timestamp = new Date(baseTime + sequence * 1000).toISOString();
|
||||
|
||||
return {
|
||||
id: `tenant-${padded}`,
|
||||
name: `Tenant ${padded}`,
|
||||
slug: sequence === 100 ? "full-dataset-needle-0100" : `tenant-${padded}`,
|
||||
status: sequence % 17 === 0 ? "inactive" : "active",
|
||||
type: sequence % 5 === 0 ? "ORGANIZATION" : "COMPANY",
|
||||
memberCount: sequence % 13,
|
||||
recursiveMemberCount: sequence % 29,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildUsers(): UserFixture[] {
|
||||
const baseTime = Date.UTC(2026, 0, 1, 0, 0, 0);
|
||||
|
||||
return Array.from({ length: userCount }, (_, index) => {
|
||||
const sequence = index + 1;
|
||||
const padded = String(sequence).padStart(4, "0");
|
||||
const timestamp = new Date(baseTime + sequence * 1000).toISOString();
|
||||
const email =
|
||||
sequence === 100
|
||||
? "full-dataset-user-needle-0100@example.com"
|
||||
: `user-${padded}@example.com`;
|
||||
|
||||
return {
|
||||
id: `user-${padded}`,
|
||||
name: `User ${padded}`,
|
||||
email,
|
||||
phone: "010-1111-2222",
|
||||
loginId: `user-${padded}`,
|
||||
role: "user",
|
||||
status: sequence % 19 === 0 ? "inactive" : "active",
|
||||
tenantId: "tenant-main",
|
||||
tenantSlug: "tenant-main",
|
||||
tenantName: "Main Tenant",
|
||||
department: "Platform",
|
||||
createdAt: timestamp,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function compareTenantValues(
|
||||
left: TenantFixture,
|
||||
right: TenantFixture,
|
||||
sortKey: string,
|
||||
) {
|
||||
const key = sortKey as keyof TenantFixture;
|
||||
const leftValue = left[key] ?? "";
|
||||
const rightValue = right[key] ?? "";
|
||||
|
||||
return String(leftValue).localeCompare(String(rightValue));
|
||||
}
|
||||
|
||||
test.describe("Tenant list performance", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
window.localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
|
||||
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("**/oidc/**", async (route) => {
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
});
|
||||
});
|
||||
|
||||
test("loads and searches the tenant list within the performance budget", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
const tenants = buildTenants();
|
||||
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
|
||||
if (url.pathname.endsWith("/user/me")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin",
|
||||
role: "super_admin",
|
||||
manageableTenants: [],
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.endsWith("/admin/tenants") &&
|
||||
route.request().method() === "GET"
|
||||
) {
|
||||
const limit = Number(url.searchParams.get("limit") ?? "500");
|
||||
const cursor = Number(url.searchParams.get("cursor") ?? "0");
|
||||
const search = url.searchParams.get("search")?.trim().toLowerCase();
|
||||
const sort = url.searchParams.get("sort") ?? "createdAt";
|
||||
const direction = url.searchParams.get("direction") ?? "desc";
|
||||
|
||||
let filtered = tenants;
|
||||
if (search) {
|
||||
filtered = tenants.filter((tenant) =>
|
||||
[tenant.id, tenant.name, tenant.slug, tenant.type].some((value) =>
|
||||
value.toLowerCase().includes(search),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = [...filtered].sort((left, right) => {
|
||||
const result = compareTenantValues(left, right, sort);
|
||||
return direction === "asc" ? result : -result;
|
||||
});
|
||||
const pageItems = sorted.slice(cursor, cursor + limit);
|
||||
const nextOffset = cursor + limit;
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: pageItems,
|
||||
total: sorted.length,
|
||||
limit,
|
||||
offset: 0,
|
||||
nextCursor:
|
||||
nextOffset < sorted.length ? String(nextOffset) : undefined,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||
});
|
||||
|
||||
const loadStarted = performance.now();
|
||||
await page.goto("/tenants");
|
||||
await expect(page.getByTestId("tenant-internal-id-tenant-3500")).toBeVisible(
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const loadMs = performance.now() - loadStarted;
|
||||
const loadSnapshot = testInfo.outputPath("tenant-list-load.png");
|
||||
await page.screenshot({ path: loadSnapshot, fullPage: true });
|
||||
|
||||
await expect(page.locator("tbody tr").first()).toContainText("Tenant 3500");
|
||||
|
||||
const searchInput = page.getByPlaceholder("이름 또는 슬러그, ID 검색");
|
||||
const searchStarted = performance.now();
|
||||
await searchInput.fill("full-dataset-needle-0100");
|
||||
await expect(page.getByTestId("tenant-internal-id-tenant-0100")).toBeVisible(
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const searchMs = performance.now() - searchStarted;
|
||||
const searchSnapshot = testInfo.outputPath("tenant-list-search.png");
|
||||
await page.screenshot({ path: searchSnapshot, fullPage: true });
|
||||
|
||||
await expect(page.locator("tbody")).toContainText(
|
||||
"full-dataset-needle-0100",
|
||||
);
|
||||
await expect(page.getByTestId("tenant-internal-id-tenant-3500")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
metric: "tenant-list-performance",
|
||||
loadMs: Math.round(loadMs),
|
||||
searchMs: Math.round(searchMs),
|
||||
loadSnapshot,
|
||||
searchSnapshot,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(loadMs).toBeLessThanOrEqual(1500);
|
||||
expect(searchMs).toBeLessThanOrEqual(500);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("User list performance", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
window.localStorage.setItem("admin_session", "fake-token");
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
window.localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
id_token: "fake-id-token",
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
scope: "openid profile email",
|
||||
profile: {
|
||||
sub: "admin-user",
|
||||
name: "Admin",
|
||||
email: "admin@test.com",
|
||||
role: "super_admin",
|
||||
},
|
||||
expires_at: Math.floor(Date.now() / 1000) + 36000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("**/oidc/**", async (route) => {
|
||||
if (route.request().url().includes("/.well-known/openid-configuration")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
issuer: "http://localhost:5000/oidc",
|
||||
authorization_endpoint: "http://localhost:5000/oidc/auth",
|
||||
token_endpoint: "http://localhost:5000/oidc/token",
|
||||
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
|
||||
jwks_uri: "http://localhost:5000/oidc/jwks",
|
||||
},
|
||||
});
|
||||
}
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
});
|
||||
});
|
||||
|
||||
test("loads and searches the user list within the performance budget", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
const users = buildUsers();
|
||||
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||
|
||||
if (url.pathname.endsWith("/user/me")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin",
|
||||
email: "admin@test.com",
|
||||
role: "super_admin",
|
||||
manageableTenants: [],
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.endsWith("/admin/tenants") &&
|
||||
route.request().method() === "GET"
|
||||
) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "tenant-main",
|
||||
slug: "tenant-main",
|
||||
name: "Main Tenant",
|
||||
type: "COMPANY",
|
||||
config: { userSchema: [] },
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.endsWith("/admin/users") &&
|
||||
route.request().method() === "GET"
|
||||
) {
|
||||
const limit = Number(url.searchParams.get("limit") ?? "50");
|
||||
const cursor = Number(url.searchParams.get("cursor") ?? "0");
|
||||
const search = url.searchParams.get("search")?.trim().toLowerCase();
|
||||
|
||||
const filtered = search
|
||||
? users.filter((user) =>
|
||||
[user.id, user.name, user.email, user.loginId].some((value) =>
|
||||
value.toLowerCase().includes(search),
|
||||
),
|
||||
)
|
||||
: users;
|
||||
const sorted = [...filtered].sort((left, right) =>
|
||||
right.createdAt.localeCompare(left.createdAt),
|
||||
);
|
||||
const pageItems = sorted.slice(cursor, cursor + limit);
|
||||
const nextOffset = cursor + limit;
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: pageItems,
|
||||
total: sorted.length,
|
||||
limit,
|
||||
offset: 0,
|
||||
nextCursor:
|
||||
nextOffset < sorted.length ? String(nextOffset) : undefined,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||
});
|
||||
|
||||
const loadStarted = performance.now();
|
||||
await page.goto("/users");
|
||||
await expect(page.getByTestId("user-internal-id-user-3500")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
const loadMs = performance.now() - loadStarted;
|
||||
const loadSnapshot = testInfo.outputPath("user-list-load.png");
|
||||
await page.screenshot({ path: loadSnapshot, fullPage: true });
|
||||
|
||||
await expect(page.getByText("User 3500")).toBeVisible();
|
||||
|
||||
const searchInput = page.getByPlaceholder("이름 또는 이메일 검색");
|
||||
const searchStarted = performance.now();
|
||||
await searchInput.fill("full-dataset-user-needle-0100");
|
||||
await expect(page.getByTestId("user-internal-id-user-0100")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
const searchMs = performance.now() - searchStarted;
|
||||
const searchSnapshot = testInfo.outputPath("user-list-search.png");
|
||||
await page.screenshot({ path: searchSnapshot, fullPage: true });
|
||||
|
||||
await expect(
|
||||
page.getByText("full-dataset-user-needle-0100@example.com"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("user-internal-id-user-3500")).toHaveCount(0);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
metric: "user-list-performance",
|
||||
loadMs: Math.round(loadMs),
|
||||
searchMs: Math.round(searchMs),
|
||||
loadSnapshot,
|
||||
searchSnapshot,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(loadMs).toBeLessThanOrEqual(1500);
|
||||
expect(searchMs).toBeLessThanOrEqual(500);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user