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})