Newer
Older
client_dwatch / lib / settings / __tests__ / advice.test.ts
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
  DEFAULT_ADVICE_RULES,
  type AdviceRuleId,
} from "@/lib/config/advice-rules";
import {
  ADVICE_ACTION_MAX_LENGTH,
  ADVICE_ACTIONS_MAX_ITEMS,
  ADVICE_TEXT_MAX_LENGTH,
  ADVICE_TITLE_MAX_LENGTH,
} from "@/lib/config";
import {
  __setAdviceStoreForTesting,
  createAdviceStore,
  loadAdviceRules,
  resetAdviceRule,
  saveAdviceRule,
} from "@/lib/settings/advice";
import {
  isStringArray,
  validateAdviceFields,
} from "@/lib/settings/advice-validation";

/**
 * アドバイス文言の運用設定(`lib/settings/advice.ts`)の期待値表テスト。
 *
 * ★fs はモックしません★(`json-store.test.ts` と同じ方針)。
 * 守りたい性質が「壊れたファイルの中身が本当に `.bak` に残るか」といった
 * ファイルシステムそのものの挙動だからです。各テストは `fs.mkdtemp` で作った
 * 独立した一時ディレクトリを使い、afterEach で必ず消します。
 *
 * ★`@/lib/logger` はモックします★。
 * `lib/logger/index.ts` は `@/lib/db` 経由で fixtures 全体を引き込むうえ、
 * ここで確かめたいのは「破損時に `settings.store_corrupt` を warn で1行出す」という
 * 配線だけで、ロガー本体の挙動は `lib/logger/__tests__/` の担当だからです。
 */
const logEventMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/logger", () => ({ logEvent: logEventMock }));

/** 破損させたファイルの中身。`.bak` に原文が残ることの確認に使い回す */
const BROKEN_JSON = '{ "R1": { "title": "途中で切れた"';

let dir = "";
let filePath = "";

beforeEach(async () => {
  dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-"));
  filePath = path.join(dir, "advice.json");
  // 実ファイル(data/settings/advice.json)を汚さないよう、必ず一時パスへ差し替える
  __setAdviceStoreForTesting(createAdviceStore(filePath));
  logEventMock.mockClear();
});

afterEach(async () => {
  __setAdviceStoreForTesting(null);
  await fs.rm(dir, { recursive: true, force: true });
});

async function readSavedJson(): Promise<Record<string, unknown>> {
  return JSON.parse(await fs.readFile(filePath, "utf-8")) as Record<
    string,
    unknown
  >;
}

async function findBackupPath(): Promise<string> {
  const names = await fs.readdir(dir);
  const backup = names.find((n) => n.endsWith(".bak"));
  expect(backup).toBeDefined();
  return path.join(dir, backup as string);
}

describe("loadAdviceRules()", () => {
  test("未編集なら既定値(DEFAULT_ADVICE_RULES)がそのまま返る", async () => {
    const rules = await loadAdviceRules();

    // 既定値を後から改善したとき、編集していないルールへ自動で反映されることの土台
    expect(rules).toEqual(DEFAULT_ADVICE_RULES);
    // 読んだだけでファイルを作らない(未編集は正常な状態)
    expect(await fs.readdir(dir)).toEqual([]);
  });
});

