Newer
Older
client_dwatch / lib / error-reports / __tests__ / query.test.ts
/**
 * `lib/error-reports/query.ts` の期待値表(指示書AW §3 表1 の並び順・表2 / 段階1-2 / CLAUDE.md §9.4)
 *
 * ★表1 は監督確定(D-160 / 指示書AW §3)。変えないこと★ — ここでは並び順だけを見る
 *   (行の組み立ては `lib/db/__tests__/error-report-mapping.test.ts`)。
 * ★表2 は指示書AW で導いたもの(監督確認済み・正)★。ルート(SQL 側・ページ分け)の確認は段階4 のテスト。
 *
 * 入力は表1 の9行のキー(`facility_id` は E1〜E5 = 1、E6〜E9 = 2)。
 *   ★E7 は errored_at が NULL で、created_at(10/6 08:00)が9行の中で一番新しい★ → 先頭に来る。
 *   ★E4 = "E-10"・E5 = "E-9" は同じ検知時刻★ → 辞書順で "E-10" が先(数値順なら逆)。
 *
 * ★状態コードは `robotErrorStatusFromResolutionStatus()` から作る(0/1/2 を直書きしない。D2)★
 */
import { describe, expect, it } from "vitest";
import { PAGINATION } from "@/lib/config";
import type { ErrorReportKey } from "@/lib/db/error-report-mapping";
import { robotErrorStatusFromResolutionStatus } from "@/lib/db/precheck-mapping";
import {
  compareErrorReportKeys,
  errorReportStatusCodes,
  filterSortErrorReportKeys,
  parseErrorReportQuery,
} from "@/lib/error-reports/query";
import type { ErrorReportDbQuery } from "@/lib/types";

const UNRESOLVED = robotErrorStatusFromResolutionStatus("unresolved");
const IN_PROGRESS = robotErrorStatusFromResolutionStatus("in_progress");
const RESOLVED = robotErrorStatusFromResolutionStatus("resolved");

function key(
  error_id: string,
  facility_id: number,
  detected_at: string,
  status: number,
  error_type = "EmergencyStop"
): ErrorReportKey {
  return { error_id, facility_id, error_type, detected_at, status };
}

// 表1 の9行(検知時刻は `errored_at ?? created_at` を `jstIsoFromDb()` に通した後の値)
const E1 = key("E-1", 1, "2026-10-05T19:05:00+09:00", UNRESOLVED);
const E2 = key("E-2", 1, "2026-10-05T19:30:00+09:00", IN_PROGRESS, "LowBattery");
const E3 = key("E-3", 1, "2026-10-05T20:00:00+09:00", RESOLVED);
const E4 = key("E-10", 1, "2026-10-05T21:00:00+09:00", UNRESOLVED);
const E5 = key("E-9", 1, "2026-10-05T21:00:00+09:00", UNRESOLVED);
const E6 = key("E-6", 2, "2026-10-05T20:30:00+09:00", UNRESOLVED);
const E7 = key("E-7", 2, "2026-10-06T08:00:00+09:00", UNRESOLVED); // created_at
const E8 = key("E-8", 2, "2026-10-05T21:30:00+09:00", UNRESOLVED, "SECURITY_ON");
const E9 = key("E-11", 2, "2026-10-05T22:00:00+09:00", UNRESOLVED, "HandleUp");

/** ★わざと並び順と違う順で渡す★ */
const KEYS: readonly ErrorReportKey[] = [E1, E2, E3, E4, E5, E6, E7, E8, E9];

const base: ErrorReportDbQuery = {
  include_resolved: false,
  facility_ids: null,
  date_from: null,
  date_to: null,
  error_type: "",
  page: 1,
  per_page: PAGINATION.defaultPerPage,
};

const ids = (keys: readonly ErrorReportKey[]) => keys.map((k) => k.error_id);

/** 表1 の並び順(既定。E3 を除く) */
const DEFAULT_ORDER = ["E-7", "E-11", "E-8", "E-10", "E-9", "E-6", "E-2", "E-1"];

