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);
|
||||
});
|
||||
});
|
||||
219
adminfront/tests/tenant-profile-performance-local.spec.ts
Normal file
219
adminfront/tests/tenant-profile-performance-local.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { expect, test, type Route } from "@playwright/test";
|
||||
|
||||
const targetTenantId =
|
||||
process.env.TENANT_PROFILE_PERF_TENANT_ID ??
|
||||
"56cd0fd7-b62a-43c0-8db9-74a30468d7cb";
|
||||
const actualApiBaseUrl =
|
||||
process.env.TENANT_PROFILE_PERF_API_BASE_URL ?? "http://localhost:5173/api";
|
||||
const normalizedActualApiBaseUrl = actualApiBaseUrl.replace(/\/$/, "");
|
||||
const evidenceDir = path.resolve("e2e-evidence");
|
||||
|
||||
type ApiTiming = {
|
||||
method: string;
|
||||
url: string;
|
||||
status: number;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
type Measurement = {
|
||||
sample: number;
|
||||
configFieldsVisibleMs: number;
|
||||
networkIdleMs: number;
|
||||
orgUnitType: string | null;
|
||||
visibility: string | null;
|
||||
worksmobileSync: string | null;
|
||||
apiTimings: ApiTiming[];
|
||||
};
|
||||
|
||||
async function fulfillFromLocalApi(route: Route, targetUrl?: string) {
|
||||
const request = route.request();
|
||||
const corsHeaders = {
|
||||
"access-control-allow-headers": "authorization,content-type,x-test-role",
|
||||
"access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
||||
"access-control-allow-origin": "*",
|
||||
};
|
||||
|
||||
if (request.method() === "OPTIONS") {
|
||||
await route.fulfill({ status: 204, headers: corsHeaders });
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = { ...request.headers(), "x-test-role": "super_admin" };
|
||||
delete headers.authorization;
|
||||
delete headers.host;
|
||||
|
||||
const response = await route.fetch({ url: targetUrl, headers });
|
||||
await route.fulfill({
|
||||
response,
|
||||
headers: { ...response.headers(), ...corsHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
function percentile(values: number[], ratio: number) {
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const index = Math.min(
|
||||
sorted.length - 1,
|
||||
Math.ceil(sorted.length * ratio) - 1,
|
||||
);
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
test.describe("Tenant profile local performance evidence", () => {
|
||||
test("loads org config fields through the local API within 500ms", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
fs.mkdirSync(evidenceDir, { recursive: true });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
|
||||
window.localStorage.setItem("X-Mock-Role", "super_admin");
|
||||
window.localStorage.removeItem("admin_session");
|
||||
for (const key of Object.keys(window.localStorage)) {
|
||||
if (key.startsWith("oidc.user:")) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = true;
|
||||
});
|
||||
|
||||
await page.route("**/oidc/**", async (route) => {
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
});
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
await fulfillFromLocalApi(route);
|
||||
});
|
||||
|
||||
await page.route("http://playwright-mock/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const source = new URL(request.url());
|
||||
const target = `${normalizedActualApiBaseUrl}${source.pathname.replace(
|
||||
/^\/api/,
|
||||
"",
|
||||
)}${source.search}`;
|
||||
await fulfillFromLocalApi(route, target);
|
||||
});
|
||||
|
||||
const requestStartedAt = new Map<string, number>();
|
||||
const apiTimings: ApiTiming[] = [];
|
||||
|
||||
page.on("request", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/v1/") || url.includes("playwright-mock/api")) {
|
||||
requestStartedAt.set(request.url(), performance.now());
|
||||
}
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
const request = response.request();
|
||||
const startedAt = requestStartedAt.get(request.url());
|
||||
if (startedAt === undefined) {
|
||||
return;
|
||||
}
|
||||
const timing = {
|
||||
method: request.method(),
|
||||
url: response.url(),
|
||||
status: response.status(),
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
};
|
||||
apiTimings.push(timing);
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes("/api/v1/") || url.includes("playwright-mock/api")) {
|
||||
console.log(
|
||||
"api-request-failed",
|
||||
JSON.stringify({
|
||||
method: request.method(),
|
||||
url,
|
||||
failure: request.failure()?.errorText,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const measurements: Measurement[] = [];
|
||||
const sampleCount = 5;
|
||||
|
||||
for (let sample = 1; sample <= sampleCount; sample += 1) {
|
||||
apiTimings.length = 0;
|
||||
const startedAt = performance.now();
|
||||
|
||||
await page.goto(`/tenants/${targetTenantId}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const orgUnitTypeSelect = page.getByTestId("tenant-org-unit-type-select");
|
||||
await expect(orgUnitTypeSelect).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.locator("#tenant-visibility")).toBeVisible();
|
||||
await expect(page.locator("#worksmobileExcluded")).toBeVisible();
|
||||
|
||||
const configFieldsVisibleMs = Math.round(performance.now() - startedAt);
|
||||
await page.waitForLoadState("networkidle", { timeout: 15000 });
|
||||
const networkIdleMs = Math.round(performance.now() - startedAt);
|
||||
|
||||
measurements.push({
|
||||
sample,
|
||||
configFieldsVisibleMs,
|
||||
networkIdleMs,
|
||||
orgUnitType: await orgUnitTypeSelect.inputValue(),
|
||||
visibility: await page.locator("#tenant-visibility").inputValue(),
|
||||
worksmobileSync: await page
|
||||
.locator("#worksmobileExcluded")
|
||||
.inputValue(),
|
||||
apiTimings: [...apiTimings],
|
||||
});
|
||||
}
|
||||
|
||||
const screenshotPath = path.join(
|
||||
evidenceDir,
|
||||
"tenant-profile-performance-local.png",
|
||||
);
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
|
||||
const configTimes = measurements.map(
|
||||
(measurement) => measurement.configFieldsVisibleMs,
|
||||
);
|
||||
const networkIdleTimes = measurements.map(
|
||||
(measurement) => measurement.networkIdleMs,
|
||||
);
|
||||
const evidence = {
|
||||
metric: "tenant-profile-local-performance",
|
||||
tenantId: targetTenantId,
|
||||
actualApiBaseUrl,
|
||||
measuredAt: new Date().toISOString(),
|
||||
browser: testInfo.project.name,
|
||||
samples: measurements,
|
||||
summary: {
|
||||
configFieldsVisibleMs: {
|
||||
min: Math.min(...configTimes),
|
||||
max: Math.max(...configTimes),
|
||||
p50: percentile(configTimes, 0.5),
|
||||
p95: percentile(configTimes, 0.95),
|
||||
},
|
||||
networkIdleMs: {
|
||||
min: Math.min(...networkIdleTimes),
|
||||
max: Math.max(...networkIdleTimes),
|
||||
p50: percentile(networkIdleTimes, 0.5),
|
||||
p95: percentile(networkIdleTimes, 0.95),
|
||||
},
|
||||
},
|
||||
screenshotPath,
|
||||
};
|
||||
const evidencePath = path.join(
|
||||
evidenceDir,
|
||||
"tenant-profile-performance-local.json",
|
||||
);
|
||||
fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
|
||||
|
||||
console.log(JSON.stringify(evidence, null, 2));
|
||||
|
||||
expect(evidence.summary.configFieldsVisibleMs.p95).toBeLessThanOrEqual(500);
|
||||
});
|
||||
});
|
||||
@@ -527,6 +527,93 @@ test.describe("Tenants Management", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should bulk update selected tenant status type and visibility", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1100, height: 760 });
|
||||
const updatePayloads: Record<string, unknown>[] = [];
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
|
||||
if (request.method() === "PUT") {
|
||||
updatePayloads.push(request.postDataJSON());
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: url.pathname.split("/").at(-1),
|
||||
name: "Updated Tenant",
|
||||
slug: "updated-tenant",
|
||||
status: "inactive",
|
||||
type: "ORGANIZATION",
|
||||
config: { visibility: "public" },
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method() !== "GET") {
|
||||
return route.continue();
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "tenant-a",
|
||||
name: "Tenant A",
|
||||
slug: "tenant-a",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
config: { visibility: "internal" },
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "tenant-b",
|
||||
name: "Tenant B",
|
||||
slug: "tenant-b",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
config: { visibility: "internal" },
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/tenants");
|
||||
|
||||
for (const tenantId of ["tenant-a", "tenant-b"]) {
|
||||
await page
|
||||
.getByTestId(`tenant-internal-id-${tenantId}`)
|
||||
.locator("xpath=ancestor::tr")
|
||||
.getByRole("checkbox")
|
||||
.click();
|
||||
}
|
||||
|
||||
await page.getByTestId("tenant-bulk-status-select").click();
|
||||
await page.getByRole("option", { name: /비활성|inactive/i }).click();
|
||||
await page.getByTestId("tenant-bulk-type-select").click();
|
||||
await page.getByRole("option", { name: /Organization|정규 조직/i }).click();
|
||||
await page.getByTestId("tenant-bulk-visibility-select").click();
|
||||
await page.getByRole("option", { name: "공개", exact: true }).click();
|
||||
await page.getByTestId("tenant-bulk-apply-btn").click();
|
||||
|
||||
await expect.poll(() => updatePayloads).toHaveLength(2);
|
||||
for (const payload of updatePayloads) {
|
||||
expect(payload).toMatchObject({
|
||||
status: "inactive",
|
||||
type: "ORGANIZATION",
|
||||
config: { visibility: "public" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("switches tree and flat views, searches UUID, and selects descendants", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -754,7 +754,7 @@ test.describe("User Management", () => {
|
||||
expect(exportUrl).toContain("includeIds=false");
|
||||
});
|
||||
|
||||
test("should show contact info in one row, hide roles, and change user status", async ({
|
||||
test("should hide role controls from the users table and change user status", async ({
|
||||
page,
|
||||
}) => {
|
||||
let updatePayload: Record<string, unknown> | undefined;
|
||||
@@ -781,13 +781,311 @@ test.describe("User Management", () => {
|
||||
const table = page.locator("table");
|
||||
await expect(
|
||||
table.getByRole("columnheader", { name: /ROLE|역할/i }),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("user-status-select-u-1").click();
|
||||
await page.getByRole("option", { name: /입사대기|Preboarding/ }).click();
|
||||
await expect
|
||||
.poll(() => updatePayload)
|
||||
.toMatchObject({ status: "preboarding" });
|
||||
|
||||
await table.locator('input[name="user-list-select-u-1"]').check();
|
||||
await expect(page.getByTestId("bulk-permission-select")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("should keep system role assignment out of the permissions screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
let bulkPayload: Record<string, unknown> | undefined;
|
||||
|
||||
await page.route(/\/admin\/system\/relations$/, async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
userId: "u-1",
|
||||
name: "John Doe",
|
||||
email: "john@test.com",
|
||||
relations: ["overview_viewers"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/admin\/users\/bulk$/, async (route) => {
|
||||
if (route.request().method() !== "PUT") {
|
||||
return route.fallback();
|
||||
}
|
||||
bulkPayload = route.request().postDataJSON();
|
||||
return route.fulfill({
|
||||
json: { results: [{ userId: "u-1", success: true }] },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/permissions-direct");
|
||||
await expect(
|
||||
page.getByTestId("permission-assignment-row-u-1-overview_viewers"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("permissions-direct-super-admin-select"),
|
||||
).toHaveCount(0);
|
||||
expect(bulkPayload).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should support bulk page and target action grants while keeping permissions direct protected", async ({
|
||||
page,
|
||||
}) => {
|
||||
const relationWrites: Array<Record<string, unknown>> = [];
|
||||
const relationDeletes: Array<Record<string, unknown>> = [];
|
||||
|
||||
await page.route(/\/admin\/system\/relations$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
userId: "u-1",
|
||||
name: "John Doe",
|
||||
email: "john@test.com",
|
||||
relations: ["overview_viewers"],
|
||||
},
|
||||
{
|
||||
userId: "u-2",
|
||||
name: "Jane Manager",
|
||||
email: "jane@test.com",
|
||||
relations: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (method === "POST") {
|
||||
relationWrites.push(route.request().postDataJSON());
|
||||
return route.fulfill({ json: { success: true } });
|
||||
}
|
||||
if (method === "DELETE") {
|
||||
relationDeletes.push(route.request().postDataJSON());
|
||||
return route.fulfill({ json: { success: true } });
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
await page.route(/\/admin\/tenants\/t-1\/relations$/, async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
userId: "u-1",
|
||||
name: "John Doe",
|
||||
email: "john@test.com",
|
||||
relations: ["profile_viewers"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (method === "POST") {
|
||||
relationWrites.push(route.request().postDataJSON());
|
||||
return route.fulfill({ json: { success: true } });
|
||||
}
|
||||
if (method === "DELETE") {
|
||||
relationDeletes.push(route.request().postDataJSON());
|
||||
return route.fulfill({ json: { success: true } });
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/permissions-direct");
|
||||
|
||||
await expect(page.getByRole("tab", { name: /상세 권한/ })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("option", { name: /권한 부여.*수정/ }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("permission-target-org-picker-frame")).toBeVisible();
|
||||
await expect(page.getByTestId("permission-target-org-picker-frame")).toHaveAttribute(
|
||||
"src",
|
||||
/rootTenantId%3Dall|rootTenantId=all/,
|
||||
);
|
||||
const pickerBox = await page
|
||||
.getByTestId("permission-target-org-picker-frame")
|
||||
.boundingBox();
|
||||
const queueBox = await page.getByTestId("permission-target-queue").boundingBox();
|
||||
expect(pickerBox?.x ?? Number.POSITIVE_INFINITY).toBeLessThan(
|
||||
queueBox?.x ?? Number.NEGATIVE_INFINITY,
|
||||
);
|
||||
|
||||
await page.getByTestId("bulk-relation-mode").selectOption("target-action");
|
||||
await expect(
|
||||
page.getByTestId("bulk-relation-operation"),
|
||||
).toHaveCount(0);
|
||||
await page.getByTestId("permission-action-tenant-picker-open").click();
|
||||
await page.getByTestId("permission-action-tenant-search").fill("Test");
|
||||
await page.getByTestId("permission-action-tenant-result-t-1").click();
|
||||
await expect(page.getByTestId("bulk-relation-target-tenant")).toHaveValue(
|
||||
"t-1",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("permission-target-tenant-scope"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("permission-target-org-picker-frame"),
|
||||
).not.toHaveAttribute("src", /tenantId%3Dt-1|tenantId=t-1/);
|
||||
await page.evaluate(() => {
|
||||
window.postMessage(
|
||||
{
|
||||
type: "orgfront:picker:confirm",
|
||||
payload: {
|
||||
selections: [
|
||||
{
|
||||
type: "user",
|
||||
id: "u-2",
|
||||
name: "Jane Manager",
|
||||
email: "jane@test.com",
|
||||
rootTenantName: "한맥가족",
|
||||
leafTenantName: "기술기획",
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
id: "u-3",
|
||||
name: "Org Picked User",
|
||||
email: "picked@test.com",
|
||||
rootTenantName: "Commercial",
|
||||
leafTenantName: "디자인팀",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
await expect(page.getByTestId("permission-target-queue")).toContainText(
|
||||
"Jane Manager",
|
||||
);
|
||||
await expect(page.getByTestId("permission-target-queue")).toContainText(
|
||||
"Org Picked User",
|
||||
);
|
||||
await expect(page.getByTestId("permission-target-queue")).toContainText(
|
||||
"한맥가족 / 기술기획",
|
||||
);
|
||||
|
||||
await page.getByTestId("bulk-relation-target").selectOption("profile");
|
||||
await page.getByTestId("bulk-relation-action").selectOption("manage");
|
||||
await page
|
||||
.getByRole("button", { name: /선택 사용자에게 권한 부여/ })
|
||||
.click();
|
||||
|
||||
await expect.poll(() => relationWrites).toContainEqual(
|
||||
{ userId: "u-2", relation: "tenants_managers" },
|
||||
);
|
||||
await expect.poll(() => relationWrites).toContainEqual(
|
||||
{ userId: "u-2", relation: "profile_managers" },
|
||||
);
|
||||
await expect.poll(() => relationWrites).toContainEqual(
|
||||
{ userId: "u-3", relation: "profile_managers" },
|
||||
);
|
||||
|
||||
await page.getByTestId("permission-assignment-search").fill("John");
|
||||
await expect(page.getByTestId("permission-assignment-row-u-1-profile_viewers")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("permission-assignment-row-u-2-profile_managers"),
|
||||
).toHaveCount(0);
|
||||
await page.getByTestId("permission-assignment-search").fill("");
|
||||
await page.getByTestId("permission-assignment-sort").selectOption("relation");
|
||||
await page
|
||||
.getByTestId("permission-assignment-level-u-1-profile_viewers")
|
||||
.selectOption("write");
|
||||
await expect.poll(() => relationWrites).toContainEqual({
|
||||
userId: "u-1",
|
||||
relation: "profile_managers",
|
||||
});
|
||||
await page
|
||||
.getByTestId("permission-assignment-remove-u-1-profile_viewers")
|
||||
.click();
|
||||
await expect.poll(() => relationDeletes).toContainEqual({
|
||||
userId: "u-1",
|
||||
relation: "profile_viewers",
|
||||
});
|
||||
});
|
||||
|
||||
test("should grant super admin role from the last tab only for super admins", async ({
|
||||
page,
|
||||
}) => {
|
||||
let bulkPayload: Record<string, unknown> | undefined;
|
||||
|
||||
await page.route(/\/admin\/system\/relations$/, async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
userId: "u-1",
|
||||
name: "John Doe",
|
||||
email: "john@test.com",
|
||||
relations: ["overview_viewers"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/admin\/users\/bulk$/, async (route) => {
|
||||
if (route.request().method() !== "PUT") {
|
||||
return route.fallback();
|
||||
}
|
||||
bulkPayload = route.request().postDataJSON();
|
||||
return route.fulfill({
|
||||
json: { results: [{ userId: "u-1", success: true }] },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/permissions-direct");
|
||||
const tabs = page.getByRole("tab");
|
||||
await expect(tabs.last()).toHaveText(/Super Admin 역할/);
|
||||
await tabs.last().click();
|
||||
|
||||
await page.getByTestId("super-admin-role-user-u-1").check();
|
||||
await page.getByRole("button", { name: /Super Admin 부여/ }).click();
|
||||
|
||||
await expect.poll(() => bulkPayload).toEqual({
|
||||
userIds: ["u-1"],
|
||||
role: "super_admin",
|
||||
});
|
||||
});
|
||||
|
||||
test("should hide the super admin role tab from non super admins", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route(/\/user\/me$/, async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
id: "operator-user",
|
||||
name: "Operator",
|
||||
email: "operator@test.com",
|
||||
role: "user",
|
||||
manageableTenants: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/permissions-direct");
|
||||
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /Super Admin 역할/ }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText(/이 작업을 수행할 권한이 없습니다/),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should center users table loading state and use compact headers", async ({
|
||||
@@ -1222,8 +1520,17 @@ test.describe("User Management", () => {
|
||||
await page.goto("/users/u-1");
|
||||
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /한맥가족 구성원/i }),
|
||||
page.getByRole("tab", { name: /^한맥가족$/i }),
|
||||
).toHaveAttribute("data-state", "active");
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /외부 기업 회원/i }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /^Commercial$/i }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: /^공공기관$/i })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: /^교육기관$/i })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: /^개인$/i })).toBeVisible();
|
||||
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
|
||||
await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible();
|
||||
await expect(
|
||||
|
||||
Reference in New Issue
Block a user