/**
* `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);
});
});