import { beforeEach, describe, expect, it, vi } from "vitest";
// ★★ルート本体は**静的 import** すること★★(`app/api/prechecks/__tests__/route.test.ts` と同じ理由)
import { GET } from "@/app/api/errors/route";
import { PRECHECK_MINUTES_BEFORE } from "@/lib/config";
import { db as dataSource } from "@/lib/db";
import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
import { errorReportListWhere } from "@/lib/db/error-report-mapping";
import { robotErrorStatusFromResolutionStatus } from "@/lib/db/precheck-mapping";
import { ROBOT_ERROR_LIST_KEY_SELECT, ROBOT_ERROR_LIST_ROW_SELECT } from "@/lib/db/robot-error-select";
import { toDbJst } from "@/lib/format/date";
import { logApiError } from "@/lib/logger/api";
import type { ApiError, ErrorReportItem, ListResponse } from "@/lib/types";
/**
* `GET /errors`(Prisma 経路。指示書AW 段階4-4-1)。
*
* ★★DBには接続しません★★ — `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に固定して
* `prismaSource.errorReports()` の経路を走らせる(`app/api/prechecks/[id]/status/__tests__/route.test.ts` と同じ作法)。
*
* ★スタブの `robotErrorReport.findMany` は★
* - ★`guardNullRobotId()` を通す★(`robot_id` が NULL の行を `robot_id` ごと読むと本物の Prisma は落ちる)
* - ★`select` を見て返す列を絞る★(1段目と2段目で返す形が違う)
* - `where` の ★`status.in` / `facility_id.in` / `OR`(期間)/ `error_id.in`★ を評価する(★知らないキーは例外★)
*
* ★表1 は監督確定(D-160 / 指示書AW §3)。変えないこと★。表2 は指示書AW で導いたもの(監督確認済み)。
* 入力は表1 の9行(`facility_id` は E1〜E5 = 1、E6〜E9 = 2)。
* ★E7 の `created_at`(10/6 08:00)は9行の中で一番新しい★ → E7 が先頭(`nulls: "last"` の並べ替えなら最後)。
* ★E4 = "E-10"・E5 = "E-9" は同じ検知時刻★ → 辞書順で "E-10" が先(数値順なら逆)。
* ★E6 の予定は履歴(`clean_timetable_histories`)にだけ置く★。
*/
// ---------------------------------------------------------------- スタブ(DBの代わり)
interface StubErrorReport {
error_id: string;
/** ★DDL では NULL 可(第11回 P-8 b)。読まないこと(指示書AU 段階0-4)★ */
robot_id: bigint | null;
clean_timetable_id: bigint | null;
serial_number: string;
facility_id: bigint;
facility: { facility_name: string };
error_type: string;
error_level: string;
errored_at: Date | null;
created_at: Date;
status: number;
responded_at: Date | null;
resolved_at: Date | null;
comment: string | null;
}
interface StubTimetable {
id: bigint;
precheck: number;
}
interface StubFacility {
id: bigint;
facility_name: string;
customer_id: bigint;
customer: { group_id: bigint };
}
type Args = Record<string, unknown> & { where?: Record<string, unknown>; select?: Record<string, unknown> };
let errorReports: StubErrorReport[];
let mainTimetables: StubTimetable[];
let historyTimetables: StubTimetable[];
let facilityRows: StubFacility[];
/** `robotErrorReport.findMany` の引数の記録(1段目・2段目) */
let errorCalls: Args[];
/** `cleanTimetable.findMany` の引数と、返した id の記録 */
let mainTimetableCalls: { args: Args; returned: string[] }[];
let historyTimetableCalls: Args[];
/** ★2段目で消えたことにする error_id★(1段目と2段目の間に行が消えた。指示書AW 4-15) */
let vanishOnSecondStage: Set<string>;
/** 次の `robotErrorReport.findMany` を例外にする */
let throwOnErrorFindMany: boolean;
vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));
/** DB の既定の照合順序は大小文字を区別しない(ci)— スタブでも再現する(D-4) */
function ciEqual(a: string, b: string): boolean {
return a.toLowerCase() === b.toLowerCase();
}
interface DateRange {
gte?: Date;
lt?: Date;
}
function inRange(value: Date | null, range: DateRange): boolean {
if (value === null) return false;
if (range.gte && value.getTime() < range.gte.getTime()) return false;
if (range.lt && value.getTime() >= range.lt.getTime()) return false;
return true;
}
/** `where` を評価する(★知らないキーは例外★ — ルートが別の条件を組み立てたら気づけるように) */
function matchErrorWhere(row: StubErrorReport, where: Record<string, unknown>): boolean {
for (const [key, cond] of Object.entries(where)) {
switch (key) {
case "status":
if (!(cond as { in: number[] }).in.includes(row.status)) return false;
break;
case "facility_id":
if (!(cond as { in: bigint[] }).in.some((id) => id === row.facility_id)) return false;
break;
case "error_id":
if (!(cond as { in: string[] }).in.some((id) => ciEqual(id, row.error_id))) return false;
break;
case "OR": {
const ok = (cond as Record<string, unknown>[]).some((c) => {
if (c.errored_at === null) {
return row.errored_at === null && inRange(row.created_at, c.created_at as DateRange);
}
return inRange(row.errored_at, c.errored_at as DateRange);
});
if (!ok) return false;
break;
}
default:
throw new Error(`[stub] robotErrorReport.findMany の where に想定外のキー: ${key}`);
}
}
return true;
}
/** `select` で返す列を絞る(入れ子の `{ select }` にも対応) */
function project(row: Record<string, unknown>, select: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(select)) {
if (value === true) out[key] = row[key];
else if (value && typeof value === "object" && "select" in value) {
out[key] = project(row[key] as Record<string, unknown>, (value as { select: Record<string, unknown> }).select);
}
}
return out;
}
function timetableFindMany(rows: StubTimetable[], args: Args): StubTimetable[] {
const ids = ((args.where?.id as { in: bigint[] } | undefined)?.in ?? []).map((id) => id.toString());
return rows.filter((t) => ids.includes(t.id.toString()));
}
vi.mock("@/lib/db/prisma", () => {
const read = {
robotErrorReport: {
findMany: async (args: Args) => {
errorCalls.push(args);
if (throwOnErrorFindMany) throw new Error("[stub] DB が落ちた");
guardNullRobotId(errorReports as unknown as Record<string, unknown>[], args);
const isSecondStage = args.where !== undefined && "error_id" in args.where;
return errorReports
.filter((row) => matchErrorWhere(row, args.where ?? {}))
.filter((row) => !(isSecondStage && vanishOnSecondStage.has(row.error_id)))
.map((row) => project(row as unknown as Record<string, unknown>, args.select ?? {}));
},
findUnique: async () => {
throw new Error("[stub] 一覧で findUnique を呼んではいけない");
},
},
cleanTimetable: {
findMany: async (args: Args) => {
const rows = timetableFindMany(mainTimetables, args);
mainTimetableCalls.push({ args, returned: rows.map((r) => r.id.toString()) });
return rows;
},
},
cleanTimetableHistory: {
findMany: async (args: Args) => {
historyTimetableCalls.push(args);
return timetableFindMany(historyTimetables, args);
},
},
// ★施設名・分類の解決(`facility.findMany`)はここに置かない★ — 下の `beforeEach` で
// `db.facilityNameIndex()` / `db.facilityGroupIndex()` を差し替える(理由はそちらのコメント)
facility: {
findMany: async () => {
throw new Error("[stub] facility.findMany は呼ばない(db の2メソッドを差し替えてある)");
},
},
};
return {
getPrisma: async () => read,
getWritePrisma: async () => {
throw new Error("[stub] 一覧で書き込み用の接続を取ってはいけない");
},
};
});
// ---------------------------------------------------------------- 合成データ(表1)
/** `+09:00` 付きの ISO → DB の壁時計値の `Date`(Prisma が返す形) */
const db = (iso: string): Date => toDbJst(new Date(iso));
const UNRESOLVED = robotErrorStatusFromResolutionStatus("unresolved");
const IN_PROGRESS = robotErrorStatusFromResolutionStatus("in_progress");
const RESOLVED = robotErrorStatusFromResolutionStatus("resolved");
const PRECHECK_30 = PRECHECK_MINUTES_BEFORE[1];
// ★10 は設定(60/30)に無い値であることが本題なので直書きする★(D-105 項1)
const PRECHECK_10 = 10;
function row(overrides: Partial<StubErrorReport> & Pick<StubErrorReport, "error_id">): StubErrorReport {
return {
robot_id: BigInt(501),
clean_timetable_id: BigInt(100),
serial_number: "PD-CC1-0001",
facility_id: BigInt(1),
facility: { facility_name: "テスト施設A" },
error_type: "EmergencyStop",
error_level: "ERROR",
errored_at: db("2026-10-05T19:00:00+09:00"),
created_at: db("2026-10-05T19:01:00+09:00"),
status: UNRESOLVED,
responded_at: null,
resolved_at: null,
comment: null,
...overrides,
};
}
const FACILITY_B = { facility_id: BigInt(2), facility: { facility_name: "テスト施設B" } };
function buildData() {
errorReports = [
row({ error_id: "E-1", errored_at: db("2026-10-05T19:05:00+09:00"), created_at: db("2026-10-05T19:06:00+09:00") }),
row({
error_id: "E-2",
clean_timetable_id: BigInt(101),
error_type: "LowBattery",
errored_at: db("2026-10-05T19:30:00+09:00"),
created_at: db("2026-10-05T19:31:00+09:00"),
status: IN_PROGRESS,
responded_at: db("2026-10-06T09:00:00+09:00"),
}),
row({
error_id: "E-3",
errored_at: db("2026-10-05T20:00:00+09:00"),
created_at: db("2026-10-05T20:01:00+09:00"),
status: RESOLVED,
responded_at: db("2026-10-06T09:00:00+09:00"),
resolved_at: db("2026-10-06T10:00:00+09:00"),
comment: "対応済み",
}),
row({ error_id: "E-10", clean_timetable_id: null, errored_at: db("2026-10-05T21:00:00+09:00"), created_at: db("2026-10-05T21:01:00+09:00") }),
row({ error_id: "E-9", clean_timetable_id: BigInt(999), errored_at: db("2026-10-05T21:00:00+09:00"), created_at: db("2026-10-05T21:02:00+09:00") }),
row({ error_id: "E-6", ...FACILITY_B, clean_timetable_id: BigInt(106), errored_at: db("2026-10-05T20:30:00+09:00"), created_at: db("2026-10-05T20:31:00+09:00") }),
row({ error_id: "E-7", ...FACILITY_B, errored_at: null, created_at: db("2026-10-06T08:00:00+09:00") }),
row({
error_id: "E-8",
...FACILITY_B,
robot_id: null,
serial_number: "0",
error_type: "SECURITY_ON",
error_level: "CRITICAL",
errored_at: db("2026-10-05T21:30:00+09:00"),
created_at: db("2026-10-05T21:31:00+09:00"),
}),
row({ error_id: "E-11", ...FACILITY_B, clean_timetable_id: BigInt(109), errored_at: db("2026-10-05T22:00:00+09:00"), created_at: db("2026-10-05T22:01:00+09:00") }),
];
mainTimetables = [
{ id: BigInt(100), precheck: 0 },
{ id: BigInt(101), precheck: PRECHECK_30 },
{ id: BigInt(109), precheck: PRECHECK_10 },
];
// ★E6 の予定は履歴にだけある★
historyTimetables = [{ id: BigInt(106), precheck: 0 }];
facilityRows = [
{ id: BigInt(1), facility_name: "テスト施設A", customer_id: BigInt(1), customer: { group_id: BigInt(1) } },
{ id: BigInt(2), facility_name: "テスト施設B", customer_id: BigInt(2), customer: { group_id: BigInt(1) } },
];
}
/** 表1 の並び順(既定。E3 を除く) */
const DEFAULT_ORDER = ["E-7", "E-11", "E-8", "E-10", "E-9", "E-6", "E-2", "E-1"];
async function get(qs = ""): Promise<Response> {
return GET(new Request(`http://localhost/api/errors${qs ? `?${qs}` : ""}`));
}
async function list(qs = ""): Promise<ListResponse<ErrorReportItem>> {
const res = await get(qs);
expect(res.status).toBe(200);
return (await res.json()) as ListResponse<ErrorReportItem>;
}
const ids = (items: ErrorReportItem[]) => items.map((i) => i.id);
beforeEach(() => {
buildData();
// ★施設名・分類の解決は `db` の2メソッドを差し替える(解決の規則 `resolveFacilityIdsBy*()` と
// `intersectFacilityIds()` は本物が走る)★。
// ★スタブの `facility.findMany` で済ませない理由★: ルートは2本を `Promise.all` で同時に呼び、
// それぞれが `await import("./prisma")` する。vitest では ★モックした `@/lib/db/prisma` を同時に動的 import すると
// 片方に本物のモジュールが返る★(2026-10-06 実測。本物は `DATABASE_URL` が無いと例外 → 500)。
// テスト環境だけの挙動で、本番の経路(`/clean-logs` も同じ形)には関係ない。
vi.spyOn(dataSource, "facilityNameIndex").mockImplementation(async () =>
facilityRows.map((f) => ({ id: Number(f.id), facility_name: f.facility_name }))
);
vi.spyOn(dataSource, "facilityGroupIndex").mockImplementation(async () =>
facilityRows.map((f) => ({
id: Number(f.id),
customer_id: Number(f.customer_id),
group_id: Number(f.customer.group_id),
}))
);
errorCalls = [];
mainTimetableCalls = [];
historyTimetableCalls = [];
vanishOnSecondStage = new Set();
throwOnErrorFindMany = false;
vi.mocked(logApiError).mockClear();
});
// ---------------------------------------------------------------- 表1
describe("表1(既定・解消も表示・各行の値)", () => {
it("★既定は E3 以外の8行・並びが表1 のとおり(E7 が先頭・同時刻は \"E-10\" が先)★", async () => {
const body = await list();
expect(ids(body.items)).toEqual(DEFAULT_ORDER);
expect(body.meta).toEqual({ total: 8, page: 1, per_page: 50 });
});
it("include_resolved=true で9行(E3 は検知時刻の位置)", async () => {
const body = await list("include_resolved=true");
expect(ids(body.items)).toEqual(["E-7", "E-11", "E-8", "E-10", "E-9", "E-6", "E-3", "E-2", "E-1"]);
expect(body.meta.total).toBe(9);
});
it.each([
["E-1", "main", null, "2026-10-05T19:05:00+09:00", "PD-CC1-0001", "EmergencyStop"],
["E-2", "precheck", PRECHECK_30, "2026-10-05T19:30:00+09:00", "PD-CC1-0001", "LowBattery"],
["E-3", "main", null, "2026-10-05T20:00:00+09:00", "PD-CC1-0001", "EmergencyStop"],
["E-10", "none", null, "2026-10-05T21:00:00+09:00", "PD-CC1-0001", "EmergencyStop"],
["E-9", "unknown", null, "2026-10-05T21:00:00+09:00", "PD-CC1-0001", "EmergencyStop"],
["E-6", "main", null, "2026-10-05T20:30:00+09:00", "PD-CC1-0001", "EmergencyStop"],
["E-7", "main", null, "2026-10-06T08:00:00+09:00", "PD-CC1-0001", "EmergencyStop"],
["E-8", "main", null, "2026-10-05T21:30:00+09:00", "0", "SECURITY_ON"],
["E-11", "precheck", PRECHECK_10, "2026-10-05T22:00:00+09:00", "PD-CC1-0001", "EmergencyStop"],
] as const)("%s: schedule_link / precheck_minutes / detected_at / serial_number / error_type", async (id, link, minutes, detectedAt, serial, errorType) => {
const body = await list("include_resolved=true");
expect(body.items.find((i) => i.id === id)).toMatchObject({
schedule_link: link,
precheck_minutes: minutes,
detected_at: detectedAt,
serial_number: serial,
error_type: errorType,
});
});
it("E3: 解消の日時と備考を返す・BigInt を残さない", async () => {
const body = await list("include_resolved=true");
expect(body.items.find((i) => i.id === "E-3")).toEqual({
id: "E-3",
detected_at: "2026-10-05T20:00:00+09:00",
facility_id: 1,
facility_name: "テスト施設A",
serial_number: "PD-CC1-0001",
error_type: "EmergencyStop",
error_level: "ERROR",
clean_timetable_id: 100,
schedule_link: "main",
precheck_minutes: null,
resolution_status: "resolved",
responded_at: "2026-10-06T09:00:00+09:00",
resolved_at: "2026-10-06T10:00:00+09:00",
note: "対応済み",
});
});
it("★E6: 本表の findMany は E6 の予定(106)を返さず、履歴から引く★", async () => {
const body = await list();
expect(body.items.find((i) => i.id === "E-6")?.schedule_link).toBe("main");
expect(mainTimetableCalls.flatMap((c) => c.returned)).not.toContain("106");
expect(historyTimetableCalls.length).toBeGreaterThan(0);
});
it("★E8: robot_id が NULL の行があっても読み取りで落ちない(select で robot_id / robot を読まない)★", async () => {
const body = await list();
expect(ids(body.items)).toContain("E-8");
});
});
// ---------------------------------------------------------------- 表2
describe("表2(絞り込み・ページ)", () => {
it("F3: include_resolved=yes → 422(DB を読まない)", async () => {
const res = await get("include_resolved=yes");
expect(res.status).toBe(422);
expect(((await res.json()) as ApiError).error.code).toBe("validation_failed");
expect(errorCalls).toHaveLength(0);
});
it("F4: error_type=secur → E8 だけ", async () => {
const body = await list("error_type=secur");
expect(ids(body.items)).toEqual(["E-8"]);
expect(body.meta.total).toBe(1);
});
it("F5: error_type が空白だけ → F1 と同じ", async () => {
expect(ids((await list("error_type=%20%20")).items)).toEqual(DEFAULT_ORDER);
});
it("★F6: date_from = date_to = E7 の created_at の日付 → E7 が入る(SQL 側も created_at で絞る)★", async () => {
const body = await list("date_from=2026-10-06&date_to=2026-10-06");
expect(ids(body.items)).toEqual(["E-7"]);
});
it("期間 10/5 → E7 以外の7行", async () => {
const body = await list("date_from=2026-10-05&date_to=2026-10-05");
expect(ids(body.items)).toEqual(DEFAULT_ORDER.filter((id) => id !== "E-7"));
});
it("F7: date_from > date_to → 422(parseCleanLogQuery() と同じ文言)", async () => {
const res = await get("date_from=2026-10-06&date_to=2026-10-05");
expect(res.status).toBe(422);
expect(((await res.json()) as ApiError).error.message).toBe("date_fromはdate_to以前の日付を指定してください");
});
it("★F8: 分類で絞って施設が0件 → 0件(全件にならない)・2段目と予定のクエリを撃たない★", async () => {
const body = await list("customer_id=99");
expect(body.items).toEqual([]);
expect(body.meta.total).toBe(0);
expect(errorCalls).toHaveLength(1);
expect(errorCalls[0].where).toMatchObject({ facility_id: { in: [] } });
expect(mainTimetableCalls).toHaveLength(0);
expect(historyTimetableCalls).toHaveLength(0);
});
it("★F9: 施設名が当たり分類も当たるが積集合が空 → 0件(AND)★", async () => {
const body = await list(`facility_name=${encodeURIComponent("テスト施設A")}&customer_id=2`);
expect(body.items).toEqual([]);
expect(body.meta.total).toBe(0);
});
it("施設名で絞る → 施設B の行だけ(並びは保つ)", async () => {
const body = await list(`facility_name=${encodeURIComponent("施設B")}`);
expect(ids(body.items)).toEqual(["E-7", "E-11", "E-8", "E-6"]);
});
it("F10: per_page=3&page=2 → 並び順の4〜6行目・meta.total = 8", async () => {
const body = await list("per_page=3&page=2");
expect(ids(body.items)).toEqual(["E-10", "E-9", "E-6"]);
expect(body.meta).toEqual({ total: 8, page: 2, per_page: 3 });
});
it("★F11: page=99 → items: []・meta.total = 8(404 にしない)・2段目を撃たない★", async () => {
const body = await list("page=99");
expect(body.items).toEqual([]);
expect(body.meta.total).toBe(8);
expect(errorCalls).toHaveLength(1);
});
it("★F12: status = 3 の行が混ざっても、既定でも「解消済みも表示」でも出ない・例外にならない★", async () => {
errorReports.push(row({ error_id: "E-X", status: 3, errored_at: db("2026-10-06T09:00:00+09:00") }));
expect(ids((await list()).items)).not.toContain("E-X");
expect(ids((await list("include_resolved=true")).items)).not.toContain("E-X");
});
});
// ---------------------------------------------------------------- 2段階で読む(D8)
describe("2段階で読む(指示書AW D8)", () => {
it("★1段目の where は errorReportListWhere() の戻りと同じ(ルートが別の条件を組み立てていない)★", async () => {
await list("date_from=2026-10-05&date_to=2026-10-06&customer_id=1&error_type=x&page=2&per_page=3");
expect(errorCalls[0].where).toEqual(
errorReportListWhere({
include_resolved: false,
facility_ids: [1],
date_from: "2026-10-05",
date_to: "2026-10-06",
error_type: "x",
page: 2,
per_page: 3,
})
);
});
it("★1段目の select は ROBOT_ERROR_LIST_KEY_SELECT、2段目は ROBOT_ERROR_LIST_ROW_SELECT★", async () => {
await list("per_page=3");
expect(errorCalls).toHaveLength(2);
expect(errorCalls[0].select).toEqual(ROBOT_ERROR_LIST_KEY_SELECT);
expect(errorCalls[1].select).toEqual(ROBOT_ERROR_LIST_ROW_SELECT);
// ★2段目はそのページの error_id だけ★
expect(errorCalls[1].where).toEqual({ error_id: { in: ["E-7", "E-11", "E-8"] } });
});
it("★0件のとき2段目と予定のクエリを撃たない★", async () => {
const body = await list("error_type=no-such-type");
expect(body).toEqual({ items: [], meta: { total: 0, page: 1, per_page: 50 } });
expect(errorCalls).toHaveLength(1);
expect(mainTimetableCalls).toHaveLength(0);
expect(historyTimetableCalls).toHaveLength(0);
});
it("予定のないページ(E4 だけ)では予定のクエリを撃たない", async () => {
const body = await list("per_page=1&page=4");
expect(ids(body.items)).toEqual(["E-10"]);
expect(mainTimetableCalls).toHaveLength(0);
expect(historyTimetableCalls).toHaveLength(0);
});
it("★1段目と2段目の間に消えた行は飛ばす(例外にしない)・meta.total は1段目の件数のまま★", async () => {
vanishOnSecondStage = new Set(["E-11"]);
const body = await list();
expect(ids(body.items)).toEqual(DEFAULT_ORDER.filter((id) => id !== "E-11"));
expect(body.meta.total).toBe(8);
});
it("DB の例外 → 500「データの取得に失敗しました」", async () => {
throwOnErrorFindMany = true;
const res = await get();
expect(res.status).toBe(500);
expect(((await res.json()) as ApiError).error).toMatchObject({
code: "internal_error",
message: "データの取得に失敗しました",
});
expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1);
});
});