/**
* 指示書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");
});
});