diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx
index b9c267ef..10b7a5ee 100644
--- a/adminfront/src/features/tenants/routes/TenantListPage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx
@@ -763,7 +763,7 @@ function TenantListPage() {
+
+
+
+
+
+
+ {scopeTenantId ? (
+
+ ) : null}
+
{
items: [
{
id: "company-1",
- name: "Hanmac",
- slug: "hanmac",
+ name: "Acme",
+ slug: "acme",
status: "active",
type: "COMPANY",
memberCount: 0,
@@ -162,7 +162,7 @@ test.describe("Tenants Management", () => {
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
.fill("team-1");
await expect(page.locator("table")).toContainText("Platform");
- await expect(page.locator("table")).toContainText("Hanmac");
+ await expect(page.locator("table")).toContainText("Acme");
await page.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i).fill("");
await page
@@ -176,6 +176,82 @@ test.describe("Tenants Management", () => {
);
});
+ test("switches tree and flat views, searches UUID, and selects descendants", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 1100, height: 760 });
+
+ await page.route("**/api/v1/admin/tenants**", async (route) => {
+ if (route.request().method() !== "GET") {
+ return route.continue();
+ }
+
+ await route.fulfill({
+ json: {
+ items: [
+ {
+ id: "company-1",
+ name: "Acme",
+ slug: "acme",
+ status: "active",
+ type: "COMPANY",
+ memberCount: 0,
+ updatedAt: new Date().toISOString(),
+ },
+ {
+ id: "dept-1",
+ name: "Planning",
+ slug: "planning",
+ status: "active",
+ type: "ORGANIZATION",
+ parentId: "company-1",
+ memberCount: 0,
+ updatedAt: new Date().toISOString(),
+ },
+ {
+ id: "team-1",
+ name: "Platform",
+ slug: "platform",
+ status: "active",
+ type: "USER_GROUP",
+ parentId: "dept-1",
+ memberCount: 0,
+ updatedAt: new Date().toISOString(),
+ },
+ ],
+ total: 3,
+ limit: 500,
+ offset: 0,
+ },
+ headers: { "Access-Control-Allow-Origin": "*" },
+ });
+ });
+
+ await page.goto("/tenants");
+
+ await expect(page.getByTestId("tenant-view-tree-btn")).toBeVisible();
+ await page.getByTestId("tenant-view-table-btn").click();
+ await expect(page.getByTestId("tenant-view-table-btn")).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+
+ await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("team-1");
+ await expect(page.locator("table")).toContainText("Platform");
+ await expect(page.locator("table")).not.toContainText("Acme");
+
+ await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("");
+ await page
+ .locator("tbody tr")
+ .filter({ hasText: "Acme" })
+ .getByRole("checkbox")
+ .click();
+
+ await expect(page.getByTestId("tenant-bulk-action-bar")).toContainText(
+ "3개 선택됨",
+ );
+ });
+
test("should virtualize large tenant lists and load next pages automatically", async ({
page,
}) => {
@@ -565,6 +641,13 @@ test.describe("Tenants Management", () => {
let exportUrl = "";
let importRequested = false;
let importBody = "";
+ const openDataManagementMenu = async () => {
+ const exportMenuItem = page.getByTestId("tenant-export-menu-item");
+ if (!(await exportMenuItem.isVisible().catch(() => false))) {
+ await page.getByTestId("tenant-data-mgmt-btn").click();
+ }
+ await expect(exportMenuItem).toBeVisible();
+ };
await page.route("**/api/v1/admin/tenants**", async (route) => {
const url = route.request().url();
@@ -627,18 +710,34 @@ test.describe("Tenants Management", () => {
await expect(page.getByText(/조직\/사용자 통합/)).toHaveCount(0);
- // Open Data Management dropdown for export check
- await page.getByTestId("tenant-data-mgmt-btn").click();
+ await openDataManagementMenu();
await expect(page.getByTestId("tenant-template-menu-item")).toBeVisible();
- await expect(page.getByTestId("tenant-export-menu-item")).toBeVisible();
await expect(page.getByTestId("tenant-import-menu-item")).toBeVisible();
const download = page.waitForEvent("download");
- await page.getByTestId("tenant-export-menu-item").click();
- await download;
+ await page.getByTestId("tenant-export-menu-item").dispatchEvent("click");
+ const exportDownload = await download;
expect(exportRequested).toBe(true);
+ expect(exportDownload.suggestedFilename()).toBe("tenants.csv");
expect(exportUrl).toContain("includeIds=false");
+ await openDataManagementMenu();
+ await expect(
+ page.getByTestId("tenant-export-with-ids-menu-item"),
+ ).toBeVisible();
+ const exportWithIdsDownload = page.waitForEvent("download");
+ await page
+ .getByTestId("tenant-export-with-ids-menu-item")
+ .dispatchEvent("click");
+ await exportWithIdsDownload;
+ expect(exportUrl).toContain("includeIds=true");
+
+ await openDataManagementMenu();
+ const templateDownload = page.waitForEvent("download");
+ await page.getByTestId("tenant-template-menu-item").dispatchEvent("click");
+ const template = await templateDownload;
+ expect(template.suggestedFilename()).toBe("tenant-import-template.csv");
+
// Upload directly via setInputFiles (Playwright supports hidden inputs)
await page.getByTestId("tenant-import-input").setInputFiles({
name: "tenants.csv",
diff --git a/adminfront/tests/worksmobile.spec.ts b/adminfront/tests/worksmobile.spec.ts
index 08dd4679..52abdf66 100644
--- a/adminfront/tests/worksmobile.spec.ts
+++ b/adminfront/tests/worksmobile.spec.ts
@@ -1,3 +1,4 @@
+import { readFile } from "node:fs/promises";
import { expect, test } from "@playwright/test";
test.describe("Worksmobile tenant management", () => {
@@ -558,4 +559,163 @@ test.describe("Worksmobile tenant management", () => {
immutableRow.getByRole("button", { name: /비밀번호 관리/ }),
).toBeDisabled();
});
+
+ test("downloads initial password CSV and enqueues WORKS admin jobs", async ({
+ page,
+ }) => {
+ const requests: string[] = [];
+ const headers = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Expose-Headers": "Content-Disposition",
+ };
+
+ await page.route("**/api/v1/**", async (route) => {
+ const url = new URL(route.request().url());
+ const method = route.request().method();
+
+ if (url.pathname.endsWith("/user/me")) {
+ return route.fulfill({
+ json: { id: "admin-user", name: "Admin", role: "super_admin" },
+ headers,
+ });
+ }
+
+ if (
+ url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
+ method === "GET"
+ ) {
+ return route.fulfill({
+ json: {
+ tenant: {
+ id: "hanmac-family-id",
+ name: "한맥 가족",
+ slug: "hanmac-family",
+ parentId: null,
+ },
+ config: {
+ enabled: true,
+ tokenConfigured: true,
+ adminTenantId: "works-tenant-1",
+ },
+ recentJobs: [
+ {
+ id: "job-retry",
+ resourceType: "USER",
+ resourceId: "user-failed",
+ action: "sync",
+ status: "failed",
+ retryCount: 1,
+ createdAt: "2026-05-01T00:00:00Z",
+ updatedAt: "2026-05-01T00:00:00Z",
+ },
+ ],
+ },
+ headers,
+ });
+ }
+
+ if (
+ url.pathname.endsWith(
+ "/admin/tenants/hanmac-family-id/worksmobile/comparison",
+ ) &&
+ method === "GET"
+ ) {
+ return route.fulfill({
+ json: { users: [], groups: [] },
+ headers,
+ });
+ }
+
+ if (
+ url.pathname.endsWith(
+ "/admin/tenants/hanmac-family-id/worksmobile/initial-passwords.csv",
+ ) &&
+ method === "GET"
+ ) {
+ requests.push("download-passwords");
+ return route.fulfill({
+ body: "email,password\nuser@example.com,Secret123!\n",
+ contentType: "text/csv",
+ headers: {
+ ...headers,
+ "Content-Disposition":
+ 'attachment; filename="worksmobile-passwords.csv"',
+ },
+ });
+ }
+
+ if (
+ url.pathname.endsWith(
+ "/admin/tenants/hanmac-family-id/worksmobile/backfill/dry-run",
+ ) &&
+ method === "POST"
+ ) {
+ requests.push("dry-run");
+ return route.fulfill({ json: { id: "job-dry-run" }, headers });
+ }
+
+ if (
+ url.pathname.endsWith(
+ "/admin/tenants/hanmac-family-id/worksmobile/orgunits/org-1/sync",
+ ) &&
+ method === "POST"
+ ) {
+ requests.push("org-sync");
+ return route.fulfill({ json: { id: "job-org-sync" }, headers });
+ }
+
+ if (
+ url.pathname.endsWith(
+ "/admin/tenants/hanmac-family-id/worksmobile/users/user-1/sync",
+ ) &&
+ method === "POST"
+ ) {
+ requests.push("user-sync");
+ return route.fulfill({ json: { id: "job-user-sync" }, headers });
+ }
+
+ if (
+ url.pathname.endsWith(
+ "/admin/tenants/hanmac-family-id/worksmobile/jobs/job-retry/retry",
+ ) &&
+ method === "POST"
+ ) {
+ requests.push("retry");
+ return route.fulfill({ json: { id: "job-retry-next" }, headers });
+ }
+
+ return route.fulfill({ json: { items: [], total: 0 }, headers });
+ });
+
+ await page.goto("/tenants/hanmac-family-id/worksmobile");
+ await expect(page.getByText("Worksmobile 연동")).toBeVisible();
+
+ const download = page.waitForEvent("download");
+ await page.getByRole("button", { name: "초기 비밀번호 CSV" }).click();
+ const passwordCsv = await download;
+ expect(passwordCsv.suggestedFilename()).toBe("worksmobile-passwords.csv");
+ const passwordCsvPath = await passwordCsv.path();
+ expect(passwordCsvPath).toBeTruthy();
+ expect(await readFile(passwordCsvPath ?? "", "utf8")).toContain(
+ "user@example.com,Secret123!",
+ );
+
+ await page.getByRole("button", { name: "Backfill Dry-run" }).click();
+ await expect.poll(() => requests).toContain("dry-run");
+
+ await page.getByPlaceholder("orgUnit tenant UUID").fill("org-1");
+ await page.getByRole("button", { name: "조직 Sync" }).click();
+ await expect.poll(() => requests).toContain("org-sync");
+
+ await page.getByPlaceholder("Kratos user UUID").fill("user-1");
+ await page.getByRole("button", { name: "구성원 Sync" }).click();
+ await expect.poll(() => requests).toContain("user-sync");
+
+ await page
+ .getByRole("row", { name: /USER:user-failed/ })
+ .getByRole("button")
+ .click();
+ await expect.poll(() => requests).toContain("retry");
+ expect(requests).toContain("download-passwords");
+ });
});