Files
fangke/sdk/test/transport.test.ts
T

222 lines
8.7 KiB
TypeScript
Raw Normal View History

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<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);
});
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 }
);
});
});