Newer
Older
client_dwatch / lib / errors / __tests__ / status-update.test.ts
/**
 * `lib/errors/status-update.ts` の期待値表(指示書BD §3-3。D-184)。
 *
 * 状態・日時・備考の規則は `lib/prechecks/__tests__/status-update.test.ts`(AT §3-4 #1〜#12)が正。
 * ここは ★`error_list_id` を足した部分★と、★事前チェックの規則をそのまま通していること★だけを見る。
 *
 * | #   | 現在の行                         | 要求                                   | 有効な選択肢 | 期待 |
 * |-----|----------------------------------|----------------------------------------|--------------|------|
 * | L1  | id null・選べる                  | status 同じ・id 40                     | 40 あり      | パッチ `{ error_list_id: 40 }` |
 * | L2  | id 40・選べる                    | status 同じ・id 40                     | (引かない) | 空パッチ(手順1。書かない) |
 * | L3  | id 7(無効化済み)・選べる       | status 対応中・id 7                    | 7 なし       | 200 相当。パッチに error_list_id なし(手順1) |
 * | L4  | id null・★選べない★             | id 40                                  | 40 あり      | 422 details.error_list_id(手順2) |
 * | L5  | id null・選べる                  | id 9999                                | 無い         | 422 details.error_list_id(手順3) |
 * | L6  | id null・選べない                | id 9999                                | —            | ★手順2が先★(details.error_list_id・文言は「選べません」) |
 * | L7  | id null・選べる・備考 null       | id 131・note 省略                      | 131 あり     | 422 details.note(手順4) |
 * | L8  | id null・選べる・備考 null       | id 131・note「理由」                   | 131 あり     | パッチ `{ error_list_id: 131, note: "理由" }` |
 * | L9  | id 131・備考あり                 | note "  "(消す)                      | (引かない) | 422 details.note(★既にその他の行で備考を消す★) |
 * | L10 | id 131・備考あり                 | id 40・note "  "                       | 40 あり      | パッチ `{ error_list_id: 40, note: null }`(適用後は 40) |
 * | L11 | id 131・備考あり                 | status 対応中                          | (引かない) | 状態だけのパッチ(適用後も備考あり) |
 * | L12 | id 131・★備考 null / 空白だけ★  | status 対応中(状態だけ)              | (引かない) | ★通る★(手順4は状態だけの要求では見ない。監督判断 2026-10-27) |
 * | L13 | id 40・備考あり                  | id 131・note 省略                      | 131 あり     | パッチ `{ error_list_id: 131 }`(適用後の備考は現在の値) |
 * | L14 | id null・★備考が空白だけ(他所が書いた)★ | id 131・note 省略               | 131 あり     | 422 details.note(★空白だけの備考は空とみなす★) |
 * | L15 | id 131・備考あり・★選べない行★  | note を消す                            | (引かない) | 手順4を見ない(通る) |
 */
import { describe, expect, it, vi } from "vitest";
import { ERROR_LIST_NOTE_REQUIRED_IDS, PRECHECK_NOTE_MAX_LENGTH } from "@/lib/config";
import {
  errorListIdToLookUp,
  isEmptyErrorPatch,
  parseErrorStatusUpdateBody,
  planErrorStatusUpdate,
  type ErrorStatusCurrent,
} from "@/lib/errors/status-update";
import { parsePrecheckStatusUpdateBody } from "@/lib/prechecks/status-update";
import type { ErrorStatusUpdateRequest } from "@/lib/types";

const OTHER = ERROR_LIST_NOTE_REQUIRED_IDS[0];
const NOW = new Date("2026-10-27T09:00:00+09:00");
const T1 = new Date("2026-10-27T07:00:00+09:00");

function current(overrides: Partial<ErrorStatusCurrent> = {}): ErrorStatusCurrent {
  return {
    resolution_status: "unresolved",
    responded_at: null,
    resolved_at: null,
    note: null,
    error_list_id: null,
    error_list_selectable: true,
    ...overrides,
  };
}

