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,
});
});
});