Newer
Older
client_dwatch / lib / settings / __tests__ / display-limits.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 { CONSECUTIVE_DAYS_DISPLAY, ESCALATION_THRESHOLDS } from "@/lib/config";
import {
  __setDisplayLimitStoreForTesting,
  createDisplayLimitStore,
  isDisplayLimitKey,
  loadConsecutiveDaysMax,
  loadDisplayLimitSettings,
  resetConsecutiveDaysMax,
  resetDisplayLimit,
  saveConsecutiveDaysMax,
  saveDisplayLimit,
  validateConsecutiveDaysMax,
  validateDisplayLimit,
} from "@/lib/settings/display-limits";

/**
 * 連続日数の表示上限(`lib/settings/display-limits.ts`)の期待値表テスト。
 * 指示書AF 段階3-4 の29行をそのまま固定する。
 *
 * ★fs はモックしません★(`advice.test.ts` と同じ方針)。
 * 守りたい性質が「壊れたファイルが本当に `.bak` へ退避されるか」といった
 * ファイルシステムそのものの挙動だからです。各テストは `fs.mkdtemp` で作った
 * 独立した一時ディレクトリを使い、afterEach で必ず消します
 * (★実ファイル `data/settings/display-limits.json` を絶対に汚さないこと★)。
 *
 * ★`@/lib/logger` はモックします★。
 * `lib/logger/index.ts` は `@/lib/db` 経由で fixtures 全体を引き込むうえ、
 * ここで確かめたいのは「不正値・破損を検知したら warn で1行出す」という配線だけで、
 * ロガー本体の挙動は `lib/logger/__tests__/` の担当だからです。
 */
const logEventMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/logger", () => ({ logEvent: logEventMock }));

/** 破損させたファイルの中身。`.bak` に原文が残ることの確認に使う */
const BROKEN_JSON = '{ "consecutive_days_max": 45';

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

beforeEach(async () => {
  dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-"));
  filePath = path.join(dir, "display-limits.json");
  __setDisplayLimitStoreForTesting(createDisplayLimitStore(filePath), filePath);
  logEventMock.mockClear();
});

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

/** ストアの中身を直に書く(画面ではなく手編集で入った値を再現する) */
async function writeStore(raw: string): Promise<void> {
  await fs.writeFile(filePath, raw, "utf-8");
}

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);
}

/** `logEvent(level, payload)` の1件目を取り出す */
function firstLogCall(): [string, Record<string, unknown>] {
  return logEventMock.mock.calls[0] as [string, Record<string, unknown>];
}

/**
 * ★検証(`validateConsecutiveDaysMax`)— 指示書AF 段階3-4 表 #1〜#17★
 *
 * ★下限・上限の境界は「ちょうどは通る / ±1 は弾く」を対で入れること★ —
 * 片方だけだと境界の向き(`<` か `<=` か)を逆に書いても落ちない。
 * ★入力側の下限は `ESCALATION_THRESHOLDS.level3Days` から取る★(`3` と直書きしない。
 * L3 の日数が変われば下限も自動で追従することを、テストでも固定する)。
 */
