diff --git a/admin/src/api/admin.test.ts b/admin/src/api/admin.test.ts index a0f243c9..cdfb6783 100644 --- a/admin/src/api/admin.test.ts +++ b/admin/src/api/admin.test.ts @@ -12,6 +12,13 @@ import { getApiErrors, getApiErrorById, patchApiErrorStatus, + getUsers, + patchUserRole, + suspendUser, + unsuspendUser, + getUserImpact, + deleteUser, + getAuditLogs, type ApiErrorRow, } from "./admin"; @@ -330,3 +337,228 @@ describe("patchApiErrorStatus", () => { ); }); }); + +const sampleUser = { + id: "user-1", + email: "user@example.com", + name: "Test User", + role: "user" as const, + status: "active" as const, + suspendedAt: null, + suspendedReason: null, + suspendedBy: null, + createdAt: "2026-01-01T00:00:00Z", + pageCount: 3, +}; + +describe("getUsers", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("200 なら users と total を返す", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ users: [sampleUser], total: 1 }), { status: 200 }), + ); + const result = await getUsers({ search: "test", status: "active", limit: 10, offset: 0 }); + expect(result.users).toEqual([sampleUser]); + expect(result.total).toBe(1); + expect(adminFetch).toHaveBeenCalledWith( + "/api/admin/users?search=test&status=active&limit=10&offset=0", + ); + }); + + it("!res.ok なら throw する", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "users failed" }), { status: 500 }), + ); + await expect(getUsers()).rejects.toThrow("users failed"); + }); +}); + +describe("patchUserRole", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("200 なら更新後 user を返す", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ user: { ...sampleUser, role: "admin" } }), { status: 200 }), + ); + const result = await patchUserRole("user-1", "admin"); + expect(result.user.role).toBe("admin"); + expect(adminFetch).toHaveBeenCalledWith( + "/api/admin/users/user-1", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ role: "admin" }), + }), + ); + }); + + it("!res.ok なら throw する", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "role failed" }), { status: 400 }), + ); + await expect(patchUserRole("user-1", "admin")).rejects.toThrow("role failed"); + }); +}); + +describe("suspendUser", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("200 なら suspended user を返す", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ user: { ...sampleUser, status: "suspended", suspendedReason: "spam" } }), + { status: 200 }, + ), + ); + const result = await suspendUser("user-1", "spam"); + expect(result.user.status).toBe("suspended"); + expect(adminFetch).toHaveBeenCalledWith( + "/api/admin/users/user-1/suspend", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ reason: "spam" }), + }), + ); + }); + + it("!res.ok なら throw する", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "suspend failed" }), { status: 500 }), + ); + await expect(suspendUser("user-1")).rejects.toThrow("suspend failed"); + }); +}); + +describe("unsuspendUser", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("200 なら active user を返す", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ user: sampleUser }), { status: 200 }), + ); + const result = await unsuspendUser("user-1"); + expect(result.user.status).toBe("active"); + expect(adminFetch).toHaveBeenCalledWith("/api/admin/users/user-1/unsuspend", { + method: "POST", + }); + }); + + it("!res.ok なら throw する", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "unsuspend failed" }), { status: 500 }), + ); + await expect(unsuspendUser("user-1")).rejects.toThrow("unsuspend failed"); + }); +}); + +describe("getUserImpact", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("200 なら impact を返す", async () => { + const impact = { + notesCount: 2, + sessionsCount: 1, + activeSubscription: true, + lastAiUsageAt: "2026-01-01T00:00:00Z", + }; + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify(impact), { status: 200 }), + ); + const result = await getUserImpact("user-1"); + expect(result).toEqual(impact); + expect(adminFetch).toHaveBeenCalledWith("/api/admin/users/user-1/impact"); + }); + + it("!res.ok なら throw する", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "impact failed" }), { status: 500 }), + ); + await expect(getUserImpact("user-1")).rejects.toThrow("impact failed"); + }); +}); + +describe("deleteUser", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("200 なら deleted user を返す", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ user: { ...sampleUser, status: "deleted" } }), { + status: 200, + }), + ); + const result = await deleteUser("user-1"); + expect(result.user.status).toBe("deleted"); + expect(adminFetch).toHaveBeenCalledWith("/api/admin/users/user-1", { method: "DELETE" }); + }); + + it("!res.ok なら throw する", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "delete failed" }), { status: 500 }), + ); + await expect(deleteUser("user-1")).rejects.toThrow("delete failed"); + }); +}); + +describe("getAuditLogs", () => { + beforeEach(() => { + vi.mocked(adminFetch).mockReset(); + }); + + it("200 なら logs と total を返す", async () => { + const logs = [ + { + id: "log-1", + actorUserId: "admin-1", + actorEmail: "admin@example.com", + actorName: "Admin", + action: "user.role.update", + targetType: "user", + targetId: "user-1", + targetEmail: "user@example.com", + targetName: "User", + before: { role: "user" }, + after: { role: "admin" }, + ipAddress: null, + userAgent: null, + createdAt: "2026-01-01T00:00:00Z", + }, + ]; + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ logs, total: 1 }), { status: 200 }), + ); + const result = await getAuditLogs({ + actorUserId: "admin-1", + action: "user.role.update", + targetType: "user", + targetId: "user-1", + from: "2026-01-01T00:00:00Z", + to: "2026-01-02T00:00:00Z", + limit: 20, + offset: 0, + }); + expect(result.logs).toEqual(logs); + expect(result.total).toBe(1); + expect(adminFetch).toHaveBeenCalledWith( + "/api/admin/audit-logs?actorUserId=admin-1&action=user.role.update&targetType=user&targetId=user-1&from=2026-01-01T00%3A00%3A00Z&to=2026-01-02T00%3A00%3A00Z&limit=20&offset=0", + ); + }); + + it("!res.ok なら throw する", async () => { + vi.mocked(adminFetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: "audit failed" }), { status: 500 }), + ); + await expect(getAuditLogs()).rejects.toThrow("audit failed"); + }); +}); diff --git a/admin/src/pages/ai-models/useAiModelActions.test.ts b/admin/src/pages/ai-models/useAiModelActions.test.ts index 623320fa..185ba504 100644 --- a/admin/src/pages/ai-models/useAiModelActions.test.ts +++ b/admin/src/pages/ai-models/useAiModelActions.test.ts @@ -282,3 +282,140 @@ describe("useAiModelActions.handleTierChange", () => { expect(patchAiModel).toHaveBeenCalledWith(baseModel.id, { tierRequired: "free" }); }); }); + +describe("useAiModelActions.handleSetSystemDefault", () => { + beforeEach(() => { + vi.mocked(patchAiModel).mockReset(); + }); + + it("成功時: 対象モデルを system default にし originalModelsRef を更新する / sets system default on success", async () => { + vi.mocked(patchAiModel).mockResolvedValueOnce({ ...baseModel, isSystemDefault: true }); + const otherModel: AiModelAdmin = { + ...baseModel, + id: "openai:gpt-4o", + modelId: "gpt-4o", + displayName: "GPT-4o", + isSystemDefault: true, + }; + const refs = createRefs([otherModel, baseModel]); + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + await act(async () => { + await result.current.handleSetSystemDefault(baseModel); + }); + + expect(patchAiModel).toHaveBeenCalledWith(baseModel.id, { isSystemDefault: true }); + expect(refs.models.find((m) => m.id === baseModel.id)?.isSystemDefault).toBe(true); + expect(refs.models.find((m) => m.id === otherModel.id)?.isSystemDefault).toBe(false); + expect(refs.originalModelsRef.current.find((m) => m.id === baseModel.id)?.isSystemDefault).toBe( + true, + ); + }); + + it("失敗時: originalModelsRef へ rollback し setError する / rollbacks on failure", async () => { + vi.mocked(patchAiModel).mockRejectedValueOnce(new Error("default failed")); + const otherModel: AiModelAdmin = { + ...baseModel, + id: "openai:gpt-4o", + modelId: "gpt-4o", + displayName: "GPT-4o", + isSystemDefault: true, + }; + const refs = createRefs([otherModel, baseModel]); + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + await act(async () => { + await result.current.handleSetSystemDefault(baseModel); + }); + + expect(refs.models.find((m) => m.id === otherModel.id)?.isSystemDefault).toBe(true); + expect(refs.models.find((m) => m.id === baseModel.id)?.isSystemDefault).toBe(false); + expect(refs.setError).toHaveBeenLastCalledWith("default failed"); + }); + + it("既に system default なら no-op / skips when already default", async () => { + const defaultModel = { ...baseModel, isSystemDefault: true }; + const refs = createRefs([defaultModel]); + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + await act(async () => { + await result.current.handleSetSystemDefault(defaultModel); + }); + + expect(patchAiModel).not.toHaveBeenCalled(); + expect(refs.setModels).not.toHaveBeenCalled(); + }); + + it("非アクティブモデルなら no-op / skips when model is inactive", async () => { + const inactiveModel = { ...baseModel, isActive: false }; + const refs = createRefs([inactiveModel]); + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + await act(async () => { + await result.current.handleSetSystemDefault(inactiveModel); + }); + + expect(patchAiModel).not.toHaveBeenCalled(); + }); + + it("設定中は二重呼び出しを無視する / ignores concurrent calls while setting", async () => { + let resolvePatch: (() => void) | null = null; + vi.mocked(patchAiModel).mockReturnValueOnce( + new Promise((resolve) => { + resolvePatch = () => resolve({ ...baseModel, isSystemDefault: true }); + }) as never, + ); + const refs = createRefs([baseModel]); + const { result } = renderHook(() => + useAiModelActions({ + setModels: refs.setModels as never, + setError: refs.setError as never, + isMountedRef: refs.isMountedRef as never, + originalModelsRef: refs.originalModelsRef as never, + }), + ); + + let first = Promise.resolve(); + let second = Promise.resolve(); + await act(async () => { + first = result.current.handleSetSystemDefault(baseModel); + second = result.current.handleSetSystemDefault(baseModel); + }); + + expect(patchAiModel).toHaveBeenCalledTimes(1); + + await act(async () => { + resolvePatch?.(); + await first; + await second; + }); + }); +}); diff --git a/admin/src/pages/errors/ErrorDetailDialog.test.tsx b/admin/src/pages/errors/ErrorDetailDialog.test.tsx index ecb6ce18..e913adbc 100644 --- a/admin/src/pages/errors/ErrorDetailDialog.test.tsx +++ b/admin/src/pages/errors/ErrorDetailDialog.test.tsx @@ -149,4 +149,93 @@ describe("ErrorDetailDialog", () => { expect(screen.getByTestId("status-select")).toHaveValue("open"); }); + + it("calls onUpdateStatus when save is clicked after changing status", async () => { + const onUpdateStatus = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + await userEvent.selectOptions(screen.getByTestId("status-select"), "resolved"); + await userEvent.click(screen.getByRole("button", { name: "保存" })); + + expect(onUpdateStatus).toHaveBeenCalledWith(baseRow.id, "resolved"); + }); + + it("does not call onUpdateStatus when save is clicked without changes", async () => { + const onUpdateStatus = vi.fn(); + render( + , + ); + + expect(screen.getByRole("button", { name: "保存" })).toBeDisabled(); + await userEvent.click(screen.getByRole("button", { name: "保存" })); + expect(onUpdateStatus).not.toHaveBeenCalled(); + }); + + it("displays saveError in an alert region", () => { + render( + , + ); + expect(screen.getByRole("alert")).toHaveTextContent("save failed"); + }); + + it("renders AI analysis sections when present", () => { + render( + , + ); + + expect(screen.getByText("AI による要約")).toBeInTheDocument(); + expect(screen.getByText("Summary text")).toBeInTheDocument(); + expect(screen.getByText("AI による原因仮説")).toBeInTheDocument(); + expect(screen.getByText("Root cause text")).toBeInTheDocument(); + expect(screen.getByText("AI による修正方針")).toBeInTheDocument(); + expect(screen.getByText("Fix text")).toBeInTheDocument(); + expect(screen.getByText("関連が疑われるファイル")).toBeInTheDocument(); + expect(screen.getByText(/src\/foo\.ts/)).toBeInTheDocument(); + expect(screen.getByText(/null ref/)).toBeInTheDocument(); + }); + + it("renders linked GitHub issue number when present", () => { + render( + , + ); + expect(screen.getByText("関連 GitHub Issue: #1234")).toBeInTheDocument(); + }); }); diff --git a/admin/src/pages/errors/ErrorsContent.test.tsx b/admin/src/pages/errors/ErrorsContent.test.tsx index d057eca5..1cf6d420 100644 --- a/admin/src/pages/errors/ErrorsContent.test.tsx +++ b/admin/src/pages/errors/ErrorsContent.test.tsx @@ -1,10 +1,13 @@ import React from "react"; -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ErrorsContent } from "./ErrorsContent"; import type { ApiErrorRow } from "@/api/admin"; +const selectCallbacks: Map void> = new Map(); +let selectCount = 0; + vi.mock("@zedi/ui", () => ({ Badge: ({ children }: { children: React.ReactNode }) => ( {children} @@ -22,7 +25,22 @@ vi.mock("@zedi/ui", () => ({ {children} ), - Select: ({ children }: { children: React.ReactNode }) =>
{children}
, + Select: ({ + children, + value, + onValueChange, + }: { + children: React.ReactNode; + value?: string; + onValueChange?: (v: string) => void; + }) => { + if (onValueChange) { + const key = selectCount === 0 ? "status" : "severity"; + selectCallbacks.set(key, onValueChange); + selectCount++; + } + return
{children}
; + }, SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => ( @@ -77,6 +95,11 @@ const defaultProps = { }; describe("ErrorsContent", () => { + beforeEach(() => { + selectCallbacks.clear(); + selectCount = 0; + }); + it("renders the page title", () => { render(); expect(screen.getByRole("heading", { name: "API エラー" })).toBeInTheDocument(); @@ -114,4 +137,72 @@ describe("ErrorsContent", () => { render(); expect(screen.getByRole("alert")).toHaveTextContent("server is down"); }); + + it("calls onStatusFilterChange when status filter changes", () => { + const onStatusFilterChange = vi.fn(); + render( + , + ); + + const statusCallback = selectCallbacks.get("status"); + expect(statusCallback).toBeDefined(); + React.act(() => { + statusCallback?.("open"); + }); + expect(onStatusFilterChange).toHaveBeenCalledWith("open"); + }); + + it("calls onSeverityFilterChange when severity filter changes", () => { + const onSeverityFilterChange = vi.fn(); + render( + , + ); + + const severityCallback = selectCallbacks.get("severity"); + expect(severityCallback).toBeDefined(); + React.act(() => { + severityCallback?.("high"); + }); + expect(onSeverityFilterChange).toHaveBeenCalledWith("high"); + }); + + it("displays total count below the table", () => { + render(); + expect(screen.getByText("合計 42 件")).toBeInTheDocument(); + }); + + it.each([ + ["open", "未対応"], + ["investigating", "調査中"], + ["resolved", "解決済み"], + ["ignored", "無視"], + ] as const)("renders status badge for %s", (status, label) => { + render( + , + ); + expect(within(screen.getByRole("table")).getByText(label)).toBeInTheDocument(); + }); + + it.each([ + ["high", "高"], + ["medium", "中"], + ["low", "低"], + ["unknown", "未判定"], + ] as const)("renders severity badge for %s", (severity, label) => { + render( + , + ); + expect(within(screen.getByRole("table")).getByText(label)).toBeInTheDocument(); + }); }); diff --git a/admin/src/pages/errors/useApiErrors.test.ts b/admin/src/pages/errors/useApiErrors.test.ts index 0eafe740..bd81eaba 100644 --- a/admin/src/pages/errors/useApiErrors.test.ts +++ b/admin/src/pages/errors/useApiErrors.test.ts @@ -12,7 +12,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, renderHook, waitFor } from "@testing-library/react"; -import type { ApiErrorRow, GetApiErrorsResponse } from "@/api/admin"; +import type { ApiErrorRow, ApiErrorStatus, GetApiErrorsResponse } from "@/api/admin"; // `getApiErrors` を mock してネットワーク呼び出しを避ける。 // Mock the REST helper so the hook's bootstrap fetch returns a fixed payload. @@ -243,4 +243,117 @@ describe("useApiErrors", () => { await waitFor(() => expect(getApiErrors).toHaveBeenCalled()); expect(lastInstance).toBeNull(); }); + + it("surfaces REST load failure as error", async () => { + vi.mocked(getApiErrors).mockRejectedValueOnce(new Error("REST failed")); + const { result } = renderHook(() => useApiErrors({ intervalMs: 0 })); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe("REST failed"); + expect(result.current.errors).toEqual([]); + }); + + it("refetch() triggers another getApiErrors call", async () => { + const { result } = renderHook(() => useApiErrors({ intervalMs: 0, enableStream: false })); + await waitFor(() => expect(result.current.loading).toBe(false)); + const callsBefore = vi.mocked(getApiErrors).mock.calls.length; + + await act(async () => { + await result.current.refetch(); + }); + + expect(vi.mocked(getApiErrors).mock.calls.length).toBe(callsBefore + 1); + }); + + it("ignores SSE update events with invalid JSON", async () => { + const { result } = renderHook(() => useApiErrors({ intervalMs: 0 })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + getInstance().dispatch("ready", ""); + getInstance().dispatch("update", "not-json"); + }); + + expect(result.current.errors).toHaveLength(1); + expect(result.current.errors[0]?.title).toBe("old error"); + expect(result.current.total).toBe(1); + }); + + it("drops an existing row via dropRowById when SSE update no longer matches filter", async () => { + const { result } = renderHook(() => useApiErrors({ status: "open", intervalMs: 0 })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + getInstance().dispatch("ready", ""); + getInstance().dispatch("update", { + ...getBaseRow(), + status: "resolved", + }); + }); + + expect(result.current.errors).toHaveLength(0); + expect(result.current.total).toBe(0); + }); + + it("polls via fallback interval when stream is disabled", async () => { + vi.useFakeTimers(); + try { + const { unmount } = renderHook(() => useApiErrors({ intervalMs: 1000, enableStream: false })); + await act(async () => { + await Promise.resolve(); + }); + const callsBefore = vi.mocked(getApiErrors).mock.calls.length; + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await Promise.resolve(); + }); + + expect(vi.mocked(getApiErrors).mock.calls.length).toBeGreaterThan(callsBefore); + unmount(); + } finally { + vi.useRealTimers(); + } + }); + + it("ignores stale REST responses when a newer request finishes first", async () => { + const firstResponse: GetApiErrorsResponse = { + errors: [getBaseRow()], + total: 1, + limit: 50, + offset: 0, + }; + const secondResponse: GetApiErrorsResponse = { + errors: [{ ...getBaseRow(), id: "00000000-0000-0000-0000-0000000000bb", title: "fresh" }], + total: 1, + limit: 50, + offset: 0, + }; + + const pendingResolvers: Array<(value: GetApiErrorsResponse) => void> = []; + vi.mocked(getApiErrors).mockImplementation((params) => { + if (params?.status === "open") { + return new Promise((resolve) => { + pendingResolvers.push(resolve); + }); + } + return Promise.resolve(secondResponse); + }); + + const { result, rerender } = renderHook( + ({ status }: { status?: ApiErrorStatus }) => + useApiErrors({ status, intervalMs: 0, enableStream: false }), + { initialProps: { status: "open" as ApiErrorStatus } }, + ); + + rerender({ status: "resolved" }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.errors[0]?.title).toBe("fresh"); + + await act(async () => { + pendingResolvers.forEach((resolve) => resolve(firstResponse)); + await Promise.resolve(); + }); + + expect(result.current.errors[0]?.title).toBe("fresh"); + }); }); diff --git a/admin/src/pages/users/UsersContent.test.tsx b/admin/src/pages/users/UsersContent.test.tsx index b37d51ed..ece66d5b 100644 --- a/admin/src/pages/users/UsersContent.test.tsx +++ b/admin/src/pages/users/UsersContent.test.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { UsersContent } from "./UsersContent"; import type { UserAdmin } from "@/api/admin"; @@ -76,10 +76,12 @@ vi.mock("@/components/ConfirmActionDialog", () => ({ open, title, confirmLabel, + onOpenChange, }: { open: boolean; title: string; confirmLabel?: string; + onOpenChange?: (open: boolean) => void; }) => { if (!open) return null; return ( @@ -88,6 +90,13 @@ vi.mock("@/components/ConfirmActionDialog", () => ({ + ); }, @@ -106,7 +115,7 @@ const mockCancelDelete = vi.fn(); let hookRoleChangeTarget: { user: UserAdmin; newRole: string } | null = null; let hookUnsuspendTarget: UserAdmin | null = null; -const hookDeleteTarget: { user: UserAdmin; impact: null; loadingImpact: boolean } | null = null; +let hookDeleteTarget: { user: UserAdmin; impact: null; loadingImpact: boolean } | null = null; vi.mock("./useConfirmDialogs", () => ({ useConfirmDialogs: () => ({ @@ -168,6 +177,51 @@ const defaultProps = { }; describe("UsersContent", () => { + beforeEach(() => { + selectCallbacks.clear(); + hookRoleChangeTarget = null; + hookUnsuspendTarget = null; + hookDeleteTarget = null; + mockRequestRoleChange.mockClear(); + mockConfirmRoleChange.mockClear(); + mockCancelRoleChange.mockClear(); + mockRequestUnsuspend.mockClear(); + mockConfirmUnsuspend.mockClear(); + mockCancelUnsuspend.mockClear(); + mockRequestDelete.mockClear(); + mockConfirmDelete.mockClear(); + mockCancelDelete.mockClear(); + }); + + it("shows error banner when error prop is set", () => { + render(); + expect(screen.getByText("something went wrong")).toBeInTheDocument(); + }); + + it("shows loading state when loading and no users yet", () => { + render(); + expect(screen.getByText("読み込み中...")).toBeInTheDocument(); + }); + + it("calls onSearchChange when search input changes", async () => { + const onSearchChange = vi.fn(); + render(); + + await userEvent.type(screen.getByLabelText("メールで検索"), "a"); + expect(onSearchChange).toHaveBeenCalled(); + }); + + it("calls onStatusFilterChange when status filter changes", () => { + const onStatusFilterChange = vi.fn(); + render(); + + const statusCallback = selectCallbacks.get("all"); + React.act(() => { + statusCallback?.("suspended"); + }); + expect(onStatusFilterChange).toHaveBeenCalledWith("suspended"); + }); + it("shows range and total when users are loaded", () => { render(); @@ -262,6 +316,76 @@ describe("UsersContent", () => { }); }); + describe("ステータスバッジと操作ボタン / Status badges and action buttons", () => { + it("shows suspended status badge", () => { + const suspendedUser: UserAdmin = { + ...mockUser, + status: "suspended", + suspendedAt: "2026-01-01T00:00:00Z", + suspendedReason: "short reason", + suspendedBy: "admin-1", + }; + render(); + expect(within(screen.getByRole("table")).getByText("suspended")).toBeInTheDocument(); + }); + + it("shows deleted status badge and deleted state text", () => { + const deletedUser: UserAdmin = { + ...mockUser, + status: "deleted", + }; + render(); + expect(within(screen.getByRole("table")).getByText("deleted")).toBeInTheDocument(); + expect(screen.getByText("削除済み")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "サスペンド" })).not.toBeInTheDocument(); + }); + + it("truncates suspended reason longer than 20 characters", () => { + const longReason = "abcdefghijklmnopqrstuvwxyz"; + const suspendedUser: UserAdmin = { + ...mockUser, + status: "suspended", + suspendedAt: "2026-01-01T00:00:00Z", + suspendedReason: longReason, + suspendedBy: "admin-1", + }; + render(); + expect(screen.getByText("(abcdefghijklmnopqrst...)")).toBeInTheDocument(); + }); + + it("shows suspend and delete buttons for active users", () => { + render(); + expect(screen.getByRole("button", { name: "サスペンド" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "削除" })).toBeInTheDocument(); + }); + + it("calls requestDelete when delete button is clicked", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "削除" })); + expect(mockRequestDelete).toHaveBeenCalledWith(mockUser); + }); + + it("shows saving state instead of action buttons", () => { + render(); + expect(screen.getByText("保存中...")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "サスペンド" })).not.toBeInTheDocument(); + }); + + it("shows delete confirmation dialog when deleteTarget is set", () => { + hookDeleteTarget = { user: mockUser, impact: null, loadingImpact: false }; + render(); + expect(screen.getByTestId("confirm-dialog-ユーザーを削除")).toBeInTheDocument(); + }); + + it("calls cancelRoleChange when role dialog is closed via onOpenChange", async () => { + hookRoleChangeTarget = { user: mockUser, newRole: "admin" }; + render(); + + await userEvent.click(screen.getByTestId("cancel-dialog-ロールを変更")); + expect(mockCancelRoleChange).toHaveBeenCalled(); + }); + }); + describe("サスペンド解除確認ダイアログ / Unsuspend confirmation", () => { const suspendedUser: UserAdmin = { ...mockUser, diff --git a/admin/src/pages/users/index.test.tsx b/admin/src/pages/users/index.test.tsx index dcefbecf..993cdda2 100644 --- a/admin/src/pages/users/index.test.tsx +++ b/admin/src/pages/users/index.test.tsx @@ -1,83 +1,140 @@ import React from "react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, within, act, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import Users from "./index"; import * as adminApi from "@/api/admin"; +import type { UserAdmin } from "@/api/admin"; -vi.mock("@zedi/ui", () => ({ - Button: ({ - children, - onClick, - disabled, - ...props - }: { - children: React.ReactNode; - onClick?: () => void; - disabled?: boolean; - }) => ( - - ), - Input: (props: React.InputHTMLAttributes) => , - Select: ({ children }: { children: React.ReactNode }) =>
{children}
, - SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => ( - - ), - SelectTrigger: ({ children, ...props }: { children: React.ReactNode }) => ( - {children} - ), - SelectValue: () => null, - Table: ({ children }: { children: React.ReactNode }) => {children}
, - TableBody: ({ children }: { children: React.ReactNode }) => {children}, - TableCell: ({ children }: { children: React.ReactNode }) => {children}, - TableHead: ({ children }: { children: React.ReactNode }) => {children}, - TableHeader: ({ children }: { children: React.ReactNode }) => {children}, - TableRow: ({ children }: { children: React.ReactNode }) => {children}, +const selectCallbacks: Map void> = new Map(); + +vi.mock("@zedi/ui", () => { + const AlertDialogContext = React.createContext<{ onOpenChange: (open: boolean) => void } | null>( + null, + ); + + return { + AlertDialog: ({ + open, + onOpenChange, + children, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + }) => + open ? ( + +
{children}
+
+ ) : null, + AlertDialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, + AlertDialogDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + AlertDialogCancel: ({ + children, + disabled, + }: { + children: React.ReactNode; + disabled?: boolean; + }) => ( + + ), + AlertDialogAction: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode; + onClick?: (e: React.MouseEvent) => void; + disabled?: boolean; + }) => { + const dialog = React.useContext(AlertDialogContext); + return ( + + ); + }, + Badge: ({ children }: { children: React.ReactNode }) => {children}, + Button: ({ + children, + onClick, + disabled, + ...props + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + }) => ( + + ), + Input: (props: React.InputHTMLAttributes) => , + Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) => ( + + ), + Textarea: (props: React.TextareaHTMLAttributes) =>