Newer
Older
client_dwatch / lib / reports / __tests__ / block-visibility.test.ts
import { describe, expect, it } from "vitest";

import { ApiFetchError } from "@/lib/api/client";
import {
  customerIdCandidates,
  resolveReportBlocks,
  toFetchOutcome,
  type ReportFetchOutcome,
} from "@/lib/reports/block-visibility";
import { TARGET_CUSTOMER_REQUIRED_MESSAGE } from "@/lib/reports/target-customer";

/**
 * 指示書AL §3-5 の期待値表(レポート画面を1本ずつ出す)。
 *
 * | # | 状況 | 期待 |
 * |---|---|---|
 * | 2-1 | 4本すべて成功 | 今までどおり4ブロック |
 * | ★2-2★ | ★KPIだけ422★ | ★★残り3ブロックが出る★★+KPIのブロックに誘導 |
 * | 2-3 | KPIだけ500 | 残り3ブロックが出る+KPIのブロックにエラー |
 * | ★2-4★ | ★4本とも失敗★ | ★★画面全体のエラー(今までどおり)★★ |
 * | 2-5 | 施設別だけ失敗 | 残り3ブロックが出る |
 *
 * ★★2-2 と 2-4 の両方を入れてある★★(指示書AL §3-5 の明示指示)。
 *
 * ★画面(`components/`)のテストは走らない★ので、
 *   ここが「どのブロックを出すか」の唯一の固定点になる。
 */

const ok: ReportFetchOutcome<unknown> = { ok: true, value: {} };

/** 対象顧客が定まらない 422(`app/api/reports/kpi/route.ts` が返す実物と同じ形) */
function ambiguousCustomer(customerIds: number[]): ReportFetchOutcome<unknown> {
  return {
    ok: false,
    failure: {
      status: 422,
      serverMessage: TARGET_CUSTOMER_REQUIRED_MESSAGE,
      details: { customer_ids: customerIds },
    },
  };
}

function serverError(
  message = "データの取得に失敗しました"
): ReportFetchOutcome<unknown> {
  return {
    ok: false,
    failure: { status: 500, serverMessage: message, details: null },
  };
}

describe("resolveReportBlocks", () => {
  it("#2-1 4本すべて成功 → 4ブロックとも出す", () => {
    const view = resolveReportBlocks({
      kpi: ok,
      facilityPerformance: ok,
      continuousProblems: ok,
      errorBreakdown: ok,
    });

    expect(view.allFailed).toBe(false);
    expect(view.kpi.kind).toBe("ready");
    expect(view.facilityPerformance.kind).toBe("ready");
    expect(view.continuousProblems.kind).toBe("ready");
    expect(view.errorBreakdown.kind).toBe("ready");
  });

  it("★#2-2 KPIだけ422 → 残り3ブロックは出て、KPIは「顧客を選んでください」へ誘導する★", () => {
    const view = resolveReportBlocks({
      kpi: ambiguousCustomer([1, 2, 3]),
      facilityPerformance: ok,
      continuousProblems: ok,
      errorBreakdown: ok,
    });

    // ★画面全体のエラーにしないこと★(これが直したかった症状そのもの)
    expect(view.allFailed).toBe(false);
    expect(view.facilityPerformance.kind).toBe("ready");
    expect(view.continuousProblems.kind).toBe("ready");
    expect(view.errorBreakdown.kind).toBe("ready");

    expect(view.kpi.kind).toBe("select-customer");
    if (view.kpi.kind !== "select-customer") return;
    // ★候補を捨てないこと★ — 画面が「選べる屋号が3件ある」と言えるのはこの配列があるから
    expect(view.kpi.candidateCustomerIds).toEqual([1, 2, 3]);
    expect(view.kpi.message).toBe(TARGET_CUSTOMER_REQUIRED_MESSAGE);
  });

  it("#2-3 KPIだけ500 → 残り3ブロックは出て、KPIのブロックにエラーを出す", () => {
    const view = resolveReportBlocks({
      kpi: serverError(),
      facilityPerformance: ok,
      continuousProblems: ok,
      errorBreakdown: ok,
    });

    expect(view.allFailed).toBe(false);
    expect(view.kpi).toEqual({
      kind: "error",
      message: "データの取得に失敗しました",
    });
    expect(view.facilityPerformance.kind).toBe("ready");
    expect(view.continuousProblems.kind).toBe("ready");
    expect(view.errorBreakdown.kind).toBe("ready");
  });

  it("★#2-4 4本とも失敗 → 画面全体のエラー(空の枠を4つ並べない)★", () => {
    const view = resolveReportBlocks({
      kpi: serverError("KPIが読めません"),
      facilityPerformance: serverError(),
      continuousProblems: serverError(),
      errorBreakdown: serverError(),
    });

    expect(view.allFailed).toBe(true);
    // 1行に出す文言は先頭(=KPI)の文言。`Promise.all` だったころの見え方に寄せている
    expect(view.message).toBe("KPIが読めません");
  });

  it("#2-5 施設別だけ失敗 → 残り3ブロックは出る", () => {
    const view = resolveReportBlocks({
      kpi: ok,
      facilityPerformance: serverError("施設別が読めません"),
      continuousProblems: ok,
      errorBreakdown: ok,
    });

    expect(view.allFailed).toBe(false);
    expect(view.facilityPerformance).toEqual({
      kind: "error",
      message: "施設別が読めません",
    });
    expect(view.kpi.kind).toBe("ready");
    expect(view.continuousProblems.kind).toBe("ready");
    expect(view.errorBreakdown.kind).toBe("ready");
  });

  it("★KPI以外が422でも「顧客を選んでください」にはしない★(顧客が定まらない話はKPIだけ)", () => {
    const view = resolveReportBlocks({
      kpi: ok,
      facilityPerformance: ambiguousCustomer([1, 2]),
      continuousProblems: ok,
      errorBreakdown: ok,
    });

    expect(view.facilityPerformance.kind).toBe("error");
  });

  it("★KPIが422でもパラメータ不正(customer_ids が無い)なら普通のエラー★", () => {
    const view = resolveReportBlocks({
      kpi: {
        ok: false,
        failure: {
          status: 422,
          serverMessage: "weekの値が不正です",
          details: { week: ["period=weekly のときは1〜6で指定してください"] },
        },
      },
      facilityPerformance: ok,
      continuousProblems: ok,
      errorBreakdown: ok,
    });

    expect(view.kpi).toEqual({ kind: "error", message: "weekの値が不正です" });
  });

  it("★候補が0社(傘下0社の422)でも誘導にする★ — 「その条件では選べない」と言えるようにするため", () => {
    const view = resolveReportBlocks({
      kpi: ambiguousCustomer([]),
      facilityPerformance: ok,
      continuousProblems: ok,
      errorBreakdown: ok,
    });

    expect(view.kpi.kind).toBe("select-customer");
    if (view.kpi.kind !== "select-customer") return;
    expect(view.kpi.candidateCustomerIds).toEqual([]);
  });

  it("4本とも失敗し、サーバ文言が1つも読めなければ message は null(画面の既定文言に落とす)", () => {
    const offline: ReportFetchOutcome<unknown> = {
      ok: false,
      failure: { status: 0, serverMessage: null, details: null },
    };
    const view = resolveReportBlocks({
      kpi: offline,
      facilityPerformance: offline,
      continuousProblems: offline,
      errorBreakdown: offline,
    });

    expect(view.allFailed).toBe(true);
    expect(view.message).toBeNull();
  });
});

