Newer
Older
client_dwatch / lib / ds-logs / __tests__ / validate.test.ts
/**
 * 指示書AY 段階3-3 の V1〜V10・X1〜X6(クエリ・本体・パスの検証)と 段階7-2 の W1〜W11(制御盤の一覧の結果の検証)、
 * あわせて prepared / complete / fail の本体の検証(§8・段階7-2 の末尾・表5 R43)。
 * ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★。
 * ★V・W は 2026-11-05 に監督が改めた後の表が正(§11-4 M7・M8)★。
 *
 * ── 段階3-3(ブラウザ向けは 422 `validation_failed`)──
 * | V1  | パラメータなし | { types: null, from: null, to: null, sort: "date_desc", page: 1, perPage: 200 } |
 * | V2  | from=2026-10-19(日付だけ)/from=2026-10-19T11:00:00(オフセットなし)/from=abc | 422 |
 * | V2b | from=2026-02-30T00:00:00+09:00/to=2026-02-29T00:00:00Z(平年)/from=2026-13-01T00:00:00+09:00 | 422 |
 * | V2c | from=2026-10-19T02:30:00Z/from=2026-10-19T02:30:00+00:00 | 通る |
 * | V3  | from > to | 422。from = to は通る(0件) |
 * | V4  | types=・types=%20・from=・to=・sort=(空・空白だけ) | 省略と同じ(V1 と同じ値) |
 * | V4b | types=app,,sys/types=app,(空でない並びの中の空の要素) | 422 |
 * | V5  | sort=name | 422 |
 * | V6  | per_page=30→30・500→200・0→1・abc→200・page=0→1・1.5→1・abc→1 | 422 にせず丸める |
 * | V7  | POST の本体 { file_ids: [] } / 501件 / 重複あり / 要素が文字列でない / 8193文字 | 422 |
 * | V8  | POST の本体が JSON だがオブジェクトでない(null・[]・1・"x") | 422「JSONの形式が不正です」 |
 * | V9  | POST の本体が JSON でない({) | 422「JSONの形式が不正です」 |
 * | V10 | wait=0・wait=26・wait=abc(制御盤向け) | 422(丸めない) |
 *
 * ── パスパラメータ(M8)──
 * | X1 | export_id / command_id が randomUUID の形(36文字) | 通る |
 * | X2 | 空文字・PATH_*_MAX_LENGTH + 1 文字 | 422(入力値を message / details に入れない) |
 * | X3 | export_id=abc | 通る(文字種は絞らない) |
 * | X4 | seq=0・1・10 | 通る |
 * | X5 | seq=-1・01・1.0・abc・1e3・空 | 422 |
 * | X6 | seq=1234567890(10桁) | 422(上限は9桁) |
 *
 * ── 段階7-2(制御盤の一覧の結果。不正なら制御盤には 400 `result_invalid`)──
 * | W1  | 正しい(types 3・items 8・truncated: false) | 受理 |
 * | W2  | types の key が重複 | 不正 |
 * | W3  | items[].type が types に無い | 不正 |
 * | W4  | modified_at にオフセットが無い | 不正 |
 * | W4b | modified_at が存在しない日付(2026-02-30T00:00:00+09:00) | 不正 |
 * | W5  | size_bytes が負・小数・文字列 | 不正 |
 * | W6  | file_id が重複 | 不正 |
 * | W7  | items が LIST_RESULT_MAX_ITEMS + 1 件 | 不正 |
 * | W8  | 本体が LIST_RESULT_MAX_BYTES を超える | 不正(読んだ長さで判定) |
 * | W9  | { error: { code, message } } | failCommand と同じ扱い |
 * | W10 | 本体が JSON だがオブジェクトでない(null・[]・1・"x") | 不正 |
 * | W11 | items[].name が api_client/api_client.log.2026-10-18 の形 | 受理(/ を理由に弾かない) |
 */
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
  DS_LOG_DEFAULT_PER_PAGE,
  EXPORT_FILE_IDS_MAX_BYTES,
  EXPORT_MAX_FILES,
  LIST_RESULT_MAX_BYTES,
  LIST_RESULT_MAX_ITEMS,
  AGENT_POLL_WAIT_SEC,
  PATH_COMMAND_ID_MAX_LENGTH,
  PATH_EXPORT_ID_MAX_LENGTH,
} from "@/lib/config/ds-agent";
import { fileIdsOfJsonBytes } from "@/lib/ds-logs/__tests__/file-ids-of-bytes";
import {
  EXPORT_FILE_IDS_TOO_LARGE_MESSAGE,
  parseAgentComplete,
  parseAgentFail,
  parseAgentListResult,
  parseAgentPollWait,
  parseAgentPrepared,
  parseDeviceLogQuery,
  parseExportRequest,
  parsePathCommandId,
  parsePathExportId,
  parsePathSeq,
} from "@/lib/ds-logs/validate";
import type { QueryParseResult } from "@/lib/api/query-result";
import type { DsAgentListResult } from "@/lib/types";