describe("validateConsecutiveDaysMax()", () => {
  describe("通る値", () => {
    test("#1 既定値そのもの(30)", () => {
      expect(
        validateConsecutiveDaysMax(CONSECUTIVE_DAYS_DISPLAY.defaultMax)
      ).toEqual({ ok: true, value: CONSECUTIVE_DAYS_DISPLAY.defaultMax });
    });

    test("#2 下限ちょうど(=L3の日数)", () => {
      const min = ESCALATION_THRESHOLDS.level3Days;
      expect(validateConsecutiveDaysMax(min)).toEqual({ ok: true, value: min });
      // 下限は L3 の日数から導かれていること(設定同士の関係を固定する)
      expect(CONSECUTIVE_DAYS_DISPLAY.minMax).toBe(ESCALATION_THRESHOLDS.level3Days);
    });

    test("#4 上限ちょうど(365)", () => {
      const max = CONSECUTIVE_DAYS_DISPLAY.maxMax;
      expect(validateConsecutiveDaysMax(max)).toEqual({ ok: true, value: max });
    });

    test("#5 上限−1(364)", () => {
      const value = CONSECUTIVE_DAYS_DISPLAY.maxMax - 1;
      expect(validateConsecutiveDaysMax(value)).toEqual({ ok: true, value });
    });
  });

  describe("弾く値", () => {
    /** 失敗の共通の期待: ok:false で理由が入る(理由に入力値をエコーしない) */
    function expectRejected(value: unknown) {
      const result = validateConsecutiveDaysMax(value);
      expect(result.ok).toBe(false);
      if (result.ok) return;
      expect(result.reason).toBeTruthy();
      expect(typeof result.reason).toBe("string");
    }

    test("#3 下限−1(=L3の日数−1)", () => {
      expectRejected(ESCALATION_THRESHOLDS.level3Days - 1);
    });

    test("#6 上限+1(366)", () => {
      expectRejected(CONSECUTIVE_DAYS_DISPLAY.maxMax + 1);
    });

    test("#7 整数でない(3.5)", () => {
      expectRejected(3.5);
    });

    test("#8 負数(-1)", () => {
      expectRejected(-1);
    });

    test("#9 0", () => {
      expectRejected(0);
    });

    /**
     * ★#10 / #11 巨大値★ — `Number.isInteger(1e9) === true` なので
     * 整数判定では弾けない。上限(`maxMax`)の条件が効いていることの確認。
     */
    test("#10 巨大値(1e9)", () => {
      expectRejected(1e9);
    });

    test("#11 巨大値(Number.MAX_SAFE_INTEGER)", () => {
      expectRejected(Number.MAX_SAFE_INTEGER);
    });

    test("#12 NaN", () => {
      expectRejected(Number.NaN);
    });

    test("#13 Infinity / -Infinity", () => {
      expectRejected(Number.POSITIVE_INFINITY);
      expectRejected(Number.NEGATIVE_INFINITY);
    });

    test("#14 文字列(\"30\")", () => {
      expectRejected(String(CONSECUTIVE_DAYS_DISPLAY.defaultMax));
    });

    test("#15 null / undefined", () => {
      expectRejected(null);
      expectRejected(undefined);
    });

    test("#16 真偽値(true)", () => {
      expectRejected(true);
    });

    test("#17 配列 / オブジェクト", () => {
      expectRejected([CONSECUTIVE_DAYS_DISPLAY.defaultMax]);
      expectRejected({ value: CONSECUTIVE_DAYS_DISPLAY.defaultMax });
    });
  });
});

/**
 * ★読み込み(`loadConsecutiveDaysMax`)— 指示書AF 段階3-4 表 #18〜#25★
 *
 * ★どの壊れ方でも例外にせず既定値へ倒れること★ —
 * 例外を投げるとシステム管理画面が開かなくなる。
 */
