/**
* logApiError の実出力検証。
*
* 全APIルートの catch 節がこの関数を通るため、ここが無検証だと
* 「生の例外メッセージが error_message に載る」等の §3 違反に誰も気づけない。
* pino の出力を捕捉して、レスポンスの ApiError と1対1で一致することを固定する。
*/
import { describe, expect, test } from "vitest";
import { createLogger } from "@/lib/logger";
import { logApiErrorTo } from "@/lib/logger/api";
import { withRequestId } from "@/lib/logger/context";
import type { ApiError } from "@/lib/types";
/** pino の出力先を差し替えて1行 JSON を集める(smoke.test.ts と同じ手法) */
function captureLines(): { lines: string[]; stream: { write(s: string): void } } {
const lines: string[] = [];
return {
lines,
stream: {
write(s: string) {
lines.push(s);
},
},
};
}
function emitApiError(
params: {
route: string;
method: string;
body: ApiError;
status: number;
err: unknown;
},
wrap?: (fn: () => void) => void
): Record<string, unknown> {
const { lines, stream } = captureLines();
const target = createLogger(stream);
const call = () => logApiErrorTo(target, params);
if (wrap) {
wrap(call);
} else {
call();
}
expect(lines).toHaveLength(1);
return JSON.parse(lines[0]) as Record<string, unknown>;
}
/** クライアントへ返す定型文。ルート側と同じ組み立て方 */
function internalErrorBody(): ApiError {
return {
error: {
code: "internal_error",
message: "データの取得に失敗しました",
details: null,
},
};
}
describe("logApiError", () => {
test("ApiError と同じ error_code / error_message / http_status が出る", () => {
const body = internalErrorBody();
const out = emitApiError({
route: "/api/clean-logs",
method: "GET",
body,
status: 500,
err: new Error("boom"),
});
expect(out.event).toBe("api.error");
// レスポンスの body から読んでいること(ログ側で組み立て直していない)
expect(out.error_code).toBe(body.error.code);
expect(out.error_message).toBe(body.error.message);
expect(out.http_status).toBe(500);
});
// ★元は 501 / `not_implemented` のケースだった★(エリア別完了率の仮実装 D-034)。
// ★D-119 で本実装になり、501 を返す実装は0件になった★ため、
// ★別の code / status の組に置き換えてある(指示書AE 段階3-1 ③)★。
// ★上のテスト(500 / `internal_error`)と code も status も違うこと★が、
// 「ログ側で組み立て直していない」ことの検証になっている。
test("body の code / message を変えるとログも追随する(同一オブジェクト由来)", () => {
const body: ApiError = {
error: {
code: "not_found",
message: "指定された清掃ログが見つかりません",
details: null,
},
};
const out = emitApiError({
route: "/api/clean-logs/[id]/areas",
method: "GET",
body,
status: 404,
err: new Error("boom"),
});
expect(out.error_code).toBe("not_found");
expect(out.error_message).toBe("指定された清掃ログが見つかりません");
expect(out.http_status).toBe(404);
});
test("生の例外メッセージは error_message に入らない(§3)", () => {
const body = internalErrorBody();
const raw = "SELECT * FROM facilities WHERE name='実在店舗名'";
const out = emitApiError({
route: "/api/clean-logs",
method: "GET",
body,
status: 500,
err: new Error(raw),
});
expect(out.error_message).toBe("データの取得に失敗しました");
expect(String(out.error_message)).not.toContain("実在店舗名");
expect(String(out.error_message)).not.toContain("SELECT");
// err の message / stack は §10 の通りデバッグ用に維持する(error レベル限定)
const err = out.err as { message?: string; stack?: string };
expect(err.message).toBe(raw);
expect(err.stack).toBeTruthy();
});
test("route / method が載る", () => {
const out = emitApiError({
route: "/api/settings/advice",
method: "PATCH",
body: internalErrorBody(),
status: 500,
err: new Error("boom"),
});
expect(out.route).toBe("/api/settings/advice");
expect(out.method).toBe("PATCH");
});
test("withRequestId の内側で呼ぶと request_id が載る", () => {
const request = new Request("http://localhost/api/clean-logs", {
headers: { "x-request-id": "api-test-0001" },
});
const out = emitApiError(
{
route: "/api/clean-logs",
method: "GET",
body: internalErrorBody(),
status: 500,
err: new Error("boom"),
},
(fn) => withRequestId(request, fn)
);
expect(out.request_id).toBe("api-test-0001");
expect(out.data_source).toBe("fixtures");
});
test("ラップ外では request_id が '-' になる(キーは消えない)", () => {
const out = emitApiError({
route: "/api/clean-logs",
method: "GET",
body: internalErrorBody(),
status: 500,
err: new Error("boom"),
});
expect(out.request_id).toBe("-");
});
test("level は error、timestamp は ISO 8601 +09:00(ミリ秒付き)", () => {
const out = emitApiError({
route: "/api/clean-logs",
method: "GET",
body: internalErrorBody(),
status: 500,
err: new Error("boom"),
});
expect(out.level).toBe("error");
expect(out.timestamp).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\+09:00$/
);
});
test("例外の独自プロパティに認証情報があれば非本番では弾く", () => {
const err = new Error("boom") as Error & { meta: Record<string, unknown> };
err.meta = { authorization: "Bearer abc123" };
const { lines, stream } = captureLines();
const target = createLogger(stream);
expect(() =>
logApiErrorTo(target, {
route: "/api/clean-logs",
method: "GET",
body: internalErrorBody(),
status: 500,
err,
})
).toThrow(/authorization/);
expect(lines).toHaveLength(0);
});
});