Newer
Older
client_dwatch / lib / db / __tests__ / timetable-source.test.ts
/**
 * 予定を `clean_timetables` と `clean_timetable_histories` の両方から読む(指示書AU 段階3b-6。
 * TeamWorks 第11回 P-7=C。D-158)。
 *
 * ★本書 3b-6 の表 T1〜T8 が正★(実装より先に書いた)。
 *
 * | # | 入力 | 期待 |
 * |---|---|---|
 * | T1 | 本表だけに id=1 | id=1 が1件 |
 * | T2 | 履歴だけに id=2 | id=2 が1件(影チェックで本表に無い) |
 * | T3 | 両方に id=3(同じ中身) | 1件。中身は本表のもの |
 * | T4 | 両方に id=4(`start_at` が違う。どちらも期間内) | 1件・本表の `start_at` |
 * | T5 | id=5: 本表は期間外、履歴は期間内 | 0件(影チェックで履歴を捨てる) |
 * | T6 | 履歴だけの id が0件 | 影チェックのクエリを撃たない |
 * | T7 | 2表に同じ `where` が渡る | 両方の呼び出し引数が `toEqual` で一致 |
 * | T8 | `parent_id IN` の塊をまたいで同じ id | flat 後に1件 |
 *
 * ★DBには接続しない★(スタブの prisma。`where` の `in` / `gte` / `lt` / 等値と `select` だけを再現する)。
 */
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import {
  findTimetableById,
  findTimetables,
  findTimetablesByChunks,
  mergeTimetableRows,
  type TimetableSourcePrisma,
} from "@/lib/db/timetable-source";
import { logEvent } from "@/lib/logger";

vi.mock("@/lib/logger", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/lib/logger")>();
  return { ...actual, logEvent: vi.fn() };
});

// ---------------------------------------------------------------- スタブ(DBの代わり)

interface Row {
  id: bigint;
  facility_id: bigint;
  section_id: bigint;
  parent_id: bigint;
  precheck: number;
  start_at: Date;
}

type Table = "cleanTimetable" | "cleanTimetableHistory";

function matches(row: Row, where: Record<string, unknown> | undefined): boolean {
  if (!where) return true;
  for (const [field, cond] of Object.entries(where)) {
    const value = row[field as keyof Row];
    if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) {
      const c = cond as { in?: unknown[]; gte?: Date; lt?: Date };
      if ("in" in c) {
        if (c.in === undefined) throw new Error("[stub] in: undefined(罠1)");
        if (!c.in.some((x) => String(x) === String(value))) return false;
      }
      if (c.gte && (value as Date).getTime() < c.gte.getTime()) return false;
      if (c.lt && (value as Date).getTime() >= c.lt.getTime()) return false;
    } else if (String(value) !== String(cond)) {
      return false;
    }
  }
  return true;
}

function project(row: Row, select: Record<string, unknown> | undefined): Record<string, unknown> {
  if (!select) return { ...row };
  const out: Record<string, unknown> = {};
  for (const key of Object.keys(select)) out[key] = row[key as keyof Row];
  return out;
}

function stub(main: Row[], history: Row[]) {
  const calls: { table: Table; method: "findMany" | "findUnique"; args: Record<string, unknown> }[] = [];
  const delegate = (table: Table, rows: Row[]) => ({
    async findMany(args: object) {
      const a = args as { where?: Record<string, unknown>; select?: Record<string, unknown> };
      calls.push({ table, method: "findMany", args: a });
      return rows.filter((row) => matches(row, a.where)).map((row) => project(row, a.select));
    },
    async findUnique(args: object) {
      const a = args as { where: { id: bigint }; select?: Record<string, unknown> };
      calls.push({ table, method: "findUnique", args: a });
      const row = rows.find((r) => r.id === a.where.id);
      return row ? project(row, a.select) : null;
    },
  });
  const prisma = {
    cleanTimetable: delegate("cleanTimetable", main),
    cleanTimetableHistory: delegate("cleanTimetableHistory", history),
  } satisfies TimetableSourcePrisma;
  return { prisma, calls };
}

/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115) */
function wall(jstIso: string): Date {
  return new Date(jstIso.replace("+09:00", "Z"));
}

function row(id: number, startAtJst: string, overrides: Partial<Row> = {}): Row {
  return {
    id: BigInt(id),
    facility_id: BigInt(1),
    section_id: BigInt(10),
    parent_id: BigInt(0),
    precheck: CLEAN_TIMETABLE_PRECHECK.main,
    start_at: wall(startAtJst),
    ...overrides,
  };
}

const IN_RANGE = "2026-10-03T02:00:00+09:00";
const IN_RANGE_2 = "2026-10-04T02:00:00+09:00";
const OUT_OF_RANGE = "2026-09-20T02:00:00+09:00";

/** 期間で絞る本清掃の引数(`reportKpis()` 当期と同じ形) */
function rangeArgs() {
  return {
    where: {
      precheck: CLEAN_TIMETABLE_PRECHECK.main,
      start_at: { gte: wall("2026-10-01T00:00:00+09:00"), lt: wall("2026-10-08T00:00:00+09:00") },
    },
    select: { id: true, facility_id: true, section_id: true, start_at: true },
  } as const;
}