function expect422<T>(result: QueryParseResult<T>) {
  expect(result.ok).toBe(false);
  if (result.ok) return;
  expect(result.status).toBe(422);
  expect(result.error.error.code).toBe("validation_failed");
}

function expectResultInvalid<T>(result: QueryParseResult<T>, message?: string) {
  expect(result.ok).toBe(false);
  if (result.ok) return;
  expect(result.status).toBe(400);
  expect(result.error.error.code).toBe("result_invalid");
  if (message !== undefined) expect(result.error.error.message).toBe(message);
}

function okQuery<T>(result: QueryParseResult<T>): T {
  if (!result.ok) throw new Error(`期待は成功: ${JSON.stringify(result.error)}`);
  return result.query;
}

const params = (qs: string) => new URLSearchParams(qs);

const V1_QUERY = { types: null, from: null, to: null, sort: "date_desc", page: 1, perPage: 200 };

describe("parseDeviceLogQuery() — 段階3-3 V1〜V6", () => {
  it("V1 パラメータなし", () => {
    expect(okQuery(parseDeviceLogQuery(params("")))).toEqual(V1_QUERY);
    expect(DS_LOG_DEFAULT_PER_PAGE).toBe(200);
  });

  it("types はカンマ区切りの配列になる", () => {
    expect(okQuery(parseDeviceLogQuery(params("types=app,sys"))).types).toEqual(["app", "sys"]);
  });

  it("from / to / sort を読む(from・to は瞬間の ms)", () => {
    expect(
      okQuery(
        parseDeviceLogQuery(
          params("from=2026-10-19T11:00:00%2B09:00&to=2026-10-19T12:00:00%2B09:00&sort=type_date_desc")
        )
      )
    ).toEqual({
      ...V1_QUERY,
      from: Date.parse("2026-10-19T11:00:00+09:00"),
      to: Date.parse("2026-10-19T12:00:00+09:00"),
      sort: "type_date_desc",
    });
  });

  it.each([
    ["日付だけ", "from=2026-10-19"],
    ["オフセットなし", "from=2026-10-19T11:00:00"],
    ["abc", "from=abc"],
  ])("V2 %s → 422", (_label, qs) => {
    expect422(parseDeviceLogQuery(params(qs)));
  });

  it.each([
    ["2/30", "from=2026-02-30T00:00:00%2B09:00"],
    ["平年の 2/29(Z)", "to=2026-02-29T00:00:00Z"],
    ["13月", "from=2026-13-01T00:00:00%2B09:00"],
  ])("V2b 存在しない日付 %s → 422", (_label, qs) => {
    expect422(parseDeviceLogQuery(params(qs)));
  });

  it.each([
    ["Z", "from=2026-10-19T02:30:00Z"],
    ["+00:00", "from=2026-10-19T02:30:00%2B00:00"],
  ])("V2c +09:00 以外のオフセット %s も通る", (_label, qs) => {
    expect(okQuery(parseDeviceLogQuery(params(qs))).from).toBe(Date.parse("2026-10-19T02:30:00Z"));
  });

  it("V3 from > to → 422", () => {
    expect422(
      parseDeviceLogQuery(params("from=2026-10-19T12:00:00%2B09:00&to=2026-10-19T11:00:00%2B09:00"))
    );
  });

  it("V3 from = to は通る", () => {
    const q = okQuery(
      parseDeviceLogQuery(params("from=2026-10-19T12:00:00%2B09:00&to=2026-10-19T03:00:00Z"))
    );
    expect(q.from).toBe(q.to);
  });

  it.each(["types=", "types=%20", "from=", "from=%20", "to=", "to=%20", "sort=", "sort=%20"])(
    "V4 %s は省略と同じ",
    (qs) => {
      expect(okQuery(parseDeviceLogQuery(params(qs)))).toEqual(V1_QUERY);
    }
  );

  it.each(["types=app,,sys", "types=app,"])("V4b %s → 422", (qs) => {
    expect422(parseDeviceLogQuery(params(qs)));
  });

  it("V5 sort=name → 422", () => {
    expect422(parseDeviceLogQuery(params("sort=name")));
  });

  it.each<[string, "page" | "perPage", number]>([
    ["per_page=30", "perPage", 30],
    ["per_page=500", "perPage", 200],
    ["per_page=0", "perPage", 1],
    ["per_page=abc", "perPage", 200],
    ["page=0", "page", 1],
    ["page=1.5", "page", 1],
    ["page=abc", "page", 1],
  ])("V6 %s は 422 にせず丸める", (qs, key, expected) => {
    expect(okQuery(parseDeviceLogQuery(params(qs)))[key]).toBe(expected);
  });
});

