import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts` 冒頭の
// 理由と同じ。`it()` の中で `await import()` するとフルスイートでタイムアウトを踏む)
import { GET } from "@/app/api/dashboard/route";
import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import type { DashboardResponse } from "@/lib/types";
/**
* GET /dashboard が ★その1日ぶんだけ★ を引いていることを固定する(指示書AC 段階2 D-6)。
* ★`date` 省略(=直近モード。D-159 / 指示書AV)では、表3 の窓(8日ぶん)に絞られていること★と、
* 直近モードの `summary` / `problem_logs` / メタの `where` も固定する(ファイル末尾の `describe`)。
*
* ------------------------------------------------------------------------
* ★★何を守るためのテストか★★
* ------------------------------------------------------------------------
* ★`?date=`(★値が空★)のときに ★全件読みへ戻らない★ こと★。
* `const date = dateRaw ?? todayJst()` と書くと ★`""` は null でも undefined でもない★ ため
* ★空文字がそのまま残り★、`db.cleanLogs({ date_from: "", date_to: "" })` が
* `dbDateRange("", "")` を通って ★`start_at` の条件ごと消え=テーブル全件読み★ になります
* (本番想定で `clean_timetables` 約26万行)。
* ★★レスポンスは後段の日付一致(`logsOfDay`)で空になるので画面からは観測できません★★ —
* ★このテストが唯一の検出手段★です。
*
* ------------------------------------------------------------------------
* ★★DBには接続しません★★
* ------------------------------------------------------------------------
* `@/lib/db/use-fixtures` の `USE_FIXTURES` を ★`false`★ に固定して
* ★`prismaSource` の経路★ を走らせる(段階2 / 段階4 のルートテストと同じ作法)。
*
* ★★ただしスタブの入れ方だけが兄弟テストと違います★★ —
* 兄弟(`/clean-logs` / `/prechecks`)は `vi.mock("@/lib/db/prisma")` で `getPrisma()` を
* 差し替えていますが、★このルートは3つのデータ源を `Promise.all` で**同時に**引きます★。
* `prismaSource` の各メソッドは `await import("./prisma")` の★遅延読み込み★なので
* (`lib/db/prisma.ts` 冒頭の理由)、★★同じモジュールへの動的 import が同時に走ると
* 一部が差し替え前の実体を掴み★★、`DATABASE_URL` 未設定で 500 になります(実測)。
* そこで ★`getPrisma()` が最初に見るシングルトン `globalThis.__dwatchPrisma` に
* 直接スタブを置きます★ — ★実装(`lib/db/prisma.ts`)の公開された仕組みをそのまま使うので
* DBにも接続しません★。★`afterEach` で必ず片付けること★。
*/
// ---------------------------------------------------------------- スタブ(DBの代わり)
interface StubTimetable {
id: bigint;
facility_id: bigint;
section_id: bigint;
parent_id: bigint;
start_at: Date;
/** ★予定の終了★(直近モードの `cleanLogRunMeta()` が読む。指示書AV 段階4-5。開始+1時間) */
end_at: Date;
precheck: number;
facility: { facility_name: string };
section: {
robots: {
serial_number: string;
device: { maker_id: number; model_name: string };
}[];
};
}
/**
* ★清掃結果の取得元は `clean_task_reports`★(指示書AQ 決定2/3。旧 `clean_status_histories`)。
* ★`clean_error` 列は無い★ — 未実施は「行が無い」で表す(指示書AQ §1-1)。
* `clean_timetable_id` / `ended_at` は nullable(指示書AQ 罠1 / 罠3)。
*/
interface StubReport {
clean_timetable_id: bigint | null;
clean_ratio: number;
status: number;
started_at: Date;
ended_at: Date | null;
clean_area: number;
task_area: number;
}
interface StubData {
timetables: StubTimetable[];
reports: StubReport[];
}
/** `findMany()` に渡された引数の記録(★どんな `where` が発行されたかを検査する★) */
const calls: {
cleanTimetable: Record<string, unknown>[];
cleanTimetableHistory: Record<string, unknown>[];
cleanTaskReport: Record<string, unknown>[];
} = { cleanTimetable: [], cleanTimetableHistory: [], cleanTaskReport: [] };
let data: StubData;
/** BigInt / number / null をまたいで id を比較する(`===` では比較できない) */
function sameId(a: unknown, b: unknown): boolean {
if (a === null || a === undefined || b === null || b === undefined) return false;
return String(a) === String(b);
}
/**
* Prisma の `where` のうち、★このテストが使う分だけ★ を再現する。
*
* ★★`in: undefined` は Prisma では「条件が消えて全件」になる★★(★罠1★)。
* ★ここでは例外にして、書き間違いが静かに通り抜けないようにする★。
*/
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>)) {
const value = row[field];
if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) {
const c = cond as Record<string, unknown>;
if ("in" in c) {
if (c.in === undefined) {
throw new Error(
`[stub] where.${field}.in が undefined です。★罠1★: Prisma では条件が消えて全件になります`
);
}
if (!(c.in as unknown[]).some((x) => sameId(x, value))) return false;
}
if (c.gte instanceof Date && (value as Date).getTime() < c.gte.getTime()) return false;
if (c.lt instanceof Date && (value as Date).getTime() >= c.lt.getTime()) return false;
} else if (typeof cond === "bigint" || typeof value === "bigint") {
if (!sameId(value, cond)) return false;
} else if (value !== cond) {
return false;
}
}
return true;
}
function project(row: Record<string, unknown>, select: unknown): Record<string, unknown> {
if (!select) return row;
const out: Record<string, unknown> = {};
for (const key of Object.keys(select as Record<string, unknown>)) out[key] = row[key];
return out;
}
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))
.map((row) => project(row, args?.select));
}
vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
/** `getPrisma()` が最初に見るシングルトンへ差し込むスタブ(上の「スタブの入れ方」を参照) */
function prismaStub(): typeof globalThis.__dwatchPrisma {
return {
cleanTimetable: {
findMany: async (args?: Record<string, unknown>) =>
findMany("cleanTimetable", data.timetables as unknown as Record<string, unknown>[], args),
},
// ★`clean_timetable_histories`(指示書AU 段階3b)。★このテストでは空★(2表に同じ引数が渡ることだけ見る)
cleanTimetableHistory: {
findMany: async (args?: Record<string, unknown>) => findMany("cleanTimetableHistory", [], args),
},
cleanTaskReport: {
findMany: async (args?: Record<string, unknown>) =>
findMany("cleanTaskReport", data.reports as unknown as Record<string, unknown>[], args),
},
// ★要レスキュー(指示書AI 段階5)が施設名の解決に使う★
// (`loadFacilityNameIndex()`。★`where` を付けない=全件★)
facility: {
findMany: async () => [
{ id: BigInt(1), facility_name: FACILITY_1 },
{ id: BigInt(2), facility_name: FACILITY_2 },
],
},
// ★このテストで中身は要らない(`db.cleanLogs()` の `where` だけを見る)★
robotErrorReport: { findMany: async () => [] },
cleanSchedule: { findMany: async () => [] },
controlErrorHistory: { findMany: async () => [] },
} as unknown as typeof globalThis.__dwatchPrisma;
}
// ---------------------------------------------------------------- 合成データ
/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115。他のテストと同じヘルパー) */
function dbWallClock(jstIso: string): Date {
return new Date(jstIso.replace("+09:00", "Z"));
}
/** ★実在の店名を使わない★(`CLAUDE.md` §7 / `docs/fixtures.md`) */
const FACILITY_1 = "バリューマート北町店";
/** ★対象日に清掃予定が無い施設★(要レスキューの検証用。指示書AI §1-5) */
const FACILITY_2 = "メガバリューマート緑野店";
function timetable(id: number, facilityId: number, jstIso: string): StubTimetable {
return {
id: BigInt(id),
facility_id: BigInt(facilityId),
section_id: BigInt(facilityId * 10),
parent_id: BigInt(0),
start_at: dbWallClock(jstIso),
// ★開始+1時間★(壁時計値のまま足す。直近モードの「終わった」の判定に使う。指示書AV 段階4-5)
end_at: new Date(dbWallClock(jstIso).getTime() + 60 * 60_000),
precheck: CLEAN_TIMETABLE_PRECHECK.main,
facility: { facility_name: FACILITY_1 },
section: {
robots: [
{
serial_number: `8111R56280600${facilityId}`,
device: { maker_id: 1, model_name: "CC1" },
},
],
},
};
}
function report(timetableId: number, cleanRatio: number, jstIso: string): StubReport {
return {
clean_timetable_id: BigInt(timetableId),
clean_ratio: cleanRatio,
// ★意味を持たない任意の生値★(CSV実測で最頻の値。status は判定に使わない。D-155 項2)。
// ★未実施の判定には使われない★(行の有無で決まる。§1-1)
status: 4,
started_at: dbWallClock(jstIso),
ended_at: dbWallClock(jstIso),
clean_area: 100,
task_area: 100,
};
}
function buildData(): StubData {
return {
timetables: [
timetable(101, 1, "2026-09-28T02:00:00+09:00"),
timetable(102, 1, "2026-09-29T02:00:00+09:00"),
// ★`NOW` と同じ日(`todayJst()` が返す日)★
timetable(103, 1, "2026-09-30T02:00:00+09:00"),
// ★★施設2は 09-27〜09-29 が3日連続で未実施、当日(09-30)には予定が無い★★
// (レポートの行を作らない=未実施。`notExecutedFromReport()`。指示書AQ §1-1)—
// ★要レスキューが「当該日のログ」で母集団を絞っていないことの検証用★(指示書AI §1-5)
timetable(201, 2, "2026-09-27T02:00:00+09:00"),
timetable(202, 2, "2026-09-28T02:00:00+09:00"),
timetable(203, 2, "2026-09-29T02:00:00+09:00"),
],
reports: [
report(101, 95, "2026-09-28T03:00:00+09:00"),
report(102, 95, "2026-09-29T03:00:00+09:00"),
report(103, 95, "2026-09-30T03:00:00+09:00"),
],
};
}
/** ★JST の 2026-09-30★(`todayJst()` がこの日を返す) */
const NOW = new Date("2026-09-30T09:00:00+09:00");
const TODAY = "2026-09-30";
async function get(search: string): Promise<DashboardResponse> {
const res = await GET(new Request(`http://localhost/api/dashboard${search}`));
return (await res.json()) as DashboardResponse;
}
/** 最後に発行された `clean_timetables` の本体クエリ(= `include` 付きの1本目) */
function mainQueryWhere(): Record<string, unknown> {
const call = calls.cleanTimetable.find((c) => "include" in c);
expect(call).toBeDefined();
return (call as { where: Record<string, unknown> }).where;
}
/** ★その1日ぶんに絞られているか★ を「壁時計値の範囲」で確かめる */
function expectSingleDayRange(where: Record<string, unknown>, date: string): void {
expectDayRange(where, date, date);
}
/** ★`from`〜`to`(両端を含む)に絞られているか★ を「壁時計値の範囲」で確かめる */
function expectDayRange(where: Record<string, unknown>, from: string, to: string): void {
expect(where).toHaveProperty("start_at");
const startAt = where.start_at as { gte: Date; lt: Date };
// ★★`toDbJst()` を通した壁時計値であること★★(C-6。★生の `parseDate()` ではない★)
expect(startAt.gte.toISOString()).toBe(`${from}T00:00:00.000Z`);
expect(startAt.lt.toISOString()).toBe(
new Date(Date.parse(`${to}T00:00:00.000Z`) + 86_400_000).toISOString()
);
}
/** ★直近モードの窓(指示書AV 表3 W4。`NOW` = 2026-09-30 09:00 JST)★ */
const LATEST_WINDOW = { from: "2026-09-23", to: TODAY } as const;
/**
* ★メタ(`cleanLogRunMeta()`)の呼び出し★ = `select` のキーがちょうど `id` / `section_id` / `end_at` の3つ。
* ★「`select` 付き」だけでは見分けられない★ — 要レスキュー(`loadConsecutiveInputs()` など)も
* `cleanTimetable.findMany` を `select` 付きで呼ぶ(指示書AV 段階4-5)。
*/
function metaCalls(): Record<string, unknown>[] {
return calls.cleanTimetable.filter((c) => {
const select = c.select as Record<string, unknown> | undefined;
return select !== undefined && Object.keys(select).sort().join(",") === "end_at,id,section_id";
});
}
beforeEach(() => {
data = buildData();
calls.cleanTimetable = [];
calls.cleanTimetableHistory = [];
calls.cleanTaskReport = [];
globalThis.__dwatchPrisma = prismaStub();
// ★`new Date()` を固定する(Date だけを差し替え、タイマーは触らない)★
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
});
afterEach(() => {
// ★★他のテストへ漏らさないこと★★(`getPrisma()` はここを最初に見る)
globalThis.__dwatchPrisma = undefined;
vi.useRealTimers();
});
// ---------------------------------------------------------------- テスト
describe("GET /dashboard — その1日ぶんだけを引く(指示書AC 段階2 D-6)", () => {
it("`date` を指定すると SQL の `where` にその1日の `start_at` が降りる", async () => {
await get("?date=2026-09-29");
expectSingleDayRange(mainQueryWhere(), "2026-09-29");
});
it("★予定は2表から読み、履歴にも同じ引数が渡る★(指示書AU 段階3b。第11回 P-7)", async () => {
await get("?date=2026-09-29");
const mainCall = calls.cleanTimetable.find((c) => "include" in c);
expect(calls.cleanTimetableHistory.find((c) => "include" in c)).toEqual(mainCall);
});
it("直近モード(`date` 省略)は表3 W4 の窓(2026-09-23〜2026-09-30)で `start_at` が絞られる(指示書AV)", async () => {
await get("");
expectDayRange(mainQueryWhere(), LATEST_WINDOW.from, LATEST_WINDOW.to);
});
it("★★`?date=`(★空文字★)でも直近モードの窓に倒れ、全件読みに戻らない★★", async () => {
// ★`??` で書くと `""` がそのまま残り、`start_at` のキーごと消えて
// ★テーブル全件読み★ になる(本番想定で約26万行)。
// ★★レスポンスからは観測しにくい★★ので、★`start_at` の条件が消えないこと★をここで見る
const body = await get("?date=");
expectDayRange(mainQueryWhere(), LATEST_WINDOW.from, LATEST_WINDOW.to);
// ★当日の行(103)が拾えていること=空振りしていないこと★
const { success, failed, not_executed } = body.summary;
expect(success + failed + not_executed).toBeGreaterThan(0);
});
it("★`?date=` と `date` 省略が同じ結果になる★", async () => {
const omitted = await get("");
const empty = await get("?date=");
expect(empty).toEqual(omitted);
});
it("形式が不正な `date` は 422 で弾かれ、`db` まで来ない", async () => {
const res = await GET(new Request("http://localhost/api/dashboard?date=2026/09/30"));
expect(res.status).toBe(422);
expect(calls.cleanTimetable).toHaveLength(0);
expect(calls.cleanTimetableHistory).toHaveLength(0);
});
});
describe("GET /dashboard — 要レスキュー店舗(指示書AI 段階5)", () => {
it("★当該日に清掃予定が無い施設も出る(評価日は直前のスケジュール存在日)★", async () => {
const body = await get(`?date=${TODAY}`);
// ★施設2は 09-30 に予定が無い★ — 当該日のログで母集団を絞っていたら消える
expect(body.rescue_required).toHaveLength(1);
expect(body.rescue_required[0]).toMatchObject({
facility_id: 2,
facility_name: FACILITY_2,
evaluated_on: "2026-09-29",
reasons: ["not_executed_streak", "escalation_l3"],
not_executed_days: 3,
consecutive_problem_days: 3,
});
});
it("★数値カード(summary)は要レスキューの影響を受けない★", async () => {
const body = await get(`?date=${TODAY}`);
// ★当日のログは施設1の1行だけ★(施設2の未実施3日は分母に入らない。ユーザー指示 2026-09-15)
const { success, failed, not_executed } = body.summary;
expect([success, failed, not_executed]).toEqual([1, 0, 0]);
});
it("要レスキューが0件でも `rescue_required` のキーは落とさない", async () => {
// ★施設2の3日連続が始まる前の日を指定する★(=どの施設も条件に当たらない)
const body = await get("?date=2026-09-28");
expect(body.rescue_required).toEqual([]);
});
});
describe("GET /dashboard — 直近モード(D-159 / 指示書AV 段階4-5)", () => {
// ★期待値は指示書AV で導いた値(§11 Q10 で監督が確定)★:
// 施設1(エリア10)は 09-30 02:00〜03:00 が一番新しい終わった予定 = 成功(完了率100)。
// 施設2(エリア20)は 09-29 02:00〜03:00 = レポートが無い = 未実施。
it("summary は各清掃エリアの直近の清掃(成功1・未実施1)", async () => {
const body = await get("");
expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 0, success_rate: 50 });
});
it("同じデータで `?date=2026-09-30` はその日の行だけ([1, 0, 0] のまま)", async () => {
const body = await get(`?date=${TODAY}`);
const { success, failed, not_executed } = body.summary;
expect([success, failed, not_executed]).toEqual([1, 0, 0]);
});
it("problem_logs は直近モードで施設2の 09-29 の1行だけ。`?date=2026-09-30` では []", async () => {
const latest = await get("");
expect(latest.problem_logs.map((r) => r.id)).toEqual([203]);
expect(latest.problem_logs[0]).toMatchObject({
facility_id: 2,
scheduled_at: "2026-09-29T02:00:00+09:00",
departed: false,
// ★`capCleanLogs()` を通っている★(指示書AV D13)
consecutive_problem_days_capped: false,
});
const ofDay = await get(`?date=${TODAY}`);
expect(ofDay.problem_logs).toEqual([]);
});
it("★メタの `where` は清掃ログ(`include` 付きの1本目)の `where` と同じ★(4-8)", async () => {
await get("");
const meta = metaCalls();
expect(meta).toHaveLength(1);
expect(meta[0].where).toEqual(mainQueryWhere());
});
it("★`?date=2026-09-29` ではメタを読まない★(0回)", async () => {
await get("?date=2026-09-29");
expect(metaCalls()).toHaveLength(0);
});
it("★schedule_progress は直近モードと `?date=2026-09-30` で同じ★(当日のまま。4-1)", async () => {
const latest = await get("");
const ofDay = await get(`?date=${TODAY}`);
expect(latest.schedule_progress).toEqual(ofDay.schedule_progress);
});
});
/**
* ★結果待ち(D-173。ユーザー判断 2026-10-07)★ — 表2 C6・C7(監督確定)。
* `NOW` = 09-30 09:00。★施設3の 09-30 07:30〜08:30 にレポートが無い★ —
* ★終了+`RESULT_WAIT_MINUTES`(120分)= 10:30 より前なので結果待ち★(`isResultPending()` 表1 P5 と同じ形)。
* ★終了(08:30)≤ 現在(09:00)なので直近の候補になる★(C6。30分待ちはやめた)。
* ★`result_pending` はデータソース(`prismaSource.cleanLogs()`)が詰める★ — ここでは数え方と `problem_logs` だけを見る。
*/
describe("GET /dashboard — 結果待ち(表2 C6・C7。D-173)", () => {
beforeEach(() => {
data.timetables.push(timetable(301, 3, "2026-09-30T07:30:00+09:00"));
});
it("C6 直近モード: 施設3の結果待ちは pending に数え、未実施・成功率の分母に入れない", async () => {
const body = await get("");
expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 1, pending: 1, success_rate: 50 });
});
it("C7 problem_logs に結果待ちの行を出さない(確定した出発失敗 203 は今までどおり出す)", async () => {
const body = await get("");
expect(body.problem_logs.map((r) => r.id)).toEqual([203]);
});
it("C5・C7 `?date=2026-09-30` でも同じ(pending 1・problem_logs は空)", async () => {
const body = await get(`?date=${TODAY}`);
expect(body.summary).toEqual({ success: 1, failed: 0, not_executed: 0, pending: 1, success_rate: 100 });
expect(body.problem_logs).toEqual([]);
});
});