function ids(rows: readonly { id: bigint }[]): string[] {
  return rows.map((r) => r.id.toString()).sort();
}

beforeEach(() => {
  vi.mocked(logEvent).mockClear();
});

// ---------------------------------------------------------------- テスト

describe("findTimetables(指示書AU 段階3b-6 T1〜T7)", () => {
  it("T1 本表だけに id=1 → 1件", async () => {
    const { prisma } = stub([row(1, IN_RANGE)], []);
    const rows = await findTimetables(prisma, rangeArgs());
    expect(ids(rows)).toEqual(["1"]);
  });

  it("T2 履歴だけに id=2 → 1件(影チェックで本表に無い)", async () => {
    const { prisma, calls } = stub([], [row(2, IN_RANGE)]);
    const rows = await findTimetables(prisma, rangeArgs());
    expect(ids(rows)).toEqual(["2"]);
    // ★影チェック(履歴だけに出た id を本表で引く)が1本撃たれている★
    const shadow = calls.filter((c) => c.table === "cleanTimetable" && "id" in (c.args.where as object));
    expect(shadow).toHaveLength(1);
    expect((shadow[0].args.where as { id: { in: bigint[] } }).id.in).toEqual([BigInt(2)]);
  });

  it("T3 両方に id=3(同じ中身)→ ★1件★。中身は本表のもの・warn は出ない", async () => {
    const main = row(3, IN_RANGE);
    const { prisma } = stub([main], [row(3, IN_RANGE)]);
    const rows = await findTimetables(prisma, rangeArgs());
    expect(rows).toHaveLength(1);
    expect(rows[0]).toEqual(project(main, rangeArgs().select));
    expect(vi.mocked(logEvent)).not.toHaveBeenCalled();
  });

  it("T4 両方に id=4(`start_at` が違う。どちらも期間内)→ ★1件・本表の `start_at`★。違いを warn に出す(Q2)", async () => {
    const { prisma } = stub([row(4, IN_RANGE)], [row(4, IN_RANGE_2)]);
    const rows = await findTimetables(prisma, rangeArgs());
    expect(rows).toHaveLength(1);
    expect(rows[0].start_at).toEqual(wall(IN_RANGE));
    expect(vi.mocked(logEvent)).toHaveBeenCalledWith("warn", {
      event: "db.timetable_conflict",
      timetable_id: "4",
      adopted_table: "clean_timetables",
      columns: ["start_at"],
      main_start_at: IN_RANGE,
      history_start_at: IN_RANGE_2,
    });
  });

  it("T5 id=5: 本表は期間外・履歴は期間内 → ★0件★(影チェックで履歴を捨てる)。違いを warn に出す(Q2)", async () => {
    const { prisma } = stub([row(5, OUT_OF_RANGE)], [row(5, IN_RANGE)]);
    const rows = await findTimetables(prisma, rangeArgs());
    expect(rows).toEqual([]);
    expect(vi.mocked(logEvent)).toHaveBeenCalledTimes(1);
    expect(vi.mocked(logEvent).mock.calls[0][1]).toMatchObject({
      event: "db.timetable_conflict",
      timetable_id: "5",
      columns: ["start_at"],
      main_start_at: OUT_OF_RANGE,
      history_start_at: IN_RANGE,
    });
  });

  it("T5' 本表は本清掃でない(precheck が違う)・履歴は本清掃 → 0件(本表が勝つ)", async () => {
    const { prisma } = stub([row(6, IN_RANGE, { precheck: 60, parent_id: BigInt(1) })], [row(6, IN_RANGE)]);
    const rows = await findTimetables(prisma, rangeArgs());
    expect(rows).toEqual([]);
  });

  it("T6 履歴だけの id が0件 → 影チェックのクエリを撃たない", async () => {
    const { prisma, calls } = stub([row(1, IN_RANGE), row(3, IN_RANGE)], [row(3, IN_RANGE)]);
    await findTimetables(prisma, rangeArgs());
    // ★本表・履歴とも1本ずつだけ★
    expect(calls.map((c) => c.table)).toEqual(["cleanTimetable", "cleanTimetableHistory"]);
  });

  it("T7 2表に同じ `where`(引数)が渡る", async () => {
    const { prisma, calls } = stub([row(1, IN_RANGE)], [row(2, IN_RANGE)]);
    await findTimetables(prisma, rangeArgs());
    const mainCall = calls.find((c) => c.table === "cleanTimetable");
    const historyCall = calls.find((c) => c.table === "cleanTimetableHistory");
    expect(mainCall?.args).toEqual(rangeArgs());
    expect(historyCall?.args).toEqual(mainCall?.args);
  });

  it("本表どうし・履歴どうしの並びは保証しないが、本表の行と履歴だけの行がすべて出る", async () => {
    const { prisma } = stub([row(1, IN_RANGE), row(3, IN_RANGE)], [row(2, IN_RANGE), row(3, IN_RANGE)]);
    const rows = await findTimetables(prisma, rangeArgs());
    expect(ids(rows)).toEqual(["1", "2", "3"]);
  });

  it("`include` の行(全列)も合わせられる(`facility_id` / `section_id` の違いも warn に出す)", async () => {
    const { prisma } = stub(
      [row(7, IN_RANGE)],
      [row(7, IN_RANGE, { facility_id: BigInt(2), section_id: BigInt(20) })]
    );
    const rows = await findTimetables(prisma, {
      where: { precheck: CLEAN_TIMETABLE_PRECHECK.main },
    });
    expect(rows).toHaveLength(1);
    expect(rows[0].facility_id).toBe(BigInt(1));
    expect(vi.mocked(logEvent).mock.calls[0][1]).toEqual({
      event: "db.timetable_conflict",
      timetable_id: "7",
      adopted_table: "clean_timetables",
      columns: ["facility_id", "section_id"],
      main_facility_id: "1",
      history_facility_id: "2",
      main_section_id: "10",
      history_section_id: "20",
    });
  });
});