describe("saveAdviceRule()", () => {
  test("1件保存すると、その1件だけが反映され他は既定値のまま", async () => {
    const saved = await saveAdviceRule("R1", { title: "出発失敗(編集後)" });

    // 戻り値は既定値に編集内容を重ねた完成形
    expect(saved).toEqual({
      ...DEFAULT_ADVICE_RULES.R1,
      title: "出発失敗(編集後)",
    });

    const rules = await loadAdviceRules();
    expect(rules.R1.title).toBe("出発失敗(編集後)");
    // 編集していないフィールドは既定値のまま(部分編集が全体を置き換えない)
    expect(rules.R1.text).toBe(DEFAULT_ADVICE_RULES.R1.text);
    expect(rules.R1.actions).toEqual(DEFAULT_ADVICE_RULES.R1.actions);
    // level / icon / condition は編集不可。保存を通しても変わらない
    expect(rules.R1.level).toBe(DEFAULT_ADVICE_RULES.R1.level);
    expect(rules.R1.icon).toBe(DEFAULT_ADVICE_RULES.R1.icon);
    expect(rules.R1.condition).toBe(DEFAULT_ADVICE_RULES.R1.condition);

    // 他のルールは1つも触られていない
    (Object.keys(DEFAULT_ADVICE_RULES) as AdviceRuleId[])
      .filter((id) => id !== "R1")
      .forEach((id) => {
        expect(rules[id]).toEqual(DEFAULT_ADVICE_RULES[id]);
      });

    // ファイルに入るのは編集された項目だけ(既定値をコピーして持たない)
    expect(await readSavedJson()).toEqual({
      R1: { title: "出発失敗(編集後)" },
    });
  });

  test("2件保存したあと1件だけ上書きしても、もう1件が消えない", async () => {
    await saveAdviceRule("R1", { title: "R1・1回目" });
    await saveAdviceRule("R3", { text: "R3の推定原因" });

    // 全件読んで1件だけ差し替えているか(読まずに書くと R3 が消える)
    await saveAdviceRule("R1", { title: "R1・2回目" });

    const rules = await loadAdviceRules();
    expect(rules.R1.title).toBe("R1・2回目");
    expect(rules.R3.text).toBe("R3の推定原因");
    expect(rules.R3.title).toBe(DEFAULT_ADVICE_RULES.R3.title);

    expect(await readSavedJson()).toEqual({
      R1: { title: "R1・2回目" },
      R3: { text: "R3の推定原因" },
    });
  });

  test("同一ルールの別フィールドを続けて保存すると両方が残る", async () => {
    await saveAdviceRule("R2", { title: "R2の見出し" });
    const saved = await saveAdviceRule("R2", { actions: ["現場に連絡"] });

    expect(saved.title).toBe("R2の見出し");
    expect(saved.actions).toEqual(["現場に連絡"]);

    const rules = await loadAdviceRules();
    expect(rules.R2.title).toBe("R2の見出し");
    expect(rules.R2.actions).toEqual(["現場に連絡"]);
  });
});

describe("resetAdviceRule()", () => {
  test("既定値に戻り、他のルールの編集は消えない", async () => {
    await saveAdviceRule("R1", { title: "R1を編集" });
    await saveAdviceRule("R4", { title: "R4を編集" });

    const reverted = await resetAdviceRule("R1");

    expect(reverted).toEqual(DEFAULT_ADVICE_RULES.R1);

    const rules = await loadAdviceRules();
    expect(rules.R1).toEqual(DEFAULT_ADVICE_RULES.R1);
    expect(rules.R4.title).toBe("R4を編集");

    // 戻したルールはキーごと消える(空オブジェクトを残さない)
    expect(await readSavedJson()).toEqual({ R4: { title: "R4を編集" } });
  });
});

describe("ファイルが壊れているとき", () => {
  test(".bak に退避し、既定値で動き、settings.store_corrupt を warn で出す", async () => {
    await fs.writeFile(filePath, BROKEN_JSON, "utf-8");

    // 壊れていても画面は落とさない。既定値で動く
    expect(await loadAdviceRules()).toEqual(DEFAULT_ADVICE_RULES);

    const backupPath = await findBackupPath();
    // /data は .gitignore なので、原文は .bak にしか残らない
    expect(await fs.readFile(backupPath, "utf-8")).toBe(BROKEN_JSON);

    expect(logEventMock).toHaveBeenCalledTimes(1);
    const [level, payload] = logEventMock.mock.calls[0] as [
      string,
      Record<string, unknown>,
    ];
    expect(level).toBe("warn");
    expect(payload.event).toBe("settings.store_corrupt");
    expect(payload.file_path).toBe(filePath);
    expect(payload.backup_path).toBe(backupPath);
    // 壊れ方の分類は入るが、壊れたファイルの中身そのものは載せない
    expect(typeof payload.reason).toBe("string");
    expect(payload.reason).toBeTruthy();
    expect(payload.reason).not.toContain("途中で切れた");
  });

  test("破損を読んだ直後に保存しても、.bak の中身は失われない", async () => {
    await fs.writeFile(filePath, BROKEN_JSON, "utf-8");
    await loadAdviceRules();
    const backupPath = await findBackupPath();

    // 退避直後の保存が .bak を巻き込んで潰さないこと(唯一の控えなので致命的)
    await saveAdviceRule("R1", { title: "破損後の最初の編集" });

    expect(await fs.readFile(backupPath, "utf-8")).toBe(BROKEN_JSON);
    // 破損分は捨てて、新しい編集だけが残る
    expect(await readSavedJson()).toEqual({
      R1: { title: "破損後の最初の編集" },
    });
    // .bak は1つだけ(2回目の読み込みで増えない=壊れたファイルは残っていない)
    expect(
      (await fs.readdir(dir)).filter((n) => n.endsWith(".bak"))
    ).toHaveLength(1);
  });
});

