Passed
Push — task/explore-skills-ui ( d41a63...af1244 )
by Yonathan
06:51 queued 10s
created

resources/assets/js/hooks/webResourceHooks/indexResourceHook.test.ts   A

Complexity

Total Complexity 1
Complexity/F 1

Size

Lines of Code 1013
Function Count 1

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 1
eloc 783
dl 0
loc 1013
rs 9.817
c 0
b 0
f 0
mnd 0
bc 0
fnc 1
bpm 0
cpm 1
noi 0

1 Function

Rating   Name   Duplication   Size   Complexity  
A indexResourceHook.test.ts ➔ arrayToIndexedObj 0 3 1
1
import fetchMock from "fetch-mock";
2
import { act, renderHook } from "@testing-library/react-hooks";
3
import { FetchError } from "../../helpers/httpRequests";
4
import useResourceIndex, { UNEXPECTED_FORMAT_ERROR } from "./indexResourceHook";
5
import { getId, hasKey, mapToObject } from "../../helpers/queries";
6
7
interface TestResource {
8
  id: number;
9
  name: string;
10
}
11
12
function arrayToIndexedObj(arr) {
13
  return mapToObject(arr, getId);
14
}
15
16
describe("indexResourceHook", () => {
17
  afterEach((): void => {
18
    fetchMock.reset();
19
    fetchMock.restore();
20
  });
21
22
  const endpoint = "https://talent.test/api/test";
23
24
  describe("test initial state and initial fetch", () => {
25
    it("Initially returns no values and a status of 'pending'", () => {
26
      fetchMock.mock("*", [], {
27
        delay: 10,
28
      });
29
      const { result } = renderHook(() => useResourceIndex(endpoint));
30
      expect(result.current.values).toEqual({});
31
      expect(result.current.indexStatus).toEqual("pending");
32
    });
33
    it("If initial value is set, returns that value and does not automatically fetch.", () => {
34
      fetchMock.mock("*", []);
35
      const initialValue = [
36
        { id: 1, name: "one" },
37
        { id: 2, name: "two" },
38
      ];
39
      const { result } = renderHook(() =>
40
        useResourceIndex(endpoint, { initialValue }),
41
      );
42
      expect(result.current.values).toEqual({
43
        1: initialValue[0],
44
        2: initialValue[1],
45
      });
46
      expect(result.current.indexStatus).toEqual("initial");
47
      expect(fetchMock.called()).toBe(false);
48
    });
49
    it("If initial value is set, but forceInitialRefresh is true, returns the initial value but also fetches.", async () => {
50
      const initialValue = [
51
        { id: 1, name: "one" },
52
        { id: 2, name: "two" },
53
      ];
54
      const updatedValue = [
55
        { id: 1, name: "one NEW" },
56
        { id: 2, name: "two NEW" },
57
        { id: 3, name: "three NEW" },
58
      ];
59
      fetchMock.mock("*", updatedValue, { delay: 5 });
60
      const { result, waitFor } = renderHook(() =>
61
        useResourceIndex(endpoint, { initialValue, forceInitialRefresh: true }),
62
      );
63
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
64
      expect(result.current.indexStatus).toEqual("pending");
65
      expect(fetchMock.called()).toBe(true);
66
      await waitFor(() => result.current.indexStatus === "fulfilled");
67
      expect(result.current.values).toEqual(arrayToIndexedObj(updatedValue));
68
      expect(result.current.indexStatus).toEqual("fulfilled");
69
    });
70
    it("If initial value is set, all entities start with status of 'initial'", () => {
71
      const initialValue = [
72
        { id: 1, name: "one" },
73
        { id: 2, name: "two" },
74
      ];
75
      const { result } = renderHook(() =>
76
        useResourceIndex(endpoint, { initialValue }),
77
      );
78
      expect(result.current.entityStatus).toEqual({
79
        1: "initial",
80
        2: "initial",
81
      });
82
    });
83
    it("Returns new values and status of 'fulfilled' after initial fetch succeeds", async () => {
84
      const initialValue = [
85
        { id: 1, name: "one" },
86
        { id: 2, name: "two" },
87
      ];
88
      fetchMock.mock("*", initialValue, {
89
        delay: 10,
90
      });
91
92
      const { result, waitFor } = renderHook(() => useResourceIndex(endpoint));
93
94
      expect(result.current.values).toEqual({});
95
      expect(result.current.indexStatus).toEqual("pending");
96
      await waitFor(
97
        () => {
98
          return result.current.indexStatus === "fulfilled";
99
        },
100
        { timeout: 100 },
101
      );
102
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
103
    });
104
    it("parseIndexResponse (if set) transforms values returns by index requests", async () => {
105
      const responseValue = [
106
        { id: 1, name: "one" },
107
        { id: 2, name: "two" },
108
      ];
109
      const parseIndexResponse = (arr) =>
110
        arr.map((x) => ({ ...x, name: `${x.name} PARSED` }));
111
      const expectValue = [
112
        { id: 1, name: "one PARSED" },
113
        { id: 2, name: "two PARSED" },
114
      ];
115
      fetchMock.mock("*", responseValue);
116
      const { result, waitFor } = renderHook(() =>
117
        useResourceIndex(endpoint, { parseIndexResponse }),
118
      );
119
      expect(result.current.values).toEqual({});
120
      expect(result.current.indexStatus).toEqual("pending");
121
      await waitFor(() => result.current.indexStatus === "fulfilled");
122
      expect(result.current.values).toEqual(arrayToIndexedObj(expectValue));
123
    });
124
    it("Initial fetch is a GET request to the provided endpoint", async () => {
125
      fetchMock.getOnce(endpoint, []);
126
      const { result, waitForNextUpdate } = renderHook(() =>
127
        useResourceIndex(endpoint),
128
      );
129
      await waitForNextUpdate();
130
      expect(result.current.indexStatus).toBe("fulfilled");
131
      expect(fetchMock.called()).toBe(true);
132
    });
133
    it("After initial fetch, all values have entityStatus of 'fulfilled'", async () => {
134
      const responseValue = [
135
        { id: 1, name: "one" },
136
        { id: 2, name: "two" },
137
      ];
138
      fetchMock.getOnce(endpoint, responseValue);
139
      const { result, waitFor } = renderHook(() => useResourceIndex(endpoint));
140
      expect(result.current.entityStatus).toEqual({});
141
      await waitFor(() => result.current.indexStatus === "fulfilled");
142
      expect(result.current.entityStatus).toEqual({
143
        1: "fulfilled",
144
        2: "fulfilled",
145
      });
146
    });
147
    it("Returns a 'rejected' status and calls handleError when initial fetch returns a server error", async () => {
148
      fetchMock.once(endpoint, 404);
149
      const handleError = jest.fn();
150
      const { result, waitForNextUpdate } = renderHook(() =>
151
        useResourceIndex(endpoint, { handleError }),
152
      );
153
      expect(result.current.values).toEqual({});
154
      expect(result.current.indexStatus).toEqual("pending");
155
      await waitForNextUpdate();
156
      expect(result.current.values).toEqual({});
157
      expect(result.current.indexStatus).toEqual("rejected");
158
      // handleError was called once on initial fetch.
159
      expect(handleError.mock.calls.length).toBe(1);
160
      // Get error from argument to mocked function.
161
      const initialError = handleError.mock.calls[0][0];
162
      expect(initialError).toBeInstanceOf(FetchError);
163
      expect(initialError.response.status).toBe(404);
164
    });
165
  });
166
  describe("test refresh callback", () => {
167
    it("refresh() triggers a GET request to endpoint and sets status to 'pending'", async () => {
168
      fetchMock.getOnce(endpoint, []);
169
      const { result, waitForNextUpdate } = renderHook(() =>
170
        useResourceIndex(endpoint, { initialValue: [] }),
171
      );
172
      expect(result.current.indexStatus).toBe("initial");
173
      await act(async () => {
174
        result.current.refresh();
175
        await waitForNextUpdate();
176
        expect(result.current.indexStatus).toEqual("pending");
177
      });
178
      expect(fetchMock.called()).toBe(true);
179
    });
180
    it("refresh() returns fetch result and updates hook value", async () => {
181
      const responseValue = [
182
        { id: 1, name: "one" },
183
        { id: 2, name: "two" },
184
      ];
185
      fetchMock.mock(endpoint, responseValue);
186
      const { result } = renderHook(() =>
187
        useResourceIndex(endpoint, { initialValue: [] }),
188
      );
189
      expect(result.current.values).toEqual({});
190
      expect(result.current.indexStatus).toEqual("initial");
191
      await act(async () => {
192
        const refreshValue = await result.current.refresh();
193
        expect(refreshValue).toEqual(responseValue);
194
      });
195
      expect(result.current.indexStatus).toEqual("fulfilled");
196
      expect(result.current.values).toEqual(arrayToIndexedObj(responseValue));
197
    });
198
    it("refresh() rejects with an error when fetch returns a server error", async () => {
199
      fetchMock.once(endpoint, 404);
200
      const handleError = jest.fn();
201
      const initialValue = [
202
        { id: 1, name: "one" },
203
        { id: 2, name: "two" },
204
      ];
205
      const { result } = renderHook(() =>
206
        useResourceIndex(endpoint, { initialValue, handleError }),
207
      );
208
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
209
      expect(result.current.indexStatus).toEqual("initial");
210
      await act(async () => {
211
        await expect(result.current.refresh()).rejects.toBeInstanceOf(
212
          FetchError,
213
        );
214
      });
215
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
216
      expect(result.current.indexStatus).toEqual("rejected");
217
      // handleError was called once on initial fetch.
218
      expect(handleError.mock.calls.length).toBe(1);
219
      // Get error from argument to mocked function.
220
      const initialError = handleError.mock.calls[0][0];
221
      expect(initialError).toBeInstanceOf(FetchError);
222
      expect(initialError.response.status).toBe(404);
223
    });
224
    it("when refresh() returns a server error, handleError is called", async () => {
225
      fetchMock.once("*", 404);
226
      const handleError = jest.fn();
227
      const initialValue = [
228
        { id: 1, name: "one" },
229
        { id: 2, name: "two" },
230
      ];
231
      const { result } = renderHook(() =>
232
        useResourceIndex(endpoint, { initialValue, handleError }),
233
      );
234
      await act(async () => {
235
        await expect(result.current.refresh()).rejects.toBeInstanceOf(
236
          FetchError,
237
        );
238
      });
239
      // handleError was called once on initial fetch.
240
      expect(handleError.mock.calls.length).toBe(1);
241
      // Get error from argument to mocked function.
242
      const initialError = handleError.mock.calls[0][0];
243
      expect(initialError).toBeInstanceOf(FetchError);
244
      expect(initialError.response.status).toBe(404);
245
    });
246
    it("when refresh() triggers a Fetch error, handleError is called", async () => {
247
      fetchMock.once("*", { throws: new Error("Failed to fetch") });
248
      const handleError = jest.fn();
249
      const initialValue = [
250
        { id: 1, name: "one" },
251
        { id: 2, name: "two" },
252
      ];
253
      const { result } = renderHook(() =>
254
        useResourceIndex(endpoint, { initialValue, handleError }),
255
      );
256
      await act(async () => {
257
        await expect(result.current.refresh()).rejects.toBeInstanceOf(Error);
258
      });
259
      // handleError was called once on initial fetch.
260
      expect(handleError.mock.calls.length).toBe(1);
261
      // Get error from argument to mocked function.
262
      const initialError = handleError.mock.calls[0][0];
263
      expect(initialError.message).toBe("Failed to fetch");
264
    });
265
    it("when refresh() returns invalid JSON, handleError is called", async () => {
266
      fetchMock.once("*", "This is the response");
267
      const handleError = jest.fn();
268
      const initialValue = [
269
        { id: 1, name: "one" },
270
        { id: 2, name: "two" },
271
      ];
272
      const { result } = renderHook(() =>
273
        useResourceIndex(endpoint, { initialValue, handleError }),
274
      );
275
      await act(async () => {
276
        await expect(result.current.refresh()).rejects.toBeInstanceOf(Error);
277
      });
278
      // handleError was called once on initial fetch.
279
      expect(handleError.mock.calls.length).toBe(1);
280
      // Get error from argument to mocked function.
281
      const initialError = handleError.mock.calls[0][0];
282
      expect(
283
        initialError.message.startsWith("invalid json response body"),
284
      ).toBe(true);
285
    });
286
    it("when refresh() returns an object with no id, handleError is called", async () => {
287
      fetchMock.once("*", [
288
        { id: 1, name: "one valid JSON" },
289
        { name: "two valid JSON but no id" },
290
      ]);
291
      const handleError = jest.fn();
292
      const initialValue = [
293
        { id: 1, name: "one" },
294
        { id: 2, name: "two" },
295
      ];
296
      const { result } = renderHook(() =>
297
        useResourceIndex(endpoint, { initialValue, handleError }),
298
      );
299
      await act(async () => {
300
        await expect(result.current.refresh()).rejects.toBeInstanceOf(Error);
301
      });
302
      // handleError was called once on initial fetch.
303
      expect(handleError.mock.calls.length).toBe(1);
304
      // Get error from argument to mocked function.
305
      const initialError = handleError.mock.calls[0][0];
306
      expect(initialError.message).toBe(UNEXPECTED_FORMAT_ERROR);
307
    });
308
    it("If refresh() is called twice, and one request returns, status remains pending", async () => {
309
      fetchMock.once(endpoint, [], {
310
        delay: 10,
311
      });
312
      // Second call will take longer.
313
      fetchMock.mock("*", [], {
314
        delay: 20,
315
      });
316
      const { result } = renderHook(() =>
317
        useResourceIndex(endpoint, { initialValue: [] }),
318
      );
319
      await act(async () => {
320
        const refreshPromise1 = result.current.refresh();
321
        const refreshPromise2 = result.current.refresh();
322
        await refreshPromise1;
323
        expect(result.current.indexStatus).toEqual("pending");
324
        await refreshPromise2;
325
        expect(result.current.indexStatus).toEqual("fulfilled");
326
      });
327
    });
328
    it("refresh() sets status on all entities to pending, and to fulfilled when it completes", async () => {
329
      fetchMock.mock("*", [
330
        { id: 1, name: "NEW one" },
331
        { id: 2, name: "NEW two" },
332
      ]);
333
      const initialValue = [
334
        { id: 1, name: "one" },
335
        { id: 2, name: "two" },
336
      ];
337
      const { result, waitForNextUpdate } = renderHook(() =>
338
        useResourceIndex(endpoint, { initialValue }),
339
      );
340
      await act(async () => {
341
        result.current.refresh();
342
        await waitForNextUpdate();
343
        expect(result.current.entityStatus).toEqual({
344
          1: "pending",
345
          2: "pending",
346
        });
347
        await waitForNextUpdate();
348
      });
349
      expect(result.current.entityStatus).toEqual({
350
        1: "fulfilled",
351
        2: "fulfilled",
352
      });
353
    });
354
    it("A successful refresh will overwrite a previous error state", async () => {
355
      const responseValue = [
356
        { id: 1, name: "NEW one" },
357
        { id: 2, name: "NEW two" },
358
      ];
359
      // First request fails, second succeeds
360
      fetchMock.once(endpoint, 404);
361
      fetchMock.mock("*", responseValue);
362
      const { result, waitForNextUpdate } = renderHook(() =>
363
        useResourceIndex(endpoint),
364
      );
365
      await waitForNextUpdate();
366
      expect(result.current.indexStatus).toBe("rejected");
367
      await act(async () => {
368
        await result.current.refresh();
369
      });
370
      expect(result.current.indexStatus).toBe("fulfilled");
371
      expect(result.current.values).toEqual(arrayToIndexedObj(responseValue));
372
    });
373
  });
374
  describe("test create callback", () => {
375
    it("create() changes createStatus from initial to pending, and to fulfilled when it completes", async () => {
376
      fetchMock.mock("*", { id: 1, name: "one" });
377
      const { result, waitForNextUpdate } = renderHook(() =>
378
        useResourceIndex<TestResource>(endpoint, { initialValue: [] }),
379
      );
380
      expect(result.current.createStatus).toBe("initial");
381
      await act(async () => {
382
        result.current.create({ id: 0, name: "one" });
383
        await waitForNextUpdate();
384
        expect(result.current.createStatus).toBe("pending");
385
        await waitForNextUpdate();
386
        expect(result.current.createStatus).toBe("fulfilled");
387
      });
388
    });
389
    it("create() triggers a POST request to endpoint", async () => {
390
      fetchMock.postOnce(endpoint, { id: 1, name: "one" });
391
      const { result } = renderHook(() =>
392
        useResourceIndex<TestResource>(endpoint, { initialValue: [] }),
393
      );
394
      await act(async () => {
395
        await result.current.create({ id: 0, name: "one" });
396
      });
397
      expect(fetchMock.called()).toBe(true);
398
    });
399
    it("If resolveCreateEndpoint is set, create() triggers a POST request to the resulting endpoint", async () => {
400
      const resolveCreateEndpoint = (baseEndpoint, newEntity) =>
401
        `${baseEndpoint}/createTest/${newEntity.name}`;
402
      const newEntity = { id: 0, name: "one" };
403
      fetchMock.postOnce(resolveCreateEndpoint(endpoint, newEntity), {
404
        id: 1,
405
        name: "one",
406
      });
407
      const { result } = renderHook(() =>
408
        useResourceIndex<TestResource>(endpoint, {
409
          initialValue: [],
410
          resolveCreateEndpoint,
411
        }),
412
      );
413
      await act(async () => {
414
        await result.current.create(newEntity);
415
      });
416
      expect(fetchMock.called()).toBe(true);
417
    });
418
    it("create() returns fetch result and adds to values when it completes", async () => {
419
      const initialValue = [
420
        { id: 1, name: "one" },
421
        { id: 2, name: "two" },
422
      ];
423
      const createValue = { id: 0, name: "three" };
424
      // NOTE: The value returned from server may be slightly different from what we send it - likely a different id.
425
      const responseValue = { id: 3, name: "three" };
426
      fetchMock.postOnce(endpoint, responseValue);
427
      const { result } = renderHook(() =>
428
        useResourceIndex(endpoint, { initialValue }),
429
      );
430
      await act(async () => {
431
        const createResponseValue = await result.current.create(createValue);
432
        expect(createResponseValue).toEqual(responseValue);
433
      });
434
      // Ensure the new value is the one returned from request, not what we tried to send.
435
      expect(result.current.values[3]).toEqual(responseValue);
436
    });
437
    it("create() rejects with an error (leaving values unchanged) when fetch returns a server error", async () => {
438
      const initialValue = [
439
        { id: 1, name: "one" },
440
        { id: 2, name: "two" },
441
      ];
442
      const createValue = { id: 3, name: "three" };
443
      fetchMock.postOnce("*", 404);
444
      const { result } = renderHook(() =>
445
        useResourceIndex(endpoint, { initialValue }),
446
      );
447
      await act(async () => {
448
        await expect(result.current.create(createValue)).rejects.toBeInstanceOf(
449
          FetchError,
450
        );
451
      });
452
      // Values should be unchanged from initial values because create request failed, though createStatus should be different.
453
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
454
      expect(result.current.createStatus).toEqual("rejected");
455
    });
456
    it("when create() returns a server error, handleError is called", async () => {
457
      const initialValue = [
458
        { id: 1, name: "one" },
459
        { id: 2, name: "two" },
460
      ];
461
      const createValue = { id: 3, name: "three" };
462
      fetchMock.postOnce("*", 404);
463
      const handleError = jest.fn();
464
      const { result } = renderHook(() =>
465
        useResourceIndex(endpoint, { initialValue, handleError }),
466
      );
467
      await act(async () => {
468
        await expect(result.current.create(createValue)).rejects.toBeInstanceOf(
469
          FetchError,
470
        );
471
      });
472
473
      // handleError was called once on initial fetch.
474
      expect(handleError.mock.calls.length).toBe(1);
475
      // Get error from argument to mocked function.
476
      const initialError = handleError.mock.calls[0][0];
477
      expect(initialError).toBeInstanceOf(FetchError);
478
      expect(initialError.response.status).toBe(404);
479
    });
480
    it("when create() triggers a Fetch error, it is handled correctly", async () => {
481
      const initialValue = [
482
        { id: 1, name: "one" },
483
        { id: 2, name: "two" },
484
      ];
485
      const createValue = { id: 3, name: "three" };
486
      fetchMock.postOnce("*", { throws: new Error("Failed to fetch") });
487
      const handleError = jest.fn();
488
      const { result } = renderHook(() =>
489
        useResourceIndex(endpoint, { initialValue, handleError }),
490
      );
491
      await act(async () => {
492
        await expect(result.current.create(createValue)).rejects.toBeInstanceOf(
493
          Error,
494
        );
495
      });
496
497
      // Values should be unchanged from initial values because create request failed, though createStatus should be different.
498
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
499
      expect(result.current.createStatus).toEqual("rejected");
500
501
      // handleError was called once on initial fetch.
502
      expect(handleError.mock.calls.length).toBe(1);
503
      // Get error from argument to mocked function.
504
      const initialError = handleError.mock.calls[0][0];
505
      expect(initialError).toBeInstanceOf(Error);
506
      expect(initialError.message).toBe("Failed to fetch");
507
    });
508
    it("when create() returns invalid JSON, error is handled correctly", async () => {
509
      const initialValue = [
510
        { id: 1, name: "one" },
511
        { id: 2, name: "two" },
512
      ];
513
      const createValue = { id: 3, name: "three" };
514
      fetchMock.postOnce("*", "This response is not JSON");
515
      const handleError = jest.fn();
516
      const { result } = renderHook(() =>
517
        useResourceIndex(endpoint, { initialValue, handleError }),
518
      );
519
      await act(async () => {
520
        await expect(result.current.create(createValue)).rejects.toBeInstanceOf(
521
          Error,
522
        );
523
      });
524
525
      // Values should be unchanged from initial values because create request failed, though createStatus should be different.
526
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
527
      expect(result.current.createStatus).toEqual("rejected");
528
529
      // handleError was called once on initial fetch.
530
      expect(handleError.mock.calls.length).toBe(1);
531
      // Get error from argument to mocked function.
532
      const initialError = handleError.mock.calls[0][0];
533
      expect(initialError).toBeInstanceOf(Error);
534
      expect(
535
        initialError.message.startsWith("invalid json response body"),
536
      ).toBe(true);
537
    });
538
    it("when create() returns an object with an id which already exists in values, that object is updated", async () => {
539
      const initialValue = [
540
        { id: 1, name: "one" },
541
        { id: 2, name: "two" },
542
      ];
543
      const duplicateValue = { id: 2, name: "UPDATED two" };
544
      fetchMock.postOnce("*", duplicateValue);
545
      const { result } = renderHook(() =>
546
        useResourceIndex(endpoint, { initialValue }),
547
      );
548
      await act(async () => {
549
        await result.current.create(duplicateValue);
550
      });
551
      expect(result.current.values[2]).toEqual(duplicateValue);
552
      expect(result.current.createStatus).toEqual("fulfilled");
553
    });
554
    it("If multiple create requests are started, createStatus remains pending untill all are complete", async () => {
555
      const createOne = { id: 0, name: "one" };
556
      const createTwo = { id: 0, name: "two" };
557
      // NOTE: The value returned from server may be slightly different from what we send it - likely a different id.
558
      fetchMock.postOnce(endpoint, createOne);
559
      fetchMock.post("*", createTwo, { delay: 5 });
560
      const { result } = renderHook(() =>
561
        useResourceIndex<TestResource>(endpoint, { initialValue: [] }),
562
      );
563
      await act(async () => {
564
        const createPromise1 = result.current.create(createOne);
565
        const createPromise2 = result.current.create(createTwo);
566
        await createPromise1;
567
        expect(result.current.values).toEqual(arrayToIndexedObj([createOne]));
568
        expect(result.current.createStatus).toEqual("pending");
569
        await createPromise2;
570
        expect(result.current.values).toEqual(
571
          arrayToIndexedObj([createOne, createTwo]),
572
        );
573
        expect(result.current.createStatus).toEqual("fulfilled");
574
      });
575
    });
576
    it("A successful create() will overwrite a previous error state", async () => {
577
      const createOne = { id: 0, name: "one" };
578
      const createTwo = { id: 0, name: "two" };
579
      // NOTE: The value returned from server may be slightly different from what we send it - likely a different id.
580
      fetchMock.postOnce(endpoint, 404);
581
      fetchMock.post("*", createTwo, { delay: 5 });
582
      const { result } = renderHook(() =>
583
        useResourceIndex<TestResource>(endpoint, { initialValue: [] }),
584
      );
585
      await act(async () => {
586
        await expect(result.current.create(createOne)).rejects.toThrow();
587
        expect(result.current.values).toEqual({});
588
        expect(result.current.createStatus).toEqual("rejected");
589
        await result.current.create(createTwo);
590
        expect(result.current.values).toEqual(arrayToIndexedObj([createTwo]));
591
        expect(result.current.createStatus).toEqual("fulfilled");
592
      });
593
    });
594
  });
595
  describe("test update callback", () => {
596
    it("update() changes status of specific entity to pending, and then fulfilled, without affecting status of others", async () => {
597
      const initialValue = [
598
        { id: 1, name: "one" },
599
        { id: 2, name: "two" },
600
      ];
601
      const updateValue = { id: 2, name: "UPDATE two" };
602
      fetchMock.mock("*", updateValue);
603
      const { result, waitForNextUpdate } = renderHook(() =>
604
        useResourceIndex(endpoint, { initialValue }),
605
      );
606
      expect(result.current.entityStatus[2]).toEqual("initial");
607
      expect(result.current.entityStatus[1]).toEqual("initial");
608
      expect(result.current.indexStatus).toEqual("initial");
609
      await act(async () => {
610
        result.current.update(updateValue);
611
        await waitForNextUpdate();
612
        expect(result.current.entityStatus[2]).toEqual("pending");
613
        expect(result.current.entityStatus[1]).toEqual("initial");
614
        expect(result.current.indexStatus).toEqual("initial");
615
        await waitForNextUpdate();
616
        expect(result.current.entityStatus[2]).toEqual("fulfilled");
617
        expect(result.current.entityStatus[1]).toEqual("initial");
618
        expect(result.current.indexStatus).toEqual("initial");
619
      });
620
    });
621
    it("update() triggers a PUT request to endpoint (with id appended)", async () => {
622
      const initialValue = [
623
        { id: 1, name: "one" },
624
        { id: 2, name: "two" },
625
      ];
626
      const updateValue = { id: 2, name: "UPDATE two" };
627
      fetchMock.putOnce(`${endpoint}/${updateValue.id}`, updateValue);
628
      const { result } = renderHook(() =>
629
        useResourceIndex(endpoint, { initialValue }),
630
      );
631
      await act(async () => {
632
        await result.current.update(updateValue);
633
      });
634
      expect(fetchMock.called()).toBe(true);
635
    });
636
    it("If resolveEntityEndpoint is set, update() triggers a PUT request to resulting endpoint", async () => {
637
      const initialValue = [
638
        { id: 1, name: "one" },
639
        { id: 2, name: "two" },
640
      ];
641
      const updateValue = { id: 2, name: "UPDATE two" };
642
      const resolveEntityEndpoint = (baseEndpoint, id) =>
643
        `${baseEndpoint}/resolveEntityTest/${id}`;
644
      fetchMock.putOnce(
645
        resolveEntityEndpoint(endpoint, updateValue.id),
646
        updateValue,
647
      );
648
      const { result } = renderHook(() =>
649
        useResourceIndex(endpoint, { initialValue, resolveEntityEndpoint }),
650
      );
651
      await act(async () => {
652
        await result.current.update(updateValue);
653
      });
654
      expect(fetchMock.called()).toBe(true);
655
    });
656
    it("update() returns fetch result and updates values when it completes", async () => {
657
      const initialValue = [
658
        { id: 1, name: "one" },
659
        { id: 2, name: "two" },
660
      ];
661
      const updateValue = { id: 2, name: "UPDATE two" };
662
      // NOTE: The value returned from server may be slightly different from what we send it.
663
      const responseValue = { id: 2, name: "UPDATE two RETURNED" };
664
      fetchMock.putOnce("*", responseValue);
665
      const { result } = renderHook(() =>
666
        useResourceIndex(endpoint, { initialValue }),
667
      );
668
      await act(async () => {
669
        const updateResponseValue = await result.current.update(updateValue);
670
        expect(updateResponseValue).toEqual(responseValue);
671
      });
672
      // Ensure the new value is the one returned from request, not what we tried to send.
673
      expect(result.current.values[2]).toEqual(responseValue);
674
    });
675
    it("update() rejects with an error (leaving values unchanged) when fetch returns a server error", async () => {
676
      const initialValue = [
677
        { id: 1, name: "one" },
678
        { id: 2, name: "two" },
679
      ];
680
      const updateValue = { id: 2, name: "UPDATE two" };
681
      fetchMock.putOnce("*", 404);
682
      const { result } = renderHook(() =>
683
        useResourceIndex(endpoint, { initialValue }),
684
      );
685
      await act(async () => {
686
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
687
          FetchError,
688
        );
689
      });
690
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
691
      expect(result.current.entityStatus[2]).toEqual("rejected");
692
    });
693
    it("when update() returns a server error, handleError is called", async () => {
694
      fetchMock.putOnce("*", 404);
695
      const handleError = jest.fn();
696
      const initialValue = [
697
        { id: 1, name: "one" },
698
        { id: 2, name: "two" },
699
      ];
700
      const updateValue = { id: 2, name: "UPDATE two" };
701
      const { result } = renderHook(() =>
702
        useResourceIndex(endpoint, { initialValue, handleError }),
703
      );
704
      await act(async () => {
705
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
706
          FetchError,
707
        );
708
      });
709
      // handleError was called once on initial fetch.
710
      expect(handleError.mock.calls.length).toBe(1);
711
      // Get error from argument to mocked function.
712
      const initialError = handleError.mock.calls[0][0];
713
      expect(initialError).toBeInstanceOf(FetchError);
714
      expect(initialError.response.status).toBe(404);
715
    });
716
    it("when update() triggers a Fetch error, error is handled correctly", async () => {
717
      fetchMock.putOnce("*", { throws: new Error("Failed to fetch") });
718
      const handleError = jest.fn();
719
      const initialValue = [
720
        { id: 1, name: "one" },
721
        { id: 2, name: "two" },
722
      ];
723
      const updateValue = { id: 2, name: "UPDATE two" };
724
      const { result } = renderHook(() =>
725
        useResourceIndex(endpoint, { initialValue, handleError }),
726
      );
727
      await act(async () => {
728
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
729
          Error,
730
        );
731
      });
732
      // handleError was called once on initial fetch.
733
      expect(handleError.mock.calls.length).toBe(1);
734
      // Get error from argument to mocked function.
735
      const initialError = handleError.mock.calls[0][0];
736
      expect(initialError.message).toBe("Failed to fetch");
737
    });
738
    it("when update() is called with an object whose id doesn't exist yet, state is unchanged until update request is fulfilled", async () => {
739
      const initialValue = [
740
        { id: 1, name: "one" },
741
        { id: 2, name: "two" },
742
      ];
743
      const newValue = { id: 3, name: "three" };
744
      fetchMock.putOnce("*", newValue);
745
      const { result, waitForNextUpdate } = renderHook(() =>
746
        useResourceIndex(endpoint, { initialValue }),
747
      );
748
      await act(async () => {
749
        result.current.update(newValue);
750
        await waitForNextUpdate();
751
        expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
752
        await waitForNextUpdate();
753
        expect(result.current.values[3]).toEqual(newValue);
754
        expect(result.current.entityStatus[3]).toEqual("fulfilled");
755
      });
756
    });
757
    it("when update() returns invalid JSON, error is handled correctly", async () => {
758
      fetchMock.putOnce("*", "This is the response");
759
      const handleError = jest.fn();
760
      const initialValue = [
761
        { id: 1, name: "one" },
762
        { id: 2, name: "two" },
763
      ];
764
      const updateValue = { id: 2, name: "UPDATE two" };
765
      const { result } = renderHook(() =>
766
        useResourceIndex(endpoint, { initialValue, handleError }),
767
      );
768
      await act(async () => {
769
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
770
          Error,
771
        );
772
      });
773
      // handleError was called once on initial fetch.
774
      expect(handleError.mock.calls.length).toBe(1);
775
      // Get error from argument to mocked function.
776
      const initialError = handleError.mock.calls[0][0];
777
      expect(
778
        initialError.message.startsWith("invalid json response body"),
779
      ).toBe(true);
780
    });