describe("loadConsecutiveDaysMax()", () => {
  test("#18 ファイルが存在しない → 既定値(読んだだけでファイルを作らない)", async () => {
    expect(await loadConsecutiveDaysMax()).toBe(
      CONSECUTIVE_DAYS_DISPLAY.defaultMax
    );
    expect(await fs.readdir(dir)).toEqual([]);
    // 未編集は正常な状態。ログも出さない
    expect(logEventMock).not.toHaveBeenCalled();
  });

  test("#19 保存済みの正しい値がそのまま返る", async () => {
    await writeStore('{"consecutive_days_max": 45}');

    expect(await loadConsecutiveDaysMax()).toBe(45);
    expect(logEventMock).not.toHaveBeenCalled();
  });

  test("#20 壊れたJSON → 既定値 + .bak + settings.store_corrupt を warn で1行", async () => {
    await writeStore(BROKEN_JSON);

    expect(await loadConsecutiveDaysMax()).toBe(
      CONSECUTIVE_DAYS_DISPLAY.defaultMax
    );

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

    expect(logEventMock).toHaveBeenCalledTimes(1);
    const [level, payload] = firstLogCall();
    expect(level).toBe("warn");
    expect(payload.event).toBe("settings.store_corrupt");
    expect(payload.file_path).toBe(filePath);
    expect(payload.backup_path).toBe(backupPath);
  });

  test("#21 トップレベルが配列 → 既定値(オブジェクト以外は破損として扱う)", async () => {
    await writeStore("[]");

    expect(await loadConsecutiveDaysMax()).toBe(
      CONSECUTIVE_DAYS_DISPLAY.defaultMax
    );
    expect(firstLogCall()[1].event).toBe("settings.store_corrupt");
  });

  test("#22 キーが無い({}) → 既定値", async () => {
    await writeStore("{}");

    expect(await loadConsecutiveDaysMax()).toBe(
      CONSECUTIVE_DAYS_DISPLAY.defaultMax
    );
    // 「壊れている」わけではないのでログは出さない
    expect(logEventMock).not.toHaveBeenCalled();
  });

  /**
   * ★#23 値は入っているが範囲外★ — ★読み込み時にも検証を通す★ことの確認。
   * 手でファイルを編集された場合や、将来 min / max を狭めた場合に起きる。
   */
  test("#23 範囲外の値(-5) → 既定値 + settings.value_invalid を warn で1行", async () => {
    await writeStore('{"consecutive_days_max": -5}');

    expect(await loadConsecutiveDaysMax()).toBe(
      CONSECUTIVE_DAYS_DISPLAY.defaultMax
    );

    expect(logEventMock).toHaveBeenCalledTimes(1);
    const [level, payload] = firstLogCall();
    expect(level).toBe("warn");
    // 破損(.bak へ退避)とは別の事象。store_corrupt を流用しない
    expect(payload.event).toBe("settings.value_invalid");
    expect(payload.file_path).toBe(filePath);
    expect(payload.setting_key).toBe("consecutive_days_max");
    // 数値1つは自由記述ではないので、そのまま載せてよい(調査に要る)
    expect(payload.invalid_value).toBe(-5);
    expect(payload.value_type).toBe("number");
    expect(payload.reason).toBeTruthy();
    // 退避していないのに「退避した」と書かない
    expect(payload.backup_path).toBeUndefined();
    expect(await fs.readdir(dir)).toEqual(["display-limits.json"]);
  });

  test("#24 文字列の値(\"45\") → 既定値(値そのものはログに載せない)", async () => {
    await writeStore('{"consecutive_days_max": "45"}');

    expect(await loadConsecutiveDaysMax()).toBe(
      CONSECUTIVE_DAYS_DISPLAY.defaultMax
    );

    expect(logEventMock).toHaveBeenCalledTimes(1);
    const payload = firstLogCall()[1];
    expect(payload.event).toBe("settings.value_invalid");
    expect(payload.value_type).toBe("string");
    // 手編集のファイルには自由記述が入りうるので、数値以外は値を載せない
    expect(payload.invalid_value).toBeUndefined();
    // file_path は一時ディレクトリ名に偶然 "45" を含みうるので除いて見る
    expect(JSON.stringify({ ...payload, file_path: undefined })).not.toContain(
      "45"
    );
  });

  test("#25 知らないキーが混ざっていても、正しい値は読める(例外にしない)", async () => {
    await writeStore('{"consecutive_days_max": 45, "unknown_key": 1}');

    expect(await loadConsecutiveDaysMax()).toBe(45);
    expect(logEventMock).not.toHaveBeenCalled();
  });
});

/**
 * ★保存(`saveConsecutiveDaysMax` / `resetConsecutiveDaysMax`)
 *  — 指示書AF 段階3-4 表 #26〜#29★
 */
describe("saveConsecutiveDaysMax() / resetConsecutiveDaysMax()", () => {
  test("#26 保存した値が読み込める", async () => {
    expect(await saveConsecutiveDaysMax(45)).toBe(45);
    expect(await loadConsecutiveDaysMax()).toBe(45);
  });

  test("#27 reset すると既定値に戻る(キーごと消える)", async () => {
    await saveConsecutiveDaysMax(45);

    expect(await resetConsecutiveDaysMax()).toBe(
      CONSECUTIVE_DAYS_DISPLAY.defaultMax
    );
    expect(await loadConsecutiveDaysMax()).toBe(
      CONSECUTIVE_DAYS_DISPLAY.defaultMax
    );
    // 既定値をコピーして持たない(既定値を後から変えたとき追従できなくなる)
    expect(await readSavedJson()).toEqual({});
  });

  test("#28 ファイルに入るのは編集されたキーだけ", async () => {
    await saveConsecutiveDaysMax(45);

    expect(await readSavedJson()).toEqual({ consecutive_days_max: 45 });
  });

  describe("#29 不正値は保存しない(ファイルに書かない)", () => {
    test.each([
      ["下限−1", ESCALATION_THRESHOLDS.level3Days - 1],
      ["上限+1", CONSECUTIVE_DAYS_DISPLAY.maxMax + 1],
      ["小数", 3.5],
      ["NaN", Number.NaN],
    ])("%s は throw する", async (_name, value) => {
      await expect(saveConsecutiveDaysMax(value)).rejects.toThrow(
        /連続日数の表示上限として保存できない値です/
      );
      // 検証を通らなかった値がファイルに残らない
      await expect(fs.readdir(dir)).resolves.toEqual([]);
    });

    test("既存の保存を巻き込んで壊さない", async () => {
      await saveConsecutiveDaysMax(45);

      await expect(saveConsecutiveDaysMax(-1)).rejects.toThrow();

      expect(await loadConsecutiveDaysMax()).toBe(45);
      expect(await readSavedJson()).toEqual({ consecutive_days_max: 45 });
    });
  });
});

