Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions admin/src/api/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import {
getApiErrors,
getApiErrorById,
patchApiErrorStatus,
getUsers,
patchUserRole,
suspendUser,
unsuspendUser,
getUserImpact,
deleteUser,
getAuditLogs,
type ApiErrorRow,
} from "./admin";

Expand Down Expand Up @@ -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");
});
});
137 changes: 137 additions & 0 deletions admin/src/pages/ai-models/useAiModelActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +417 to +418

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Initialize pending promises before awaiting them

In the admin CI path I checked, .github/workflows/ci.yml runs the admin production build, and admin/package.json defines that as tsc -b && vite build; because admin/tsconfig.json is strict and includes src, TypeScript cannot prove that first and second are assigned inside the act callback before these awaits, so this new test adds TS2454 errors and blocks the admin build/deploy. Initialize them with a definite value or use a non-null assertion after assignment.

Useful? React with 👍 / 👎.

});
});
});
Loading
Loading