describe("parseExportRequest() — 段階3-3 V7〜V9", () => {
  it("正しい本体(file_ids 1件・EXPORT_MAX_FILES 件・8192文字)は通る", () => {
    expect(okQuery(parseExportRequest(JSON.stringify({ file_ids: ["a", "b"] })))).toEqual({
      fileIds: ["a", "b"],
    });
    const max = Array.from({ length: EXPORT_MAX_FILES }, (_, i) => `id-${i}`);
    expect(okQuery(parseExportRequest(JSON.stringify({ file_ids: max }))).fileIds).toHaveLength(
      EXPORT_MAX_FILES
    );
    const long = "x".repeat(8192);
    expect(okQuery(parseExportRequest(JSON.stringify({ file_ids: [long] }))).fileIds).toEqual([long]);
  });

  it.each<[string, unknown]>([
    ["空の配列", { file_ids: [] }],
    ["EXPORT_MAX_FILES + 1 件", { file_ids: Array.from({ length: EXPORT_MAX_FILES + 1 }, (_, i) => `id-${i}`) }],
    ["重複あり", { file_ids: ["a", "a"] }],
    ["要素が文字列でない", { file_ids: ["a", 1] }],
    ["8193文字", { file_ids: ["x".repeat(8193)] }],
    ["空文字の要素", { file_ids: [""] }],
    ["file_ids が無い", {}],
    ["file_ids が配列でない", { file_ids: "a" }],
  ])("V7 %s → 422", (_label, body) => {
    expect422(parseExportRequest(JSON.stringify(body)));
  });

  it("K7-1 file_ids の JSON がちょうど EXPORT_FILE_IDS_MAX_BYTES → 通る", () => {
    const fileIds = fileIdsOfJsonBytes(EXPORT_FILE_IDS_MAX_BYTES);
    expect(new TextEncoder().encode(JSON.stringify(fileIds)).length).toBe(EXPORT_FILE_IDS_MAX_BYTES);
    expect(okQuery(parseExportRequest(JSON.stringify({ file_ids: fileIds }))).fileIds).toEqual(fileIds);
  });

  it("K7-2 file_ids の JSON が EXPORT_FILE_IDS_MAX_BYTES + 1 → 422・多すぎます", () => {
    const fileIds = fileIdsOfJsonBytes(EXPORT_FILE_IDS_MAX_BYTES + 1);
    expect(new TextEncoder().encode(JSON.stringify(fileIds)).length).toBe(EXPORT_FILE_IDS_MAX_BYTES + 1);
    const result = parseExportRequest(JSON.stringify({ file_ids: fileIds }));
    expect422(result);
    if (!result.ok) {
      expect(result.error.error.message).toBe(EXPORT_FILE_IDS_TOO_LARGE_MESSAGE);
      expect(result.error.error.details?.file_ids).toHaveLength(1);
    }
  });

  it("K7-3 多バイト文字はバイトで数える(文字数では上限内でも 422)", () => {
    const fileIds = ["あ".repeat(8192), "い".repeat(8192)];
    expect(fileIds.join("").length).toBeLessThan(EXPORT_FILE_IDS_MAX_BYTES);
    expect(new TextEncoder().encode(JSON.stringify(fileIds)).length).toBeGreaterThan(EXPORT_FILE_IDS_MAX_BYTES);
    const result = parseExportRequest(JSON.stringify({ file_ids: fileIds }));
    expect422(result);
    if (!result.ok) expect(result.error.error.message).toBe(EXPORT_FILE_IDS_TOO_LARGE_MESSAGE);
  });

  it.each(["null", "[]", "1", '"x"'])("V8 オブジェクトでない JSON(%s)→ 422「JSONの形式が不正です」", (text) => {
    const result = parseExportRequest(text);
    expect422(result);
    if (!result.ok) expect(result.error.error.message).toBe("JSONの形式が不正です");
  });

  it("V9 JSON でない({)→ 422「JSONの形式が不正です」", () => {
    const result = parseExportRequest("{");
    expect422(result);
    if (!result.ok) expect(result.error.error.message).toBe("JSONの形式が不正です");
  });
});

