Newer
Older
client_dwatch / lib / ds-alerts / __tests__ / query.test.ts
import { describe, expect, it } from "vitest";
import {
  filterDsAlertsByStatus,
  filterDsAlertsByType,
  parseDsAlertQuery,
  sortDsAlerts,
  summarizeDsAlerts,
} from "@/lib/ds-alerts/query";
import type { DsAlert } from "@/lib/types";

function row(overrides: Partial<DsAlert>): DsAlert {
  return {
    id: 1,
    alert_type: "security_off",
    device_serial: "DS-001",
    facility_id: 1,
    facility_name: "メガバリューマート中央本店",
    section_name: "1F売り場",
    detected_at: "2026-08-27T03:42:00+09:00",
    resolution_status: "pending",
    responded_at: null,
    resolved_at: null,
    memo: null,
    ...overrides,
  };
}

describe("parseDsAlertQuery", () => {
  it("省略時は type=all, status=open になる", () => {
    const parsed = parseDsAlertQuery(new URLSearchParams());
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.type).toBe("all");
    expect(parsed.query.status).toBe("open");
  });

  it("typeが不正な値なら422", () => {
    const parsed = parseDsAlertQuery(new URLSearchParams({ type: "bogus" }));
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) {
      expect(parsed.status).toBe(422);
      expect(parsed.error.error.code).toBe("validation_failed");
    }
  });

  it("statusが不正な値なら422", () => {
    const parsed = parseDsAlertQuery(new URLSearchParams({ status: "bogus" }));
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) {
      expect(parsed.status).toBe(422);
      expect(parsed.error.error.code).toBe("validation_failed");
    }
  });

  // ★どのパラメータが不正か呼び出し側に伝わること(details)を固定する★
  it("type不正のdetailsは選択肢を列挙する", () => {
    const parsed = parseDsAlertQuery(new URLSearchParams({ type: "bogus" }));
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) {
      expect(parsed.error.error.message).toBe("typeの値が不正です");
      expect(parsed.error.error.details).toEqual({
        type: ["all / security_off / disconnected のいずれかを指定してください"],
      });
    }
  });

  it("status不正のdetailsは選択肢を列挙する", () => {
    const parsed = parseDsAlertQuery(new URLSearchParams({ status: "bogus" }));
    expect(parsed.ok).toBe(false);
    if (!parsed.ok) {
      expect(parsed.error.error.message).toBe("statusの値が不正です");
      expect(parsed.error.error.details).toEqual({
        status: ["open / all / done のいずれかを指定してください"],
      });
    }
  });

  /**
   * ★group_id / customer_id(指示書AK 段階2)★
   *
   * ★★元は「渡してもパース結果に含まれない(D-053)」だったテストを反転させたもの★★ —
   *   ★`it` ごと消さないこと★。1本も残っていないと、次に誰かが「受け取って捨てる」
   *   実装に戻しても気づけない(指示書AK 落とし穴8)。
   *
   * | # | 入力 | 期待 |
   * |---|---|---|
   * | 2-1 | `group_id=1` | `1`(★`toHaveProperty` ではなく値まで見る★) |
   * | 2-2 | `customer_id=2` | `2` |
   * | 2-3 | 未指定 | ★`null`★(絞らない) |
   * | 2-4 | `group_id=0` | ★422★(0 は「グループなし」のセンチネル。有効なIDではない。D-011) |
   * | 2-5 | `group_id=1abc` | ★422★(★`parseInt` の前方一致で `1` にしない★。D-123 で実際に踏んだ罠) |
   * | 2-6 | `group_id=-1` | 422 |
   * | 2-7 | `group_id=1.5` | 422 |
   * | 2-8 | `9007199254740992`(安全整数超) | 422 |
   */
  it("group_id / customer_id を値として読む(D-053 解除。指示書AK 段階2)", () => {
    const parsed = parseDsAlertQuery(new URLSearchParams({ group_id: "1", customer_id: "2" }));
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.groupId).toBe(1);
    expect(parsed.query.customerId).toBe(2);
  });

  it("group_id / customer_id 未指定は null(絞らない)", () => {
    const parsed = parseDsAlertQuery(new URLSearchParams());
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.groupId).toBeNull();
    expect(parsed.query.customerId).toBeNull();
  });

  it.each(["0", "1abc", "-1", "1.5", "9007199254740992"])(
    "group_id=%s は422",
    (value) => {
      const parsed = parseDsAlertQuery(new URLSearchParams({ group_id: value }));
      expect(parsed.ok).toBe(false);
      if (parsed.ok) throw new Error("422を期待");
      expect(parsed.status).toBe(422);
      expect(parsed.error.error.details).toEqual({
        group_id: ["1以上の整数で指定してください"],
      });
    }
  );

  it.each(["0", "1abc"])("customer_id=%s も同じ規則で422", (value) => {
    const parsed = parseDsAlertQuery(new URLSearchParams({ customer_id: value }));
    expect(parsed.ok).toBe(false);
    if (parsed.ok) throw new Error("422を期待");
    expect(parsed.status).toBe(422);
    expect(parsed.error.error.details).toEqual({
      customer_id: ["1以上の整数で指定してください"],
    });
  });

  it("page/per_pageの既定値と上限", () => {
    const parsed = parseDsAlertQuery(new URLSearchParams());
    expect(parsed.ok).toBe(true);
    if (!parsed.ok) throw new Error("query");
    expect(parsed.query.page).toBe(1);
    expect(parsed.query.perPage).toBe(50);

    const capped = parseDsAlertQuery(new URLSearchParams({ per_page: "9999" }));
    expect(capped.ok).toBe(true);
    if (!capped.ok) throw new Error("query");
    expect(capped.query.perPage).toBe(200);
  });
});

