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