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"); + }); });