Newer
Older
client_dwatch / app / api / prechecks / [id] / status / __tests__ / route.test.ts
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// ★★ルート本体は**静的 import** すること★★(`app/api/prechecks/__tests__/route.test.ts` と同じ理由)
import { PATCH } from "@/app/api/prechecks/[id]/status/route";
import { CLEAN_TIMETABLE_PRECHECK, PRECHECK_MINUTES_BEFORE, PRECHECK_NOTE_MAX_LENGTH } from "@/lib/config";
import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
import { logEvent } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import type { DbWriteLogEvent } from "@/lib/logger/events";
import type { PrecheckItem } from "@/lib/types";

/**
 * `PATCH /prechecks/{id}/status`(Prisma 経路。指示書AT 段階5 / §5 のテスト一覧)。
 *
 * ★★DBには接続しません★★ — `getPrisma()`(参照用)と `getWritePrisma()`(書き込み用)を
 * スタブに差し替え、`USE_FIXTURES` を `false` に固定して `prismaSource` の経路を走らせる
 * (`app/api/prechecks/__tests__/route.test.ts` と同じ作法)。
 * ★`updateMany` の `data` を記録し、★4列のうち該当分だけ★が渡っていることを見る★。
 * (生成される SQL に `updated_at` が入らないことは `lib/db/__tests__/robot-error-write-sql.test.ts` が見る)
 */

// ---------------------------------------------------------------- スタブ(DBの代わり)

interface StubTimetable {
  id: bigint;
  parent_id: bigint;
  precheck: number;
  start_at: Date;
}

interface StubErrorReport {
  error_id: string;
  clean_timetable_id: bigint | null;
  error_type: string;
  errored_at: Date | null;
  created_at: Date;
  serial_number: string;
  facility_id: bigint;
  facility: { facility_name: string };
  status: number;
  responded_at: Date | null;
  resolved_at: Date | null;
  comment: string | null;
  /** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */
  robot_id?: bigint | null;
}

/**
 * `clean_task_reports` の1行(親の本清掃の結果。`main_run_succeeded` に使う。D-171)。
 * ★完了率は面積から出す★(`runCompletionRate()`。`clean_ratio` 列は読まない。D-155)
 */
interface StubTaskReport {
  clean_timetable_id: bigint | null;
  clean_area: number;
  task_area: number;
  clean_ratio: number;
  started_at: Date;
  ended_at: Date | null;
}

interface UpdateManyArgs {
  where: { error_id: string };
  data: Record<string, unknown>;
}

let timetables: StubTimetable[];
/** ★`clean_timetable_histories` の行(指示書AU 段階3b)。`buildData()` は空にする★ */
let historyTimetables: StubTimetable[];
let errorReports: StubErrorReport[];
let taskReports: StubTaskReport[];
/** `cleanTaskReport.findMany` に渡された `where`(★1回だけ・親の本清掃の id で引くこと★。D-171) */
let taskReportWheres: unknown[];
let updateCalls: UpdateManyArgs[];
/** 次の `updateMany` の振る舞い(既定は「一致した行に data を当てる」) */
let updateBehavior: "apply" | "throw" | "no_match";

/** DB の既定の照合順序は大小文字を区別しない(ci)— スタブでも再現する(D-4) */
function ciEqual(a: string, b: string): boolean {
  return a.toLowerCase() === b.toLowerCase();
}

class FakePrismaKnownError extends Error {
  code = "P2039";
  meta = {
    driverAdapterError: {
      cause: {
        originalCode: "1143",
        originalMessage: "UPDATE command denied to user 'secret-db-user'@'host' for column 'updated_at'",
        kind: "mysql",
      },
    },
  };
}

vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));

vi.mock("@/lib/logger", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/lib/logger")>();
  // ★`withRequestId` はそのまま使う★。見たいのは `logEvent`(db.write)だけ
  return { ...actual, logEvent: vi.fn() };
});

vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));