/**
 * ★`GET /settings/display-limits` が返す形★
 *
 * ★`min` / `max` は検証範囲そのもの★ — 画面の入力欄がこの値を使うため、
 * ここでずらすと「画面では入れられるのにサーバが422」になる。
 */
describe("loadDisplayLimitSettings()", () => {
  test("未編集なら既定値・customized:false", async () => {
    expect(await loadDisplayLimitSettings()).toEqual([
      {
        key: "consecutive_days_max",
        value: CONSECUTIVE_DAYS_DISPLAY.defaultMax,
        default: CONSECUTIVE_DAYS_DISPLAY.defaultMax,
        min: CONSECUTIVE_DAYS_DISPLAY.minMax,
        max: CONSECUTIVE_DAYS_DISPLAY.maxMax,
        customized: false,
      },
    ]);
  });

  test("編集後は value が変わり customized:true", async () => {
    await saveConsecutiveDaysMax(45);

    const [item] = await loadDisplayLimitSettings();
    expect(item.value).toBe(45);
    expect(item.default).toBe(CONSECUTIVE_DAYS_DISPLAY.defaultMax);
    expect(item.customized).toBe(true);
  });

  test("不正な値で既定値に倒れたときは customized:false(実際に効いていないため)", async () => {
    await writeStore('{"consecutive_days_max": -5}');

    const [item] = await loadDisplayLimitSettings();
    expect(item.value).toBe(CONSECUTIVE_DAYS_DISPLAY.defaultMax);
    expect(item.customized).toBe(false);
  });
});

/**
 * ★★キーごとの振り分け(★2つ目のキーを足したときに必ず気づけること★)★★
 *
 * ★★このセクションの本題は「コンパイル時の保証」で、それはテストでは表現できません★★。
 * ここにコメントで残します:
 *
 *   `lib/settings/display-limits.ts` の `DISPLAY_LIMIT_HANDLERS` は
 *   ★`Record<DisplayLimitKey, DisplayLimitHandler>`★ です。
 *   `lib/types/index.ts` の `DisplayLimitKey` にキーを1つ足して、
 *   ★この表に処理(validate / save / reset / setting)を足さないと
 *   `npx tsc --noEmit` が落ちます★
 *   (例: `型 '{ consecutive_days_max: … }' に プロパティ 'grade_max' がありません`)。
 *   ★実測済み★: 2つ目のキーを一時的に足して tsc が落ちることを確認してから元に戻しました。
 *
 * ★なぜこの保証が要るのか★ — 以前は API ルート側の `KNOWN_KEYS` だけが
 *   「複数キーを受け付ける」形で、保存・リセット・取得は `consecutive_days_max` に
 *   固定されていました。その状態で2つ目のキーを足すと
 *   ★別キーへの PATCH が `consecutive_days_max` を書き換えます★。
 *   ★例外もテスト失敗も出ません★(「変えたはずの設定が変わらず、関係ない設定が変わる」)。
 *   ユーザー指示(2026-09-13 のA案)は「作法を確定させてから他へ広げる」なので、
 *   ★2つ目のキーを足すことは計画済み★です(指示書AF §H-1)。
 *
 * 以下は ★実行時★ の振る舞い(未知のキーを弾くこと・キーと処理が対応していること)だけを固定します。
 */
