Fetch_test.res
1open VitestHelpers
2
3@module("vitest") @val external vi: 'a = "vi"
4
5let mockFetchJson: (JSON.t, int, string) => unit = %raw(`
6 function(json, status, statusText) {
7 globalThis.fetch = vi.fn(() => Promise.resolve({
8 ok: status >= 200 && status < 300,
9 status: status,
10 statusText: statusText,
11 json: () => Promise.resolve(json)
12 }));
13 }
14`)
15
16let setupMockWithCapture: unit => 'a = %raw(`
17 function() {
18 const calls = [];
19 globalThis.fetch = vi.fn((url, init) => {
20 calls.push({url, init});
21 return Promise.resolve({
22 ok: true,
23 status: 200,
24 statusText: 'OK',
25 json: () => Promise.resolve(null)
26 });
27 });
28 return calls;
29 }
30`)
31
32let getContentType: 'a => 'b = %raw(`
33 function(calls) {
34 const headers = calls[0].init.headers;
35 if (Array.isArray(headers)) {
36 const found = headers.find(h => h[0] === 'Content-Type');
37 return found ? found[1] : undefined;
38 }
39 return headers['Content-Type'];
40 }
41`)
42
43let getCredentials: 'a => 'b = %raw(`
44 function(calls) {
45 return calls[0].init.credentials;
46 }
47`)
48
49describe("Fetch", () => {
50 testAsync("GET request returns user-defined response variant", async t => {
51 let json = JSON.Encode.object(Dict.fromArray([("name", JSON.Encode.string("test"))]))
52 mockFetchJson(json, 200, "OK")
53
54 let result = await Fetch.get("http://example.com/api")
55 ->Fetch.onResponse(
56 ~onSuccess=json => {
57 switch json->JSON.Decode.object {
58 | Some(dict) =>
59 switch dict->Dict.get("name") {
60 | Some(name) =>
61 switch name->JSON.Decode.string {
62 | Some(s) => #Success(s)
63 | None => #DecodeError("name not string")
64 }
65 | None => #DecodeError("name missing")
66 }
67 | None => #DecodeError("not an object")
68 }
69 },
70 ~onError=(status, message) => #APIError(status, message),
71 )
72 ->Fetch.send
73
74 t->expect(result)->toEqual(#Success("test"))
75 })
76
77 testAsync("POST request sends body and auto-sets content-type", async t => {
78 let body = JSON.Encode.object(Dict.fromArray([("data", JSON.Encode.string("value"))]))
79 let responseJson = JSON.Encode.object(Dict.fromArray([("id", JSON.Encode.int(123))]))
80 mockFetchJson(responseJson, 201, "Created")
81
82 let result = await Fetch.post("http://example.com/api")
83 ->Fetch.setBody(body)
84 ->Fetch.onResponse(
85 ~onSuccess=json => {
86 switch json->JSON.Decode.object {
87 | Some(dict) =>
88 switch dict->Dict.get("id") {
89 | Some(id) =>
90 switch id->JSON.Decode.float {
91 | Some(f) => #Created(f->Float.toInt)
92 | None => #DecodeError("id not number")
93 }
94 | None => #DecodeError("id missing")
95 }
96 | None => #DecodeError("not an object")
97 }
98 },
99 ~onError=(status, message) => #APIError(status, message),
100 )
101 ->Fetch.send
102
103 t->expect(result)->toEqual(#Created(123))
104 })
105
106 testAsync("POST auto-sets content-type header", async t => {
107 let body = JSON.Encode.string("test body")
108 let _calls = setupMockWithCapture()
109
110 let _result = await Fetch.post("http://example.com/api")
111 ->Fetch.setBody(body)
112 ->Fetch.onResponse(
113 ~onSuccess=_json => #Success(true),
114 ~onError=(_status, _message) => #Success(false),
115 )
116 ->Fetch.send
117
118 let ct = getContentType(_calls)
119 t->expect(ct)->toEqual("application/json")
120 })
121
122 testAsync("POST does not override custom content-type", async t => {
123 let body = JSON.Encode.string("test body")
124 let _calls = setupMockWithCapture()
125
126 let _result = await Fetch.post("http://example.com/api")
127 ->Fetch.setHeader("Content-Type", "text/plain")
128 ->Fetch.setBody(body)
129 ->Fetch.onResponse(
130 ~onSuccess=_json => #Success(true),
131 ~onError=(_status, _message) => #Success(false),
132 )
133 ->Fetch.send
134
135 let ct = getContentType(_calls)
136 t->expect(ct)->toEqual("text/plain")
137 })
138
139 testAsync("error response triggers onError", async t => {
140 let errorJson = JSON.Encode.object(Dict.fromArray([("error", JSON.Encode.string("not found"))]))
141 mockFetchJson(errorJson, 404, "Not Found")
142
143 let result = await Fetch.get("http://example.com/api")
144 ->Fetch.onResponse(
145 ~onSuccess=_json => #Success(0),
146 ~onError=(status, message) => {
147 if status == 404 && message == "not found" {
148 #NotFound
149 } else {
150 #OtherError
151 }
152 },
153 )
154 ->Fetch.send
155
156 t->expect(result)->toEqual(#NotFound)
157 })
158
159 testAsync("promise returns a rescript promise", async t => {
160 let json = JSON.Encode.object(Dict.fromArray([("value", JSON.Encode.int(42))]))
161 mockFetchJson(json, 200, "OK")
162
163 let p =
164 Fetch.get("http://example.com/api")
165 ->Fetch.onResponse(
166 ~onSuccess=json => {
167 switch json->JSON.Decode.object {
168 | Some(dict) =>
169 switch dict->Dict.get("value") {
170 | Some(v) =>
171 switch v->JSON.Decode.float {
172 | Some(f) => #Success(f->Float.toInt)
173 | None => #DecodeError("value not number")
174 }
175 | None => #DecodeError("value missing")
176 }
177 | None => #DecodeError("not an object")
178 }
179 },
180 ~onError=(status, message) => #APIError(status, message),
181 )
182 ->Fetch.promise
183
184 let result = await p
185 t->expect(result)->toEqual(#Success(42))
186 })
187
188 testAsync("DELETE request works", async t => {
189 mockFetchJson(JSON.Encode.null, 204, "No Content")
190
191 let result = await Fetch.delete("http://example.com/api/1")
192 ->Fetch.onResponse(~onSuccess=_json => #Deleted, ~onError=(_status, _message) => #Failed)
193 ->Fetch.send
194
195 t->expect(result)->toEqual(#Deleted)
196 })
197
198 testAsync("default credentials is same-origin", async t => {
199 let _calls = setupMockWithCapture()
200
201 let _result = await Fetch.get("http://example.com/api")
202 ->Fetch.onResponse(
203 ~onSuccess=_json => #Success(true),
204 ~onError=(_status, _message) => #Success(false),
205 )
206 ->Fetch.send
207
208 let creds = getCredentials(_calls)
209 t->expect(creds)->toEqual("same-origin")
210 })
211
212 testAsync("credentials omit sets omit", async t => {
213 let _calls = setupMockWithCapture()
214
215 let _result = await Fetch.get("http://example.com/api")
216 ->Fetch.credentials(Fetch.Omit)
217 ->Fetch.onResponse(
218 ~onSuccess=_json => #Success(true),
219 ~onError=(_status, _message) => #Success(false),
220 )
221 ->Fetch.send
222
223 let creds = getCredentials(_calls)
224 t->expect(creds)->toEqual("omit")
225 })
226
227 testAsync("credentials include sets include", async t => {
228 let _calls = setupMockWithCapture()
229
230 let _result = await Fetch.post("http://example.com/api")
231 ->Fetch.credentials(Fetch.Include)
232 ->Fetch.onResponse(
233 ~onSuccess=_json => #Success(true),
234 ~onError=(_status, _message) => #Success(false),
235 )
236 ->Fetch.send
237
238 let creds = getCredentials(_calls)
239 t->expect(creds)->toEqual("include")
240 })
241
242 testAsync("abort triggers onAbort handler", async t => {
243 let controller = Fetch.AbortController.make()
244
245 // Create a fetch that listens to the abort signal
246
247 (
248 %raw(`
249 (() => {
250 globalThis.fetch = vi.fn((url, init) => {
251 return new Promise((resolve, reject) => {
252 // Listen for abort
253 if (init.signal) {
254 init.signal.addEventListener('abort', () => {
255 reject(new DOMException('The operation was aborted', 'AbortError'));
256 });
257 }
258 // Never resolve - wait for abort
259 });
260 });
261 })()
262 `): unit
263 )
264
265 let p =
266 Fetch.get("http://example.com/api")
267 ->Fetch.withAbortController(controller, () => #Aborted)
268 ->Fetch.onResponse(~onSuccess=_json => #Success(true), ~onError=(_status, _message) => #Error)
269 ->Fetch.promise
270
271 // Small delay then abort
272 await (%raw(`new Promise(r => setTimeout(r, 10))`): promise<unit>)
273 Fetch.AbortController.abort(controller)
274
275 let result = await p
276 t->expect(result)->toEqual(#Aborted)
277 })
278
279 testAsync("abort after completion is no-op", async t => {
280 let controller = Fetch.AbortController.make()
281 let json = JSON.Encode.object(Dict.fromArray([("data", JSON.Encode.string("value"))]))
282 mockFetchJson(json, 200, "OK")
283
284 let p =
285 Fetch.get("http://example.com/api")
286 ->Fetch.withAbortController(controller, () => #Aborted)
287 ->Fetch.onResponse(~onSuccess=_json => #Success(true), ~onError=(_status, _message) => #Error)
288 ->Fetch.promise
289
290 // Wait for completion
291 let result = await p
292
293 // Abort after completion - should be no-op
294 Fetch.AbortController.abort(controller)
295
296 t->expect(result)->toEqual(#Success(true))
297 })
298
299 testAsync("request with abort controller completes normally", async t => {
300 let controller = Fetch.AbortController.make()
301 let json = JSON.Encode.object(Dict.fromArray([("name", JSON.Encode.string("test"))]))
302 mockFetchJson(json, 200, "OK")
303
304 let result = await Fetch.get("http://example.com/api")
305 ->Fetch.withAbortController(controller, () => #Aborted)
306 ->Fetch.onResponse(
307 ~onSuccess=json => {
308 switch json->JSON.Decode.object {
309 | Some(dict) =>
310 switch dict->Dict.get("name") {
311 | Some(name) =>
312 switch name->JSON.Decode.string {
313 | Some(s) => #Success(s)
314 | None => #DecodeError("not string")
315 }
316 | None => #DecodeError("missing")
317 }
318 | None => #DecodeError("not object")
319 }
320 },
321 ~onError=(_status, _message) => #Error,
322 )
323 ->Fetch.send
324
325 t->expect(result)->toEqual(#Success("test"))
326 })
327})