| 
 | 1 | +import { type AxiosInstance, type AxiosResponse } from "axios";  | 
 | 2 | +import { type ReaderLike } from "eventsource";  | 
 | 3 | +import { EventEmitter } from "node:events";  | 
 | 4 | +import { type IncomingMessage } from "node:http";  | 
 | 5 | +import { describe, it, expect, vi } from "vitest";  | 
 | 6 | + | 
 | 7 | +import { createStreamingFetchAdapter } from "@/api/streamingFetchAdapter";  | 
 | 8 | + | 
 | 9 | +const TEST_URL = "https://example.com/api";  | 
 | 10 | + | 
 | 11 | +describe("createStreamingFetchAdapter", () => {  | 
 | 12 | +	describe("Request Handling", () => {  | 
 | 13 | +		it("passes URL, signal, and responseType to axios", async () => {  | 
 | 14 | +			const mockAxios = createAxiosMock();  | 
 | 15 | +			const mockStream = createMockStream();  | 
 | 16 | +			setupAxiosResponse(mockAxios, 200, {}, mockStream);  | 
 | 17 | + | 
 | 18 | +			const adapter = createStreamingFetchAdapter(mockAxios);  | 
 | 19 | +			const signal = new AbortController().signal;  | 
 | 20 | + | 
 | 21 | +			await adapter(TEST_URL, { signal });  | 
 | 22 | + | 
 | 23 | +			expect(mockAxios.request).toHaveBeenCalledWith({  | 
 | 24 | +				url: TEST_URL,  | 
 | 25 | +				signal, // correctly passes signal  | 
 | 26 | +				headers: {},  | 
 | 27 | +				responseType: "stream",  | 
 | 28 | +				validateStatus: expect.any(Function),  | 
 | 29 | +			});  | 
 | 30 | +		});  | 
 | 31 | + | 
 | 32 | +		it("applies headers in correct precedence order (config overrides init)", async () => {  | 
 | 33 | +			const mockAxios = createAxiosMock();  | 
 | 34 | +			const mockStream = createMockStream();  | 
 | 35 | +			setupAxiosResponse(mockAxios, 200, {}, mockStream);  | 
 | 36 | + | 
 | 37 | +			// Test 1: No config headers, only init headers  | 
 | 38 | +			const adapter1 = createStreamingFetchAdapter(mockAxios);  | 
 | 39 | +			await adapter1(TEST_URL, {  | 
 | 40 | +				headers: { "X-Init": "init-value" },  | 
 | 41 | +			});  | 
 | 42 | + | 
 | 43 | +			expect(mockAxios.request).toHaveBeenCalledWith(  | 
 | 44 | +				expect.objectContaining({  | 
 | 45 | +					headers: { "X-Init": "init-value" },  | 
 | 46 | +				}),  | 
 | 47 | +			);  | 
 | 48 | + | 
 | 49 | +			// Test 2: Config headers merge with init headers  | 
 | 50 | +			const adapter2 = createStreamingFetchAdapter(mockAxios, {  | 
 | 51 | +				"X-Config": "config-value",  | 
 | 52 | +			});  | 
 | 53 | +			await adapter2(TEST_URL, {  | 
 | 54 | +				headers: { "X-Init": "init-value" },  | 
 | 55 | +			});  | 
 | 56 | + | 
 | 57 | +			expect(mockAxios.request).toHaveBeenCalledWith(  | 
 | 58 | +				expect.objectContaining({  | 
 | 59 | +					headers: {  | 
 | 60 | +						"X-Init": "init-value",  | 
 | 61 | +						"X-Config": "config-value",  | 
 | 62 | +					},  | 
 | 63 | +				}),  | 
 | 64 | +			);  | 
 | 65 | + | 
 | 66 | +			// Test 3: Config headers override init headers  | 
 | 67 | +			const adapter3 = createStreamingFetchAdapter(mockAxios, {  | 
 | 68 | +				"X-Header": "config-value",  | 
 | 69 | +			});  | 
 | 70 | +			await adapter3(TEST_URL, {  | 
 | 71 | +				headers: { "X-Header": "init-value" },  | 
 | 72 | +			});  | 
 | 73 | + | 
 | 74 | +			expect(mockAxios.request).toHaveBeenCalledWith(  | 
 | 75 | +				expect.objectContaining({  | 
 | 76 | +					headers: { "X-Header": "config-value" },  | 
 | 77 | +				}),  | 
 | 78 | +			);  | 
 | 79 | +		});  | 
 | 80 | +	});  | 
 | 81 | + | 
 | 82 | +	describe("Response Properties", () => {  | 
 | 83 | +		it("returns response with correct properties", async () => {  | 
 | 84 | +			const mockAxios = createAxiosMock();  | 
 | 85 | +			const mockStream = createMockStream();  | 
 | 86 | +			setupAxiosResponse(  | 
 | 87 | +				mockAxios,  | 
 | 88 | +				200,  | 
 | 89 | +				{ "content-type": "text/event-stream" },  | 
 | 90 | +				mockStream,  | 
 | 91 | +			);  | 
 | 92 | + | 
 | 93 | +			const adapter = createStreamingFetchAdapter(mockAxios);  | 
 | 94 | +			const response = await adapter(TEST_URL);  | 
 | 95 | + | 
 | 96 | +			expect(response.url).toBe(TEST_URL);  | 
 | 97 | +			expect(response.status).toBe(200);  | 
 | 98 | +			expect(response.headers.get("content-type")).toBe("text/event-stream");  | 
 | 99 | +			// Headers are lowercased when we retrieve them  | 
 | 100 | +			expect(response.headers.get("CoNtEnT-TyPe")).toBe("text/event-stream");  | 
 | 101 | +			expect(response.body?.getReader).toBeDefined();  | 
 | 102 | +		});  | 
 | 103 | + | 
 | 104 | +		it("detects redirected requests", async () => {  | 
 | 105 | +			const mockAxios = createAxiosMock();  | 
 | 106 | +			const mockStream = createMockStream();  | 
 | 107 | +			const mockResponse = {  | 
 | 108 | +				status: 200,  | 
 | 109 | +				headers: {},  | 
 | 110 | +				data: mockStream,  | 
 | 111 | +				request: {  | 
 | 112 | +					res: {  | 
 | 113 | +						responseUrl: "https://redirect.com/api",  | 
 | 114 | +					},  | 
 | 115 | +				},  | 
 | 116 | +			} as AxiosResponse<IncomingMessage>;  | 
 | 117 | +			vi.mocked(mockAxios.request).mockResolvedValue(mockResponse);  | 
 | 118 | + | 
 | 119 | +			const adapter = createStreamingFetchAdapter(mockAxios);  | 
 | 120 | +			const response = await adapter(TEST_URL);  | 
 | 121 | + | 
 | 122 | +			expect(response.redirected).toBe(true);  | 
 | 123 | +		});  | 
 | 124 | +	});  | 
 | 125 | + | 
 | 126 | +	describe("Stream Handling", () => {  | 
 | 127 | +		it("enqueues data chunks from stream", async () => {  | 
 | 128 | +			const { mockStream, reader } = await setupReaderTest();  | 
 | 129 | + | 
 | 130 | +			const chunk1 = Buffer.from("data1");  | 
 | 131 | +			const chunk2 = Buffer.from("data2");  | 
 | 132 | +			mockStream.emit("data", chunk1);  | 
 | 133 | +			mockStream.emit("data", chunk2);  | 
 | 134 | +			mockStream.emit("end");  | 
 | 135 | + | 
 | 136 | +			const result1 = await reader.read();  | 
 | 137 | +			expect(result1.value).toEqual(chunk1);  | 
 | 138 | +			expect(result1.done).toBe(false);  | 
 | 139 | + | 
 | 140 | +			const result2 = await reader.read();  | 
 | 141 | +			expect(result2.value).toEqual(chunk2);  | 
 | 142 | +			expect(result2.done).toBe(false);  | 
 | 143 | + | 
 | 144 | +			const result3 = await reader.read();  | 
 | 145 | +			// Closed after end  | 
 | 146 | +			expect(result3.done).toBe(true);  | 
 | 147 | +		});  | 
 | 148 | + | 
 | 149 | +		it("propagates stream errors", async () => {  | 
 | 150 | +			const { mockStream, reader } = await setupReaderTest();  | 
 | 151 | + | 
 | 152 | +			const error = new Error("Stream error");  | 
 | 153 | +			mockStream.emit("error", error);  | 
 | 154 | + | 
 | 155 | +			await expect(reader.read()).rejects.toThrow("Stream error");  | 
 | 156 | +		});  | 
 | 157 | + | 
 | 158 | +		it("handles errors after stream is closed", async () => {  | 
 | 159 | +			const { mockStream, reader } = await setupReaderTest();  | 
 | 160 | + | 
 | 161 | +			mockStream.emit("end");  | 
 | 162 | +			await reader.read();  | 
 | 163 | + | 
 | 164 | +			// Emit events after stream is closed - should not throw  | 
 | 165 | +			expect(() => mockStream.emit("data", Buffer.from("late"))).not.toThrow();  | 
 | 166 | +			expect(() => mockStream.emit("end")).not.toThrow();  | 
 | 167 | +		});  | 
 | 168 | + | 
 | 169 | +		it("destroys stream on cancel", async () => {  | 
 | 170 | +			const { mockStream, reader } = await setupReaderTest();  | 
 | 171 | + | 
 | 172 | +			await reader.cancel();  | 
 | 173 | + | 
 | 174 | +			expect(mockStream.destroy).toHaveBeenCalled();  | 
 | 175 | +		});  | 
 | 176 | +	});  | 
 | 177 | +});  | 
 | 178 | + | 
 | 179 | +function createAxiosMock(): AxiosInstance {  | 
 | 180 | +	return {  | 
 | 181 | +		request: vi.fn(),  | 
 | 182 | +	} as unknown as AxiosInstance;  | 
 | 183 | +}  | 
 | 184 | + | 
 | 185 | +function createMockStream(): IncomingMessage {  | 
 | 186 | +	const stream = new EventEmitter() as IncomingMessage;  | 
 | 187 | +	stream.destroy = vi.fn();  | 
 | 188 | +	return stream;  | 
 | 189 | +}  | 
 | 190 | + | 
 | 191 | +function setupAxiosResponse(  | 
 | 192 | +	axios: AxiosInstance,  | 
 | 193 | +	status: number,  | 
 | 194 | +	headers: Record<string, string>,  | 
 | 195 | +	stream: IncomingMessage,  | 
 | 196 | +): void {  | 
 | 197 | +	vi.mocked(axios.request).mockResolvedValue({  | 
 | 198 | +		status,  | 
 | 199 | +		headers,  | 
 | 200 | +		data: stream,  | 
 | 201 | +	});  | 
 | 202 | +}  | 
 | 203 | + | 
 | 204 | +async function setupReaderTest(): Promise<{  | 
 | 205 | +	mockStream: IncomingMessage;  | 
 | 206 | +	reader: ReaderLike | ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>>;  | 
 | 207 | +}> {  | 
 | 208 | +	const mockAxios = createAxiosMock();  | 
 | 209 | +	const mockStream = createMockStream();  | 
 | 210 | +	setupAxiosResponse(mockAxios, 200, {}, mockStream);  | 
 | 211 | + | 
 | 212 | +	const adapter = createStreamingFetchAdapter(mockAxios);  | 
 | 213 | +	const response = await adapter(TEST_URL);  | 
 | 214 | +	const reader = response.body?.getReader();  | 
 | 215 | +	if (reader === undefined) {  | 
 | 216 | +		throw new Error("Reader is undefined");  | 
 | 217 | +	}  | 
 | 218 | + | 
 | 219 | +	return { mockStream, reader };  | 
 | 220 | +}  | 
0 commit comments