describe("parseAgentPollWait() — 段階3-3 V10(表5 R8)", () => {
  it("省略は AGENT_POLL_WAIT_SEC(25)", () => {
    expect(okQuery(parseAgentPollWait(null))).toBe(AGENT_POLL_WAIT_SEC);
    expect(AGENT_POLL_WAIT_SEC).toBe(25);
  });

  it.each<[string, number]>([
    ["1", 1],
    ["25", 25],
  ])("wait=%s → %d", (value, expected) => {
    expect(okQuery(parseAgentPollWait(value))).toBe(expected);
  });

  it.each(["0", "26", "abc"])("V10 wait=%s → 422(丸めない)", (value) => {
    expect422(parseAgentPollWait(value));
  });
});

describe("パスパラメータ — 段階3-3 X1〜X6(M8)", () => {
  const idParsers = [
    ["export_id", parsePathExportId, PATH_EXPORT_ID_MAX_LENGTH],
    ["command_id", parsePathCommandId, PATH_COMMAND_ID_MAX_LENGTH],
  ] as const;

  it.each(idParsers)("X1 %s が randomUUID の形(36文字)なら通る", (_name, parse) => {
    const id = randomUUID();
    expect(okQuery(parse(id))).toBe(id);
  });

  it.each(idParsers)("X1 %s が上限ちょうどなら通る", (_name, parse, max) => {
    const id = "a".repeat(max);
    expect(okQuery(parse(id))).toBe(id);
  });

  it.each(idParsers)("X2 %s が空文字・上限 + 1 文字なら 422(入力値をエコーしない)", (_name, parse, max) => {
    expect422(parse(""));
    const tooLong = "Q".repeat(max + 1);
    const result = parse(tooLong);
    expect422(result);
    if (!result.ok) expect(JSON.stringify(result.error)).not.toContain(tooLong);
  });

  it.each(idParsers)("X3 %s=abc(形は違うが長さは範囲内)は通る", (_name, parse) => {
    expect(okQuery(parse("abc"))).toBe("abc");
  });

  it.each<[string, number]>([
    ["0", 0],
    ["1", 1],
    ["10", 10],
    ["123456789", 123456789],
  ])("X4 seq=%s → %d", (value, expected) => {
    expect(okQuery(parsePathSeq(value))).toBe(expected);
  });

  it.each(["-1", "01", "1.0", "abc", "1e3", "", " 1"])("X5 seq=%j → 422", (value) => {
    expect422(parsePathSeq(value));
  });

  it("X6 seq=1234567890(10桁)→ 422", () => {
    expect422(parsePathSeq("1234567890"));
  });
});

// ── 段階7-2 W1〜W11 ──

const W_TYPES = [
  { key: "app", label: "アプリ" },
  { key: "comm", label: "通信" },
  { key: "sys", label: "システム" },
];

