08495bc365
Co-Authored-By: Claude Code <noreply@anthropic.com>
466 lines
21 KiB
TypeScript
466 lines
21 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||
import { Transport } from "../src/transport";
|
||
import { mergeConfig } from "../src/config";
|
||
import { SDK_VERSION, SCHEMA_VERSION, BUILD_ID } from "../src/types";
|
||
import { getVisitorId, getSessionId } from "../src/identity";
|
||
|
||
// 快照信封验证(T8):identity 包一层 spy(委托真实实现,其余用例行为不变),
|
||
// 断言"全队列带归属字段的 flush 不再读身份"(信封归属=事件顶层快照)。
|
||
vi.mock("../src/identity", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("../src/identity")>();
|
||
return {
|
||
...actual,
|
||
getVisitorId: vi.fn(actual.getVisitorId),
|
||
getSessionId: vi.fn(actual.getSessionId),
|
||
};
|
||
});
|
||
|
||
// uuid v4 格式(crypto.randomUUID 或 Math.random 兜底均为该形状)
|
||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||
|
||
/** 可选 data:成功响应的 T3 契约体 {accepted,duplicated,rejected,request_id,server_time} */
|
||
const res = (status: number, data?: unknown) =>
|
||
({ ok: status < 400, status, json: () => Promise.resolve(data ?? null) }) as unknown as Response;
|
||
|
||
// 与 mergeConfig 默认值无关的测试专用 cfg(各用例按需覆盖)
|
||
function mkCfg(over: Record<string, unknown> = {}) {
|
||
return {
|
||
siteId: "site1",
|
||
endpoint: "/api/v1/collect",
|
||
batchSize: 3,
|
||
flushMs: 15000,
|
||
maxRetry: 3,
|
||
queueMax: 50,
|
||
...over,
|
||
};
|
||
}
|
||
|
||
let fetchMock: ReturnType<typeof vi.fn>;
|
||
|
||
describe("transport", () => {
|
||
beforeEach(() => {
|
||
localStorage.clear(); // identity 持久化隔离
|
||
vi.useFakeTimers(); // 默认连 Date 一起 fake
|
||
fetchMock = vi.fn().mockResolvedValue(res(200));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
vi.mocked(getVisitorId).mockClear(); // identity spy 调用记录按用例隔离
|
||
vi.mocked(getSessionId).mockClear();
|
||
});
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals(); // 还原 fetch / navigator 桩
|
||
vi.useRealTimers();
|
||
delete (globalThis as { __ctk_ctx?: unknown }).__ctk_ctx;
|
||
});
|
||
|
||
it("满 batchSize 即发:body 含 batch_id/site_id/visitor_id/session_id/sent_at/events", () => {
|
||
const t = new Transport(mkCfg({ batchSize: 3 }));
|
||
t.push({ event_id: "e1" });
|
||
t.push({ event_id: "e2" });
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
t.push({ event_id: "e3" });
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
|
||
const [url, init] = fetchMock.mock.calls[0];
|
||
expect(url).toBe("/api/v1/collect");
|
||
expect(init.method).toBe("POST");
|
||
expect(init.keepalive).toBe(true);
|
||
const body = JSON.parse(init.body);
|
||
expect(body.batch_id).toMatch(UUID_RE);
|
||
expect(body.site_id).toBe("site1");
|
||
expect(body.visitor_id).toMatch(UUID_RE);
|
||
expect(body.session_id).toMatch(UUID_RE);
|
||
expect(body.sent_at).toBe(new Date().toISOString()); // fake Date 下两处取值一致
|
||
expect(body.sent_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||
expect(body.events.map((e: { event_id: string }) => e.event_id)).toEqual([
|
||
"e1",
|
||
"e2",
|
||
"e3",
|
||
]);
|
||
expect("context" in body).toBe(false); // __ctk_ctx 未注入时序列化后无 context 键
|
||
});
|
||
|
||
it("不足 batchSize 时由 flushMs 定时器触发", async () => {
|
||
const t = new Transport(mkCfg({ batchSize: 10 }));
|
||
t.push({ event_id: "e1" });
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
await vi.advanceTimersByTimeAsync(14999);
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
await vi.advanceTimersByTimeAsync(1);
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(JSON.parse(fetchMock.mock.calls[0][1].body).events).toHaveLength(1);
|
||
});
|
||
|
||
it("context 透传:__ctk_ctx 原样进 body.context", () => {
|
||
const t = new Transport(mkCfg({ batchSize: 1 }));
|
||
(globalThis as { __ctk_ctx?: unknown }).__ctk_ctx = { device_type: "PC" };
|
||
t.push({ event_id: "e1" });
|
||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||
expect(body.context).toEqual({ device_type: "PC" }); // 引用透传而非拷贝拼接
|
||
});
|
||
|
||
it("500 失败退避重试后成功:body 逐字节不变(同 event_id 重放)", async () => {
|
||
fetchMock.mockResolvedValueOnce(res(500)).mockResolvedValueOnce(res(200));
|
||
const t = new Transport(mkCfg({ batchSize: 2 }));
|
||
t.push({ event_id: "e1" });
|
||
t.push({ event_id: "e2" });
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
|
||
await vi.advanceTimersByTimeAsync(2000); // 第 1 次退避 500*2^1*jitter ∈ [800,1200]
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(fetchMock.mock.calls[1][1].body).toBe(fetchMock.mock.calls[0][1].body);
|
||
});
|
||
|
||
it("400 不重试(参数错,重试无意义),事件落 dropped 桶(四桶对账无幽灵丢失)", async () => {
|
||
fetchMock.mockResolvedValueOnce(res(400));
|
||
const t = new Transport(mkCfg({ batchSize: 1 }));
|
||
t.push({ event_id: "e1" });
|
||
await vi.advanceTimersByTimeAsync(10000);
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(t.dropped).toBe(1); // M1:4xx 不重试但必须计丢弃
|
||
});
|
||
|
||
it("403 不重试(权限错,重试只会掩盖配置问题),事件落 dropped 桶", async () => {
|
||
fetchMock.mockResolvedValueOnce(res(403));
|
||
const t = new Transport(mkCfg({ batchSize: 1 }));
|
||
t.push({ event_id: "e1" });
|
||
await vi.advanceTimersByTimeAsync(10000);
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(t.dropped).toBe(1);
|
||
});
|
||
|
||
it("429 要重试(限流是暂态)", async () => {
|
||
fetchMock.mockResolvedValueOnce(res(429)).mockResolvedValueOnce(res(200));
|
||
const t = new Transport(mkCfg({ batchSize: 1 }));
|
||
t.push({ event_id: "e1" });
|
||
await vi.advanceTimersByTimeAsync(2000);
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(t.dropped).toBe(0);
|
||
});
|
||
|
||
it("重试耗尽按本批事件数计丢弃", async () => {
|
||
fetchMock.mockResolvedValue(res(500)); // 恒 500
|
||
const t = new Transport(mkCfg({ batchSize: 2, maxRetry: 1 }));
|
||
t.push({ event_id: "e1" });
|
||
t.push({ event_id: "e2" });
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
|
||
await vi.advanceTimersByTimeAsync(2000); // 第 1 次重试(∈[800,1200])后再失败
|
||
expect(fetchMock).toHaveBeenCalledTimes(2); // 初发 1 + 重试 1,共 2 次
|
||
expect(t.dropped).toBe(2); // 按入批事件数计,而非按批计
|
||
});
|
||
|
||
it("queueMax 溢出丢最旧", () => {
|
||
const t = new Transport(mkCfg({ batchSize: 100, queueMax: 3, flushMs: 60000 }));
|
||
["e1", "e2", "e3", "e4"].forEach((id) => t.push({ event_id: id }));
|
||
expect(t.dropped).toBe(1);
|
||
t.flush();
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||
expect(body.events.map((e: { event_id: string }) => e.event_id)).toEqual([
|
||
"e2",
|
||
"e3",
|
||
"e4",
|
||
]); // 留最新 3 条
|
||
});
|
||
|
||
it("flush(true) 走 sendBeacon(Blob/text/plain),不调 fetch", async () => {
|
||
// happy-dom 的 sendBeacon 会发真实网络请求,必须 stubGlobal 替换 navigator(不能只 spy)
|
||
const beacon = vi.fn().mockReturnValue(true);
|
||
vi.stubGlobal("navigator", { sendBeacon: beacon });
|
||
const t = new Transport(mkCfg({ batchSize: 10 }));
|
||
t.push({ event_id: "e1" });
|
||
t.flush(true);
|
||
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
expect(beacon).toHaveBeenCalledTimes(1);
|
||
const [url, blob] = beacon.mock.calls[0];
|
||
expect(url).toBe("/api/v1/collect");
|
||
expect(blob).toBeInstanceOf(Blob);
|
||
expect(blob.type).toBe("text/plain");
|
||
expect(JSON.parse(await blob.text()).events).toEqual([{ event_id: "e1" }]);
|
||
});
|
||
|
||
it("fetch 同步抛异常不冒泡到 push 调用方", async () => {
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(() => {
|
||
throw new Error("network gone");
|
||
})
|
||
);
|
||
const t = new Transport(mkCfg({ batchSize: 1, maxRetry: 0 }));
|
||
expect(() => t.push({ event_id: "e1" })).not.toThrow(); // try/catch 全包红线
|
||
await vi.advanceTimersByTimeAsync(100);
|
||
expect(t.dropped).toBe(1); // maxRetry=0 耗尽 → 按事件数丢弃
|
||
});
|
||
|
||
it("sendBeacon 返回 false(队列满/载荷超限)按本批事件数计 dropped", () => {
|
||
vi.stubGlobal("navigator", { sendBeacon: vi.fn().mockReturnValue(false) });
|
||
const t = new Transport(mkCfg({ batchSize: 10 }));
|
||
t.push({ event_id: "e1" });
|
||
t.push({ event_id: "e2" });
|
||
t.flush(true);
|
||
expect(t.dropped).toBe(2); // 信标被浏览器拒收(64KB 上限等)→ 整批丢
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("stringify 抛错(track 引入宿主可控对象):已换出批次按事件数计 dropped", () => {
|
||
const t = new Transport(mkCfg({ batchSize: 10, flushMs: 60000 }));
|
||
const bad: { event_id: string; self?: unknown } = { event_id: "bad" };
|
||
bad.self = bad; // 循环引用 → JSON.stringify 抛 TypeError
|
||
t.push(bad);
|
||
t.push({ event_id: "ok" });
|
||
expect(() => t.flush()).not.toThrow();
|
||
expect(t.dropped).toBe(2); // 换出的 2 条全部落丢,不能静默不计
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
|
||
t.push({ event_id: "e3" }); // 队列已换出,后续事件照常
|
||
t.flush();
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(JSON.parse(fetchMock.mock.calls[0][1].body).events).toEqual([{ event_id: "e3" }]);
|
||
});
|
||
|
||
// ---- T8:快照信封 / 混合拆批 / beacon 预算 / 响应计数 ----
|
||
|
||
it("flush 从队首事件快照取归属:信封 visitor/session=事件顶层字段,identity 零调用", () => {
|
||
const t = new Transport(mkCfg({ batchSize: 10 }));
|
||
t.push({ event_id: "e1", visitor_id: "v-snap", session_id: "s-snap", page_id: "p-snap" });
|
||
t.push({ event_id: "e2", visitor_id: "v-snap", session_id: "s-snap", page_id: "p-snap" });
|
||
t.flush();
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(getSessionId).not.toHaveBeenCalled(); // 快照归属——flush 不再读身份(消灭 D02 传输侧)
|
||
expect(getVisitorId).not.toHaveBeenCalled();
|
||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||
expect(body.visitor_id).toBe("v-snap");
|
||
expect(body.session_id).toBe("s-snap");
|
||
});
|
||
|
||
it("混合归属队列拆子批:两组 (visitor,session,page) → 两次独立请求各含正确信封(组内 FIFO)", () => {
|
||
const t = new Transport(mkCfg({ batchSize: 10 }));
|
||
t.push({ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
|
||
t.push({ event_id: "b1", visitor_id: "v2", session_id: "s2", page_id: "p2" });
|
||
t.push({ event_id: "a2", visitor_id: "v1", session_id: "s1", page_id: "p1" });
|
||
t.flush();
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
const b1 = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||
const b2 = JSON.parse(fetchMock.mock.calls[1][1].body);
|
||
expect(b1.visitor_id).toBe("v1"); // 信封归属=组 key(组首事件快照)
|
||
expect(b1.session_id).toBe("s1");
|
||
expect(b1.events.map((e: { event_id: string }) => e.event_id)).toEqual(["a1", "a2"]); // 组内 FIFO
|
||
expect(b2.visitor_id).toBe("v2");
|
||
expect(b2.session_id).toBe("s2");
|
||
expect(b2.events.map((e: { event_id: string }) => e.event_id)).toEqual(["b1"]);
|
||
expect(b1.batch_id).not.toBe(b2.batch_id); // 每子批独立 batch_id
|
||
});
|
||
|
||
it("无归属字段的兼容事件(track 宿主对象)走兜底组:信封从 identity 读,不静默丢", () => {
|
||
const t = new Transport(mkCfg({ batchSize: 10 }));
|
||
t.push({ event_id: "t1" }); // track 命令原样对象——无归属字段
|
||
t.push({ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
|
||
t.flush();
|
||
expect(fetchMock).toHaveBeenCalledTimes(2); // 归属组 + 兜底组
|
||
const b1 = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||
const b2 = JSON.parse(fetchMock.mock.calls[1][1].body); // 兜底组排后
|
||
expect(b1.visitor_id).toBe("v1");
|
||
expect(b1.events).toEqual([{ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" }]);
|
||
expect(b2.visitor_id).toMatch(UUID_RE); // getVisitorId() 旧路径构造信封
|
||
expect(b2.session_id).toMatch(UUID_RE); // getSessionId() 旧路径
|
||
expect(b2.events).toEqual([{ event_id: "t1" }]); // 公开 API 不能静默丢
|
||
expect(getSessionId).toHaveBeenCalledTimes(1); // 惰性求值:仅兜底组读一次
|
||
});
|
||
|
||
it("子批独立重试:组A 500 失败重试,组B 200 不重试", async () => {
|
||
const t = new Transport(mkCfg({ batchSize: 10 }));
|
||
t.push({ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
|
||
t.push({ event_id: "b1", visitor_id: "v2", session_id: "s2", page_id: "p2" });
|
||
fetchMock
|
||
.mockResolvedValueOnce(res(500)) // 组A 初发
|
||
.mockResolvedValueOnce(res(200)) // 组B(成功不重试)
|
||
.mockResolvedValueOnce(res(200)); // 组A 重试
|
||
t.flush();
|
||
expect(fetchMock).toHaveBeenCalledTimes(2); // 组间顺序:初发各一次
|
||
await vi.advanceTimersByTimeAsync(2000); // 组A 退避 ∈[800,1200]
|
||
expect(fetchMock).toHaveBeenCalledTimes(3); // 仅组A 重试
|
||
expect(fetchMock.mock.calls[2][1].body).toBe(fetchMock.mock.calls[0][1].body); // 同 body 重放
|
||
expect(t.dropped).toBe(0);
|
||
});
|
||
|
||
it("子批独立重试资格:组A 耗尽不清扫组B 的重试计数(组B 仍可自救)", async () => {
|
||
// 抖动钳制:两组退避同为 1000ms——fake timer 同刻按调度序(A 先)触发,
|
||
// 否则 Math.random 会让 A/B 重试顺序随机翻转(响应序列错绑到另一组)
|
||
const rnd = vi.spyOn(Math, "random").mockReturnValue(0.5);
|
||
const t = new Transport(mkCfg({ batchSize: 10, maxRetry: 1 }));
|
||
t.push({ event_id: "a1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
|
||
t.push({ event_id: "a2", visitor_id: "v1", session_id: "s1", page_id: "p1" });
|
||
t.push({ event_id: "b1", visitor_id: "v2", session_id: "s2", page_id: "p2" });
|
||
fetchMock
|
||
.mockResolvedValueOnce(res(500)) // A 初发
|
||
.mockResolvedValueOnce(res(500)) // B 初发
|
||
.mockResolvedValueOnce(res(500)) // A 重试 → 耗尽(dropped+=2)
|
||
.mockResolvedValueOnce(res(200)); // B 重试 → 成功
|
||
t.flush();
|
||
await vi.advanceTimersByTimeAsync(2000); // 两组退避均已到期
|
||
expect(fetchMock).toHaveBeenCalledTimes(4); // 共享计数器下 B 会错失自己的重试(仅 3 次)
|
||
expect(t.dropped).toBe(2); // 仅组A 的 2 条落丢
|
||
expect(JSON.parse(fetchMock.mock.calls[3][1].body).visitor_id).toBe("v2"); // B 的重试成功送达
|
||
rnd.mockRestore();
|
||
});
|
||
|
||
it("beacon 载荷超 60KB 预算 → 降级 fetch keepalive;失败耗尽按事件数计 dropped", async () => {
|
||
const beacon = vi.fn().mockReturnValue(true);
|
||
vi.stubGlobal("navigator", { sendBeacon: beacon });
|
||
fetchMock.mockResolvedValue(res(500)); // 降级后恒失败
|
||
const t = new Transport(mkCfg({ batchSize: 10, maxRetry: 0 }));
|
||
t.push({
|
||
event_id: "big",
|
||
visitor_id: "v1",
|
||
session_id: "s1",
|
||
page_id: "p1",
|
||
// 须落在 (60_000, 64_000] 窗口:超信标预算触发降级,又不触发 fetch 64KB 裁剪守卫
|
||
properties: { blob: "x".repeat(62_000) },
|
||
});
|
||
t.flush(true);
|
||
expect(beacon).not.toHaveBeenCalled(); // 超预算不走信标
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
const init = fetchMock.mock.calls[0][1];
|
||
expect(init.keepalive).toBe(true);
|
||
await vi.advanceTimersByTimeAsync(100);
|
||
expect(t.dropped).toBe(1); // fetch 亦失败(maxRetry=0 耗尽)按事件数计
|
||
});
|
||
|
||
it("响应解析:accepted/duplicated/rejected 累计进 counts,server_time 记录(T3 契约)", async () => {
|
||
const t = new Transport(mkCfg({ batchSize: 1 }));
|
||
fetchMock.mockResolvedValueOnce(
|
||
res(200, {
|
||
code: 200,
|
||
data: {
|
||
accepted: 2,
|
||
duplicated: 1,
|
||
rejected: [{ index: 0, event_id: "e1", reason: "invalid_field" }],
|
||
request_id: "r1",
|
||
server_time: "2026-09-18T10:00:00+00:00",
|
||
},
|
||
message: "ok",
|
||
})
|
||
);
|
||
t.push({ event_id: "e1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
|
||
await vi.advanceTimersByTimeAsync(0); // 微任务冲刷(json promise)
|
||
expect(t.accepted).toBe(2);
|
||
expect(t.duplicated).toBe(1);
|
||
expect(t.rejected).toBe(1); // rejected 明细数组长度累计
|
||
expect(t.lastServerTime).toBe("2026-09-18T10:00:00+00:00"); // 供 offset 估计
|
||
|
||
fetchMock.mockResolvedValueOnce(
|
||
res(200, {
|
||
code: 200,
|
||
data: { accepted: 1, duplicated: 0, rejected: [], request_id: "r2", server_time: "2026-09-18T10:00:05+00:00" },
|
||
message: "ok",
|
||
})
|
||
);
|
||
t.push({ event_id: "e2", visitor_id: "v1", session_id: "s1", page_id: "p1" });
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
expect(t.accepted).toBe(3); // 跨批累计
|
||
expect(t.rejected).toBe(1);
|
||
expect(t.lastServerTime).toBe("2026-09-18T10:00:05+00:00"); // 最新值
|
||
});
|
||
|
||
it("响应体非 JSON 不影响送达判定:不重试不丢弃,计数不动", async () => {
|
||
fetchMock.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: () => Promise.reject(new Error("not json")),
|
||
} as unknown as Response);
|
||
const t = new Transport(mkCfg({ batchSize: 1 }));
|
||
t.push({ event_id: "e1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
|
||
await vi.advanceTimersByTimeAsync(2000);
|
||
expect(fetchMock).toHaveBeenCalledTimes(1); // 计数解析失败不触发重试(批已送达)
|
||
expect(t.dropped).toBe(0);
|
||
expect(t.accepted).toBe(0);
|
||
});
|
||
|
||
it("v2 信封形态:恒 schema_version=2 + sdk_version + build_id(sent_at 保留)", () => {
|
||
const t = new Transport(mkCfg({ batchSize: 1 }));
|
||
t.push({ event_id: "e1", visitor_id: "v1", session_id: "s1", page_id: "p1" });
|
||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||
expect(body.schema_version).toBe(SCHEMA_VERSION); // types.ts 单一来源
|
||
expect(body.schema_version).toBe(2); // v1 兼容形态不再产生
|
||
expect(body.sdk_version).toBe(SDK_VERSION);
|
||
expect(body.build_id).toBe(BUILD_ID); // vitest define 注入 "vitest"
|
||
expect(body.sent_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); // sent_at 已有保留
|
||
});
|
||
|
||
it("宿主对象归属字段 getter 抛错:不冒泡,兜底组落丢,归属组照发", () => {
|
||
const t = new Transport(mkCfg({ batchSize: 10 }));
|
||
const evil = { event_id: "evil" } as { event_id: string; visitor_id?: string };
|
||
Object.defineProperty(evil, "visitor_id", {
|
||
// 分组读取/stringify 都会触发——宿主可控对象红线(spec §4.5)
|
||
get() {
|
||
throw new Error("getter boom");
|
||
},
|
||
enumerable: true,
|
||
});
|
||
t.push(evil);
|
||
t.push({ event_id: "ok" }); // 与 evil 同落兜底组(无归属字段)
|
||
t.push({ event_id: "safe", visitor_id: "v1", session_id: "s1", page_id: "p1" });
|
||
expect(() => t.flush()).not.toThrow(); // 红线:异常不得冒泡
|
||
expect(t.dropped).toBe(2); // 兜底组(evil+ok)stringify 抛错按组内事件数计
|
||
expect(fetchMock).toHaveBeenCalledTimes(1); // 归属组不受宿主坏对象波及
|
||
expect(JSON.parse(fetchMock.mock.calls[0][1].body).events).toEqual([
|
||
{ event_id: "safe", visitor_id: "v1", session_id: "s1", page_id: "p1" },
|
||
]);
|
||
});
|
||
|
||
// ---- I1/M1:超预算组送达闭环 + 4xx 丢弃计数 ----
|
||
|
||
it("超 64KB 组丢最旧闭环:裁剪至预算内送达,最旧事件计 dropped", () => {
|
||
const t = new Transport(mkCfg({ batchSize: 10 }));
|
||
const big = (id: string, n: number) => ({
|
||
event_id: id,
|
||
visitor_id: "v1",
|
||
session_id: "s1",
|
||
page_id: "p1",
|
||
properties: { blob: "x".repeat(n) },
|
||
});
|
||
t.push(big("e1", 33_000));
|
||
t.push(big("e2", 33_000));
|
||
t.push(big("e3", 2_000));
|
||
t.flush();
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||
expect(body.events.map((e: { event_id: string }) => e.event_id)).toEqual(["e2", "e3"]); // e1(最旧)被裁
|
||
expect(t.dropped).toBe(1); // 被裁的最旧事件逐条计 dropped
|
||
});
|
||
|
||
it("单事件超 64KB:整组裁空不发送不空转重试,全量计 dropped", async () => {
|
||
fetchMock.mockResolvedValue(res(500)); // 若误发送会进入重试空转——用调用数守卫
|
||
const t = new Transport(mkCfg({ batchSize: 10, maxRetry: 3 }));
|
||
t.push({
|
||
event_id: "huge",
|
||
visitor_id: "v1",
|
||
session_id: "s1",
|
||
page_id: "p1",
|
||
properties: { blob: "x".repeat(70_000) },
|
||
});
|
||
t.flush();
|
||
expect(fetchMock).not.toHaveBeenCalled(); // keepalive 64KiB 硬限确定性失败——不空转
|
||
await vi.advanceTimersByTimeAsync(60_000);
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
expect(t.dropped).toBe(1); // 整组丢弃完毕
|
||
});
|
||
});
|
||
|
||
describe("mergeConfig", () => {
|
||
it("缺省填充默认值,显式项覆盖", () => {
|
||
expect(mergeConfig({ siteId: "s", endpoint: "/e" })).toEqual({
|
||
siteId: "s",
|
||
endpoint: "/e",
|
||
batchSize: 10,
|
||
flushMs: 15000,
|
||
maxRetry: 3,
|
||
queueMax: 200,
|
||
});
|
||
expect(mergeConfig({ siteId: "s", endpoint: "/e", batchSize: 5, flushMs: 1000 })).toMatchObject(
|
||
{ batchSize: 5, flushMs: 1000, maxRetry: 3, queueMax: 200 }
|
||
);
|
||
});
|
||
});
|