/** 有効な選択肢の判定(★呼ばれた id を記録する★ — 引かなくてよい場面で呼ばないことを見る) */
function activeIds(...ids: number[]) {
  return vi.fn((id: number) => ids.includes(id));
}

function plan(cur: ErrorStatusCurrent, request: ErrorStatusUpdateRequest, lookup = activeIds(40, OTHER)) {
  return { result: planErrorStatusUpdate(cur, request, NOW, lookup), lookup };
}

// ---------------------------------------------------------------- 本文

describe("parseErrorStatusUpdateBody", () => {
  it("error_list_id を省略 → 事前チェックと同じ結果(キーを足さない)", () => {
    const body = { status: "in_progress", note: "a" };
    expect(parseErrorStatusUpdateBody(body)).toEqual(parsePrecheckStatusUpdateBody(body));
    expect(parseErrorStatusUpdateBody(body)).toEqual({ ok: true, query: { status: "in_progress", note: "a" } });
  });

  it("1以上の整数 → query に入る", () => {
    expect(parseErrorStatusUpdateBody({ status: "resolved", error_list_id: 131 })).toEqual({
      ok: true,
      query: { status: "resolved", error_list_id: 131 },
    });
    expect(parseErrorStatusUpdateBody({ status: "resolved", error_list_id: 1 })).toMatchObject({ ok: true });
  });

  it.each([
    ["null", null],
    ["0", 0],
    ["負数", -1],
    ["小数", 1.5],
    ["文字列", "131"],
    ["真偽値", true],
    ["配列", [131]],
    ["2^53(安全な整数でない)", 2 ** 53],
    ["NaN 相当(JSON には無いが念のため)", Number.NaN],
  ])("error_list_id が %s → 422(details は null。note の型違いと同じ書き方)", (_label, value) => {
    const result = parseErrorStatusUpdateBody({ status: "resolved", error_list_id: value });
    expect(result).toEqual({
      ok: false,
      status: 422,
      error: {
        error: {
          code: "validation_failed",
          message: "error_list_idは1以上の整数で指定してください",
          details: null,
        },
      },
    });
  });

  it("★入力値をエコーしない★", () => {
    const result = parseErrorStatusUpdateBody({ status: "resolved", error_list_id: "秘密の値" });
    expect(JSON.stringify(result)).not.toContain("秘密の値");
  });

  it("★status・note の検証が先★(両方不正なら status / note の 422)", () => {
    expect(parseErrorStatusUpdateBody({ status: "done", error_list_id: 0 })).toMatchObject({
      ok: false,
      error: { error: { message: "statusの値が不正です" } },
    });
    expect(parseErrorStatusUpdateBody({ status: "resolved", note: 1, error_list_id: 0 })).toMatchObject({
      ok: false,
      error: { error: { message: "noteは文字列で指定してください" } },
    });
    expect(
      parseErrorStatusUpdateBody({ status: "resolved", note: "a".repeat(PRECHECK_NOTE_MAX_LENGTH + 1), error_list_id: 0 })
    ).toMatchObject({ ok: false, error: { error: { details: { note: expect.any(Array) } } } });
  });

  it("★/prechecks の本文の検証は error_list_id を捨てるまま★(挙動を変えていない)", () => {
    expect(parsePrecheckStatusUpdateBody({ status: "resolved", error_list_id: 131 })).toEqual({
      ok: true,
      query: { status: "resolved" },
    });
    // 不正な値でも 422 にしない(他の未知のキーと同じく捨てる)
    expect(parsePrecheckStatusUpdateBody({ status: "resolved", error_list_id: null })).toEqual({
      ok: true,
      query: { status: "resolved" },
    });
  });

  it("その他の未知のキーは捨てる", () => {
    expect(parseErrorStatusUpdateBody({ status: "resolved", error_list_id: 3, comment: "x", updated_at: "y" })).toEqual({
      ok: true,
      query: { status: "resolved", error_list_id: 3 },
    });
  });
});

// ---------------------------------------------------------------- 計画

