| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592 |
- import { afterEach, describe, expect, test, vi } from "vitest";
- import type { ProviderEndpoint } from "@/types/provider";
- function makeEndpoint(overrides: Partial<ProviderEndpoint>): ProviderEndpoint {
- return {
- id: 1,
- vendorId: 1,
- providerType: "claude",
- url: "https://example.com",
- label: null,
- sortOrder: 0,
- isEnabled: true,
- lastProbedAt: null,
- lastProbeOk: null,
- lastProbeStatusCode: null,
- lastProbeLatencyMs: null,
- lastProbeErrorType: null,
- lastProbeErrorMessage: null,
- createdAt: new Date(0),
- updatedAt: new Date(0),
- deletedAt: null,
- ...overrides,
- };
- }
- function createCircuitBreakerMock(overrides: Partial<Record<string, unknown>> = {}) {
- return {
- getEndpointCircuitStateSync: vi.fn(() => "closed"),
- resetEndpointCircuit: vi.fn(async () => {}),
- recordEndpointFailure: vi.fn(async () => {}),
- ...overrides,
- };
- }
- afterEach(() => {
- vi.unstubAllGlobals();
- vi.useRealTimers();
- delete process.env.ENDPOINT_PROBE_METHOD;
- });
- describe("provider-endpoints: probe", () => {
- test("probeEndpointUrl: HEAD 成功时直接返回,不触发 GET", async () => {
- process.env.ENDPOINT_PROBE_METHOD = "HEAD";
- vi.resetModules();
- const logger = {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- };
- vi.doMock("@/lib/logger", () => ({ logger }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: vi.fn(),
- recordProviderEndpointProbeResult: vi.fn(),
- updateProviderEndpointProbeSnapshot: vi.fn(),
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
- const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
- if (init?.method === "HEAD") {
- return new Response(null, { status: 204 });
- }
- throw new Error("unexpected");
- });
- vi.stubGlobal("fetch", fetchMock);
- const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
- const result = await probeEndpointUrl("https://example.com", 1234);
- expect(result).toEqual(
- expect.objectContaining({ ok: true, method: "HEAD", statusCode: 204, errorType: null })
- );
- expect(fetchMock).toHaveBeenCalledTimes(1);
- });
- test("probeEndpointUrl: HEAD 网络错误时回退 GET", async () => {
- process.env.ENDPOINT_PROBE_METHOD = "HEAD";
- vi.resetModules();
- const logger = {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- };
- vi.doMock("@/lib/logger", () => ({ logger }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: vi.fn(),
- recordProviderEndpointProbeResult: vi.fn(),
- updateProviderEndpointProbeSnapshot: vi.fn(),
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
- const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
- if (init?.method === "HEAD") {
- throw new Error("boom");
- }
- if (init?.method === "GET") {
- return new Response(null, { status: 200 });
- }
- throw new Error("unexpected");
- });
- vi.stubGlobal("fetch", fetchMock);
- const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
- const result = await probeEndpointUrl("https://example.com", 1234);
- expect(result).toEqual(
- expect.objectContaining({ ok: true, method: "GET", statusCode: 200, errorType: null })
- );
- expect(fetchMock).toHaveBeenCalledTimes(2);
- });
- test("probeEndpointUrl: 5xx 返回 ok=false 且标注 http_5xx", async () => {
- process.env.ENDPOINT_PROBE_METHOD = "HEAD";
- vi.resetModules();
- const logger = {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- };
- vi.doMock("@/lib/logger", () => ({ logger }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: vi.fn(),
- recordProviderEndpointProbeResult: vi.fn(),
- updateProviderEndpointProbeSnapshot: vi.fn(),
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
- vi.stubGlobal(
- "fetch",
- vi.fn(async () => new Response(null, { status: 503 }))
- );
- const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
- const result = await probeEndpointUrl("https://example.com", 1234);
- expect(result.ok).toBe(false);
- expect(result.method).toBe("HEAD");
- expect(result.statusCode).toBe(503);
- expect(result.errorType).toBe("http_5xx");
- expect(result.errorMessage).toBe("HTTP 503");
- });
- test("probeEndpointUrl: 4xx 仍视为 ok=true", async () => {
- process.env.ENDPOINT_PROBE_METHOD = "HEAD";
- vi.resetModules();
- const logger = {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- };
- vi.doMock("@/lib/logger", () => ({ logger }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: vi.fn(),
- recordProviderEndpointProbeResult: vi.fn(),
- updateProviderEndpointProbeSnapshot: vi.fn(),
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
- vi.stubGlobal(
- "fetch",
- vi.fn(async () => new Response(null, { status: 404 }))
- );
- const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
- const result = await probeEndpointUrl("https://example.com", 1234);
- expect(result.ok).toBe(true);
- expect(result.statusCode).toBe(404);
- expect(result.errorType).toBeNull();
- });
- test("probeEndpointUrl: AbortError 归类为 timeout", async () => {
- process.env.ENDPOINT_PROBE_METHOD = "HEAD";
- vi.resetModules();
- const logger = {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- };
- vi.doMock("@/lib/logger", () => ({ logger }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: vi.fn(),
- recordProviderEndpointProbeResult: vi.fn(),
- updateProviderEndpointProbeSnapshot: vi.fn(),
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
- const fetchMock = vi.fn(async () => {
- const err = new Error("");
- err.name = "AbortError";
- throw err;
- });
- vi.stubGlobal("fetch", fetchMock);
- const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
- const result = await probeEndpointUrl("https://example.com", 1);
- expect(result.ok).toBe(false);
- expect(result.method).toBe("GET");
- expect(result.statusCode).toBeNull();
- expect(result.errorType).toBe("timeout");
- expect(result.errorMessage).toBe("timeout");
- });
- test("probeProviderEndpointAndRecord: endpoint 不存在时返回 null", async () => {
- vi.resetModules();
- const recordMock = vi.fn(async () => {});
- const snapshotMock = vi.fn(async () => {});
- const findMock = vi.fn(async () => null);
- const logger = {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- };
- const recordFailureMock = vi.fn(async () => {});
- vi.doMock("@/lib/logger", () => ({ logger }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: findMock,
- recordProviderEndpointProbeResult: recordMock,
- updateProviderEndpointProbeSnapshot: snapshotMock,
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () =>
- createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock })
- );
- vi.stubGlobal(
- "fetch",
- vi.fn(async () => new Response(null, { status: 200 }))
- );
- const { probeProviderEndpointAndRecord } = await import("@/lib/provider-endpoints/probe");
- const result = await probeProviderEndpointAndRecord({ endpointId: 123, source: "manual" });
- expect(result).toBeNull();
- expect(recordMock).not.toHaveBeenCalled();
- expect(snapshotMock).not.toHaveBeenCalled();
- expect(recordFailureMock).not.toHaveBeenCalled();
- });
- test("probeProviderEndpointAndRecord: 记录入库字段包含 source/ok/statusCode/latency/probedAt", async () => {
- process.env.ENDPOINT_PROBE_METHOD = "HEAD";
- vi.useFakeTimers();
- vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
- vi.resetModules();
- const recordMock = vi.fn(async () => {});
- const snapshotMock = vi.fn(async () => {});
- const findMock = vi.fn(async () => makeEndpoint({ id: 123, url: "https://example.com" }));
- const logger = {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- };
- const recordFailureMock = vi.fn(async () => {});
- vi.doMock("@/lib/logger", () => ({ logger }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: findMock,
- recordProviderEndpointProbeResult: recordMock,
- updateProviderEndpointProbeSnapshot: snapshotMock,
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () =>
- createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock })
- );
- vi.stubGlobal(
- "fetch",
- vi.fn(async () => new Response(null, { status: 200 }))
- );
- const { probeProviderEndpointAndRecord } = await import("@/lib/provider-endpoints/probe");
- const result = await probeProviderEndpointAndRecord({
- endpointId: 123,
- source: "manual",
- timeoutMs: 1111,
- });
- expect(result).toEqual(expect.objectContaining({ ok: true, statusCode: 200, errorType: null }));
- expect(recordMock).toHaveBeenCalledTimes(1);
- const payload = recordMock.mock.calls[0]?.[0];
- expect(payload).toEqual(
- expect.objectContaining({
- endpointId: 123,
- source: "manual",
- ok: true,
- statusCode: 200,
- errorType: null,
- errorMessage: null,
- })
- );
- const probedAt = (payload as { probedAt: Date }).probedAt;
- expect(probedAt).toBeInstanceOf(Date);
- expect(probedAt.toISOString()).toBe("2026-01-01T00:00:00.000Z");
- expect(snapshotMock).not.toHaveBeenCalled();
- expect(recordFailureMock).not.toHaveBeenCalled();
- });
- test("probeProviderEndpointAndRecord: scheduled 成功总是写入探测日志记录", async () => {
- process.env.ENDPOINT_PROBE_METHOD = "HEAD";
- vi.useFakeTimers();
- vi.setSystemTime(new Date("2026-01-01T00:00:30.000Z"));
- vi.resetModules();
- const recordMock = vi.fn(async () => {});
- const recordFailureMock = vi.fn(async () => {});
- const endpoint = makeEndpoint({
- id: 1,
- url: "https://example.com",
- lastProbeOk: true,
- lastProbedAt: new Date("2026-01-01T00:00:00.000Z"),
- });
- vi.doMock("@/lib/logger", () => ({
- logger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- },
- }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: vi.fn(async () => endpoint),
- recordProviderEndpointProbeResult: recordMock,
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () =>
- createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock })
- );
- vi.stubGlobal(
- "fetch",
- vi.fn(async () => new Response(null, { status: 200 }))
- );
- const { probeProviderEndpointAndRecord } = await import("@/lib/provider-endpoints/probe");
- const result = await probeProviderEndpointAndRecord({ endpointId: 1, source: "scheduled" });
- expect(result).toEqual(expect.objectContaining({ ok: true, statusCode: 200 }));
- expect(recordMock).toHaveBeenCalledTimes(1);
- expect(recordFailureMock).not.toHaveBeenCalled();
- });
- test("probeProviderEndpointAndRecord: 失败会计入端点熔断计数(scheduled 与 manual)", async () => {
- process.env.ENDPOINT_PROBE_METHOD = "HEAD";
- vi.resetModules();
- const recordMock = vi.fn(async () => {});
- const recordFailureMock = vi.fn(async () => {});
- vi.doMock("@/lib/logger", () => ({
- logger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- },
- }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: vi.fn(async () =>
- makeEndpoint({ id: 123, url: "https://example.com" })
- ),
- recordProviderEndpointProbeResult: recordMock,
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () =>
- createCircuitBreakerMock({ recordEndpointFailure: recordFailureMock })
- );
- vi.stubGlobal(
- "fetch",
- vi.fn(async () => new Response(null, { status: 503 }))
- );
- const { probeProviderEndpointAndRecord } = await import("@/lib/provider-endpoints/probe");
- await probeProviderEndpointAndRecord({ endpointId: 123, source: "scheduled" });
- await probeProviderEndpointAndRecord({ endpointId: 123, source: "manual" });
- expect(recordFailureMock).toHaveBeenCalledTimes(2);
- expect(recordMock).toHaveBeenCalledTimes(2);
- });
- test("probeEndpointUrl: TCP mode connects to host:port without HTTP request", async () => {
- process.env.ENDPOINT_PROBE_METHOD = "TCP";
- vi.resetModules();
- const logger = {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- };
- vi.doMock("@/lib/logger", () => ({ logger }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: vi.fn(),
- recordProviderEndpointProbeResult: vi.fn(),
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
- // Mock net.createConnection to simulate successful TCP connection
- const mockSocket = {
- destroy: vi.fn(),
- on: vi.fn(),
- };
- vi.doMock("node:net", () => ({
- default: {
- createConnection: vi.fn((_opts: unknown, cb: () => void) => {
- // Simulate immediate successful connection
- setTimeout(() => cb(), 0);
- return mockSocket;
- }),
- },
- }));
- const fetchMock = vi.fn();
- vi.stubGlobal("fetch", fetchMock);
- const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
- const result = await probeEndpointUrl("https://api.example.com:8443/v1", 5000);
- expect(result.ok).toBe(true);
- expect(result.method).toBe("TCP");
- expect(result.statusCode).toBeNull();
- expect(result.errorType).toBeNull();
- expect(result.latencyMs).toBeTypeOf("number");
- // fetch should never be called in TCP mode
- expect(fetchMock).not.toHaveBeenCalled();
- });
- test("probeEndpointUrl: TCP mode defaults to port 80 for http URLs", async () => {
- process.env.ENDPOINT_PROBE_METHOD = "TCP";
- vi.resetModules();
- vi.doMock("@/lib/logger", () => ({
- logger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- },
- }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: vi.fn(),
- recordProviderEndpointProbeResult: vi.fn(),
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
- const mockSocket = {
- destroy: vi.fn(),
- on: vi.fn(),
- };
- vi.doMock("node:net", () => ({
- default: {
- createConnection: vi.fn((_opts: unknown, cb: () => void) => {
- setTimeout(() => cb(), 0);
- return mockSocket;
- }),
- },
- }));
- const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
- const result = await probeEndpointUrl("http://api.example.com/v1/messages", 5000);
- // TCP connection succeeds, no HTTP status code
- expect(result.ok).toBe(true);
- expect(result.method).toBe("TCP");
- expect(result.statusCode).toBeNull();
- });
- test("probeEndpointUrl: TCP mode returns invalid_url for bad URLs", async () => {
- process.env.ENDPOINT_PROBE_METHOD = "TCP";
- vi.resetModules();
- vi.doMock("@/lib/logger", () => ({
- logger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- },
- }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: vi.fn(),
- recordProviderEndpointProbeResult: vi.fn(),
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
- const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
- const result = await probeEndpointUrl("not-a-valid-url", 5000);
- expect(result.ok).toBe(false);
- expect(result.method).toBe("TCP");
- expect(result.errorType).toBe("invalid_url");
- });
- test("probeEndpointUrl: defaults to TCP when ENDPOINT_PROBE_METHOD is not set", async () => {
- delete process.env.ENDPOINT_PROBE_METHOD;
- vi.resetModules();
- vi.doMock("@/lib/logger", () => ({
- logger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- },
- }));
- vi.doMock("@/repository", () => ({
- findProviderEndpointById: vi.fn(),
- recordProviderEndpointProbeResult: vi.fn(),
- }));
- vi.doMock("@/lib/endpoint-circuit-breaker", () => createCircuitBreakerMock());
- const mockSocket = {
- destroy: vi.fn(),
- on: vi.fn(),
- };
- vi.doMock("node:net", () => ({
- default: {
- createConnection: vi.fn((_opts: unknown, cb: () => void) => {
- setTimeout(() => cb(), 0);
- return mockSocket;
- }),
- },
- }));
- const fetchMock = vi.fn();
- vi.stubGlobal("fetch", fetchMock);
- const { probeEndpointUrl } = await import("@/lib/provider-endpoints/probe");
- const result = await probeEndpointUrl("https://example.com", 5000);
- expect(result.method).toBe("TCP");
- expect(fetchMock).not.toHaveBeenCalled();
- });
- });
|