781
    it("when update() returns an object with no id, error is handled correctly", async () => {
782
      fetchMock.putOnce("*", { name: "This is valid JSON now" });
783
      const handleError = jest.fn();
784
      const initialValue = [
785
        { id: 1, name: "one" },
786
        { id: 2, name: "two" },
787
      ];
788
      const updateValue = { id: 2, name: "UPDATE two" };
789
      const { result } = renderHook(() =>
790
        useResourceIndex(endpoint, { initialValue, handleError }),
791
      );
792
      await act(async () => {
793
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
794
          Error,
795
        );
796
      });
797
      // handleError was called once on initial fetch.
798
      expect(handleError.mock.calls.length).toBe(1);
799
      // Get error from argument to mocked function.
800
      const initialError = handleError.mock.calls[0][0];
801
      expect(initialError.message).toBe(UNEXPECTED_FORMAT_ERROR);
802
    });
803
    it("If multiple update requests are started, status remains pending until all are complete", async () => {
804
      const response1 = { id: 2, name: "UPDATE two v1" };
805
      const response2 = { id: 2, name: "UPDATE two v2" };
806
      fetchMock.once(`${endpoint}/2`, response1);
807
      // Second call will take longer.
808
      fetchMock.mock("*", response2, {
809
        delay: 5,
810
      });
811
      const initialValue = [
812
        { id: 1, name: "one" },
813
        { id: 2, name: "two" },
814
      ];
815
      const updateValue = { id: 2, name: "UPDATE two" };
816
      const { result } = renderHook(() =>
817
        useResourceIndex(endpoint, { initialValue }),
818
      );
819
      await act(async () => {
820
        const updatePromise1 = result.current.update(updateValue);
821
        const updatePromise2 = result.current.update(updateValue);
822
        await updatePromise1;
823
        expect(result.current.values[2]).toEqual(response1);
824
        expect(result.current.entityStatus[2]).toEqual("pending");
825
        await updatePromise2;
826
        expect(result.current.values[2]).toEqual(response2);
827
        expect(result.current.entityStatus[2]).toEqual("fulfilled");
828
      });
829
    });
