Newer
Older
client_dwatch / app / api / settings / advice / __tests__ / route.test.ts
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の値が不正です");
  });
});