Newer
Older
client_dwatch / lib / prechecks / __tests__ / status-update.test.ts
/**
 * 指示書AT §3-4(状態遷移と日時の規則)/ §3-6(備考の規則)の期待値表。
 *
 * ★期待値表 → テスト → 実装の順(`CLAUDE.md` §6-2)★。表の行番号は指示書AT §3-4 の # と同じ。
 *
 * 不変条件(この規則で保たれる形):
 *   未解消 → 両方 null / 対応中 → `responded_at` のみ / 解消 → 両方あり
 */
import { describe, expect, it } from "vitest";
import { PRECHECK_NOTE_MAX_LENGTH } from "@/lib/config";
import {
  parsePrecheckStatusUpdateBody,
  planPrecheckStatusUpdate,
  type PrecheckStatusCurrent,
} from "@/lib/prechecks/status-update";
import type { PrecheckStatus, PrecheckStatusPatch } from "@/lib/types";

// `now` = 2026-10-02 08:15 JST、`t1` = 07:00、`t2` = 07:30(すべて「真の瞬間」)
const NOW = new Date("2026-10-02T08:15:00+09:00");
const T1 = new Date("2026-10-02T07:00:00+09:00");
const T2 = new Date("2026-10-02T07:30:00+09:00");

function current(
  status: PrecheckStatus,
  respondedAt: Date | null,
  resolvedAt: Date | null,
  note: string | null = null
): PrecheckStatusCurrent {
  return { resolution_status: status, responded_at: respondedAt, resolved_at: resolvedAt, note };
}

describe("planPrecheckStatusUpdate — §3-4 の期待値表(12行)", () => {
  const table: {
    no: number;
    cur: PrecheckStatusCurrent;
    req: PrecheckStatus;
    expected: PrecheckStatusPatch;
  }[] = [
    { no: 1, cur: current("unresolved", null, null), req: "in_progress", expected: { status: "in_progress", responded_at: NOW } },
    { no: 2, cur: current("unresolved", null, null), req: "resolved", expected: { status: "resolved", responded_at: NOW, resolved_at: NOW } },
    { no: 3, cur: current("unresolved", null, null), req: "unresolved", expected: {} },
    // ★responded_at は変えない★
    { no: 4, cur: current("in_progress", T1, null), req: "resolved", expected: { status: "resolved", resolved_at: NOW } },
    // 対応中なのに responded_at が null(不正データ)
    { no: 5, cur: current("in_progress", null, null), req: "resolved", expected: { status: "resolved", responded_at: NOW, resolved_at: NOW } },
    { no: 6, cur: current("in_progress", T1, null), req: "in_progress", expected: {} },
    { no: 7, cur: current("in_progress", T1, null), req: "unresolved", expected: { status: "unresolved", responded_at: null } },
    // ★戻すと両方消す★
    { no: 8, cur: current("resolved", T1, T2), req: "unresolved", expected: { status: "unresolved", responded_at: null, resolved_at: null } },
    { no: 9, cur: current("resolved", T1, T2), req: "in_progress", expected: { status: "in_progress", resolved_at: null } },
    // ★resolved_at を now で上書きしない★
    { no: 10, cur: current("resolved", T1, T2), req: "resolved", expected: {} },
    // ★既にある responded_at は上書きしない★(先方が入れた等)
    { no: 11, cur: current("unresolved", T1, null), req: "in_progress", expected: { status: "in_progress" } },
    // 解消なのに resolved_at が null(不正データ)
    { no: 12, cur: current("resolved", T1, null), req: "resolved", expected: { resolved_at: NOW } },
  ];

  it.each(table)("#$no", ({ cur, req, expected }) => {
    expect(planPrecheckStatusUpdate(cur, { status: req }, NOW)).toEqual(expected);
  });

  it("★出力のキーは4つ(status / note / responded_at / resolved_at)以外を含まない★", () => {
    const allowed = ["status", "note", "responded_at", "resolved_at"];
    for (const { cur, req } of table) {
      const patch = planPrecheckStatusUpdate(cur, { status: req, note: "x" }, NOW);
      for (const key of Object.keys(patch)) expect(allowed).toContain(key);
    }
  });

  it("★`now` 以外の時刻を使わない★(日時の値は now か null か、キーが無いか)", () => {
    for (const { cur, req } of table) {
      const patch = planPrecheckStatusUpdate(cur, { status: req }, NOW);
      for (const key of ["responded_at", "resolved_at"] as const) {
        if (!(key in patch)) continue;
        const value = patch[key];
        if (value !== null) expect(value).toBe(NOW);
      }
    }
  });
});

/**
 * ★表に無い行★ — 不変条件が崩れた行(不正データ)に「同じ状態」を送ったとき。
 * 規則: ★同じ状態の要求では「足りない日時を now で埋める」だけ★(#12 と同じ)。
 *   ★値を消すのは状態を変える要求のときだけ★(#7・#8・#9)— 備考だけ変える操作
 *   (画面は現在の status を送る)で、先方が入れた日時を消さないため。
 */
