import { beforeEach, describe, expect, it, vi } from "vitest";
import { db } from "@/lib/db";
import { dsDevices as fixtureDsDevices } from "@/lib/fixtures/ds-devices";
import { logEvent } from "@/lib/logger";
/**
* 指示書AH 段階1 — ★`dsDevices()` が `control_reports` を全件読むのをやめた★ことの固定。
*
* ------------------------------------------------------------------------
* ★★ローカルDBでは絶対に検出できません★★
* ------------------------------------------------------------------------
* ローカルの `control_reports` は ★実測14行★。以前の実装(`include` + `take: 1`)でも
* 何も起きません。★★`take: 1` は1文字もSQLに出ず、Prisma が制御盤ぶんの行を
* 全部取り寄せてからアプリ側で1件に切っているだけ★★(実測)なので、
* ★行数が増えた日(想定1,100万行)に初めて `JavaScript heap out of memory` で落ちます★
* (実測: 100万行で 2.7GB / ★300万行で即死★)。
*
* ★だから「結果」ではなく「発行したクエリ」を固定します★(#2)。
* ★結果だけを見るテストは、`include` に戻しても緑のままです★。
*
* ------------------------------------------------------------------------
* ★★DBには接続しません★★
* ------------------------------------------------------------------------
* `@/lib/db/prisma` の `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に
* 固定して ★`prismaSource` の経路★ を走らせます
* (`app/api/clean-logs/__tests__/route.test.ts` と同じ作法)。
*/
// ---------------------------------------------------------------- スタブ(DBの代わり)
/** `controls` の1行(★`include: { facility: true }` 済みの形で持つ★) */
interface StubControl {
id: bigint;
serial_number: string;
facility_id: bigint;
status: number;
deleted_at: Date | null;
facility: { facility_name: string };
}
/** `control_reports` の1行(★このテストが使う列だけ★) */
interface StubReport {
control_id: bigint;
reported_at: Date;
}
interface StubData {
controls: StubControl[];
reports: StubReport[];
}
/** 発行されたクエリの記録(★`controlReport.findMany` が0回であることを見る★) */
const calls: {
controlFindMany: Record<string, unknown>[];
controlReportGroupBy: Record<string, unknown>[];
controlReportFindMany: Record<string, unknown>[];
} = { controlFindMany: [], controlReportGroupBy: [], controlReportFindMany: [] };
let data: StubData;
vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
// ★心拍0件・状態が 0/1 以外の `data.anomaly`(指示書BB 表4 D3・D4)を見るため `logEvent` だけ差し替える★
vi.mock("@/lib/logger", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logger")>();
return { ...actual, logEvent: vi.fn() };
});
vi.mock("@/lib/db/prisma", () => ({
getPrisma: async () => ({
control: {
findMany: async (args?: Record<string, unknown>) => {
calls.controlFindMany.push(args ?? {});
const where = (args?.where ?? {}) as { deleted_at?: Date | null };
// ★`deleted_at: null` の絞り込みを**わざと**実装している★ —
// 条件を落としたら #4 が赤くなるように
const rows =
"deleted_at" in where && where.deleted_at === null
? data.controls.filter((c) => c.deleted_at === null)
: data.controls;
return rows.map((c) => ({ ...c }));
},
},
controlReport: {
// ★呼ばれてはいけない経路★(呼ばれたら #2 が赤くなる)
findMany: async (args?: Record<string, unknown>) => {
calls.controlReportFindMany.push(args ?? {});
return data.reports.map((r) => ({ ...r }));
},
groupBy: async (args?: Record<string, unknown>) => {
calls.controlReportGroupBy.push(args ?? {});
const where = (args?.where ?? {}) as {
control_id?: { in?: readonly bigint[] };
};
const ids = where.control_id?.in;
if (ids === undefined) {
throw new Error(
"[stub] where.control_id.in が undefined です。★罠1★: Prisma では条件が消えて全件になります"
);
}
const wanted = new Set(ids.map((id) => String(id)));
const maxByControl = new Map<string, { control_id: bigint; reported_at: Date }>();
for (const row of data.reports) {
const key = String(row.control_id);
if (!wanted.has(key)) continue;
const current = maxByControl.get(key);
if (!current || current.reported_at.getTime() < row.reported_at.getTime()) {
maxByControl.set(key, { control_id: row.control_id, reported_at: row.reported_at });
}
}
// ★Prisma と同じく `control_id` は `BigInt` のまま返す★ —
// ★`String()` でキーを揃えるのをやめたら(罠1)#1 が例外で落ちる★
return [...maxByControl.values()].map((g) => ({
control_id: g.control_id,
_max: { reported_at: g.reported_at },
}));
},
},
}),
}));
// ---------------------------------------------------------------- 合成データ
/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115。他のテストと同じヘルパー) */
function dbWallClock(jstIso: string): Date {
return new Date(jstIso.replace("+09:00", "Z"));
}
/** ★実在の店名を使わない★(`CLAUDE.md` §7 / `docs/fixtures.md`) */
function control(
id: number,
serial: string,
facilityId: number,
facilityName: string,
status: number,
deletedAt: Date | null = null
): StubControl {
return {
id: BigInt(id),
serial_number: serial,
facility_id: BigInt(facilityId),
status,
deleted_at: deletedAt,
facility: { facility_name: facilityName },
};
}
function report(controlId: number, jstIso: string): StubReport {
return { control_id: BigInt(controlId), reported_at: dbWallClock(jstIso) };
}
function buildData(): StubData {
return {
controls: [
control(1, "DS-001", 1, "メガバリューマート中央本店", 0),
control(2, "DS-002", 3, "メガバリューマート東口店", 1),
// ★論理削除済み★(#4)
control(
3,
"DS-003",
4,
"バリューマート西通り店",
0,
dbWallClock("2026-09-01T00:00:00+09:00")
),
],
reports: [
// ★制御盤1は3件。★最新は 09:05★(古い行に引きずられないこと)
report(1, "2026-09-14T03:42:00+09:00"),
report(1, "2026-09-14T09:05:00+09:00"),
report(1, "2026-09-13T22:10:00+09:00"),
report(2, "2026-09-14T08:58:00+09:00"),
// ★論理削除された制御盤の心拍★(一覧から消えるので使われない)
report(3, "2026-09-14T09:00:00+09:00"),
],
};
}
/** `data.anomaly` のうち、指定の metric のものだけ */
function anomalies(metric: string) {
return vi
.mocked(logEvent)
.mock.calls.filter(([, p]) => p.event === "data.anomaly" && p.metric === metric)
.map(([, p]) => p);
}
beforeEach(() => {
vi.mocked(logEvent).mockClear();
data = buildData();
calls.controlFindMany = [];
calls.controlReportGroupBy = [];
calls.controlReportFindMany = [];
});
describe("prismaSource.dsDevices(指示書AH 段階1 / D-1〜D-3)", () => {
it("#1 通常 — ★制御盤ごとに最新の `reported_at` を返す★(従来と同じ `DsDevice[]`)", async () => {
const rows = await db.dsDevices();
expect(rows).toEqual([
{
serial_number: "DS-001",
facility_id: 1,
facility_name: "メガバリューマート中央本店",
connection_status: "ok",
// ★3件のうち最大★(古い2件に引きずられない)
last_reported_at: "2026-09-14T09:05:00+09:00",
note: null,
},
{
serial_number: "DS-002",
facility_id: 3,
facility_name: "メガバリューマート東口店",
// ★controls.status=1 → error(D-065 Q1-2)★
connection_status: "error",
last_reported_at: "2026-09-14T08:58:00+09:00",
note: null,
},
]);
// ★`facility_id` は `number`★(`BigInt` を残すと `NextResponse.json` が例外を投げる)
expect(typeof rows[0].facility_id).toBe("number");
});
it("#2 ★発行クエリの検査★ — `controlReport.findMany` が0回 / `groupBy` が1回", async () => {
await db.dsDevices();
// ★★これが本作業の核心★★ — 行を1行も運ばないこと
expect(calls.controlReportFindMany).toHaveLength(0);
expect(calls.controlReportGroupBy).toHaveLength(1);
expect(calls.controlFindMany).toHaveLength(1);
// ★`control_reports` の入れ子 include に戻っていないこと★
// (戻すと `take: 1` がSQLに降りず、制御盤ぶんの行を全部読む)
const include = calls.controlFindMany[0].include as Record<string, unknown>;
expect(Object.keys(include)).toEqual(["facility"]);
const groupBy = calls.controlReportGroupBy[0];
expect(groupBy.by).toEqual(["control_id"]);
// ★★`MAX(id)` に変えないこと(★罠3★ / D-3)★★ — `id` の大小順と
// `reported_at` の大小順が一致する保証が無い(TeamWorks の書き込み順は未確認)
expect(groupBy._max).toEqual({ reported_at: true });
// ★★時間の足切りを足さないこと(★罠2★ / D-2)★★ —
// `reported_at` に索引が無く走査は減らないうえ、
// ★足切りの外にしか心拍が無い制御盤の最終通信が空欄になる★
expect(Object.keys(groupBy.where as object)).toEqual(["control_id"]);
// ★★`take` を足さないこと(★罠8★)★★ — SQLに降りない
expect(groupBy.take).toBeUndefined();
// ★論理削除された制御盤は `in` にも入らない★
const ids = (groupBy.where as { control_id: { in: bigint[] } }).control_id.in;
expect(ids.map(String)).toEqual(["1", "2"]);
});
it("#3 ★制御盤に心拍が0件★ — 例外にせず `last_reported_at: null` で出し、ログを残す(指示書BB 表4 D3。旧: 例外)", async () => {
// 制御盤2の心拍だけを消す(★制御盤1には残す=他の制御盤は今どおり出る★)
data.reports = data.reports.filter((r) => String(r.control_id) !== "2");
const rows = await db.dsDevices();
expect(rows.map((r) => [r.serial_number, r.last_reported_at])).toEqual([
["DS-001", "2026-09-14T09:05:00+09:00"],
["DS-002", null],
]);
expect(anomalies("control_reports_missing")).toEqual([
{ event: "data.anomaly", metric: "control_reports_missing", expected: 1, actual: 0, control_id: "2" },
]);
});
it("#3b 全台に心拍がある → `control_reports_missing` を出さない", async () => {
await db.dsDevices();
expect(anomalies("control_reports_missing")).toHaveLength(0);
expect(anomalies("controls_status_unknown")).toHaveLength(0);
});
it("#3c ★`controls.status` が 2(0/1 以外)★ — 一覧に出さず、ログを残す(指示書BB 表4 D4)", async () => {
data.controls.push(control(9, "DS-009", 5, "バリューマート北口店", 2));
data.reports.push(report(9, "2026-09-14T08:00:00+09:00"));
const rows = await db.dsDevices();
expect(rows.map((r) => r.serial_number)).toEqual(["DS-001", "DS-002"]);
expect(anomalies("controls_status_unknown")).toEqual([
{ event: "data.anomaly", metric: "controls_status_unknown", expected: "0/1", actual: 2, control_id: "9" },
]);
});
it("#3d D3(心拍なし)と D4(状態 2)が1台ずつ混ざる → 例外にならず、他の制御盤は今どおり出る(指示書BB 表4 D5)", async () => {
data.controls.push(control(9, "DS-009", 5, "バリューマート北口店", 2));
data.controls.push(control(10, "DS-010", 6, "バリューマート南口店", 0));
const rows = await db.dsDevices();
expect(rows.map((r) => [r.serial_number, r.connection_status, r.last_reported_at])).toEqual([
["DS-001", "ok", "2026-09-14T09:05:00+09:00"],
["DS-002", "error", "2026-09-14T08:58:00+09:00"],
["DS-010", "ok", null],
]);
});
it("#4 ★論理削除された制御盤は一覧に出ない★(従来どおり。`deleted_at` を落とさない)", async () => {
const rows = await db.dsDevices();
expect(rows.map((r) => r.serial_number)).toEqual(["DS-001", "DS-002"]);
// ★`inactive` では絞らない★(無効化は削除ではない。指示書P 禁止事項10 / D-086)ので
// `where` のキーは `deleted_at` だけ
expect(calls.controlFindMany[0].where).toEqual({ deleted_at: null });
});
it("#5 `USE_FIXTURES` true/false で ★同じ形の `DsDevice`★ を返す(禁止事項6)", async () => {
// ★fixtures 版(`fixtureSource.dsDevices()`)は `lib/fixtures/ds-devices.ts` を
// そのまま返す★ので、その1件を Prisma 経路の合成データで再現して突き合わせる。
const fixture = fixtureDsDevices.find((d) => d.serial_number === "DS-001");
if (!fixture) throw new Error("fixtures に DS-001 がありません");
data = {
controls: [
control(
1,
fixture.serial_number,
fixture.facility_id,
fixture.facility_name,
fixture.connection_status === "ok" ? 0 : 1
),
],
reports: [report(1, fixture.last_reported_at)],
};
const rows = await db.dsDevices();
expect(rows).toHaveLength(1);
// ★キーの集合が一致すること★(片方にだけフィールドがある状態を作らない)
expect(Object.keys(rows[0]).sort()).toEqual(Object.keys(fixture).sort());
// ★`note` 以外は一致すること★ —
// ★Prisma 経路の `note` は常に `null`★(DBに対応カラムが無い。D-065 Q1-1)で、
// 実値は `app/api/ds-devices/route.ts` がJSONストアからマージする。
// ★これは実装漏れではなく意図した非対称★。
expect({ ...rows[0], note: null }).toEqual({ ...fixture, note: null });
});
});
describe("prismaSource.dsDeviceExists — 一覧と同じ規則(指示書BB 表4 D4)", () => {
it("`controls.status` が 0 / 1 の制御盤は true", async () => {
expect(await db.dsDeviceExists("DS-001")).toBe(true);
expect(await db.dsDeviceExists("DS-002")).toBe(true);
});
it("★`controls.status` が 2 の制御盤は false★(一覧に出ないので備考も404)", async () => {
data.controls.push(control(9, "DS-009", 5, "バリューマート北口店", 2));
expect(await db.dsDeviceExists("DS-009")).toBe(false);
// ★状態を読むために `status` を select している★
expect(calls.controlFindMany.at(-1)?.select).toEqual({ serial_number: true, status: true });
});
});