/**
* apiFetch の成功時・失敗時の挙動。
*
* 画面はここが投げる `ApiFetchError` の `serverMessage` を見て
* 「サーバー文言を出す / 画面既定文言を出す」を切り替えるため、
* その境界(ApiError として解釈できたか)をテストで固定する。
*/
import { afterEach, describe, expect, test, vi } from "vitest";
import { apiFetch, ApiFetchError } from "@/lib/api/client";
import type { ApiError, CleanLogItem, ListResponse } from "@/lib/types";
/** fetch の戻りを差し替える。json() の失敗も再現できるようにしてある */
function mockFetch(response: {
ok?: boolean;
status?: number;
json: () => unknown;
}): ReturnType<typeof vi.fn> {
const status = response.status ?? 200;
const fn = vi.fn(async () => ({
ok: response.ok ?? (status >= 200 && status < 300),
status,
json: async () => response.json(),
}));
vi.stubGlobal("fetch", fn);
return fn;
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("apiFetch 成功時", () => {
test("パース済みの本文を T として返す", async () => {
const body: ListResponse<CleanLogItem> = {
items: [],
meta: { total: 3, page: 1, per_page: 50 },
};
mockFetch({ status: 200, json: () => body });
const result = await apiFetch<ListResponse<CleanLogItem>>(
"/api/clean-logs?date_from=2026-08-01"
);
expect(result.meta.total).toBe(3);
expect(result.items).toEqual([]);
});
test("相対パスと init をそのまま fetch へ渡す", async () => {
const fn = mockFetch({ status: 200, json: () => ({ ok: true }) });
const init: RequestInit = { method: "PATCH", body: '{"rule":"R1"}' };
await apiFetch("/api/settings/advice", init);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn.mock.calls[0][0]).toBe("/api/settings/advice");
expect(fn.mock.calls[0][1]).toBe(init);
});
test("成功でも本文が JSON でなければ ApiFetchError(serverMessage は null)", async () => {
mockFetch({
status: 200,
json: () => {
throw new SyntaxError("Unexpected token <");
},
});
const error = await apiFetch("/api/clean-logs").catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiFetchError);
expect((error as ApiFetchError).status).toBe(200);
expect((error as ApiFetchError).serverMessage).toBeNull();
});
});
describe("apiFetch 失敗時(ApiError のパース)", () => {
test("ApiError を解釈して code / message / status を持つ", async () => {
const body: ApiError = {
error: {
code: "validation_failed",
message: "date_from〜date_toは731日以内で指定してください",
details: { date_from: ["範囲が広すぎます"] },
},
};
mockFetch({ status: 422, json: () => body });
const error = (await apiFetch("/api/clean-logs").catch(
(e: unknown) => e
)) as ApiFetchError;
expect(error).toBeInstanceOf(ApiFetchError);
expect(error.status).toBe(422);
expect(error.code).toBe("validation_failed");
expect(error.serverMessage).toBe(
"date_from〜date_toは731日以内で指定してください"
);
expect(error.details).toEqual({ date_from: ["範囲が広すぎます"] });
});
// ★元は 501 / `not_implemented` のケースだった★(エリア別完了率の仮実装 D-034)。
// ★D-119 で本実装になり、501 を返す実装は0件になった★ため、
// ★汎用の 5xx に置き換えてある(指示書AE 段階3-1 ③)★。
// ★テスト自体は消さないこと★ — 500 以外の 5xx でも `serverMessage` が取れることの検証は今も有効。
test("500 以外の 5xx でも定型メッセージが serverMessage で取れる", async () => {
const body: ApiError = {
error: {
code: "internal_error",
message: "サーバーエラーが発生しました",
details: null,
},
};
mockFetch({ status: 503, json: () => body });
const error = (await apiFetch("/api/clean-logs/1/areas").catch(
(e: unknown) => e
)) as ApiFetchError;
expect(error.status).toBe(503);
expect(error.serverMessage).toBe("サーバーエラーが発生しました");
});
test("ApiError の形をしていない本文は serverMessage / code が null", async () => {
mockFetch({ status: 500, json: () => ({ message: "oops" }) });
const error = (await apiFetch("/api/clean-logs").catch(
(e: unknown) => e
)) as ApiFetchError;
expect(error.status).toBe(500);
expect(error.code).toBeNull();
expect(error.serverMessage).toBeNull();
});
test("HTMLのエラーページ(JSON不正)でも serverMessage は null", async () => {
mockFetch({
status: 502,
json: () => {
throw new SyntaxError("Unexpected token <");
},
});
const error = (await apiFetch("/api/clean-logs").catch(
(e: unknown) => e
)) as ApiFetchError;
expect(error.status).toBe(502);
expect(error.serverMessage).toBeNull();
// 画面に "<!DOCTYPE html>..." を出さないための境界
expect(error.message).not.toContain("<");
});
test("code が無くても message は採用する(移行前の挙動を維持)", async () => {
mockFetch({ status: 500, json: () => ({ error: { message: "保存に失敗" } }) });
const error = (await apiFetch("/api/settings/advice").catch(
(e: unknown) => e
)) as ApiFetchError;
expect(error.serverMessage).toBe("保存に失敗");
expect(error.code).toBeNull();
});
test("error.message が文字列でなければ解釈しない", async () => {
mockFetch({
status: 500,
json: () => ({ error: { code: "internal_error", message: 123 } }),
});
const error = (await apiFetch("/api/clean-logs").catch(
(e: unknown) => e
)) as ApiFetchError;
expect(error.serverMessage).toBeNull();
expect(error.code).toBeNull();
});
test("ネットワーク到達不能は status 0 で包み、原因を cause に残す", async () => {
const cause = new TypeError("Failed to fetch");
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw cause;
})
);
const error = (await apiFetch("/api/clean-logs").catch(
(e: unknown) => e
)) as ApiFetchError;
expect(error).toBeInstanceOf(ApiFetchError);
expect(error.status).toBe(0);
expect(error.serverMessage).toBeNull();
expect(error.cause).toBe(cause);
});
});
describe("apiFetch は自アプリ専用", () => {
test("絶対URLは受け付けない", async () => {
const fn = mockFetch({ status: 200, json: () => ({}) });
await expect(apiFetch("https://example.com/api")).rejects.toThrow(
TypeError
);
await expect(apiFetch("//example.com/api")).rejects.toThrow(TypeError);
expect(fn).not.toHaveBeenCalled();
});
});