import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Transport } from "../src/transport"; import { mergeConfig } from "../src/config"; // 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}$/; const res = (status: number) => ({ ok: status < 400, status }) 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); }); 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 失败退避重试后成功:retry 归零,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); expect((t as unknown as { retry: number }).retry).toBe(0); }); it("400 不重试(参数错,重试无意义)", 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 as unknown as { retry: number }).retry).toBe(0); }); it("403 不重试(权限错,重试只会掩盖配置问题)", 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); }); 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); // 按入批事件数计,而非按批计 expect((t as unknown as { retry: number }).retry).toBe(0); // 归零供下批复用 }); 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" }]); }); }); 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 } ); }); });