import { describe, expect, it } from "vitest";
// ★ルート本体は**静的 import** すること★(`app/api/reports/__tests__/route.test.ts` の実測)
import { GET } from "@/app/api/clean-area/route";
import { facilities } from "@/lib/fixtures";
import { includesFoldName } from "@/lib/format/search-text";
import type { CleanAreaResponse } from "@/lib/types";
/**
* `GET /clean-area` を ★fixtures(`USE_FIXTURES` の既定 = true)★ で通すテスト。
*
* ------------------------------------------------------------------------
* ★★なぜ `route.test.ts` と分けてあるのか★★
* ------------------------------------------------------------------------
* ルートは ★施設名の解決と分類の解決を `Promise.all` で並列に★ 走らせる
* (`resolveFacilityIdsByName()` / `resolveFacilityIdsByMasters()`。指示書AK §6-2)。
*
* ★★`route.test.ts` の `vi.mock("@/lib/db/prisma")` ではこの経路を試せません★★ —
* `lib/db/index.ts` は `await import("./prisma")` で Prisma を動的に読むため、
* ★同じ瞬間に2本の動的 import が走ると、片方が差し替えをすり抜けて本物を掴み、
* 「DATABASE_URL が無い」で 500 になります★(実測。テスト環境側の制約であって
* ルートの不具合ではない)。
* ★fixtures 経路は動的 import を通らない★ので、★★積集合(AND)はこちらで固定します★★。
*
* ------------------------------------------------------------------------
* ★★期待値は fixtures から組み立てる★★(`app/api/__tests__/master-filter.test.ts` と同じ作法)
* ------------------------------------------------------------------------
* 施設の割り当てが変わってもテストが「件数だけ違う」で落ちないようにするため。
* ★代わりに「絞る前後で必ず差が出ること」を明示的に確かめる★
* (=絞り込みが本当に効いたことの確認)。
*/
async function body(qs = ""): Promise<CleanAreaResponse> {
const res = await GET(new Request(`http://localhost/api/clean-area${qs}`));
expect(res.status).toBe(200);
return (await res.json()) as CleanAreaResponse;
}
/** 面積を持つ施設だけ(fixtures の面積行が無い施設は内訳に現れない) */
async function facilityIdsInResponse(qs = ""): Promise<number[]> {
const res = await body(qs);
return res.items.map((row) => row.id as number).sort((a, b) => a - b);
}
describe("GET /clean-area(fixtures)", () => {
it("#F1 ★粒度を変えても全体合計が変わらない★(§9-3 の 9-8 / B-5)", async () => {
const byFacility = await body("?group_by=facility");
const byCustomer = await body("?group_by=customer");
const byGroup = await body("?group_by=group");
expect(byFacility.total.clean_area).toBeGreaterThan(0);
expect(byCustomer.total).toEqual(byFacility.total);
expect(byGroup.total).toEqual(byFacility.total);
// ★内訳の和 = 全体合計★(粒度ごとに確かめる。落とし穴4 / 落とし穴5)
for (const res of [byFacility, byCustomer, byGroup]) {
const sum = res.items.reduce((acc, row) => acc + row.clean_area, 0);
expect(sum).toBeCloseTo(res.total.clean_area, 6);
}
// 粒度を上げるほど行数は減る(同数のままなら畳めていない)
expect(byGroup.items.length).toBeLessThan(byFacility.items.length);
});
it("#F2 ★分類で絞ると内訳がその傘下だけになる★", async () => {
const all = await facilityIdsInResponse();
const groupId = facilities[0].group_id;
expect(groupId).not.toBeNull();
const filtered = await facilityIdsInResponse(`?group_id=${groupId}`);
const allowed = new Set(
facilities.filter((f) => f.group_id === groupId).map((f) => f.id)
);
expect(filtered.every((id) => allowed.has(id))).toBe(true);
// ★絞り込みが本当に効いたことの確認★ — 絞る前には傘下でない施設が居た
expect(all.some((id) => !allowed.has(id))).toBe(true);
expect(filtered.length).toBeLessThan(all.length);
});
it("#F3 ★★施設名 × 分類は AND(積集合)★★(指示書AK §6-2 / 落とし穴3)", async () => {
// ★`Promise.all` で並列解決される2つの条件★が両方効いていることを見る。
// ★キーワードは「2つ以上の屋号にまたがる」ものを選ぶこと★ —
// 1屋号にしか当たらない語だと、AND を無視しても同じ結果になり ★テストが無意味になる★。
const customerId = facilities[0].customer_id;
const keyword = "バリューマート";
const byName = facilities.filter((f) => includesFoldName(f.facility_name, keyword));
// ★前提そのものを固定する★(fixtures が変わったらここで落ちる)
expect(new Set(byName.map((f) => f.customer_id)).size).toBeGreaterThan(1);
const expected = byName.filter((f) => f.customer_id === customerId).map((f) => f.id);
expect(expected.length).toBeGreaterThan(0);
const got = await facilityIdsInResponse(
`?customer_id=${customerId}&facility_name=${encodeURIComponent(keyword)}`
);
// ★面積行を持たない施設は内訳に出ない★ので「期待の部分集合であること」を見る
expect(got.every((id) => expected.includes(id))).toBe(true);
// ★どちらか片方を捨てていないこと★ —
// 施設名だけで引くと、別の屋号の施設まで入ってくる(= AND が効いていれば入らない)
const byNameOnly = await facilityIdsInResponse(
`?facility_name=${encodeURIComponent(keyword)}`
);
expect(byNameOnly.length).toBeGreaterThanOrEqual(got.length);
expect(
byNameOnly.every((id) => facilities.find((f) => f.id === id)?.customer_id === customerId)
).toBe(false);
});
it("#F4 ★実在しない group_id は0件(全件に化けない)★(§9-2 の 9-4)", async () => {
const res = await body("?group_id=9999");
expect(res.items).toEqual([]);
expect(res.total).toEqual({
clean_area: 0,
task_area: 0,
// ★分母0は `null`★(0% ではない)
completion_rate: null,
executed_count: 0,
});
expect(res.meta).toEqual({ total: 0, page: 1, per_page: 0 });
});
it("#F5 期間で絞ると合計が減る(§9-2 の 9-3)", async () => {
const all = await body();
// fixtures の1日だけを切り出す(★どの日でもよいので全期間より小さくなればよい★)
const narrowed = await body("?date_from=2026-09-10&date_to=2026-09-10");
expect(narrowed.total.clean_area).toBeLessThan(all.total.clean_area);
expect(narrowed.total.executed_count).toBeLessThan(all.total.executed_count);
});
});