import { beforeEach, describe, expect, it, vi } from "vitest";
// ★★ルート本体は**静的 import** すること★★(`app/api/prechecks/__tests__/route.test.ts` と同じ理由)
import { GET } from "@/app/api/ds-alerts/route";
import { D_SEC_ALERT_ERROR_TYPES } from "@/lib/config";
import { guardNullRobotId } from "@/lib/db/__tests__/robot-error-null-guard";
import { dsAlertWhere } from "@/lib/db/ds-alert-mapping";
import { robotErrorStatusFromResolutionStatus } from "@/lib/db/precheck-mapping";
import { ROBOT_ERROR_DS_ALERT_SELECT } from "@/lib/db/robot-error-select";
import type { DsAlertSummary } from "@/lib/ds-alerts/query";
import { toDbJst } from "@/lib/format/date";
import { logApiError } from "@/lib/logger/api";
import type { ApiError, DsAlert, SummaryListResponse } from "@/lib/types";
/**
* `GET /ds-alerts`(Prisma 経路。指示書AX 段階4-4-1)。
*
* ★★DBには接続しません★★ — `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に固定して
* `prismaSource.dsAlerts()` の経路を走らせる(`app/api/errors/__tests__/route.test.ts` と同じ作法)。
*
* ★スタブの `robotErrorReport.findMany` は★
* - ★`guardNullRobotId()` を通す★(D-Sec の行は全部 `robot_id` NULL。`robot_id` / `robot` を読むと本物の Prisma は落ちる)
* - ★`where` を無視して全行返す★ — SQL が照合順序(ci・PAD SPACE)で `d-s_control_failure` や末尾空白の行まで
* 当ててしまい、可視性の条件も効かなかった状態を再現する。★正は純粋関数(`dsAlertsFromErrorRows()`)★(D2・D12)
* - `select` を見て返す列を絞る
* ★`controlErrorHistory` は呼ばれたら落ちる★(もう読まない。P-10 = A)。
*
* 入力は表1 の13行(R1〜R13。出るのは R1〜R4・R11〜R13 の7行)。期待値は表2(監督判断 2026-10-19 で正)。
* 施設: 1 = テスト施設A / 2 = テスト施設B(制御盤 DFAS010001 の所属)/ 3 = テスト施設C。
* ★R11 は施設2の制御盤のシリアルだが、行の施設は3★(U3)。
* ★R1 = "E-9"・R12 = "E-10" は同じ検知時刻★ → 辞書順で "E-10" が先(数値順なら逆。D11)。
*/
// ---------------------------------------------------------------- スタブ(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; deleted_at: Date | null };
section_id: bigint;
section: { section_name: string; 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;
}
type Args = Record<string, unknown> & { where?: Record<string, unknown>; select?: Record<string, unknown> };
let errorReports: StubErrorReport[];
/** `robotErrorReport.findMany` の引数の記録 */
let errorCalls: Args[];
vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));
/** `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;
}
vi.mock("@/lib/db/prisma", () => {
const read = {
robotErrorReport: {
findMany: async (args: Args) => {
errorCalls.push(args);
guardNullRobotId(errorReports as unknown as Record<string, unknown>[], args);
// ★where を無視して全行返す★(上の docstring。SQL は写し)
return errorReports.map((row) => project(row as unknown as Record<string, unknown>, args.select ?? {}));
},
},
// ★もう読まない(P-10 = A。指示書AX)★ — 呼ばれたらルートが 500 になり、テストが落ちる
controlErrorHistory: {
findMany: async () => {
throw new Error("[stub] control_error_histories は読まない(指示書AX。P-10 = A)");
},
},
};
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 DS_ALERT_TYPE = D_SEC_ALERT_ERROR_TYPES[0];
/** ★施設2の制御盤のシリアル★(R11 は施設3の行だがこのシリアル) */
const SERIAL_FACILITY_2 = "DFAS010001";
// ★実在の店名を使わない★(`CLAUDE.md` §7)
const FACILITY_2 = {
facility_id: BigInt(2),
facility: { facility_name: "テスト施設B", deleted_at: null },
section_id: BigInt(20),
section: { section_name: "1F売り場", deleted_at: null },
};
const FACILITY_3 = {
facility_id: BigInt(3),
facility: { facility_name: "テスト施設C", deleted_at: null },
section_id: BigInt(30),
section: { section_name: "2F衣料品", deleted_at: null },
};
/** 既定は D-Sec の行(R1 の形)。施設2・status 0・robot_id NULL */
function row(overrides: Partial<StubErrorReport> & Pick<StubErrorReport, "error_id">): StubErrorReport {
return {
robot_id: null,
clean_timetable_id: BigInt(100),
serial_number: SERIAL_FACILITY_2,
...FACILITY_2,
error_type: DS_ALERT_TYPE,
error_level: "CRITICAL",
errored_at: db("2026-10-05T22:00:00+09:00"),
created_at: db("2026-10-05T22:01:00+09:00"),
status: UNRESOLVED,
responded_at: null,
resolved_at: null,
comment: null,
...overrides,
};
}
/** ★出ない行(R5〜R10)は出る行のどれより新しい検知時刻にする★(漏れたら先頭に出て気づける) */
const LEAK_AT = { errored_at: db("2026-10-06T05:00:00+09:00"), created_at: db("2026-10-06T05:01:00+09:00") };
function buildData() {
errorReports = [
// R1
row({ error_id: "E-9" }),
// R2: 対応中・備考あり
row({
error_id: "R-2",
errored_at: db("2026-10-05T21:00:00+09:00"),
created_at: db("2026-10-05T21:01:00+09:00"),
status: IN_PROGRESS,
responded_at: db("2026-10-06T09:05:00+09:00"),
comment: "回線業者に確認中",
}),
// R3: 解消
row({
error_id: "R-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-06T08:40:00+09:00"),
resolved_at: db("2026-10-06T09:15:00+09:00"),
comment: "正常復帰を確認",
}),
// R4: errored_at NULL → detected_at は created_at(ステージングの16行すべてがこの形)
row({ error_id: "R-4", errored_at: null, created_at: db("2026-10-06T03:00:00+09:00") }),
// R5: SECURITY_ON(U1)
row({ error_id: "R-5", serial_number: "---", error_type: "SECURITY_ON", ...LEAK_AT }),
// R6: NOT_STARTED(U2)
row({ error_id: "R-6", serial_number: "---", error_type: "NOT_STARTED", ...LEAK_AT }),
// R7: ロボットのエラー(D1)
row({ error_id: "R-7", robot_id: BigInt(501), serial_number: "PD-CC1-0001", error_type: "EmergencyStop", error_level: "ERROR", ...LEAK_AT }),
// R8: ★わざと小文字★(SQL の ci では当たるが `===` で外れる。D2)
row({ error_id: "R-8", error_type: "d-s_control_failure", ...LEAK_AT }),
// R9: ★わざと末尾に空白★(SQL の PAD SPACE では当たるが `===` で外れる。D2)
row({ error_id: "R-9", error_type: "D-S_CONTROL_FAILURE ", ...LEAK_AT }),
// R10: status 3(出さない・例外にしない。D3)
row({ error_id: "R-10", status: 3, ...LEAK_AT }),
// R11: 施設2の制御盤のシリアル・行の施設は3(U3)
row({
error_id: "R-11",
...FACILITY_3,
errored_at: db("2026-10-05T23:00:00+09:00"),
created_at: db("2026-10-05T23:01:00+09:00"),
}),
// R12: R1 と同じ検知時刻・"E-10"(D11)
row({ error_id: "E-10", created_at: db("2026-10-05T22:02:00+09:00") }),
// R13: 予定 id NULL(予定の有無で絞らない)
row({
error_id: "R-13",
clean_timetable_id: null,
errored_at: db("2026-10-05T19:00:00+09:00"),
created_at: db("2026-10-05T19:01:00+09:00"),
}),
];
}
/** 出る7行の並び(`detected_at` 降順・同値は id の辞書順) */
const ALL_ORDER = ["R-4", "R-11", "E-10", "E-9", "R-2", "R-3", "R-13"];
/** 既定(`status=open`)。R3 以外の6行 */
const OPEN_ORDER = ALL_ORDER.filter((id) => id !== "R-3");
const HIDDEN = ["R-5", "R-6", "R-7", "R-8", "R-9", "R-10"];
type Body = SummaryListResponse<DsAlert, DsAlertSummary>;
async function get(qs = ""): Promise<Response> {
return GET(new Request(`http://localhost/api/ds-alerts${qs ? `?${qs}` : ""}`));
}
async function list(qs = ""): Promise<Body> {
const res = await get(qs);
expect(res.status).toBe(200);
return (await res.json()) as Body;
}
const ids = (items: DsAlert[]) => items.map((i) => i.id);
beforeEach(() => {
buildData();
errorCalls = [];
vi.mocked(logApiError).mockClear();
});
// ---------------------------------------------------------------- 読み方
describe("読み方(指示書AX D1〜D3)", () => {
it("★findMany の where は dsAlertWhere({ open_only: false })(全件。指示書BB 表10 N8)、select は ROBOT_ERROR_DS_ALERT_SELECT★", async () => {
await list();
expect(errorCalls).toHaveLength(1);
expect(errorCalls[0].where).toEqual(dsAlertWhere({ open_only: false }));
// ★解消(2)も SQL で読む(`summary.resolved` を数えるため)★
expect((errorCalls[0].where as { status: { in: number[] } }).status.in).toContain(2);
expect(errorCalls[0].select).toEqual(ROBOT_ERROR_DS_ALERT_SELECT);
});
it("★robot_id が NULL の行があっても読み取りで落ちない・control_error_histories を読まない★", async () => {
await list();
expect(vi.mocked(logApiError)).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------- 表2
describe("表2(パラメータ・summary)", () => {
it("A1: 既定(type=all&status=open)→ R3 以外の6行・meta.total = 6", async () => {
const body = await list();
expect(ids(body.items)).toEqual(OPEN_ORDER);
expect(body.meta).toEqual({ total: 6, page: 1, per_page: 50 });
});
it("A2: status=resolved → R3 だけ", async () => {
const body = await list("status=resolved");
expect(ids(body.items)).toEqual(["R-3"]);
expect(body.meta.total).toBe(1);
});
it("A3: status=all → 7行", async () => {
const body = await list("status=all");
expect(ids(body.items)).toEqual(ALL_ORDER);
expect(body.meta.total).toBe(7);
});
it("★A4: status=done(旧の値)→ 422。黙って倒さない(DB を読まない)★", async () => {
const res = await get("status=done");
expect(res.status).toBe(422);
const body = (await res.json()) as ApiError;
expect(body.error.code).toBe("validation_failed");
expect(body.error.details).toEqual({ status: ["open / all / resolved のいずれかを指定してください"] });
expect(errorCalls).toHaveLength(0);
});
it("A5: type=security_off → 200・0件(422 にしない。enum は残す。D4)", async () => {
const body = await list("type=security_off");
expect(body.items).toEqual([]);
expect(body.meta.total).toBe(0);
});
it("A6: type=disconnected → A1 と同じ", async () => {
const body = await list("type=disconnected");
expect(ids(body.items)).toEqual(OPEN_ORDER);
expect(body.meta.total).toBe(6);
});
it.each(["", "status=all", "status=resolved", "type=security_off", "type=disconnected&status=all"])(
"A7: summary は type / status によらず { unresolved: 5, in_progress: 1, resolved: 1 }(%s)",
async (qs) => {
const body = await list(qs);
expect(body.summary).toEqual({ unresolved: 5, in_progress: 1, resolved: 1 });
}
);
it("A9: per_page=2&page=2 → 並び順の3〜4行目・meta.total = 6", async () => {
const body = await list("per_page=2&page=2");
expect(ids(body.items)).toEqual(["E-10", "E-9"]);
expect(body.meta).toEqual({ total: 6, page: 2, per_page: 2 });
});
});
// ---------------------------------------------------------------- 表1(ルートから見た値)
describe("表1(ルートから見た値)", () => {
it("★R5〜R10 は出ない(status=all でも)★ — SQL が ci・PAD SPACE で当てても純粋関数の === で外れる", async () => {
const body = await list("status=all");
for (const id of HIDDEN) expect(ids(body.items)).not.toContain(id);
});
it("★R10(status 3)が混ざっても 500 にならない★", async () => {
const res = await get("status=all");
expect(res.status).toBe(200);
expect(vi.mocked(logApiError)).not.toHaveBeenCalled();
});
it("R1: 各値(id は string・シリアルは生値・BigInt を残さない)", async () => {
const body = await list();
expect(body.items.find((i) => i.id === "E-9")).toEqual({
id: "E-9",
alert_type: "disconnected",
device_serial: SERIAL_FACILITY_2,
facility_id: 2,
facility_name: "テスト施設B",
section_name: "1F売り場",
detected_at: "2026-10-05T22:00:00+09:00",
resolution_status: "unresolved",
responded_at: null,
resolved_at: null,
note: null,
});
});
it("R2: 対応中・responded_at・note", async () => {
const body = await list();
expect(body.items.find((i) => i.id === "R-2")).toMatchObject({
resolution_status: "in_progress",
responded_at: "2026-10-06T09:05:00+09:00",
resolved_at: null,
note: "回線業者に確認中",
});
});
it("R3: 解消(status=all で出る)", async () => {
const body = await list("status=all");
expect(body.items.find((i) => i.id === "R-3")).toMatchObject({
resolution_status: "resolved",
responded_at: "2026-10-06T08:40:00+09:00",
resolved_at: "2026-10-06T09:15:00+09:00",
});
});
it("★R4: errored_at が NULL → detected_at は created_at(9時間ずらさない)★", async () => {
const body = await list();
expect(body.items.find((i) => i.id === "R-4")?.detected_at).toBe("2026-10-06T03:00:00+09:00");
});
it("★R11: 行の施設3で出る(制御盤のシリアルの所属=施設2ではない)・シリアルはそのまま★", async () => {
const body = await list();
expect(body.items.find((i) => i.id === "R-11")).toMatchObject({
facility_id: 3,
facility_name: "テスト施設C",
section_name: "2F衣料品",
device_serial: SERIAL_FACILITY_2,
});
});
it("★R12: 同じ検知時刻は辞書順で \"E-10\" が \"E-9\" より先★", async () => {
const order = ids((await list()).items);
expect(order.indexOf("E-10")).toBeLessThan(order.indexOf("E-9"));
});
it("R13: 予定 id が NULL でも出る", async () => {
expect(ids((await list()).items)).toContain("R-13");
});
});
// ---------------------------------------------------------------- 表5(指示書AX U8)
/**
* ★表5 の D-Sec 列(V4〜V7)★。スタブは where を無視して全行返す(SQL の可視性の条件が効かなかった状態)ので、
* ★純粋関数側の `isVisibleErrorRow()` が外す★ことを見る。★削除の日時は過去の値★(NULL か否かだけ。D12)。
*/
describe("表5 — 削除済みの施設・清掃エリアの行は出ない(指示書AX U8)", () => {
const DELETED_AT = db("2026-10-01T10:00:00+09:00");
beforeEach(() => {
errorReports.push(
// V4: 両方生きている・status 0
row({ error_id: "V-4", ...LEAK_AT }),
// V5: 施設が削除済み・status 0
row({ error_id: "V-5", ...LEAK_AT, facility: { facility_name: "削除済みテスト施設", deleted_at: DELETED_AT } }),
// V6: 清掃エリアが削除済み・status 1
row({
error_id: "V-6",
...LEAK_AT,
status: IN_PROGRESS,
responded_at: db("2026-10-06T09:00:00+09:00"),
section: { section_name: "旧2F売り場(削除済み)", deleted_at: DELETED_AT },
}),
// V7: 両方削除済み・status 2
row({
error_id: "V-7",
...LEAK_AT,
status: RESOLVED,
responded_at: db("2026-10-06T09:00:00+09:00"),
resolved_at: db("2026-10-06T09:30:00+09:00"),
facility: { facility_name: "削除済みテスト施設", deleted_at: DELETED_AT },
section: { section_name: "旧2F売り場(削除済み)", deleted_at: DELETED_AT },
})
);
});
it("V4: 出る・summary.unresolved に数える", async () => {
const body = await list();
expect(ids(body.items)[0]).toBe("V-4");
expect(body.summary.unresolved).toBe(6);
});
it.each(["", "status=all", "status=resolved"])("★V5〜V7: 一覧に出ない(%s)★", async (qs) => {
const body = await list(qs);
for (const id of ["V-5", "V-6", "V-7"]) expect(ids(body.items)).not.toContain(id);
});
it("★V5〜V7: summary にも数えない(in_progress・resolved は表1 のまま)★", async () => {
const body = await list("status=all");
expect(body.summary).toEqual({ unresolved: 6, in_progress: 1, resolved: 1 });
expect(body.meta.total).toBe(8);
});
});