Newer
Older
client_dwatch / lib / db / __tests__ / robot-error-write-sql.test.ts
/**
 * ★★ガードテスト: `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("秘密の値");
    }
  });
});