vi.mock("@/lib/db/prisma", () => {
  const read = {
    robotErrorReport: {
      findUnique: async (args: { where: { error_id: string }; select?: Record<string, unknown> }) => {
        // ★`robot_id` が NULL の行を `robot_id` ごと読むと本物の Prisma は落ちる(指示書AU 段階0-4)★
        guardNullRobotId(errorReports as unknown as Record<string, unknown>[], args);
        return errorReports.find((r) => ciEqual(r.error_id, args.where.error_id)) ?? null;
      },
      updateMany: async () => {
        throw new Error("[stub] 参照用の接続で updateMany を呼んではいけない");
      },
    },
    // ★予定は本表 → 無ければ履歴の順に id で引く(`findTimetableById()`。指示書AU 段階3b-2 の 5)★。
    //   旧: 親の本清掃行は `findFirst({ id, precheck })` — いまは id で引いて `precheck` を JS で確かめる
    cleanTimetable: {
      findUnique: async (args: { where: { id: bigint } }) =>
        timetables.find((t) => t.id === args.where.id) ?? null,
    },
    cleanTimetableHistory: {
      findUnique: async (args: { where: { id: bigint } }) =>
        historyTimetables.find((t) => t.id === args.where.id) ?? null,
    },
    // ★親の本清掃のレポート(`main_run_succeeded`。D-171)★
    cleanTaskReport: {
      findMany: async (args: { where: { clean_timetable_id: bigint } }) => {
        taskReportWheres.push(args.where);
        return taskReports.filter((r) => r.clean_timetable_id === args.where.clean_timetable_id);
      },
    },
  };
  const write = {
    robotErrorReport: {
      updateMany: async (args: UpdateManyArgs) => {
        updateCalls.push(args);
        if (updateBehavior === "throw") throw new FakePrismaKnownError("Database error");
        if (updateBehavior === "no_match") return { count: 0 };
        const rows = errorReports.filter((r) => ciEqual(r.error_id, args.where.error_id));
        for (const row of rows) Object.assign(row, args.data);
        return { count: rows.length };
      },
    },
  };
  return { getPrisma: async () => read, getWritePrisma: async () => write };
});

// ---------------------------------------------------------------- 合成データ

/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115) */
function dbWallClock(jstIso: string): Date {
  return new Date(jstIso.replace("+09:00", "Z"));
}

const NOW_ISO = "2026-10-02T08:15:00+09:00";
const T1_ISO = "2026-10-02T07:00:00+09:00";
const T2_ISO = "2026-10-02T07:30:00+09:00";

function errorReport(
  errorId: string,
  timetableId: number | null,
  overrides: Partial<StubErrorReport> = {}
): StubErrorReport {
  return {
    error_id: errorId,
    clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
    error_type: "EmergencyStop",
    errored_at: dbWallClock("2026-10-02T01:00:00+09:00"),
    created_at: dbWallClock("2026-10-02T01:00:00+09:00"),
    serial_number: "8111R5628060011",
    facility_id: BigInt(1),
    // ★実在の店名を使わない★(`CLAUDE.md` §7)
    facility: { facility_name: "バリューマート北町店" },
    status: 0,
    responded_at: null,
    resolved_at: null,
    comment: null,
    ...overrides,
  };
}

function buildData() {
  historyTimetables = [];
  timetables = [
    // 本清掃行(親)
    { id: BigInt(101), parent_id: BigInt(0), precheck: CLEAN_TIMETABLE_PRECHECK.main, start_at: dbWallClock("2026-10-02T02:00:00+09:00") },
    // 事前チェック行(60分前 / 30分前)
    { id: BigInt(1011), parent_id: BigInt(101), precheck: PRECHECK_MINUTES_BEFORE[0], start_at: dbWallClock("2026-10-02T01:00:00+09:00") },
    { id: BigInt(1012), parent_id: BigInt(101), precheck: PRECHECK_MINUTES_BEFORE[1], start_at: dbWallClock("2026-10-02T01:30:00+09:00") },
    // ★親が存在しない事前チェック行★(構造的に壊れたデータ → 500)
    { id: BigInt(2011), parent_id: BigInt(999), precheck: PRECHECK_MINUTES_BEFORE[0], start_at: dbWallClock("2026-10-02T01:00:00+09:00") },
  ];
  // ★親の本清掃 101 は 95%(◎)★ → `main_run_succeeded: true`(D-171 の M1)
  taskReports = [
    {
      clean_timetable_id: BigInt(101),
      clean_area: 95,
      task_area: 100,
      clean_ratio: 0,
      started_at: dbWallClock("2026-10-02T02:00:00+09:00"),
      ended_at: dbWallClock("2026-10-02T03:00:00+09:00"),
    },
  ];
  taskReportWheres = [];
  errorReports = [
    errorReport("RER-UNRESOLVED", 1011),
    errorReport("RER-IN-PROGRESS", 1012, { status: 1, responded_at: dbWallClock(T1_ISO), comment: "既存の備考" }),
    errorReport("RER-RESOLVED", 1011, { status: 2, responded_at: dbWallClock(T1_ISO), resolved_at: dbWallClock(T2_ISO) }),
    // ★予定に紐づかないエラー★(N-5 の D-S制御盤・セキュリティの行を含む)→ 404
    errorReport("RER-NO-TIMETABLE", null),
    // ★本清掃行を指すエラー★(`launch_failed` に読み替えない。D-104)→ 404
    errorReport("RER-MAIN", 101),
    // ★参照先の予定行が無い★ → 404
    errorReport("RER-DANGLING", 777),
    // ★親の本清掃行が無い事前チェック行★ → 500
    errorReport("RER-ORPHAN", 2011),
  ];
}

