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, }); await page.getByRole("button", { name: "최상위 테넌트로 생성" }).click(); 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.getByRole("button", { name: /^생성$/ }); await submitBtn.click(); await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 }); }); test("should ask for parent tenant before tenant details", async ({ page, }) => { const tenants = [ { id: "family-1", name: "한맥가족", slug: "hanmac-family", status: "active", type: "COMPANY_GROUP", memberCount: 0, parentId: null, }, { id: "company-1", name: "삼안", slug: "saman", status: "active", type: "COMPANY", memberCount: 0, parentId: "family-1", }, { id: "outside-1", name: "외부회사", slug: "outside", status: "active", type: "COMPANY", memberCount: 0, parentId: null, }, ]; await page.route("**/api/v1/admin/tenants**", async (route) => { const headers = { "Access-Control-Allow-Origin": "*" }; return route.fulfill({ json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 }, headers, }); }); await page.goto("/tenants/new"); await expect(page.locator("h2").last()).toContainText(/추가|Create/i, { timeout: 20000, }); await expect( page.getByRole("button", { name: "한맥가족에서 선택" }), ).toBeVisible(); await expect( page.getByRole("button", { name: "다른 테넌트 선택" }), ).toBeVisible(); const parentLabelTop = await page .getByText(/상위 테넌트/) .first() .evaluate((element) => element.getBoundingClientRect().top); const rootButtonTop = await page .getByRole("button", { name: "최상위 테넌트로 생성" }) .evaluate((element) => element.getBoundingClientRect().top); expect(Math.abs(parentLabelTop - rootButtonTop)).toBeLessThan(10); await expect(page.locator('input[name="name"]')).toHaveCount(0); await page.getByRole("button", { name: "다른 테넌트 선택" }).click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.getByPlaceholder("테넌트 이름 또는 슬러그 검색").fill("outside"); await page.getByRole("button", { name: /외부회사/ }).click(); await expect( page .getByTestId("tenant-parent-picker-slot") .getByText("outside · COMPANY"), ).toBeVisible(); await expect(page.getByText("일반 하위 테넌트")).toBeVisible(); await expect(page.locator('input[name="name"]')).toBeVisible(); await expect(page.getByLabel("조직 세부타입")).toHaveCount(0); await expect(page.getByLabel("공개 범위")).toHaveCount(0); await page .getByTestId("tenant-parent-picker-slot") .getByRole("button", { name: "한맥가족에서 선택" }) .click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.evaluate(() => { window.postMessage( { type: "orgfront:picker:confirm", payload: { selections: [{ type: "tenant", id: "family-1", name: "한맥가족" }], }, }, window.location.origin, ); }); await expect(page.getByText("hanmac-family · COMPANY_GROUP")).toBeVisible(); await expect(page.getByText("한맥가족 하위 테넌트")).toBeVisible(); await expect(page.locator('input[name="name"]')).toBeVisible(); await expect(page.getByLabel("조직 세부타입")).toBeVisible(); await expect(page.getByLabel("공개 범위")).toBeVisible(); }); test("should create a hanmac-family child tenant with org config", async ({ page, }) => { await page.setViewportSize({ width: 1280, height: 800 }); let createBody = ""; const tenants = [ { id: "family-1", name: "한맥가족", slug: "hanmac-family", status: "active", type: "COMPANY_GROUP", memberCount: 0, parentId: null, }, { id: "company-1", name: "삼안", slug: "saman", status: "active", type: "COMPANY", memberCount: 0, parentId: "family-1", }, ]; await page.route("**/api/v1/admin/tenants**", async (route) => { const method = route.request().method(); const headers = { "Access-Control-Allow-Origin": "*" }; if (method === "GET") { return route.fulfill({ json: { items: tenants, total: tenants.length, limit: 1000, offset: 0, }, headers, }); } if (method === "POST") { createBody = route.request().postData() ?? ""; return route.fulfill({ json: { id: "created-tenant-id", name: "신규 센터" }, headers, }); } return route.fulfill({ json: {}, headers }); }); await page.goto("/tenants/new"); await expect(page.locator("h2").last()).toContainText(/추가|Create/i, { timeout: 20000, }); await page.getByRole("button", { name: "한맥가족에서 선택" }).click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.evaluate(() => { window.postMessage( { type: "orgfront:picker:confirm", payload: { selections: [{ type: "tenant", id: "family-1", name: "한맥가족" }], }, }, window.location.origin, ); }); await expect(page.getByText("hanmac-family · COMPANY_GROUP")).toBeVisible(); await expect(page.getByLabel("조직 세부타입")).toBeVisible(); await expect(page.getByLabel("공개 범위")).toBeVisible(); const layout = page.getByTestId("tenant-parent-org-config-layout"); const parentWidth = await page .getByTestId("tenant-parent-picker-slot") .evaluate((element) => element.getBoundingClientRect().width); const orgUnitWidth = await page .getByTestId("tenant-org-unit-type-slot") .evaluate((element) => element.getBoundingClientRect().width); const visibilityWidth = await page .getByTestId("tenant-visibility-slot") .evaluate((element) => element.getBoundingClientRect().width); const columns = await layout.evaluate( (element) => window.getComputedStyle(element).gridTemplateColumns, ); expect(columns.split(" ").length).toBe(4); expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7); expect(parentWidth).toBeLessThan(orgUnitWidth * 2.3); expect(Math.abs(orgUnitWidth - visibilityWidth)).toBeLessThan(8); await page.locator('input[name="name"]').first().fill("신규 센터"); await page.locator('input[name="slug"]').first().fill("new-center"); await page.getByLabel("조직 세부타입").selectOption("센터"); await page.getByLabel("공개 범위").selectOption("internal"); await page .locator("button") .filter({ hasText: /생성|Create/i }) .first() .click(); await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 }); expect(JSON.parse(createBody)).toMatchObject({ parentId: "family-1", config: { orgUnitType: "센터", visibility: "internal", }, }); }); 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]},parent-created,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, }); await page.getByRole("button", { name: "최상위 테넌트로 생성" }).click(); const submitBtn = page.getByRole("button", { name: /^생성$/ }); 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(); }); test("should show tenant UUID at the top of tenant detail profile", async ({ page, }) => { const tenantUuid = "11111111-2222-4333-8444-555555555555"; const tenant = { id: tenantUuid, name: "Tenant With UUID", slug: "tenant-with-uuid", status: "active", type: "COMPANY", memberCount: 0, parentId: null, config: {}, domains: [], updatedAt: new Date().toISOString(), }; await page.route("**/api/v1/admin/tenants**", async (route) => { const url = route.request().url(); const headers = { "Access-Control-Allow-Origin": "*" }; if (url.includes(`/admin/tenants/${tenantUuid}`)) { return route.fulfill({ json: tenant, headers }); } return route.fulfill({ json: { items: [tenant], total: 1, limit: 1000, offset: 0 }, headers, }); }); await page.goto(`/tenants/${tenantUuid}`); const titleRow = page.getByTestId("tenant-detail-title-row"); await expect(titleRow).toBeVisible({ timeout: 20000 }); await expect(titleRow).toContainText("Tenant With UUID"); await expect(titleRow).toContainText(tenantUuid); await expect(titleRow).not.toContainText("Tenant UUID"); await expect(page.getByTestId("tenant-detail-copy-uuid")).toBeVisible(); }); test("should place hanmac org config beside parent tenant picker", async ({ page, }) => { await page.setViewportSize({ width: 1280, height: 800 }); const tenants = [ { id: "family-1", name: "한맥가족", slug: "hanmac-family", status: "active", type: "COMPANY_GROUP", memberCount: 0, parentId: null, config: {}, }, { id: "company-1", name: "삼안", slug: "saman", status: "active", type: "COMPANY", memberCount: 0, parentId: "family-1", config: {}, }, { id: "team-1", name: "기획팀", slug: "planning", status: "active", type: "USER_GROUP", memberCount: 0, parentId: "company-1", config: { orgUnitType: "팀", visibility: "internal" }, }, ]; await page.route("**/api/v1/admin/tenants**", async (route) => { const url = route.request().url(); const headers = { "Access-Control-Allow-Origin": "*" }; if (url.includes("/admin/tenants/team-1")) { return route.fulfill({ json: tenants[2], headers }); } return route.fulfill({ json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 }, headers, }); }); await page.goto("/tenants/team-1"); const layout = page.getByTestId("tenant-parent-org-config-layout"); await expect(layout).toBeVisible({ timeout: 20000 }); await expect(layout).toContainText("상위 테넌트"); await expect(layout).toContainText("조직 세부타입"); await expect(layout).toContainText("공개 범위"); const columns = await layout.evaluate( (element) => window.getComputedStyle(element).gridTemplateColumns, ); expect(columns.split(" ").length).toBe(4); const parentWidth = await page .getByTestId("tenant-parent-picker-slot") .evaluate((element) => element.getBoundingClientRect().width); const orgUnitWidth = await page .getByTestId("tenant-org-unit-type-slot") .evaluate((element) => element.getBoundingClientRect().width); const visibilityWidth = await page .getByTestId("tenant-visibility-slot") .evaluate((element) => element.getBoundingClientRect().width); expect(parentWidth).toBeGreaterThan(orgUnitWidth * 1.7); expect(parentWidth).toBeLessThan(orgUnitWidth * 2.3); expect(Math.abs(orgUnitWidth - visibilityWidth)).toBeLessThan(8); }); });