describe("findTimetablesByChunks(指示書AU 段階3b-3 / T8)", () => {
  it("T8 `parent_id IN` の塊をまたいで同じ id → flat 後に1件(本表の行)", async () => {
    // ★id=8 は本表では親 101、履歴では親 102 → 別の塊に入る★
    const { prisma, calls } = stub(
      [row(8, IN_RANGE, { precheck: 60, parent_id: BigInt(101) })],
      [row(8, IN_RANGE, { precheck: 60, parent_id: BigInt(102) }), row(9, IN_RANGE, { precheck: 30, parent_id: BigInt(102) })]
    );
    const rows = await findTimetablesByChunks(prisma, [[BigInt(101)], [BigInt(102)]], (chunk) => ({
      where: { parent_id: { in: chunk } },
      select: { id: true, parent_id: true, precheck: true },
    }));
    expect(ids(rows)).toEqual(["8", "9"]);
    expect(rows.find((r) => r.id === BigInt(8))?.parent_id).toBe(BigInt(101));
    // ★塊ごとに、2表へ同じ引数★
    for (const chunk of [[BigInt(101)], [BigInt(102)]]) {
      const args = { where: { parent_id: { in: chunk } }, select: { id: true, parent_id: true, precheck: true } };
      expect(calls.filter((c) => c.table === "cleanTimetable" && c.method === "findMany").map((c) => c.args)).toContainEqual(args);
      expect(calls.filter((c) => c.table === "cleanTimetableHistory").map((c) => c.args)).toContainEqual(args);
    }
  });

  it("塊が0個ならクエリを1本も撃たない", async () => {
    const { prisma, calls } = stub([row(1, IN_RANGE)], [row(2, IN_RANGE)]);
    const rows = await findTimetablesByChunks(prisma, [], (chunk: bigint[]) => ({
      where: { id: { in: chunk } },
      select: { id: true },
    }));
    expect(rows).toEqual([]);
    expect(calls).toEqual([]);
  });
});

describe("mergeTimetableRows(純粋関数)", () => {
  it("本表を全部採り、履歴は本表にも影にも無い id だけ足す", () => {
    const merged = mergeTimetableRows(
      [{ id: BigInt(1), v: "main" }],
      [{ id: BigInt(1), v: "history" }, { id: BigInt(2), v: "history" }, { id: BigInt(3), v: "history" }],
      new Set(["3"])
    );
    expect(merged).toEqual([{ id: BigInt(1), v: "main" }, { id: BigInt(2), v: "history" }]);
  });

  it("★履歴の中で同じ id が2回出たら例外★(呼び出しの誤り)", () => {
    expect(() =>
      mergeTimetableRows([], [{ id: BigInt(1) }, { id: BigInt(1) }], new Set())
    ).toThrow(/clean_timetable_histories/);
  });
});

describe("findTimetableById(`precheckItemById()` 用。3b-2 の 5)", () => {
  const select = { id: true, parent_id: true, precheck: true } as const;

  it("本表にあれば本表の行(履歴は引かない)", async () => {
    const { prisma, calls } = stub([row(1, IN_RANGE)], [row(1, IN_RANGE, { precheck: 60 })]);
    const found = await findTimetableById(prisma, BigInt(1), select);
    expect(found).toEqual({ id: BigInt(1), parent_id: BigInt(0), precheck: CLEAN_TIMETABLE_PRECHECK.main });
    expect(calls.map((c) => c.table)).toEqual(["cleanTimetable"]);
  });

  it("本表に無ければ履歴の行", async () => {
    const { prisma, calls } = stub([], [row(2, IN_RANGE, { precheck: 60, parent_id: BigInt(1) })]);
    const found = await findTimetableById(prisma, BigInt(2), select);
    expect(found).toEqual({ id: BigInt(2), parent_id: BigInt(1), precheck: 60 });
    // ★両方に同じ引数★
    expect(calls.map((c) => c.args)).toEqual([
      { where: { id: BigInt(2) }, select },
      { where: { id: BigInt(2) }, select },
    ]);
  });

  it("どちらにも無ければ null", async () => {
    const { prisma } = stub([], []);
    expect(await findTimetableById(prisma, BigInt(3), select)).toBeNull();
  });
});