describe("customerIdCandidates", () => {
  it("数値の配列なら読む", () => {
    expect(customerIdCandidates({ customer_ids: [1, 2] })).toEqual([1, 2]);
  });

  it("★空配列は null ではなく [] を返す★(候補0社も答えのうち)", () => {
    expect(customerIdCandidates({ customer_ids: [] })).toEqual([]);
  });

  it("details が null なら null", () => {
    expect(customerIdCandidates(null)).toBeNull();
  });

  it("キーが無ければ null", () => {
    expect(customerIdCandidates({ week: ["1〜6で指定してください"] })).toBeNull();
  });

  it("★文字列の配列は拒む★ — パラメータ不正の422と取り違えないため", () => {
    expect(customerIdCandidates({ customer_ids: ["1", "2"] })).toBeNull();
  });

  it("配列でなければ null(CSVエクスポート422の `{ total, max }` 形と取り違えない)", () => {
    expect(customerIdCandidates({ total: 25000, max: 20000 })).toBeNull();
  });
});

describe("toFetchOutcome", () => {
  it("成功はそのまま値を持つ", () => {
    expect(toFetchOutcome({ status: "fulfilled", value: 42 })).toEqual({
      ok: true,
      value: 42,
    });
  });

  it("★ApiFetchError は status / 文言 / details を引き継ぐ★", () => {
    const outcome = toFetchOutcome({
      status: "rejected",
      reason: new ApiFetchError({
        status: 422,
        code: "validation_failed",
        serverMessage: TARGET_CUSTOMER_REQUIRED_MESSAGE,
        details: { customer_ids: [1, 2] },
      }),
    });

    expect(outcome.ok).toBe(false);
    if (outcome.ok) return;
    expect(outcome.failure.status).toBe(422);
    expect(outcome.failure.serverMessage).toBe(TARGET_CUSTOMER_REQUIRED_MESSAGE);
    expect(customerIdCandidates(outcome.failure.details)).toEqual([1, 2]);
  });

  it("★ApiFetchError 以外の例外も失敗として扱う★(1本の想定外で4本を巻き添えにしない)", () => {
    const outcome = toFetchOutcome({
      status: "rejected",
      reason: new TypeError("想定外"),
    });

    expect(outcome.ok).toBe(false);
    if (outcome.ok) return;
    // 0 は「レスポンスが無い」の意。★422 判定に混ざらない値★
    expect(outcome.failure.status).toBe(0);
    expect(outcome.failure.serverMessage).toBeNull();
  });
});