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

196 lines
7.5 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";
// 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 耗尽 → 按事件数丢弃
});
});
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 }
);
});
});