describe("filterSortErrorReportKeys(表1 の並び順・表2)", () => {
  it("F1: 既定 → E3 以外の8行・表1 の並び順(E7 が先頭・同時刻は \"E-10\" が \"E-9\" より先)", () => {
    expect(ids(filterSortErrorReportKeys(KEYS, base))).toEqual(DEFAULT_ORDER);
  });

  it("F2: include_resolved=true → 9行(E3 は検知時刻の位置に入る)", () => {
    expect(ids(filterSortErrorReportKeys(KEYS, { ...base, include_resolved: true }))).toEqual([
      "E-7",
      "E-11",
      "E-8",
      "E-10",
      "E-9",
      "E-6",
      "E-3",
      "E-2",
      "E-1",
    ]);
  });

  it("F4: error_type=secur(大小文字違い・部分一致)→ E8 だけ", () => {
    expect(ids(filterSortErrorReportKeys(KEYS, { ...base, error_type: "secur" }))).toEqual(["E-8"]);
  });

  it("F5: error_type が空 → F1 と同じ", () => {
    expect(ids(filterSortErrorReportKeys(KEYS, { ...base, error_type: "" }))).toEqual(DEFAULT_ORDER);
  });

  it("F6: date_from = date_to = E7 の created_at の日付 → E7 が入る(errored_at が NULL でも created_at の日付で当たる)", () => {
    expect(
      ids(filterSortErrorReportKeys(KEYS, { ...base, date_from: "2026-10-06", date_to: "2026-10-06" }))
    ).toEqual(["E-7"]);
  });

  it("期間 10/5 だけ → E7 が外れる(日付は detected_at の先頭10文字で比べる)", () => {
    expect(
      ids(filterSortErrorReportKeys(KEYS, { ...base, date_from: "2026-10-05", date_to: "2026-10-05" }))
    ).toEqual(DEFAULT_ORDER.filter((id) => id !== "E-7"));
  });

  it("下限だけ・上限だけでも絞れる", () => {
    expect(ids(filterSortErrorReportKeys(KEYS, { ...base, date_from: "2026-10-06" }))).toEqual(["E-7"]);
    expect(ids(filterSortErrorReportKeys(KEYS, { ...base, date_to: "2026-10-05" }))).toEqual(
      DEFAULT_ORDER.filter((id) => id !== "E-7")
    );
  });

  it("★F8: facility_ids = [] → 0件(全件にならない。罠1)★", () => {
    expect(filterSortErrorReportKeys(KEYS, { ...base, facility_ids: [] })).toEqual([]);
  });

  it("facility_ids = [2] → 施設2の行だけ(並び順は保つ)", () => {
    expect(ids(filterSortErrorReportKeys(KEYS, { ...base, facility_ids: [2] }))).toEqual([
      "E-7",
      "E-11",
      "E-8",
      "E-6",
    ]);
  });

  it("★F12: status = 3 の行が混ざっても、既定でも「解消済みも表示」でも出ない・例外にならない★", () => {
    const withOdd = [...KEYS, key("E-X", 1, "2026-10-06T09:00:00+09:00", 3)];
    expect(() => filterSortErrorReportKeys(withOdd, base)).not.toThrow();
    expect(ids(filterSortErrorReportKeys(withOdd, base))).not.toContain("E-X");
    expect(ids(filterSortErrorReportKeys(withOdd, { ...base, include_resolved: true }))).not.toContain("E-X");
  });

  it("★入力の配列を書き換えない★", () => {
    const input = [...KEYS];
    const snapshot = [...input];
    filterSortErrorReportKeys(input, { ...base, include_resolved: true });
    expect(input).toEqual(snapshot);
  });
});

describe("compareErrorReportKeys(D7)", () => {
  it("検知時刻の降順", () => {
    expect(compareErrorReportKeys(E7, E1)).toBeLessThan(0);
    expect(compareErrorReportKeys(E1, E7)).toBeGreaterThan(0);
  });

  it("★同時刻は error_id の昇順(文字列の辞書順。\"E-10\" が \"E-9\" より先)★", () => {
    expect(compareErrorReportKeys(E4, E5)).toBeLessThan(0);
    expect(compareErrorReportKeys(E5, E4)).toBeGreaterThan(0);
  });

  it("同じキーは 0", () => {
    expect(compareErrorReportKeys(E1, { ...E1 })).toBe(0);
  });

  it("★大文字は小文字より前(コードポイント順。localeCompare ではない)★", () => {
    const upper = key("B", 1, "2026-10-05T19:00:00+09:00", UNRESOLVED);
    const lower = key("a", 1, "2026-10-05T19:00:00+09:00", UNRESOLVED);
    expect(compareErrorReportKeys(upper, lower)).toBeLessThan(0);
  });
});

describe("errorReportStatusCodes(D2)", () => {
  it("false → 未解消・対応中のコード", () => {
    expect(errorReportStatusCodes(false)).toEqual(
      (["unresolved", "in_progress"] as const).map(robotErrorStatusFromResolutionStatus)
    );
  });

  it("true → それに解消を足したもの", () => {
    expect(errorReportStatusCodes(true)).toEqual(
      (["unresolved", "in_progress", "resolved"] as const).map(robotErrorStatusFromResolutionStatus)
    );
  });
});