async function patch(id: string, body: unknown, raw = false): Promise<Response> {
  return PATCH(
    new Request(`http://localhost/api/prechecks/${encodeURIComponent(id)}/status`, {
      method: "PATCH",
      body: raw ? (body as string) : JSON.stringify(body),
    }),
    { params: Promise.resolve({ id }) }
  );
}

function dbWriteLogs(): { level: string; payload: DbWriteLogEvent }[] {
  return vi
    .mocked(logEvent)
    .mock.calls.filter(([, payload]) => payload.event === "db.write")
    .map(([level, payload]) => ({ level, payload: payload as DbWriteLogEvent }));
}

beforeEach(() => {
  buildData();
  updateCalls = [];
  updateBehavior = "apply";
  vi.mocked(logEvent).mockClear();
  vi.mocked(logApiError).mockClear();
  // ★Date だけを止める★(`now` = 2026-10-02 08:15 JST)
  vi.useFakeTimers({ toFake: ["Date"] });
  vi.setSystemTime(new Date(NOW_ISO));
});

afterEach(() => {
  vi.useRealTimers();
});

// ---------------------------------------------------------------- 200

describe("200 — robot_id が NULL のエラー行(指示書AU 段階0-4。第11回 P-8 b)", () => {
  it("★robot_id が NULL の行でも読み取りで落ちず、更新後の行を返す★(select で robot_id / robot を読まない)", async () => {
    errorReports = errorReports.map((r) => (r.error_id === "RER-UNRESOLVED" ? { ...r, robot_id: null } : r));
    const res = await patch("RER-UNRESOLVED", { status: "in_progress" });
    expect(res.status).toBe(200);
    const body = (await res.json()) as PrecheckItem;
    expect(body).toMatchObject({ id: "RER-UNRESOLVED", resolution_status: "in_progress" });
  });
});

describe("200 — main_run_succeeded(ユーザー指示 2026-10-07。D-171)", () => {
  it("★親の本清掃が ◎ なら true★。レポートは親の本清掃の id で1回だけ引く", async () => {
    const res = await patch("RER-UNRESOLVED", { status: "in_progress" });
    expect(res.status).toBe(200);
    const body = (await res.json()) as PrecheckItem;
    // ★対応状況は本清掃の成否に関係なく、送った値のまま★
    expect(body).toMatchObject({ resolution_status: "in_progress", main_run_succeeded: true });
    for (const where of taskReportWheres) expect(where).toEqual({ clean_timetable_id: BigInt(101) });
  });

  it("親の本清掃にレポートが無ければ false(M4)/ △ なら false(M3)", async () => {
    const saved = taskReports;
    taskReports = [];
    const none = (await (await patch("RER-UNRESOLVED", { status: "in_progress" })).json()) as PrecheckItem;
    expect(none.main_run_succeeded).toBe(false);

    taskReports = saved.map((r) => ({ ...r, clean_area: 50 }));
    const poor = (await (await patch("RER-UNRESOLVED", { status: "resolved" })).json()) as PrecheckItem;
    expect(poor.main_run_succeeded).toBe(false);
  });
});

