import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts` の冒頭コメントと同じ理由)
import { GET, PATCH } from "@/app/api/settings/advice/route";
import { logApiError } from "@/lib/logger/api";
import { __setAdviceStoreForTesting, createAdviceStore } from "@/lib/settings/advice";
import type { AdviceOverrides } from "@/lib/settings/advice";
import type { JsonStore } from "@/lib/settings/json-store";
/**
* `GET / PATCH /settings/advice` の頑丈さ(指示書BB 表8 T1・H1〜H3・J1〜J3。A5・A10・D-182)。
*
* | # | 入力 | 期待 |
* |---|---|---|
* | T1 | GET で `loadAdviceRules()` が例外 | 500・internal_error・「データの取得に失敗しました」・`api.error` が1件 |
* | H1 | PATCH `rule: "constructor"` | 422「ruleの値が不正です」 |
* | H2 | `"toString"` / `"__proto__"` / `"hasOwnProperty"` | 422 |
* | H3 | `"R1"` | 今どおり(200) |
* | J1 | 本文が `null` | 422・validation_failed・「JSONの形式が不正です」 |
* | J2 | 本文が `[]` / `1` / `"x"` | 422(J1 と同じ) |
* | J3 | 本文が `{}` | 今どおり(422「ruleの値が不正です」) |
*
* ★保存先は `fs.mkdtemp` の一時ディレクトリ★(実ファイル `data/settings/advice.json` を汚さない)
*/
vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));
let dir = "";
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-advice-route-"));
__setAdviceStoreForTesting(createAdviceStore(path.join(dir, "advice.json"), () => {}));
vi.mocked(logApiError).mockClear();
});
afterEach(async () => {
__setAdviceStoreForTesting(null);
await fs.rm(dir, { recursive: true, force: true });
});
function patchRaw(raw: string): Promise<Response> {
return PATCH(new Request("http://localhost/api/settings/advice", { method: "PATCH", body: raw }));
}
describe("GET /settings/advice — try の外の例外(指示書BB 表8 T1)", () => {
it("★T1: 読み込みが例外 → 500・標準の形・api.error が1件★", async () => {
const broken: JsonStore<AdviceOverrides> = {
read: async () => {
throw new Error("[stub] 読み込みに失敗");
},
write: async () => {},
};
__setAdviceStoreForTesting(broken);
const res = await GET(new Request("http://localhost/api/settings/advice"));
expect(res.status).toBe(500);
expect(await res.json()).toEqual({
error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
});
expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1);
expect(vi.mocked(logApiError).mock.calls[0][0]).toMatchObject({ route: "/api/settings/advice", method: "GET", status: 500 });
});
it("読み込みが成功すれば今どおり 200", async () => {
const res = await GET(new Request("http://localhost/api/settings/advice"));
expect(res.status).toBe(200);
});
});
describe("PATCH /settings/advice — rule の検証(指示書BB 表8 H1〜H3。A10)", () => {
it.each(["constructor", "toString", "__proto__", "hasOwnProperty"])(
"★H1・H2: rule=%s → 422「ruleの値が不正です」★(`Object.prototype` のキーを通さない)",
async (rule) => {
const res = await patchRaw(JSON.stringify({ rule, title: "x" }));
expect(res.status).toBe(422);
expect(await res.json()).toEqual({
error: { code: "validation_failed", message: "ruleの値が不正です", details: null },
});
}
);
it("H3: rule=R1 → 今どおり 200", async () => {
const res = await patchRaw(JSON.stringify({ rule: "R1", title: "出発失敗の確認" }));
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ rule: "R1", title: "出発失敗の確認" });
});
});
describe("PATCH /settings/advice — 本文がオブジェクトでない(指示書BB 表8 J1〜J3。A5・D19)", () => {
it.each(["null", "[]", "1", '"x"'])("★J1・J2: 本文 %s → 422「JSONの形式が不正です」★", async (raw) => {
const res = await patchRaw(raw);
expect(res.status).toBe(422);
expect(await res.json()).toEqual({
error: { code: "validation_failed", message: "JSONの形式が不正です", details: null },
});
});
it("J3: 本文 {} → 今どおり(項目の検証で422「ruleの値が不正です」)", async () => {
const res = await patchRaw("{}");
expect(res.status).toBe(422);
expect(((await res.json()) as { error: { message: string } }).error.message).toBe("ruleの値が不正です");
});
});