describe("未知のルールID", () => {
  const UNKNOWN = "R99" as AdviceRuleId;

  test("saveAdviceRule は throw する", async () => {
    await expect(saveAdviceRule(UNKNOWN, { title: "x" })).rejects.toThrow(
      /未知のアドバイスルールです: R99/
    );
    // 知らないIDがファイルに書き込まれない(画面から消せないゴミになるため)
    await expect(fs.readdir(dir)).resolves.toEqual([]);
  });

  test("resetAdviceRule も throw する(save と同じ扱いに揃える)", async () => {
    await expect(resetAdviceRule(UNKNOWN)).rejects.toThrow(
      /未知のアドバイスルールです: R99/
    );
  });

  test("未知IDの reset は既存の編集を巻き込まない", async () => {
    await saveAdviceRule("R1", { title: "残るべき編集" });

    await expect(resetAdviceRule(UNKNOWN)).rejects.toThrow();

    expect((await loadAdviceRules()).R1.title).toBe("残るべき編集");
  });
});

/**
 * ★入力の長さ上限(`lib/settings/advice-validation.ts`)の境界値★
 *
 * 指示書AE 段階2-3-4 の期待値表をそのまま固定する(D-124)。
 * ★「上限ちょうどは通る / 上限+1 は422」を必ず対で入れること★ —
 * 片方だけだと境界の向き(`>` か `>=` か)を逆に書いても落ちない。
 */
