Files
fangke/sdk/test/transport.test.ts
T
2026-09-18 14:27:26 +08:00

466 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
);
});
});