import { expect, test } from "@playwright/test"; test.describe("Worksmobile tenant management", () => { test.beforeEach(async ({ page }) => { await page.addInitScript(() => { window.localStorage.setItem("locale", "ko"); window.localStorage.setItem("admin_session", "fake-token"); window.localStorage.setItem("RoleSwitcher-Collapsed", "true"); ( 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("**/oidc/**", async (route) => { await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); }); }); test("opens Worksmobile in the current tab and filters comparison rows", async ({ page, }) => { const comparisonRequests: boolean[] = []; const syncRequests: Array<{ userId: string; body: Record; }> = []; await page.route("**/api/v1/**", async (route) => { const url = new URL(route.request().url()); const method = route.request().method(); const headers = { "Access-Control-Allow-Origin": "*" }; const isWorksmobileTenantPath = (suffix: string) => url.pathname.endsWith(`/admin/tenants/hanmac-family-id${suffix}`) || url.pathname.endsWith( `/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a${suffix}`, ); if (url.pathname.endsWith("/user/me")) { return route.fulfill({ json: { id: "admin-user", name: "Admin", role: "super_admin", manageableTenants: [ { id: "038326b6-954a-48a7-a85f-efd83f62b82a", name: "한맥 가족", slug: "hanmac-family", type: "COMPANY_GROUP", }, ], }, headers, }); } if ( url.pathname.endsWith("/admin/tenants/hanmac-family-id") && method === "GET" ) { return route.fulfill({ json: { id: "hanmac-family-id", name: "한맥 가족", slug: "hanmac-family", type: "COMPANY_GROUP", status: "active", parentId: null, }, headers, }); } if (isWorksmobileTenantPath("/worksmobile") && method === "GET") { return route.fulfill({ json: { tenant: { id: "hanmac-family-id", name: "한맥 가족", slug: "hanmac-family", type: "COMPANY_GROUP", status: "active", memberCount: 0, createdAt: "2026-05-04T00:00:00Z", updatedAt: "2026-05-04T00:00:00Z", }, config: { enabled: true, tokenConfigured: true, }, recentJobs: [], }, headers, }); } if ( isWorksmobileTenantPath("/worksmobile/credential-batches") && method === "GET" ) { return route.fulfill({ json: [], headers }); } if ( isWorksmobileTenantPath("/worksmobile/comparison") && method === "GET" ) { const includeMatched = url.searchParams.get("includeMatched") === "true"; comparisonRequests.push(includeMatched); return route.fulfill({ json: { users: includeMatched ? [ { resourceType: "USER", baronId: "user-matched", baronName: "홍길동", baronPrimaryOrgId: "team-tech", baronPrimaryOrgName: "기술기획", worksmobileId: "works-user-matched", externalKey: "user-matched", worksmobileName: "홍길동", status: "matched", worksmobilePrimaryOrgId: "works-team-tech", worksmobilePrimaryOrgName: "WORKS 기술기획", }, { resourceType: "USER", baronId: "user-missing", baronName: "김누락", status: "missing_in_worksmobile", }, { resourceType: "USER", worksmobileId: "works-user-only", externalKey: "works-user-only", worksmobileName: "박웍스", status: "missing_in_baron", }, { resourceType: "USER", worksmobileId: "works-hidden-su", externalKey: "works-hidden-su", worksmobileName: "숨김 SU", worksmobileEmail: "su-@samaneng.com", status: "missing_in_baron", }, { resourceType: "USER", baronId: "user-hidden-cyhan1", baronName: "숨김 CYHAN1", baronEmail: "cyhan1@hanmaceng.co.kr", worksmobileId: "works-hidden-cyhan1", worksmobileName: "숨김 CYHAN1", worksmobileEmail: "cyhan1@hanmaceng.co.kr", status: "matched", }, ] : [ { resourceType: "USER", baronId: "user-missing", baronName: "김누락", status: "missing_in_worksmobile", }, { resourceType: "USER", worksmobileId: "works-user-only", externalKey: "works-user-only", worksmobileName: "박웍스", status: "missing_in_baron", }, { resourceType: "USER", worksmobileId: "works-hidden-su", externalKey: "works-hidden-su", worksmobileName: "숨김 SU", worksmobileEmail: "su-@samaneng.com", status: "missing_in_baron", }, ], groups: [ { resourceType: "GROUP", baronId: "group-missing", baronName: "Baron 전용 조직", baronParentId: "parent-tech", baronParentName: "기술본부", status: "missing_in_worksmobile", }, { resourceType: "GROUP", worksmobileId: "works-group-only", externalKey: "works-group-only", worksmobileName: "WORKS 전용 조직", worksmobileParentId: "works-parent-tech", worksmobileParentName: "WORKS 기술본부", status: "missing_in_baron", }, ], }, headers, }); } if ( isWorksmobileTenantPath("/worksmobile/users/user-missing/sync") && method === "POST" ) { syncRequests.push({ userId: "user-missing", body: JSON.parse(route.request().postData() ?? "{}") as Record< string, unknown >, }); return route.fulfill({ json: { id: "job-user-missing", resourceId: "user-missing" }, headers, }); } return route.fulfill({ json: { items: [], total: 0 }, headers }); }); await page.goto("/worksmobile"); await expect(page).toHaveURL(/\/worksmobile$/); await expect(page.getByRole("tab", { name: "이력" })).toBeVisible(); await expect(page.getByRole("tab", { name: "사용자" })).toBeVisible(); await expect(page.getByRole("tab", { name: "조직" })).toBeVisible(); await page.getByRole("tab", { name: "이력" }).click(); await expect(page.getByText("비밀번호 파일 히스토리")).not.toBeVisible(); await expect(page.getByText("최근 작업")).toBeVisible(); await expect(page.getByText("domainMappings")).not.toBeVisible(); await expect(page.getByText("SCIM token")).not.toBeVisible(); await page.getByRole("tab", { name: "사용자" }).click(); await expect(page.getByText("사용자 단건 동기화")).toBeVisible(); await expect(page.getByPlaceholder("Kratos user UUID")).toBeVisible(); const userSyncCard = page.getByTestId("worksmobile-users-single-sync"); const userComparisonTable = page.getByTestId( "worksmobile-구성원-virtual-body", ); await expect(userComparisonTable).toBeVisible(); await expect(page.getByTestId("worksmobile-구성원-row-count")).toHaveText( "표시 2 / 전체 5", ); await expect(userSyncCard).toBeVisible(); expect( await page.evaluate(() => { const table = document.querySelector( '[data-testid="worksmobile-구성원-virtual-body"]', ); const sync = document.querySelector( '[data-testid="worksmobile-users-single-sync"]', ); return Boolean( table && sync && table.compareDocumentPosition(sync) & Node.DOCUMENT_POSITION_FOLLOWING, ); }), ).toBe(true); await expect(page.getByText("김누락")).toBeVisible(); await expect(page.getByText("박웍스")).toBeVisible(); await expect(page.getByText("숨김 SU")).not.toBeVisible(); await expect(page.getByText("숨김 CYHAN1")).not.toBeVisible(); await expect(page.getByText("su-@samaneng.com")).not.toBeVisible(); await expect(page.getByText("cyhan1@hanmaceng.co.kr")).not.toBeVisible(); await expect(page.getByText("WORKS 전용 조직")).not.toBeVisible(); await expect(page.getByText("홍길동")).not.toBeVisible(); expect(comparisonRequests[0]).toBe(true); await page .getByPlaceholder("구성원 이름 또는 UUID 검색") .fill("su-@samaneng.com"); await expect(page.getByText("숨김 SU")).not.toBeVisible(); await page.getByPlaceholder("구성원 이름 또는 UUID 검색").fill(""); const userComparisonSection = page .getByRole("heading", { name: "구성원" }) .locator("xpath=ancestor::div[contains(@class, 'space-y-2')][1]"); const filterButtons = userComparisonSection .getByRole("button", { name: /바론에만 있음|웍스에만 있음|양쪽 다 있음/, }) .allTextContents(); await expect .poll(() => filterButtons) .toEqual(["바론에만 있음", "웍스에만 있음", "양쪽 다 있음"]); await userComparisonSection .getByRole("button", { name: "웍스에만 있음" }) .click(); await userComparisonSection .getByRole("button", { name: "웍스에만 있음" }) .click(); await expect(page.getByText("박웍스")).not.toBeVisible(); await expect(page.getByText("김누락")).toBeVisible(); await expect(page.getByText("홍길동")).not.toBeVisible(); await userComparisonSection .getByRole("button", { name: "양쪽 다 있음" }) .click(); await expect(page.getByText("홍길동")).toHaveCount(2); await expect(page.getByText("기술기획", { exact: true })).toBeVisible(); await expect(page.getByText("team-tech", { exact: true })).toBeVisible(); await expect(page.getByText("WORKS 기술기획")).toBeVisible(); await expect(page.getByText("works-team-tech")).toBeVisible(); await expect(page.getByText("김누락")).toBeVisible(); await expect(page.getByText("박웍스")).not.toBeVisible(); await userComparisonSection .getByRole("button", { name: "바론에만 있음" }) .click(); await expect(page.getByText("홍길동")).toHaveCount(2); await expect(page.getByText("김누락")).not.toBeVisible(); await expect(page.getByText("박웍스")).not.toBeVisible(); await userComparisonSection .getByRole("button", { name: "웍스에만 있음" }) .click(); await expect(page.getByText("홍길동")).toHaveCount(2); await expect(page.getByText("김누락")).not.toBeVisible(); await expect(page.getByText("박웍스")).toBeVisible(); await userComparisonSection .getByRole("button", { name: "양쪽 다 있음" }) .click(); await expect(page.getByText("김누락")).not.toBeVisible(); await expect(page.getByText("박웍스")).toBeVisible(); await expect(page.getByText("홍길동")).not.toBeVisible(); await userComparisonSection .getByRole("button", { name: "바론에만 있음" }) .click(); await expect(page.getByText("김누락")).toBeVisible(); await expect(page.getByText("박웍스")).toBeVisible(); await expect(page.getByText("홍길동")).not.toBeVisible(); await page .getByRole("row", { name: /김누락/ }) .getByRole("checkbox") .check(); await page .getByRole("button", { name: "선택 구성원 WORKS에 생성" }) .click(); await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible(); await page.getByLabel("초기 비밀번호").fill("InitPass123!"); await page.getByRole("button", { name: "생성 작업 등록" }).click(); await expect .poll(() => syncRequests) .toEqual([ { userId: "user-missing", body: expect.objectContaining({ initialPassword: "InitPass123!" }), }, ]); await page.getByRole("tab", { name: "조직" }).click(); await expect(page.getByText("조직 단건 동기화")).toBeVisible(); await expect(page.getByPlaceholder("orgUnit tenant UUID")).toBeVisible(); const groupSyncCard = page.getByTestId("worksmobile-groups-single-sync"); const groupComparisonTable = page.getByTestId( "worksmobile-조직/그룹-virtual-body", ); await expect(groupComparisonTable).toBeVisible(); await expect( page.getByTestId("worksmobile-조직/그룹-row-count"), ).toHaveText("표시 2 / 전체 2"); await expect(groupSyncCard).toBeVisible(); expect( await page.evaluate(() => { const table = document.querySelector( '[data-testid="worksmobile-조직/그룹-virtual-body"]', ); const sync = document.querySelector( '[data-testid="worksmobile-groups-single-sync"]', ); return Boolean( table && sync && table.compareDocumentPosition(sync) & Node.DOCUMENT_POSITION_FOLLOWING, ); }), ).toBe(true); await expect(page.getByText("WORKS 전용 조직")).toBeVisible(); await expect(page.getByText("기술본부", { exact: true })).toBeVisible(); await expect(page.getByText("parent-tech", { exact: true })).toBeVisible(); await expect(page.getByText("WORKS 기술본부")).toBeVisible(); await expect(page.getByText("works-parent-tech")).toBeVisible(); }); test("separates selected user create and update actions", async ({ page, }) => { const syncRequests: Array<{ userId: string; body: Record; }> = []; await page.route("**/api/v1/**", async (route) => { const url = new URL(route.request().url()); const method = route.request().method(); const headers = { "Access-Control-Allow-Origin": "*" }; const isWorksmobileTenantPath = (suffix: string) => url.pathname.endsWith(`/admin/tenants/hanmac-family-id${suffix}`) || url.pathname.endsWith( `/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a${suffix}`, ); if (url.pathname.endsWith("/user/me")) { return route.fulfill({ json: { id: "admin-user", name: "Admin", role: "super_admin", manageableTenants: [ { id: "038326b6-954a-48a7-a85f-efd83f62b82a", name: "한맥 가족", slug: "hanmac-family", type: "COMPANY_GROUP", }, ], }, headers, }); } if ( url.pathname.endsWith("/admin/tenants/hanmac-family-id") && method === "GET" ) { return route.fulfill({ json: { id: "hanmac-family-id", name: "한맥 가족", slug: "hanmac-family", type: "COMPANY_GROUP", status: "active", parentId: null, }, headers, }); } if (isWorksmobileTenantPath("/worksmobile") && method === "GET") { return route.fulfill({ json: { tenant: { id: "hanmac-family-id", name: "한맥 가족", slug: "hanmac-family", type: "COMPANY_GROUP", status: "active", memberCount: 0, createdAt: "2026-05-04T00:00:00Z", updatedAt: "2026-05-04T00:00:00Z", }, config: { enabled: true, tokenConfigured: true, }, recentJobs: [], }, headers, }); } if ( isWorksmobileTenantPath("/worksmobile/credential-batches") && method === "GET" ) { return route.fulfill({ json: [], headers }); } if ( isWorksmobileTenantPath("/worksmobile/comparison") && method === "GET" ) { return route.fulfill({ json: { users: [ { resourceType: "USER", baronId: "user-missing", baronName: "김생성", status: "missing_in_worksmobile", }, { resourceType: "USER", baronId: "user-update", baronName: "이업데이트", baronEmail: "domain@typo.example.com", worksmobileId: "works-user-update", externalKey: "user-update", worksmobileName: "이업데이트", worksmobileEmail: "domain@example.com", status: "needs_update", }, ], groups: [], }, headers, }); } if ( isWorksmobileTenantPath("/worksmobile/users/user-missing/sync") && method === "POST" ) { syncRequests.push({ userId: "user-missing", body: JSON.parse(route.request().postData() ?? "{}") as Record< string, unknown >, }); return route.fulfill({ json: { id: "job-user-missing", resourceId: "user-missing" }, headers, }); } if ( isWorksmobileTenantPath("/worksmobile/users/user-update/sync") && method === "POST" ) { syncRequests.push({ userId: "user-update", body: JSON.parse(route.request().postData() ?? "{}") as Record< string, unknown >, }); return route.fulfill({ json: { id: "job-user-update", resourceId: "user-update" }, headers, }); } return route.fulfill({ json: { items: [], total: 0 }, headers }); }); await page.goto("/worksmobile"); await page.getByRole("tab", { name: "사용자" }).click(); const userComparisonSection = page .getByRole("heading", { name: "구성원" }) .locator("xpath=ancestor::div[contains(@class, 'space-y-2')][1]"); await expect(userComparisonSection.getByText("김생성")).toBeVisible(); await expect(userComparisonSection.getByText("이업데이트")).toHaveCount(2); const statusHeader = userComparisonSection .locator("thead th") .filter({ hasText: "상태" }) .locator("div") .first(); await expect .poll(() => statusHeader.evaluate((element) => { const style = window.getComputedStyle(element); return { alignItems: style.alignItems, display: style.display }; }), ) .toEqual({ alignItems: "center", display: "flex" }); await page .getByRole("row", { name: /김생성/ }) .getByRole("checkbox") .check(); await page .getByRole("row", { name: /이업데이트/ }) .getByRole("checkbox") .check(); await userComparisonSection .getByRole("button", { name: "선택 구성원 WORKS에 생성" }) .click(); await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible(); await page.getByLabel("초기 비밀번호").fill("InitPass123!"); await page.getByRole("button", { name: "생성 작업 등록" }).click(); await expect .poll(() => syncRequests) .toEqual([ { userId: "user-missing", body: expect.objectContaining({ initialPassword: "InitPass123!" }), }, ]); const updateRowCheckbox = userComparisonSection .getByRole("row", { name: /이업데이트/ }) .getByRole("checkbox"); await expect(updateRowCheckbox).not.toBeChecked(); await page .getByRole("row", { name: /이업데이트/ }) .getByRole("checkbox") .check(); await userComparisonSection .getByRole("button", { name: "선택 구성원 업데이트 적용" }) .click(); await expect .poll(() => syncRequests) .toEqual([ { userId: "user-missing", body: expect.objectContaining({ initialPassword: "InitPass123!" }), }, { userId: "user-update", body: expect.not.objectContaining({ initialPassword: expect.anything(), }), }, ]); }); test("shows a toast when selected WORKS creation fails", async ({ page }) => { await page.route("**/api/v1/**", async (route) => { const url = new URL(route.request().url()); const method = route.request().method(); const headers = { "Access-Control-Allow-Origin": "*" }; 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") && method === "GET" ) { return route.fulfill({ json: { id: "hanmac-family-id", name: "한맥 가족", slug: "hanmac-family", parentId: null, }, headers, }); } if ( url.pathname.endsWith( "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile", ) && method === "GET" ) { return route.fulfill({ json: { tenant: { id: "hanmac-family-id", name: "한맥 가족", slug: "hanmac-family", parentId: null, }, config: {}, recentJobs: [], }, headers, }); } if ( url.pathname.endsWith( "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/credential-batches", ) && method === "GET" ) { return route.fulfill({ json: [], headers }); } if ( url.pathname.endsWith( "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/comparison", ) && method === "GET" ) { return route.fulfill({ json: { users: [ { resourceType: "USER", baronId: "user-fail", baronName: "실패 사용자", status: "missing_in_worksmobile", }, ], groups: [], }, headers, }); } if ( url.pathname.endsWith( "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/users/user-fail/sync", ) && method === "POST" ) { return route.fulfill({ status: 500, json: { error: "WORKS API rejected user creation" }, headers, }); } return route.fulfill({ json: { items: [], total: 0 }, headers }); }); await page.goto("/worksmobile"); await page.getByRole("tab", { name: "사용자" }).click(); await page .getByRole("row", { name: /실패 사용자/ }) .getByRole("checkbox") .check(); await page .getByRole("button", { name: "선택 구성원 WORKS에 생성" }) .click(); await page.getByLabel("초기 비밀번호").fill("InitPass123!"); await page.getByRole("button", { name: "생성 작업 등록" }).click(); await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible(); await page.getByLabel("초기 비밀번호").fill("InitPass123!"); await page.getByRole("button", { name: "생성 작업 등록" }).click(); await expect(page.getByText("WORKS 생성 작업 등록 실패")).toBeVisible(); await expect( page.getByText(/WORKS API rejected user creation/), ).toBeVisible(); }); test("keeps wide comparison columns inside table scroll and blocks immutable WORKS accounts", async ({ page, }) => { await page.setViewportSize({ width: 900, height: 700 }); await page.route("**/api/v1/**", async (route) => { const url = new URL(route.request().url()); const method = route.request().method(); const headers = { "Access-Control-Allow-Origin": "*" }; 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") && method === "GET" ) { return route.fulfill({ json: { id: "hanmac-family-id", name: "한맥 가족", slug: "hanmac-family", parentId: null, }, headers, }); } if ( url.pathname.endsWith( "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile", ) && method === "GET" ) { return route.fulfill({ json: { tenant: { id: "hanmac-family-id", name: "한맥 가족", slug: "hanmac-family", parentId: null, }, config: { adminTenantId: "works-tenant-1", }, recentJobs: [], }, headers, }); } if ( url.pathname.endsWith( "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/credential-batches", ) && method === "GET" ) { return route.fulfill({ json: [], headers }); } if ( url.pathname.endsWith( "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/comparison", ) && method === "GET" ) { return route.fulfill({ json: { users: [ { resourceType: "USER", worksmobileId: "works-user-with-extra-long-identifier-for-scroll-check", externalKey: "external-key-with-extra-long-identifier", worksmobileName: "긴 WORKS 사용자", worksmobileEmail: "long-works-user-name-for-scroll@samaneng.com", worksmobileDomainId: 300285955, worksmobileDomainName: "samaneng.com", worksmobilePrimaryOrgId: "works-primary-org-with-extra-long-identifier", worksmobilePrimaryOrgName: "긴 WORKS 조직", status: "missing_in_baron", }, { resourceType: "USER", worksmobileId: "works-cyhan", worksmobileName: "변경 불가 계정", worksmobileEmail: "cyhan@samaneng.com", worksmobileDomainId: 300285955, worksmobileDomainName: "samaneng.com", status: "missing_in_baron", }, ], groups: [], }, headers, }); } return route.fulfill({ json: { items: [], total: 0 }, headers }); }); await page.goto("/worksmobile"); await page.getByRole("tab", { name: "사용자" }).click(); await expect(page.getByText("긴 WORKS 사용자")).toBeVisible(); const userColumnButton = page .getByRole("heading", { name: "구성원" }) .locator("xpath=ancestor::div[contains(@class, 'space-y-2')][1]") .getByRole("button", { name: "컬럼 설정" }); await userColumnButton.evaluate((element) => { element.scrollIntoView({ block: "center", inline: "nearest" }); }); await userColumnButton.evaluate((el) => (el as HTMLButtonElement).click()); const settingsDialog = page.getByRole("dialog"); await expect(settingsDialog.getByText("구성원 컬럼 설정")).toBeVisible(); await settingsDialog.getByText("Baron ID").click(); await settingsDialog.getByText("WORKS", { exact: true }).click(); await settingsDialog.getByText("external_key").click(); await settingsDialog.getByRole("button", { name: "닫기" }).click(); const pageOverflow = await page.evaluate(() => ({ documentScrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth, viewportWidth: document.documentElement.clientWidth, })); expect( Math.max(pageOverflow.documentScrollWidth, pageOverflow.bodyScrollWidth), ).toBeLessThanOrEqual(pageOverflow.viewportWidth + 1); const userTableScroll = await page .locator("table") .first() .evaluate((table) => { const container = table.parentElement?.parentElement as HTMLElement; return { clientWidth: container.clientWidth, overflowX: window.getComputedStyle(container).overflowX, scrollWidth: container.scrollWidth, }; }); expect(userTableScroll.overflowX).toBe("auto"); expect(userTableScroll.scrollWidth).toBeGreaterThan( userTableScroll.clientWidth, ); const immutableRow = page.getByRole("row", { name: /변경 불가 계정/, }); await expect(immutableRow.getByRole("checkbox")).toBeDisabled(); await expect( immutableRow.getByRole("button", { name: /비밀번호 관리/ }), ).toBeDisabled(); }); test("shows WORKS job history and enqueues 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/038326b6-954a-48a7-a85f-efd83f62b82a/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", payload: { loginEmail: "changed-user@example.com", displayName: "변경 사용자", primaryLeafOrgName: "인재성장", requestSummary: { email: "changed-user@example.com", displayName: "변경 사용자", userExternalKey: "user-failed", }, }, }, { id: "job-org-auto", resourceType: "ORGUNIT", resourceId: "org-auto", action: "UPSERT", status: "processed", retryCount: 0, createdAt: "2026-05-01T00:00:00Z", updatedAt: "2026-05-01T00:01:00Z", payload: { matchLocalPart: "people-growth", requestSummary: { orgUnitName: "인재성장", email: "people-growth@example.com", orgUnitExternalKey: "org-auto", parentOrgUnitId: "externalKey:parent-org", }, }, }, { id: "job-pending", resourceType: "ORGUNIT", resourceId: "org-pending", action: "UPSERT", status: "pending", retryCount: 0, createdAt: "2026-05-01T00:00:00Z", updatedAt: "2026-05-01T00:01:00Z", payload: { matchLocalPart: "halla-site", requestSummary: { orgUnitName: "한라 현장", email: "halla-site@hallasanup.com", orgUnitExternalKey: "org-pending", }, }, }, ], }, headers, }); } if ( url.pathname.endsWith( "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/credential-batches", ) && method === "GET" ) { return route.fulfill({ json: [ { batchId: "batch-1", operation: "worksmobile_user_sync", userCount: 1, processedCount: 1, pendingCount: 0, processingCount: 0, failedCount: 0, hasPasswords: true, createdAt: "2026-05-01T00:00:00Z", }, ], headers, }); } if ( url.pathname.endsWith( "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/comparison", ) && method === "GET" ) { return route.fulfill({ json: { users: [], groups: [] }, headers, }); } if ( url.pathname.endsWith( "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/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/038326b6-954a-48a7-a85f-efd83f62b82a/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/038326b6-954a-48a7-a85f-efd83f62b82a/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/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/jobs/job-retry/retry", ) && method === "POST" ) { requests.push("retry"); return route.fulfill({ json: { id: "job-retry-next" }, headers }); } if ( url.pathname.endsWith( "/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/jobs/pending", ) && method === "DELETE" ) { requests.push("delete-pending"); return route.fulfill({ json: { deletedCount: 1 }, headers }); } return route.fulfill({ json: { items: [], total: 0 }, headers }); }); await page.goto("/worksmobile"); await expect(page.getByText("Worksmobile 연동")).toBeVisible(); await page.getByRole("tab", { name: "이력" }).click(); await expect(page.getByText("비밀번호 파일 히스토리")).not.toBeVisible(); await expect( page.getByRole("button", { name: /비밀번호 CSV 다운로드/ }), ).toHaveCount(0); await page.getByRole("button", { name: "Backfill Dry-run" }).click(); await expect.poll(() => requests).toContain("dry-run"); await page.getByRole("tab", { name: "조직" }).click(); 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.getByRole("tab", { name: "사용자" }).click(); 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("tab", { name: "이력" }).click(); await expect(page.getByRole("row", { name: /변경 사용자/ })).toContainText( "changed-user@example.com", ); await expect( page.getByRole("row", { name: /ORGUNIT:people-growth/ }), ).toContainText("people-growth@example.com"); await expect( page .getByRole("row", { name: /ORGUNIT:people-growth/ }) .getByText("externalKey:parent-org") .first(), ).toBeVisible(); const failedJobRow = page.getByRole("row", { name: /변경 사용자/ }); await failedJobRow.getByText("payload").click(); await expect( failedJobRow.getByText('"loginEmail": "changed-user@example.com"'), ).toBeVisible(); await failedJobRow.getByRole("button").click(); await expect.poll(() => requests).toContain("retry"); page.once("dialog", (dialog) => dialog.accept()); await page.getByRole("button", { name: /대기중 payload 삭제/ }).click(); await expect.poll(() => requests).toContain("delete-pending"); }); });