Newer
Older
client_dwatch / app / api / ds-alerts / __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/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);
  });
});