Newer
Older
client_dwatch / lib / masters / __tests__ / query.test.ts
import { describe, expect, it } from "vitest";
import { parseMasterIdParam } from "@/lib/api/master-id";
import {
  filterCustomersByGroup,
  masterListResponse,
  parseCustomerQuery,
  sortCustomers,
  sortGroups,
} from "@/lib/masters/query";
import type { Customer, Group } from "@/lib/types";

/**
 * 分類マスタのクエリ処理(指示書AK 段階1)の期待値表。
 *
 * ------------------------------------------------------------------------
 * ★★何を守るためのテストか★★
 * ------------------------------------------------------------------------
 *  - ★`group_id=1abc` が `1` として通らないこと★(★D-123 で実際に踏んだ罠★。
 *    `Number.parseInt()` は前方一致なので、送った文字列とは違うIDで絞り込みが走る)
 *  - ★`group_id=0` を受け付けないこと★(0 は「ホールディングスなし」のセンチネルで、
 *    有効なIDではない。D-011)
 *  - ★未指定(`null`)と「該当0件」(`[]`)を取り違えないこと★ —
 *    取り違えると ★0件のはずが全件表示に化ける★(指示書AK 落とし穴1 と同じ形)
 */

const GROUPS: Group[] = [
  { id: 3, group_name: "グループC" },
  { id: 1, group_name: "グループA" },
  { id: 2, group_name: "グループB" },
];

const CUSTOMERS: Customer[] = [
  { id: 30, customer_name: "屋号C", group_id: null },
  { id: 10, customer_name: "屋号A", group_id: 1 },
  { id: 20, customer_name: "屋号B", group_id: 1 },
  { id: 40, customer_name: "屋号D", group_id: 2 },
];

function parse(qs: string) {
  return parseCustomerQuery(new URLSearchParams(qs));
}

describe("parseMasterIdParam", () => {
  const CASES: { input: string | null; expected: number | null }[] = [
    { input: null, expected: null },
    { input: "", expected: null },
    { input: "   ", expected: null },
    { input: "1", expected: 1 },
    { input: " 2 ", expected: 2 },
    { input: "9007199254740991", expected: 9007199254740991 },
  ];
  for (const { input, expected } of CASES) {
    it(`${JSON.stringify(input)} → ${expected}`, () => {
      const r = parseMasterIdParam(input, "group_id");
      expect(r.ok).toBe(true);
      if (r.ok) expect(r.query).toBe(expected);
    });
  }

  // ★2-4〜2-8(指示書AK §5-2 の表)と同じ境界★ — 段階2 のパーサもこの関数を使う
  const INVALID = [
    "0", // ★センチネル。有効なIDではない★
    "1abc", // ★D-123 の罠。parseInt なら 1 として通ってしまう★
    "-1",
    "1.5",
    "1e9",
    "9007199254740992", // ★安全整数超★
    "1", // 全角数字
    "abc",
  ];
  for (const input of INVALID) {
    it(`${JSON.stringify(input)} は 422`, () => {
      const r = parseMasterIdParam(input, "group_id");
      expect(r.ok).toBe(false);
      if (!r.ok) {
        expect(r.status).toBe(422);
        expect(r.error.error.code).toBe("validation_failed");
        expect(r.error.error.details).toEqual({
          group_id: ["1以上の整数で指定してください"],
        });
      }
    });
  }

  it("details のキーはパラメータ名になる(customer_id)", () => {
    const r = parseMasterIdParam("0", "customer_id");
    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.error.error.details).toHaveProperty("customer_id");
  });

  it("エラー文言に入力値をエコーしない", () => {
    const r = parseMasterIdParam("<script>", "group_id");
    expect(r.ok).toBe(false);
    if (!r.ok) expect(JSON.stringify(r.error)).not.toContain("script");
  });
});

describe("parseCustomerQuery", () => {
  it("未指定なら groupId は null", () => {
    const r = parse("");
    expect(r.ok).toBe(true);
    if (r.ok) expect(r.query.groupId).toBeNull();
  });

  it("group_id=1 なら 1 が入る", () => {
    const r = parse("group_id=1");
    expect(r.ok).toBe(true);
    if (r.ok) expect(r.query.groupId).toBe(1);
  });

  it("group_id=0 は 422", () => {
    const r = parse("group_id=0");
    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.status).toBe(422);
  });
});

describe("filterCustomersByGroup", () => {
  it("null(未指定)は全件", () => {
    expect(filterCustomersByGroup(CUSTOMERS, null)).toHaveLength(4);
  });

  it("group_id を指定すると傘下だけになる", () => {
    expect(filterCustomersByGroup(CUSTOMERS, 1).map((c) => c.id)).toEqual([10, 20]);
  });

  it("★実在しない group_id は空配列★(全件に化けないこと)", () => {
    expect(filterCustomersByGroup(CUSTOMERS, 999)).toEqual([]);
  });

  it("★group_id が null の屋号は、どの group_id でも当たらない★", () => {
    for (const id of [1, 2, 999]) {
      expect(filterCustomersByGroup(CUSTOMERS, id).some((c) => c.group_id === null)).toBe(
        false
      );
    }
  });
});

describe("sortGroups / sortCustomers", () => {
  it("id の昇順(契約どおり)", () => {
    expect(sortGroups(GROUPS).map((g) => g.id)).toEqual([1, 2, 3]);
    expect(sortCustomers(CUSTOMERS).map((c) => c.id)).toEqual([10, 20, 30, 40]);
  });

  it("引数を破壊しない(純粋関数)", () => {
    sortGroups(GROUPS);
    expect(GROUPS[0].id).toBe(3);
  });
});

describe("masterListResponse", () => {
  it("★items を切らない★(セレクトリストが黙って短くならないこと)", () => {
    const items = Array.from({ length: 120 }, (_, i) => ({ id: i + 1 }));
    const body = masterListResponse(items);
    expect(body.items).toHaveLength(120);
    expect(body.meta).toEqual({ total: 120, page: 1, per_page: 120 });
  });

  it("0件でも meta を返す", () => {
    expect(masterListResponse([]).meta).toEqual({ total: 0, page: 1, per_page: 0 });
  });
});