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 { ERROR_LIST_SELECT } from "@/lib/db/error-list-source";
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;
/** ★`deleted_at` は可視性(`isVisibleErrorRow()`。指示書AX U8・表5)★ */
facility: { facility_name: string; deleted_at: Date | null };
section: { deleted_at: Date | null };
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;
/** エラー内容(`error_lists.id`。指示書BD §3-2)。null = 未選択 */
error_list_id: bigint | null;
}
/** `error_lists` の行(指示書BD §5-3。名前解決は絞らない) */
interface StubErrorList {
id: bigint;
error_category_main_en: string;
error_category_main_ja: string;
error_category_sub_en: string;
error_category_sub_ja: string;
error_detail_en: string;
error_detail_ja: string;
inactive: number;
deleted_at: Date | 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[];
let errorListRows: StubErrorList[];
/** `errorList.findMany` の引数の記録(★名前解決は一覧1回につき1回まで・id が無ければ0回★) */
let errorListCalls: Args[];
/** ★2段目で消えたことにする error_id★(1段目と2段目の間に行が消えた。指示書AW 4-15) */
let vanishOnSecondStage: Set<string>;
/** ★2段目で `status` が変わったことにする(error_id → 新しい status)★(指示書BB 表3 S7) */
let statusOnSecondStage: Map<string, number>;
/** 次の `robotErrorReport.findMany` を例外にする */
let throwOnErrorFindMany: boolean;
/**
* ★1段目の `where` を無視して全行返す★(SQL の可視性の条件が効かなかった状態。指示書AX 表5 の注意)。
* ★純粋関数側(`isVisibleErrorRow()`)で外れる★ことを見るため。2段目(`error_id IN`)は評価する
*/
let ignoreFirstStageWhere: 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;
}
// ★可視性(`ERROR_ROW_VISIBLE_WHERE`。指示書AX U8)— `{ deleted_at: null }` だけを受ける★
case "facility":
case "section": {
const relation = cond as Record<string, unknown>;
if (Object.keys(relation).join(",") !== "deleted_at" || relation.deleted_at !== null) {
throw new Error(`[stub] where.${key} に想定外の条件: ${JSON.stringify(relation)}`);
}
if ((key === "facility" ? row.facility : row.section).deleted_at !== null) 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;
const where = !isSecondStage && ignoreFirstStageWhere ? {} : (args.where ?? {});
return errorReports
.filter((row) => matchErrorWhere(row, where))
.filter((row) => !(isSecondStage && vanishOnSecondStage.has(row.error_id)))
.map((row) => {
const status = isSecondStage ? statusOnSecondStage.get(row.error_id) : undefined;
const changed = status === undefined ? row : { ...row, status };
return project(changed 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);
},
},
// ★名前解決(`loadErrorListIndexByIds()`)— where は `id IN` だけを受ける(★他のキーは例外★。絞らないことの見張り)★
errorList: {
findMany: async (args: Args) => {
errorListCalls.push(args);
const where = args.where ?? {};
if (Object.keys(where).join(",") !== "id") {
throw new Error(`[stub] errorList.findMany の where に想定外のキー: ${Object.keys(where).join(",")}`);
}
const ids = (where.id as { in: bigint[] }).in.map((id) => id.toString());
return errorListRows
.filter((r) => ids.includes(r.id.toString()))
.map((r) => project(r as unknown as Record<string, unknown>, args.select ?? {}));
},
},
// ★施設名・分類の解決(`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", deleted_at: null },
section: { deleted_at: null },
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,
error_list_id: null,
...overrides,
};
}
function errorListRow(id: number, overrides: Partial<StubErrorList> = {}): StubErrorList {
return {
id: BigInt(id),
error_category_main_en: `MAIN-EN-${id}`,
error_category_main_ja: `分類${id}`,
error_category_sub_en: `sub-en-${id}`,
error_category_sub_ja: `小分類${id}`,
error_detail_en: `detail-en-${id}`,
error_detail_ja: `詳細${id}`,
inactive: 0,
deleted_at: null,
...overrides,
};
}
const FACILITY_B = { facility_id: BigInt(2), facility: { facility_name: "テスト施設B", deleted_at: null } };
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 }];
// ★表1 の9行はすべて error_list_id が null★(エラー内容の表 L は下の describe で行に値を入れる)
errorListRows = [
errorListRow(40),
errorListRow(7, { inactive: 1 }),
errorListRow(8, { deleted_at: db("2026-10-01T10:00:00+09:00") }),
errorListRow(131),
];
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 = [];
errorListCalls = [];
vanishOnSecondStage = new Set();
statusOnSecondStage = new Map();
throwOnErrorFindMany = false;
ignoreFirstStageWhere = 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: "対応済み",
error_list_id: null,
error_list: null,
error_list_selectable: true,
});
});
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",
sort: null,
order: "asc",
schedule: null,
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("★S7: 1段目の後に `status` が 3 に変わった行は飛ばす(例外にしない)・meta.total は1段目の件数のまま★(指示書BB U3)", async () => {
statusOnSecondStage = new Map([["E-11", 3]]);
const body = await list();
expect(ids(body.items)).toEqual(DEFAULT_ORDER.filter((id) => id !== "E-11"));
expect(body.meta.total).toBe(8);
});
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);
});
});
// ---------------------------------------------------------------- 表5(指示書AX U8)
/**
* ★表5(削除済みの施設・清掃エリア。指示書AX U8・D12。§11 Q9 で監督確認済み)のエラー一覧の列★。
* V1 = 表1 の E-1(施設1・清掃エリアは生きている)。V2 = 施設3(削除済み)、V3 = 施設1の削除済みの清掃エリア。
* ★V2・V3 は表1 のどの行より新しい検知時刻にする★(漏れたら先頭に出て気づける)。
* ★削除の日時は過去の値★(NULL か否かだけで決まる。D12)。
*/
describe("表5 — 削除済みの施設・清掃エリアの行は出ない(指示書AX U8)", () => {
const DELETED_AT = db("2026-10-01T10:00:00+09:00");
const DELETED_FACILITY_NAME = "削除済みテスト施設";
beforeEach(() => {
errorReports.push(
row({
error_id: "V-2",
facility_id: BigInt(3),
facility: { facility_name: DELETED_FACILITY_NAME, deleted_at: DELETED_AT },
errored_at: db("2026-10-06T09:00:00+09:00"),
created_at: db("2026-10-06T09:01:00+09:00"),
}),
row({
error_id: "V-3",
section: { deleted_at: DELETED_AT },
errored_at: db("2026-10-06T09:10:00+09:00"),
created_at: db("2026-10-06T09:11:00+09:00"),
})
);
// ★施設名の解決は deleted_at で絞らない(罠7)★ — 削除済みの施設も索引に載る
facilityRows.push({
id: BigInt(3),
facility_name: DELETED_FACILITY_NAME,
customer_id: BigInt(1),
customer: { group_id: BigInt(1) },
});
});
it("V2・V3: 既定で出ない・meta.total に数えない(V1 = E-1 と表1 の並びはそのまま)", async () => {
const body = await list();
expect(ids(body.items)).toEqual(DEFAULT_ORDER);
expect(body.meta.total).toBe(8);
});
it("V2・V3: include_resolved=true でも出ない", async () => {
const body = await list("include_resolved=true");
expect(ids(body.items)).not.toContain("V-2");
expect(ids(body.items)).not.toContain("V-3");
expect(body.meta.total).toBe(9);
});
it("★V9: 同じ施設1の別の清掃エリアが削除済みでも、施設1の生きている行(E-1)は出る★", async () => {
const body = await list(`facility_name=${encodeURIComponent("テスト施設A")}`);
expect(ids(body.items)).toContain("E-1");
expect(ids(body.items)).not.toContain("V-3");
});
it("★V8: 削除済みの施設の名前で絞る → 0件・200(422 / 500 にしない)★", async () => {
const body = await list(`facility_name=${encodeURIComponent(DELETED_FACILITY_NAME)}`);
expect(body.items).toEqual([]);
expect(body.meta.total).toBe(0);
// ★0件の理由が「可視性で外れた」ことを確かめる★ — 名前の解決が削除済みの施設を落とす後退(罠7)だと
// `facility_id: { in: [] }` になり、上の2行だけでは緑のまま
expect(errorCalls[0].where).toMatchObject({ facility_id: { in: [BigInt(3)] } });
});
it("★SQL の where を無視して全行返しても V2・V3 は出ない(純粋関数 isVisibleErrorRow() で外れる)★", async () => {
ignoreFirstStageWhere = true;
const def = await list();
expect(ids(def.items)).toEqual(DEFAULT_ORDER);
expect(def.meta.total).toBe(8);
const all = await list("include_resolved=true");
expect(ids(all.items)).not.toContain("V-2");
expect(ids(all.items)).not.toContain("V-3");
expect(all.meta.total).toBe(9);
});
it("1段目の where に施設・清掃エリアの可視性が入っている", async () => {
await list();
expect(errorCalls[0].where).toMatchObject({
facility: { deleted_at: null },
section: { deleted_at: null },
});
});
});
describe("GET /errors — sort / schedule(指示書BH §11 R5)", () => {
it("include_resolved + schedule=unscheduled + sort=serial_number&order=desc → データ層へ届く", async () => {
const spy = vi.spyOn(dataSource, "errorReports");
const body = await list(
"include_resolved=true&schedule=unscheduled&sort=serial_number&order=desc"
);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
include_resolved: true,
schedule: "unscheduled",
sort: "serial_number",
order: "desc",
})
);
expect(body.meta.total).toBe(1);
expect(ids(body.items)).toEqual(["E-10"]);
});
it("sort=facility_name → 422", async () => {
const res = await get("sort=facility_name");
expect(res.status).toBe(422);
});
});
describe("GET /errors — 存在しない日付は422(指示書BB 表8 Q6)", () => {
it("★`2026-02-31` は形式違い(`2026/02/03`)と同じ code・message・details の422★", async () => {
const bad = await get("date_from=2026-02-31");
const format = await get("date_from=2026/02/03");
expect(bad.status).toBe(422);
expect(format.status).toBe(422);
expect(await bad.json()).toEqual(await format.json());
});
});
// ---------------------------------------------------------------- エラー内容(指示書BD §3-2・§5-3)
/**
* 表 L(GET /errors のエラー内容の3項目。Prisma 経路)。表1 の行に `error_list_id` を入れて見る。
* | # | 行 | error_type | error_list_id | error_lists の行 | 期待 error_list | selectable |
* |----|------|---------------------|---------------|---------------------------|-----------------------------|------------|
* | L1 | E-1 | EmergencyStop | 40 | 有効 | 40 の行 | true |
* | L2 | E-2 | LowBattery | 9999 | 無い | null(id は 9999 のまま) | true |
* | L3 | E-6 | EmergencyStop | 7 | ★inactive 1★ | 7 の行(絞らない) | true |
* | L4 | E-7 | EmergencyStop | 8 | ★deleted_at あり★ | 8 の行(絞らない) | true |
* | L5 | E-11 | NOT_STARTED | 131 | 有効・note_required | 131 の行 | true |
* | L6 | E-8 | SECURITY_ON | null | — | null | ★false★ |
* | L7 | E-10 | D-S_CONTROL_FAILURE | 40 | 有効(データの食い違い) | 40 の行(値は出す) | ★false★ |
* | L8 | E-9 | EmergencyStop | 40 | ★E-1 と同じ id★ | 40 の行(in には1つ) | true |
*/
describe("GET /errors — エラー内容(表 L。指示書BD §3-2)", () => {
function setErrorLists(): void {
const set = (id: string, patch: Partial<StubErrorReport>) => {
const target = errorReports.find((r) => r.error_id === id);
if (!target) throw new Error(`[test] ${id} がありません`);
Object.assign(target, patch);
};
set("E-1", { error_list_id: BigInt(40) });
set("E-2", { error_list_id: BigInt(9999) });
set("E-6", { error_list_id: BigInt(7) });
set("E-7", { error_list_id: BigInt(8) });
set("E-11", { error_type: "NOT_STARTED", error_list_id: BigInt(131) });
set("E-10", { error_type: "D-S_CONTROL_FAILURE", error_list_id: BigInt(40) });
set("E-9", { error_list_id: BigInt(40) });
}
it.each([
["L1", "E-1", 40, 40, true],
["L2", "E-2", 9999, null, true],
["L3", "E-6", 7, 7, true],
["L4", "E-7", 8, 8, true],
["L5", "E-11", 131, 131, true],
["L6", "E-8", null, null, false],
["L7", "E-10", 40, 40, false],
["L8", "E-9", 40, 40, true],
] as const)("%s: %s", async (_no, id, listId, resolvedId, selectable) => {
setErrorLists();
const item = (await list()).items.find((i) => i.id === id);
expect({
error_list_id: item?.error_list_id,
resolved: item?.error_list?.id ?? null,
error_list_selectable: item?.error_list_selectable,
}).toEqual({ error_list_id: listId, resolved: resolvedId, error_list_selectable: selectable });
});
it("L1・L5: error_list は行の形のまま(文字列は変換しない)・note_required は 131 だけ", async () => {
setErrorLists();
const items = (await list()).items;
expect(items.find((i) => i.id === "E-1")?.error_list).toEqual({
id: 40,
error_category_main_en: "MAIN-EN-40",
error_category_main_ja: "分類40",
error_category_sub_en: "sub-en-40",
error_category_sub_ja: "小分類40",
error_detail_en: "detail-en-40",
error_detail_ja: "詳細40",
note_required: false,
});
expect(items.find((i) => i.id === "E-11")?.error_list?.note_required).toBe(true);
});
it("★名前解決は1回だけ・where は id IN だけ(重複なし・ページの行の id だけ)・select は ERROR_LIST_SELECT★", async () => {
setErrorLists();
await list();
expect(errorListCalls).toHaveLength(1);
expect(errorListCalls[0].select).toEqual(ERROR_LIST_SELECT);
const inIds = (errorListCalls[0].where as { id: { in: bigint[] } }).id.in.map((id) => id.toString()).sort();
expect(inIds).toEqual(["131", "40", "7", "8", "9999"]);
});
it("★ページに無い行の id は引かない★(per_page=1 → 先頭の E-7 の 8 だけ)", async () => {
setErrorLists();
await list("per_page=1");
expect(errorListCalls).toHaveLength(1);
expect((errorListCalls[0].where as { id: { in: bigint[] } }).id.in).toEqual([BigInt(8)]);
});
it("★ページの行が全部 error_list_id null なら名前解決のクエリを撃たない★", async () => {
const body = await list();
expect(body.items.every((i) => i.error_list_id === null && i.error_list === null)).toBe(true);
expect(errorListCalls).toHaveLength(0);
});
it("★0件のとき名前解決のクエリも撃たない★", async () => {
setErrorLists();
await list("error_type=no-such-type");
expect(errorListCalls).toHaveLength(0);
});
it("表1 の既定(error_list_id 全部 null): SECURITY_ON の E-8 だけ selectable false", async () => {
const body = await list();
expect(body.items.filter((i) => !i.error_list_selectable).map((i) => i.id)).toEqual(["E-8"]);
});
});