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件目" },
});
});
});