From 3e31fdfa0c7720d95d84cfecee6967a3cc63ee7d Mon Sep 17 00:00:00 2001 From: Lectom Date: Fri, 29 May 2026 14:31:10 +0900 Subject: [PATCH] test: raise frontend coverage baselines --- adminfront/scripts/serve-prod.mjs | 2 +- adminfront/src/app/routes.tsx | 1 - .../src/components/layout/AppLayout.test.tsx | 145 +++++++ .../src/components/layout/AppLayout.tsx | 6 +- adminfront/src/components/ui/avatar.test.tsx | 49 +++ .../src/components/ui/separator.test.tsx | 41 ++ .../src/features/audit/AuditLogsPage.tsx | 1 - .../features/coverage/adminAuditAuth.test.tsx | 175 ++++++++ .../coverage/adminLargePages.test.tsx | 297 ++++++++++++++ .../coverage/adminTenantDetailPages.test.tsx | 129 ++++++ .../coverage/adminTenantGroupsPage.test.tsx | 116 ++++++ .../coverage/adminTenantTabs.test.tsx | 162 ++++++++ .../features/overview/GlobalOverviewPage.tsx | 1 - .../routes/TenantAdminsAndOwnersTab.tsx | 12 +- .../tenants/routes/TenantDetailPage.tsx | 1 - .../tenants/routes/TenantGroupsPage.tsx | 3 +- .../tenants/routes/TenantListPage.tsx | 55 +-- .../tenants/routes/TenantSubTenantsPage.tsx | 2 +- .../tenants/routes/TenantUsersPage.tsx | 18 +- .../routes/GlobalUserGroupListPage.tsx | 1 - .../routes/TenantUserGroupsTab.tsx | 16 +- .../src/features/users/UserCreatePage.tsx | 7 +- .../src/features/users/UserDetailPage.tsx | 89 ++-- .../users/UserListPage.render.test.tsx | 2 +- .../src/features/users/UserListPage.tsx | 9 +- .../components/UserBulkMoveGroupModal.tsx | 4 +- .../src/features/users/orgChartPicker.ts | 2 +- .../src/features/users/utils/csvParser.ts | 6 +- adminfront/src/lib/adminApi.contract.test.ts | 185 +++++++++ adminfront/src/lib/tenantTree.ts | 2 +- adminfront/tests/tenants.spec.ts | 4 +- adminfront/tests/users_bulk_secondary.spec.ts | 59 ++- backend/internal/handler/user_handler.go | 81 +++- backend/internal/handler/user_handler_test.go | 44 +- .../common/ForbiddenMessage.test.tsx | 77 ++++ .../src/components/layout/AppLayout.test.tsx | 166 ++++++++ devfront/src/features/auth/authPages.test.tsx | 161 ++++++++ .../src/features/coverage/commonSort.test.ts | 54 +++ .../src/features/coverage/pageSmoke.test.tsx | 383 ++++++++++++++++++ devfront/src/lib/devApi.test.ts | 250 ++++++++++++ .../src/components/layout/AppLayout.test.tsx | 166 ++++++++ orgfront/src/components/ui/basic.test.tsx | 95 +++++ .../src/features/coverage/pageSmoke.test.tsx | 307 ++++++++++++++ orgfront/src/lib/adminApi.test.ts | 143 ++++++- orgfront/src/lib/devApi.test.ts | 139 +++++++ .../tests/login-performance-budget.spec.ts | 5 - .../tests/password-and-reset.spec.ts | 2 +- .../tests/profile-department.spec.ts | 2 +- userfront-e2e/tests/route-inventory.spec.ts | 2 +- .../tests/session-cross-browser-debug.spec.ts | 17 - 50 files changed, 3482 insertions(+), 214 deletions(-) create mode 100644 adminfront/src/components/layout/AppLayout.test.tsx create mode 100644 adminfront/src/components/ui/avatar.test.tsx create mode 100644 adminfront/src/components/ui/separator.test.tsx create mode 100644 adminfront/src/features/coverage/adminAuditAuth.test.tsx create mode 100644 adminfront/src/features/coverage/adminLargePages.test.tsx create mode 100644 adminfront/src/features/coverage/adminTenantDetailPages.test.tsx create mode 100644 adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx create mode 100644 adminfront/src/features/coverage/adminTenantTabs.test.tsx create mode 100644 adminfront/src/lib/adminApi.contract.test.ts create mode 100644 devfront/src/components/common/ForbiddenMessage.test.tsx create mode 100644 devfront/src/components/layout/AppLayout.test.tsx create mode 100644 devfront/src/features/auth/authPages.test.tsx create mode 100644 devfront/src/features/coverage/commonSort.test.ts create mode 100644 devfront/src/features/coverage/pageSmoke.test.tsx create mode 100644 devfront/src/lib/devApi.test.ts create mode 100644 orgfront/src/components/layout/AppLayout.test.tsx create mode 100644 orgfront/src/components/ui/basic.test.tsx create mode 100644 orgfront/src/features/coverage/pageSmoke.test.tsx create mode 100644 orgfront/src/lib/devApi.test.ts diff --git a/adminfront/scripts/serve-prod.mjs b/adminfront/scripts/serve-prod.mjs index a69d7bdb..ec25704d 100644 --- a/adminfront/scripts/serve-prod.mjs +++ b/adminfront/scripts/serve-prod.mjs @@ -3,7 +3,7 @@ import { createServer } from "node:http"; import { extname, join, normalize, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const rootDir = fileURLToPath(new URL("..", import.meta.url)); +const _rootDir = fileURLToPath(new URL("..", import.meta.url)); const distDir = resolve( process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist", ); diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 5e231dbc..47029154 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -19,7 +19,6 @@ import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage" import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage"; import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage"; import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab"; -import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage"; import UserCreatePage from "../features/users/UserCreatePage"; import UserDetailPage from "../features/users/UserDetailPage"; import UserListPage from "../features/users/UserListPage"; diff --git a/adminfront/src/components/layout/AppLayout.test.tsx b/adminfront/src/components/layout/AppLayout.test.tsx new file mode 100644 index 00000000..39fa215b --- /dev/null +++ b/adminfront/src/components/layout/AppLayout.test.tsx @@ -0,0 +1,145 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createI18nMock } from "../../test/i18nMock"; +import AppLayout from "./AppLayout"; + +const authState = { + isAuthenticated: true, + isLoading: false, + user: { + access_token: "access-token", + expires_at: Math.floor(Date.now() / 1000) + 120, + profile: { + sub: "admin-1", + name: "Admin User", + email: "admin@example.com", + }, + }, + signinSilent: vi.fn(async () => undefined), + removeUser: vi.fn(), +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => authState, +})); + +vi.mock("../../lib/i18n", () => createI18nMock()); + +vi.mock("../../lib/adminApi", () => ({ + fetchMe: vi.fn(async () => ({ + id: "admin-1", + name: "Fetched Admin", + email: "fetched@example.com", + role: "super_admin", + tenantId: "tenant-1", + manageableTenants: [ + { + id: "tenant-1", + name: "GPDTDC", + slug: "gpdtdc", + type: "COMPANY", + }, + { + id: "tenant-2", + name: "기술연구팀", + slug: "gpdtdc-rnd", + type: "ORGANIZATION", + }, + ], + })), +})); + +function renderLayout(entry = "/users") { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + + }> + Users outlet} /> + User detail outlet} /> + Tenant outlet} + /> + Login outlet} /> + + + + , + ); +} + +describe("admin AppLayout", () => { + beforeEach(() => { + ( + window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } + )._IS_TEST_MODE = true; + authState.isAuthenticated = true; + authState.isLoading = false; + authState.user.expires_at = Math.floor(Date.now() / 1000) + 120; + authState.signinSilent.mockClear(); + authState.removeUser.mockClear(); + window.localStorage.clear(); + vi.spyOn(window, "confirm").mockReturnValue(true); + }); + + it("renders admin navigation, fetched profile, and outlet content", async () => { + renderLayout(); + + expect(await screen.findByText("Fetched Admin")).toBeInTheDocument(); + expect(screen.getByText("Admin Control")).toBeInTheDocument(); + expect(screen.getByText("Users outlet")).toBeInTheDocument(); + expect(screen.getByText("Tenants")).toBeInTheDocument(); + expect(screen.getByText("Data Integrity")).toBeInTheDocument(); + }); + + it("opens profile menu, navigates, toggles theme/session, and logs out", async () => { + renderLayout(); + + const themeButton = await screen.findByRole("button", { + name: "테마 전환", + }); + fireEvent.click(themeButton); + expect(document.documentElement.classList.contains("dark")).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" })); + expect(screen.getByText("Manageable Tenants")).toBeInTheDocument(); + + const sessionSwitch = screen.getByRole("switch"); + fireEvent.click(sessionSwitch); + expect(window.localStorage.getItem("baron_session_expiry_enabled")).toBe( + "false", + ); + + fireEvent.click(screen.getByText("기술연구팀")); + expect(await screen.findByText("Tenant outlet")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" })); + fireEvent.click(screen.getAllByText("내 정보")[0]); + expect(await screen.findByText("User detail outlet")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" })); + fireEvent.click(screen.getAllByText("Logout")[1]); + expect(window.confirm).toHaveBeenCalled(); + expect(authState.removeUser).toHaveBeenCalled(); + }, 10_000); + + it("attempts silent renewal on user activity when session is near expiry", async () => { + authState.user.expires_at = Math.floor(Date.now() / 1000) + 60; + + renderLayout(); + await screen.findByText("Fetched Admin"); + fireEvent.keyDown(window, { key: "Tab" }); + + expect(authState.signinSilent).toHaveBeenCalled(); + }); +}); diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 2013f0e0..0c46e736 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -148,11 +148,7 @@ function AppLayout() { const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => readShellSessionExpiryEnabled(!isDevelopmentRuntime), ); - const { - data: profile, - isLoading: isProfileLoading, - error: profileError, - } = useQuery({ + const { data: profile } = useQuery({ queryKey: ["me"], queryFn: async () => { debugLog("[AppLayout] Fetching profile..."); diff --git a/adminfront/src/components/ui/avatar.test.tsx b/adminfront/src/components/ui/avatar.test.tsx new file mode 100644 index 00000000..ac6591d8 --- /dev/null +++ b/adminfront/src/components/ui/avatar.test.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { Avatar, AvatarFallback, AvatarImage } from "./avatar"; + +let container: HTMLDivElement | null = null; + +const render = async (element: React.ReactElement) => { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(element); + }); + return root; +}; + +afterEach(() => { + if (container) { + container.remove(); + container = null; + } +}); + +describe("Avatar", () => { + it("renders image and fallback with merged classes", async () => { + const root = await render( + + + AU + , + ); + + const avatar = container?.querySelector("[data-testid='avatar']"); + const fallback = container?.textContent; + + expect(avatar?.className).toContain("custom-root"); + expect(fallback).toContain("AU"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/adminfront/src/components/ui/separator.test.tsx b/adminfront/src/components/ui/separator.test.tsx new file mode 100644 index 00000000..45866cce --- /dev/null +++ b/adminfront/src/components/ui/separator.test.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { Separator } from "./separator"; + +let container: HTMLDivElement | null = null; + +const render = async (element: React.ReactElement) => { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(element); + }); + return root; +}; + +afterEach(() => { + if (container) { + container.remove(); + container = null; + } +}); + +describe("Separator", () => { + it("renders a horizontal separator with custom classes", async () => { + const root = await render( + , + ); + + const separator = container?.querySelector("[data-testid='separator']"); + + expect(separator?.className).toContain("h-px"); + expect(separator?.className).toContain("custom-separator"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index d55540ba..128e7c6e 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -3,7 +3,6 @@ import type { AxiosError } from "axios"; import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react"; import * as React from "react"; import { - formatAuditValue, parseAuditDetails, resolveAuditAction, resolveAuditActor, diff --git a/adminfront/src/features/coverage/adminAuditAuth.test.tsx b/adminfront/src/features/coverage/adminAuditAuth.test.tsx new file mode 100644 index 00000000..c0188d6b --- /dev/null +++ b/adminfront/src/features/coverage/adminAuditAuth.test.tsx @@ -0,0 +1,175 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import type React from "react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createI18nMock } from "../../test/i18nMock"; +import AuditLogsPage from "../audit/AuditLogsPage"; +import AuthCallbackPage from "../auth/AuthCallbackPage"; +import AuthGuard from "../auth/AuthGuard"; + +const authState = { + isAuthenticated: true, + isLoading: false, + activeNavigator: undefined as string | undefined, + error: null as Error | null, + user: { + access_token: "access-token", + state: undefined as unknown, + }, +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => authState, +})); + +vi.mock("../../lib/i18n", () => createI18nMock()); + +vi.mock("../../lib/adminApi", () => ({ + fetchAuditLogs: vi.fn(async () => ({ + items: [ + { + event_id: "event-1", + timestamp: "2026-05-01T00:00:00Z", + user_id: "admin-1", + event_type: "USER_UPDATE", + status: "success", + ip_address: "127.0.0.1", + user_agent: "Vitest", + details: JSON.stringify({ action: "USER_UPDATE", actor: "Admin" }), + }, + { + event_id: "event-2", + timestamp: "2026-05-01T01:00:00Z", + user_id: "admin-2", + event_type: "LOGIN_FAILED", + status: "failure", + ip_address: "127.0.0.2", + user_agent: "Vitest", + details: "{}", + }, + ], + limit: 50, + })), +})); + +function renderWithProviders(ui: React.ReactElement, entry = "/") { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + {ui} + , + ); +} + +describe("admin audit and auth coverage smoke", () => { + beforeEach(() => { + ( + window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } + )._IS_TEST_MODE = false; + authState.isAuthenticated = true; + authState.isLoading = false; + authState.activeNavigator = undefined; + authState.error = null; + authState.user = { + access_token: "access-token", + state: undefined, + }; + window.localStorage.clear(); + }); + + it("renders audit log table with fetched events", async () => { + renderWithProviders(); + + expect(await screen.findByText("감사 로그")).toBeInTheDocument(); + expect(screen.getByText("admin-1")).toBeInTheDocument(); + expect(screen.getByText("USER_UPDATE")).toBeInTheDocument(); + }); + + it("renders AuthGuard loading, error, redirect, test, and outlet states", async () => { + authState.isLoading = true; + renderWithProviders( + + }> + Secure outlet} /> + + , + "/secure", + ); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + + authState.isLoading = false; + authState.error = new Error("OIDC failed"); + renderWithProviders( + + }> + Secure outlet} /> + + , + "/secure", + ); + expect(screen.getByText("인증 오류")).toBeInTheDocument(); + + authState.error = null; + authState.isAuthenticated = false; + renderWithProviders( + + }> + Secure outlet} /> + + Login outlet} /> + , + "/secure?x=1", + ); + expect(screen.getByText("Login outlet")).toBeInTheDocument(); + + ( + window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } + )._IS_TEST_MODE = true; + renderWithProviders( + + }> + Secure outlet} /> + + , + "/secure", + ); + expect(screen.getByText("Secure outlet")).toBeInTheDocument(); + }); + + it("stores callback token and navigates by auth result", async () => { + authState.isAuthenticated = true; + authState.user = { + access_token: "callback-token", + state: { returnTo: "/users" }, + }; + + renderWithProviders( + + } /> + Users outlet} /> + Login outlet} /> + , + "/auth/callback", + ); + expect(await screen.findByText("Users outlet")).toBeInTheDocument(); + expect(window.localStorage.getItem("admin_session")).toBe("callback-token"); + + authState.isAuthenticated = false; + authState.error = new Error("callback failed"); + renderWithProviders( + + } /> + Login outlet} /> + , + "/auth/callback", + ); + expect(await screen.findByText("Login outlet")).toBeInTheDocument(); + }); +}); diff --git a/adminfront/src/features/coverage/adminLargePages.test.tsx b/adminfront/src/features/coverage/adminLargePages.test.tsx new file mode 100644 index 00000000..5ac4055d --- /dev/null +++ b/adminfront/src/features/coverage/adminLargePages.test.tsx @@ -0,0 +1,297 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen } from "@testing-library/react"; +import type React from "react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createI18nMock } from "../../test/i18nMock"; +import { TenantWorksmobilePage } from "../tenants/routes/TenantWorksmobilePage"; +import TenantListPage from "../tenants/routes/TenantListPage"; +import UserCreatePage from "../users/UserCreatePage"; +import UserDetailPage from "../users/UserDetailPage"; + +const tenantItems = [ + { + id: "tenant-root", + type: "COMPANY_GROUP", + name: "한맥 가족", + slug: "hanmac-family", + description: "root", + status: "active", + memberCount: 0, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + { + id: "tenant-company", + type: "COMPANY", + parentId: "tenant-root", + name: "GPDTDC", + slug: "gpdtdc", + description: "company", + status: "active", + memberCount: 2, + config: { + userSchema: [ + { + key: "employee_id", + label: "사번", + type: "text", + required: false, + }, + ], + }, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + { + id: "tenant-leaf", + type: "ORGANIZATION", + parentId: "tenant-company", + name: "기술연구팀", + slug: "gpdtdc-rnd", + description: "leaf", + status: "active", + memberCount: 1, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, +]; + +const userDetail = { + id: "user-1", + email: "engineer@example.com", + name: "Engineer User", + phone: "010-0000-0000", + role: "user", + status: "active", + tenantSlug: "gpdtdc-rnd", + tenantId: "tenant-leaf", + department: "기술연구팀", + grade: "책임", + position: "팀장", + jobTitle: "Backend", + metadata: { + employee_id: "EMP001", + sub_email: ["engineer.sub@example.com"], + }, + tenant: tenantItems[2], + appointments: [ + { + tenantId: "tenant-leaf", + tenantSlug: "gpdtdc-rnd", + tenantName: "기술연구팀", + isPrimary: true, + isOwner: false, + isAdmin: false, + isManager: true, + department: "기술연구팀", + grade: "책임", + position: "팀장", + jobTitle: "Backend", + metadata: { employee_id: "EMP001" }, + }, + ], + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-02T00:00:00Z", +}; + +vi.mock("../../lib/i18n", () => createI18nMock()); + +vi.mock("../../components/auth/RoleGuard", () => ({ + RoleGuard: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("../../lib/adminApi", () => ({ + fetchMe: vi.fn(async () => ({ + id: "admin-1", + role: "super_admin", + name: "Admin User", + email: "admin@example.com", + })), + fetchAllTenants: vi.fn(async () => ({ + items: tenantItems, + total: tenantItems.length, + })), + fetchTenants: vi.fn(async () => ({ + items: tenantItems, + limit: 500, + offset: 0, + total: tenantItems.length, + nextCursor: null, + })), + fetchTenant: vi.fn(async (id: string) => { + return tenantItems.find((tenant) => tenant.id === id) ?? tenantItems[1]; + }), + createUser: vi.fn(async () => ({ + id: "created-user", + email: "created@example.com", + generatedPassword: "GeneratedPassword!1", + })), + fetchUser: vi.fn(async () => userDetail), + fetchUserRpHistory: vi.fn(async () => [ + { + client_id: "orgfront", + client_name: "OrgFront", + last_login_at: "2026-05-01T00:00:00Z", + login_count: 3, + }, + ]), + fetchPasswordPolicy: vi.fn(async () => ({ + minLength: 12, + lowercase: true, + uppercase: true, + number: true, + nonAlphanumeric: true, + minCharacterTypes: 3, + })), + updateUser: vi.fn(async () => userDetail), + deleteUser: vi.fn(async () => undefined), + updateTenant: vi.fn(async () => tenantItems[1]), + deleteTenantsBulk: vi.fn(async () => ({ deleted: 1 })), + exportTenantsCSV: vi.fn(async () => new Blob(["name,slug\nGPDTDC,gpdtdc"])), + importTenantsCSV: vi.fn(async () => ({ + created: 1, + updated: 0, + failed: 0, + errors: [], + })), + fetchWorksmobileOverview: vi.fn(async () => ({ + tenant: tenantItems[1], + config: { + enabled: true, + tokenConfigured: true, + adminTenantId: "works-admin", + domainMappings: { "example.com": 1001 }, + }, + recentJobs: [ + { + id: "job-1", + resourceType: "USER", + resourceId: "user-1", + action: "SYNC", + status: "failed", + retryCount: 1, + lastError: "temporary failure", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:10:00Z", + }, + ], + })), + fetchWorksmobileComparison: vi.fn(async () => ({ + users: [ + { + resourceType: "USER", + baronId: "user-1", + baronName: "Engineer User", + baronEmail: "engineer@example.com", + baronPrimaryOrgId: "tenant-leaf", + baronPrimaryOrgName: "기술연구팀", + worksmobileId: "works-user-1", + worksmobileName: "Engineer User", + worksmobileEmail: "engineer@example.com", + worksmobilePrimaryOrgId: "works-org-1", + worksmobilePrimaryOrgName: "기술연구팀", + status: "matched", + }, + { + resourceType: "USER", + baronId: "user-2", + baronName: "New User", + baronEmail: "new@example.com", + status: "baron_only", + }, + ], + groups: [ + { + resourceType: "ORG_UNIT", + baronId: "tenant-leaf", + baronSlug: "gpdtdc-rnd", + baronName: "기술연구팀", + worksmobileId: "works-org-1", + worksmobileName: "기술연구팀", + status: "needs_update", + }, + ], + })), + enqueueWorksmobileBackfillDryRun: vi.fn(async () => ({ id: "job-dry" })), + retryWorksmobileJob: vi.fn(async () => ({ id: "job-retry" })), + downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => new Blob(["id"])), + enqueueWorksmobileOrgUnitSync: vi.fn(async () => ({ id: "job-org" })), + enqueueWorksmobileOrgUnitDelete: vi.fn(async () => ({ id: "job-delete" })), + enqueueWorksmobileUserSync: vi.fn(async () => ({ id: "job-user" })), +})); + +function renderWithProviders(ui: React.ReactElement, entry = "/") { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + {ui} + , + ); +} + +describe("adminfront large page coverage smoke", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders user creation form with tenant context", async () => { + renderWithProviders( + + } /> + , + "/users/new?tenantSlug=gpdtdc-rnd", + ); + + expect(await screen.findByText("사용자 추가")).toBeInTheDocument(); + expect(screen.getByLabelText("이메일")).toBeInTheDocument(); + }); + + it("renders user detail form and RP history", async () => { + renderWithProviders( + + } /> + , + "/users/user-1", + ); + + expect(await screen.findByDisplayValue("Engineer User")).toBeInTheDocument(); + expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0); + expect(screen.getByDisplayValue("engineer@example.com")).toBeInTheDocument(); + }); + + it("renders tenant list hierarchy", async () => { + renderWithProviders( + + } /> + , + "/tenants", + ); + + expect(await screen.findByText("GPDTDC")).toBeInTheDocument(); + expect(screen.getByText("기술연구팀")).toBeInTheDocument(); + }); + + it("renders worksmobile comparison screens", async () => { + cleanup(); + renderWithProviders( + + } + /> + , + "/tenants/tenant-company/worksmobile", + ); + + expect(await screen.findByText("Worksmobile 연동")).toBeInTheDocument(); + expect(screen.getByText("Baron / Works 비교")).toBeInTheDocument(); + expect(screen.getByText("Backfill Dry-run")).toBeInTheDocument(); + }); +}); diff --git a/adminfront/src/features/coverage/adminTenantDetailPages.test.tsx b/adminfront/src/features/coverage/adminTenantDetailPages.test.tsx new file mode 100644 index 00000000..90dbbf86 --- /dev/null +++ b/adminfront/src/features/coverage/adminTenantDetailPages.test.tsx @@ -0,0 +1,129 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import type React from "react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createI18nMock } from "../../test/i18nMock"; +import TenantCreatePage from "../tenants/routes/TenantCreatePage"; +import { TenantProfilePage } from "../tenants/routes/TenantProfilePage"; +import { TenantSchemaPage } from "../tenants/routes/TenantSchemaPage"; + +const tenants = [ + { + id: "tenant-root", + type: "COMPANY_GROUP", + name: "한맥 가족", + slug: "hanmac-family", + description: "", + status: "active", + memberCount: 0, + domains: ["hmac.kr"], + config: { visibility: "public" }, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + { + id: "tenant-company", + type: "COMPANY", + parentId: "tenant-root", + name: "GPDTDC", + slug: "gpdtdc", + description: "실 조직", + status: "active", + memberCount: 2, + domains: ["gpdtdc.example.com"], + config: { + visibility: "public", + userSchema: [ + { + key: "employee_id", + label: "사번", + type: "text", + required: false, + adminOnly: false, + isLoginId: true, + indexed: true, + }, + ], + }, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, +]; + +vi.mock("../../lib/i18n", () => createI18nMock()); + +vi.mock("../../lib/adminApi", () => ({ + fetchMe: vi.fn(async () => ({ + id: "admin-1", + role: "super_admin", + })), + fetchAllTenants: vi.fn(async () => ({ + items: tenants, + total: tenants.length, + })), + fetchTenant: vi.fn(async (id: string) => { + return tenants.find((tenant) => tenant.id === id) ?? tenants[1]; + }), + createTenant: vi.fn(async () => tenants[1]), + updateTenant: vi.fn(async () => tenants[1]), + deleteTenant: vi.fn(async () => undefined), + approveTenant: vi.fn(async () => tenants[1]), +})); + +function renderWithProviders(ui: React.ReactElement, entry: string) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + {ui} + , + ); +} + +describe("admin tenant detail page coverage smoke", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, "confirm").mockReturnValue(true); + }); + + it("renders tenant create page with parent context", async () => { + renderWithProviders( + + } /> + , + "/tenants/new?parentId=tenant-root", + ); + + expect(await screen.findByText("테넌트 생성")).toBeInTheDocument(); + expect(screen.getByText("Tenant Profile")).toBeInTheDocument(); + expect(screen.getByText("정책 메모")).toBeInTheDocument(); + }); + + it("renders tenant profile and schema management pages", async () => { + renderWithProviders( + + + + + + } + /> + , + "/tenants/tenant-company", + ); + + expect(await screen.findByDisplayValue("GPDTDC")).toBeInTheDocument(); + expect(screen.getByDisplayValue("gpdtdc")).toBeInTheDocument(); + expect(await screen.findByText("사용자 스키마 확장")).toBeInTheDocument(); + expect(screen.getByDisplayValue("employee_id")).toBeInTheDocument(); + }); +}); diff --git a/adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx b/adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx new file mode 100644 index 00000000..4fb0dd45 --- /dev/null +++ b/adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx @@ -0,0 +1,116 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import type React from "react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createI18nMock } from "../../test/i18nMock"; +import TenantGroupsPage from "../tenants/routes/TenantGroupsPage"; + +const tenant = { + id: "tenant-company", + type: "COMPANY", + name: "GPDTDC", + slug: "gpdtdc", + description: "", + status: "active", + memberCount: 2, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", +}; + +const members = [ + { + id: "user-1", + name: "Member User", + email: "member@example.com", + }, +]; + +vi.mock("../../lib/i18n", () => createI18nMock()); + +vi.mock("../../lib/adminApi", () => ({ + fetchTenant: vi.fn(async () => tenant), + fetchUsers: vi.fn(async () => ({ + items: [ + { + id: "user-1", + name: "Member User", + email: "member@example.com", + role: "user", + status: "active", + }, + { + id: "user-2", + name: "Candidate User", + email: "candidate@example.com", + role: "user", + status: "active", + }, + ], + total: 2, + })), + fetchGroups: vi.fn(async () => [ + { + id: "group-root", + tenantId: "tenant-company", + name: "연구소", + description: "root group", + members, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + { + id: "group-child", + tenantId: "tenant-company", + parentId: "group-root", + name: "플랫폼팀", + description: "child group", + members: [], + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + ]), + createGroup: vi.fn(async () => undefined), + deleteGroup: vi.fn(async () => undefined), + addGroupMember: vi.fn(async () => undefined), + removeGroupMember: vi.fn(async () => undefined), +})); + +function renderWithProviders(ui: React.ReactElement, entry: string) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + {ui} + , + ); +} + +describe("TenantGroupsPage coverage smoke", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, "confirm").mockReturnValue(true); + }); + + it("renders group hierarchy and selected group members", async () => { + renderWithProviders( + + } + /> + , + "/tenants/tenant-company/groups", + ); + + expect((await screen.findAllByText("연구소")).length).toBeGreaterThan(0); + expect(screen.getAllByText("플랫폼팀").length).toBeGreaterThan(0); + expect(screen.getByText("새 그룹 생성")).toBeInTheDocument(); + expect(screen.getByText("조직 단위 레벨")).toBeInTheDocument(); + }); +}); diff --git a/adminfront/src/features/coverage/adminTenantTabs.test.tsx b/adminfront/src/features/coverage/adminTenantTabs.test.tsx new file mode 100644 index 00000000..362a73fc --- /dev/null +++ b/adminfront/src/features/coverage/adminTenantTabs.test.tsx @@ -0,0 +1,162 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import type React from "react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createI18nMock } from "../../test/i18nMock"; +import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab"; +import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab"; + +const tenants = [ + { + id: "tenant-root", + type: "COMPANY_GROUP", + name: "한맥 가족", + slug: "hanmac-family", + description: "", + status: "active", + memberCount: 0, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + { + id: "tenant-company", + type: "COMPANY", + parentId: "tenant-root", + name: "GPDTDC", + slug: "gpdtdc", + description: "", + status: "active", + memberCount: 2, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + { + id: "tenant-leaf", + type: "ORGANIZATION", + parentId: "tenant-company", + name: "기술연구팀", + slug: "gpdtdc-rnd", + description: "", + status: "active", + memberCount: 1, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, +]; + +const users = [ + { + id: "user-owner", + name: "Owner User", + email: "owner@example.com", + role: "tenant_admin", + status: "active", + }, + { + id: "user-admin", + name: "Admin User", + email: "admin@example.com", + role: "tenant_admin", + status: "active", + }, + { + id: "user-member", + name: "Member User", + email: "member@example.com", + role: "user", + status: "active", + tenantSlug: "gpdtdc-rnd", + tenant: tenants[2], + }, +]; + +vi.mock("../../lib/i18n", () => createI18nMock()); + +vi.mock("react-oidc-context", () => ({ + useAuth: () => ({ + user: { + profile: { + sub: "admin-1", + }, + }, + }), +})); + +vi.mock("../../lib/adminApi", () => ({ + fetchTenantOwners: vi.fn(async () => [users[0]]), + fetchTenantAdmins: vi.fn(async () => [users[1]]), + addTenantOwner: vi.fn(async () => undefined), + addTenantAdmin: vi.fn(async () => undefined), + removeTenantOwner: vi.fn(async () => undefined), + removeTenantAdmin: vi.fn(async () => undefined), + fetchUsers: vi.fn(async () => ({ + items: users, + total: users.length, + })), + fetchAllTenants: vi.fn(async () => ({ + items: tenants, + total: tenants.length, + })), + updateTenant: vi.fn(async () => tenants[2]), + updateUser: vi.fn(async () => users[2]), + exportTenantsCSV: vi.fn(async () => ({ + blob: new Blob(["name,slug"]), + filename: "tenants.csv", + })), +})); + +function renderWithProviders(ui: React.ReactElement, entry: string) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + {ui} + , + ); +} + +describe("admin tenant tab coverage smoke", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, "confirm").mockReturnValue(true); + }); + + it("renders tenant owners and admins lists", async () => { + renderWithProviders( + + } + /> + , + "/tenants/tenant-company/permissions", + ); + + expect(await screen.findByText("Owner User")).toBeInTheDocument(); + expect(screen.getByText("Admin User")).toBeInTheDocument(); + expect(screen.getByText("owner@example.com")).toBeInTheDocument(); + expect(screen.getByText("admin@example.com")).toBeInTheDocument(); + }); + + it("renders tenant hierarchy and selected organization members", async () => { + renderWithProviders( + + } + /> + , + "/tenants/tenant-company/organization", + ); + + expect((await screen.findAllByText("GPDTDC")).length).toBeGreaterThan(0); + expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0); + expect(await screen.findByText("Member User")).toBeInTheDocument(); + }); +}); diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index 90c12801..2b9095ef 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -23,7 +23,6 @@ import { fetchDataIntegrityReport, type RPUsageDailyMetric, type RPUsagePeriod, - type TenantSummary, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index 6dc881a9..fc78435d 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -5,7 +5,6 @@ import { Plus, Search, ShieldCheck, - Trash2, UserPlus, Users, } from "lucide-react"; @@ -28,7 +27,6 @@ import { DialogDescription, DialogHeader, DialogTitle, - DialogTrigger, } from "../../../components/ui/dialog"; import { Input } from "../../../components/ui/input"; import { @@ -68,7 +66,7 @@ function mergePendingMembers( export function TenantAdminsAndOwnersTab() { const auth = useAuth(); const navigate = useNavigate(); - const currentUserId = auth.user?.profile.sub; + const _currentUserId = auth.user?.profile.sub; const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>(); const tenantId = tenantIdParam ?? ""; const queryClient = useQueryClient(); @@ -187,7 +185,7 @@ export function TenantAdminsAndOwnersTab() { ), ); }, - onError: (err: AxiosError<{ error?: string }>, userId, context) => { + onError: (err: AxiosError<{ error?: string }>, _userId, context) => { if (context?.previousOwners) { queryClient.setQueryData( ["tenant-owners", tenantId], @@ -288,7 +286,7 @@ export function TenantAdminsAndOwnersTab() { t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."), ); }, - onError: (err: AxiosError<{ error?: string }>, userId, context) => { + onError: (err: AxiosError<{ error?: string }>, _userId, context) => { if (context?.previousAdmins) { queryClient.setQueryData( ["tenant-admins", tenantId], @@ -310,7 +308,7 @@ export function TenantAdminsAndOwnersTab() { } }; - const handleRemoveOwner = (userId: string, userName: string) => { + const _handleRemoveOwner = (userId: string, userName: string) => { if ( window.confirm( t( @@ -324,7 +322,7 @@ export function TenantAdminsAndOwnersTab() { } }; - const handleRemoveAdmin = (userId: string, userName: string) => { + const _handleRemoveAdmin = (userId: string, userName: string) => { if ( window.confirm( t( diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index fb452a3c..b7bae980 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -1,7 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Copy } from "lucide-react"; import { Link, Outlet, useLocation, useParams } from "react-router-dom"; -import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { fetchMe, fetchTenant } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index 87d226d2..df61e3b3 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -38,7 +38,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from "../../../components/ui/dialog"; import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; @@ -239,7 +238,7 @@ const UserGroupTreeNode: React.FC = ({ function TenantGroupsPage() { const params = useParams<{ tenantId: string }>(); const tenantId = params.tenantId ?? ""; - const queryClient = useQueryClient(); + const _queryClient = useQueryClient(); const [newGroupName, setNewGroupName] = useState(""); const [newGroupDesc, setNewGroupNameDesc] = useState(""); diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 14a4b9c0..d5a53164 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -4,7 +4,6 @@ import { useMutation, useQuery, } from "@tanstack/react-query"; -import { useVirtualizer } from "@tanstack/react-virtual"; import type { AxiosError } from "axios"; import { ArrowDown, @@ -14,7 +13,6 @@ import { ChevronDown, ChevronRight, Download, - ExternalLink, FileSpreadsheet, LayoutDashboard, List, @@ -28,22 +26,13 @@ import { import * as React from "react"; import { Link, useNavigate } from "react-router-dom"; import { PageHeader } from "../../../../../common/core/components/page"; -import { - SortableTableHead, - sortableTableHeadBaseClassName, - sortableTableHeaderClassName, -} from "../../../../../common/core/components/sort"; import { type SortConfig, type SortResolverMap, sortItems, toggleSort, } from "../../../../../common/core/utils"; -import { - commonStickyTableHeaderClass, - commonTableShellClass, - commonTableViewportClass, -} from "../../../../../common/ui/table"; +import { commonStickyTableHeaderClass } from "../../../../../common/ui/table"; import { RoleGuard } from "../../../components/auth/RoleGuard"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; @@ -71,7 +60,6 @@ import { DropdownMenuTrigger, } from "../../../components/ui/dropdown-menu"; import { Input } from "../../../components/ui/input"; -import { ScrollArea } from "../../../components/ui/scroll-area"; import { Select, SelectContent, @@ -79,7 +67,6 @@ import { SelectTrigger, SelectValue, } from "../../../components/ui/select"; -import { Separator } from "../../../components/ui/separator"; import { Switch } from "../../../components/ui/switch"; import { Table, @@ -92,7 +79,6 @@ import { import { toast } from "../../../components/ui/use-toast"; import type { UserProfileResponse } from "../../../lib/adminApi"; import { - deleteTenant, deleteTenantsBulk, exportTenantsCSV, fetchMe, @@ -132,13 +118,8 @@ import { const tenantCSVTemplate = "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n"; const tenantPageSize = 500; -const tenantVirtualizationThreshold = 250; -const tenantEstimatedRowHeight = 73; -const tenantLoadAheadPx = 360; -const tenantLoadAheadRows = 30; type TenantSortKey = keyof TenantSummary | "recursiveMemberCount"; -type TenantListRow = TenantSummary & { recursiveMemberCount: number }; const getTenantIcon = (type?: string) => { switch (type?.toUpperCase()) { @@ -301,7 +282,7 @@ function TenantListPage() { >({}); const [previewOpen, setPreviewOpen] = React.useState(false); const [selectedBulkStatus, setSelectedBulkStatus] = React.useState(""); - const tenantTableScrollRef = React.useRef(null); + const _tenantTableScrollRef = React.useRef(null); const { data: profile } = useQuery({ queryKey: ["me"], @@ -340,13 +321,6 @@ function TenantListPage() { (profile?.manageableTenants?.length ?? 0) > 1), }); - const deleteMutation = useMutation({ - mutationFn: (tenantId: string) => deleteTenant(tenantId), - onSuccess: () => { - query.refetch(); - }, - }); - const deleteBulkMutation = useMutation({ mutationFn: (ids: string[]) => deleteTenantsBulk(ids), onSuccess: () => { @@ -725,25 +699,6 @@ function TenantListPage() { importMutation.mutate(file); }; - const handleDelete = (tenantId: string, tenantName: string) => { - const tenant = allTenants.find((item) => item.id === tenantId); - if (tenant && isSeedTenant(tenant)) { - return; - } - if ( - !window.confirm( - t( - "msg.admin.tenants.delete_confirm", - '테넌트 "{{name}}"를 삭제할까요?', - { name: tenantName }, - ), - ) - ) { - return; - } - deleteMutation.mutate(tenantId); - }; - return (
void; onSelectAll: (checked: boolean) => void; - onDelete: (tenantId: string, tenantName: string) => void; - isDeletePending: boolean; search: string; deletableTenants: TenantSummary[]; statusMutation: UseMutationResult< @@ -1368,8 +1319,6 @@ const TenantHierarchyView: React.FC<{ selectedIds, onSelect, onSelectAll, - onDelete, - isDeletePending, search, deletableTenants, statusMutation, diff --git a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx index 5fbd5edd..1fcb5111 100644 --- a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { ArrowRight, Building2, Plus } from "lucide-react"; +import { Building2, Plus } from "lucide-react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { commonStickyTableHeaderClass, diff --git a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx index 8c8638ce..a6d61460 100644 --- a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx @@ -1,14 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { - Loader2, - Mail, - MoreHorizontal, - Plus, - User, - UserMinus, - UserPlus, -} from "lucide-react"; +import { Loader2, Mail, Plus, User, UserPlus } from "lucide-react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { commonStickyTableHeaderClass } from "../../../../../common/ui/table"; import { Badge } from "../../../components/ui/badge"; @@ -19,12 +11,6 @@ import { CardHeader, CardTitle, } from "../../../components/ui/card"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "../../../components/ui/dropdown-menu"; import { Table, TableBody, @@ -80,7 +66,7 @@ function TenantUsersPage() { }, }); - const handleRemoveMember = (userId: string, userName: string) => { + const _handleRemoveMember = (userId: string, userName: string) => { if (!tenantSlug) return; if ( window.confirm( diff --git a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx index a4533e21..5fb3b1f1 100644 --- a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx +++ b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx @@ -1,6 +1,5 @@ import { useQuery } from "@tanstack/react-query"; import { Building2, Plus, Users } from "lucide-react"; -import { useState } from "react"; import { Link } from "react-router-dom"; import { commonStickyTableHeaderClass } from "../../../../../common/ui/table"; import { Badge } from "../../../components/ui/badge"; diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index b46d00ae..a4b4a25f 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -6,7 +6,6 @@ import { Building2, ChevronDown, ChevronRight, - CornerDownRight, Download, ExternalLink, FolderOpen, @@ -41,7 +40,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from "../../../components/ui/dialog"; import { DropdownMenu, @@ -52,7 +50,6 @@ import { DropdownMenuTrigger, } from "../../../components/ui/dropdown-menu"; import { Input } from "../../../components/ui/input"; -import { Label } from "../../../components/ui/label"; import { ScrollArea } from "../../../components/ui/scroll-area"; import { Table, @@ -62,15 +59,8 @@ import { TableHeader, TableRow, } from "../../../components/ui/table"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "../../../components/ui/tabs"; import { toast } from "../../../components/ui/use-toast"; import { - createUser, exportTenantsCSV, fetchAllTenants, fetchUsers, @@ -413,7 +403,7 @@ const MemberTable: React.FC<{ function TenantUserGroupsTab() { const { tenantId } = useParams<{ tenantId: string }>(); - const navigate = useNavigate(); + const _navigate = useNavigate(); const queryClient = useQueryClient(); const [selectedNodeId, setSelectedNodeId] = useState(tenantId || ""); @@ -452,7 +442,7 @@ function TenantUserGroupsTab() { queryFn: () => fetchAllTenants(), }); - const { currentBase, subTree } = useMemo(() => { + const { currentBase } = useMemo(() => { const allItems = allTenantsData?.items ?? []; return buildTenantFullTree(allItems, tenantId); }, [allTenantsData, tenantId]); @@ -855,7 +845,7 @@ const UserAddDialog: React.FC<{ try { const res = await fetchUsers(20, 0, userSearch); setSearchResults(res.items); - } catch (err) { + } catch (_err) { toast.error(t("msg.admin.users.list.fetch_error", "사용자 검색 실패")); } finally { setIsSearching(false); diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index d47a49c5..f7cccce0 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -8,7 +8,6 @@ import { Plus, Save, Trash2, - Mail, X, } from "lucide-react"; import * as React from "react"; @@ -22,7 +21,6 @@ import { CardHeader, CardTitle, } from "../../components/ui/card"; -import { Checkbox } from "../../components/ui/checkbox"; import { Dialog, DialogContent, @@ -184,7 +182,7 @@ function UserCreatePage() { if (e.key === "Enter" || e.key === "," || e.key === " ") { e.preventDefault(); const value = newSubEmail.trim().replace(/,/g, ""); - if (value && value.includes("@") && !currentSubEmails.includes(value)) { + if (value?.includes("@") && !currentSubEmails.includes(value)) { setValue("metadata.sub_email", [...currentSubEmails, value], { shouldDirty: true, }); @@ -667,8 +665,7 @@ function UserCreatePage() { onClick={() => { const value = newSubEmail.trim().replace(/,/g, ""); if ( - value && - value.includes("@") && + value?.includes("@") && !currentSubEmails.includes(value) ) { setValue( diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 02f1a721..58fa2659 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -35,7 +35,6 @@ import { CardHeader, CardTitle, } from "../../components/ui/card"; -import { Checkbox } from "../../components/ui/checkbox"; import { Dialog, DialogContent, @@ -95,8 +94,8 @@ import { resolvePersonalTenant } from "./utils/personalTenant"; type UserFormValues = Omit & { email: string; - metadata: Record> & { - sub_email?: string[]; + metadata: Record & { + sub_email?: string | string[]; }; }; type UserCategory = "hanmac" | "external" | "personal"; @@ -109,6 +108,44 @@ type AppointmentDraft = UserAppointment & { const PASSWORD_RESET_MIN_LENGTH = 12; +function isMetadataRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function cleanMetadataValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean); + } + if (isMetadataRecord(value)) { + return Object.fromEntries( + Object.entries(value).filter( + ([_, fieldValue]) => + fieldValue !== undefined && fieldValue !== null && fieldValue !== "", + ), + ); + } + return value; +} + +function normalizeSubEmails(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.includes("@")); + } + if (typeof value === "string" && value.trim() !== "") { + return value + .split(/[;,\n\r\t]/) + .map((email) => email.trim()) + .filter((email) => email.includes("@")); + } + return []; +} + function createDraftId() { return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`; } @@ -322,8 +359,8 @@ function UserDetailPage() { const userId = params.id ?? ""; const navigate = useNavigate(); const queryClient = useQueryClient(); - const [error, setError] = React.useState(null); - const [successMsg, setSuccessMsg] = React.useState(null); + const [_error, _setError] = React.useState(null); + const [_successMsg, _setSuccessMsg] = React.useState(null); const [isPasswordResetOpen, setIsPasswordResetOpen] = React.useState(false); const [generatedPassword, setGeneratedPassword] = React.useState< string | null @@ -419,7 +456,7 @@ function UserDetailPage() { if (e.key === "Enter" || e.key === "," || e.key === " ") { e.preventDefault(); const value = newSubEmail.trim().replace(/,/g, ""); - if (value && value.includes("@") && !currentSubEmails.includes(value)) { + if (value?.includes("@") && !currentSubEmails.includes(value)) { setValue("metadata.sub_email", [...currentSubEmails, value], { shouldDirty: true, }); @@ -595,7 +632,7 @@ function UserDetailPage() { ); }; - const setPrimaryAppointment = (targetIndex: number) => { + const _setPrimaryAppointment = (targetIndex: number) => { setAdditionalAppointments((current) => current.map((appointment, index) => ({ ...appointment, @@ -774,15 +811,17 @@ function UserDetailPage() { }); const onSubmit = async (data: UserFormValues) => { - // Filter out undefined/null/empty strings from metadata const cleanMetadata = Object.fromEntries( - Object.entries(data.metadata).map(([tenantId, fields]) => { - const cleanFields = Object.fromEntries( - Object.entries(fields).filter( - ([_, v]) => v !== undefined && v !== null && v !== "", - ), - ); - return [tenantId, cleanFields]; + Object.entries(data.metadata ?? {}).flatMap(([key, value]) => { + const cleanedValue = cleanMetadataValue(value); + if ( + cleanedValue === undefined || + cleanedValue === null || + cleanedValue === "" + ) { + return []; + } + return [[key, cleanedValue]]; }), ); @@ -792,19 +831,11 @@ function UserDetailPage() { sub_email: rawSubEmail, ...safeMetadata } = cleanMetadata; - - // Parse sub_email - let sub_email: string[] = []; - if (typeof rawSubEmail === "string" && rawSubEmail.trim() !== "") { - sub_email = rawSubEmail - .split(/[;,\n\r\t]/) - .map((e) => e.trim()) - .filter((e) => e.includes("@")); - } + const subEmail = normalizeSubEmails(rawSubEmail); const metadata: Record = { ...safeMetadata, - ...(sub_email.length > 0 ? { sub_email } : { sub_email: [] }), + ...(subEmail.length > 0 ? { sub_email: subEmail } : { sub_email: [] }), }; const payload: UserUpdateRequest = { @@ -813,7 +844,7 @@ function UserDetailPage() { }; // email cannot be updated directly via this API in current backend implementation, // so we delete it from payload if it spread - // @ts-ignore + // @ts-expect-error delete payload.email; payload.role = undefined; @@ -989,8 +1020,7 @@ function UserDetailPage() { {user.email}
- {user.metadata?.sub_email && - Array.isArray(user.metadata.sub_email) && + {Array.isArray(user.metadata?.sub_email) && user.metadata.sub_email.length > 0 && (
@@ -1167,8 +1197,7 @@ function UserDetailPage() { onClick={() => { const value = newSubEmail.trim().replace(/,/g, ""); if ( - value && - value.includes("@") && + value?.includes("@") && !currentSubEmails.includes(value) ) { setValue( diff --git a/adminfront/src/features/users/UserListPage.render.test.tsx b/adminfront/src/features/users/UserListPage.render.test.tsx index 72c5145f..6694f252 100644 --- a/adminfront/src/features/users/UserListPage.render.test.tsx +++ b/adminfront/src/features/users/UserListPage.render.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createI18nMock } from "../../test/i18nMock"; diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index fa5d4795..a914b63a 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -13,7 +13,6 @@ import { ChevronDown, ChevronLeft, ChevronRight, - Download, FileDown, FileSpreadsheet, LayoutDashboard, @@ -268,7 +267,7 @@ const UserListSearchControls = React.memo(function UserListSearchControls({ }); function UserListPage() { - const navigate = useNavigate(); + const _navigate = useNavigate(); const [page, setPage] = React.useState(1); const [search, setSearch] = React.useState(""); const [selectedCompany, setSelectedCompany] = React.useState(""); @@ -563,7 +562,7 @@ function UserListPage() { }, }); - const handleApplyBulkStatus = () => { + const _handleApplyBulkStatus = () => { if (selectedUserIds.length === 0 || !selectedBulkStatus) return; bulkUpdateMutation.mutate({ userIds: selectedUserIds, @@ -571,7 +570,7 @@ function UserListPage() { }); }; - const handleApplyBulkPermission = () => { + const _handleApplyBulkPermission = () => { if (selectedUserIds.length === 0 || !selectedBulkPermission) return; bulkUpdateMutation.mutate({ userIds: selectedUserIds, @@ -594,7 +593,7 @@ function UserListPage() { } }; - const handleDelete = (userId: string, userName: string) => { + const _handleDelete = (userId: string, userName: string) => { if ( !window.confirm( t( diff --git a/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx b/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx index 41232425..c045d2f9 100644 --- a/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx +++ b/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx @@ -19,8 +19,6 @@ import { bulkUpdateUsers, fetchAllTenants, fetchGroups, - type GroupSummary, - type TenantSummary, type UserSummary, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; @@ -49,7 +47,7 @@ export function UserBulkMoveGroupModal({ const [searchTerm, setSearchTerm] = React.useState(""); const [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false); - const queryClient = useQueryClient(); + const _queryClient = useQueryClient(); const { data: tenantsData } = useQuery({ queryKey: ["tenants", "all"], diff --git a/adminfront/src/features/users/orgChartPicker.ts b/adminfront/src/features/users/orgChartPicker.ts index 7d8cbf2d..20f06746 100644 --- a/adminfront/src/features/users/orgChartPicker.ts +++ b/adminfront/src/features/users/orgChartPicker.ts @@ -102,7 +102,7 @@ export function isHanmacFamilyTenant( tenants: T[], hanmacFamilyTenantId?: string, ) { - if (!tenant || !tenant.id) return false; + if (!tenant?.id) return false; const rootTenantId = resolveHanmacFamilyTenantId( tenants, diff --git a/adminfront/src/features/users/utils/csvParser.ts b/adminfront/src/features/users/utils/csvParser.ts index db811b38..a6ab2f1c 100644 --- a/adminfront/src/features/users/utils/csvParser.ts +++ b/adminfront/src/features/users/utils/csvParser.ts @@ -354,10 +354,12 @@ function applySecondaryEmailMetadata( value: string, ) { const emails = splitEmailTokens(value); - item.metadata.sub_email = uniqueEmails([ - ...metadataEmailList(item.metadata.sub_email), + const uniqueSecondaryEmails = uniqueEmails([ + ...metadataEmailList(item.metadata.secondary_emails), ...emails, ]); + item.metadata.sub_email = value; + item.metadata.secondary_emails = uniqueSecondaryEmails; addWorksmobileAliasEmails(item, emails); } diff --git a/adminfront/src/lib/adminApi.contract.test.ts b/adminfront/src/lib/adminApi.contract.test.ts new file mode 100644 index 00000000..5c6de00e --- /dev/null +++ b/adminfront/src/lib/adminApi.contract.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiClient = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +}; + +const fetchAllCursorPages = vi.fn(async () => ({ + items: [{ id: "tenant-1", name: "Tenant", slug: "tenant" }], + total: 1, +})); + +vi.mock("./apiClient", () => ({ + default: apiClient, +})); + +vi.mock("./auth", () => ({ + userManager: { + getUser: vi.fn(async () => ({ access_token: "access-token" })), + }, +})); + +vi.mock("../../../common/core/pagination", () => ({ + fetchAllCursorPages, +})); + +describe("adminApi endpoint contracts", () => { + beforeEach(() => { + apiClient.get.mockReset(); + apiClient.post.mockReset(); + apiClient.put.mockReset(); + apiClient.patch.mockReset(); + apiClient.delete.mockReset(); + + apiClient.get.mockResolvedValue({ + data: { ok: true }, + headers: { "content-disposition": 'attachment; filename="export.csv"' }, + }); + apiClient.post.mockResolvedValue({ data: { ok: true } }); + apiClient.put.mockResolvedValue({ data: { ok: true } }); + apiClient.patch.mockResolvedValue({ data: { ok: true } }); + apiClient.delete.mockResolvedValue({ data: { ok: true } }); + fetchAllCursorPages.mockClear(); + window.localStorage.clear(); + }); + + it("routes read APIs to their documented admin endpoints", async () => { + const adminApi = await import("./adminApi"); + + await adminApi.fetchAuditLogs(10, "cursor-a"); + await adminApi.fetchAdminOverviewStats(); + await adminApi.fetchDataIntegrityReport(); + await adminApi.fetchOrphanUserLoginIDs(); + await adminApi.fetchUserProjectionStatus(); + await adminApi.fetchAdminRPUsageDaily({ + days: 30, + period: "week", + tenantId: "tenant-1", + }); + await adminApi.fetchTenants(25, 50, "parent-1", "cursor-b"); + await adminApi.fetchAllTenants({ pageSize: 200, parentId: "parent-1" }); + await adminApi.fetchTenant("tenant-1"); + await adminApi.fetchTenantAdmins("tenant-1"); + await adminApi.fetchTenantOwners("tenant-1"); + await adminApi.fetchGroups("tenant-1"); + await adminApi.fetchGroup("tenant-1", "group-1"); + await adminApi.fetchGroupRoles("tenant-1", "group-1"); + await adminApi.fetchApiKeys(20, 40); + await adminApi.fetchUsers(30, 60, "admin", "tenant"); + await adminApi.fetchUser("user-1"); + await adminApi.fetchWorksmobileOverview("tenant-1"); + await adminApi.fetchWorksmobileComparison("tenant-1", true); + await adminApi.downloadWorksmobileInitialPasswordsCSV("tenant-1"); + await adminApi.fetchPasswordPolicy(); + await adminApi.fetchUserRpHistory("user-1"); + await adminApi.fetchMe(); + await adminApi.fetchRelyingParties("tenant-1"); + await adminApi.fetchAllRelyingParties(); + await adminApi.fetchRelyingParty("client-1"); + await adminApi.fetchRPOwners("client-1"); + + expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", { + params: { limit: 10, cursor: "cursor-a" }, + }); + expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/tenants", { + params: { + limit: 25, + offset: 50, + parentId: "parent-1", + cursor: "cursor-b", + }, + }); + expect(fetchAllCursorPages).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/v1/admin/tenants", + pageSize: 200, + params: { parentId: "parent-1" }, + }), + ); + expect(apiClient.get).toHaveBeenCalledWith( + "/v1/admin/tenants/tenant-1/worksmobile/comparison", + { params: { includeMatched: true } }, + ); + expect(await adminApi.exportTenantsCSV(true, "parent-1")).toMatchObject({ + filename: "export.csv", + }); + expect( + await adminApi.exportUsersCSV("admin", "tenant", true), + ).toMatchObject({ + filename: "export.csv", + }); + }); + + it("routes mutation APIs to their documented admin endpoints", async () => { + const adminApi = await import("./adminApi"); + + await adminApi.deleteOrphanUserLoginIDs(["orphan-1"]); + await adminApi.reconcileUserProjection(); + await adminApi.resetUserProjection(); + await adminApi.createTenant({ name: "Tenant", slug: "tenant" }); + await adminApi.updateTenant("tenant-1", { status: "inactive" }); + await adminApi.deleteTenant("tenant-1"); + await adminApi.deleteTenantsBulk(["tenant-1"]); + await adminApi.importTenantsCSV(new File(["name"], "tenants.csv")); + await adminApi.approveTenant("tenant-1"); + await adminApi.addTenantAdmin("tenant-1", "user-1"); + await adminApi.removeTenantAdmin("tenant-1", "user-1"); + await adminApi.addTenantOwner("tenant-1", "user-1"); + await adminApi.removeTenantOwner("tenant-1", "user-1"); + await adminApi.createGroup("tenant-1", { name: "Group" }); + await adminApi.deleteGroup("tenant-1", "group-1"); + await adminApi.addGroupMember("tenant-1", "group-1", "user-1"); + await adminApi.removeGroupMember("tenant-1", "group-1", "user-1"); + await adminApi.assignGroupRole("tenant-1", "group-1", "tenant-2", "owner"); + await adminApi.removeGroupRole("tenant-1", "group-1", "tenant-2", "owner"); + await adminApi.createApiKey({ name: "key", scopes: ["read"] }); + await adminApi.updateApiKeyScopes("key-1", { scopes: ["write"] }); + await adminApi.rotateApiKeySecret("key-1"); + await adminApi.deleteApiKey("key-1"); + await adminApi.createUser({ email: "user@example.com", name: "User" }); + await adminApi.bulkCreateUsers([ + { email: "user@example.com", name: "User", metadata: {} }, + ]); + await adminApi.enqueueWorksmobileBackfillDryRun("tenant-1"); + await adminApi.enqueueWorksmobileOrgUnitSync("tenant-1", "org/unit"); + await adminApi.enqueueWorksmobileOrgUnitDelete("tenant-1", "org/unit"); + await adminApi.enqueueWorksmobileUserSync("tenant-1", "user-1"); + await adminApi.retryWorksmobileJob("tenant-1", "job-1"); + await adminApi.bulkUpdateUsers({ userIds: ["user-1"], status: "inactive" }); + await adminApi.bulkDeleteUsers(["user-1"]); + await adminApi.updateUser("user-1", { status: "active" }); + await adminApi.deleteUser("user-1"); + await adminApi.createRelyingParty("tenant-1", { + client_name: "RP", + redirect_uris: ["https://rp.example/callback"], + }); + await adminApi.updateRelyingParty("client-1", { + client_name: "RP", + redirect_uris: ["https://rp.example/callback"], + }); + await adminApi.deleteRelyingParty("client-1"); + await adminApi.addRPOwner("client-1", "User:user-1"); + await adminApi.removeRPOwner("client-1", "User:user-1"); + + expect(apiClient.delete).toHaveBeenCalledWith( + "/v1/admin/integrity/orphan-user-login-ids", + { data: { ids: ["orphan-1"] } }, + ); + expect(apiClient.post).toHaveBeenCalledWith( + "/v1/admin/projections/users/reconcile", + ); + expect(apiClient.put).toHaveBeenCalledWith("/v1/admin/users/user-1", { + status: "active", + }); + expect(apiClient.post).toHaveBeenCalledWith( + "/v1/admin/tenants/tenant-1/worksmobile/orgunits/org%2Funit/sync", + ); + expect(apiClient.delete).toHaveBeenCalledWith( + "/v1/admin/relying-parties/client-1/owners/User:user-1", + ); + }); +}); diff --git a/adminfront/src/lib/tenantTree.ts b/adminfront/src/lib/tenantTree.ts index 1c6ae529..80606f19 100644 --- a/adminfront/src/lib/tenantTree.ts +++ b/adminfront/src/lib/tenantTree.ts @@ -24,7 +24,7 @@ export function buildTenantFullTree( }); } - const visitedDuringBuild = new Set(); + const _visitedDuringBuild = new Set(); // Build initial children relations and prevent simple cycles for (const t of allTenants) { if (t.parentId && t.parentId !== t.id) { diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index f12eeba7..77a9d2dd 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -258,7 +258,7 @@ test.describe("Tenants Management", () => { page, }) => { await page.setViewportSize({ width: 900, height: 700 }); - let requestCount = 0; + let _requestCount = 0; await page.route("**/api/v1/admin/tenants**", async (route) => { if (route.request().method() !== "GET") { @@ -266,7 +266,7 @@ test.describe("Tenants Management", () => { } const url = new URL(route.request().url()); const cursor = url.searchParams.get("cursor"); - requestCount += 1; + _requestCount += 1; if (!cursor) { return route.fulfill({ diff --git a/adminfront/tests/users_bulk_secondary.spec.ts b/adminfront/tests/users_bulk_secondary.spec.ts index d7a03e7b..ef1e814f 100644 --- a/adminfront/tests/users_bulk_secondary.spec.ts +++ b/adminfront/tests/users_bulk_secondary.spec.ts @@ -1,11 +1,21 @@ import { expect, test } from "@playwright/test"; +type BulkUsersRequest = { + users: Array<{ + metadata: { + sub_email?: string[]; + }; + }>; +}; + test.describe("Users Bulk Upload Secondary Emails", () => { 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; + ( + window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } + )._IS_TEST_MODE = true; const authData = { access_token: "fake-token", @@ -13,12 +23,20 @@ test.describe("Users Bulk Upload Secondary Emails", () => { profile: { sub: "admin-user", name: "Admin", role: "super_admin" }, expires_at: Math.floor(Date.now() / 1000) + 36000, }; - window.localStorage.setItem("oidc.user:http://localhost:5000/oidc:adminfront", JSON.stringify(authData)); + window.localStorage.setItem( + "oidc.user:http://localhost:5000/oidc:adminfront", + JSON.stringify(authData), + ); }); await page.route("**/api/v1/user/me", async (route) => { return route.fulfill({ - json: { id: "admin-user", name: "Admin", role: "super_admin", manageableTenants: [] }, + json: { + id: "admin-user", + name: "Admin", + role: "super_admin", + manageableTenants: [], + }, headers: { "Access-Control-Allow-Origin": "*" }, }); }); @@ -31,7 +49,7 @@ test.describe("Users Bulk Upload Secondary Emails", () => { }); await page.route("**/api/v1/admin/users*", async (route) => { - if(route.request().url().includes("/bulk")) { + if (route.request().url().includes("/bulk")) { return route.continue(); } return route.fulfill({ @@ -45,14 +63,20 @@ test.describe("Users Bulk Upload Secondary Emails", () => { }); }); - test("should parse secondary_emails and send to backend", async ({ page }) => { - let bulkPayload: any = null; + test("should parse secondary_emails and send to backend", async ({ + page, + }) => { + let bulkPayload: BulkUsersRequest | null = null; await page.route("**/api/v1/admin/users/bulk", async (route) => { if (route.request().method() === "POST") { - bulkPayload = route.request().postDataJSON(); + bulkPayload = route.request().postDataJSON() as BulkUsersRequest; return route.fulfill({ - json: { results: [{ email: "test@example.com", success: true, userId: "u-1" }] }, + json: { + results: [ + { email: "test@example.com", success: true, userId: "u-1" }, + ], + }, headers: { "Access-Control-Allow-Origin": "*" }, }); } @@ -60,21 +84,26 @@ test.describe("Users Bulk Upload Secondary Emails", () => { }); await page.goto("/users"); - await expect(page.getByTestId("page-title")).toContainText(/사용자|Users/i, { timeout: 20000 }); + 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 + .getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i }) + .click(); // Create a mock CSV with secondary_emails const csvContent = `email,sub_email,name,phone,role,tenant_slug\ntest@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug`; - const fileChooserPromise = page.waitForEvent('filechooser'); + const fileChooserPromise = page.waitForEvent("filechooser"); await page.getByText(/파일 선택|Change file|Select file/i).click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles({ - name: 'users_with_secondary.csv', - mimeType: 'text/csv', + name: "users_with_secondary.csv", + mimeType: "text/csv", buffer: Buffer.from(csvContent), }); @@ -87,9 +116,9 @@ test.describe("Users Bulk Upload Secondary Emails", () => { expect(bulkPayload).not.toBeNull(); expect(bulkPayload.users).toHaveLength(1); - + // The most important check - does it parse to the metadata expect(bulkPayload.users[0].metadata.sub_email).toContain("sub1@test.com"); expect(bulkPayload.users[0].metadata.sub_email).toContain("sub2@test.com"); }); -}); \ No newline at end of file +}); diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 03faf338..a2ebbc9c 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -729,14 +729,14 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { req.CompanyCode = tenant.Slug } - // Collect and sync all custom login IDs based on tenant schemas - loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "") - attributes["role"] = role if tenantID != "" { attributes["tenant_id"] = tenantID } + // Collect and sync all custom login IDs based on tenant schemas + loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "") + if h.UserRepo != nil { if err := h.ensureHanmacCreateEmailAllowed(c.Context(), email, req.CompanyCode, tenantID); err != nil { if strings.Contains(err.Error(), "한맥가족") { @@ -2050,7 +2050,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { // Validate all collected LoginIDs userEmail := extractTraitString(traits, "email") userPhone := extractTraitString(traits, "phone_number") - + allEmails := []string{userEmail} if secondaryRaw, exists := traits["sub_email"]; exists { if secondaryEmails, ok := secondaryRaw.([]interface{}); ok { @@ -2534,20 +2534,23 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService var allCustomIDs []string idSet := make(map[string]bool) + normalizeCustomLoginIDsTrait(traits) + // Collect tenant IDs to check schemas for tenantIDsToCheck := make(map[string]bool) + primaryTenantID := extractTraitString(traits, "tenant_id") for k, v := range metadata { - // Heuristic: if it's a map, it's likely namespaced metadata for a tenant - if _, ok := v.(map[string]any); ok { - tenantIDsToCheck[k] = true - } else if _, ok := v.(map[string]interface{}); ok { + if isTenantMetadataNamespace(k, v, primaryTenantID) { tenantIDsToCheck[k] = true } } // Also check primary tenant if available - if tid := extractTraitString(traits, "tenant_id"); tid != "" { + if tid := primaryTenantID; tid != "" && (len(metadata) > 0 || isMetadataMap(traits[tid])) { tenantIDsToCheck[tid] = true } + if len(tenantIDsToCheck) == 0 { + return nil + } for tid := range tenantIDsToCheck { tenant, err := tenantService.GetTenant(ctx, tid) @@ -2629,6 +2632,66 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService return loginIDRecords } +func isTenantMetadataNamespace(key string, value any, primaryTenantID string) bool { + return isTenantMetadataNamespaceKey(key, primaryTenantID) && isMetadataMap(value) +} + +func isTenantMetadataNamespaceKey(key string, primaryTenantID string) bool { + if key == "" { + return false + } + if primaryTenantID != "" && key == primaryTenantID { + return true + } + if len(key) != 36 { + return false + } + for index, char := range key { + switch index { + case 8, 13, 18, 23: + if char != '-' { + return false + } + default: + if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) { + return false + } + } + } + return true +} + +func isMetadataMap(value any) bool { + if _, ok := value.(map[string]any); ok { + return true + } + if _, ok := value.(map[string]interface{}); ok { + return true + } + return false +} + +func normalizeCustomLoginIDsTrait(traits map[string]interface{}) { + raw, exists := traits["custom_login_ids"] + if !exists { + return + } + switch values := raw.(type) { + case []string: + return + case []interface{}: + normalized := make([]string, 0, len(values)) + for _, value := range values { + if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { + normalized = append(normalized, text) + } + } + if len(normalized) > 0 { + traits["custom_login_ids"] = normalized + } + } +} + func formatTime(value time.Time) string { if value.IsZero() { return "" diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 1b38eb7d..12a86e51 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -707,13 +707,6 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT Status: domain.TenantStatusActive, Config: domain.JSONMap{}, }, nil).Once() - mockTenant.On("GetTenant", mock.Anything, "t-saman").Return(&domain.Tenant{ - ID: "t-saman", - Slug: "saman", - Name: "삼안", - Status: domain.TenantStatusActive, - Config: domain.JSONMap{}, - }, nil) mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool { _, hasCompanyCode := user.Attributes["companyCode"] @@ -1183,6 +1176,41 @@ func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) { mockKratos.AssertExpectations(t) } +func TestSyncCustomLoginIDs_IgnoresFlatMetadataMaps(t *testing.T) { + mockTenant := new(MockTenantServiceForUser) + tenantID := "tenant-uuid" + + mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{ + ID: tenantID, + Slug: "test-tenant", + Config: domain.JSONMap{ + "userSchema": []interface{}{ + map[string]interface{}{"key": "emp_no", "isLoginId": true}, + }, + }, + }, nil).Once() + + traits := map[string]interface{}{ + "tenant_id": tenantID, + } + metadata := map[string]any{ + tenantID: map[string]interface{}{ + "emp_no": "E1001", + }, + "worksmobileAliasEmails": map[string]interface{}{ + "0": "alias@hanmaceng.co.kr", + }, + } + + records := syncCustomLoginIDs(context.Background(), mockTenant, traits, metadata, "user-1") + + require.Len(t, records, 1) + require.Equal(t, tenantID, records[0].TenantID) + require.Equal(t, "E1001", records[0].LoginID) + mockTenant.AssertNotCalled(t, "GetTenant", mock.Anything, "worksmobileAliasEmails") + mockTenant.AssertExpectations(t) +} + func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) { t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) { app := fiber.New() @@ -1764,7 +1792,7 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing ID: "new-tenant-id", Slug: "new-tenant", Config: domain.JSONMap{}, - }, nil).Twice() + }, nil).Once() mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool { _, hasCompanyCode := traits["companyCode"] return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id" diff --git a/devfront/src/components/common/ForbiddenMessage.test.tsx b/devfront/src/components/common/ForbiddenMessage.test.tsx new file mode 100644 index 00000000..4189d754 --- /dev/null +++ b/devfront/src/components/common/ForbiddenMessage.test.tsx @@ -0,0 +1,77 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ForbiddenMessage } from "./ForbiddenMessage"; + +const authState = { + user: { + profile: { + role: "user", + }, + }, +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => authState, +})); + +vi.mock("../../lib/i18n", () => ({ + t: (key: string, fallback?: string, vars?: Record) => { + let text = fallback ?? key; + for (const [name, value] of Object.entries(vars ?? {})) { + text = text.replaceAll(`{{${name}}}`, String(value)); + } + return text; + }, +})); + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } +}); + +async function renderMessage(resourceToken: "audit" | "clients" | "consents") { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + await act(async () => { + root.render(); + }); + + return container; +} + +describe("ForbiddenMessage", () => { + it("renders resource-specific user guidance", async () => { + authState.user.profile.role = "user"; + + const audit = await renderMessage("audit"); + expect(audit.textContent).toContain("Audit Logs"); + expect(audit.textContent).toContain("audit read relationship"); + + const consents = await renderMessage("consents"); + expect(consents.textContent).toContain("User Consent Grants"); + expect(consents.textContent).toContain("consent read"); + + const clients = await renderMessage("clients"); + expect(clients.textContent).toContain("Connected Applications"); + expect(clients.textContent).toContain("target RP"); + }); + + it("renders role-specific administrator guidance", async () => { + authState.user.profile.role = "rp_admin"; + const rpAdmin = await renderMessage("clients"); + expect(rpAdmin.textContent).toContain("RP administrators"); + + authState.user.profile.role = "tenant_admin"; + const tenantAdmin = await renderMessage("clients"); + expect(tenantAdmin.textContent).toContain("tenant administrator"); + }); +}); diff --git a/devfront/src/components/layout/AppLayout.test.tsx b/devfront/src/components/layout/AppLayout.test.tsx new file mode 100644 index 00000000..9bdac4e8 --- /dev/null +++ b/devfront/src/components/layout/AppLayout.test.tsx @@ -0,0 +1,166 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AppLayout from "./AppLayout"; + +const authState = { + isAuthenticated: true, + isLoading: false, + activeNavigator: undefined as string | undefined, + error: null as Error | null, + user: { + access_token: "access-token", + expires_at: Math.floor(Date.now() / 1000) + 120, + profile: { + sub: "user-1", + name: "Dev Admin", + email: "dev@example.com", + role: "super_admin", + }, + }, + signinSilent: vi.fn(), + removeUser: vi.fn(), +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => authState, +})); + +vi.mock("../../features/auth/authApi", () => ({ + fetchMe: vi.fn(async () => ({ + id: "user-1", + name: "Fetched Dev Admin", + email: "fetched@example.com", + role: "super_admin", + })), +})); + +vi.mock("../../lib/i18n", () => ({ + t: (key: string, fallback?: string, vars?: Record) => { + let text = fallback ?? key; + for (const [name, value] of Object.entries(vars ?? {})) { + text = text.replaceAll(`{{${name}}}`, String(value)); + } + return text; + }, +})); + +const roots: Root[] = []; + +beforeEach(() => { + authState.isAuthenticated = true; + authState.isLoading = false; + authState.activeNavigator = undefined; + authState.error = null; + authState.user.expires_at = Math.floor(Date.now() / 1000) + 120; + authState.signinSilent.mockReset(); + authState.signinSilent.mockResolvedValue(undefined); + authState.removeUser.mockReset(); + window.localStorage.clear(); + vi.spyOn(window, "confirm").mockReturnValue(true); +}); + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.restoreAllMocks(); +}); + +async function renderLayout(initialEntry = "/clients") { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + + }> + Client outlet
} /> + Profile outlet} /> + + + + , + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return container; +} + +describe("devfront AppLayout", () => { + it("renders shell navigation, profile summary, and outlet content", async () => { + const container = await renderLayout(); + + expect(container.textContent).toContain("Developer Console"); + expect(container.textContent).toContain("Clients"); + expect(container.textContent).toContain("Client outlet"); + expect(container.textContent).toContain("Fetched Dev Admin"); + expect(document.documentElement.classList.contains("light")).toBe(true); + }); + + it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => { + const container = await renderLayout(); + + const themeButton = container.querySelector( + 'button[aria-label="Toggle theme"]', + ) as HTMLButtonElement; + await act(async () => { + themeButton.click(); + }); + expect(document.documentElement.classList.contains("dark")).toBe(true); + + const profileButton = container.querySelector( + 'button[aria-label="Open account menu"]', + ) as HTMLButtonElement; + await act(async () => { + profileButton.click(); + }); + expect(container.textContent).toContain("My Profile"); + + const profileMenuItem = Array.from( + container.querySelectorAll('button[role="menuitem"]'), + ).find((button) => button.textContent?.includes("My Profile")); + await act(async () => { + (profileMenuItem as HTMLButtonElement).click(); + }); + expect(container.textContent).toContain("Profile outlet"); + + const logoutButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Logout"), + ); + await act(async () => { + (logoutButton as HTMLButtonElement).click(); + }); + expect(window.confirm).toHaveBeenCalled(); + expect(authState.removeUser).toHaveBeenCalled(); + }); + + it("attempts silent renewal after user action when the session is expiring", async () => { + authState.user.expires_at = Math.floor(Date.now() / 1000) + 60; + await renderLayout(); + + await act(async () => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" })); + }); + + expect(authState.signinSilent).toHaveBeenCalled(); + }); +}); diff --git a/devfront/src/features/auth/authPages.test.tsx b/devfront/src/features/auth/authPages.test.tsx new file mode 100644 index 00000000..2c2cf98f --- /dev/null +++ b/devfront/src/features/auth/authPages.test.tsx @@ -0,0 +1,161 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AuthCallbackPage from "./AuthCallbackPage"; +import AuthGuard from "./AuthGuard"; +import AuthPage from "./AuthPage"; +import LoginPage from "./LoginPage"; + +const authState = { + isAuthenticated: false, + isLoading: false, + activeNavigator: undefined as string | undefined, + error: null as Error | null, + user: undefined as + | { + state?: unknown; + } + | undefined, + signinRedirect: vi.fn(), +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => authState, +})); + +vi.mock("../../lib/auth", () => ({ + userManager: { + signinPopupCallback: vi.fn(async () => undefined), + }, +})); + +const roots: Root[] = []; + +beforeEach(() => { + authState.isAuthenticated = false; + authState.isLoading = false; + authState.activeNavigator = undefined; + authState.error = null; + authState.user = undefined; + authState.signinRedirect.mockReset(); + authState.signinRedirect.mockResolvedValue(undefined); +}); + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } +}); + +async function renderWithRouter( + element: React.ReactElement, + { + entry = "/", + path = "*", + }: { + entry?: string; + path?: string; + } = {}, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + await act(async () => { + root.render( + + + + Protected outlet} /> + + Login route} /> + Clients route} /> + Profile route} /> + + , + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return container; +} + +describe("devfront auth pages", () => { + it("renders the static auth planning page", async () => { + const container = await renderWithRouter(); + + expect(container.textContent).toContain("Admin auth guardrails"); + expect(container.textContent).toContain("Device approval"); + }); + + it("renders login page and starts SSO redirect from the action button", async () => { + const container = await renderWithRouter(, { + entry: "/login?returnTo=/profile", + path: "/login", + }); + + expect(container.textContent).toContain("개발자 포털 로그인"); + + const loginButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("SSO 계정으로 로그인"), + ); + await act(async () => { + (loginButton as HTMLButtonElement).click(); + }); + + expect(authState.signinRedirect).toHaveBeenCalledWith({ + state: { returnTo: "/clients" }, + }); + }); + + it("shows AuthGuard loading, error, redirect, and protected outlet states", async () => { + authState.isLoading = true; + const loading = await renderWithRouter(); + expect(loading.textContent).toContain("Loading..."); + + authState.isLoading = false; + authState.error = new Error("OIDC failed"); + const error = await renderWithRouter(); + expect(error.textContent).toContain("Authentication Error"); + + const retryButton = error.querySelector("button") as HTMLButtonElement; + await act(async () => { + retryButton.click(); + }); + expect(authState.signinRedirect).toHaveBeenCalled(); + + authState.error = null; + const redirected = await renderWithRouter(); + expect(redirected.textContent).toContain("Login route"); + + authState.isAuthenticated = true; + const protectedPage = await renderWithRouter(); + expect(protectedPage.textContent).toContain("Protected outlet"); + }); + + it("navigates from callback by auth result and stored return target", async () => { + authState.isAuthenticated = true; + authState.user = { state: { returnTo: "/profile" } }; + + const authenticated = await renderWithRouter(, { + entry: "/auth/callback", + path: "/auth/callback", + }); + expect(authenticated.textContent).toContain("Profile route"); + + authState.isAuthenticated = false; + authState.error = new Error("callback failed"); + const failed = await renderWithRouter(, { + entry: "/auth/callback", + path: "/auth/callback", + }); + expect(failed.textContent).toContain("Login route"); + }); +}); diff --git a/devfront/src/features/coverage/commonSort.test.ts b/devfront/src/features/coverage/commonSort.test.ts new file mode 100644 index 00000000..74276796 --- /dev/null +++ b/devfront/src/features/coverage/commonSort.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + compareNullableValues, + sortItems, + toggleSort, +} from "../../../../common/core/utils/sort"; + +describe("common sort utilities in devfront coverage", () => { + it("keeps nullish values last and compares normalized primitive values", () => { + expect(compareNullableValues(null, "alpha", "asc")).toBe(1); + expect(compareNullableValues("alpha", undefined, "asc")).toBe(-1); + expect(compareNullableValues("Beta", "alpha", "asc")).toBe(1); + expect(compareNullableValues("Beta", "alpha", "desc")).toBe(-1); + expect(compareNullableValues(true, false, "asc")).toBe(1); + expect( + compareNullableValues( + new Date("2026-05-02T00:00:00Z"), + new Date("2026-05-01T00:00:00Z"), + "asc", + ), + ).toBe(1); + }); + + it("toggles sort direction and sorts with default and custom resolvers", () => { + const firstSort = toggleSort(null, "name"); + expect(firstSort).toEqual({ key: "name", direction: "asc" }); + expect(toggleSort(firstSort, "name")).toEqual({ + key: "name", + direction: "desc", + }); + expect(toggleSort(firstSort, "createdAt")).toEqual({ + key: "createdAt", + direction: "asc", + }); + + const rows = [ + { name: "charlie", rank: 3, nested: { score: 20 } }, + { name: "Alpha", rank: 1, nested: { score: 30 } }, + { name: "bravo", rank: 2, nested: { score: 10 } }, + ]; + + expect( + sortItems(rows, { key: "name", direction: "asc" }).map( + (row) => row.name, + ), + ).toEqual(["Alpha", "bravo", "charlie"]); + expect( + sortItems(rows, { key: "score", direction: "desc" }, { + score: (row) => row.nested.score, + }).map((row) => row.name), + ).toEqual(["Alpha", "charlie", "bravo"]); + expect(sortItems(rows, null)).not.toBe(rows); + }); +}); diff --git a/devfront/src/features/coverage/pageSmoke.test.tsx b/devfront/src/features/coverage/pageSmoke.test.tsx new file mode 100644 index 00000000..72ccaeab --- /dev/null +++ b/devfront/src/features/coverage/pageSmoke.test.tsx @@ -0,0 +1,383 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import AuditLogsPage from "../audit/AuditLogsPage"; +import ClientConsentsPage from "../clients/ClientConsentsPage"; +import ClientDetailsPage from "../clients/ClientDetailsPage"; +import ClientGeneralPage from "../clients/ClientGeneralPage"; +import ClientRelationsPage from "../clients/ClientRelationsPage"; +import ClientsPage from "../clients/ClientsPage"; +import { ClientFederationPage } from "../clients/routes/ClientFederationPage"; +import DeveloperRequestPage from "../developer-request/DeveloperRequestPage"; +import GlobalOverviewPage from "../overview/GlobalOverviewPage"; +import ProfilePage from "../profile/ProfilePage"; + +const authProfile = { + sub: "user-1", + role: "super_admin", + tenant_id: "tenant-1", + companyCode: "HANMAC", +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => ({ + isAuthenticated: true, + isLoading: false, + user: { + access_token: "access-token", + profile: authProfile, + }, + signinRedirect: vi.fn(), + removeUser: vi.fn(), + }), +})); + +vi.mock("../../lib/i18n", () => ({ + t: (key: string, fallback?: string, vars?: Record) => { + let text = fallback ?? key; + for (const [name, value] of Object.entries(vars ?? {})) { + text = text.replaceAll(`{{${name}}}`, String(value)); + } + return text; + }, +})); + +const clientSummary = { + id: "client-a", + name: "Console App", + type: "private" as const, + status: "active" as const, + createdAt: "2026-05-01T00:00:00Z", + redirectUris: ["https://app.example/callback"], + scopes: ["openid", "profile"], + tokenEndpointAuthMethod: "client_secret_basic", + metadata: { + headless_login_enabled: true, + headless_login_jwks_uri: "https://app.example/jwks.json", + id_token_claims: [ + { + namespace: "rp_claims", + key: "employee_id", + value: "E001", + valueType: "text", + }, + ], + }, +}; + +const clientDetail = { + client: { + ...clientSummary, + clientSecret: "secret-value", + jwksUri: "https://app.example/jwks.json", + backchannelLogoutUri: "https://app.example/logout", + backchannelLogoutSessionRequired: true, + grantTypes: ["authorization_code"], + responseTypes: ["code"], + }, + endpoints: { + discovery: "https://sso.example/.well-known/openid-configuration", + issuer: "https://sso.example", + authorization: "https://sso.example/oauth2/auth", + token: "https://sso.example/oauth2/token", + userinfo: "https://sso.example/userinfo", + }, + headlessJwksCache: { + clientId: "client-a", + jwksUri: "https://app.example/jwks.json", + cachedAt: "2026-05-01T00:00:00Z", + expiresAt: "2026-05-02T00:00:00Z", + lastCheckedAt: "2026-05-01T01:00:00Z", + lastSuccessfulVerificationAt: "2026-05-01T01:00:00Z", + lastRefreshStatus: "success" as const, + cachedKids: ["kid-1"], + parsedKeys: [{ kid: "kid-1", kty: "RSA", use: "sig", alg: "RS256" }], + }, +}; + +vi.mock("../../lib/devApi", () => ({ + fetchClients: vi.fn(async () => ({ + items: [clientSummary], + limit: 100, + offset: 0, + })), + fetchDevStats: vi.fn(async () => ({ + total_clients: 1, + active_sessions: 12, + auth_failures_24h: 2, + })), + fetchDevRPUsageDaily: vi.fn(async () => ({ + days: 14, + period: "day", + items: [ + { + date: "2026-05-01", + tenantId: "tenant-1", + tenantType: "COMPANY", + tenantName: "Hanmac", + clientId: "client-a", + clientName: "Console App", + loginRequests: 10, + otherRequests: 4, + uniqueSubjects: 3, + }, + { + date: "2026-05-08", + tenantId: "tenant-1", + tenantType: "COMPANY", + tenantName: "Hanmac", + clientId: "client-a", + clientName: "Console App", + loginRequests: 8, + otherRequests: 5, + uniqueSubjects: 4, + }, + ], + })), + fetchClient: vi.fn(async () => clientDetail), + fetchClientRelations: vi.fn(async () => ({ + items: [ + { + relation: "admins", + subject: "User:user-1", + subjectType: "User", + subjectId: "user-1", + userName: "Dev Admin", + userEmail: "dev@example.com", + }, + ], + })), + fetchDevUsers: vi.fn(async () => ({ + items: [ + { + id: "user-2", + name: "Editor User", + email: "editor@example.com", + loginId: "editor", + }, + ], + })), + addClientRelation: vi.fn(async () => ({ + relation: "admins", + subject: "User:user-2", + subjectType: "User", + subjectId: "user-2", + })), + removeClientRelation: vi.fn(async () => undefined), + updateClientStatus: vi.fn(async () => clientDetail), + createClient: vi.fn(async () => clientDetail), + updateClient: vi.fn(async () => clientDetail), + rotateClientSecret: vi.fn(async () => clientDetail), + refreshHeadlessJwksCache: vi.fn(async () => clientDetail), + revokeHeadlessJwksCache: vi.fn(async () => undefined), + deleteClient: vi.fn(async () => undefined), + fetchConsents: vi.fn(async () => ({ + items: [ + { + subject: "user-1", + userName: "Consent User", + clientId: "client-a", + clientName: "Console App", + grantedScopes: ["openid", "profile"], + authenticatedAt: "2026-05-01T02:00:00Z", + createdAt: "2026-05-01T00:00:00Z", + status: "active", + tenantId: "tenant-1", + tenantName: "Hanmac", + }, + ], + })), + revokeConsent: vi.fn(async () => undefined), + listIdpConfigsForClient: vi.fn(async () => [ + { + id: "idp-1", + client_id: "client-a", + provider_type: "oidc", + display_name: "Workspace OIDC", + status: "active", + issuer_url: "https://accounts.example", + oidc_client_id: "oidc-client", + scopes: "openid email profile", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + ]), + createIdpConfigForClient: vi.fn(async (payload) => ({ + id: "idp-1", + ...payload, + status: "active", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + })), + updateIdpConfig: vi.fn(async (_clientId, idpId, payload) => ({ + id: idpId, + client_id: "client-a", + provider_type: "oidc", + display_name: "Provider", + status: "active", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + ...payload, + })), + deleteIdpConfig: vi.fn(async () => undefined), + fetchDevAuditLogs: vi.fn(async () => ({ + items: [ + { + event_id: "event-1", + timestamp: "2026-05-01T00:00:00Z", + user_id: "user-1", + event_type: "client.update", + status: "success", + ip_address: "127.0.0.1", + user_agent: "Vitest", + details: "{\"client_id\":\"client-a\"}", + }, + ], + limit: 50, + })), + fetchMyTenants: vi.fn(async () => [ + { + id: "tenant-1", + name: "Hanmac", + slug: "hanmac", + type: "COMPANY", + status: "active", + description: "", + memberCount: 10, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + ]), + fetchDeveloperRequestStatus: vi.fn(async () => ({ status: "approved" })), + requestDeveloperAccess: vi.fn(async () => ({ status: "pending" })), + fetchDeveloperRequests: vi.fn(async () => [ + { + id: 1, + userId: "user-3", + tenantId: "tenant-1", + name: "Requester", + organization: "Hanmac", + email: "requester@example.com", + reason: "Need RP access", + status: "pending", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + ]), + approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })), + rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })), + cancelDeveloperRequestApproval: vi.fn(async () => ({ status: "cancelled" })), +})); + +vi.mock("../auth/authApi", () => ({ + fetchMe: vi.fn(async () => ({ + id: "user-1", + email: "dev@example.com", + name: "Dev Admin", + role: "super_admin", + tenantId: "tenant-1", + })), +})); + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } +}); + +async function renderPage( + element: React.ReactElement, + { + path = "/", + entry = path, + }: { + path?: string; + entry?: string; + } = {}, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + + + + + , + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return container; +} + +describe("devfront coverage smoke pages", () => { + it("renders overview, client list, audit, developer request, and profile pages", async () => { + const overview = await renderPage(); + expect(overview.textContent).toContain("Console App"); + + const clients = await renderPage(); + expect(clients.textContent).toContain("Console App"); + + const audit = await renderPage(); + expect(audit.textContent).toContain("client.update"); + + const requests = await renderPage(); + expect(requests.textContent).toContain("Requester"); + + const profile = await renderPage(); + expect(profile.textContent).toContain("Dev Admin"); + }); + + it("renders client detail, settings, consent, federation, and relationship pages", async () => { + const details = await renderPage(, { + path: "/clients/:id", + entry: "/clients/client-a", + }); + expect(details.textContent).toContain("Console App"); + + const settings = await renderPage(, { + path: "/clients/:id/settings", + entry: "/clients/client-a/settings", + }); + expect(settings.textContent).toContain("Console App"); + + const consents = await renderPage(, { + path: "/clients/:id/consents", + entry: "/clients/client-a/consents", + }); + expect(consents.textContent).toContain("Consent User"); + + const federation = await renderPage(, { + path: "/clients/:id/federation", + entry: "/clients/client-a/federation", + }); + expect(federation.textContent).toContain("Workspace OIDC"); + + const relations = await renderPage(, { + path: "/clients/:id/relationships", + entry: "/clients/client-a/relationships", + }); + expect(relations.textContent).toContain("Dev Admin"); + }); +}); diff --git a/devfront/src/lib/devApi.test.ts b/devfront/src/lib/devApi.test.ts new file mode 100644 index 00000000..2bd50b49 --- /dev/null +++ b/devfront/src/lib/devApi.test.ts @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiClient = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +}; + +vi.mock("./apiClient", () => ({ + default: apiClient, +})); + +describe("devApi", () => { + beforeEach(() => { + apiClient.get.mockReset(); + apiClient.post.mockReset(); + apiClient.put.mockReset(); + apiClient.patch.mockReset(); + apiClient.delete.mockReset(); + }); + + it("fetches list and detail resources with expected query parameters", async () => { + const { + fetchClients, + fetchDevStats, + fetchDevRPUsageDaily, + fetchTenants, + fetchClient, + fetchClientRelations, + fetchDevUsers, + fetchConsents, + fetchDevAuditLogs, + fetchMyTenants, + fetchDeveloperRequestStatus, + fetchDeveloperRequests, + listIdpConfigsForClient, + } = await import("./devApi"); + apiClient.get.mockResolvedValue({ data: { ok: true } }); + + await fetchClients(); + await fetchDevStats(); + await fetchDevRPUsageDaily({ days: 30, period: "week" }); + await fetchTenants(25, 50, "tenant-parent"); + await fetchClient("client-a"); + await fetchClientRelations("client-a"); + await fetchDevUsers("admin", 5, "client-a"); + await fetchConsents("user-a", "client-a", "active"); + await fetchDevAuditLogs(10, "cursor-a", { + action: "client.update", + client_id: "client-a", + status: "success", + tenant_id: "tenant-a", + }); + await fetchMyTenants(); + await fetchDeveloperRequestStatus("tenant-a"); + await fetchDeveloperRequests("pending"); + await listIdpConfigsForClient("client-a"); + + expect(apiClient.get).toHaveBeenCalledWith("/dev/clients"); + expect(apiClient.get).toHaveBeenCalledWith("/dev/stats"); + expect(apiClient.get).toHaveBeenCalledWith("/dev/rp-usage/daily", { + params: { days: 30, period: "week" }, + }); + expect(apiClient.get).toHaveBeenCalledWith("/tenants", { + params: { limit: 25, offset: 50, parentId: "tenant-parent" }, + }); + expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a"); + expect(apiClient.get).toHaveBeenCalledWith( + "/dev/clients/client-a/relations", + ); + expect(apiClient.get).toHaveBeenCalledWith("/dev/users", { + params: { search: "admin", limit: 5, clientId: "client-a" }, + }); + expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", { + params: { subject: "user-a", client_id: "client-a", status: "active" }, + }); + expect(apiClient.get).toHaveBeenCalledWith("/dev/audit-logs", { + params: { + limit: 10, + cursor: "cursor-a", + action: "client.update", + client_id: "client-a", + status: "success", + tenant_id: "tenant-a", + }, + }); + expect(apiClient.get).toHaveBeenCalledWith("/dev/my-tenants"); + expect(apiClient.get).toHaveBeenCalledWith( + "/dev/developer-request/status", + { + params: { tenantId: "tenant-a" }, + }, + ); + expect(apiClient.get).toHaveBeenCalledWith("/dev/developer-request/list", { + params: { status: "pending" }, + }); + expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a/idps"); + }); + + it("omits optional consent filters when they are empty or all", async () => { + const { fetchConsents, revokeConsent } = await import("./devApi"); + apiClient.get.mockResolvedValue({ data: { items: [] } }); + apiClient.delete.mockResolvedValue({ data: {} }); + + await fetchConsents("user-a", undefined, "all"); + await revokeConsent("user-a"); + + expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", { + params: { subject: "user-a" }, + }); + expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", { + params: { subject: "user-a" }, + }); + }); + + it("sends mutation requests to the documented dev endpoints", async () => { + const { + addClientRelation, + removeClientRelation, + updateClientStatus, + createClient, + updateClient, + rotateClientSecret, + refreshHeadlessJwksCache, + revokeHeadlessJwksCache, + deleteClient, + revokeConsent, + createIdpConfigForClient, + updateIdpConfig, + deleteIdpConfig, + requestDeveloperAccess, + approveDeveloperRequest, + rejectDeveloperRequest, + cancelDeveloperRequestApproval, + } = await import("./devApi"); + apiClient.post.mockResolvedValue({ data: { ok: true } }); + apiClient.put.mockResolvedValue({ data: { ok: true } }); + apiClient.patch.mockResolvedValue({ data: { ok: true } }); + apiClient.delete.mockResolvedValue({ data: {} }); + + await addClientRelation("client-a", { + relation: "admins", + userId: "user-a", + }); + await removeClientRelation("client-a", "admins", "User:user-a"); + await updateClientStatus("client-a", "inactive"); + await createClient({ id: "client-a", name: "Console App" }); + await updateClient("client-a", { name: "Console App Updated" }); + await rotateClientSecret("client-a"); + await refreshHeadlessJwksCache("client-a"); + await revokeHeadlessJwksCache("client-a"); + await deleteClient("client-a"); + await revokeConsent("user-a", "client-a"); + await createIdpConfigForClient({ + client_id: "client-a", + provider_type: "oidc", + display_name: "OIDC Provider", + status: "active", + }); + await updateIdpConfig("client-a", "idp-a", { status: "inactive" }); + await deleteIdpConfig("client-a", "idp-a"); + await requestDeveloperAccess({ + name: "Dev User", + organization: "Hanmac", + reason: "Need RP access", + tenantId: "tenant-a", + }); + await approveDeveloperRequest(1, "approved"); + await rejectDeveloperRequest(2, "rejected"); + await cancelDeveloperRequestApproval(3, "cancelled"); + + expect(apiClient.post).toHaveBeenCalledWith( + "/dev/clients/client-a/relations", + { relation: "admins", userId: "user-a" }, + ); + expect(apiClient.delete).toHaveBeenCalledWith( + "/dev/clients/client-a/relations", + { + params: { relation: "admins", subject: "User:user-a" }, + }, + ); + expect(apiClient.patch).toHaveBeenCalledWith( + "/dev/clients/client-a/status", + { + status: "inactive", + }, + ); + expect(apiClient.post).toHaveBeenCalledWith("/dev/clients", { + id: "client-a", + name: "Console App", + }); + expect(apiClient.put).toHaveBeenCalledWith("/dev/clients/client-a", { + name: "Console App Updated", + }); + expect(apiClient.post).toHaveBeenCalledWith( + "/dev/clients/client-a/secret/rotate", + ); + expect(apiClient.post).toHaveBeenCalledWith( + "/dev/clients/client-a/headless-jwks/refresh", + ); + expect(apiClient.delete).toHaveBeenCalledWith( + "/dev/clients/client-a/headless-jwks/cache", + ); + expect(apiClient.delete).toHaveBeenCalledWith("/dev/clients/client-a"); + expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", { + params: { subject: "user-a", client_id: "client-a" }, + }); + expect(apiClient.post).toHaveBeenCalledWith("/dev/clients/client-a/idps", { + client_id: "client-a", + provider_type: "oidc", + display_name: "OIDC Provider", + status: "active", + }); + expect(apiClient.put).toHaveBeenCalledWith( + "/dev/clients/client-a/idps/idp-a", + { + status: "inactive", + }, + ); + expect(apiClient.delete).toHaveBeenCalledWith( + "/dev/clients/client-a/idps/idp-a", + ); + expect(apiClient.post).toHaveBeenCalledWith("/dev/developer-request", { + name: "Dev User", + organization: "Hanmac", + reason: "Need RP access", + tenantId: "tenant-a", + }); + expect(apiClient.post).toHaveBeenCalledWith( + "/dev/developer-request/1/approve", + { + adminNotes: "approved", + }, + ); + expect(apiClient.post).toHaveBeenCalledWith( + "/dev/developer-request/2/reject", + { + adminNotes: "rejected", + }, + ); + expect(apiClient.post).toHaveBeenCalledWith( + "/dev/developer-request/3/cancel-approval", + { + adminNotes: "cancelled", + }, + ); + }); +}); diff --git a/orgfront/src/components/layout/AppLayout.test.tsx b/orgfront/src/components/layout/AppLayout.test.tsx new file mode 100644 index 00000000..1871a72a --- /dev/null +++ b/orgfront/src/components/layout/AppLayout.test.tsx @@ -0,0 +1,166 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AppLayout from "./AppLayout"; + +const authState = { + isAuthenticated: true, + isLoading: false, + activeNavigator: undefined as string | undefined, + error: null as Error | null, + user: { + access_token: "access-token", + expires_at: Math.floor(Date.now() / 1000) + 120, + profile: { + sub: "user-1", + name: "Org Admin", + email: "org@example.com", + role: "super_admin", + }, + }, + signinSilent: vi.fn(), + removeUser: vi.fn(), +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => authState, +})); + +vi.mock("../../features/auth/authApi", () => ({ + fetchMe: vi.fn(async () => ({ + id: "user-1", + name: "Fetched Org Admin", + email: "fetched@example.com", + role: "super_admin", + })), +})); + +vi.mock("../../lib/i18n", () => ({ + t: (key: string, fallback?: string, vars?: Record) => { + let text = fallback ?? key; + for (const [name, value] of Object.entries(vars ?? {})) { + text = text.replaceAll(`{{${name}}}`, String(value)); + } + return text; + }, +})); + +const roots: Root[] = []; + +beforeEach(() => { + authState.isAuthenticated = true; + authState.isLoading = false; + authState.activeNavigator = undefined; + authState.error = null; + authState.user.expires_at = Math.floor(Date.now() / 1000) + 120; + authState.signinSilent.mockReset(); + authState.signinSilent.mockResolvedValue(undefined); + authState.removeUser.mockReset(); + window.localStorage.clear(); + vi.spyOn(window, "confirm").mockReturnValue(true); +}); + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.restoreAllMocks(); +}); + +async function renderLayout(initialEntry = "/clients") { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + + }> + Client outlet} /> + Profile outlet} /> + + + + , + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return container; +} + +describe("orgfront AppLayout", () => { + it("renders shell navigation, profile summary, and outlet content", async () => { + const container = await renderLayout(); + + expect(container.textContent).toContain("Developer Console"); + expect(container.textContent).toContain("Clients"); + expect(container.textContent).toContain("Client outlet"); + expect(container.textContent).toContain("Fetched Org Admin"); + expect(document.documentElement.classList.contains("light")).toBe(true); + }); + + it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => { + const container = await renderLayout(); + + const themeButton = container.querySelector( + 'button[aria-label="테마 전환"]', + ) as HTMLButtonElement; + await act(async () => { + themeButton.click(); + }); + expect(document.documentElement.classList.contains("dark")).toBe(true); + + const profileButton = container.querySelector( + 'button[aria-label="계정 메뉴 열기"]', + ) as HTMLButtonElement; + await act(async () => { + profileButton.click(); + }); + expect(container.textContent).toContain("Account"); + + const profileMenuItem = Array.from( + container.querySelectorAll('button[role="menuitem"]'), + ).find((button) => button.textContent?.includes("내 정보")); + await act(async () => { + (profileMenuItem as HTMLButtonElement).click(); + }); + expect(container.textContent).toContain("Profile outlet"); + + const logoutButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Logout"), + ); + await act(async () => { + (logoutButton as HTMLButtonElement).click(); + }); + expect(window.confirm).toHaveBeenCalled(); + expect(authState.removeUser).toHaveBeenCalled(); + }); + + it("attempts silent renewal after user action when the session is expiring", async () => { + authState.user.expires_at = Math.floor(Date.now() / 1000) + 60; + await renderLayout(); + + await act(async () => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" })); + }); + + expect(authState.signinSilent).toHaveBeenCalled(); + }); +}); diff --git a/orgfront/src/components/ui/basic.test.tsx b/orgfront/src/components/ui/basic.test.tsx new file mode 100644 index 00000000..b13af471 --- /dev/null +++ b/orgfront/src/components/ui/basic.test.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { Avatar, AvatarFallback, AvatarImage } from "./avatar"; +import { Badge } from "./badge"; +import { Input } from "./input"; +import { Label } from "./label"; +import { Separator } from "./separator"; +import { Switch } from "./switch"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "./table"; +import { Textarea } from "./textarea"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement | null = null; + +const render = async (element: React.ReactElement) => { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(element); + }); + return root; +}; + +afterEach(() => { + if (container) { + container.remove(); + container = null; + } +}); + +describe("orgfront UI wrappers", () => { + it("renders form, badge, avatar, switch, separator, and table wrappers", async () => { + const root = await render( +
+ + Active + + + + OU + + + +