import { describe, expect, it, vi } from "vitest";
// ★★ルート本体は**静的 import** すること★★ — `it()` の中で `await import()` すると、
// 最初の1本だけがモジュール読み込み(`next/server` / ロガー等)を丸ごと抱え込み、
// フルスイート実行時に既定のタイムアウトを踏んで不定期に落ちる
// (`app/api/clean-logs/__tests__/route.test.ts` の実測)。
// ★`vi.mock()` は import より先に巻き上げられる★ので、静的 import でもスタブは効く。
import { GET } from "@/app/api/clean-logs/export/route";
import { MAX_CSV_ROWS } from "@/lib/config";
import type { ApiError, CleanLogItem } from "@/lib/types";
/**
* CSVエクスポートの行数上限(ユーザー指示 2026-09-15 / 指示書AI 段階8)
*
* ★期待値表(指示書AI 10-5)★
*
* | # | 状況 | サーバ |
* |-----|------------------------|-------------------------|
* | 8-1 | 0件 | 200(ヘッダ行だけのCSV)|
* | 8-2 | 19,999件 | 200 |
* | 8-3 | ★ちょうど 20,000件★ | ★★200(「上限」は含む)★★ |
* | 8-4 | ★20,001件★ | ★★422★★ |
* | 8-5 | 1店舗・2年・1,300件 | 200(期間が長くても行数が少なければ出せる)|
*
* ★★8-3 と 8-4 の両方を必ず入れること★★ — 境界の片側だけだと、
* `>` と `>=` の取り違えが通ってしまう(`CLAUDE.md` §9.4)。
*
* ------------------------------------------------------------------------
* ★★DBには接続しません★★
* ------------------------------------------------------------------------
* `@/lib/db` の `db` をスタブに差し替える。★`resolveFacilityIdsByName()` も
* 同じ `db`(`facilityNameIndex()`)を通る★ので、ここ1箇所で足りる。
*
* ★行数だけが関心事なので、行の中身は使い回しの1件でよい★ —
* CSVの中身(列・エスケープ)は `lib/clean-logs/__tests__/csv.test.ts` の担当。
*/
/** `db.cleanLogs()` が返す行数。各テストが書き換える */
let rowCount = 0;
function row(id: number): CleanLogItem {
return {
id,
facility_id: 1,
facility_name: "メガバリューマート中央本店",
model_name: "PUDU CC1",
serial_number: "8111R5628060011",
scheduled_at: "2026-08-27T02:00:00+09:00",
status_1h: "待機中",
status_1h_minutes: 60,
status_30m: "待機中",
status_30m_minutes: 30,
departed: true,
// `clean_task_reports.status` の生値(CSV実測で最多の値。意味は第10回 N-2 で受領済み。指示書AQ §1-2)
task_status: 4,
// ★結果待ち(D-173)★ — レポートあり・確定の行
result_pending: false,
completion_rate: 90.1,
grade: "◎",
consecutive_problem_days: 0,
escalation_level: null,
};
}
vi.mock("@/lib/db", () => ({
db: {
cleanLogs: async () =>
Array.from({ length: rowCount }, (_unused, i) => row(i + 1)),
// 施設名の解決に使う(施設名を指定しないテストでは読まれない)
facilityNameIndex: async () => [
{ id: 1, facility_name: "メガバリューマート中央本店" },
],
},
}));
async function get(query = ""): Promise<Response> {
return GET(new Request(`http://localhost/api/clean-logs/export?${query}`));
}
describe("GET /clean-logs/export — 行数の上限", () => {
it("8-1: 0件は200(ヘッダ行だけのCSV)", async () => {
rowCount = 0;
const res = await get();
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("text/csv");
const body = await res.text();
// ヘッダ行だけ(BOM + 1行)。★0件を422にしないこと★
expect(body.trimEnd().split("\n")).toHaveLength(1);
});
it("8-2: 上限より1件少なければ200", async () => {
rowCount = MAX_CSV_ROWS - 1;
const res = await get();
expect(res.status).toBe(200);
});
it("8-3: ★ちょうど上限は200★(「上限」は含む)", async () => {
rowCount = MAX_CSV_ROWS;
const res = await get();
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("text/csv");
});
it("8-4: ★上限+1件は422★(500ではない)", async () => {
rowCount = MAX_CSV_ROWS + 1;
const res = await get();
expect(res.status).toBe(422);
const body = (await res.json()) as ApiError;
expect(body.error.code).toBe("validation_failed");
// ★details に実数を入れる★ — 「あと何件減らせばよいか」が文言を読まずに分かる
expect(body.error.details).toEqual({
total: MAX_CSV_ROWS + 1,
max: MAX_CSV_ROWS,
});
// 件数は文言にも出す(画面に出さずAPIを直接叩いた人のため)
expect(body.error.message).toContain(String(MAX_CSV_ROWS + 1));
expect(body.error.message).toContain(String(MAX_CSV_ROWS));
});
it("8-5: 期間が2年でも行数が少なければ200(★上限は日数ではなく行数★)", async () => {
rowCount = 1_300;
const res = await get(
"date_from=2026-01-01&date_to=2028-01-01&facility_name=メガバリューマート中央本店"
);
expect(res.status).toBe(200);
});
it("日付の範囲が上限(2年)を超えていれば、行数を数える前に422", async () => {
rowCount = 0;
const res = await get("date_from=2026-01-01&date_to=2028-01-02");
expect(res.status).toBe(422);
const body = (await res.json()) as ApiError;
expect(body.error.code).toBe("validation_failed");
});
});