const W_ITEMS = [
  { file_id: "f1", name: "app_20261019_1100.log", type: "app", size_bytes: 1000, modified_at: "2026-10-19T11:00:00+09:00" },
  { file_id: "f2", name: "app_20261019_0900.log", type: "app", size_bytes: 2000, modified_at: "2026-10-19T09:00:00+09:00" },
  { file_id: "f3", name: "comm_20261019_1100.log", type: "comm", size_bytes: 3000, modified_at: "2026-10-19T11:00:00+09:00" },
  { file_id: "f4", name: "comm_20261018_1200.log", type: "comm", size_bytes: 4000, modified_at: "2026-10-18T12:00:00+09:00" },
  { file_id: "f5", name: "sys_20261019_1130.log", type: "sys", size_bytes: 5000, modified_at: "2026-10-19T11:30:00+09:00" },
  { file_id: "f6", name: "sys_20261018_1159.log", type: "sys", size_bytes: 6000, modified_at: "2026-10-18T11:59:59+09:00" },
  { file_id: "f7", name: "app_20261019_0200.log", type: "app", size_bytes: 7000, modified_at: "2026-10-18T17:00:00Z" },
  { file_id: "f8", name: "app_20261019_1200.log", type: "app", size_bytes: 8000, modified_at: "2026-10-19T12:00:00+09:00" },
];

const W1_BODY = { types: W_TYPES, items: W_ITEMS, truncated: false };

function withItem(index: number, patch: Record<string, unknown>) {
  return { ...W1_BODY, items: W_ITEMS.map((item, i) => (i === index ? { ...item, ...patch } : item)) };
}

/** 壊した key を types[0] と items の type:"app" の両方に使う(指示書BK K1) */
function withTypeKey(bad: string) {
  return {
    ...W1_BODY,
    types: [{ key: bad, label: "x" }, ...W_TYPES.slice(1)],
    items: W_ITEMS.map((item) => (item.type === "app" ? { ...item, type: bad } : item)),
  };
}

function manyItems(count: number) {
  return {
    types: [{ key: "app", label: "app" }],
    items: Array.from({ length: count }, (_, i) => ({
      file_id: `f${i}`,
      name: `app/app.log.${i}`,
      type: "app",
      size_bytes: i,
      modified_at: "2026-10-19T12:00:00+09:00",
    })),
    truncated: true,
  };
}

const byteLength = (text: string) => new TextEncoder().encode(text).length;

/** 成功の形(`{ types, items, truncated }`)の items。失敗の報告(`{ error }`)なら落とす */
function listItems(result: QueryParseResult<DsAgentListResult>) {
  const query = okQuery(result);
  if ("error" in query) throw new Error("期待は一覧の結果");
  return query.items;
}