describe("planErrorStatusUpdate(表 L1〜L13)", () => {
  it("L1 未選択 → 有効な id: error_list_id だけを書く", () => {
    const { result, lookup } = plan(current(), { status: "unresolved", error_list_id: 40 });
    expect(result).toEqual({ ok: true, query: { error_list_id: 40 } });
    expect(lookup).toHaveBeenCalledWith(40);
  });

  it("L2 現在値と同じ id → 空パッチ(検証しない・引かない)", () => {
    const { result, lookup } = plan(current({ error_list_id: 40 }), { status: "unresolved", error_list_id: 40 });
    expect(result).toEqual({ ok: true, query: {} });
    expect(lookup).not.toHaveBeenCalled();
  });

  it("L3 ★無効化済みの id が入った行で、同じ id を送って状態だけ変える → 通る★(手順1)", () => {
    const { result, lookup } = plan(current({ error_list_id: 7 }), { status: "in_progress", error_list_id: 7 }, activeIds(40));
    expect(result).toEqual({ ok: true, query: { status: "in_progress", responded_at: NOW } });
    expect(lookup).not.toHaveBeenCalled();
  });

  it("L4 ★選べない行で id を変える → 422 details.error_list_id(手順2)★", () => {
    const { result, lookup } = plan(current({ error_list_selectable: false }), { status: "unresolved", error_list_id: 40 });
    expect(result).toEqual({
      ok: false,
      status: 422,
      error: {
        error: {
          code: "validation_failed",
          message: "この行ではエラー内容を選べません",
          details: { error_list_id: ["この行ではエラー内容を選べません"] },
        },
      },
    });
    expect(lookup).not.toHaveBeenCalled();
  });

  it("L5 有効な選択肢に無い id(無い・無効化・削除済み)→ 422 details.error_list_id(手順3)", () => {
    const { result } = plan(current(), { status: "unresolved", error_list_id: 9999 });
    expect(result).toEqual({
      ok: false,
      status: 422,
      error: {
        error: {
          code: "validation_failed",
          message: "error_list_idの値が不正です",
          details: { error_list_id: ["有効なエラー内容を指定してください"] },
        },
      },
    });
  });

  it("L6 ★選べない行で無い id → 手順2が先(選べません)★", () => {
    const { result, lookup } = plan(current({ error_list_selectable: false }), { status: "unresolved", error_list_id: 9999 });
    expect(result).toMatchObject({ ok: false, error: { error: { message: "この行ではエラー内容を選べません" } } });
    expect(lookup).not.toHaveBeenCalled();
  });

  it("L7 その他を選んで備考を書かない → 422 details.note(手順4)", () => {
    const { result } = plan(current(), { status: "unresolved", error_list_id: OTHER });
    expect(result).toEqual({
      ok: false,
      status: 422,
      error: {
        error: {
          code: "validation_failed",
          message: "備考を入力してください",
          details: { note: ["このエラー内容を選んだときは備考が必須です"] },
        },
      },
    });
  });

  it("L7b その他+空白だけの備考(=削除)→ 422 details.note", () => {
    const { result } = plan(current(), { status: "unresolved", error_list_id: OTHER, note: "   " });
    expect(result).toMatchObject({ ok: false, error: { error: { details: { note: expect.any(Array) } } } });
  });

  it("L8 その他+備考 → 2列を書く(備考はトリム)", () => {
    const { result } = plan(current(), { status: "unresolved", error_list_id: OTHER, note: "  理由  " });
    expect(result).toEqual({ ok: true, query: { note: "理由", error_list_id: OTHER } });
  });

  it("L9 ★既にその他の行で備考を消す → 422 details.note★(id を送らなくても)", () => {
    const cur = current({ error_list_id: OTHER, note: "理由" });
    const { result, lookup } = plan(cur, { status: "unresolved", note: "  " });
    expect(result).toMatchObject({ ok: false, status: 422, error: { error: { details: { note: expect.any(Array) } } } });
    expect(lookup).not.toHaveBeenCalled();
    // 同じ id を送っても同じ(手順1で id は検証しないが、手順4は適用後の値で判定する)
    expect(plan(cur, { status: "unresolved", note: "", error_list_id: OTHER }).result).toMatchObject({ ok: false, status: 422 });
  });

  it("L10 その他 → 別の id に変えて備考を消す → 通る(適用後は備考必須でない)", () => {
    const { result } = plan(current({ error_list_id: OTHER, note: "理由" }), { status: "unresolved", error_list_id: 40, note: "  " });
    expect(result).toEqual({ ok: true, query: { note: null, error_list_id: 40 } });
  });

  it("L11 その他+備考ありの行で状態だけ変える → 通る", () => {
    const { result } = plan(current({ error_list_id: OTHER, note: "理由" }), { status: "in_progress" });
    expect(result).toEqual({ ok: true, query: { status: "in_progress", responded_at: NOW } });
  });

  it("L12 ★その他・備考が空の行で状態だけ変える → 通る(手順4は状態だけの要求では見ない)★", () => {
    for (const note of [null, "", "   "]) {
      const { result } = plan(current({ error_list_id: OTHER, note }), { status: "in_progress" });
      expect(result).toEqual({ ok: true, query: { status: "in_progress", responded_at: NOW } });
    }
    // 同じ id を添えても(パッチに error_list_id が入らないので)状態だけの要求のまま
    const { result } = plan(current({ error_list_id: OTHER, note: null }), { status: "in_progress", error_list_id: OTHER });
    expect(result).toMatchObject({ ok: true });
  });

  it("L14 ★既存の備考が空白だけ(他所が書いた)の行でその他を選ぶ → 422 details.note★", () => {
    for (const note of ["", "   "]) {
      const { result } = plan(current({ note }), { status: "unresolved", error_list_id: OTHER });
      expect(result).toMatchObject({ ok: false, status: 422, error: { error: { details: { note: expect.any(Array) } } } });
    }
  });

  it("L15 ★選べない行では手順4を見ない★(その他・備考なしの行で備考を消しても通る)", () => {
    const cur = current({ error_list_selectable: false, error_list_id: OTHER, note: "a" });
    expect(plan(cur, { status: "unresolved", note: "" }).result).toEqual({ ok: true, query: { note: null } });
    // 同じ id を送っても手順1で書かず、手順4も見ない
    expect(plan(cur, { status: "unresolved", note: " ", error_list_id: OTHER }).result).toEqual({
      ok: true,
      query: { note: null },
    });
  });

  it("L13 備考ありの行でその他を選ぶ(備考は省略)→ 通る(適用後の備考は現在の値)", () => {
    const { result } = plan(current({ error_list_id: 40, note: "既存" }), { status: "unresolved", error_list_id: OTHER });
    expect(result).toEqual({ ok: true, query: { error_list_id: OTHER } });
  });

  it("★状態・日時・備考は事前チェックの規則のまま★(対応中 → 解消・responded_at は動かさない)", () => {
    const { result } = plan(current({ resolution_status: "in_progress", responded_at: T1 }), {
      status: "resolved",
      note: "完了",
      error_list_id: 40,
    });
    expect(result).toEqual({ ok: true, query: { status: "resolved", resolved_at: NOW, note: "完了", error_list_id: 40 } });
  });
});

describe("errorListIdToLookUp(有効な選択肢を引く必要があるか)", () => {
  it.each([
    ["省略", current(), { status: "unresolved" as const }, null],
    ["現在値と同じ", current({ error_list_id: 40 }), { status: "unresolved" as const, error_list_id: 40 }, null],
    ["選べない行", current({ error_list_selectable: false }), { status: "unresolved" as const, error_list_id: 40 }, null],
    ["変わる・選べる", current({ error_list_id: 40 }), { status: "unresolved" as const, error_list_id: 41 }, 41],
  ])("%s → %s", (_label, cur, request, expected) => {
    expect(errorListIdToLookUp(cur, request)).toBe(expected);
  });
});

describe("isEmptyErrorPatch", () => {
  it("★error_list_id だけのパッチは空でない★", () => {
    expect(isEmptyErrorPatch({})).toBe(true);
    expect(isEmptyErrorPatch({ error_list_id: 40 })).toBe(false);
    expect(isEmptyErrorPatch({ note: null })).toBe(false);
  });
});