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 {
__setDeviceNotesStoreForTesting,
createDeviceNotesStore,
readDeviceNotes,
saveDeviceNote,
} from "@/lib/settings/ds-device-notes";
/**
* D-Sデバイス備考(`lib/settings/ds-device-notes.ts`)の期待値表テスト。
*
* ★fs はモックしません★(`json-store.test.ts` / `advice.test.ts` と同じ方針)。
* 守りたい性質が「壊れたファイルの中身が本当に `.bak` に残るか」といった
* ファイルシステムそのものの挙動だからです。各テストは `fs.mkdtemp` で作った
* 独立した一時ディレクトリを使い、afterEach で必ず消します。
*
* ★`@/lib/logger` はモックします★。
* `lib/logger/index.ts` は `@/lib/db` 経由で fixtures 全体を引き込むうえ、
* ここで確かめたいのは「破損時に `settings.store_corrupt` を warn で1行出す」という
* 配線だけで、ロガー本体の挙動は `lib/logger/__tests__/` の担当だからです。
*/
const logEventMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/logger", () => ({ logEvent: logEventMock }));
/** 破損させたファイルの中身。`.bak` に原文が残ることの確認に使い回す */
const BROKEN_JSON = '{ "DS-001": "途中で切れた"';
let dir = "";
let filePath = "";
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-"));
filePath = path.join(dir, "ds-device-notes.json");
// 実ファイル(data/settings/ds-device-notes.json)を汚さないよう、必ず一時パスへ差し替える
__setDeviceNotesStoreForTesting(createDeviceNotesStore(filePath));
logEventMock.mockClear();
});
afterEach(async () => {
__setDeviceNotesStoreForTesting(null);
await fs.rm(dir, { recursive: true, force: true });
});
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);
}
describe("readDeviceNotes()", () => {
test("ファイルが無くても例外にならず、空で返る", async () => {
const notes = await readDeviceNotes();
// 「まだ誰も備考を入れていない」は正常な状態
expect(Object.keys(notes)).toEqual([]);
expect(notes).toEqual({});
// 読んだだけでファイルを作らない
expect(await fs.readdir(dir)).toEqual([]);
});
});
describe("saveDeviceNote()", () => {
test("保存した値がそのまま読み戻せる", async () => {
const saved = await saveDeviceNote("DS-001", "制御盤の位置が変更されました");
expect(saved).toBe("制御盤の位置が変更されました");
const notes = await readDeviceNotes();
expect(notes["DS-001"]).toBe("制御盤の位置が変更されました");
expect(await readSavedJson()).toEqual({
"DS-001": "制御盤の位置が変更されました",
});
});
test("前後の空白は落として保存する(保存されるのはトリム後の値)", async () => {
const saved = await saveDeviceNote("DS-001", " 電源系を要確認 ");
expect(saved).toBe("電源系を要確認");
expect((await readDeviceNotes())["DS-001"]).toBe("電源系を要確認");
});
test("同じデバイスを上書きすると、後から保存した値になる", async () => {
await saveDeviceNote("DS-001", "1回目");
const saved = await saveDeviceNote("DS-001", "2回目");
expect(saved).toBe("2回目");
expect(await readSavedJson()).toEqual({ "DS-001": "2回目" });
});
test("★空文字で保存するとキーごと消え、戻り値は null★", async () => {
await saveDeviceNote("DS-001", "いったん入力した備考");
const saved = await saveDeviceNote("DS-001", "");
expect(saved).toBeNull();
// 空文字を値として残さない(「未入力」と「空文字」を区別しない)
expect(await readDeviceNotes()).toEqual({});
expect(await readSavedJson()).toEqual({});
});
test("★前後の空白だけの文字列も削除扱いになる★", async () => {
await saveDeviceNote("DS-001", "いったん入力した備考");
const saved = await saveDeviceNote("DS-001", " ");
expect(saved).toBeNull();
// 画面上は空欄に見えるのにキーが残る、という状態を作らない
expect(await readDeviceNotes()).toEqual({});
expect(await readSavedJson()).toEqual({});
});
test("未保存のデバイスを空文字で保存しても例外にならない", async () => {
const saved = await saveDeviceNote("DS-999", "");
expect(saved).toBeNull();
expect(await readSavedJson()).toEqual({});
});
test("2件保存したあと1件だけ上書きしても、もう1件が消えない", async () => {
await saveDeviceNote("DS-001", "1号機の備考");
await saveDeviceNote("DS-002", "2号機の備考");
// 全件読んで1件だけ差し替えているか(読まずに書くと DS-002 が消える)
await saveDeviceNote("DS-001", "1号機の備考(更新)");
expect(await readSavedJson()).toEqual({
"DS-001": "1号機の備考(更新)",
"DS-002": "2号機の備考",
});
});
test("2件保存したあと1件だけ削除しても、もう1件が消えない", async () => {
await saveDeviceNote("DS-001", "1号機の備考");
await saveDeviceNote("DS-002", "2号機の備考");
expect(await saveDeviceNote("DS-001", "")).toBeNull();
const notes = await readDeviceNotes();
expect(notes["DS-001"]).toBeUndefined();
expect(notes["DS-002"]).toBe("2号機の備考");
expect(await readSavedJson()).toEqual({ "DS-002": "2号機の備考" });
});
});
describe("ファイルが壊れているとき", () => {
test(".bak に退避し、空で動き、settings.store_corrupt を warn で出す", async () => {
await fs.writeFile(filePath, BROKEN_JSON, "utf-8");
// 壊れていても画面は落とさない。備考なしとして動く
expect(await readDeviceNotes()).toEqual({});
const backupPath = await findBackupPath();
// /data は .gitignore なので、原文は .bak にしか残らない
expect(await fs.readFile(backupPath, "utf-8")).toBe(BROKEN_JSON);
expect(logEventMock).toHaveBeenCalledTimes(1);
const [level, payload] = logEventMock.mock.calls[0] as [
string,
Record<string, unknown>,
];
expect(level).toBe("warn");
expect(payload.event).toBe("settings.store_corrupt");
expect(payload.file_path).toBe(filePath);
expect(payload.backup_path).toBe(backupPath);
// 壊れ方の分類は入るが、壊れたファイルの中身そのものは載せない
expect(typeof payload.reason).toBe("string");
expect(payload.reason).toBeTruthy();
expect(payload.reason).not.toContain("途中で切れた");
});
test("破損を読んだ直後に保存しても、.bak の中身は失われない", async () => {
await fs.writeFile(filePath, BROKEN_JSON, "utf-8");
await readDeviceNotes();
const backupPath = await findBackupPath();
// 退避直後の保存が .bak を巻き込んで潰さないこと(唯一の控えなので致命的)
await saveDeviceNote("DS-001", "破損後の最初の備考");
expect(await fs.readFile(backupPath, "utf-8")).toBe(BROKEN_JSON);
expect(await readSavedJson()).toEqual({ "DS-001": "破損後の最初の備考" });
// .bak は1つだけ(2回目の読み込みで増えない=壊れたファイルは残っていない)
expect(
(await fs.readdir(dir)).filter((n) => n.endsWith(".bak"))
).toHaveLength(1);
});
});
describe("プロトタイプ汚染", () => {
test("ファイルに __proto__ というキーがあっても Object.prototype を汚さない", async () => {
await fs.writeFile(
filePath,
'{ "__proto__": { "polluted": "yes" }, "DS-001": "正常" }',
"utf-8"
);
const notes = await readDeviceNotes();
expect((({}) as Record<string, unknown>).polluted).toBeUndefined();
expect(
(Object.prototype as unknown as Record<string, unknown>).polluted
).toBeUndefined();
// 汚染しないだけで、正常なキーは失わない
expect(notes["DS-001"]).toBe("正常");
});
test("__proto__ をシリアルにして保存しても Object.prototype を汚さない", async () => {
const saved = await saveDeviceNote("__proto__", "汚染を狙う値");
expect(saved).toBe("汚染を狙う値");
// Object.prototype 自体が置き換わっていないこと
expect(Object.getPrototypeOf({})).toBe(Object.prototype);
expect(
(Object.prototype as unknown as Record<string, unknown>).polluted
).toBeUndefined();
// ★ここは toEqual で比較できません★。オブジェクトリテラルの `__proto__:` は
// 自前プロパティではなくプロトタイプ指定として解釈されるため、
// 期待値側が `{}` になってしまいます。キーと値を直接確かめます。
const raw = await readSavedJson();
expect(Object.keys(raw)).toEqual(["__proto__"]);
expect(raw["__proto__"]).toBe("汚染を狙う値");
// 読み戻しても普通のキーのまま(プロトタイプへ流れ込まない)
const notes = await readDeviceNotes();
expect(Object.keys(notes)).toEqual(["__proto__"]);
expect(notes["__proto__"]).toBe("汚染を狙う値");
});
test("constructor をシリアルにしても壊れない", async () => {
await saveDeviceNote("constructor", "紛らわしいシリアル");
await saveDeviceNote("DS-001", "普通のシリアル");
const notes = await readDeviceNotes();
expect(notes["constructor"]).toBe("紛らわしいシリアル");
expect(notes["DS-001"]).toBe("普通のシリアル");
});
});