describe("validateAdviceFields()", () => {
  /** 失敗の共通の期待: 422 / validation_failed / details あり */
  function expectRejected(result: ReturnType<typeof validateAdviceFields>) {
    expect(result).not.toBeNull();
    if (result === null) return;
    expect(result.status).toBe(422);
    expect(result.error.error.code).toBe("validation_failed");
    expect(result.error.error.details).not.toBeNull();
  }

  test("何も指定しなければ通る(部分編集を壊さない)", () => {
    expect(validateAdviceFields({})).toBeNull();
  });

  test("既定値(DEFAULT_ADVICE_RULES)は全ルールが上限内に収まっている", () => {
    // 上限を下げすぎて「既定値すら保存できない」状態に気付けるようにする
    for (const r of Object.values(DEFAULT_ADVICE_RULES)) {
      expect(
        validateAdviceFields({ title: r.title, text: r.text, actions: r.actions })
      ).toBeNull();
    }
  });

  describe("title", () => {
    test("上限ちょうどは通る", () => {
      expect(
        validateAdviceFields({ title: "あ".repeat(ADVICE_TITLE_MAX_LENGTH) })
      ).toBeNull();
    });

    test("上限+1 は422", () => {
      expectRejected(
        validateAdviceFields({ title: "あ".repeat(ADVICE_TITLE_MAX_LENGTH + 1) })
      );
    });
  });

  describe("text", () => {
    test("上限ちょうどは通る", () => {
      expect(
        validateAdviceFields({ text: "あ".repeat(ADVICE_TEXT_MAX_LENGTH) })
      ).toBeNull();
    });

    test("上限+1 は422", () => {
      expectRejected(
        validateAdviceFields({ text: "あ".repeat(ADVICE_TEXT_MAX_LENGTH + 1) })
      );
    });
  });

  describe("actions", () => {
    const item = (n: number) => Array.from({ length: n }, (_, i) => `アクション${i}`);

    test("上限件数ちょうどは通る", () => {
      expect(
        validateAdviceFields({ actions: item(ADVICE_ACTIONS_MAX_ITEMS) })
      ).toBeNull();
    });

    test("上限件数+1 は422", () => {
      expectRejected(
        validateAdviceFields({ actions: item(ADVICE_ACTIONS_MAX_ITEMS + 1) })
      );
    });

    test("空配列は通る(全アクションを消す編集を禁止しない)", () => {
      expect(validateAdviceFields({ actions: [] })).toBeNull();
    });

    test("要素長が上限ちょうどは通る", () => {
      expect(
        validateAdviceFields({ actions: ["あ".repeat(ADVICE_ACTION_MAX_LENGTH)] })
      ).toBeNull();
    });

    test("1要素でも要素長上限+1 があれば422", () => {
      expectRejected(
        validateAdviceFields({
          actions: ["短い", "あ".repeat(ADVICE_ACTION_MAX_LENGTH + 1), "短い"],
        })
      );
    });

    /**
     * ★文字列以外の要素は「落とす」のではなく422★(査読指摘)
     *
     * 以前はルート側が `.filter((a) => typeof a === "string")` で落としていたため、
     * `actions: [1, 2, 3]` が★空配列で保存=既存アクションの全消去★になり、
     * しかもエラーが出なかった。`d-watch-api.yaml` の契約は
     * `actions: items: { type: string }` なので、非文字列は契約違反。
     */
    describe("要素の型", () => {
      test("全部文字列なら通る(従来どおり)", () => {
        expect(validateAdviceFields({ actions: ["現場に連絡", "再起動"] })).toBeNull();
      });

      test("全部が非文字列なら422(★空配列で保存して全消去しない★)", () => {
        expectRejected(validateAdviceFields({ actions: [1, 2, 3] }));
      });

      test("1つでも非文字列が混ざれば422", () => {
        expectRejected(validateAdviceFields({ actions: ["現場に連絡", 2] }));
      });

      test.each([
        ["null", [null]],
        ["undefined", [undefined]],
        ["真偽値", [true]],
        ["オブジェクト", [{ text: "現場に連絡" }]],
        ["入れ子の配列", [["現場に連絡"]]],
      ])("%s が混ざっていても422", (_name, actions) => {
        expectRejected(validateAdviceFields({ actions }));
      });

      test("details には期待する形式だけを入れ、入力値をエコーしない", () => {
        const result = validateAdviceFields({ actions: ["echo-me", 1] });
        expect(result).not.toBeNull();
        if (result === null) return;
        expect(result.error.error.details).toHaveProperty("actions");
        expect(JSON.stringify(result.error)).not.toContain("echo-me");
      });

      test("★型で弾かれた編集は保存に進まない=既存のアクションが残る★", async () => {
        // ルートと同じ順序(検証→保存)を並べて、全消去にならないことを固定する
        await saveAdviceRule("R1", { actions: ["先に保存したアクション"] });

        const result = validateAdviceFields({ actions: [1, 2, 3] });
        expect(result).not.toBeNull();
        // 検証が失敗したので saveAdviceRule は呼ばれない

        expect((await loadAdviceRules()).R1.actions).toEqual([
          "先に保存したアクション",
        ]);
      });
    });
  });

  describe("isStringArray()", () => {
    test.each([
      ["空配列", [], true],
      ["全部文字列", ["a", "b"], true],
      ["空文字を含む", ["", "b"], true],
      ["数値を含む", ["a", 1], false],
      ["nullを含む", ["a", null], false],
      ["全部数値", [1, 2], false],
    ])("%s → %s", (_name, value, expected) => {
      expect(isStringArray(value as readonly unknown[])).toBe(expected);
    });
  });

  test("エラーの details に入力値をエコーしない(上限値だけを返す)", () => {
    const attack = "echo-me".repeat(ADVICE_TITLE_MAX_LENGTH);
    const result = validateAdviceFields({ title: attack });
    expect(result).not.toBeNull();
    if (result === null) return;
    expect(JSON.stringify(result.error)).not.toContain("echo-me");
  });

  test("複数フィールドが同時に違反しても最初の1つで422にする(後続を評価しない)", () => {
    const result = validateAdviceFields({
      title: "あ".repeat(ADVICE_TITLE_MAX_LENGTH + 1),
      text: "あ".repeat(ADVICE_TEXT_MAX_LENGTH + 1),
    });
    expect(result).not.toBeNull();
    if (result === null) return;
    expect(result.error.error.details).toHaveProperty("title");
    expect(result.error.error.details).not.toHaveProperty("text");
  });
});