/**
* パスパラメータ検証(`lib/api/path-params.ts`)のテスト。
*
* ★期待値表を先に作ってから実装している★(`CLAUDE.md` §9.4 / 指示書AE 段階2-2-4)。
* ★`Number.parseInt` の前方一致で黙って通っていた入力(`"-5"` / `"12abc"` / `"1.9"` / `"1e9"`)を
* ここで固定する★ — 実装を「速いから」と `parseInt` に戻すと、このテストが落ちる。
*/
import { describe, expect, it } from "vitest";
import {
parsePathErrorId,
parsePathId,
parsePathReportId,
parsePathSerialNumber,
} from "@/lib/api/path-params";
import {
PATH_ERROR_ID_MAX_LENGTH,
PATH_REPORT_ID_MAX_LENGTH,
PATH_SERIAL_NUMBER_MAX_LENGTH,
} from "@/lib/config";
/** 失敗ケースの共通の期待: 422 / validation_failed */
function expectRejected(result: ReturnType<typeof parsePathId>) {
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(422);
expect(result.error.error.code).toBe("validation_failed");
}
describe("parsePathId", () => {
describe("通る入力", () => {
it.each([
["1", 1],
["538", 538],
// 上限ちょうど(安全整数の最大値)は通る。ここが落ちると境界の向きが逆
["9007199254740991", 9007199254740991],
])("%s は %i として通る", (input, expected) => {
const result = parsePathId(input);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.query).toBe(expected);
});
});
describe("422 で弾く入力", () => {
it.each([
["0", "IDは1以上(0は存在しない)"],
["-5", "負数。parseInt は -5 として通してしまう"],
["1.9", "非整数。parseInt は 1 に切り捨てて通してしまう"],
["12abc", "ゴミ付き。parseInt は 12 として通してしまう"],
["1e9", "指数表記。parseInt は 1 として通してしまう"],
["", "空文字"],
["9007199254740993", "安全整数超(Number() で桁が落ちる)"],
["1", "全角数字(JS の \\d は ASCII のみ)"],
[" 1", "前に空白。トリムして受け入れない"],
["1 ", "後ろに空白。トリムして受け入れない"],
["1\n", "末尾改行。$ は m フラグ無しなので許さない"],
["+1", "符号付き"],
["0x10", "16進表記"],
["abc", "数字を含まない"],
])("%s は422(%s)", (input) => {
expectRejected(parsePathId(input));
});
});
it("エラー文言と details に入力値をエコーしない", () => {
const attack = "99999999-echo-me";
const result = parsePathId(attack);
expect(result.ok).toBe(false);
if (result.ok) return;
const serialized = JSON.stringify(result.error);
expect(serialized).not.toContain(attack);
expect(serialized).not.toContain("echo-me");
});
it("details は details キーを落とさない(API契約上 details は常に存在する)", () => {
const result = parsePathId("0");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.error.details).not.toBeNull();
});
});
describe("parsePathSerialNumber", () => {
it("通常のシリアルは通る(fixtures 形式・YAML の example 形式の両方)", () => {
for (const input of ["8111R5628060011", "DS-001"]) {
const result = parsePathSerialNumber(input);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.query).toBe(input);
}
});
it("上限ちょうどの長さは通る", () => {
const input = "A".repeat(PATH_SERIAL_NUMBER_MAX_LENGTH);
const result = parsePathSerialNumber(input);
expect(result.ok).toBe(true);
});
it("上限+1 の長さは422", () => {
const result = parsePathSerialNumber("A".repeat(PATH_SERIAL_NUMBER_MAX_LENGTH + 1));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(422);
expect(result.error.error.code).toBe("validation_failed");
});
it("空文字は422", () => {
const result = parsePathSerialNumber("");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(422);
});
it("★文字種は絞らない★ — 記号や日本語を含んでいても長さ以内なら通る", () => {
// 実データの形式は TeamWorks 側で決まり、DFA側で未確定(CLAUDE.md §7)。
// ここが落ちるように「英数字のみ」等を足すと、未知の形式を推測で弾くことになる
for (const input of ["DS_001", "ds-001", "制御盤1", "8111R5628060011/"]) {
expect(parsePathSerialNumber(input).ok).toBe(true);
}
});
it("エラー文言と details に入力値をエコーしない", () => {
const attack = `${"A".repeat(PATH_SERIAL_NUMBER_MAX_LENGTH)}echo-me`;
const result = parsePathSerialNumber(attack);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(JSON.stringify(result.error)).not.toContain("echo-me");
});
});
/**
* `parsePathErrorId()`(指示書AT 段階1)— `robot_error_reports.error_id` は `varchar(50)`。
* ★`parsePathSerialNumber()` と同じ作法★(空文字と上限超だけ 422、文字種は絞らない)。
*/
describe("parsePathErrorId", () => {
it("上限は varchar(50) と同じ 50", () => {
expect(PATH_ERROR_ID_MAX_LENGTH).toBe(50);
});
it("50文字ちょうどは通る(値はそのまま。Number() を通さない)", () => {
const input = "A".repeat(50);
const result = parsePathErrorId(input);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.query).toBe(input);
});
it("51文字は422", () => {
const result = parsePathErrorId("A".repeat(51));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(422);
expect(result.error.error.code).toBe("validation_failed");
});
it("空文字は422", () => {
const result = parsePathErrorId("");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(422);
});
it("★文字種は絞らない★ — 記号・日本語・数字だけの値も長さ以内なら通る", () => {
for (const input of ["RER-1001-1", "1", "007", "err_01/x", "エラー1", " 1"]) {
const result = parsePathErrorId(input);
expect(result.ok).toBe(true);
if (!result.ok) return;
// ★数値化しない★("007" が "7" にならない)
expect(result.query).toBe(input);
}
});
it("エラー文言と details に入力値をエコーしない", () => {
const attack = `${"A".repeat(50)}echo-me`;
const result = parsePathErrorId(attack);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(JSON.stringify(result.error)).not.toContain("echo-me");
});
});
/** 表P — `parsePathReportId()`(指示書BI §4) */
describe("parsePathReportId", () => {
const details422 = { report_id: [`${PATH_REPORT_ID_MAX_LENGTH}文字以内で指定してください`] };
it("P-1: 空文字は422(details を丸ごと)", () => {
const result = parsePathReportId("");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(422);
expect(result.error.error.details).toEqual(details422);
});
it("P-2: 19桁の数字文字列はそのまま通る", () => {
const input = "9200000000000000002";
const result = parsePathReportId(input);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.query).toBe(input);
});
it("P-3: 20文字は通る", () => {
const input = "1".repeat(20);
const result = parsePathReportId(input);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.query).toBe(input);
});
it("P-4: 21文字は422(本文に入力値が出ない)", () => {
const input = "1".repeat(21);
const result = parsePathReportId(input);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(422);
expect(result.error.error.code).toBe("validation_failed");
expect(result.error.error.details).toEqual(details422);
expect(JSON.stringify(result.error)).not.toContain(input);
});
it("P-5: abc は通る(文字種は絞らない)", () => {
const result = parsePathReportId("abc");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.query).toBe("abc");
});
});