830
    it("A successful update() will overwrite a previous error state", async () => {
831
      const initialValue = [
832
        { id: 1, name: "one" },
833
        { id: 2, name: "two" },
834
      ];
835
      const updateValue = { id: 2, name: "UPDATE two" };
836
      // First request fails, second succeeds
837
      fetchMock.putOnce(`${endpoint}/${updateValue.id}`, 404);
838
      fetchMock.mock("*", updateValue);
839
      const { result } = renderHook(() =>
840
        useResourceIndex(endpoint, { initialValue }),
841
      );
842
      await act(async () => {
843
        expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
844
          FetchError,
845
        );
846
      });
847
      expect(result.current.entityStatus[2]).toBe("rejected");
848
      await act(async () => {
849
        await result.current.update(updateValue);
850
      });
851
      expect(result.current.entityStatus[2]).toBe("fulfilled");
852
      expect(result.current.values[2]).toEqual(updateValue);
853
    });
854
  });
855
  describe("test deleteResource callback", () => {
856
    it("deleteResource() changes status of specific entity from initial to pending. Status and value are removed when it completes.", async () => {
857
      const initialValue = [
858
        { id: 1, name: "one" },
859
        { id: 2, name: "two" },
860
      ];
861
      fetchMock.mock("*", 200, { delay: 5 });
862
      const { result, waitForNextUpdate } = renderHook(() =>
863
        useResourceIndex(endpoint, { initialValue }),
864
      );
865
      expect(result.current.entityStatus[2]).toEqual("initial");
866
      expect(result.current.entityStatus[1]).toEqual("initial");
867
      expect(result.current.indexStatus).toEqual("initial");
868
      await act(async () => {
869
        result.current.deleteResource(2);
870
        await waitForNextUpdate();
871
        expect(result.current.entityStatus[2]).toEqual("pending");
872
        expect(result.current.entityStatus[1]).toEqual("initial");
873
        expect(result.current.indexStatus).toEqual("initial");
874
        await waitForNextUpdate();
875
        expect(hasKey(result.current.entityStatus, 2)).toBe(false);
876
        expect(hasKey(result.current.values, 2)).toBe(false);
877
        expect(result.current.entityStatus[1]).toEqual("initial");
878
        expect(result.current.indexStatus).toEqual("initial");
879
      });
880
    });
881
    it("deleteResource() triggers a DELETE request to endpoint with id appended", async () => {
882
      const initialValue = [
883
        { id: 1, name: "one" },
884
        { id: 2, name: "two" },
885
      ];
886
      fetchMock.deleteOnce(`${endpoint}/2`, 200);
887
      const { result } = renderHook(() =>
888
        useResourceIndex(endpoint, { initialValue }),
889
      );
890
      await act(async () => {
891
        await result.current.deleteResource(2);
892
      });
893
      expect(fetchMock.called()).toBe(true);
894
    });
895
    it("If resolveEntityEndpoint is set, deleteResource() triggers a DELETE request to resulting endpoint", async () => {
896
      const initialValue = [
897
        { id: 1, name: "one" },
898
        { id: 2, name: "two" },
899
      ];
900
      const resolveEntityEndpoint = (baseEndpoint, id) =>
901
        `${baseEndpoint}/resolveEntityTest/${id}`;
902
      fetchMock.deleteOnce(resolveEntityEndpoint(endpoint, 2), 200);
903
      const { result } = renderHook(() =>
904
        useResourceIndex(endpoint, { initialValue, resolveEntityEndpoint }),
905
      );
906
      await act(async () => {
907
        await result.current.deleteResource(2);
908
      });
909
      expect(fetchMock.called()).toBe(true);
910
    });
911
    it("deleteResource() resolves when entity is removed from values", async () => {
912
      const initialValue = [
913
        { id: 1, name: "one" },
914
        { id: 2, name: "two" },
915
      ];
916
      fetchMock.mock("*", 200, { delay: 5 });
917
      const { result } = renderHook(() =>
918
        useResourceIndex(endpoint, { initialValue }),
919
      );
920
      await act(async () => {
921
        await result.current.deleteResource(2);
922
        expect(hasKey(result.current.entityStatus, 2)).toBe(false);
923
        expect(hasKey(result.current.values, 2)).toBe(false);
924
      });
925
    });
926
    it("deleteResource() rejects with an error (leaving values unchanged) when fetch returns a server error", async () => {
927
      const initialValue = [
928
        { id: 1, name: "one" },
929
        { id: 2, name: "two" },
930
      ];
931
      fetchMock.deleteOnce("*", 404);
932
      const { result } = renderHook(() =>
933
        useResourceIndex(endpoint, { initialValue }),
934
      );
935
      await act(async () => {
936
        await expect(result.current.deleteResource(2)).rejects.toBeInstanceOf(
937
          FetchError,
938
        );
939
      });
940
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
941
      expect(result.current.entityStatus[2]).toEqual("rejected");
942
    });
943
    it("when deleteResource() returns a server error, handleError is called", async () => {
944
      fetchMock.deleteOnce("*", 500);
945
      const handleError = jest.fn();
946
      const initialValue = [
947
        { id: 1, name: "one" },
948
        { id: 2, name: "two" },
949
      ];
950
      const { result } = renderHook(() =>
951
        useResourceIndex(endpoint, { initialValue, handleError }),
952
      );
953
      await act(async () => {
954
        await expect(result.current.deleteResource(2)).rejects.toBeInstanceOf(
955
          FetchError,
956
        );
957
      });
958
      // handleError was called once on initial fetch.
959
      expect(handleError.mock.calls.length).toBe(1);
960
      // Get error from argument to mocked function.
961
      const initialError = handleError.mock.calls[0][0];
962
      expect(initialError).toBeInstanceOf(FetchError);
963
      expect(initialError.response.status).toBe(500);
964
    });
965
    it("when deleteResource() triggers a Fetch error, handleError is called", async () => {
966
      fetchMock.deleteOnce("*", { throws: new Error("Failed to fetch") });
967
      const handleError = jest.fn();
968
      const initialValue = [
969
        { id: 1, name: "one" },
970
        { id: 2, name: "two" },
971
      ];
972
      const { result } = renderHook(() =>
973
        useResourceIndex(endpoint, { initialValue, handleError }),
974
      );
975
      await act(async () => {
976
        await expect(result.current.deleteResource(2)).rejects.toBeInstanceOf(
977
          Error,
978
        );
979
      });
980
      // handleError was called once on initial fetch.
981
      expect(handleError.mock.calls.length).toBe(1);
982
      // Get error from argument to mocked function.
983
      const initialError = handleError.mock.calls[0][0];
984
      expect(initialError).toBeInstanceOf(Error);
985
      expect(initialError).toEqual(new Error("Failed to fetch"));
986
    });
987
    it("if deleteResource() is called multiple times on the same id, status should remain pending until one succeeds. Later callbacks should not change values.", async () => {
988
      const initialValue = [
989
        { id: 1, name: "one" },
990
        { id: 2, name: "two" },
991
      ];
992
      // First call will fail, subsequent calls will succeed.
993
      fetchMock.deleteOnce(`${endpoint}/2`, 500);
994
      fetchMock.delete("*", 200, { delay: 5 });
995
      const { result } = renderHook(() =>
996
        useResourceIndex(endpoint, { initialValue }),
997
      );
998
      await act(async () => {
999
        const deletePromise1 = result.current.deleteResource(2);
1000
        const deletePromise2 = result.current.deleteResource(2);
1001
        const deletePromise3 = result.current.deleteResource(2);
1002
        await expect(deletePromise1).rejects.toThrow();
1003
        expect(result.current.entityStatus[2]).toBe("pending");
1004
        await deletePromise2;
1005
        const expectValue = { 1: { id: 1, name: "one" } };
1006
        expect(result.current.values).toEqual(expectValue);
1007
        await deletePromise3;
1008
        expect(result.current.values).toEqual(expectValue);
1009
      });
1010
    });
1011
  });
1012
});
1013