/**
* ★★ガードテスト: `robot_error_reports` への UPDATE が4カラムのうち渡した列だけになること★★
* (指示書AT §3-1 / 段階2)
*
* ------------------------------------------------------------------------
* ★★何を守るためのテストか★★
* ------------------------------------------------------------------------
* `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()`)。テスト用に別の書き方をしない。
*/
import { describe, expect, it } from "vitest";
import type { SqlDriverAdapterFactory } from "@prisma/client/runtime/client";
import { PrismaClient } from "@/lib/generated/prisma/client";
import {
robotErrorColumnsFromPatch,
writeRobotErrorColumns,
} from "@/lib/db/robot-error-write";
import type { 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();
}
}
/** `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("秘密のメモ");
}
});
});