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(); 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 = {}) { return { siteId: "site1", endpoint: "/api/v1/collect", batchSize: 3, flushMs: 15000, maxRetry: 3, queueMax: 50, ...over, }; } let fetchMock: ReturnType; 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 } ); }); });