diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx index 19f93442..0b36c1ed 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx @@ -11,36 +11,37 @@ import DataIntegrityPage from "./DataIntegrityPage"; let currentRole = "super_admin"; +const integrityReport = { + status: "fail", + checkedAt: "2026-05-14T00:00:00Z", + summary: { + totalChecks: 2, + passed: 1, + warnings: 0, + failures: 1, + }, + sections: [ + { + key: "tenant_integrity", + label: "테넌트 정합성", + status: "fail", + checks: [ + { + key: "duplicate_tenant_slugs", + label: "중복 테넌트 slug", + description: "active tenant slug의 대소문자 무시 중복을 검사합니다.", + status: "fail", + severity: "error", + count: 1, + }, + ], + }, + ], +}; + vi.mock("../../lib/adminApi", () => ({ fetchMe: vi.fn(async () => ({ role: currentRole })), - fetchDataIntegrityReport: vi.fn(async () => ({ - status: "fail", - checkedAt: "2026-05-14T00:00:00Z", - summary: { - totalChecks: 2, - passed: 1, - warnings: 0, - failures: 1, - }, - sections: [ - { - key: "tenant_integrity", - label: "테넌트 정합성", - status: "fail", - checks: [ - { - key: "duplicate_tenant_slugs", - label: "중복 테넌트 slug", - description: - "active tenant slug의 대소문자 무시 중복을 검사합니다.", - status: "fail", - severity: "error", - count: 1, - }, - ], - }, - ], - })), + fetchDataIntegrityReport: vi.fn(async () => integrityReport), fetchOrphanUserLoginIDs: vi.fn(async () => ({ items: [ { @@ -123,6 +124,34 @@ describe("DataIntegrityPage", () => { ]); }); + it("disables recheck button and shows manual recheck progress", async () => { + let finishRecheck: (value: typeof integrityReport) => void = () => {}; + const pendingRecheck = new Promise((resolve) => { + finishRecheck = resolve; + }); + + renderPage(); + + expect(await screen.findByText("중복 테넌트 slug")).toBeInTheDocument(); + vi.mocked(fetchDataIntegrityReport).mockImplementationOnce( + () => pendingRecheck, + ); + + fireEvent.click(screen.getByRole("button", { name: "다시 검사" })); + + expect(screen.getByRole("button", { name: "검사 중" })).toBeDisabled(); + expect( + screen.getByText("정합성 검사를 실행 중입니다."), + ).toBeInTheDocument(); + + finishRecheck(integrityReport); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "다시 검사" })).toBeEnabled(); + }); + expect(screen.getByText("검사가 완료되었습니다.")).toBeInTheDocument(); + }); + it("blocks non-super admins", async () => { currentRole = "tenant_admin"; diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx index ff365f08..170637e3 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx @@ -77,6 +77,19 @@ function reasonLabel(reason: string) { } } +function recheckStatusText(status: "idle" | "running" | "success" | "error") { + switch (status) { + case "running": + return "정합성 검사를 실행 중입니다."; + case "success": + return "검사가 완료되었습니다."; + case "error": + return "검사에 실패했습니다."; + default: + return ""; + } +} + function OrphanLoginIDTable({ items, selectedIds, @@ -156,6 +169,9 @@ function OrphanLoginIDTable({ function DataIntegrityContent() { const queryClient = useQueryClient(); const [selectedOrphanIds, setSelectedOrphanIds] = useState([]); + const [recheckStatus, setRecheckStatus] = useState< + "idle" | "running" | "success" | "error" + >("idle"); const { data, isLoading, isError, error, refetch, isFetching } = useQuery({ queryKey: ["data-integrity-report"], queryFn: fetchDataIntegrityReport, @@ -194,6 +210,16 @@ function DataIntegrityContent() { deleteMutation.mutate(selectedOrphanIds); } }; + const isManualRechecking = recheckStatus === "running"; + const handleRecheck = async () => { + if (isManualRechecking) { + return; + } + setRecheckStatus("running"); + const result = await refetch(); + setRecheckStatus(result.isError ? "error" : "success"); + }; + const recheckMessage = recheckStatusText(recheckStatus); return (
@@ -204,15 +230,25 @@ function DataIntegrityContent() { 데이터 정합성 검증 - +
+ + {recheckMessage ? ( + + {recheckMessage} + + ) : null} +
{isError ? ( diff --git a/adminfront/tests/data_integrity.spec.ts b/adminfront/tests/data_integrity.spec.ts index 44ae06c2..d3cc0914 100644 --- a/adminfront/tests/data_integrity.spec.ts +++ b/adminfront/tests/data_integrity.spec.ts @@ -3,6 +3,7 @@ import { expect, test } from "@playwright/test"; test.describe("Data integrity management", () => { test.beforeEach(async ({ page }) => { let orphanLoginIDDeleted = false; + let integrityReportRequests = 0; await page.addInitScript(() => { window.localStorage.setItem("locale", "ko"); @@ -133,6 +134,10 @@ test.describe("Data integrity management", () => { return; } if (url.includes("/api/v1/admin/integrity")) { + integrityReportRequests += 1; + if (integrityReportRequests > 1) { + await new Promise((resolve) => setTimeout(resolve, 150)); + } await route.fulfill({ json: { status: "fail", @@ -184,6 +189,18 @@ test.describe("Data integrity management", () => { await expect(page.getByRole("button", { name: "다시 검사" })).toBeVisible(); }); + test("shows manual recheck progress and completion", async ({ page }) => { + await page.goto("/system/data-integrity"); + + await expect(page.getByText("중복 테넌트 slug")).toBeVisible(); + await page.getByRole("button", { name: "다시 검사" }).click(); + + await expect(page.getByRole("button", { name: "검사 중" })).toBeDisabled(); + await expect(page.getByText("정합성 검사를 실행 중입니다.")).toBeVisible(); + await expect(page.getByText("검사가 완료되었습니다.")).toBeVisible(); + await expect(page.getByRole("button", { name: "다시 검사" })).toBeEnabled(); + }); + test("deletes selected orphan login ID targets after confirmation", async ({ page, }) => { diff --git a/docs/data-integrity-management.md b/docs/data-integrity-management.md index eade096a..b0877611 100644 --- a/docs/data-integrity-management.md +++ b/docs/data-integrity-management.md @@ -46,6 +46,7 @@ Baron SSO의 신원/권한 SoT는 Ory Stack(Kratos, Keto, Hydra)입니다. 이 ## adminfront 동작 - `super_admin`은 사이드바의 `데이터 정합성` 메뉴에서 리포트를 볼 수 있습니다. +- `다시 검사` 실행 중에는 버튼이 비활성화되고 `검사 중` 상태가 표시됩니다. 요청이 끝나면 완료 또는 실패 상태 문구가 화면에 남습니다. - `super_admin`은 같은 메뉴에서 유령 로그인 ID 대상을 확인하고, 체크박스로 선택한 뒤 확인 대화상자를 거쳐 삭제할 수 있습니다. - `super_admin`은 adminfront 개요 화면 하단에서도 최종 검증 상태, 실패 건수, 검사 시각, 섹션별 상태 요약을 볼 수 있습니다. - `tenant_admin` 등 non-super role은 화면 접근 시 권한 없음 메시지만 봅니다.