Newer
Older
client_dwatch / lib / settings / __tests__ / json-store.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 { 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();
    }
  });
});