Newer
Older
client_dwatch / app / api / errors / __tests__ / route.test.ts
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",
        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 — 存在しない日付は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"]);
  });
});