describe("parseAgentListResult() — 段階7-2 W1〜W11", () => {
  it("W1 正しい結果は受理(そのまま返す)", () => {
    expect(okQuery(parseAgentListResult(JSON.stringify(W1_BODY)))).toEqual(W1_BODY);
  });

  it("truncated: true もそのまま返す(表5 R38・D18)", () => {
    const body = { ...W1_BODY, truncated: true };
    expect(okQuery(parseAgentListResult(JSON.stringify(body)))).toEqual(body);
  });

  it.each<[string, unknown]>([
    ["W2 types の key が重複", { ...W1_BODY, types: [...W_TYPES, { key: "app", label: "重複" }] }],
    ["W3 items[].type が types に無い", withItem(0, { type: "xyz" })],
    ["W4 modified_at にオフセットが無い", withItem(0, { modified_at: "2026-10-19T11:00:00" })],
    ["W4b modified_at が存在しない日付", withItem(0, { modified_at: "2026-02-30T00:00:00+09:00" })],
    ["W5 size_bytes が負", withItem(0, { size_bytes: -1 })],
    ["W5 size_bytes が小数", withItem(0, { size_bytes: 1.5 })],
    ["W5 size_bytes が文字列", withItem(0, { size_bytes: "1000" })],
    ["W6 file_id が重複", withItem(1, { file_id: "f1" })],
    ["truncated が無い(必須の項目が無い。R43)", { types: W_TYPES, items: W_ITEMS }],
    ["types が配列でない(型が違う。R43)", { ...W1_BODY, types: "app" }],
    ["items[].name が文字列でない(型が違う。R43)", withItem(0, { name: 1 })],
  ])("%s → 400 result_invalid", (_label, body) => {
    expectResultInvalid(parseAgentListResult(JSON.stringify(body)));
  });

  it("W7 items が LIST_RESULT_MAX_ITEMS + 1 件 → 不正(ちょうどは受理)", () => {
    expect(listItems(parseAgentListResult(JSON.stringify(manyItems(LIST_RESULT_MAX_ITEMS))))).toHaveLength(
      LIST_RESULT_MAX_ITEMS
    );
    expectResultInvalid(parseAgentListResult(JSON.stringify(manyItems(LIST_RESULT_MAX_ITEMS + 1))));
  });

  it("W8 本体が LIST_RESULT_MAX_BYTES を超える → 不正(ちょうどは受理)", () => {
    // ★ASCII だけの本体★(文字数とバイト数が一致するので、どちらで数えても境界が同じになる)
    const body = JSON.stringify({ ...manyItems(3), truncated: false });
    const exact = body + " ".repeat(LIST_RESULT_MAX_BYTES - byteLength(body));
    expect(byteLength(exact)).toBe(LIST_RESULT_MAX_BYTES);
    expect(listItems(parseAgentListResult(exact))).toHaveLength(3);
    expectResultInvalid(parseAgentListResult(`${exact} `));
  });

  it("W9 { error: { code, message } } は失敗の報告として受理(failCommand と同じ扱い)", () => {
    const body = { error: { code: "io_error", message: "読めませんでした" } };
    expect(okQuery(parseAgentListResult(JSON.stringify(body)))).toEqual(body);
  });

  it.each<[string, unknown]>([
    ["code が空", { error: { code: "", message: "m" } }],
    ["code が65文字", { error: { code: "c".repeat(65), message: "m" } }],
    ["message が501文字", { error: { code: "io_error", message: "m".repeat(501) } }],
    ["message が無い", { error: { code: "io_error" } }],
  ])("W9 の error の形の誤り(%s)→ 不正", (_label, body) => {
    expectResultInvalid(parseAgentListResult(JSON.stringify(body)));
  });

  it.each(["null", "[]", "1", '"x"'])("W10 オブジェクトでない JSON(%s)→ 400 result_invalid", (text) => {
    expectResultInvalid(parseAgentListResult(text));
  });

  it("JSON でない本体(R43)→ 400 result_invalid", () => {
    expectResultInvalid(parseAgentListResult("{"));
  });

  it("W11 name に / を含む({root の name}/{相対パス})は受理", () => {
    const body = withItem(0, { name: "api_client/api_client.log.2026-10-18" });
    expect(okQuery(parseAgentListResult(JSON.stringify(body)))).toEqual(body);
  });

  it.each<[string, unknown]>([
    ["R45 key が空", withTypeKey("")],
    ["R46 key にカンマ", withTypeKey("a,b")],
    ["R46b key が空白だけ", withTypeKey(" ")],
    ["R46b key の先頭が空白", withTypeKey(" app")],
  ])("%s → 400 result_invalid・typesの形式が不正です(指示書BK K1)", (_label, body) => {
    expectResultInvalid(parseAgentListResult(JSON.stringify(body)), "typesの形式が不正です");
  });

  it.each<[string, unknown]>([
    ["R47 file_id が8193文字", withItem(0, { file_id: "x".repeat(8193) })],
  ])("%s → 400 result_invalid", (_label, body) => {
    expectResultInvalid(parseAgentListResult(JSON.stringify(body)));
  });

  it("R46b key の中の空白(a b)はよい", () => {
    const body = {
      types: [{ key: "a b", label: "x" }],
      items: [{ file_id: "f1", name: "n", type: "a b", size_bytes: 1, modified_at: "2026-10-19T11:00:00+09:00" }],
      truncated: false,
    };
    expect(okQuery(parseAgentListResult(JSON.stringify(body)))).toEqual(body);
  });

  it("R47 file_id が8192文字はよい", () => {
    const body = withItem(0, { file_id: "x".repeat(8192) });
    expect(listItems(parseAgentListResult(JSON.stringify(body)))[0].file_id).toHaveLength(8192);
  });
});

