fix(nodejs-client): fix all test mocks for fetch API

- Add createMockResponse helper to properly mock fetch responses
- Update all test cases to use the mock helper with all required methods
- Fix timeout error test to use AbortError instead of fake timers
- Ensure beforeEach restores real timers to avoid test interference
- All 85 tests now passing
This commit is contained in:
yyh 2026-01-05 14:04:57 +08:00
parent a4495ab586
commit df67842fae
No known key found for this signature in database
2 changed files with 144 additions and 122 deletions

View File

@ -39,6 +39,7 @@ const createMockResponse = (options = {}) => {
describe("HttpClient", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.useRealTimers(); // Ensure real timers are used by default
});
it("builds requests with auth headers and JSON content type", async () => {
@ -66,12 +67,12 @@ describe("HttpClient", () => {
});
it("serializes array query params", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
text: async () => "ok",
});
const mockFetch = vi.fn().mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
@ -153,12 +154,12 @@ describe("HttpClient", () => {
});
it("respects form-data headers", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
text: async () => "ok",
});
const mockFetch = vi.fn().mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test" });
@ -183,24 +184,25 @@ describe("HttpClient", () => {
it("maps 401 and 429 errors", async () => {
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
headers: new Headers(),
text: vi.fn().mockResolvedValue('{"message":"unauthorized"}'),
json: vi.fn().mockResolvedValue({ message: "unauthorized" }),
});
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
status: 401,
data: { message: "unauthorized" },
})
);
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(AuthenticationError);
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
headers: new Headers({ "retry-after": "2" }),
text: vi.fn().mockResolvedValue('{"message":"rate"}'),
json: vi.fn().mockResolvedValue({ message: "rate" }),
});
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
status: 429,
headers: { "retry-after": "2" },
data: { message: "rate" },
})
);
const error = await client
.requestRaw({ method: "GET", path: "/meta" })
.catch((err) => err);
@ -211,44 +213,39 @@ describe("HttpClient", () => {
it("maps validation and upload errors", async () => {
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 422,
headers: new Headers(),
text: vi.fn().mockResolvedValue('{"message":"invalid"}'),
json: vi.fn().mockResolvedValue({ message: "invalid" }),
});
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
status: 422,
data: { message: "invalid" },
})
);
await expect(
client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } })
).rejects.toBeInstanceOf(ValidationError);
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
headers: new Headers(),
text: vi.fn().mockResolvedValue('{"message":"bad upload"}'),
json: vi.fn().mockResolvedValue({ message: "bad upload" }),
});
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
status: 400,
data: { message: "bad upload" },
})
);
await expect(
client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } })
).rejects.toBeInstanceOf(FileUploadError);
});
it("maps timeout and network errors", async () => {
const client = new HttpClient({ apiKey: "test", maxRetries: 0, timeout: 0.001 });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
global.fetch = vi.fn().mockImplementation(() =>
new Promise((resolve) => setTimeout(() => resolve({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({}),
}), 100))
);
// Test AbortError (which is what timeout produces)
global.fetch = vi.fn().mockRejectedValue(new DOMException("aborted", "AbortError"));
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(TimeoutError);
// Test network error
global.fetch = vi.fn().mockRejectedValue(new Error("network"));
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
@ -258,12 +255,12 @@ describe("HttpClient", () => {
it("retries on timeout errors", async () => {
const mockFetch = vi.fn()
.mockRejectedValueOnce(new DOMException("aborted", "AbortError"))
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
text: async () => "ok",
});
.mockResolvedValueOnce(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const client = new HttpClient({ apiKey: "test", maxRetries: 1, retryDelay: 0 });
@ -283,13 +280,13 @@ describe("HttpClient", () => {
});
it("returns APIError for other http failures", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: new Headers(),
text: vi.fn().mockResolvedValue('{"message":"server"}'),
json: vi.fn().mockResolvedValue({ message: "server" }),
});
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
status: 500,
data: { message: "server" },
})
);
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
await expect(
@ -298,12 +295,12 @@ describe("HttpClient", () => {
});
it("logs requests and responses when enableLogging is true", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({ ok: true }),
});
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
status: 200,
data: { ok: true },
})
);
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({ apiKey: "test", enableLogging: true });
@ -318,12 +315,12 @@ describe("HttpClient", () => {
it("logs retry attempts when enableLogging is true", async () => {
const mockFetch = vi.fn()
.mockRejectedValueOnce(new DOMException("aborted", "AbortError"))
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
text: async () => "ok",
});
.mockResolvedValueOnce(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});

View File

@ -3,6 +3,31 @@ import { ChatClient, DifyClient, WorkflowClient, BASE_URL, routes } from "./inde
const mockFetch = vi.fn();
// Helper to create a mock fetch response
const createMockResponse = (options = {}) => {
const {
ok = true,
status = 200,
headers = {},
body = null,
data = null,
} = options;
const headersObj = new Headers(headers);
const response = {
ok,
status,
headers: headersObj,
body,
json: vi.fn().mockResolvedValue(data),
text: vi.fn().mockResolvedValue(typeof data === 'string' ? data : JSON.stringify(data)),
blob: vi.fn().mockResolvedValue(new Blob()),
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)),
};
return response;
};
beforeEach(() => {
vi.restoreAllMocks();
mockFetch.mockReset();
@ -29,12 +54,12 @@ describe("Send Requests", () => {
const difyClient = new DifyClient("test");
const method = "GET";
const endpoint = routes.application.url();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "response",
});
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "response",
})
);
await difyClient.sendRequest(method, endpoint);
@ -46,12 +71,12 @@ describe("Send Requests", () => {
it("uses the getMeta route configuration", async () => {
const difyClient = new DifyClient("test");
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "ok",
});
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await difyClient.getMeta("end-user");
@ -85,12 +110,12 @@ describe("File uploads", () => {
it("does not override multipart boundary headers for FormData", async () => {
const difyClient = new DifyClient("test");
const form = new globalThis.FormData();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "ok",
});
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await difyClient.fileUpload(form, "end-user");
@ -106,12 +131,12 @@ describe("File uploads", () => {
describe("Workflow client", () => {
it("uses tasks stop path for workflow stop", async () => {
const workflowClient = new WorkflowClient("test");
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "stopped",
});
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "stopped",
})
);
await workflowClient.stop("task-1", "end-user");
@ -125,12 +150,12 @@ describe("Workflow client", () => {
it("maps workflow log filters to service api params", async () => {
const workflowClient = new WorkflowClient("test");
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "ok",
});
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await workflowClient.getLogs({
createdAtAfter: "2024-01-01T00:00:00Z",
@ -157,12 +182,12 @@ describe("Workflow client", () => {
describe("Chat client", () => {
it("places user in query for suggested messages", async () => {
const chatClient = new ChatClient("test");
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "ok",
});
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await chatClient.getSuggested("msg-1", "end-user");
@ -175,12 +200,12 @@ describe("Chat client", () => {
it("uses last_id when listing conversations", async () => {
const chatClient = new ChatClient("test");
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "ok",
});
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await chatClient.getConversations("end-user", "last-1", 10);
@ -195,12 +220,12 @@ describe("Chat client", () => {
it("lists app feedbacks without user params", async () => {
const chatClient = new ChatClient("test");
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => "ok",
});
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
await chatClient.getAppFeedbacks(1, 20);