/**
* ★★ガードテスト: `robot_error_reports` への UPDATE が許された列のうち渡した列だけになること★★
* (指示書AT §3-1 / 段階2。指示書BD §5-4。D-184)
*
* ★許された列は経路ごとに違う★: 事前チェック(`writeRobotErrorColumns()`)は4列、
* エラー一覧(`writeErrorReportColumns()`)は5列(4列+`error_list_id`)。どちらも ★`updated_at` を含まない★。
*
* ------------------------------------------------------------------------
* ★★何を守るためのテストか★★
* ------------------------------------------------------------------------
* `prisma/schema.prisma` の `RobotErrorReport.updated_at` に `@updatedAt` が付いていると、
* ★`updateMany` でも Prisma が `updated_at = <アプリの時刻(UTC)>` を SET に足す★(実測)。
* ① 列単位 GRANT の更新用アカウントでは権限エラー(5列目の書き込み。`CLAUDE.md` §7 違反)
* ② JST の DB に UTC の壁時計値(9時間前)が入る
* db pull のたびに `@updatedAt` を手で付け直す運用(D-059)があるので、
* ★誰かが機械的に付け直したら、`npx prisma generate` → `npm test` でここが赤くなる★。
*
* ------------------------------------------------------------------------
* ★★DBには接続しません★★
* ------------------------------------------------------------------------
* ★本物の生成クライアント★(`@/lib/generated/prisma/client`)に、
* ★送られた SQL を記録するだけの偽ドライバアダプタ★をつなぐ。
* 呼ぶのは ★本番と同じ書き込み関数★(`writeRobotErrorColumns()` / `writeErrorReportColumns()`)。テスト用に別の書き方をしない。
*/
import { describe, expect, it } from "vitest";
import type { SqlDriverAdapterFactory } from "@prisma/client/runtime/client";
import { PrismaClient } from "@/lib/generated/prisma/client";
import {
ERROR_REPORT_WRITABLE_COLUMNS,
ROBOT_ERROR_WRITABLE_COLUMNS,
columnsForErrorPatch,
columnsForPatch,
robotErrorColumnsFromErrorPatch,
robotErrorColumnsFromPatch,
writeErrorReportColumns,
writeRobotErrorColumns,
} from "@/lib/db/robot-error-write";
import type { ErrorStatusPatch, PrecheckStatusPatch } from "@/lib/types";
interface RecordedQuery {
sql: string;
args: unknown[];
}
/** SQL を記録するだけの偽アダプタ。★トランザクションを張られたら例外★(`updateMany` は1文のはず) */
function recordingAdapter(recorded: RecordedQuery[]): SqlDriverAdapterFactory {
const info = { provider: "mysql" as const, adapterName: "dwatch-recording-fake" };
return {
...info,
async connect() {
return {
...info,
async queryRaw(params) {
recorded.push({ sql: params.sql, args: params.args });
return { columnTypes: [], columnNames: [], rows: [] };
},
async executeRaw(params) {
recorded.push({ sql: params.sql, args: params.args });
return 1;
},
async executeScript() {
throw new Error("[fake adapter] executeScript は想定外です");
},
async startTransaction() {
throw new Error("[fake adapter] トランザクションは想定外です(updateMany は1文の UPDATE のはず)");
},
getConnectionInfo() {
return { supportsRelationJoins: false };
},
async dispose() {},
};
},
};
}
async function runWrite(patch: PrecheckStatusPatch) {
const recorded: RecordedQuery[] = [];
const client = new PrismaClient({ adapter: recordingAdapter(recorded) });
try {
const result = await writeRobotErrorColumns(client, "RER-TEST-1", patch);
return { recorded, result };
} finally {
await client.$disconnect();
}
}
async function runErrorWrite(patch: ErrorStatusPatch) {
const recorded: RecordedQuery[] = [];
const client = new PrismaClient({ adapter: recordingAdapter(recorded) });
try {
const result = await writeErrorReportColumns(client, "RER-TEST-1", patch);
return { recorded, result };
} finally {
await client.$disconnect();
}
}
/** `UPDATE ... SET a = ?, b = ? WHERE ...` の SET 句の列名を取り出す */
function setColumns(sql: string): string[] {
const match = /\bSET\s+([\s\S]+?)\s+WHERE\b/i.exec(sql);
if (!match) throw new Error(`SET 句が見つかりません: ${sql}`);
return match[1].split(",").map((part) => {
const column = /`([^`]+)`\s*=/.exec(part.trim());
if (!column) throw new Error(`列名を読めません: ${part}`);
return column[1];
});
}
function updateStatements(recorded: RecordedQuery[]): RecordedQuery[] {
return recorded.filter((q) => /^\s*UPDATE\b/i.test(q.sql));
}
/** 引数の日時を ISO 文字列にそろえる(アダプタに来る形が文字列でも Date でも比較できるように) */
function asIso(value: unknown): unknown {
return value instanceof Date ? value.toISOString() : value;
}
// `now` = 2026-10-02 08:15 JST(真の瞬間)
const NOW = new Date("2026-10-02T08:15:00+09:00");
describe("robot_error_reports への UPDATE(本物の生成クライアント+偽アダプタ)", () => {
it("★SET は渡したパッチの列と完全に一致し、updated_at を含まない★(3列)", async () => {
const { recorded, result } = await runWrite({
status: "in_progress",
note: "現場に連絡済み",
responded_at: NOW,
});
const updates = updateStatements(recorded);
expect(updates).toHaveLength(1);
expect(recorded).toHaveLength(1); // SELECT もトランザクションも無い1文
const columns = setColumns(updates[0].sql);
expect(columns).toEqual(["status", "comment", "responded_at"]);
expect(columns).not.toContain("updated_at");
expect(updates[0].sql).toMatch(/WHERE\s+.*`error_id`\s*=\s*\?/i);
expect(result).toEqual({ matched: 1, columns: ["status", "comment", "responded_at"] });
});
it("★4列すべてを渡しても4列だけ★(updated_at を含まない)", async () => {
const { recorded } = await runWrite({
status: "resolved",
note: null,
responded_at: NOW,
resolved_at: NOW,
});
const updates = updateStatements(recorded);
expect(updates).toHaveLength(1);
const columns = setColumns(updates[0].sql);
expect([...columns].sort()).toEqual(["comment", "resolved_at", "responded_at", "status"]);
expect(columns).not.toContain("updated_at");
});
it("1列だけ(status のみ)でも SET はその1列だけ", async () => {
const { recorded } = await runWrite({ status: "in_progress" });
const updates = updateStatements(recorded);
expect(updates).toHaveLength(1);
expect(setColumns(updates[0].sql)).toEqual(["status"]);
});
it("★日時の引数は壁時計値(toDbJst() を通した値)★ — 08:15 JST は \"2026-10-02T08:15:00.000Z\"", async () => {
const { recorded } = await runWrite({ responded_at: NOW, resolved_at: null });
const updates = updateStatements(recorded);
expect(updates).toHaveLength(1);
const columns = setColumns(updates[0].sql);
const args = updates[0].args.map(asIso);
expect(args[columns.indexOf("responded_at")]).toBe("2026-10-02T08:15:00.000Z");
expect(args[columns.indexOf("resolved_at")]).toBeNull();
// ★付け忘れると UTC の "2026-10-01T23:15:00.000Z" になる★
expect(args).not.toContain("2026-10-01T23:15:00.000Z");
});
it("status は 0/1/2 に、where の引数は error_id の文字列のまま", async () => {
const { recorded } = await runWrite({ status: "resolved" });
const [update] = updateStatements(recorded);
expect(update.args).toEqual([2, "RER-TEST-1"]);
});
});
describe("robotErrorColumnsFromPatch(実行時の歯止め)", () => {
it("★4つ以外のキーを渡すと例外★(updated_at / error_type)", () => {
for (const key of ["updated_at", "error_type", "comment"]) {
const patch = { status: "resolved", [key]: "x" } as unknown as PrecheckStatusPatch;
expect(() => robotErrorColumnsFromPatch(patch)).toThrow(/書き込めないキー/);
}
});
it("空パッチは例外(何も書かない要求はここまで来ない)", () => {
expect(() => robotErrorColumnsFromPatch({})).toThrow(/書き込む列がありません/);
});
it("日時が Date でも null でもなければ例外", () => {
const patch = { responded_at: "2026-10-02" } as unknown as PrecheckStatusPatch;
expect(() => robotErrorColumnsFromPatch(patch)).toThrow(/Date か null/);
});
it("note → comment に写す(null は削除)", () => {
expect(robotErrorColumnsFromPatch({ note: "a" })).toEqual({ comment: "a" });
expect(robotErrorColumnsFromPatch({ note: null })).toEqual({ comment: null });
});
it("例外のメッセージに備考の中身を出さない", () => {
const patch = { note: "秘密のメモ", extra: 1 } as unknown as PrecheckStatusPatch;
expect(() => robotErrorColumnsFromPatch(patch)).toThrow();
try {
robotErrorColumnsFromPatch(patch);
} catch (e) {
expect(String(e)).not.toContain("秘密のメモ");
}
});
});
// ---------------------------------------------------------------- エラー一覧の経路(5列。指示書BD §5-4。D-184)
describe("許された列の一覧(★増やすなら決定記録と CLAUDE.md §7 を先に直す★)", () => {
it("事前チェックは4列・エラー一覧は5列(4列+error_list_id)", () => {
expect([...ROBOT_ERROR_WRITABLE_COLUMNS]).toEqual(["status", "comment", "responded_at", "resolved_at"]);
expect([...ERROR_REPORT_WRITABLE_COLUMNS]).toEqual([
"status",
"comment",
"responded_at",
"resolved_at",
"error_list_id",
]);
});
});
describe("robot_error_reports への UPDATE — エラー一覧の経路(writeErrorReportColumns)", () => {
it("★5列すべてを渡しても SET は5列だけ★(updated_at を含まない・1文)", async () => {
const { recorded, result } = await runErrorWrite({
status: "resolved",
note: "その他の理由",
responded_at: NOW,
resolved_at: NOW,
error_list_id: 131,
});
const updates = updateStatements(recorded);
expect(updates).toHaveLength(1);
expect(recorded).toHaveLength(1);
const columns = setColumns(updates[0].sql);
expect(columns).toHaveLength(5);
expect([...columns].sort()).toEqual(["comment", "error_list_id", "resolved_at", "responded_at", "status"]);
expect(columns).not.toContain("updated_at");
expect(result.columns).toEqual(["status", "comment", "responded_at", "resolved_at", "error_list_id"]);
});
it("error_list_id だけ → SET は error_list_id の1列・引数は 131(bigint)と error_id", async () => {
const { recorded, result } = await runErrorWrite({ error_list_id: 131 });
const [update] = updateStatements(recorded);
expect(setColumns(update.sql)).toEqual(["error_list_id"]);
expect(update.args.map((a) => (typeof a === "bigint" ? a.toString() : a))).toEqual(["131", "RER-TEST-1"]);
expect(result).toEqual({ matched: 1, columns: ["error_list_id"] });
});
it("4列だけのパッチは事前チェックの経路と同じ SET になる", async () => {
const patch = { status: "in_progress" as const, note: "a", responded_at: NOW };
const a = await runWrite(patch);
const b = await runErrorWrite(patch);
expect(updateStatements(b.recorded)[0].sql).toBe(updateStatements(a.recorded)[0].sql);
expect(updateStatements(b.recorded)[0].args.map(asIso)).toEqual(updateStatements(a.recorded)[0].args.map(asIso));
});
});
describe("★事前チェックの経路では error_list_id を書けない★(指示書BD §2)", () => {
it("robotErrorColumnsFromPatch / columnsForPatch に error_list_id → 例外", () => {
const patch = { status: "resolved", error_list_id: 131 } as unknown as PrecheckStatusPatch;
expect(() => robotErrorColumnsFromPatch(patch)).toThrow(/書き込めないキー/);
expect(() => columnsForPatch(patch)).toThrow(/書き込めないキー/);
});
it("writeRobotErrorColumns に error_list_id → 例外・SQL を1文も送らない", async () => {
const patch = { error_list_id: 131 } as unknown as PrecheckStatusPatch;
await expect(runWrite(patch)).rejects.toThrow(/書き込めないキー/);
const recorded: RecordedQuery[] = [];
const client = new PrismaClient({ adapter: recordingAdapter(recorded) });
try {
await expect(writeRobotErrorColumns(client, "RER-TEST-1", patch)).rejects.toThrow();
} finally {
await client.$disconnect();
}
expect(recorded).toHaveLength(0);
});
});
describe("robotErrorColumnsFromErrorPatch(実行時の歯止め。5列版)", () => {
it("error_list_id は bigint に写す・4列の写しは4列版と同じ", () => {
expect(robotErrorColumnsFromErrorPatch({ error_list_id: 40 })).toEqual({ error_list_id: BigInt(40) });
expect(robotErrorColumnsFromErrorPatch({ note: null, status: "resolved" })).toEqual(
robotErrorColumnsFromPatch({ note: null, status: "resolved" })
);
expect(columnsForErrorPatch({ status: "resolved", error_list_id: 40 })).toEqual(["status", "error_list_id"]);
});
it("★5つ以外のキー → 例外★(updated_at / error_type / comment / error_list)", () => {
for (const key of ["updated_at", "error_type", "comment", "error_list"]) {
const patch = { error_list_id: 1, [key]: "x" } as unknown as ErrorStatusPatch;
expect(() => robotErrorColumnsFromErrorPatch(patch)).toThrow(/書き込めないキー/);
}
});
it("空パッチ → 例外", () => {
expect(() => robotErrorColumnsFromErrorPatch({})).toThrow(/書き込む列がありません/);
});
it.each([
["0", 0],
["負数", -1],
["小数", 1.5],
["文字列", "131"],
["null", null],
["2^53", 2 ** 53],
])("error_list_id が %s → 例外(ルートの検証を抜けて来たら呼び出しの誤り)", (_label, value) => {
const patch = { error_list_id: value } as unknown as ErrorStatusPatch;
expect(() => robotErrorColumnsFromErrorPatch(patch)).toThrow(/1以上の整数/);
});
it("例外のメッセージに値を出さない", () => {
const patch = { note: "秘密のメモ", error_list_id: "秘密の値" } as unknown as ErrorStatusPatch;
try {
robotErrorColumnsFromErrorPatch(patch);
expect.unreachable();
} catch (e) {
expect(String(e)).not.toContain("秘密のメモ");
expect(String(e)).not.toContain("秘密の値");
}
});
});