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 { createJsonStore, type JsonStore } from "@/lib/settings/json-store";
/**
* DFA 独自ストア(`lib/settings/json-store.ts`)の期待値表テスト。
*
* ★このリポジトリで唯一「実際にファイルを読み書きする」テストです★
* fs をモックしないのは、守りたい性質(rename が既存ファイルを上書きするか、
* .tmp が残らないか)が OS の挙動そのものだからです。モックすると
* 開発機(Windows)と本番(Linux)の差を検出できません。
*
* 各テストは `fs.mkdtemp` で作った独立した一時ディレクトリを使い、
* afterEach で必ず消します(テスト間で状態を共有しない)。
*/
type Bag = Record<string, unknown>;
const empty = (): Bag => Object.create(null) as Bag;
let dir = "";
let filePath = "";
function createStore(
onWarn?: (info: {
filePath: string;
backupPath: string;
reason: string;
}) => void,
overridePath?: string
): JsonStore<Bag> {
return createJsonStore<Bag>({
filePath: overridePath ?? filePath,
empty,
onWarn,
});
}
async function listNames(target = dir): Promise<string[]> {
return fs.readdir(target);
}
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-"));
filePath = path.join(dir, "advice.json");
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
describe("read()", () => {
test("ファイルが無いときは空の値を返し、.bak を作らない", async () => {
const onWarn = vi.fn();
const result = await createStore(onWarn).read();
expect(Object.keys(result)).toEqual([]);
expect(onWarn).not.toHaveBeenCalled();
// 未編集(=一度も保存していない)は正常な状態なので、証拠を残す必要がない
expect(await listNames()).toEqual([]);
});
test("正しいJSONはその内容がそのまま返る", async () => {
await fs.writeFile(
filePath,
JSON.stringify({ R1: { title: "出発失敗" }, R2: { title: "完了率×" } }),
"utf-8"
);
const onWarn = vi.fn();
const result = await createStore(onWarn).read();
expect(result).toEqual({
R1: { title: "出発失敗" },
R2: { title: "完了率×" },
});
expect(onWarn).not.toHaveBeenCalled();
expect((await listNames()).some((n) => n.endsWith(".bak"))).toBe(false);
});
test("JSONが壊れていたら .bak に退避し、空の値を返して onWarn を1回呼ぶ", async () => {
const broken = '{ "R1": { "title": "途中で切れた"';
await fs.writeFile(filePath, broken, "utf-8");
const onWarn = vi.fn();
const result = await createStore(onWarn).read();
expect(Object.keys(result)).toEqual([]);
expect(onWarn).toHaveBeenCalledTimes(1);
const info = onWarn.mock.calls[0][0] as {
filePath: string;
backupPath: string;
reason: string;
};
expect(info.filePath).toBe(filePath);
expect(info.reason).toBeTruthy();
// 壊れた中身は .bak にそのまま残る(/data は .gitignore なので他に控えが無い)
expect(await fs.readFile(info.backupPath, "utf-8")).toBe(broken);
// 固定名にすると2回目の破損で1回目の証拠が消えるため、名前はタイムスタンプ付き
expect(path.basename(info.backupPath)).toMatch(
/^advice\.json\.\d{4}-\d{2}-\d{2}T[\d-]+\.bak$/
);
});
test("★.bak の作成に失敗したら、空の値を返さず例外を投げる★", async () => {
/**
* 一番重要なテスト。
* .bak を作れないまま空の値を返すと、呼び出し側が次に write() した瞬間に
* 壊れたファイルが上書きされ、二度と復旧できません。
* 「データが消えるより例外の方がまし」を固定します。
*
* 失敗のさせ方はモックではなく本物:時計を固定して .bak の名前を再現可能にし、
* 同じ名前の**ディレクトリ**を先に置きます。ファイルをディレクトリへ rename する
* のは Windows / Linux どちらでも失敗します。
*/
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-09-07T12:34:56.000Z"));
try {
const broken = "{ 壊れている";
// 1回目: 実装が付ける .bak の名前を実測する(名前の形式に依存しないため)
await fs.writeFile(filePath, broken, "utf-8");
await createStore().read();
const backupName = (await listNames()).find((n) => n.endsWith(".bak"));
expect(backupName).toBeDefined();
const backupPath = path.join(dir, backupName as string);
// 退避先を「同名のディレクトリ」に置き換える
await fs.rm(backupPath);
await fs.mkdir(backupPath);
// 2回目: 時計は固定のままなので退避先の名前は同じ → rename が本当に失敗する
await fs.writeFile(filePath, broken, "utf-8");
const onWarn = vi.fn();
// 「退避に失敗した」ことが理由であると明示する
// (たまたま別の理由で落ちて通ってしまうテストにしない)
await expect(createStore(onWarn).read()).rejects.toThrow(/退避に失敗/);
// 退避できていないのに「退避した」と通知すると誤解を招くので呼ばない
expect(onWarn).not.toHaveBeenCalled();
// 壊れたファイルは消さずに残す(手で復旧する余地を残す)
expect(await fs.readFile(filePath, "utf-8")).toBe(broken);
} finally {
vi.useRealTimers();
}
});
test("中身が配列ならオブジェクトでないので破損扱いにする", async () => {
await fs.writeFile(filePath, JSON.stringify([1, 2, 3]), "utf-8");
const onWarn = vi.fn();
const result = await createStore(onWarn).read();
expect(Object.keys(result)).toEqual([]);
expect(onWarn).toHaveBeenCalledTimes(1);
expect((await listNames()).some((n) => n.endsWith(".bak"))).toBe(true);
});
test("中身が文字列ならオブジェクトでないので破損扱いにする", async () => {
await fs.writeFile(filePath, JSON.stringify("advice"), "utf-8");
const onWarn = vi.fn();
const result = await createStore(onWarn).read();
expect(Object.keys(result)).toEqual([]);
expect(onWarn).toHaveBeenCalledTimes(1);
expect((await listNames()).some((n) => n.endsWith(".bak"))).toBe(true);
});
test("__proto__ というキーがあっても Object.prototype を汚染しない", async () => {
await fs.writeFile(
filePath,
'{ "__proto__": { "polluted": "yes" }, "R1": { "title": "正常" } }',
"utf-8"
);
const result = await createStore().read();
expect(({} as Bag).polluted).toBeUndefined();
expect((Object.prototype as unknown as Bag).polluted).toBeUndefined();
// 汚染しないだけで、正常なキーは失わない
expect(result.R1).toEqual({ title: "正常" });
});
test("constructor / prototype というキーがあっても汚染しない", async () => {
await fs.writeFile(
filePath,
'{ "constructor": { "prototype": { "polluted": "yes" } }, "prototype": { "polluted": "yes" }, "R1": { "title": "正常" } }',
"utf-8"
);
const result = await createStore().read();
expect(({} as Bag).polluted).toBeUndefined();
expect((Object.prototype as unknown as Bag).polluted).toBeUndefined();
expect(result.R1).toEqual({ title: "正常" });
});
});
describe("write()", () => {
test("保存先のディレクトリが無くても作って書ける", async () => {
const nested = path.join(dir, "data", "settings", "advice.json");
const store = createStore(undefined, nested);
await store.write({ R1: { title: "初回" } });
expect(JSON.parse(await fs.readFile(nested, "utf-8"))).toEqual({
R1: { title: "初回" },
});
});
test("既存ファイルは置き換わる(rename が上書きできることの固定)", async () => {
// 開発機は Windows、本番は Linux。rename の上書き挙動に差が無いことを固定する
await fs.writeFile(filePath, JSON.stringify({ old: true }), "utf-8");
await createStore().write({ R1: { title: "新しい" } });
expect(JSON.parse(await fs.readFile(filePath, "utf-8"))).toEqual({
R1: { title: "新しい" },
});
});
test("書いた直後に read() すると同じ値が返る(往復)", async () => {
const store = createStore();
const value: Bag = {
R1: { title: "出発失敗", body: "事前チェックのアラート内容を確認" },
R2: { title: "完了率×", enabled: false, order: 2 },
};
await store.write(value);
expect(await store.read()).toEqual(value);
});
test("書き込み後に .tmp が残っていない", async () => {
await createStore().write({ R1: { title: "初回" } });
const names = await listNames();
expect(names.some((n) => n.endsWith(".tmp"))).toBe(false);
expect(names).toEqual(["advice.json"]);
});
});
describe("部分更新", () => {
test("1件保存 → もう1件だけ追記しても、前の1件が消えない", async () => {
const store = createStore();
await store.write({ R1: { title: "1件目" } });
const first = await store.read();
expect(first).toEqual({ R1: { title: "1件目" } });
await store.write({ ...first, R2: { title: "2件目" } });
const second = await store.read();
expect(second).toEqual({
R1: { title: "1件目" },
R2: { title: "2件目" },
});
});
});
// ★表12(指示書BB A12・D20・D-182)★ — 一時ファイルを書き込みごとに別の名前にし、同じファイルへの書き込みは直列化する
describe("同時保存(指示書BB 表12)", () => {
// ★直列化(ファイルパスごとのモジュール内の待ち行列)は指示書BB §10 Q6 の監督の判断(2026-10-19)★。
// 直列化の前は、Windows で同じ宛先への rename が同時に走ると EPERM になった(2026-10-19 実測で5回中5回)。★リトライは足さない★
test("★J1: 同じストアに5回の write() を同時に → 5回とも解決し、最後に呼んだ値が残り、.tmp が残らない★", async () => {
const store = createStore();
const values: Bag[] = [1, 2, 3, 4, 5].map((n) => ({ R1: { title: `${n}回目` } }));
await expect(Promise.all(values.map((v) => store.write(v)))).resolves.toHaveLength(5);
// 呼んだ順に1件ずつ書くので、後勝ち=最後に呼んだ値(表12 の「5つのどれか」より強い)
expect(JSON.parse(await fs.readFile(filePath, "utf-8"))).toEqual(values[4]);
expect(await listNames()).toEqual(["advice.json"]);
});
test("★別々の createJsonStore() でも、同じパスなら同じ列に並ぶ★", async () => {
// 待ち行列はストアごとではなくモジュールで持つ(advice と display-limits が別のストアを作っても安全に)
const stores = [createStore(), createStore(), createStore()];
const values: Bag[] = stores.map((_, i) => ({ R1: { title: `ストア${i + 1}` } }));
await Promise.all(stores.map((s, i) => s.write(values[i])));
expect(JSON.parse(await fs.readFile(filePath, "utf-8"))).toEqual(values[2]);
expect(await listNames()).toEqual(["advice.json"]);
});
test("★失敗した write() は後続を止めない(列は次へ進む)★", async () => {
const store = createStore();
// fs はモックしない(ファイル冒頭の方針)。BigInt は JSON.stringify が TypeError を投げるので、本物の失敗になる
const bad = { R1: { count: BigInt(1) } } as Bag;
const results = await Promise.allSettled([
store.write({ R1: { title: "1回目" } }),
store.write(bad),
store.write({ R1: { title: "3回目" } }),
]);
expect(results.map((r) => r.status)).toEqual(["fulfilled", "rejected", "fulfilled"]);
// 失敗は握り潰さず、その write() の呼び出し側に返る
expect((results[1] as PromiseRejectedResult).reason).toBeInstanceOf(TypeError);
expect(JSON.parse(await fs.readFile(filePath, "utf-8"))).toEqual({ R1: { title: "3回目" } });
expect(await listNames()).toEqual(["advice.json"]);
// 失敗のあとに単独で呼んでも書ける(列が詰まったままになっていない)
await store.write({ R1: { title: "4回目" } });
expect(JSON.parse(await fs.readFile(filePath, "utf-8"))).toEqual({ R1: { title: "4回目" } });
});
test("★J2: 一時ファイル名は `<filePath>.<pid>.<uuid>.tmp`(2回の write() で別の名前)★", async () => {
const spy = vi.spyOn(fs, "writeFile");
try {
const store = createStore();
await store.write({ R1: { title: "1回目" } });
await store.write({ R1: { title: "2回目" } });
const tmpPaths = spy.mock.calls.map(([p]) => String(p)).filter((p) => p.endsWith(".tmp"));
expect(tmpPaths).toHaveLength(2);
const prefix = `${filePath}.${process.pid}.`;
for (const p of tmpPaths) {
expect(p.startsWith(prefix)).toBe(true);
expect(p.slice(prefix.length)).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/);
}
expect(tmpPaths[0]).not.toBe(tmpPaths[1]);
} finally {
spy.mockRestore();
}
});
});