import { describe, expect, it } from "vitest";
import {
filterDsAlertsByStatus,
filterDsAlertsByType,
parseDsAlertQuery,
sortDsAlerts,
summarizeDsAlerts,
} from "@/lib/ds-alerts/query";
import type { DsAlert } from "@/lib/types";
function row(overrides: Partial<DsAlert>): DsAlert {
return {
id: "1",
alert_type: "security_off",
device_serial: "DS-001",
facility_id: 1,
facility_name: "メガバリューマート中央本店",
section_name: "1F売り場",
detected_at: "2026-08-27T03:42:00+09:00",
resolution_status: "unresolved",
responded_at: null,
resolved_at: null,
note: null,
...overrides,
};
}
describe("parseDsAlertQuery", () => {
it("省略時は type=all, status=open になる", () => {
const parsed = parseDsAlertQuery(new URLSearchParams());
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.type).toBe("all");
expect(parsed.query.status).toBe("open");
});
it("typeが不正な値なら422", () => {
const parsed = parseDsAlertQuery(new URLSearchParams({ type: "bogus" }));
expect(parsed.ok).toBe(false);
if (!parsed.ok) {
expect(parsed.status).toBe(422);
expect(parsed.error.error.code).toBe("validation_failed");
}
});
it("statusが不正な値なら422", () => {
const parsed = parseDsAlertQuery(new URLSearchParams({ status: "bogus" }));
expect(parsed.ok).toBe(false);
if (!parsed.ok) {
expect(parsed.status).toBe(422);
expect(parsed.error.error.code).toBe("validation_failed");
}
});
// ★どのパラメータが不正か呼び出し側に伝わること(details)を固定する★
it("type不正のdetailsは選択肢を列挙する", () => {
const parsed = parseDsAlertQuery(new URLSearchParams({ type: "bogus" }));
expect(parsed.ok).toBe(false);
if (!parsed.ok) {
expect(parsed.error.error.message).toBe("typeの値が不正です");
expect(parsed.error.error.details).toEqual({
type: ["all / security_off / disconnected のいずれかを指定してください"],
});
}
});
it("status不正のdetailsは選択肢を列挙する", () => {
const parsed = parseDsAlertQuery(new URLSearchParams({ status: "bogus" }));
expect(parsed.ok).toBe(false);
if (!parsed.ok) {
expect(parsed.error.error.message).toBe("statusの値が不正です");
expect(parsed.error.error.details).toEqual({
status: ["open / all / resolved のいずれかを指定してください"],
});
}
});
// ★指示書AX 表2 A2・A4(D10): 旧 `done` は別名で受けない(黙って倒さない)★
it("status=resolved は通る", () => {
const parsed = parseDsAlertQuery(new URLSearchParams({ status: "resolved" }));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.status).toBe("resolved");
});
it("★A4: status=done(旧の値)は422★", () => {
const parsed = parseDsAlertQuery(new URLSearchParams({ status: "done" }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("422を期待");
expect(parsed.status).toBe(422);
expect(parsed.error.error.details).toEqual({
status: ["open / all / resolved のいずれかを指定してください"],
});
});
it("★A5: type=security_off は422にしない(enum は残す。D4)★", () => {
expect(parseDsAlertQuery(new URLSearchParams({ type: "security_off" })).ok).toBe(true);
});
/**
* ★group_id / customer_id(指示書AK 段階2)★
*
* ★★元は「渡してもパース結果に含まれない(D-053)」だったテストを反転させたもの★★ —
* ★`it` ごと消さないこと★。1本も残っていないと、次に誰かが「受け取って捨てる」
* 実装に戻しても気づけない(指示書AK 落とし穴8)。
*
* | # | 入力 | 期待 |
* |---|---|---|
* | 2-1 | `group_id=1` | `1`(★`toHaveProperty` ではなく値まで見る★) |
* | 2-2 | `customer_id=2` | `2` |
* | 2-3 | 未指定 | ★`null`★(絞らない) |
* | 2-4 | `group_id=0` | ★422★(0 は「グループなし」のセンチネル。有効なIDではない。D-011) |
* | 2-5 | `group_id=1abc` | ★422★(★`parseInt` の前方一致で `1` にしない★。D-123 で実際に踏んだ罠) |
* | 2-6 | `group_id=-1` | 422 |
* | 2-7 | `group_id=1.5` | 422 |
* | 2-8 | `9007199254740992`(安全整数超) | 422 |
*/
it("group_id / customer_id を値として読む(D-053 解除。指示書AK 段階2)", () => {
const parsed = parseDsAlertQuery(new URLSearchParams({ group_id: "1", customer_id: "2" }));
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.groupId).toBe(1);
expect(parsed.query.customerId).toBe(2);
});
it("group_id / customer_id 未指定は null(絞らない)", () => {
const parsed = parseDsAlertQuery(new URLSearchParams());
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.groupId).toBeNull();
expect(parsed.query.customerId).toBeNull();
});
it.each(["0", "1abc", "-1", "1.5", "9007199254740992"])(
"group_id=%s は422",
(value) => {
const parsed = parseDsAlertQuery(new URLSearchParams({ group_id: value }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("422を期待");
expect(parsed.status).toBe(422);
expect(parsed.error.error.details).toEqual({
group_id: ["1以上の整数で指定してください"],
});
}
);
it.each(["0", "1abc"])("customer_id=%s も同じ規則で422", (value) => {
const parsed = parseDsAlertQuery(new URLSearchParams({ customer_id: value }));
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("422を期待");
expect(parsed.status).toBe(422);
expect(parsed.error.error.details).toEqual({
customer_id: ["1以上の整数で指定してください"],
});
});
it("page/per_pageの既定値と上限", () => {
const parsed = parseDsAlertQuery(new URLSearchParams());
expect(parsed.ok).toBe(true);
if (!parsed.ok) throw new Error("query");
expect(parsed.query.page).toBe(1);
expect(parsed.query.perPage).toBe(50);
const capped = parseDsAlertQuery(new URLSearchParams({ per_page: "9999" }));
expect(capped.ok).toBe(true);
if (!capped.ok) throw new Error("query");
expect(capped.query.perPage).toBe(200);
});
});
describe("filterDsAlertsByType", () => {
const items: DsAlert[] = [
row({ id: "1", alert_type: "security_off" }),
row({ id: "2", alert_type: "disconnected" }),
];
it("type=all は全件保持する", () => {
expect(filterDsAlertsByType(items, "all")).toHaveLength(2);
});
it("type=security_off で絞り込む", () => {
expect(filterDsAlertsByType(items, "security_off").map((a) => a.id)).toEqual(["1"]);
});
it("type=disconnected で絞り込む", () => {
expect(filterDsAlertsByType(items, "disconnected").map((a) => a.id)).toEqual(["2"]);
});
});
describe("filterDsAlertsByStatus(指示書AX D10)", () => {
const items: DsAlert[] = [
row({ id: "1", resolution_status: "unresolved" }),
row({ id: "2", resolution_status: "in_progress" }),
row({ id: "3", resolution_status: "resolved" }),
];
it("status=open は未解消+対応中(解消以外)", () => {
expect(filterDsAlertsByStatus(items, "open").map((a) => a.id)).toEqual(["1", "2"]);
});
it("status=resolved は解消のみ", () => {
expect(filterDsAlertsByStatus(items, "resolved").map((a) => a.id)).toEqual(["3"]);
});
it("status=all は全件保持する", () => {
expect(filterDsAlertsByStatus(items, "all")).toHaveLength(3);
});
it("type=disconnected & status=resolved で解消が無ければ0件", () => {
const disconnectedItems: DsAlert[] = [
row({ id: "1", alert_type: "disconnected", resolution_status: "unresolved" }),
];
const filtered = filterDsAlertsByStatus(
filterDsAlertsByType(disconnectedItems, "disconnected"),
"resolved"
);
expect(filtered).toHaveLength(0);
});
});
describe("sortDsAlerts", () => {
it("detected_atの降順になる", () => {
const items: DsAlert[] = [
row({ id: "1", detected_at: "2026-08-26T03:00:00+09:00" }),
row({ id: "2", detected_at: "2026-08-27T03:00:00+09:00" }),
row({ id: "3", detected_at: "2026-08-25T03:00:00+09:00" }),
];
expect(sortDsAlerts(items).map((a) => a.id)).toEqual(["2", "1", "3"]);
});
it("detected_atが同値のときはidの昇順で安定させる(D-052と同じ規則)", () => {
const items: DsAlert[] = [
row({ id: "3", detected_at: "2026-08-27T03:00:00+09:00" }),
row({ id: "1", detected_at: "2026-08-27T03:00:00+09:00" }),
row({ id: "2", detected_at: "2026-08-27T03:00:00+09:00" }),
];
expect(sortDsAlerts(items).map((a) => a.id)).toEqual(["1", "2", "3"]);
});
it("★同値のときの id は文字列の辞書順(\"10\" が \"9\" より先。数値順なら逆。指示書AX D11・表1 R12)★", () => {
const items: DsAlert[] = [
row({ id: "9", detected_at: "2026-08-27T03:00:00+09:00" }),
row({ id: "10", detected_at: "2026-08-27T03:00:00+09:00" }),
row({ id: "E-9", detected_at: "2026-08-27T03:00:00+09:00" }),
row({ id: "E-10", detected_at: "2026-08-27T03:00:00+09:00" }),
];
expect(sortDsAlerts(items).map((a) => a.id)).toEqual(["10", "9", "E-10", "E-9"]);
});
it("★入力の配列を書き換えない★", () => {
const items: DsAlert[] = [row({ id: "2" }), row({ id: "1" })];
sortDsAlerts(items);
expect(items.map((a) => a.id)).toEqual(["2", "1"]);
});
});
describe("summarizeDsAlerts(キーは unresolved / in_progress / resolved。指示書AX D10)", () => {
it("unresolved/in_progress/resolvedをそれぞれ数える", () => {
const items: DsAlert[] = [
row({ id: "1", resolution_status: "unresolved" }),
row({ id: "2", resolution_status: "unresolved" }),
row({ id: "3", resolution_status: "in_progress" }),
row({ id: "4", resolution_status: "resolved" }),
];
expect(summarizeDsAlerts(items)).toEqual({ unresolved: 2, in_progress: 1, resolved: 1 });
});
it("0件でも壊れない", () => {
expect(summarizeDsAlerts([])).toEqual({ unresolved: 0, in_progress: 0, resolved: 0 });
});
});
describe("summaryはtype/statusフィルタから独立している(D-054と同じ規則)", () => {
const items: DsAlert[] = [
row({ id: "1", alert_type: "security_off", resolution_status: "unresolved" }),
row({ id: "2", alert_type: "disconnected", resolution_status: "in_progress" }),
row({ id: "3", alert_type: "disconnected", resolution_status: "resolved" }),
];
it("typeやstatusを変えてもsummaryは同一(全アラートに対する集計)", () => {
// route.ts と同じ組み立て順: summaryは全件(フィルタ前)に対して計算する
const summaryBase = summarizeDsAlerts(items);
const summaryAfterType = summarizeDsAlerts(items);
const summaryAfterStatus = summarizeDsAlerts(items);
expect(summaryBase).toEqual({ unresolved: 1, in_progress: 1, resolved: 1 });
expect(summaryBase).toEqual(summaryAfterType);
expect(summaryBase).toEqual(summaryAfterStatus);
// 一覧側はtype/statusで変わる
expect(filterDsAlertsByType(items, "security_off")).toHaveLength(1);
expect(filterDsAlertsByStatus(items, "resolved")).toHaveLength(1);
});
});