import { expect, test } from "@playwright/test"; test.describe("User Management", () => { test.beforeEach(async ({ page }) => { await page.addInitScript(() => { const authority = "http://localhost:5000/oidc"; const client_id = "adminfront"; const key = `oidc.user:${authority}:${client_id}`; const authData = { id_token: "fake-id-token", access_token: "fake-token", token_type: "Bearer", scope: "openid profile email", profile: { sub: "admin-user", name: "Admin", email: "admin@test.com", role: "super_admin", }, expires_at: Math.floor(Date.now() / 1000) + 36000, }; window.localStorage.setItem(key, JSON.stringify(authData)); window.localStorage.setItem("admin_session", "fake-token"); window.localStorage.setItem("locale", "ko"); window.localStorage.setItem("oidc.state", "dummy"); ( window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } )._IS_TEST_MODE = true; }); await page.route("**/oidc/**", async (route) => { if (route.request().url().includes("/.well-known/openid-configuration")) { return route.fulfill({ json: { issuer: "http://localhost:5000/oidc", authorization_endpoint: "http://localhost:5000/oidc/auth", token_endpoint: "http://localhost:5000/oidc/token", userinfo_endpoint: "http://localhost:5000/oidc/userinfo", jwks_uri: "http://localhost:5000/oidc/jwks", }, }); } await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); }); await page.route(/.*\/api\/v1\/.*/, async (route) => { const url = route.request().url(); const method = route.request().method(); if (url.includes("/user/me")) { return route.fulfill({ json: { id: "admin-user", name: "Admin", email: "admin@test.com", role: "super_admin", manageableTenants: [], }, }); } if (url.match(/\/admin\/tenants(\?.*)?$/) && method === "GET") { return route.fulfill({ json: { items: [ { id: "t-1", slug: "test-tenant", name: "Test Tenant", config: { userSchema: [ { key: "loginId", label: "Login ID", type: "text", isLoginId: true, }, ], }, }, { id: "03dbe16b-e47b-4f72-927b-782807d67a35", slug: "tech-planning", name: "기술기획", type: "USER_GROUP", parentId: "hanmac-company-id", config: {}, }, { id: "hanmac-family-id", slug: "hanmac-family", name: "한맥가족", type: "COMPANY_GROUP", config: {}, }, { id: "hanmac-company-id", slug: "hanmac-company", name: "한맥기술", type: "COMPANY", parentId: "hanmac-family-id", config: {}, }, { id: "hanmac-team-id", slug: "hanmac-team", name: "한맥팀", type: "USER_GROUP", parentId: "hanmac-company-id", config: {}, }, { id: "system-id", slug: "system", name: "System Tenant", type: "SYSTEM", config: {}, }, { id: "external-tenant-id", slug: "external-tenant", name: "External Tenant", type: "COMPANY", config: {}, }, ], total: 7, limit: 100, offset: 0, }, }); } if (url.match(/\/admin\/tenants$/) && method === "POST") { const postData = route.request().postDataJSON(); return route.fulfill({ status: 201, json: { id: "personal-tenant-id", slug: postData?.slug || "personal", name: postData?.name || "Personal", type: postData?.type || "PERSONAL", config: {}, }, }); } if (url.match(/\/admin\/tenants\/t-1$/) && method === "GET") { return route.fulfill({ json: { id: "t-1", slug: "test-tenant", name: "Test Tenant", config: { userSchema: [ { key: "loginId", label: "Login ID", type: "text", isLoginId: true, }, ], }, }, }); } if ( url.match(/\/admin\/tenants\/03dbe16b-e47b-4f72-927b-782807d67a35$/) && method === "GET" ) { return route.fulfill({ json: { id: "03dbe16b-e47b-4f72-927b-782807d67a35", slug: "tech-planning", name: "기술기획", type: "USER_GROUP", config: {}, }, }); } if (url.match(/\/admin\/users\/u-1$/) && method === "GET") { return route.fulfill({ json: { id: "u-1", name: "John Doe", email: "john@test.com", loginId: "johndoe", tenantSlug: "test-tenant", tenant: { id: "t-1", name: "Test Tenant", slug: "test-tenant" }, role: "user", status: "active", metadata: { "t-1": { loginId: "johndoe" } }, }, }); } if (url.includes("/password/policy")) { return route.fulfill({ json: { minLength: 12, lowercase: true, uppercase: true, number: true, nonAlphanumeric: true, }, }); } if (url.includes("/rp-history")) { return route.fulfill({ json: [], }); } if (url.match(/\/admin\/users(\?.*)?$/) && method === "POST") { // Parse request payload to simulate validation checks const postData = route.request().postDataJSON(); if (postData && postData.metadata?.loginId === "existing_user") { // Simulate a backend conflict error (409) for an existing loginId return route.fulfill({ status: 409, json: { error: "이미 존재하는 로그인 ID 입니다.", }, }); } // Mock successful user creation return route.fulfill({ json: { id: "new-user-id", name: "New User", email: "newuser@test.com", loginId: postData?.metadata?.loginId || "newuser123", }, }); } if (url.match(/\/admin\/users\/u-1$/) && method === "PUT") { const postData = route.request().postData(); console.log("PUT /admin/users/u-1 payload:", postData); // Force 409 error for this specific conflict string if (postData?.includes("johndoe_conflict")) { return route.fulfill({ status: 409, json: { error: "이미 존재하는 로그인 ID 입니다.", }, }); } // Mock successful user update return route.fulfill({ json: { id: "u-1", name: "John Doe Updated", email: "john@test.com", loginId: "johndoe_updated", status: "inactive", }, }); } if (url.match(/\/admin\/users(\?.*)?$/) && method === "GET") { return route.fulfill({ json: { items: [ { id: "u-1", name: "John Doe", email: "john@test.com", phone: "010-1111-2222", loginId: "johndoe", role: "user", status: "active", createdAt: "2026-04-01T00:00:00Z", }, ], total: 1, limit: 50, offset: 0, }, }); } return route.fulfill({ json: { items: [], total: 0 } }); }); }); test("should successfully edit a user's Login ID", async ({ page }) => { await page.goto("/users/u-1"); // "테넌트 프로필" 탭 클릭 await page .getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i }) .click(); // Wait for the form to load with the existing login ID const loginIdInput = page.locator( 'input[name*="metadata"][name*="loginId"]', ); await expect(loginIdInput).toBeVisible(); await expect(loginIdInput).toHaveValue("johndoe"); // Change the Login ID await loginIdInput.fill("johndoe_updated"); // Submit the form using Enter key await loginIdInput.press("Enter"); // Check for success message await expect(page.getByText(/저장/i).first()).toBeVisible(); }); test("should show conflict error when updating to an existing Login ID", async ({ page, }) => { // Intercept ANY PUT request to this user and return 409 await page.route(/\/admin\/users\/u-1/, async (route) => { if (route.request().method() === "PUT") { return route.fulfill({ status: 409, contentType: "application/json", body: JSON.stringify({ error: "이미 존재하는 로그인 ID 입니다." }), }); } return route.fallback(); }); await page.goto("/users/u-1"); // "테넌트 프로필" 탭 클릭 await page .getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i }) .click(); const loginIdInput = page.locator( 'input[name*="metadata"][name*="loginId"]', ); await expect(loginIdInput).toBeVisible(); await expect(loginIdInput).toHaveValue("johndoe"); // Use a value similar to the successful edit test await loginIdInput.fill("johndoe_conflict"); // Submit the form using Enter key await loginIdInput.press("Enter"); // Check for the specific error await expect( page.getByText(/이미 존재하는 로그인 ID 입니다/i).first(), ).toBeVisible(); }); test("should successfully create a new user with a Login ID", async ({ page, }) => { await page.goto("/users/new"); // Ensure the page title is loaded await expect(page.getByText(/사용자 추가/i).first()).toBeVisible(); const userTypeTabs = page.getByRole("tab"); await expect(userTypeTabs).toHaveText([ "한맥가족 구성원", "외부 기업 회원", "개인 회원", ]); await expect( page.getByRole("tab", { name: /외부 기업 회원/i }), ).toBeVisible(); await expect( page.getByRole("tab", { name: /한맥가족 구성원/i }), ).toBeVisible(); await expect(page.getByRole("tab", { name: /개인 회원/i })).toBeVisible(); await expect( page.getByRole("tab", { name: /한맥가족 구성원/i }), ).toHaveAttribute("data-state", "active"); await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0); // Select Tenant first (important for schema fields to show up) await page.getByRole("tab", { name: /외부 기업 회원/i }).click(); await page.selectOption("select#tenantSlug", "test-tenant"); // Fill required fields await page.locator('input[name="name"]').fill("New User"); await page.locator('input[name="email"]').fill("newuser@test.com"); // Fill Login ID const loginIdInput = page.locator('input[id*="metadata"][id*="loginId"]'); await loginIdInput.fill("newuser123"); // Submit the form const createButton = page.getByRole("button", { name: /생성/i }); await createButton.click(); // Assuming successful creation redirects back to the user list await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 }); }); test("should export users through the authenticated API client", async ({ page, }) => { let authorizationHeader: string | undefined; let exportUrl = ""; await page.route(/\/admin\/users\/export(\?.*)?$/, async (route) => { authorizationHeader = route.request().headers().authorization; exportUrl = route.request().url(); return route.fulfill({ status: 200, headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": 'attachment; filename="users.csv"', }, body: "email,name\njohn@test.com,John Doe\n", }); }); await page.goto("/users"); const [download] = await Promise.all([ page.waitForEvent("download"), page.getByRole("button", { name: /내보내기|Export/i }).click(), ]); expect(download.suggestedFilename()).toBe("users.csv"); expect(authorizationHeader).toBe("Bearer fake-token"); expect(exportUrl).toContain("includeIds=false"); }); test("should show contact info in one row, hide roles, and toggle user status", async ({ page, }) => { let updatePayload: Record | undefined; await page.route(/\/admin\/users\/u-1$/, async (route) => { if (route.request().method() === "PUT") { updatePayload = route.request().postDataJSON(); return route.fulfill({ json: { id: "u-1", name: "John Doe", email: "john@test.com", phone: "010-1111-2222", loginId: "johndoe", status: "inactive", createdAt: "2026-04-01T00:00:00Z", }, }); } return route.fallback(); }); await page.goto("/users"); const table = page.locator("table"); await expect( table.getByRole("columnheader", { name: /ROLE|역할/i }), ).toHaveCount(0); await expect(page.getByTestId("user-contact-u-1")).toContainText( "John Doe john@test.com 010-1111-2222", ); await page.getByTestId("user-status-toggle-u-1").click(); await expect .poll(() => updatePayload) .toMatchObject({ status: "inactive" }); }); test("should expose internal user uuid in the users table", async ({ page, }) => { const internalUserId = "4d20c735-05d0-42d4-9479-0e9be74fd987"; await page.route(/\/admin\/users(\?.*)?$/, async (route) => { if (route.request().method() !== "GET") { return route.fallback(); } return route.fulfill({ json: { items: [ { id: internalUserId, name: "UUID User", email: "uuid-user@test.com", phone: "010-2222-3333", loginId: "uuid_login_id", role: "user", status: "active", createdAt: "2026-04-01T00:00:00Z", }, ], total: 1, limit: 50, offset: 0, }, }); }); await page.goto("/users"); await expect(page.locator("table")).toContainText(internalUserId); }); test("should create a Hanmac family user with tenant appointments and no representative affiliation", async ({ page, }) => { let createPayload: Record | undefined; await page.route(/\/admin\/users(\?.*)?$/, async (route) => { if (route.request().method() === "POST") { createPayload = route.request().postDataJSON(); return route.fulfill({ status: 201, json: { id: "new-user-id", name: "Family User", email: "family@test.com", }, }); } return route.fallback(); }); await page.goto("/users/new"); await expect( page.getByRole("tab", { name: /한맥가족 구성원/i }), ).toHaveAttribute("data-state", "active"); await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0); await expect(page.locator("select#role")).toHaveCount(0); await expect(page.locator("input#department")).toHaveCount(0); await expect(page.getByText(/대표 소속/i)).toHaveCount(0); await page.getByRole("button", { name: /^추가$/i }).click(); await expect(page.getByTestId("appointment-row-0")).toBeVisible(); await expect( page.getByTestId("appointment-tenant-owner-line-0"), ).toBeVisible(); await expect(page.getByTestId("appointment-position-line-0")).toBeVisible(); await page.getByRole("button", { name: /테넌트 선택/i }).click(); await expect(page.getByTitle(/테넌트 선택/i)).toHaveAttribute( "src", /\/login\?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id$/, ); await page.evaluate(() => { window.dispatchEvent( new MessageEvent("message", { data: { type: "orgfront:picker:confirm", payload: { mode: "single", selections: [ { type: "tenant", id: "03dbe16b-e47b-4f72-927b-782807d67a35", name: "기술기획", }, ], }, }, }), ); }); await expect(page.getByText("기술기획")).toBeVisible(); await page.getByRole("switch", { name: /대표 조직/i }).click(); await page.getByLabel(/^직무$/i).fill("플랫폼 운영"); await page.getByLabel(/^직급$/i).fill("책임"); await page.getByLabel(/^직책$/i).fill("팀장"); await page.locator('input[name="name"]').fill("Family User"); await page.locator('input[name="email"]').fill("family@test.com"); await page.getByRole("button", { name: /생성/i }).click(); await expect .poll(() => createPayload) .toMatchObject({ metadata: { hanmacFamily: true, additionalAppointments: [ { tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35", tenantSlug: "tech-planning", tenantName: "기술기획", isOwner: true, grade: "책임", jobTitle: "플랫폼 운영", position: "팀장", }, ], }, }); expect(createPayload).not.toHaveProperty("role"); expect(createPayload).not.toHaveProperty("department"); expect(createPayload).not.toHaveProperty("tenantSlug"); expect(createPayload).not.toHaveProperty("companyCode"); expect(createPayload).not.toHaveProperty("primaryTenantId"); }); test("should hide Hanmac family subtree and system tenants when creating a non-family user", async ({ page, }) => { await page.goto("/users/new"); await page.getByRole("tab", { name: /외부 기업 회원/i }).click(); const tenantOptionValues = await page .locator("select#tenantSlug option") .evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value), ); expect(tenantOptionValues).toContain("test-tenant"); expect(tenantOptionValues).toContain("external-tenant"); expect(tenantOptionValues).not.toContain(""); expect(tenantOptionValues).not.toContain("system"); expect(tenantOptionValues).not.toContain("hanmac-family"); expect(tenantOptionValues).not.toContain("hanmac-company"); expect(tenantOptionValues).not.toContain("hanmac-team"); expect(tenantOptionValues).not.toContain("tech-planning"); }); test("should create a personal user and provision Personal tenant when missing", async ({ page, }) => { let tenantPayload: Record | undefined; let createPayload: Record | undefined; await page.route(/\/admin\/tenants$/, async (route) => { if (route.request().method() === "POST") { tenantPayload = route.request().postDataJSON(); return route.fulfill({ status: 201, json: { id: "personal-tenant-id", slug: "personal", name: "Personal", type: "PERSONAL", config: {}, }, }); } return route.fallback(); }); await page.route(/\/admin\/users(\?.*)?$/, async (route) => { if (route.request().method() === "POST") { createPayload = route.request().postDataJSON(); return route.fulfill({ status: 201, json: { id: "personal-user-id", name: "Personal User", email: "personal@test.com", }, }); } return route.fallback(); }); await page.goto("/users/new"); await page.getByRole("tab", { name: /개인 회원/i }).click(); await expect(page.getByTestId("personal-tenant-summary")).toContainText( /Personal/i, ); await page.locator('input[name="name"]').fill("Personal User"); await page.locator('input[name="email"]').fill("personal@test.com"); await page.getByRole("button", { name: /생성/i }).click(); await expect .poll(() => tenantPayload) .toMatchObject({ name: "Personal", slug: "personal", type: "PERSONAL" }); await expect .poll(() => createPayload) .toMatchObject({ tenantSlug: "personal", metadata: { userType: "personal", hanmacFamily: false }, }); }); test("should show Hanmac family appointments layout on user detail", async ({ page, }) => { await page.route(/\/admin\/users\/u-1$/, async (route) => { if (route.request().method() === "GET") { return route.fulfill({ json: { id: "u-1", name: "Family User", email: "family@test.com", phone: "010-1111-2222", loginId: "familyuser", role: "user", status: "active", createdAt: "2026-04-01T00:00:00Z", updatedAt: "2026-04-01T00:00:00Z", metadata: { hanmacFamily: true, additionalAppointments: [ { tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35", tenantSlug: "tech-planning", tenantName: "기술기획", isPrimary: true, isOwner: true, grade: "책임", jobTitle: "플랫폼 운영", position: "팀장", }, ], }, }, }); } return route.fallback(); }); await page.goto("/users/u-1"); await expect( page.getByRole("tab", { name: /한맥가족 구성원/i }), ).toHaveAttribute("data-state", "active"); await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0); await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible(); await expect( page.getByTestId("detail-appointment-tenant-owner-line-0"), ).toContainText(/기술기획|대표 조직|조직장/); await expect( page.getByTestId("detail-appointment-row-0").getByRole("switch", { name: /대표 조직/i, }), ).toBeChecked(); await expect( page.getByTestId("detail-appointment-row-0").getByRole("switch", { name: /대표 조직/i, }), ).toBeDisabled(); await expect( page.getByTestId("detail-appointment-position-line-0"), ).toBeVisible(); }); test("should save selected Hanmac representative appointment from user detail", async ({ page, }) => { let updatePayload: Record | undefined; await page.route(/\/admin\/users\/u-1$/, async (route) => { if (route.request().method() === "GET") { return route.fulfill({ json: { id: "u-1", name: "Family User", email: "family@test.com", phone: "010-1111-2222", loginId: "familyuser", role: "user", status: "active", createdAt: "2026-04-01T00:00:00Z", updatedAt: "2026-04-01T00:00:00Z", metadata: { hanmacFamily: true, additionalAppointments: [ { tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35", tenantSlug: "tech-planning", tenantName: "기술기획", isOwner: true, grade: "책임", jobTitle: "플랫폼 운영", position: "팀장", }, { tenantId: "hanmac-team-id", tenantSlug: "hanmac-team", tenantName: "한맥팀", isOwner: false, grade: "선임", jobTitle: "개발", position: "파트장", }, ], }, }, }); } if (route.request().method() === "PUT") { updatePayload = route.request().postDataJSON(); return route.fulfill({ json: { id: "u-1", name: "Family User", email: "family@test.com", status: "active", }, }); } return route.fallback(); }); await page.goto("/users/u-1"); await page .getByTestId("detail-appointment-row-1") .getByRole("switch", { name: /대표 조직/i }) .click(); await expect( page.getByTestId("detail-appointment-row-0").getByRole("switch", { name: /대표 조직/i, }), ).not.toBeChecked(); await expect( page.getByTestId("detail-appointment-row-1").getByRole("switch", { name: /대표 조직/i, }), ).toBeChecked(); await page.locator("form").evaluate((form) => { (form as HTMLFormElement).requestSubmit(); }); await expect .poll(() => updatePayload) .toMatchObject({ tenantSlug: "hanmac-team", primaryTenantId: "hanmac-team-id", primaryTenantName: "한맥팀", primaryTenantIsOwner: true, metadata: { primaryTenantId: "hanmac-team-id", primaryTenantName: "한맥팀", primaryTenantSlug: "hanmac-team", primaryTenantIsOwner: true, additionalAppointments: [ { tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35", isPrimary: false, }, { tenantId: "hanmac-team-id", isPrimary: true }, ], }, }); }); test("should show conflict error when creating with an existing Login ID", async ({ page, }) => { await page.goto("/users/new"); await expect(page.getByText(/사용자 추가/i).first()).toBeVisible(); await page.getByRole("tab", { name: /외부 기업 회원/i }).click(); // Select Tenant first (important for schema fields to show up) await page.selectOption("select#tenantSlug", "test-tenant"); // Fill required fields await page.locator('input[name="name"]').fill("New User"); await page.locator('input[name="email"]').fill("newuser@test.com"); // Fill Login ID that triggers the mock conflict error const loginIdInput = page.locator('input[id*="metadata"][id*="loginId"]'); await loginIdInput.fill("existing_user"); // Submit the form const createButton = page.getByRole("button", { name: /생성/i }); await createButton.click(); // Check for the specific conflict error message from the backend mock await expect( page.getByText(/이미 존재하는 로그인 ID 입니다/i), ).toBeVisible(); }); });