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("残るべき編集");
});
// ★`Object.prototype` から引ける名前もストア側で弾く★(指示書BB A10・表8 H1/H2)—
// `DEFAULT_ADVICE_RULES[id]` で判定すると `"constructor"` / `"__proto__"` が通ってしまう。
// ルートの 422 とは別に、ライブラリを直接呼んだときの最後の砦(`Object.hasOwn`)を固定する
test.each(["constructor", "__proto__", "toString", "hasOwnProperty"])(
"saveAdviceRule(%j) は throw し、ファイルに書かない",
async (name) => {
await expect(saveAdviceRule(name as AdviceRuleId, { title: "x" })).rejects.toThrow(
`未知のアドバイスルールです: ${name}`
);
await expect(fs.readdir(dir)).resolves.toEqual([]);
}
);
test.each(["constructor", "__proto__"])("resetAdviceRule(%j) も throw する", async (name) => {
await expect(resetAdviceRule(name as AdviceRuleId)).rejects.toThrow(
`未知のアドバイスルールです: ${name}`
);
});
});
/**
* ★入力の長さ上限(`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");
});
});