// ── prepared / complete / fail の本体(段階7-2 の末尾・表5 R43。形の誤りはすべて 400 result_invalid)──

const HEX64 = "a".repeat(64);

describe("parseAgentPrepared()", () => {
  it.each<[string, unknown]>([
    ["total_bytes=1・file_count=1(下限。指示書BJ R48)", { total_bytes: 1, sha256: HEX64, file_count: 1 }],
    ["一般的な値", { total_bytes: 9437184, sha256: "0123456789abcdef".repeat(4), file_count: 2 }],
  ])("%s は通る", (_label, body) => {
    expect(okQuery(parseAgentPrepared(JSON.stringify(body)))).toEqual(body);
  });

  it.each<[string, string]>([
    ["JSON でない", "{"],
    ["null", "null"],
    ["配列", "[]"],
    ["total_bytes が無い", JSON.stringify({ sha256: HEX64, file_count: 1 })],
    ["total_bytes が0(指示書BJ R48)", JSON.stringify({ total_bytes: 0, sha256: HEX64, file_count: 1 })],
    ["total_bytes が負", JSON.stringify({ total_bytes: -1, sha256: HEX64, file_count: 1 })],
    ["total_bytes が小数", JSON.stringify({ total_bytes: 1.5, sha256: HEX64, file_count: 1 })],
    ["total_bytes が文字列", JSON.stringify({ total_bytes: "1", sha256: HEX64, file_count: 1 })],
    ["sha256 が大文字", JSON.stringify({ total_bytes: 1, sha256: "A".repeat(64), file_count: 1 })],
    ["sha256 が63桁", JSON.stringify({ total_bytes: 1, sha256: "a".repeat(63), file_count: 1 })],
    ["file_count が0", JSON.stringify({ total_bytes: 1, sha256: HEX64, file_count: 0 })],
    ["file_count が無い", JSON.stringify({ total_bytes: 1, sha256: HEX64 })],
  ])("%s → 400 result_invalid", (_label, text) => {
    expectResultInvalid(parseAgentPrepared(text));
  });
});

describe("parseAgentComplete()", () => {
  it("正しい本体は通る", () => {
    const body = { total_bytes: 36, sha256: HEX64 };
    expect(okQuery(parseAgentComplete(JSON.stringify(body)))).toEqual(body);
  });

  it.each<[string, string]>([
    ["JSON でない", "{"],
    ["数値", "0"],
    ["sha256 が無い", JSON.stringify({ total_bytes: 1 })],
    ["total_bytes が負", JSON.stringify({ total_bytes: -1, sha256: HEX64 })],
    ["total_bytes が0(指示書BK K3)", JSON.stringify({ total_bytes: 0, sha256: HEX64 })],
    ["sha256 が16進でない", JSON.stringify({ total_bytes: 1, sha256: "g".repeat(64) })],
  ])("%s → 400 result_invalid", (_label, text) => {
    expectResultInvalid(parseAgentComplete(text));
  });
});

describe("parseAgentFail()", () => {
  it.each<[string, unknown]>([
    ["下限(1文字・1文字)", { code: "x", message: "m" }],
    ["上限(64文字・500文字)", { code: "c".repeat(64), message: "m".repeat(500) }],
    ["export_not_found(D25 ②)", { code: "export_not_found", message: "zip がありません" }],
  ])("%s は通る", (_label, body) => {
    expect(okQuery(parseAgentFail(JSON.stringify(body)))).toEqual(body);
  });

  it.each<[string, string]>([
    ["JSON でない", "{"],
    ["文字列", '"x"'],
    ["code が空", JSON.stringify({ code: "", message: "m" })],
    ["code が65文字", JSON.stringify({ code: "c".repeat(65), message: "m" })],
    ["message が空", JSON.stringify({ code: "x", message: "" })],
    ["message が501文字", JSON.stringify({ code: "x", message: "m".repeat(501) })],
    ["code が文字列でない", JSON.stringify({ code: 1, message: "m" })],
  ])("%s → 400 result_invalid", (_label, text) => {
    expectResultInvalid(parseAgentFail(text));
  });
});