import { expect, test } from "@playwright/test"; test.describe("Users Bulk Upload", () => { 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/users")) { if (!url.includes("/bulk")) { return route.fulfill({ json: { items: [], total: 0, limit: 50, offset: 0 }, headers, }); } } if (url.includes("/admin/tenants")) { 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 open bulk upload modal and show preview", async ({ page }) => { await page.goto("/users"); // 헤더 타이틀이 뜰 때까지 대기 await expect(page.getByTestId("page-title")).toContainText( /사용자|Users/i, { timeout: 20000 }, ); await page.getByTestId("user-data-mgmt-btn").click(); await page .getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i }) .click(); await expect(page.getByTestId("bulk-upload-title")).toBeVisible({ timeout: 10000, }); const downloadBtn = page .locator("button") .filter({ hasText: /템플릿 다운로드|템플릿 받기|Download Template/ }) .first(); await expect(downloadBtn).toBeVisible(); }); test("should show success results after mock upload", async ({ page }) => { await page.route("**/api/v1/admin/users/bulk", async (route) => { if (route.request().method() === "POST") { await route.fulfill({ json: { results: [ { email: "success@test.com", success: true, userId: "u-1" }, { email: "fail@test.com", success: false, message: "Invalid format", }, ], }, headers: { "Access-Control-Allow-Origin": "*" }, }); } else { await route.continue(); } }); await page.goto("/users"); await expect(page.getByTestId("page-title")).toContainText( /사용자|Users/i, { timeout: 20000 }, ); await page.getByTestId("user-data-mgmt-btn").click(); await page .getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i }) .click(); const uploadBtn = page.getByTestId("bulk-start-btn"); await expect(uploadBtn).toBeDisabled(); }); test("should create missing tenant before user bulk import", async ({ page, }) => { const requests: string[] = []; let bulkPayload = ""; await page.route("**/api/v1/admin/tenants**", async (route) => { const method = route.request().method(); requests.push(`${method} ${route.request().url()}`); if (method === "GET") { return route.fulfill({ json: { items: [], total: 0, limit: 100, offset: 0 }, headers: { "Access-Control-Allow-Origin": "*" }, }); } if (method === "POST") { return route.fulfill({ status: 201, json: { id: "staging-missing-tenant-id", name: "Missing Tenant", slug: "missing-slug", type: "COMPANY", description: "Imported memo", status: "active", domains: ["missing.example.com"], memberCount: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, headers: { "Access-Control-Allow-Origin": "*" }, }); } return route.continue(); }); await page.route("**/api/v1/admin/users/bulk", async (route) => { bulkPayload = route.request().postData() ?? ""; return route.fulfill({ json: { results: [{ email: "new@test.com", success: true, userId: "u-1" }], }, headers: { "Access-Control-Allow-Origin": "*" }, }); }); await page.goto("/users"); await expect(page.getByTestId("page-title")).toContainText( /사용자|Users/i, { timeout: 20000 }, ); await page.getByTestId("user-data-mgmt-btn").click(); await page .getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i }) .click(); await page.locator('input[type="file"]').setInputFiles({ name: "users.csv", mimeType: "text/csv", buffer: Buffer.from( "email,name,tenant_id,tenant_slug,tenant_name,tenant_type,tenant_memo,email_domain\nnew@test.com,New User,local-tenant-id,missing-slug,Missing Tenant,COMPANY,Imported memo,missing.example.com\n", ), }); await expect( page.getByTestId("user-import-tenant-resolution"), ).toContainText(/신규 생성|Create new/i); await page.getByTestId("bulk-start-btn").click(); await expect(page.getByText("new@test.com")).toBeVisible(); expect(requests.some((request) => request.startsWith("POST "))).toBe(true); expect(bulkPayload).toContain('"tenantId":"staging-missing-tenant-id"'); expect(bulkPayload).toContain('"tenantSlug":"missing-slug"'); expect(bulkPayload).toContain('"emailDomain":"missing.example.com"'); }); test("should include one nullable additional appointment from numbered CSV columns", async ({ page, }) => { let bulkPayload = ""; await page.unroute("**/api/v1/**"); await page.route("**/api/v1/user/me", async (route) => { return route.fulfill({ json: { id: "admin-user", name: "Admin", role: "super_admin", manageableTenants: [], }, headers: { "Access-Control-Allow-Origin": "*" }, }); }); await page.route(/\/api\/v1\/admin\/users(?:\?|$)/, async (route) => { return route.fulfill({ json: { items: [], total: 0, limit: 50, offset: 0 }, headers: { "Access-Control-Allow-Origin": "*" }, }); }); await page.route(/\/api\/v1\/admin\/tenants(?:\?|$)/, async (route) => { if (route.request().method() === "GET") { return route.fulfill({ json: { items: [ { id: "tenant-primary-id", name: "Primary Tenant", slug: "primary-tenant", status: "active", type: "COMPANY", memberCount: 0, updatedAt: new Date().toISOString(), }, { id: "tenant-second-id", name: "Second Tenant", slug: "second-tenant", status: "active", type: "COMPANY", memberCount: 0, updatedAt: new Date().toISOString(), }, ], total: 2, limit: 1000, offset: 0, }, headers: { "Access-Control-Allow-Origin": "*" }, }); } return route.fulfill({ status: 201, json: { id: "tenant-created-id", name: "Primary Tenant", slug: "primary-tenant", status: "active", type: "COMPANY", memberCount: 0, updatedAt: new Date().toISOString(), }, headers: { "Access-Control-Allow-Origin": "*" }, }); }); await page.route("**/api/v1/admin/users/bulk", async (route) => { bulkPayload = route.request().postData() ?? ""; return route.fulfill({ json: { results: [{ email: "dual@test.com", success: true, userId: "u-1" }], }, headers: { "Access-Control-Allow-Origin": "*" }, }); }); await page.goto("/users"); await expect(page.getByTestId("page-title")).toContainText( /사용자|Users/i, { timeout: 20000 }, ); await page.getByTestId("user-data-mgmt-btn").click(); await page .getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i }) .click(); await page.locator('input[type="file"]').setInputFiles({ name: "users.csv", mimeType: "text/csv", buffer: Buffer.from( [ "email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1,sub_email", "dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002,dual.alias@hanmaceng.co.kr", ].join("\n"), ), }); await page.getByTestId("bulk-start-btn").click(); await expect(page.getByText("dual@test.com")).toBeVisible(); const payload = JSON.parse(bulkPayload); expect(payload.users[0].tenantSlug).toBe("primary-tenant"); expect(payload.users[0].metadata.employee_id).toBe("EMP001"); expect(payload.users[0].metadata.sub_email).toEqual([ "dual.alias@hanmaceng.co.kr", ]); expect(payload.users[0].metadata.secondary_emails).toEqual([ "dual.alias@hanmaceng.co.kr", ]); expect(payload.users[0].metadata.aliasEmails).toEqual([ "dual.alias@hanmaceng.co.kr", ]); expect(payload.users[0].additionalAppointments).toEqual([ { tenantSlug: "second-tenant", department: "센터", grade: "수석", jobTitle: "Architecture", metadata: { employee_id: "EMP002", }, }, ]); }); test("should show GPDTDC as Baron representative without changing WorksMobile primary affiliation", async ({ page, }) => { let bulkPayload = ""; await page.unroute("**/api/v1/**"); await page.route("**/api/v1/user/me", async (route) => { return route.fulfill({ json: { id: "admin-user", name: "Admin", role: "super_admin", manageableTenants: [], }, headers: { "Access-Control-Allow-Origin": "*" }, }); }); await page.route(/\/api\/v1\/admin\/users(?:\?|$)/, async (route) => { return route.fulfill({ json: { items: [], total: 0, limit: 50, offset: 0 }, headers: { "Access-Control-Allow-Origin": "*" }, }); }); await page.route(/\/api\/v1\/admin\/tenants(?:\?|$)/, async (route) => { return route.fulfill({ json: { items: [ { id: "family-id", name: "한맥가족사", slug: "hanmac-family", status: "active", type: "COMPANY", memberCount: 0, updatedAt: new Date().toISOString(), }, { id: "gpdtdc-id", name: "총괄기획&기술개발센터", slug: "gpdtdc", parentId: "family-id", status: "active", type: "COMPANY", memberCount: 0, updatedAt: new Date().toISOString(), }, { id: "tdc-id", name: "기술개발센터", slug: "tdc", parentId: "gpdtdc-id", status: "active", type: "ORGANIZATION", memberCount: 0, updatedAt: new Date().toISOString(), }, { id: "rnd-saman-id", name: "삼안기술개발센터", slug: "rnd-saman", status: "active", type: "ORGANIZATION", memberCount: 0, updatedAt: new Date().toISOString(), }, ], total: 4, limit: 1000, offset: 0, }, headers: { "Access-Control-Allow-Origin": "*" }, }); }); await page.route("**/api/v1/admin/users/bulk", async (route) => { bulkPayload = route.request().postData() ?? ""; return route.fulfill({ json: { results: [ { email: "gpdtdc-dual@test.com", success: true, userId: "u-1" }, ], }, headers: { "Access-Control-Allow-Origin": "*" }, }); }); await page.goto("/users"); await expect(page.getByTestId("page-title")).toContainText( /사용자|Users/i, { timeout: 20000 }, ); await page.getByTestId("user-data-mgmt-btn").click(); await page .getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i }) .click(); await page.locator('input[type="file"]').setInputFiles({ name: "users.csv", mimeType: "text/csv", buffer: Buffer.from( [ "email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1", "gpdtdc-dual@test.com,GPDTDC Dual User,010-0000-0000,user,rnd-saman,삼안기술연구소,책임,,,SAMAN001,tdc,,책임연구원,,,B24051", ].join("\n"), ), }); await page.getByTestId("bulk-start-btn").click(); await expect(page.getByText("gpdtdc-dual@test.com")).toBeVisible(); const payload = JSON.parse(bulkPayload); expect(payload.users[0].tenantSlug).toBe("gpdtdc"); expect(payload.users[0].additionalAppointments).toEqual([ expect.objectContaining({ tenantSlug: "rnd-saman", isPrimary: true, department: "삼안기술연구소", grade: "책임", metadata: { employee_id: "SAMAN001", }, }), expect.objectContaining({ tenantSlug: "tdc", isPrimary: false, grade: "책임연구원", metadata: { employee_id: "B24051", }, }), ]); }); });