import { beforeEach, describe, expect, it, vi } from "vitest";
// ★ルート本体は★★静的 import★★すること★ — `it()` の中で `await import()` すると、
// 最初の1本がモジュール読み込みを抱え込み、フルスイート実行時にタイムアウトしうる
// (`app/api/reports/__tests__/route.test.ts` の実測)。
import { GET } from "@/app/api/clean-area/route";
import { CLEAN_AREA_GROUP_NONE_LABEL } from "@/lib/clean-area/aggregate";
import { CLEAN_AREA_ANOMALY_METRICS } from "@/lib/clean-area/anomaly";
import { AREA_UNIT, CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import { logEvent } from "@/lib/logger";
import type { ApiError, CleanAreaResponse } from "@/lib/types";
/**
* `GET /clean-area` の期待値表(指示書AN ★段階4★ / §9-2 の確認項目をテストに落としたもの)。
*
* ------------------------------------------------------------------------
* ★★何を守るためのテストか★★
* ------------------------------------------------------------------------
* - ★★粒度を変えても全体合計が変わらないこと★★(§9-2 の 9-2 / §5-3 B-5 / 落とし穴4)—
* ★変わったらロールアップが壊れている★。例外もテスト失敗も出ない類の壊れ方
* - ★`group_id=9999` が0件になること★(§9-2 の 9-4 / ★落とし穴3★)—
* ★`facility_ids` を `?.length` で分岐すると「0件ヒット」が「全件」に化ける★
* - ★★番人を呼んでいること★★(§2-5 / ★落とし穴2★)— ルートが
* `reportCleanAreaAnomalies()` を呼び忘れても ★例外もテスト失敗も出ない★ ので、
* ★ここで固定しておかないと静かに消える★
* - ★完了率を丸めないこと★(§9-2 の 9-5 / `CLAUDE.md` §4.3)
* - ★単位(`AREA_UNIT`)をレスポンスに入れないこと★(§9-2 の 9-6 / §2-4)
*
* ------------------------------------------------------------------------
* ★★DBには接続しません★★
* ------------------------------------------------------------------------
* `@/lib/db/prisma` の `getPrisma()` を ★スタブに差し替え★、
* `@/lib/db/use-fixtures` を ★`false`★ に固定して ★`prismaSource` の経路★ を走らせる
* (`app/api/customers/__tests__/route.test.ts` / `app/api/reports/__tests__/route.test.ts`
* と同じ作法)。実DB(テストDB/ローカルDB)との突き合わせは別途実施する(§9)。
*/
// ---------------------------------------------------------------- スタブ(DBの代わり)
interface StubFacility {
id: bigint;
facility_name: string;
customer_id: bigint;
customer: {
customer_name: string;
/** ★NOT NULL で 0 = ホールディングスなしのセンチネル★(D-011) */
group_id: bigint;
group: { group_name: string } | null;
};
}
/**
* `clean_task_reports` の1行(★旧: `clean_status_histories`。指示書AQ 決定2 / 決定3★)。
*/
interface StubReport {
facility_id: bigint;
/**
* ★nullable★(`prisma/schema.prisma` の `CleanTaskReport.clean_timetable_id`)—
* ★TeamWorks がまだ埋めていない★(指示書AQ 決定5 / 罠1)。
*/
clean_timetable_id: bigint | null;
/**
* ★DB の DATETIME は「日本時間の壁時計値」★なので、スタブも
* ★壁時計値を UTC のフィールドに載せた `Date`★ で持つ(`toDbJst()` の形)。
* 例: JST 2026-09-05 02:00 の清掃 → `new Date("2026-09-05T02:00:00Z")`。
*/
started_at: Date;
clean_area: number;
task_area: number;
/**
* ★完了率には使っていない★(D-155。1回ごとの完了率は面積から `runCompletionRate()`)。
* 列を読んでいないことを確かめるため、面積比と★わざと食い違う値★を入れてある。
*/
clean_ratio: number;
}
interface Range {
gte?: Date;
lt?: Date;
}
interface GroupByArgs {
by: string[];
where?: {
started_at?: Range;
facility_id?: { in: bigint[] };
/** ★番人だけが付ける★(`countDuplicatedTimetables()`。`IS NOT NULL`) */
clean_timetable_id?: { not: null };
};
having?: { clean_timetable_id?: { _count?: { gt?: number } } };
}
interface FindManyArgs {
where?: GroupByArgs["where"];
select?: Record<string, boolean>;
}
interface TimetableFindManyArgs {
where: { id: { in: bigint[] }; precheck: number };
select: { id: true };
}
let data: {
facilities: StubFacility[];
reports: StubReport[];
/**
* ★本清掃でない(事前チェックの)予定の id★。★ここに無い id は本清掃(`precheck = 0`)として扱う★
* (予定そのものが無い dangling は `danglingTimetableIds` で表す)
*/
precheckTimetableIds: Set<string>;
/** ★予定の行が存在しない id★(dangling) */
danglingTimetableIds: Set<string>;
/**
* ★`clean_timetable_histories` にだけある本清掃の予定の id★(指示書AU 段階3b)。
* ★本表(`clean_timetables`)はこの id を返さない★。無ければ空
*/
historyOnlyTimetableIds?: Set<string>;
};
/** ★DBに何を投げたかを見る★(0件が全件に化けていないことは `where` でも確かめる) */
const groupByCalls: GroupByArgs[] = [];
/** ★完了率用の取り寄せ(`cleanTaskReport.findMany`)と予定の引き直し(`cleanTimetable.findMany`)★ */
const findManyCalls: FindManyArgs[] = [];
const timetableCalls: TimetableFindManyArgs[] = [];
/** ★`clean_timetable_histories` への引き直し(指示書AU 段階3b)★ */
const historyTimetableCalls: TimetableFindManyArgs[] = [];
function matches(row: StubReport, where: GroupByArgs["where"]): boolean {
if (!where) return true;
const { started_at: range, facility_id, clean_timetable_id } = where;
if (range?.gte && row.started_at < range.gte) return false;
if (range?.lt && row.started_at >= range.lt) return false;
// ★`in: []` は0件(`WHERE 1=0`)★。★条件そのものが無いときだけ全件★
if (facility_id && !facility_id.in.map(Number).includes(Number(row.facility_id))) {
return false;
}
// ★`{ not: null }` は `IS NOT NULL`★(Prisma の nullable フィールドの作法)
if (clean_timetable_id && row.clean_timetable_id === null) return false;
return true;
}
function groupBy(args: GroupByArgs) {
groupByCalls.push(args);
const rows = data.reports.filter((row) => matches(row, args.where));
if (args.by[0] === "clean_timetable_id") {
// ★番人のクエリ★: ★`having` で「2行以上」だけを返す★(絞らないと最大26万行返る)
// ★★SQL の `GROUP BY` と同じく NULL 同士は1つのグループにまとめる★★ —
// ★スタブが NULL を捨ててしまうと、実装が `IS NOT NULL` の除外を落としても
// テストが通ってしまう★(指示書AQ 罠1 / §5-3)
const counts = new Map<string, { id: bigint | null; n: number }>();
for (const row of rows) {
const key = row.clean_timetable_id === null ? "NULL" : String(row.clean_timetable_id);
const entry = counts.get(key) ?? { id: row.clean_timetable_id, n: 0 };
entry.n += 1;
counts.set(key, entry);
}
// ★`having` が無ければ全グループを返す★(実DBと同じ。落とすと番人の件数が膨らんで検出できる)
const gt = args.having?.clean_timetable_id?._count?.gt;
return [...counts.values()]
.filter(({ n }) => gt === undefined || n > gt)
.map(({ id, n }) => ({
clean_timetable_id: id,
_count: { clean_timetable_id: n },
}));
}
// ★面積の合計だけ★(指示書AS 段階4。完了率・実施回数は `findMany` の行から数える)
const buckets = new Map<string, { clean: number; task: number }>();
for (const row of rows) {
const key = String(row.facility_id);
const bucket = buckets.get(key) ?? { clean: 0, task: 0 };
bucket.clean += row.clean_area;
bucket.task += row.task_area;
buckets.set(key, bucket);
}
return [...buckets.entries()].map(([id, b]) => ({
facility_id: BigInt(id),
_sum: { clean_area: b.clean, task_area: b.task },
}));
}
/** ★完了率用の取り寄せ★(`where` は集計と同じものに `{ not: null }` を足したもの) */
function findManyReports(args: FindManyArgs) {
findManyCalls.push(args);
return data.reports.filter((row) => matches(row, args.where));
}
/** ★本清掃(`precheck = 0`)の予定だけを返す★(事前チェック・dangling・履歴だけの予定は返らない) */
function findManyTimetables(args: TimetableFindManyArgs) {
const inMain = (id: bigint) =>
!data.danglingTimetableIds.has(String(id)) && !(data.historyOnlyTimetableIds?.has(String(id)) ?? false);
// ★影チェック(`precheck` なしで id を引き直す。`lib/db/timetable-source.ts`)★ — 本表にある id を返す。
// ★記録しない★(`timetableCalls` の `precheck` を見ている #13 を変えないため)
if (args.where.precheck === undefined) return args.where.id.in.filter(inMain).map((id) => ({ id }));
timetableCalls.push(args);
if (args.where.precheck !== 0) return [];
return args.where.id.in
.filter((id) => !data.precheckTimetableIds.has(String(id)) && inMain(id))
.map((id) => ({ id }));
}
/** ★`clean_timetable_histories` 側★: 履歴だけにある本清掃の予定を返す(指示書AU 段階3b) */
function findManyHistoryTimetables(args: TimetableFindManyArgs) {
historyTimetableCalls.push(args);
if (args.where.precheck !== 0) return [];
return args.where.id.in
.filter((id) => data.historyOnlyTimetableIds?.has(String(id)) ?? false)
.map((id) => ({ id }));
}
vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
vi.mock("@/lib/logger", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logger")>();
// ★`withRequestId` はそのまま使う★(差し替えるとルートが動かない)。見たいのは `logEvent` だけ
return { ...actual, logEvent: vi.fn() };
});
vi.mock("@/lib/db/prisma", () => ({
getPrisma: async () => ({
// ★施設マスタは `select` で列を絞って読むが、スタブは絞らずに全列返す★
// (`select` を適用しない=上位集合を返すので、どのローダーからでも使える)
facility: { findMany: async () => data.facilities },
// ★集計元は `clean_task_reports`★(旧: `cleanStatusHistory`。指示書AQ 決定2 / 決定3)
cleanTaskReport: {
groupBy: async (args: GroupByArgs) => groupBy(args),
findMany: async (args: FindManyArgs) => findManyReports(args),
},
// ★本清掃かどうかの引き直し★(指示書AS 段階4。`include` は使わない)
cleanTimetable: {
findMany: async (args: TimetableFindManyArgs) => findManyTimetables(args),
},
// ★予定は `clean_timetable_histories` からも読む(指示書AU 段階3b)★
cleanTimetableHistory: {
findMany: async (args: TimetableFindManyArgs) => findManyHistoryTimetables(args),
},
}),
}));
function facility(
id: number,
name: string,
customerId: number,
customerName: string,
groupId: number,
groupName: string | null
): StubFacility {
return {
id: BigInt(id),
facility_name: name,
customer_id: BigInt(customerId),
customer: {
customer_name: customerName,
group_id: BigInt(groupId),
group: groupName === null ? null : { group_name: groupName },
},
};
}
function report(
facilityId: number,
timetableId: number | null,
day: string,
cleanArea: number,
taskArea: number,
cleanRatio: number
): StubReport {
return {
facility_id: BigInt(facilityId),
clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
started_at: new Date(`${day}T02:00:00Z`),
clean_area: cleanArea,
task_area: taskArea,
clean_ratio: cleanRatio,
};
}
/** ★番人のログ(`data.anomaly`)だけ★を取り出す(`judge.clean_area` は毎回1行出る) */
function anomalyEvents() {
return vi
.mocked(logEvent)
.mock.calls.filter(([, payload]) => payload.event === "data.anomaly");
}
async function get(qs = ""): Promise<Response> {
return GET(new Request(`http://localhost/api/clean-area${qs}`));
}
async function body(qs = ""): Promise<CleanAreaResponse> {
const res = await get(qs);
expect(res.status).toBe(200);
return (await res.json()) as CleanAreaResponse;
}
beforeEach(() => {
vi.mocked(logEvent).mockClear();
groupByCalls.length = 0;
findManyCalls.length = 0;
timetableCalls.length = 0;
historyTimetableCalls.length = 0;
data = {
precheckTimetableIds: new Set(),
danglingTimetableIds: new Set(),
facilities: [
// ホールディングス1「バリューHD」→ 屋号10「バリューマート」→ 施設1・2
facility(1, "バリューマート渋谷本店", 10, "バリューマート", 1, "バリューHD"),
facility(2, "バリューマート戸塚店", 10, "バリューマート", 1, "バリューHD"),
// 同じホールディングスの別屋号20「メガバリュー」→ 施設3
facility(3, "メガバリューマート緑野店", 20, "メガバリュー", 1, "バリューHD"),
// ★ホールディングスなし(group_id=0 のセンチネル)★ → 屋号30 → 施設4
facility(4, "ひとり商店", 30, "ひとり商店グループ", 0, null),
],
// ★`clean_ratio` 列は1回ごとの面積比(clean_area÷task_area)と**わざと食い違う値**にしてある★ —
// ★完了率を `clean_ratio` 列で出していたら #1 / #7 が落ちる★(D-155。列は完了率に使わない)。
// 1回ごとの完了率(`runCompletionRate()`): 90.0 / 80.0(80.05 を切り捨て)/ 70.0 / 50.0 / 10.0
reports: [
report(1, 1001, "2026-09-01", 900, 1000, 88.1),
report(1, 1002, "2026-09-10", 800.5, 1000, 70.05),
report(2, 1003, "2026-09-01", 700, 1000, 65),
report(3, 1004, "2026-09-10", 500, 1000, 40),
report(4, 1005, "2026-09-10", 100, 1000, 12.5),
],
};
});
describe("GET /clean-area", () => {
it("#1 期間未指定なら全期間の合計を返す(施設単位が既定)", async () => {
const res = await body();
expect(res.total).toEqual({
clean_area: 3000.5,
task_area: 5000,
// ★1回ごとの完了率の単純平均★(D-155。旧: `clean_ratio` 列の平均 → その前は面積比 3000.5/5000)
completion_rate: (90 + 80 + 70 + 50 + 10) / 5,
executed_count: 5,
});
// ★既定の粒度は facility★(施設4件ぶんの内訳)
expect(res.items.map((r) => r.scope)).toEqual(["facility", "facility", "facility", "facility"]);
expect(res.meta).toEqual({ total: 4, page: 1, per_page: 4 });
// ★期間未指定なら `started_at` の条件を付けない★(全期間)
expect(groupByCalls[0].where?.started_at).toBeUndefined();
});
it("#2 ★粒度を変えても全体合計が変わらない★(§9-2 の 9-2 / B-5)", async () => {
// ★3本を `Promise.all` で並べないこと★ — ★このテストの制約★:
// `lib/db/index.ts` は `await import("./prisma")` で Prisma を動的に読む。
// ★同じ瞬間に2本以上の動的 import が走ると、`vi.mock` の差し替えを1本がすり抜けて
// 本物のクライアントを掴み、「DATABASE_URL が無い」で 500 になる★(実測)。
// ★ルート側の `Promise.all`(施設名 × 分類の並列解決)は正しい★ので、
// ★そちらの経路は fixtures で確かめる★(`route.fixtures.test.ts`)。
const byFacility = await body("?group_by=facility");
const byCustomer = await body("?group_by=customer");
const byGroup = await body("?group_by=group");
expect(byCustomer.total).toEqual(byFacility.total);
expect(byGroup.total).toEqual(byFacility.total);
// 内訳の行数だけが変わる(施設4 / 屋号3 / ホールディングス2)
expect([byFacility.items.length, byCustomer.items.length, byGroup.items.length]).toEqual([
4, 3, 2,
]);
// ★内訳の和 = 全体合計★(どの粒度でも)
for (const res of [byFacility, byCustomer, byGroup]) {
const sum = res.items.reduce((acc, r) => acc + r.clean_area, 0);
expect(sum).toBeCloseTo(res.total.clean_area, 10);
}
});
it("#3 ★ホールディングスなしは「グループなし」1行★(『グループ0』を作らない。§9-3 の 9-9)", async () => {
const res = await body("?group_by=group");
const none = res.items.find((r) => r.id === null);
expect(none?.name).toBe(CLEAN_AREA_GROUP_NONE_LABEL);
expect(res.items.some((r) => r.id === 0)).toBe(false);
// ★その面積が全体合計から落ちないこと★(B-3)
expect(none?.clean_area).toBe(100);
});
it("#4 期間で絞ると合計が減る(§9-2 の 9-3)", async () => {
const res = await body("?date_from=2026-09-01&date_to=2026-09-01");
expect(res.total.clean_area).toBe(1600);
expect(res.total.executed_count).toBe(2);
// ★`to` はその日を含む★(翌日 00:00 未満)
expect(groupByCalls[0].where?.started_at?.gte).toEqual(new Date("2026-09-01T00:00:00Z"));
expect(groupByCalls[0].where?.started_at?.lt).toEqual(new Date("2026-09-02T00:00:00Z"));
});
it("#5 ★group_id=9999 は0件(全件に化けない)★(§9-2 の 9-4 / 落とし穴3)", 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% ではない。A-1 / 落とし穴7)
completion_rate: null,
executed_count: 0,
});
expect(res.meta).toEqual({ total: 0, page: 1, per_page: 0 });
// ★条件が消えていないこと★ — `in: []` が渡っている(`undefined` なら全件に化けている)
expect(groupByCalls[0].where?.facility_id).toEqual({ in: [] });
});
// ★施設名 × 分類の積集合(AND)は `route.fixtures.test.ts` が見る★ —
// ルートがその2つを `Promise.all` で並列に解決するため、
// ★このファイルの `vi.mock("@/lib/db/prisma")` では再現できない★(#2 の注記)。
it("#7 ★完了率を丸めない★(§9-2 の 9-5 / CLAUDE.md §4.3)", async () => {
const res = await body();
const f1 = res.items.find((r) => r.id === 1);
// ★期待値の式が変わった★(D-155。1回ごとの完了率 90.0 / 80.0 の単純平均。
// 旧: `clean_ratio` 列の平均 (88.1 + 70.05) / 2 → その前は面積比 1700.5/2000)
expect(f1?.completion_rate).toBe((90 + 80) / 2);
// ★期間の合計どうしの面積比(1700.5/2000 = 85.025)に戻っていないこと★(D-151。★戻っても例外は出ない★)
expect(f1?.completion_rate).not.toBe((1700.5 / 2000) * 100);
// ★`clean_ratio` 列を読んでいないこと★
expect(f1?.completion_rate).not.toBe((88.1 + 70.05) / 2);
expect(res.total.completion_rate).toBe((90 + 80 + 70 + 50 + 10) / 5);
});
it("#8 ★単位をレスポンスに入れない★(§9-2 の 9-6 / §2-4)", async () => {
const res = await get();
const text = await res.text();
expect(text).not.toContain(AREA_UNIT);
// ★異常のIDも返さない★(通知先はログであって画面ではない)
expect(text).not.toContain("unmatched_facility_ids");
});
it("#9 ★★番人を呼ぶ★★(1タスクにレポートが2行ある件数。§2-5 / 落とし穴2)", async () => {
// 同じ `clean_timetable_id` に2行目を足す(★SUM が同じ清掃を2回数える状態★)
data.reports.push(report(1, 1001, "2026-09-01", 900, 1000, 88.1));
await body();
expect(vi.mocked(logEvent)).toHaveBeenCalledWith(
"warn",
expect.objectContaining({
event: "data.anomaly",
route: "/api/clean-area",
method: "GET",
metric: CLEAN_AREA_ANOMALY_METRICS.duplicatedTimetables,
expected: 0,
actual: 1,
})
);
});
it("#9b ★★`clean_timetable_id` が NULL の行は番人が数えない★★(指示書AQ 罠1 / §5-3)", async () => {
// ★TeamWorks がまだ埋めていない★ NULL の行を2行足す(★SQL の GROUP BY は NULL 同士を
// 1グループにまとめる★ので、除外を落とすと `having > 1` に当たって毎回誤報する)
data.reports.push(report(1, null, "2026-09-11", 10, 20, 50));
data.reports.push(report(2, null, "2026-09-11", 30, 40, 50));
const res = await body();
// ★重複ではないので番人は黙っている★(#11 と同じく、`data.anomaly` は1行も出ない)
expect(anomalyEvents()).toEqual([]);
// ★番人のクエリに `IS NOT NULL` が付いている★
const sentinel = groupByCalls.find((c) => c.by[0] === "clean_timetable_id");
expect(sentinel?.where?.clean_timetable_id).toEqual({ not: null });
// ★★NULL の行も集計の母集団には入る★★(番人の除外を集計側に持ち込まない。
// `countDuplicatedTimetables()` の docstring)
const stats = groupByCalls.find((c) => c.by[0] === "facility_id");
expect(stats?.where?.clean_timetable_id).toBeUndefined();
expect(res.total.clean_area).toBe(3000.5 + 10 + 30);
// ★★実施回数には予定外(NULL)を入れない★★(D-155 項3 / 指示書AS D9。面積の合計には入る)
expect(res.total.executed_count).toBe(5);
});
it("#10 ★施設マスタに無い facility_id も番人が出す★(黙って消さない。B-4)", async () => {
data.reports.push(report(99, 1099, "2026-09-10", 50, 100, 50));
const res = await body();
// ★内訳にも全体合計にも入れない★(入れると「全体合計 ≠ 内訳の和」になる)
expect(res.items.some((r) => r.id === 99)).toBe(false);
expect(res.total.clean_area).toBe(3000.5);
expect(vi.mocked(logEvent)).toHaveBeenCalledWith(
"warn",
expect.objectContaining({
metric: CLEAN_AREA_ANOMALY_METRICS.unmatchedFacilities,
expected: 0,
actual: 1,
})
);
});
it("#11 異常が無ければ `data.anomaly` を1行も出さない", async () => {
await body();
expect(anomalyEvents()).toEqual([]);
});
it("#12 ★`judge.clean_area` に取り寄せた行数と所要時間を出す★(D-156 論点2)", async () => {
data.reports.push(report(1, null, "2026-09-11", 10, 20, 50));
await body();
const events = vi
.mocked(logEvent)
.mock.calls.filter(([, payload]) => payload.event === "judge.clean_area");
expect(events).toHaveLength(1);
const [level, payload] = events[0];
expect(level).toBe("info");
expect(payload).toEqual(
expect.objectContaining({
event: "judge.clean_area",
route: "/api/clean-area",
method: "GET",
// ★NULL の行は `where` で除くので取り寄せない★(5行)
rate_rows: 5,
total_executed_count: 5,
raw_total_completion_rate: (90 + 80 + 70 + 50 + 10) / 5,
})
);
expect(typeof (payload as { elapsed_ms: unknown }).elapsed_ms).toBe("number");
});
it("#13 ★完了率用の取り寄せは集計と同じ `where` に `clean_timetable_id: { not: null }` を足したもの★", async () => {
await body("?date_from=2026-09-01&date_to=2026-09-01");
const stats = groupByCalls.find((c) => c.by[0] === "facility_id");
expect(findManyCalls).toHaveLength(1);
expect(findManyCalls[0].where).toEqual({ ...stats?.where, clean_timetable_id: { not: null } });
// ★予定の引き直しは `precheck = CLEAN_TIMETABLE_PRECHECK.main`★
expect(timetableCalls.every((c) => c.where.precheck === CLEAN_TIMETABLE_PRECHECK.main)).toBe(true);
});
it("#14 ★事前チェック・dangling の予定に紐づく行は実施回数・完了率に入らない(面積には入る)★", async () => {
data.precheckTimetableIds.add("1001");
data.danglingTimetableIds.add("1002");
const res = await body();
const f1 = res.items.find((r) => r.id === 1);
expect(f1?.clean_area).toBe(900 + 800.5);
expect(f1?.executed_count).toBe(0);
expect(f1?.completion_rate).toBeNull();
expect(res.total.executed_count).toBe(3);
});
it("★履歴(clean_timetable_histories)だけにある予定に紐づく行も実施回数・完了率に入る★(指示書AU 段階3b-1 の 12)", async () => {
const baseline = await body();
data.historyOnlyTimetableIds = new Set(["1001", "1004"]);
// ★旧(本表だけ)なら 1001 / 1004 は「本清掃に紐づかない行」になり、完了率・実施回数から黙って落ちていた★
expect(await body()).toEqual(baseline);
// ★2表に同じ引数★
expect(historyTimetableCalls).toEqual(timetableCalls);
});
it("#12 group_by が enum 外なら 422(DBを読まない)", async () => {
const res = await get("?group_by=facilities");
expect(res.status).toBe(422);
const err = (await res.json()) as ApiError;
expect(err.error.code).toBe("validation_failed");
expect(err.error.details).toEqual({
group_by: ["group / customer / facility のいずれかを指定してください"],
});
expect(groupByCalls).toHaveLength(0);
});
it("#13 date_from の形式違いは 422", async () => {
const res = await get("?date_from=2026/09/01");
expect(res.status).toBe(422);
const err = (await res.json()) as ApiError;
expect(err.error.details).toEqual({
date_from: ["YYYY-MM-DD形式で指定してください"],
});
});
it("#14 date_from > date_to は 422(黙って0件にしない)", async () => {
const res = await get("?date_from=2026-09-10&date_to=2026-09-01");
expect(res.status).toBe(422);
expect(groupByCalls).toHaveLength(0);
});
it("#15 ★日数の上限は無い★(10年ぶんを指定しても200。§2-3)", async () => {
const res = await get("?date_from=2016-01-01&date_to=2026-09-16");
expect(res.status).toBe(200);
});
it("#16 group_id=0 は 422(0 はセンチネルで有効なIDではない。D-011)", async () => {
const res = await get("?group_id=0");
expect(res.status).toBe(422);
expect(groupByCalls).toHaveLength(0);
});
it("#17 DBが落ちたら 500(共通のエラー形式・固定文言)", async () => {
data = {
get facilities(): StubFacility[] {
throw new Error("db down");
},
reports: [],
precheckTimetableIds: new Set(),
danglingTimetableIds: new Set(),
};
const res = await get();
expect(res.status).toBe(500);
const err = (await res.json()) as ApiError;
expect(err.error.code).toBe("internal_error");
expect(err.error.message).toBe("データの取得に失敗しました");
});
});