describe("filterDsAlertsByType", () => {
  const items: DsAlert[] = [
    row({ id: 1, alert_type: "security_off" }),
    row({ id: 2, alert_type: "disconnected" }),
  ];

  it("type=all は全件保持する", () => {
    expect(filterDsAlertsByType(items, "all")).toHaveLength(2);
  });

  it("type=security_off で絞り込む", () => {
    expect(filterDsAlertsByType(items, "security_off").map((a) => a.id)).toEqual([1]);
  });

  it("type=disconnected で絞り込む", () => {
    expect(filterDsAlertsByType(items, "disconnected").map((a) => a.id)).toEqual([2]);
  });
});

describe("filterDsAlertsByStatus", () => {
  const items: DsAlert[] = [
    row({ id: 1, resolution_status: "pending" }),
    row({ id: 2, resolution_status: "in_progress" }),
    row({ id: 3, resolution_status: "done" }),
  ];

  it("status=open は未対応+対応中(対応済み以外)", () => {
    expect(filterDsAlertsByStatus(items, "open").map((a) => a.id)).toEqual([1, 2]);
  });

  it("status=done は対応済みのみ", () => {
    expect(filterDsAlertsByStatus(items, "done").map((a) => a.id)).toEqual([3]);
  });

  it("status=all は全件保持する", () => {
    expect(filterDsAlertsByStatus(items, "all")).toHaveLength(3);
  });

  it("type=disconnected & status=done で0件(docs/fixtures.mdの再現条件)", () => {
    const disconnectedItems: DsAlert[] = [
      row({ id: 1, alert_type: "disconnected", resolution_status: "pending" }),
    ];
    const filtered = filterDsAlertsByStatus(
      filterDsAlertsByType(disconnectedItems, "disconnected"),
      "done"
    );
    expect(filtered).toHaveLength(0);
  });
});

describe("sortDsAlerts", () => {
  it("detected_atの降順になる", () => {
    const items: DsAlert[] = [
      row({ id: 1, detected_at: "2026-08-26T03:00:00+09:00" }),
      row({ id: 2, detected_at: "2026-08-27T03:00:00+09:00" }),
      row({ id: 3, detected_at: "2026-08-25T03:00:00+09:00" }),
    ];
    expect(sortDsAlerts(items).map((a) => a.id)).toEqual([2, 1, 3]);
  });

  it("detected_atが同値のときはidの昇順で安定させる(D-052と同じ規則)", () => {
    const items: DsAlert[] = [
      row({ id: 3, detected_at: "2026-08-27T03:00:00+09:00" }),
      row({ id: 1, detected_at: "2026-08-27T03:00:00+09:00" }),
      row({ id: 2, detected_at: "2026-08-27T03:00:00+09:00" }),
    ];
    expect(sortDsAlerts(items).map((a) => a.id)).toEqual([1, 2, 3]);
  });
});

describe("summarizeDsAlerts", () => {
  it("pending/in_progress/doneをそれぞれ数える", () => {
    const items: DsAlert[] = [
      row({ id: 1, resolution_status: "pending" }),
      row({ id: 2, resolution_status: "pending" }),
      row({ id: 3, resolution_status: "in_progress" }),
      row({ id: 4, resolution_status: "done" }),
    ];
    expect(summarizeDsAlerts(items)).toEqual({ pending: 2, in_progress: 1, done: 1 });
  });

  it("0件でも壊れない", () => {
    expect(summarizeDsAlerts([])).toEqual({ pending: 0, in_progress: 0, done: 0 });
  });
});

describe("summaryはtype/statusフィルタから独立している(D-054と同じ規則)", () => {
  const items: DsAlert[] = [
    row({ id: 1, alert_type: "security_off", resolution_status: "pending" }),
    row({ id: 2, alert_type: "disconnected", resolution_status: "in_progress" }),
    row({ id: 3, alert_type: "disconnected", resolution_status: "done" }),
  ];

  it("typeやstatusを変えてもsummaryは同一(全アラートに対する集計)", () => {
    // route.ts と同じ組み立て順: summaryは全件(フィルタ前)に対して計算する
    const summaryBase = summarizeDsAlerts(items);
    const summaryAfterType = summarizeDsAlerts(items);
    const summaryAfterStatus = summarizeDsAlerts(items);

    expect(summaryBase).toEqual({ pending: 1, in_progress: 1, done: 1 });
    expect(summaryBase).toEqual(summaryAfterType);
    expect(summaryBase).toEqual(summaryAfterStatus);

    // 一覧側はtype/statusで変わる
    expect(filterDsAlertsByType(items, "security_off")).toHaveLength(1);
    expect(filterDsAlertsByStatus(items, "done")).toHaveLength(1);
  });
});