import { beforeEach, describe, expect, it, vi } from "vitest";
// ★ルート本体は静的 import★(`it()` の中で `await import()` すると、最初の1本だけが
// モジュール読み込みを抱え込んでフルスイート実行時にタイムアウトしうる。
// `app/api/reports/__tests__/route.test.ts` の実測)
import { GET as GET_CUSTOMERS } from "@/app/api/customers/route";
import { GET as GET_GROUPS } from "@/app/api/groups/route";
import type { ApiError, Customer, Group, ListResponse } from "@/lib/types";
/**
* 分類マスタAPI 2本(`GET /groups` / `GET /customers`。指示書AK 段階1)の期待値表。
*
* ------------------------------------------------------------------------
* ★★DBには接続しません★★
* ------------------------------------------------------------------------
* `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、
* `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
* ★`prismaSource` の経路★ を走らせる(`app/api/reports/__tests__/route.test.ts` と同じ作法)。
* fixtures 側(`USE_FIXTURES=true`)の不変条件は
* `lib/fixtures/__tests__/masters.test.ts` が見る。
*
* ------------------------------------------------------------------------
* ★★何を守るためのテストか★★
* ------------------------------------------------------------------------
* - ★`group_id = 0` を API に漏らさないこと★(指示書AK 落とし穴3 / D-011)—
* ★漏らすと「グループ0」という存在しないホールディングスが画面に出る★
* - ★`deleted_at` で絞ること/`inactive` では絞らないこと★(§4-1 #4)—
* ★どちらも「件数が静かに変わる」だけで例外は出ない★
* - ★セレクトリストを切らないこと★(§4-1 #1)— 51件目以降が消えても誰も気づけない
* - ★`customers.contract` を読まないこと★(D-010 / 落とし穴5)
*/
interface StubGroup {
id: bigint;
group_name: string;
inactive: number;
deleted_at: Date | null;
}
interface StubCustomer {
id: bigint;
customer_name: string;
group_id: bigint;
inactive: number;
deleted_at: Date | null;
}
const calls: { group: Record<string, unknown>[]; customer: Record<string, unknown>[] } = {
group: [],
customer: [],
};
let data: { groups: StubGroup[]; customers: StubCustomer[] };
function matchesWhere(row: Record<string, unknown>, where: unknown): boolean {
if (!where) return true;
for (const [field, cond] of Object.entries(where as Record<string, unknown>)) {
if (row[field] !== cond) return false;
}
return true;
}
function findMany(
bucket: keyof typeof calls,
rows: Record<string, unknown>[],
args: Record<string, unknown> | undefined
) {
calls[bucket].push(args ?? {});
return rows.filter((row) => matchesWhere(row, args?.where));
}
vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
vi.mock("@/lib/db/prisma", () => ({
getPrisma: async () => ({
group: {
findMany: async (args?: Record<string, unknown>) =>
findMany("group", data.groups as unknown as Record<string, unknown>[], args),
},
customer: {
findMany: async (args?: Record<string, unknown>) =>
findMany("customer", data.customers as unknown as Record<string, unknown>[], args),
},
}),
}));
function group(
id: number,
name: string,
opts: { inactive?: number; deleted?: boolean } = {}
): StubGroup {
return {
id: BigInt(id),
group_name: name,
inactive: opts.inactive ?? 0,
deleted_at: opts.deleted ? new Date("2026-09-01T00:00:00Z") : null,
};
}
function customer(
id: number,
name: string,
groupId: number,
opts: { inactive?: number; deleted?: boolean } = {}
): StubCustomer {
return {
id: BigInt(id),
customer_name: name,
group_id: BigInt(groupId),
inactive: opts.inactive ?? 0,
deleted_at: opts.deleted ? new Date("2026-09-01T00:00:00Z") : null,
};
}
async function getGroups(qs = ""): Promise<Response> {
return GET_GROUPS(new Request(`http://localhost/api/groups${qs}`));
}
async function getCustomers(qs = ""): Promise<Response> {
return GET_CUSTOMERS(new Request(`http://localhost/api/customers${qs}`));
}
beforeEach(() => {
calls.group = [];
calls.customer = [];
data = {
groups: [
group(2, "グループB"),
group(1, "グループA"),
// ★無効化(inactive=1)でも一覧に出る★ / ★論理削除は出ない★
group(3, "グループC(無効)", { inactive: 1 }),
group(4, "グループD(削除済み)", { deleted: true }),
],
customers: [
customer(20, "屋号B", 1),
customer(10, "屋号A", 1),
customer(30, "屋号C", 2),
// ★group_id=0(ホールディングスなしのセンチネル。D-011)★
customer(40, "屋号D", 0),
customer(50, "屋号E(無効)", 1, { inactive: 1 }),
customer(60, "屋号F(削除済み)", 1, { deleted: true }),
],
};
});
describe("GET /groups", () => {
it("#1 id の昇順で返し、meta は total / page=1 / per_page=total", async () => {
const res = await getGroups();
expect(res.status).toBe(200);
const body = (await res.json()) as ListResponse<Group>;
expect(body.items.map((g) => g.id)).toEqual([1, 2, 3]);
expect(body.meta).toEqual({ total: 3, page: 1, per_page: 3 });
});
it("#2 deleted_at で絞る(論理削除は出ない)/inactive では絞らない", async () => {
const body = (await (await getGroups()).json()) as ListResponse<Group>;
expect(body.items.map((g) => g.group_name)).toContain("グループC(無効)");
expect(body.items.map((g) => g.group_name)).not.toContain("グループD(削除済み)");
expect(calls.group[0].where).toEqual({ deleted_at: null });
});
it("#3 ★id=0 の行を作らない★(『グループ0』が現れないこと)", async () => {
const body = (await (await getGroups()).json()) as ListResponse<Group>;
expect(body.items.some((g) => g.id === 0)).toBe(false);
});
it("#4 返すのは id と group_name だけ(inactive / deleted_at を漏らさない)", async () => {
const body = (await (await getGroups()).json()) as ListResponse<Group>;
expect(Object.keys(body.items[0]).sort()).toEqual(["group_name", "id"]);
});
it("#5 DBが落ちたら 500(共通のエラー形式)", async () => {
data = { get groups(): StubGroup[] { throw new Error("db down"); }, customers: [] };
const res = await getGroups();
expect(res.status).toBe(500);
const body = (await res.json()) as ApiError;
expect(body.error.code).toBe("internal_error");
});
});
describe("GET /customers", () => {
it("#6 id の昇順で返す(meta は /groups と同じ規則)", async () => {
const res = await getCustomers();
expect(res.status).toBe(200);
const body = (await res.json()) as ListResponse<Customer>;
expect(body.items.map((c) => c.id)).toEqual([10, 20, 30, 40, 50]);
expect(body.meta).toEqual({ total: 5, page: 1, per_page: 5 });
});
it("#7 ★group_id=0 は null になる★(『グループ0』を API に漏らさない。D-011)", async () => {
const body = (await (await getCustomers()).json()) as ListResponse<Customer>;
const d = body.items.find((c) => c.id === 40);
expect(d?.group_id).toBeNull();
expect(body.items.some((c) => c.group_id === 0)).toBe(false);
});
it("#8 deleted_at で絞る/inactive では絞らない", async () => {
const body = (await (await getCustomers()).json()) as ListResponse<Customer>;
expect(body.items.map((c) => c.id)).toContain(50);
expect(body.items.map((c) => c.id)).not.toContain(60);
expect(calls.customer[0].where).toEqual({ deleted_at: null });
});
it("#9 group_id=1 で傘下だけに絞れる", async () => {
const body = (await (await getCustomers("?group_id=1")).json()) as ListResponse<Customer>;
expect(body.items.map((c) => c.id)).toEqual([10, 20, 50]);
expect(body.meta.total).toBe(3);
});
it("#10 ★実在しない group_id は 200・空★(404 にしない)", async () => {
const res = await getCustomers("?group_id=999");
expect(res.status).toBe(200);
const body = (await res.json()) as ListResponse<Customer>;
expect(body.items).toEqual([]);
expect(body.meta).toEqual({ total: 0, page: 1, per_page: 0 });
});
it("#11 ★group_id=0 は 422★(0 はセンチネルで有効なIDではない)", async () => {
const res = await getCustomers("?group_id=0");
expect(res.status).toBe(422);
const body = (await res.json()) as ApiError;
expect(body.error.code).toBe("validation_failed");
expect(body.error.details).toEqual({
group_id: ["1以上の整数で指定してください"],
});
});
it("#12 ★group_id=1abc は 422★(parseInt の前方一致で 1 にしない。D-123)", async () => {
const res = await getCustomers("?group_id=1abc");
expect(res.status).toBe(422);
// ★422 のときは DB を読まない★
expect(calls.customer).toHaveLength(0);
});
it("#13 group_id= (空)は未指定と同じ(全件)", async () => {
const body = (await (await getCustomers("?group_id=")).json()) as ListResponse<Customer>;
expect(body.items).toHaveLength(5);
});
it("#14 返すのは id / customer_name / group_id だけ(★contract を読まない★。D-010)", async () => {
const body = (await (await getCustomers()).json()) as ListResponse<Customer>;
expect(Object.keys(body.items[0]).sort()).toEqual([
"customer_name",
"group_id",
"id",
]);
// `select` で列を絞っていないので、`where` に contract が混ざっていないことも見ておく
expect(JSON.stringify(calls.customer[0])).not.toContain("contract");
});
/**
* 指示書AL ★段階3★ §4-4 — ★`db.customers()` に `orderBy` があること★。
*
* ★★結果の並びでは確かめられない★★ — このスタブは配列をそのまま返すので、
* ★`orderBy` を消してもルートの `sortCustomers()` が並べ直して通ってしまう★。
* ★だから「DBに何を投げたか」を見る★。
*
* ★`/customers` 自身はこの `orderBy` に依存していない★(ルートで並べ替えるため)。
* ★依存しているのは `GET /reports/kpi` の 422★ — `details.customer_ids` は
* `db.customers()` の並びをそのまま流す(`resolveTargetCustomer()` は並べ替えない)。
*/
it("★段階3 §4-4★ id の昇順で DB から引く(★候補の並びをDB任せにしない★)", async () => {
await getCustomers();
expect(calls.customer[0].orderBy).toEqual({ id: "asc" });
});
});