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: "pending",
responded_at: null,
resolved_at: null,
memo: 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 / done のいずれかを指定してください"],
});
}
});
/**
* ★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", () => {
const items: DsAlert[] = [
row({ id: 1, resolution_status: "pending" }),
row({ id: 2, resolution_status: "in_progress" }),
row({ id: 3, resolution_status: "done" }),
];
it("status=open は未対応+対応中(対応済み以外)", () => {
expect(filterDsAlertsByStatus(items, "open").map((a) => a.id)).toEqual([1, 2]);
});
it("status=done は対応済みのみ", () => {
expect(filterDsAlertsByStatus(items, "done").map((a) => a.id)).toEqual([3]);
});
it("status=all は全件保持する", () => {
expect(filterDsAlertsByStatus(items, "all")).toHaveLength(3);
});
it("type=disconnected & status=done で0件(docs/fixtures.mdの再現条件)", () => {
const disconnectedItems: DsAlert[] = [
row({ id: 1, alert_type: "disconnected", resolution_status: "pending" }),
];
const filtered = filterDsAlertsByStatus(
filterDsAlertsByType(disconnectedItems, "disconnected"),
"done"
);
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]);
});
});
describe("summarizeDsAlerts", () => {
it("pending/in_progress/doneをそれぞれ数える", () => {
const items: DsAlert[] = [
row({ id: 1, resolution_status: "pending" }),
row({ id: 2, resolution_status: "pending" }),
row({ id: 3, resolution_status: "in_progress" }),
row({ id: 4, resolution_status: "done" }),
];
expect(summarizeDsAlerts(items)).toEqual({ pending: 2, in_progress: 1, done: 1 });
});
it("0件でも壊れない", () => {
expect(summarizeDsAlerts([])).toEqual({ pending: 0, in_progress: 0, done: 0 });
});
});
describe("summaryはtype/statusフィルタから独立している(D-054と同じ規則)", () => {
const items: DsAlert[] = [
row({ id: 1, alert_type: "security_off", resolution_status: "pending" }),
row({ id: 2, alert_type: "disconnected", resolution_status: "in_progress" }),
row({ id: 3, alert_type: "disconnected", resolution_status: "done" }),
];
it("typeやstatusを変えてもsummaryは同一(全アラートに対する集計)", () => {
// route.ts と同じ組み立て順: summaryは全件(フィルタ前)に対して計算する
const summaryBase = summarizeDsAlerts(items);
const summaryAfterType = summarizeDsAlerts(items);
const summaryAfterStatus = summarizeDsAlerts(items);
expect(summaryBase).toEqual({ pending: 1, in_progress: 1, done: 1 });
expect(summaryBase).toEqual(summaryAfterType);
expect(summaryBase).toEqual(summaryAfterStatus);
// 一覧側はtype/statusで変わる
expect(filterDsAlertsByType(items, "security_off")).toHaveLength(1);
expect(filterDsAlertsByStatus(items, "done")).toHaveLength(1);
});
});