describe("キーごとの振り分け", () => {
  describe("isDisplayLimitKey()(★未知のキーはルートが422にする★)", () => {
    test("表にあるキーは true", () => {
      expect(isDisplayLimitKey("consecutive_days_max")).toBe(true);
    });

    /**
     * ★`false` を返した key は `app/api/settings/display-limits/route.ts` が
     *   422 `validation_failed`「keyの値が不正です」で弾きます★。
     *   HTTP のステータスそのものをここで確かめられないのは、
     *   ★このリポジトリに API ルートの単体テストが1件も無い★ためです
     *   (指示書AF 段階3-2。検証を純粋関数へ寄せている理由でもあります)。
     */
    test.each([
      ["未知のキー(将来の候補)", "grade_thresholds"],
      ["空文字", ""],
      ["似ているが別物", "consecutive_days_min"],
      ["前後に空白", " consecutive_days_max "],
      ["大文字", "CONSECUTIVE_DAYS_MAX"],
    ])("%s は false", (_name, value) => {
      expect(isDisplayLimitKey(value)).toBe(false);
    });

    test.each([
      ["undefined", undefined],
      ["null", null],
      ["数値", 1],
      ["オブジェクト", { key: "consecutive_days_max" }],
      ["配列", ["consecutive_days_max"]],
    ])("文字列でない %s は false", (_name, value) => {
      expect(isDisplayLimitKey(value)).toBe(false);
    });

    /**
     * ★`key in obj` や `HANDLERS[key]` で書くとここが true になり、
     *   未知のキーが「知っているキー」として PATCH の先へ進みます★
     *   (`json-store.ts` のプロトタイプ汚染対策と同じ警戒)。
     */
    test.each([["toString"], ["constructor"], ["__proto__"], ["hasOwnProperty"]])(
      "プロトタイプのプロパティ %s は false",
      (value) => {
        expect(isDisplayLimitKey(value)).toBe(false);
      }
    );
  });

  describe("キーを渡す入口が、そのキー専用の処理に繋がっていること", () => {
    test("validateDisplayLimit() は validateConsecutiveDaysMax() と同じ判定", () => {
      // 通る値 / 弾く値の両方で一致を見る(片方だけだと常に ok:true でも通る)
      expect(validateDisplayLimit("consecutive_days_max", 45)).toEqual(
        validateConsecutiveDaysMax(45)
      );
      expect(
        validateDisplayLimit(
          "consecutive_days_max",
          CONSECUTIVE_DAYS_DISPLAY.maxMax + 1
        )
      ).toEqual(validateConsecutiveDaysMax(CONSECUTIVE_DAYS_DISPLAY.maxMax + 1));
    });

    test("saveDisplayLimit() は consecutive_days_max として保存される", async () => {
      expect(await saveDisplayLimit("consecutive_days_max", 45)).toBe(45);

      expect(await readSavedJson()).toEqual({ consecutive_days_max: 45 });
      expect(await loadConsecutiveDaysMax()).toBe(45);
    });

    test("saveDisplayLimit() も不正値は保存しない(検証は保存側でも効く)", async () => {
      await expect(
        saveDisplayLimit("consecutive_days_max", 3.5)
      ).rejects.toThrow(/連続日数の表示上限として保存できない値です/);
      await expect(fs.readdir(dir)).resolves.toEqual([]);
    });

    test("resetDisplayLimit() はキーごと消して既定値を返す", async () => {
      await saveDisplayLimit("consecutive_days_max", 45);

      expect(await resetDisplayLimit("consecutive_days_max")).toBe(
        CONSECUTIVE_DAYS_DISPLAY.defaultMax
      );
      expect(await readSavedJson()).toEqual({});
    });

    test("loadDisplayLimitSettings() は表のキーを全件返す(重複なし)", async () => {
      const items = await loadDisplayLimitSettings();
      const keys = items.map((i) => i.key);

      // P1 で編集できるのは1つだけ(指示書AF §A-2 のA案)。
      // ★2つ目のキーを足したらこの件数も増える★ — そのときは期待値を更新すること
      expect(keys).toEqual(["consecutive_days_max"]);
      expect(new Set(keys).size).toBe(keys.length);
    });
  });
});