describe("200 — 状態の遷移(§3-4)", () => {
  it("#1 未解消 → 対応中: status と responded_at だけを書き、更新後の行を返す", async () => {
    const res = await patch("RER-UNRESOLVED", { status: "in_progress" });
    expect(res.status).toBe(200);

    expect(updateCalls).toHaveLength(1);
    expect(updateCalls[0].where).toEqual({ error_id: "RER-UNRESOLVED" });
    // ★4列のうち該当分だけ★
    expect(Object.keys(updateCalls[0].data).sort()).toEqual(["responded_at", "status"]);
    expect(updateCalls[0].data.status).toBe(1);
    // ★`toDbJst()` が通っている★ — 08:15 JST は壁時計値 "2026-10-02T08:15:00Z" として渡る
    expect((updateCalls[0].data.responded_at as Date).toISOString()).toBe("2026-10-02T08:15:00.000Z");

    const body = (await res.json()) as PrecheckItem;
    expect(body).toMatchObject({
      id: "RER-UNRESOLVED",
      severity: "precheck",
      resolution_status: "in_progress",
      responded_at: NOW_ISO,
      resolved_at: null,
      note: null,
    });
  });

  it("#2 未解消 → 解消: status / responded_at / resolved_at を書く", async () => {
    const res = await patch("RER-UNRESOLVED", { status: "resolved" });
    expect(res.status).toBe(200);
    expect(Object.keys(updateCalls[0].data).sort()).toEqual(["resolved_at", "responded_at", "status"]);
    const body = (await res.json()) as PrecheckItem;
    expect(body).toMatchObject({ resolution_status: "resolved", responded_at: NOW_ISO, resolved_at: NOW_ISO });
  });

  it("#4 対応中 → 解消: responded_at は変えない", async () => {
    const res = await patch("RER-IN-PROGRESS", { status: "resolved" });
    expect(res.status).toBe(200);
    expect(Object.keys(updateCalls[0].data).sort()).toEqual(["resolved_at", "status"]);
    const body = (await res.json()) as PrecheckItem;
    expect(body).toMatchObject({ resolution_status: "resolved", responded_at: T1_ISO, resolved_at: NOW_ISO });
  });

  it("#8 解消 → 未解消: 両方の日時を null にする(戻すと消す)", async () => {
    const res = await patch("RER-RESOLVED", { status: "unresolved" });
    expect(res.status).toBe(200);
    expect(updateCalls[0].data).toEqual({ status: 0, responded_at: null, resolved_at: null });
    const body = (await res.json()) as PrecheckItem;
    expect(body).toMatchObject({ resolution_status: "unresolved", responded_at: null, resolved_at: null });
  });

  it("#9 解消 → 対応中: resolved_at だけを消す", async () => {
    const res = await patch("RER-RESOLVED", { status: "in_progress" });
    expect(res.status).toBe(200);
    expect(updateCalls[0].data).toEqual({ status: 1, resolved_at: null });
  });

  it("備考だけ変える(現在の status を送る): comment だけを書く・前後の空白を除く", async () => {
    const res = await patch("RER-IN-PROGRESS", { status: "in_progress", note: "  店長に確認  " });
    expect(res.status).toBe(200);
    expect(updateCalls[0].data).toEqual({ comment: "店長に確認" });
    expect(((await res.json()) as PrecheckItem).note).toBe("店長に確認");
  });

  it("備考を空にする → comment = null", async () => {
    const res = await patch("RER-IN-PROGRESS", { status: "in_progress", note: "   " });
    expect(res.status).toBe(200);
    expect(updateCalls[0].data).toEqual({ comment: null });
    expect(((await res.json()) as PrecheckItem).note).toBeNull();
  });

  it("★空パッチなら updateMany を呼ばず、現在の値を返す。db.write も出さない★", async () => {
    for (const [id, body] of [
      ["RER-UNRESOLVED", { status: "unresolved" }],
      ["RER-IN-PROGRESS", { status: "in_progress", note: " 既存の備考 " }],
      ["RER-RESOLVED", { status: "resolved" }],
    ] as const) {
      const res = await patch(id, body);
      expect(res.status).toBe(200);
      expect(((await res.json()) as PrecheckItem).id).toBe(id);
    }
    expect(updateCalls).toHaveLength(0);
    expect(dbWriteLogs()).toHaveLength(0);
  });

  it("★本文に updated_at・error_type・memo を混ぜても data に出ない★", async () => {
    const res = await patch("RER-UNRESOLVED", {
      status: "in_progress",
      note: "a",
      updated_at: "2000-01-01T00:00:00Z",
      error_type: "Hacked",
      memo: "旧名",
      comment: "直接",
      serial_number: "X",
    });
    expect(res.status).toBe(200);
    expect(Object.keys(updateCalls[0].data).sort()).toEqual(["comment", "responded_at", "status"]);
    expect(updateCalls[0].data.comment).toBe("a");
  });
});