describe("parseErrorReportQuery", () => {
  const parse = (qs: string) => parseErrorReportQuery(new URLSearchParams(qs));

  it("パラメータなし → 既定値", () => {
    expect(parse("")).toEqual({
      ok: true,
      query: {
        dateFrom: null,
        dateTo: null,
        includeResolved: false,
        facilityName: "",
        errorType: "",
        groupId: null,
        customerId: null,
        page: 1,
        perPage: PAGINATION.defaultPerPage,
      },
    });
  });

  it("include_resolved: true → true / false・空 → false", () => {
    const pick = (qs: string) => {
      const r = parse(qs);
      return r.ok ? r.query.includeResolved : "fail";
    };
    expect(pick("include_resolved=true")).toBe(true);
    expect(pick("include_resolved=false")).toBe(false);
    expect(pick("include_resolved=")).toBe(false);
  });

  it("★F3: include_resolved=yes(true / false 以外)→ 422(黙って false に倒さない)★", () => {
    const r = parse("include_resolved=yes");
    expect(r.ok).toBe(false);
    if (!r.ok) {
      expect(r.status).toBe(422);
      expect(r.error.error.code).toBe("validation_failed");
    }
    // 大文字も true / false 以外として扱う
    expect(parse("include_resolved=TRUE").ok).toBe(false);
  });

  it("★エラーに入力値をエコーしない★", () => {
    const r = parse("include_resolved=evil-input-value");
    expect(r.ok).toBe(false);
    if (!r.ok) expect(JSON.stringify(r.error)).not.toContain("evil-input-value");
  });

  it("★F7: date_from > date_to → 422(parseCleanLogQuery() と同じ文言)★", () => {
    const r = parse("date_from=2026-10-06&date_to=2026-10-05");
    expect(r.ok).toBe(false);
    if (!r.ok) {
      expect(r.status).toBe(422);
      expect(r.error.error.message).toBe("date_fromはdate_to以前の日付を指定してください");
    }
  });

  it("date_from = date_to は通る", () => {
    const r = parse("date_from=2026-10-06&date_to=2026-10-06");
    expect(r.ok && r.query.dateFrom).toBe("2026-10-06");
    expect(r.ok && r.query.dateTo).toBe("2026-10-06");
  });

  it("日付の形式が不正 → 422", () => {
    expect(parse("date_from=2026/10/05").ok).toBe(false);
    expect(parse("date_to=20261005").ok).toBe(false);
    const r = parse("date_from=abc");
    if (!r.ok) expect(r.status).toBe(422);
  });

  it("★空の日付は null に正規化する★", () => {
    const r = parse("date_from=&date_to=");
    expect(r.ok && r.query.dateFrom).toBeNull();
    expect(r.ok && r.query.dateTo).toBeNull();
  });

  it("★期間の日数の上限は設けない(/prechecks と同じ。§11 Q13)★", () => {
    expect(parse("date_from=2020-01-01&date_to=2026-12-31").ok).toBe(true);
  });

  it("group_id=0 → 422(parseMasterIdFilters() に任せる)", () => {
    const r = parse("group_id=0");
    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.status).toBe(422);
  });

  it("group_id / customer_id を読む", () => {
    const r = parse("group_id=3&customer_id=7");
    expect(r.ok && r.query.groupId).toBe(3);
    expect(r.ok && r.query.customerId).toBe(7);
  });

  it("page / per_page は parsePrecheckQuery() と同じ丸め", () => {
    const pick = (qs: string) => {
      const r = parse(qs);
      return r.ok ? [r.query.page, r.query.perPage] : "fail";
    };
    expect(pick("page=0")).toEqual([1, PAGINATION.defaultPerPage]);
    expect(pick("page=abc")).toEqual([1, PAGINATION.defaultPerPage]);
    expect(pick("page=3&per_page=10")).toEqual([3, 10]);
    expect(pick("per_page=0")).toEqual([1, 1]);
    expect(pick(`per_page=${PAGINATION.maxPerPage + 1}`)).toEqual([1, PAGINATION.maxPerPage]);
  });

  it("error_type / facility_name は前後の空白を除く(F5: 空白だけ → \"\")", () => {
    const r = parse("error_type=%20%20secur%20&facility_name=%20%E6%96%BD%E8%A8%AD%20");
    expect(r.ok && r.query.errorType).toBe("secur");
    expect(r.ok && r.query.facilityName).toBe("施設");
    const blank = parse("error_type=%20%20%20");
    expect(blank.ok && blank.query.errorType).toBe("");
  });
});