Newer
Older
client_dwatch / lib / logger / __tests__ / api.test.ts
/**
 * 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);
  });
});