// ---------------------------------------------------------------- 422

describe("422 — 入力の形式", () => {
  it.each([
    ["id が51文字", "A".repeat(51), { status: "resolved" }, false],
    ["JSON でない", "RER-UNRESOLVED", "{not json", true],
    ["status が enum 外", "RER-UNRESOLVED", { status: "done" }, false],
    ["status が無い", "RER-UNRESOLVED", { note: "a" }, false],
    ["note が数値", "RER-UNRESOLVED", { status: "resolved", note: 1 }, false],
    ["note が null", "RER-UNRESOLVED", { status: "resolved", note: null }, false],
    [`note が${PRECHECK_NOTE_MAX_LENGTH + 1}文字`, "RER-UNRESOLVED", { status: "resolved", note: "a".repeat(PRECHECK_NOTE_MAX_LENGTH + 1) }, false],
  ] as const)("%s → 422(書き込まない)", async (_label, id, body, raw) => {
    const res = await patch(id, body, raw);
    expect(res.status).toBe(422);
    expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation_failed");
    expect(updateCalls).toHaveLength(0);
  });
});

// ---------------------------------------------------------------- 404 / 500

describe("200 — 履歴(clean_timetable_histories)の予定に紐づくエラー行(指示書AU 段階3b。§3b-7 Q4)", () => {
  it("★事前チェック行も親の本清掃行も履歴だけにある → 書ける★(実在するアラートなので)", async () => {
    historyTimetables = [
      { id: BigInt(301), parent_id: BigInt(0), precheck: CLEAN_TIMETABLE_PRECHECK.main, start_at: dbWallClock("2026-10-01T02:00:00+09:00") },
      { id: BigInt(3011), parent_id: BigInt(301), precheck: PRECHECK_MINUTES_BEFORE[0], start_at: dbWallClock("2026-10-01T01:00:00+09:00") },
    ];
    errorReports.push(errorReport("RER-HISTORY", 3011));
    const res = await patch("RER-HISTORY", { status: "in_progress" });
    expect(res.status).toBe(200);
    const body = (await res.json()) as PrecheckItem;
    expect(body).toMatchObject({ id: "RER-HISTORY", scheduled_at: "2026-10-01T02:00:00+09:00" });
  });

  it("★本表の事前チェック行の親が履歴だけにある → 書ける★", async () => {
    historyTimetables = [
      { id: BigInt(999), parent_id: BigInt(0), precheck: CLEAN_TIMETABLE_PRECHECK.main, start_at: dbWallClock("2026-10-02T02:00:00+09:00") },
    ];
    const res = await patch("RER-ORPHAN", { status: "in_progress" });
    expect(res.status).toBe(200);
  });

  it("★同じ id が本表にもあれば本表が勝つ★(本表の親が本清掃でなければ、履歴が本清掃でも 500)", async () => {
    timetables.push({ id: BigInt(999), parent_id: BigInt(0), precheck: PRECHECK_MINUTES_BEFORE[0], start_at: dbWallClock("2026-10-02T01:00:00+09:00") });
    historyTimetables = [
      { id: BigInt(999), parent_id: BigInt(0), precheck: CLEAN_TIMETABLE_PRECHECK.main, start_at: dbWallClock("2026-10-02T02:00:00+09:00") },
    ];
    const res = await patch("RER-ORPHAN", { status: "in_progress" });
    expect(res.status).toBe(500);
  });
});

