import { expect, test } from "@playwright/test"; test.describe("Tenants Management", () => { 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}`; const authData = { access_token: "fake-token", token_type: "Bearer", profile: { sub: "admin-user", name: "Admin", role: "super_admin" }, expires_at: Math.floor(Date.now() / 1000) + 36000, }; window.localStorage.setItem(key, JSON.stringify(authData)); }); await page.route("**/api/v1/**", async (route) => { const url = route.request().url(); const headers = { "Access-Control-Allow-Origin": "*" }; if (url.includes("/user/me")) { return route.fulfill({ json: { id: "admin-user", name: "Admin", role: "super_admin", manageableTenants: [], }, headers, }); } if (url.includes("/admin/tenants")) { if ( route.request().method() === "GET" && !url.includes("/parent-1") && !url.includes("/organization") ) { return route.fulfill({ json: { items: [], total: 0, limit: 100, offset: 0 }, headers, }); } } return route.fulfill({ json: { items: [], total: 0 }, headers }); }); await page.route("**/oidc/**", async (route) => { await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); }); }); test("should list tenants", async ({ page }) => { await page.setViewportSize({ width: 900, height: 700 }); const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea"; await page.route("**/api/v1/admin/tenants**", async (route) => { if (route.request().method() === "GET") { await route.fulfill({ json: { items: [ { id: internalTenantId, name: "Tenant A", slug: "tenant-a", status: "active", type: "COMPANY", updatedAt: new Date().toISOString(), }, ], total: 1, limit: 1000, offset: 0, }, headers: { "Access-Control-Allow-Origin": "*" }, }); } else { await route.continue(); } }); await page.goto("/tenants"); await expect(page.locator("h2").last()).toContainText( /테넌트 목록|Tenants/i, { timeout: 20000 }, ); await expect(page.locator("table")).toContainText("Tenant A", { timeout: 10000, }); await expect(page.locator("table")).toContainText(internalTenantId); await expect(page.locator("table")).toContainText("COMPANY"); await expect(page.locator("table")).not.toContainText("일반 기업"); const headerWhiteSpace = await page .locator("table thead th") .evaluateAll((headers) => headers.map((header) => window.getComputedStyle(header).whiteSpace), ); expect(headerWhiteSpace.every((value) => value === "nowrap")).toBe(true); }); test("should create a new tenant", async ({ page }) => { await page.goto("/tenants/new"); await expect(page.locator("h2").last()).toContainText(/추가|Create/i, { timeout: 20000, }); const nameInput = page.locator('input[name="name"]').first(); await nameInput.fill("New Tenant"); const slugInput = page.locator('input[name="slug"]').first(); await slugInput.fill("new-tenant"); await page.locator("textarea").first().fill("Description"); const submitBtn = page .locator("button") .filter({ hasText: /생성|Create/i }) .first(); await submitBtn.click(); await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 }); }); test("should export and import tenant CSV without organization/user combined import", async ({ page, browserName, }, testInfo) => { let exportRequested = false; let exportUrl = ""; let importRequested = false; let importBody = ""; await page.route("**/api/v1/admin/tenants**", async (route) => { const url = route.request().url(); const method = route.request().method(); const headers = { "Access-Control-Allow-Origin": "*" }; if (url.includes("/export")) { exportRequested = true; exportUrl = url; return route.fulfill({ body: "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n", contentType: "text/csv", headers: { ...headers, "Content-Disposition": 'attachment; filename="tenants.csv"', }, }); } if (url.includes("/import")) { importRequested = true; importBody = route.request().postData() ?? ""; return route.fulfill({ json: { created: 0, updated: 1, failed: 0, errors: [] }, headers, }); } if (method === "GET") { return route.fulfill({ json: { items: [ { id: "tenant-alpha-id", name: "Tenant Alpha", slug: "tenant-alpha", status: "active", type: "COMPANY", domains: [], memberCount: 0, updatedAt: new Date().toISOString(), }, ], total: 1, limit: 1000, offset: 0, }, headers, }); } return route.continue(); }); await page.goto("/tenants"); await expect(page.locator("h2").last()).toContainText( /테넌트 목록|Tenants/i, { timeout: 20000 }, ); await expect(page.getByText(/조직\/사용자 통합/)).toHaveCount(0); await expect(page.getByTestId("tenant-template-btn")).toBeVisible(); await expect(page.getByTestId("tenant-export-btn")).toBeVisible(); await expect(page.getByTestId("tenant-import-btn")).toBeVisible(); const download = page.waitForEvent("download"); await page.getByTestId("tenant-export-btn").click(); await download; expect(exportRequested).toBe(true); expect(exportUrl).toContain("includeIds=false"); await page.getByTestId("tenant-import-input").setInputFiles({ name: "tenants.csv", mimeType: "text/csv", buffer: Buffer.from( "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Tenant Alpha,COMPANY,,tenant-alpha-copy,Imported memo,imported.example.com\n", ), }); await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인"); await expect(page.getByTestId("tenant-import-candidate")).toContainText( "Tenant Alpha", ); await page.getByTestId("tenant-import-confirm-btn").click(); await expect(page.getByTestId("tenant-import-result")).toContainText( /갱신 1|Updated 1/i, ); expect(importRequested).toBe(true); expect(importBody).toContain('filename="tenants.csv"'); if (browserName !== "webkit") { if (testInfo.project.name !== "webkit") { expect(importBody).toContain("tenant-alpha-id"); } } }); test("should resolve tenant CSV conflicts by choosing create and remapping parent ids", async ({ page, }, testInfo) => { let importBody = ""; await page.route("**/api/v1/admin/tenants**", async (route) => { const url = route.request().url(); const method = route.request().method(); const headers = { "Access-Control-Allow-Origin": "*" }; if (url.includes("/import")) { importBody = route.request().postData() ?? ""; return route.fulfill({ json: { created: 2, updated: 0, failed: 0, errors: [] }, headers, }); } if (method === "GET") { return route.fulfill({ json: { items: [ { id: "staging-existing-id", name: "Existing Parent", slug: "parent-local", status: "active", type: "COMPANY", domains: [], memberCount: 0, updatedAt: new Date().toISOString(), }, ], total: 1, limit: 1000, offset: 0, }, headers, }); } return route.continue(); }); await page.goto("/tenants"); await expect(page.locator("h2").last()).toContainText( /테넌트 목록|Tenants/i, { timeout: 20000 }, ); await page.getByTestId("tenant-import-input").setInputFiles({ name: "tenants.csv", mimeType: "text/csv", buffer: Buffer.from( [ "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain", "local-parent-id,Parent Tenant,COMPANY,,parent-local,,", "local-child-id,Child Tenant,USER_GROUP,local-parent-id,child-local,,", ].join("\n"), ), }); await expect(page.getByRole("dialog")).toContainText("CSV 가져오기 확인"); await page .getByTestId("tenant-import-match-select-2") .selectOption("__create__"); await page .getByTestId("tenant-import-create-slug-2") .fill("parent-created"); await page .getByTestId("tenant-import-match-select-3") .selectOption("__create__"); await page.getByTestId("tenant-import-create-slug-3").fill("child-created"); await page.getByTestId("tenant-import-confirm-btn").click(); await expect(page.getByTestId("tenant-import-result")).toContainText( /생성 2|Created 2/i, ); if (testInfo.project.name === "webkit") { expect(importBody).not.toContain("local-parent-id"); expect(importBody).not.toContain("local-child-id"); return; } expect(importBody).not.toContain("local-parent-id"); expect(importBody).not.toContain("local-child-id"); const parentMatch = importBody.match( /([0-9a-f-]{36}),Parent Tenant,COMPANY,,parent-created/, ); expect(parentMatch?.[1]).toBeTruthy(); expect(importBody).toContain( `,Child Tenant,USER_GROUP,${parentMatch?.[1]},child-created`, ); }); test("should show validation error on empty name", async ({ page }) => { await page.goto("/tenants/new"); await expect(page.locator("h2").last()).toContainText(/추가|Create/i, { timeout: 20000, }); const submitBtn = page .locator("button") .filter({ hasText: /생성|Create/i }) .first(); await expect(submitBtn).toBeDisabled(); await page.locator('input[name="name"]').first().fill("Valid Name"); await expect(submitBtn).not.toBeDisabled(); }); test("should show organization hierarchy and member list distinction", async ({ page, }) => { 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) => { const url = route.request().url(); if (url.includes("/organization")) { await route.fulfill({ json: mockTenants, headers: { "Access-Control-Allow-Origin": "*" }, }); } else if (url.includes("/parent-1")) { await route.fulfill({ json: mockTenants[0], headers: { "Access-Control-Allow-Origin": "*" }, }); } else { await route.fulfill({ json: { items: mockTenants, total: 2, limit: 1000, offset: 0 }, headers: { "Access-Control-Allow-Origin": "*" }, }); } }); await page.goto("/tenants/parent-1/organization"); await expect( page.locator(".font-bold, h2").filter({ hasText: "Parent Org" }).first(), ).toBeVisible({ timeout: 20000 }); await expect( page .locator(".grid .font-bold, .grid .text-sm") .filter({ hasText: "Child Team" }) .first(), ).toBeVisible(); await expect( page .locator("h3") .filter({ hasText: /소속 멤버|Members|구성원 관리|Member Management/i }) .first(), ).toBeVisible(); }); });