describe("planPrecheckStatusUpdate — 表に無い行(不正データに同じ状態を送る)", () => {
  it("対応中で responded_at が null → 対応中: responded_at=now", () => {
    expect(planPrecheckStatusUpdate(current("in_progress", null, null), { status: "in_progress" }, NOW)).toEqual({
      responded_at: NOW,
    });
  });

  it("未解消で responded_at あり → 未解消: 何も書かない(同じ状態では消さない)", () => {
    expect(planPrecheckStatusUpdate(current("unresolved", T1, null), { status: "unresolved" }, NOW)).toEqual({});
  });

  it("解消で responded_at が null → 解消: responded_at=now(resolved_at は上書きしない)", () => {
    expect(planPrecheckStatusUpdate(current("resolved", null, T2), { status: "resolved" }, NOW)).toEqual({
      responded_at: NOW,
    });
  });

  it("未解消で resolved_at あり(不正)→ 対応中: 状態を変えるので resolved_at を消す", () => {
    expect(planPrecheckStatusUpdate(current("unresolved", T1, T2), { status: "in_progress" }, NOW)).toEqual({
      status: "in_progress",
      resolved_at: null,
    });
  });
});

describe("planPrecheckStatusUpdate — §3-6 の備考の表", () => {
  const base = current("in_progress", T1, null, "既存の備考");

  it("note が無い → comment は変えない(状態だけの変更)", () => {
    expect(planPrecheckStatusUpdate(base, { status: "in_progress" }, NOW)).toEqual({});
    expect(planPrecheckStatusUpdate(base, { status: "resolved" }, NOW)).toEqual({
      status: "resolved",
      resolved_at: NOW,
    });
  });

  it("空文字 → comment = null(削除)", () => {
    expect(planPrecheckStatusUpdate(base, { status: "in_progress", note: "" }, NOW)).toEqual({ note: null });
  });

  it("空白だけ → comment = null(削除)", () => {
    expect(planPrecheckStatusUpdate(base, { status: "in_progress", note: "   \n " }, NOW)).toEqual({
      note: null,
    });
  });

  it("それ以外 → 前後の空白を除いて保存", () => {
    expect(planPrecheckStatusUpdate(base, { status: "in_progress", note: "  現場に連絡  " }, NOW)).toEqual({
      note: "現場に連絡",
    });
  });

  it("トリム後が現在値と同じ → comment は書かない", () => {
    expect(planPrecheckStatusUpdate(base, { status: "in_progress", note: " 既存の備考 " }, NOW)).toEqual({});
  });

  it("現在 null の行に空文字 → 書かない(null のまま)", () => {
    const noNote = current("unresolved", null, null, null);
    expect(planPrecheckStatusUpdate(noNote, { status: "unresolved", note: "   " }, NOW)).toEqual({});
  });

  it("状態と備考を同時に変える", () => {
    expect(
      planPrecheckStatusUpdate(current("unresolved", null, null), { status: "in_progress", note: "連絡済み" }, NOW)
    ).toEqual({ status: "in_progress", note: "連絡済み", responded_at: NOW });
  });
});

describe("parsePrecheckStatusUpdateBody — §3-6 の入力検証(422)", () => {
  function expect422(body: unknown) {
    const result = parsePrecheckStatusUpdateBody(body);
    expect(result.ok).toBe(false);
    if (result.ok) return;
    expect(result.status).toBe(422);
    expect(result.error.error.code).toBe("validation_failed");
  }

  it("status だけ → 通る(note は undefined)", () => {
    const result = parsePrecheckStatusUpdateBody({ status: "resolved" });
    expect(result).toEqual({ ok: true, query: { status: "resolved" } });
  });

  it("status と note → 通る(note はトリムしない。トリムは planPrecheckStatusUpdate の仕事)", () => {
    const result = parsePrecheckStatusUpdateBody({ status: "in_progress", note: " a " });
    expect(result).toEqual({ ok: true, query: { status: "in_progress", note: " a " } });
  });

  it("本文がオブジェクトでない → 422", () => {
    for (const body of [null, [], "x", 1, true]) expect422(body);
  });

  it("status が無い・enum 外 → 422", () => {
    expect422({});
    expect422({ status: "done" });
    expect422({ status: 1 });
    expect422({ status: null });
  });

  it("note が文字列以外(null・数値など)→ 422", () => {
    expect422({ status: "resolved", note: null });
    expect422({ status: "resolved", note: 1 });
    expect422({ status: "resolved", note: ["a"] });
  });

  it("★401文字以上(トリム前で数える)→ 422★ / 400文字ちょうどは通る", () => {
    expect(PRECHECK_NOTE_MAX_LENGTH).toBe(400);
    expect(parsePrecheckStatusUpdateBody({ status: "resolved", note: "a".repeat(400) }).ok).toBe(true);
    expect422({ status: "resolved", note: "a".repeat(401) });
    // トリムすれば400文字以内でも、トリム前で401文字なら弾く
    expect422({ status: "resolved", note: ` ${"a".repeat(399)} ` + " " });
  });

  it("★本文に4つ以外のキー(updated_at・error_type・memo など)があっても無視する★", () => {
    const result = parsePrecheckStatusUpdateBody({
      status: "resolved",
      note: "a",
      updated_at: "2026-01-01",
      error_type: "x",
      memo: "旧名",
      comment: "y",
    });
    expect(result).toEqual({ ok: true, query: { status: "resolved", note: "a" } });
  });

  it("エラーに入力値をエコーしない", () => {
    const result = parsePrecheckStatusUpdateBody({ status: "echo-me" });
    expect(result.ok).toBe(false);
    if (result.ok) return;
    expect(JSON.stringify(result.error)).not.toContain("echo-me");
  });
});