describe("404 — 対応状況を記録できる行ではない(§3-3)", () => {
  it.each([
    ["行が無い", "RER-NOT-EXIST"],
    ["clean_timetable_id が null", "RER-NO-TIMETABLE"],
    ["本清掃行を指すエラー", "RER-MAIN"],
    ["参照先の予定行が無い", "RER-DANGLING"],
    // ★出発失敗の id(本清掃の clean_timetables.id の文字列)は error_id として当たらない★
    ["出発失敗の id", "101"],
    // ★DB は ci で当たるが JS の === で弾く★(D-4。別の文字列で同じ行を書き換えさせない)
    ["大小文字だけ違う id", "rer-unresolved"],
  ])("%s → 404(書き込まない)", async (_label, id) => {
    const res = await patch(id, { status: "resolved" });
    expect(res.status).toBe(404);
    expect(await res.json()).toEqual({
      error: {
        code: "not_found",
        message: "対応状況を記録できるアラートが見つかりません(出発失敗には記録先がありません)",
        details: null,
      },
    });
    expect(updateCalls).toHaveLength(0);
    expect(dbWriteLogs()).toHaveLength(0);
  });

  it("親の本清掃行が無い事前チェック行 → 500(prechecks() と同じく例外)", async () => {
    const res = await patch("RER-ORPHAN", { status: "resolved" });
    expect(res.status).toBe(500);
    expect(updateCalls).toHaveLength(0);
    expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1);
  });

  it("★matched === 0(確認と書き込みの間に行が消えた)→ 404。db.write は success: false / no_matching_row★", async () => {
    updateBehavior = "no_match";
    const res = await patch("RER-UNRESOLVED", { status: "in_progress" });
    expect(res.status).toBe(404);
    const logs = dbWriteLogs();
    expect(logs).toHaveLength(1);
    expect(logs[0].level).toBe("error");
    expect(logs[0].payload).toMatchObject({ success: false, db_error_code: "no_matching_row" });
  });

  it("★書き込みの例外 → 500。db.write(success: false)と api.error が1件ずつ。コードだけを載せる★", async () => {
    updateBehavior = "throw";
    const res = await patch("RER-UNRESOLVED", { status: "in_progress" });
    expect(res.status).toBe(500);
    const logs = dbWriteLogs();
    expect(logs).toHaveLength(1);
    expect(logs[0].level).toBe("error");
    expect(logs[0].payload).toMatchObject({ success: false, db_error_code: "P2039:1143" });
    // ★エラーメッセージ(DB のユーザー名が入る)を載せない★
    expect(JSON.stringify(logs[0].payload)).not.toContain("secret-db-user");
    // ★db.write には err を渡さない★
    const dbWriteCall = vi.mocked(logEvent).mock.calls.find(([, p]) => p.event === "db.write");
    expect((dbWriteCall as unknown[] | undefined)?.length).toBe(2);
    expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1);
  });
});

// ---------------------------------------------------------------- 監査ログ

describe("db.write(§3-7)", () => {
  it("★成功で1件。record_id は文字列・columns は実際に SET した列だけ・備考の中身を含まない★", async () => {
    const res = await patch("RER-UNRESOLVED", { status: "in_progress", note: "秘密の備考メモ" });
    expect(res.status).toBe(200);
    const logs = dbWriteLogs();
    expect(logs).toHaveLength(1);
    expect(logs[0].level).toBe("info");
    expect(logs[0].payload).toEqual({
      event: "db.write",
      route: "/api/prechecks/[id]/status",
      method: "PATCH",
      table: "robot_error_reports",
      record_id: "RER-UNRESOLVED",
      columns: ["status", "comment", "responded_at"],
      status_old: "unresolved",
      status_new: "in_progress",
      responded_at_old: null,
      responded_at_new: NOW_ISO,
      resolved_at_old: null,
      resolved_at_new: null,
      comment_changed: true,
      success: true,
    });
    expect(typeof logs[0].payload.record_id).toBe("string");
    expect(JSON.stringify(logs[0].payload)).not.toContain("秘密の備考メモ");
  });

  it("状態だけの変更では comment_changed: false・columns に comment が無い", async () => {
    await patch("RER-RESOLVED", { status: "unresolved" });
    const [log] = dbWriteLogs();
    expect(log.payload).toMatchObject({
      columns: ["status", "responded_at", "resolved_at"],
      status_old: "resolved",
      status_new: "unresolved",
      responded_at_old: T1_ISO,
      responded_at_new: null,
      resolved_at_old: T2_ISO,
      resolved_at_new: null,
      comment_changed: false,
    });
  });
});