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();
});
});