forked from baron/baron-sso
계층 표시 테스트 코드 추가
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -65,11 +65,7 @@ import {
|
|||||||
updateUser,
|
updateUser,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||||
type TenantNode = TenantSummary & {
|
|
||||||
children: TenantNode[];
|
|
||||||
recursiveMemberCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTenantIcon = (type?: string) => {
|
const getTenantIcon = (type?: string) => {
|
||||||
switch (type?.toUpperCase()) {
|
switch (type?.toUpperCase()) {
|
||||||
@@ -779,58 +775,10 @@ function TenantUserGroupsTab() {
|
|||||||
|
|
||||||
const allTenants = data?.items ?? [];
|
const allTenants = data?.items ?? [];
|
||||||
|
|
||||||
const { currentBase, subTree } = useMemo(() => {
|
const { currentBase, subTree } = useMemo(
|
||||||
if (allTenants.length === 0) return { currentBase: null, subTree: [] };
|
() => buildTenantFullTree(allTenants, tenantId),
|
||||||
|
[allTenants, tenantId],
|
||||||
const tenantMap = new Map<string, TenantNode>();
|
);
|
||||||
for (const t of allTenants) {
|
|
||||||
tenantMap.set(t.id, {
|
|
||||||
...t,
|
|
||||||
children: [],
|
|
||||||
recursiveMemberCount: t.memberCount || 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build initial children relations
|
|
||||||
for (const t of allTenants) {
|
|
||||||
if (t.parentId) {
|
|
||||||
const parent = tenantMap.get(t.parentId);
|
|
||||||
const child = tenantMap.get(t.id);
|
|
||||||
if (parent && child) {
|
|
||||||
parent.children.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to calculate recursive counts
|
|
||||||
const calculateRecursive = (node: TenantNode): number => {
|
|
||||||
let total = node.memberCount || 0;
|
|
||||||
for (const child of node.children) {
|
|
||||||
total += calculateRecursive(child);
|
|
||||||
}
|
|
||||||
node.recursiveMemberCount = total;
|
|
||||||
return total;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate for all root nodes (those without parent or top-level in current context)
|
|
||||||
for (const node of tenantMap.values()) {
|
|
||||||
// We only strictly need to calculate from the top-most nodes to cover everything
|
|
||||||
if (!node.parentId) {
|
|
||||||
calculateRecursive(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
|
|
||||||
const base = tenantMap.get(tenantId);
|
|
||||||
if (base) {
|
|
||||||
calculateRecursive(base);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentBase: base || null,
|
|
||||||
subTree: base ? base.children : [],
|
|
||||||
};
|
|
||||||
}, [allTenants, tenantId]);
|
|
||||||
|
|
||||||
const handleAdd = (id: string) =>
|
const handleAdd = (id: string) =>
|
||||||
updateParentMutation.mutate({ id, parentId: tenantId });
|
updateParentMutation.mutate({ id, parentId: tenantId });
|
||||||
|
|||||||
93
adminfront/src/lib/tenantTree.test.ts
Normal file
93
adminfront/src/lib/tenantTree.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { TenantSummary } from "./adminApi";
|
||||||
|
import { buildTenantFullTree } from "./tenantTree";
|
||||||
|
|
||||||
|
describe("tenantTree utility", () => {
|
||||||
|
const mockTenants: TenantSummary[] = [
|
||||||
|
{
|
||||||
|
id: "root-1",
|
||||||
|
name: "Root",
|
||||||
|
slug: "root",
|
||||||
|
type: "COMPANY",
|
||||||
|
memberCount: 10,
|
||||||
|
parentId: undefined,
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "child-1",
|
||||||
|
name: "Child 1",
|
||||||
|
slug: "child-1",
|
||||||
|
type: "USER_GROUP",
|
||||||
|
memberCount: 5,
|
||||||
|
parentId: "root-1",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "grandchild-1",
|
||||||
|
name: "Grandchild 1",
|
||||||
|
slug: "grandchild-1",
|
||||||
|
type: "USER_GROUP",
|
||||||
|
memberCount: 2,
|
||||||
|
parentId: "child-1",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("calculates recursive member counts correctly", () => {
|
||||||
|
const { currentBase } = buildTenantFullTree(mockTenants, "root-1");
|
||||||
|
|
||||||
|
expect(currentBase).not.toBeNull();
|
||||||
|
if (currentBase) {
|
||||||
|
// Direct: 10, Child: 5, Grandchild: 2 -> Total: 17
|
||||||
|
expect(currentBase.recursiveMemberCount).toBe(17);
|
||||||
|
expect(currentBase.children).toHaveLength(1);
|
||||||
|
|
||||||
|
const child = currentBase.children[0];
|
||||||
|
// Direct: 5, Grandchild: 2 -> Total: 7
|
||||||
|
expect(child.recursiveMemberCount).toBe(7);
|
||||||
|
expect(child.children).toHaveLength(1);
|
||||||
|
|
||||||
|
const grandchild = child.children[0];
|
||||||
|
// Direct: 2 -> Total: 2
|
||||||
|
expect(grandchild.recursiveMemberCount).toBe(2);
|
||||||
|
expect(grandchild.children).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null currentBase if rootId is not found", () => {
|
||||||
|
const { currentBase } = buildTenantFullTree(mockTenants, "non-existent");
|
||||||
|
expect(currentBase).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds correct structure with multiple roots", () => {
|
||||||
|
const multiRootTenants: TenantSummary[] = [
|
||||||
|
...mockTenants,
|
||||||
|
{
|
||||||
|
id: "root-2",
|
||||||
|
name: "Root 2",
|
||||||
|
slug: "root-2",
|
||||||
|
type: "COMPANY",
|
||||||
|
memberCount: 3,
|
||||||
|
parentId: undefined,
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { subTree } = buildTenantFullTree(multiRootTenants);
|
||||||
|
expect(subTree).toHaveLength(2);
|
||||||
|
expect(subTree.map(n => n.id)).toContain("root-1");
|
||||||
|
expect(subTree.map(n => n.id)).toContain("root-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
69
adminfront/src/lib/tenantTree.ts
Normal file
69
adminfront/src/lib/tenantTree.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { TenantSummary } from "./adminApi";
|
||||||
|
|
||||||
|
export type TenantNode = TenantSummary & {
|
||||||
|
children: TenantNode[];
|
||||||
|
recursiveMemberCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a hierarchical tree from a flat list of tenants and calculates
|
||||||
|
* direct and recursive member counts for each node.
|
||||||
|
*/
|
||||||
|
export function buildTenantFullTree(
|
||||||
|
allTenants: TenantSummary[],
|
||||||
|
rootId?: string,
|
||||||
|
): { currentBase: TenantNode | null; subTree: TenantNode[] } {
|
||||||
|
if (allTenants.length === 0) return { currentBase: null, subTree: [] };
|
||||||
|
|
||||||
|
const tenantMap = new Map<string, TenantNode>();
|
||||||
|
for (const t of allTenants) {
|
||||||
|
tenantMap.set(t.id, {
|
||||||
|
...t,
|
||||||
|
children: [],
|
||||||
|
recursiveMemberCount: t.memberCount || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build initial children relations
|
||||||
|
for (const t of allTenants) {
|
||||||
|
if (t.parentId) {
|
||||||
|
const parent = tenantMap.get(t.parentId);
|
||||||
|
const child = tenantMap.get(t.id);
|
||||||
|
if (parent && child) {
|
||||||
|
parent.children.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to calculate recursive counts
|
||||||
|
const calculateRecursive = (node: TenantNode): number => {
|
||||||
|
let total = node.memberCount || 0;
|
||||||
|
for (const child of node.children) {
|
||||||
|
total += calculateRecursive(child);
|
||||||
|
}
|
||||||
|
node.recursiveMemberCount = total;
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate for all top-level nodes (those without parent)
|
||||||
|
for (const node of tenantMap.values()) {
|
||||||
|
if (!node.parentId) {
|
||||||
|
calculateRecursive(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a specific rootId is provided, find and return its subtree
|
||||||
|
if (rootId) {
|
||||||
|
const base = tenantMap.get(rootId);
|
||||||
|
if (base) {
|
||||||
|
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
|
||||||
|
calculateRecursive(base);
|
||||||
|
return { currentBase: base, subTree: base.children };
|
||||||
|
}
|
||||||
|
return { currentBase: null, subTree: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no rootId, return all top-level roots as subTree
|
||||||
|
const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId);
|
||||||
|
return { currentBase: null, subTree: roots };
|
||||||
|
}
|
||||||
@@ -94,4 +94,107 @@ test.describe("Tenants Management", () => {
|
|||||||
await page.fill("input >> nth=0", "Valid Name");
|
await page.fill("input >> nth=0", "Valid Name");
|
||||||
await expect(submitBtn).not.toBeDisabled();
|
await expect(submitBtn).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should show organization hierarchy and member list distinction", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Mock parent tenant and its children
|
||||||
|
const mockTenants = [
|
||||||
|
{
|
||||||
|
id: "parent-1",
|
||||||
|
name: "Parent Org",
|
||||||
|
slug: "parent-slug",
|
||||||
|
status: "active",
|
||||||
|
type: "COMPANY",
|
||||||
|
memberCount: 5,
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "child-1",
|
||||||
|
name: "Child Team",
|
||||||
|
slug: "child-slug",
|
||||||
|
status: "active",
|
||||||
|
type: "USER_GROUP",
|
||||||
|
memberCount: 3,
|
||||||
|
parentId: "parent-1",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await page.route("**/api/v1/admin/tenants*", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: mockTenants,
|
||||||
|
total: 2,
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock members for parent and child
|
||||||
|
await page.route(
|
||||||
|
"**/api/v1/admin/users?*companyCode=parent-slug*",
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [{ id: "u1", name: "User One", email: "u1@parent.com" }],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.route(
|
||||||
|
"**/api/v1/admin/users?*companyCode=child-slug*",
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [{ id: "u2", name: "User Two", email: "u2@child.com" }],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/tenants/parent-1/organization");
|
||||||
|
|
||||||
|
// Wait for the table to appear
|
||||||
|
await expect(page.locator("table")).toBeVisible();
|
||||||
|
|
||||||
|
// Check if hierarchy shows correctly
|
||||||
|
await expect(page.locator("table")).toContainText("Parent Org");
|
||||||
|
await expect(page.locator("table")).toContainText("Child Team");
|
||||||
|
|
||||||
|
// Check if member counts (Direct/Total) are displayed
|
||||||
|
// Parent should have Direct 5, Total 8
|
||||||
|
const parentRow = page.locator("tr", { hasText: "Parent Org" });
|
||||||
|
await expect(parentRow).toContainText("5"); // Direct
|
||||||
|
await expect(parentRow).toContainText("8"); // Total (5 + 3)
|
||||||
|
|
||||||
|
// Check for either English or Korean labels
|
||||||
|
const hasDirectLabel = await parentRow.evaluate(el =>
|
||||||
|
el.textContent?.includes("Direct") || el.textContent?.includes("소속")
|
||||||
|
);
|
||||||
|
const hasTotalLabel = await parentRow.evaluate(el =>
|
||||||
|
el.textContent?.includes("Total") || el.textContent?.includes("전체")
|
||||||
|
);
|
||||||
|
expect(hasDirectLabel).toBe(true);
|
||||||
|
expect(hasTotalLabel).toBe(true);
|
||||||
|
|
||||||
|
// Open Member List Dialog - Click the members count button
|
||||||
|
const memberButton = parentRow.getByRole("button").filter({ hasText: /Direct|소속/ });
|
||||||
|
await memberButton.click();
|
||||||
|
|
||||||
|
// Check Tabs in Member List Dialog
|
||||||
|
// Use regex to match either language, ignoring the count suffix
|
||||||
|
await expect(page.locator('button[role="tab"]').filter({ hasText: /소속 멤버|Direct Members/ })).toBeVisible();
|
||||||
|
await expect(page.locator('button[role="tab"]').filter({ hasText: /하위 조직 멤버|Descendant Members/ })).toBeVisible();
|
||||||
|
|
||||||
|
// Direct Members Tab should show parent's user
|
||||||
|
await expect(page.locator("role=dialog")).toContainText("u1@parent.com");
|
||||||
|
|
||||||
|
// Switch to Descendant Members Tab
|
||||||
|
await page.click('button[role="tab"]:has-text("하위 조직 멤버"), button[role="tab"]:has-text("Descendant Members")');
|
||||||
|
await expect(page.locator("role=dialog")).toContainText("u2@child.com");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user