Passed
Push — feature/azure-webapp-pipeline-... ( 271549...3c88ad )
by Grant
07:11 queued 10s
created

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

Complexity

Total Complexity 1
Complexity/F 1

Size

Lines of Code 1091
Function Count 1

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 1
eloc 842
c 0
b 0
f 0
dl 0
loc 1091
rs 9.7579
mnd 0
bc 0
fnc 1
bpm 0
cpm 1
noi 0
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
    it("Can store multiple items with same id if keyFn is overridden", async () => {
166
      const initialValue = [
167
        { id: 1, type: "red", name: "one" },
168
        { id: 1, type: "blue", name: "two" },
169
      ];
170
      const responseValue = [
171
        { id: 1, type: "red", name: "one" },
172
        { id: 1, type: "blue", name: "two NEW" },
173
      ];
174
      const keyFn = (item: any) => `${item.type}-${item.id}`;
175
      fetchMock.once("*", responseValue);
176
      const { result, waitForNextUpdate } = renderHook(() =>
177
        useResourceIndex(endpoint, {
178
          initialValue,
179
          keyFn,
180
          forceInitialRefresh: true,
181
        }),
182
      );
183
      expect(result.current.values).toEqual({
184
        "red-1": initialValue[0],
185
        "blue-1": initialValue[1],
186
      });
187
      await waitForNextUpdate({ timeout: false });
188
      expect(result.current.values).toEqual({
189
        "red-1": responseValue[0],
190
        "blue-1": responseValue[1],
191
      });
192
    });
193
  });
194
  describe("test refresh callback", () => {
195
    it("refresh() triggers a GET request to endpoint and sets status to 'pending'", async () => {
196
      fetchMock.getOnce(endpoint, []);
197
      const { result, waitForNextUpdate } = renderHook(() =>
198
        useResourceIndex(endpoint, { initialValue: [] }),
199
      );
200
      expect(result.current.indexStatus).toBe("initial");
201
      await act(async () => {
202
        result.current.refresh();
203
        await waitForNextUpdate({ timeout: false });
204
        expect(result.current.indexStatus).toEqual("pending");
205
      });
206
      expect(fetchMock.called()).toBe(true);
207
    });
208
    it("refresh() returns fetch result and updates hook value", async () => {
209
      const responseValue = [
210
        { id: 1, name: "one" },
211
        { id: 2, name: "two" },
212
      ];
213
      fetchMock.mock(endpoint, responseValue);
214
      const { result } = renderHook(() =>
215
        useResourceIndex(endpoint, { initialValue: [] }),
216
      );
217
      expect(result.current.values).toEqual({});
218
      expect(result.current.indexStatus).toEqual("initial");
219
      await act(async () => {
220
        const refreshValue = await result.current.refresh();
221
        expect(refreshValue).toEqual(responseValue);
222
      });
223
      expect(result.current.indexStatus).toEqual("fulfilled");
224
      expect(result.current.values).toEqual(arrayToIndexedObj(responseValue));
225
    });
226
    it("refresh() rejects with an error when fetch returns a server error", async () => {
227
      fetchMock.once(endpoint, 404);
228
      const handleError = jest.fn();
229
      const initialValue = [
230
        { id: 1, name: "one" },
231
        { id: 2, name: "two" },
232
      ];
233
      const { result } = renderHook(() =>
234
        useResourceIndex(endpoint, { initialValue, handleError }),
235
      );
236
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
237
      expect(result.current.indexStatus).toEqual("initial");
238
      await act(async () => {
239
        await expect(result.current.refresh()).rejects.toBeInstanceOf(
240
          FetchError,
241
        );
242
      });
243
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
244
      expect(result.current.indexStatus).toEqual("rejected");
245
      // handleError was called once on initial fetch.
246
      expect(handleError.mock.calls.length).toBe(1);
247
      // Get error from argument to mocked function.
248
      const initialError = handleError.mock.calls[0][0];
249
      expect(initialError).toBeInstanceOf(FetchError);
250
      expect(initialError.response.status).toBe(404);
251
    });
252
    it("when refresh() returns a server error, handleError is called", async () => {
253
      fetchMock.once("*", 404);
254
      const handleError = jest.fn();
255
      const initialValue = [
256
        { id: 1, name: "one" },
257
        { id: 2, name: "two" },
258
      ];
259
      const { result } = renderHook(() =>
260
        useResourceIndex(endpoint, { initialValue, handleError }),
261
      );
262
      await act(async () => {
263
        await expect(result.current.refresh()).rejects.toBeInstanceOf(
264
          FetchError,
265
        );
266
      });
267
      // handleError was called once on initial fetch.
268
      expect(handleError.mock.calls.length).toBe(1);
269
      // Get error from argument to mocked function.
270
      const initialError = handleError.mock.calls[0][0];
271
      expect(initialError).toBeInstanceOf(FetchError);
272
      expect(initialError.response.status).toBe(404);
273
    });
274
    it("when refresh() triggers a Fetch error, handleError is called", async () => {
275
      fetchMock.once("*", { throws: new Error("Failed to fetch") });
276
      const handleError = jest.fn();
277
      const initialValue = [
278
        { id: 1, name: "one" },
279
        { id: 2, name: "two" },
280
      ];
281
      const { result } = renderHook(() =>
282
        useResourceIndex(endpoint, { initialValue, handleError }),
283
      );
284
      await act(async () => {
285
        await expect(result.current.refresh()).rejects.toBeInstanceOf(Error);
286
      });
287
      // handleError was called once on initial fetch.
288
      expect(handleError.mock.calls.length).toBe(1);
289
      // Get error from argument to mocked function.
290
      const initialError = handleError.mock.calls[0][0];
291
      expect(initialError.message).toBe("Failed to fetch");
292
    });
293
    it("when refresh() returns invalid JSON, handleError is called", async () => {
294
      fetchMock.once("*", "This is the response");
295
      const handleError = jest.fn();
296
      const initialValue = [
297
        { id: 1, name: "one" },
298
        { id: 2, name: "two" },
299
      ];
300
      const { result } = renderHook(() =>
301
        useResourceIndex(endpoint, { initialValue, handleError }),
302
      );
303
      await act(async () => {
304
        await expect(result.current.refresh()).rejects.toBeInstanceOf(Error);
305
      });
306
      // handleError was called once on initial fetch.
307
      expect(handleError.mock.calls.length).toBe(1);
308
      // Get error from argument to mocked function.
309
      const initialError = handleError.mock.calls[0][0];
310
      expect(
311
        initialError.message.startsWith("invalid json response body"),
312
      ).toBe(true);
313
    });
314
    it("when refresh() returns an object with no id, handleError is called", async () => {
315
      fetchMock.once("*", [
316
        { id: 1, name: "one valid JSON" },
317
        { name: "two valid JSON but no id" },
318
      ]);
319
      const handleError = jest.fn();
320
      const initialValue = [
321
        { id: 1, name: "one" },
322
        { id: 2, name: "two" },
323
      ];
324
      const { result } = renderHook(() =>
325
        useResourceIndex(endpoint, { initialValue, handleError }),
326
      );
327
      await act(async () => {
328
        await expect(result.current.refresh()).rejects.toBeInstanceOf(Error);
329
      });
330
      // handleError was called once on initial fetch.
331
      expect(handleError.mock.calls.length).toBe(1);
332
      // Get error from argument to mocked function.
333
      const initialError = handleError.mock.calls[0][0];
334
      expect(initialError.message).toBe(UNEXPECTED_FORMAT_ERROR);
335
    });
336
    it("If refresh() is called twice, and one request returns, status remains pending", async () => {
337
      fetchMock.once(endpoint, [], {
338
        delay: 10,
339
      });
340
      // Second call will take longer.
341
      fetchMock.mock("*", [], {
342
        delay: 20,
343
      });
344
      const { result } = renderHook(() =>
345
        useResourceIndex(endpoint, { initialValue: [] }),
346
      );
347
      await act(async () => {
348
        const refreshPromise1 = result.current.refresh();
349
        const refreshPromise2 = result.current.refresh();
350
        await refreshPromise1;
351
        expect(result.current.indexStatus).toEqual("pending");
352
        await refreshPromise2;
353
        expect(result.current.indexStatus).toEqual("fulfilled");
354
      });
355
    });
356
    it("refresh() sets status on all entities to pending, and to fulfilled when it completes", async () => {
357
      fetchMock.mock("*", [
358
        { id: 1, name: "NEW one" },
359
        { id: 2, name: "NEW two" },
360
      ]);
361
      const initialValue = [
362
        { id: 1, name: "one" },
363
        { id: 2, name: "two" },
364
      ];
365
      const { result, waitForNextUpdate } = renderHook(() =>
366
        useResourceIndex(endpoint, { initialValue }),
367
      );
368
      await act(async () => {
369
        result.current.refresh();
370
        await waitForNextUpdate({ timeout: false });
371
        expect(result.current.entityStatus).toEqual({
372
          1: "pending",
373
          2: "pending",
374
        });
375
        await waitForNextUpdate();
376
      });
377
      expect(result.current.entityStatus).toEqual({
378
        1: "fulfilled",
379
        2: "fulfilled",
380
      });
381
    });
382
    it("A successful refresh will overwrite a previous error state", async () => {
383
      const responseValue = [
384
        { id: 1, name: "NEW one" },
385
        { id: 2, name: "NEW two" },
386
      ];
387
      // First request fails, second succeeds
388
      fetchMock.once(endpoint, 404);
389
      fetchMock.mock("*", responseValue);
390
      const { result, waitForNextUpdate } = renderHook(() =>
391
        useResourceIndex(endpoint),
392
      );
393
      await waitForNextUpdate();
394
      expect(result.current.indexStatus).toBe("rejected");
395
      await act(async () => {
396
        await result.current.refresh();
397
      });
398
      expect(result.current.indexStatus).toBe("fulfilled");
399
      expect(result.current.values).toEqual(arrayToIndexedObj(responseValue));
400
    });
401
  });
402
  describe("test create callback", () => {
403
    it("create() changes createStatus from initial to pending, and to fulfilled when it completes", async () => {
404
      fetchMock.mock("*", { id: 1, name: "one" });
405
      const { result, waitForNextUpdate } = renderHook(() =>
406
        useResourceIndex<TestResource>(endpoint, { initialValue: [] }),
407
      );
408
      expect(result.current.createStatus).toBe("initial");
409
      await act(async () => {
410
        result.current.create({ id: 0, name: "one" });
411
        await waitForNextUpdate({ timeout: false });
412
        expect(result.current.createStatus).toBe("pending");
413
        await waitForNextUpdate();
414
        expect(result.current.createStatus).toBe("fulfilled");
415
      });
416
    });
417
    it("create() triggers a POST request to endpoint", async () => {
418
      fetchMock.postOnce(endpoint, { id: 1, name: "one" });
419
      const { result } = renderHook(() =>
420
        useResourceIndex<TestResource>(endpoint, { initialValue: [] }),
421
      );
422
      await act(async () => {
423
        await result.current.create({ id: 0, name: "one" });
424
      });
425
      expect(fetchMock.called()).toBe(true);
426
    });
427
    it("If resolveCreateEndpoint is set, create() triggers a POST request to the resulting endpoint", async () => {
428
      const resolveCreateEndpoint = (baseEndpoint, newEntity) =>
429
        `${baseEndpoint}/createTest/${newEntity.name}`;
430
      const newEntity = { id: 0, name: "one" };
431
      fetchMock.postOnce(resolveCreateEndpoint(endpoint, newEntity), {
432
        id: 1,
433
        name: "one",
434
      });
435
      const { result } = renderHook(() =>
436
        useResourceIndex<TestResource>(endpoint, {
437
          initialValue: [],
438
          resolveCreateEndpoint,
439
        }),
440
      );
441
      await act(async () => {
442
        await result.current.create(newEntity);
443
      });
444
      expect(fetchMock.called()).toBe(true);
445
    });
446
    it("create() returns fetch result and adds to values when it completes", async () => {
447
      const initialValue = [
448
        { id: 1, name: "one" },
449
        { id: 2, name: "two" },
450
      ];
451
      const createValue = { id: 0, name: "three" };
452
      // NOTE: The value returned from server may be slightly different from what we send it - likely a different id.
453
      const responseValue = { id: 3, name: "three" };
454
      fetchMock.postOnce(endpoint, responseValue);
455
      const { result } = renderHook(() =>
456
        useResourceIndex(endpoint, { initialValue }),
457
      );
458
      await act(async () => {
459
        const createResponseValue = await result.current.create(createValue);
460
        expect(createResponseValue).toEqual(responseValue);
461
      });
462
      // Ensure the new value is the one returned from request, not what we tried to send.
463
      expect(result.current.values[3]).toEqual(responseValue);
464
    });
465
    it("create() rejects with an error (leaving values unchanged) when fetch returns a server error", async () => {
466
      const initialValue = [
467
        { id: 1, name: "one" },
468
        { id: 2, name: "two" },
469
      ];
470
      const createValue = { id: 3, name: "three" };
471
      fetchMock.postOnce("*", 404);
472
      const { result } = renderHook(() =>
473
        useResourceIndex(endpoint, { initialValue }),
474
      );
475
      await act(async () => {
476
        await expect(result.current.create(createValue)).rejects.toBeInstanceOf(
477
          FetchError,
478
        );
479
      });
480
      // Values should be unchanged from initial values because create request failed, though createStatus should be different.
481
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
482
      expect(result.current.createStatus).toEqual("rejected");
483
    });
484
    it("when create() returns a server error, handleError is called", async () => {
485
      const initialValue = [
486
        { id: 1, name: "one" },
487
        { id: 2, name: "two" },
488
      ];
489
      const createValue = { id: 3, name: "three" };
490
      fetchMock.postOnce("*", 404);
491
      const handleError = jest.fn();
492
      const { result } = renderHook(() =>
493
        useResourceIndex(endpoint, { initialValue, handleError }),
494
      );
495
      await act(async () => {
496
        await expect(result.current.create(createValue)).rejects.toBeInstanceOf(
497
          FetchError,
498
        );
499
      });
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(FetchError);
506
      expect(initialError.response.status).toBe(404);
507
    });
508
    it("when create() triggers a Fetch error, it 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("*", { throws: new Error("Failed to fetch") });
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(initialError.message).toBe("Failed to fetch");
535
    });
536
    it("when create() returns invalid JSON, error is handled correctly", async () => {
537
      const initialValue = [
538
        { id: 1, name: "one" },
539
        { id: 2, name: "two" },
540
      ];
541
      const createValue = { id: 3, name: "three" };
542
      fetchMock.postOnce("*", "This response is not JSON");
543
      const handleError = jest.fn();
544
      const { result } = renderHook(() =>
545
        useResourceIndex(endpoint, { initialValue, handleError }),
546
      );
547
      await act(async () => {
548
        await expect(result.current.create(createValue)).rejects.toBeInstanceOf(
549
          Error,
550
        );
551
      });
552
553
      // Values should be unchanged from initial values because create request failed, though createStatus should be different.
554
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
555
      expect(result.current.createStatus).toEqual("rejected");
556
557
      // handleError was called once on initial fetch.
558
      expect(handleError.mock.calls.length).toBe(1);
559
      // Get error from argument to mocked function.
560
      const initialError = handleError.mock.calls[0][0];
561
      expect(initialError).toBeInstanceOf(Error);
562
      expect(
563
        initialError.message.startsWith("invalid json response body"),
564
      ).toBe(true);
565
    });
566
    it("when create() returns an object with an id which already exists in values, that object is updated", async () => {
567
      const initialValue = [
568
        { id: 1, name: "one" },
569
        { id: 2, name: "two" },
570
      ];
571
      const duplicateValue = { id: 2, name: "UPDATED two" };
572
      fetchMock.postOnce("*", duplicateValue);
573
      const { result } = renderHook(() =>
574
        useResourceIndex(endpoint, { initialValue }),
575
      );
576
      await act(async () => {
577
        await result.current.create(duplicateValue);
578
      });
579
      expect(result.current.values[2]).toEqual(duplicateValue);
580
      expect(result.current.createStatus).toEqual("fulfilled");
581
    });
582
    it("If multiple create requests are started, createStatus remains pending until all are complete", async () => {
583
      const createOne = { id: 0, name: "one" };
584
      const createTwo = { id: 0, name: "two" };
585
      // NOTE: The value returned from server may be slightly different from what we send it - likely a different id.
586
      fetchMock.postOnce(endpoint, createOne);
587
      fetchMock.post("*", createTwo, { delay: 5 });
588
      const { result } = renderHook(() =>
589
        useResourceIndex<TestResource>(endpoint, { initialValue: [] }),
590
      );
591
      await act(async () => {
592
        const createPromise1 = result.current.create(createOne);
593
        const createPromise2 = result.current.create(createTwo);
594
        await createPromise1;
595
        expect(result.current.values).toEqual(arrayToIndexedObj([createOne]));
596
        expect(result.current.createStatus).toEqual("pending");
597
        await createPromise2;
598
        expect(result.current.values).toEqual(
599
          arrayToIndexedObj([createOne, createTwo]),
600
        );
601
        expect(result.current.createStatus).toEqual("fulfilled");
602
      });
603
    });
604
    it("A successful create() will overwrite a previous error state", async () => {
605
      const createOne = { id: 0, name: "one" };
606
      const createTwo = { id: 0, name: "two" };
607
      // NOTE: The value returned from server may be slightly different from what we send it - likely a different id.
608
      fetchMock.postOnce(endpoint, 404);
609
      fetchMock.post("*", createTwo, { delay: 5 });
610
      const { result } = renderHook(() =>
611
        useResourceIndex<TestResource>(endpoint, { initialValue: [] }),
612
      );
613
      await act(async () => {
614
        await expect(result.current.create(createOne)).rejects.toThrow();
615
        expect(result.current.values).toEqual({});
616
        expect(result.current.createStatus).toEqual("rejected");
617
        await result.current.create(createTwo);
618
        expect(result.current.values).toEqual(arrayToIndexedObj([createTwo]));
619
        expect(result.current.createStatus).toEqual("fulfilled");
620
      });
621
    });
622
    it("Can store new item at correct key if keyFn is overridden", async () => {
623
      const createValue = { id: 1, type: "red", name: "one" };
624
      const keyFn = (item: any) => `${item.type}-${item.id}`;
625
      fetchMock.once("*", createValue);
626
      const { result } = renderHook(() =>
627
        useResourceIndex(endpoint, { initialValue: [], keyFn }),
628
      );
629
      expect(result.current.values).toEqual({});
630
      await act(async () => {
631
        await result.current.create(createValue);
632
      });
633
      expect(result.current.values).toEqual({
634
        "red-1": createValue,
635
      });
636
    });
637
  });
638
  describe("test update callback", () => {
639
    it("update() changes status of specific entity to pending, and then fulfilled, without affecting status of others", async () => {
640
      const initialValue = [
641
        { id: 1, name: "one" },
642
        { id: 2, name: "two" },
643
      ];
644
      const updateValue = { id: 2, name: "UPDATE two" };
645
      fetchMock.mock("*", updateValue);
646
      const { result, waitForNextUpdate } = renderHook(() =>
647
        useResourceIndex(endpoint, { initialValue }),
648
      );
649
      expect(result.current.entityStatus[2]).toEqual("initial");
650
      expect(result.current.entityStatus[1]).toEqual("initial");
651
      expect(result.current.indexStatus).toEqual("initial");
652
      await act(async () => {
653
        result.current.update(updateValue);
654
        await waitForNextUpdate({ timeout: false });
655
        expect(result.current.entityStatus[2]).toEqual("pending");
656
        expect(result.current.entityStatus[1]).toEqual("initial");
657
        expect(result.current.indexStatus).toEqual("initial");
658
        await waitForNextUpdate();
659
        expect(result.current.entityStatus[2]).toEqual("fulfilled");
660
        expect(result.current.entityStatus[1]).toEqual("initial");
661
        expect(result.current.indexStatus).toEqual("initial");
662
      });
663
    });
664
    it("update() triggers a PUT request to endpoint (with id appended)", async () => {
665
      const initialValue = [
666
        { id: 1, name: "one" },
667
        { id: 2, name: "two" },
668
      ];
669
      const updateValue = { id: 2, name: "UPDATE two" };
670
      fetchMock.putOnce(`${endpoint}/${updateValue.id}`, updateValue);
671
      const { result } = renderHook(() =>
672
        useResourceIndex(endpoint, { initialValue }),
673
      );
674
      await act(async () => {
675
        await result.current.update(updateValue);
676
      });
677
      expect(fetchMock.called()).toBe(true);
678
    });
679
    it("If resolveEntityEndpoint is set, update() triggers a PUT request to resulting endpoint", async () => {
680
      const initialValue = [
681
        { id: 1, name: "one" },
682
        { id: 2, name: "two" },
683
      ];
684
      const updateValue = { id: 2, name: "UPDATE two" };
685
      const resolveEntityEndpoint = (baseEndpoint, entity) =>
686
        `${baseEndpoint}/resolveEntityTest/${entity.id}`;
687
      fetchMock.putOnce(
688
        resolveEntityEndpoint(endpoint, updateValue),
689
        updateValue,
690
      );
691
      const { result } = renderHook(() =>
692
        useResourceIndex(endpoint, { initialValue, resolveEntityEndpoint }),
693
      );
694
      await act(async () => {
695
        await result.current.update(updateValue);
696
      });
697
      expect(fetchMock.called()).toBe(true);
698
    });
699
    it("update() returns fetch result and updates values when it completes", async () => {
700
      const initialValue = [
701
        { id: 1, name: "one" },
702
        { id: 2, name: "two" },
703
      ];
704
      const updateValue = { id: 2, name: "UPDATE two" };
705
      // NOTE: The value returned from server may be slightly different from what we send it.
706
      const responseValue = { id: 2, name: "UPDATE two RETURNED" };
707
      fetchMock.putOnce("*", responseValue);
708
      const { result } = renderHook(() =>
709
        useResourceIndex(endpoint, { initialValue }),
710
      );
711
      await act(async () => {
712
        const updateResponseValue = await result.current.update(updateValue);
713
        expect(updateResponseValue).toEqual(responseValue);
714
      });
715
      // Ensure the new value is the one returned from request, not what we tried to send.
716
      expect(result.current.values[2]).toEqual(responseValue);
717
    });
718
    it("update() rejects with an error (leaving values unchanged) when fetch returns a server error", async () => {
719
      const initialValue = [
720
        { id: 1, name: "one" },
721
        { id: 2, name: "two" },
722
      ];
723
      const updateValue = { id: 2, name: "UPDATE two" };
724
      fetchMock.putOnce("*", 404);
725
      const { result } = renderHook(() =>
726
        useResourceIndex(endpoint, { initialValue }),
727
      );
728
      await act(async () => {
729
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
730
          FetchError,
731
        );
732
      });
733
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
734
      expect(result.current.entityStatus[2]).toEqual("rejected");
735
    });
736
    it("when update() returns a server error, handleError is called", async () => {
737
      fetchMock.putOnce("*", 404);
738
      const handleError = jest.fn();
739
      const initialValue = [
740
        { id: 1, name: "one" },
741
        { id: 2, name: "two" },
742
      ];
743
      const updateValue = { id: 2, name: "UPDATE two" };
744
      const { result } = renderHook(() =>
745
        useResourceIndex(endpoint, { initialValue, handleError }),
746
      );
747
      await act(async () => {
748
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
749
          FetchError,
750
        );
751
      });
752
      // handleError was called once on initial fetch.
753
      expect(handleError.mock.calls.length).toBe(1);
754
      // Get error from argument to mocked function.
755
      const initialError = handleError.mock.calls[0][0];
756
      expect(initialError).toBeInstanceOf(FetchError);
757
      expect(initialError.response.status).toBe(404);
758
    });
759
    it("when update() triggers a Fetch error, error is handled correctly", async () => {
760
      fetchMock.putOnce("*", { throws: new Error("Failed to fetch") });
761
      const handleError = jest.fn();
762
      const initialValue = [
763
        { id: 1, name: "one" },
764
        { id: 2, name: "two" },
765
      ];
766
      const updateValue = { id: 2, name: "UPDATE two" };
767
      const { result } = renderHook(() =>
768
        useResourceIndex(endpoint, { initialValue, handleError }),
769
      );
770
      await act(async () => {
771
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
772
          Error,
773
        );
774
      });
775
      // handleError was called once on initial fetch.
776
      expect(handleError.mock.calls.length).toBe(1);
777
      // Get error from argument to mocked function.
778
      const initialError = handleError.mock.calls[0][0];
779
      expect(initialError.message).toBe("Failed to fetch");
780
    });
781
    it("when update() is called with an object whose id doesn't exist yet, state is unchanged until update request is fulfilled", async () => {
782
      const initialValue = [
783
        { id: 1, name: "one" },
784
        { id: 2, name: "two" },
785
      ];
786
      const newValue = { id: 3, name: "three" };
787
      fetchMock.putOnce("*", newValue);
788
      const { result, waitForNextUpdate } = renderHook(() =>
789
        useResourceIndex(endpoint, { initialValue }),
790
      );
791
      await act(async () => {
792
        result.current.update(newValue);
793
        await waitForNextUpdate({ timeout: false });
794
        expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
795
        await waitForNextUpdate();
796
        expect(result.current.values[3]).toEqual(newValue);
797
        expect(result.current.entityStatus[3]).toEqual("fulfilled");
798
      });
799
    });
800
    it("when update() returns invalid JSON, error is handled correctly", async () => {
801
      fetchMock.putOnce("*", "This is the response");
802
      const handleError = jest.fn();
803
      const initialValue = [
804
        { id: 1, name: "one" },
805
        { id: 2, name: "two" },
806
      ];
807
      const updateValue = { id: 2, name: "UPDATE two" };
808
      const { result } = renderHook(() =>
809
        useResourceIndex(endpoint, { initialValue, handleError }),
810
      );
811
      await act(async () => {
812
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
813
          Error,
814
        );
815
      });
816
      // handleError was called once on initial fetch.
817
      expect(handleError.mock.calls.length).toBe(1);
818
      // Get error from argument to mocked function.
819
      const initialError = handleError.mock.calls[0][0];
820
      expect(
821
        initialError.message.startsWith("invalid json response body"),
822
      ).toBe(true);
823
    });
824
    it("when update() returns an object with no id, error is handled correctly", async () => {
825
      fetchMock.putOnce("*", { name: "This is valid JSON now" });
826
      const handleError = jest.fn();
827
      const initialValue = [
828
        { id: 1, name: "one" },
829
        { id: 2, name: "two" },
830
      ];
831
      const updateValue = { id: 2, name: "UPDATE two" };
832
      const { result } = renderHook(() =>
833
        useResourceIndex(endpoint, { initialValue, handleError }),
834
      );
835
      await act(async () => {
836
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
837
          Error,
838
        );
839
      });
840
      // handleError was called once on initial fetch.
841
      expect(handleError.mock.calls.length).toBe(1);
842
      // Get error from argument to mocked function.
843
      const initialError = handleError.mock.calls[0][0];
844
      expect(initialError.message).toBe(UNEXPECTED_FORMAT_ERROR);
845
    });
846
    it("If multiple update requests are started, status remains pending until all are complete", async () => {
847
      const response1 = { id: 2, name: "UPDATE two v1" };
848
      const response2 = { id: 2, name: "UPDATE two v2" };
849
      fetchMock.once(`${endpoint}/2`, response1);
850
      // Second call will take longer.
851
      fetchMock.mock("*", response2, {
852
        delay: 5,
853
      });
854
      const initialValue = [
855
        { id: 1, name: "one" },
856
        { id: 2, name: "two" },
857
      ];
858
      const updateValue = { id: 2, name: "UPDATE two" };
859
      const { result } = renderHook(() =>
860
        useResourceIndex(endpoint, { initialValue }),
861
      );
862
      await act(async () => {
863
        const updatePromise1 = result.current.update(updateValue);
864
        const updatePromise2 = result.current.update(updateValue);
865
        await updatePromise1;
866
        expect(result.current.values[2]).toEqual(response1);
867
        expect(result.current.entityStatus[2]).toEqual("pending");
868
        await updatePromise2;
869
        expect(result.current.values[2]).toEqual(response2);
870
        expect(result.current.entityStatus[2]).toEqual("fulfilled");
871
      });
872
    });
873
    it("A successful update() will overwrite a previous error state", async () => {
874
      const initialValue = [
875
        { id: 1, name: "one" },
876
        { id: 2, name: "two" },
877
      ];
878
      const updateValue = { id: 2, name: "UPDATE two" };
879
      // First request fails, second succeeds
880
      fetchMock.putOnce(`${endpoint}/${updateValue.id}`, 404);
881
      fetchMock.mock("*", updateValue);
882
      const { result } = renderHook(() =>
883
        useResourceIndex(endpoint, { initialValue }),
884
      );
885
      await act(async () => {
886
        expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
887
          FetchError,
888
        );
889
      });
890
      expect(result.current.entityStatus[2]).toBe("rejected");
891
      await act(async () => {
892
        await result.current.update(updateValue);
893
      });
894
      expect(result.current.entityStatus[2]).toBe("fulfilled");
895
      expect(result.current.values[2]).toEqual(updateValue);
896
    });
897
    it("Will update the correct value if keyFn is overridden", async () => {
898
      const initialValue = [
899
        { id: 1, type: "red", name: "one" },
900
        { id: 1, type: "blue", name: "two" },
901
      ];
902
      const responseValue = { id: 1, type: "blue", name: "NEW one" };
903
      const keyFn = (item: any) => `${item.type}-${item.id}`;
904
      fetchMock.once("*", responseValue);
905
      const { result } = renderHook(() =>
906
        useResourceIndex(endpoint, { initialValue, keyFn }),
907
      );
908
      expect(result.current.values).toEqual({
909
        "red-1": initialValue[0],
910
        "blue-1": initialValue[1],
911
      });
912
      await act(async () => {
913
        await result.current.update(responseValue);
914
      });
915
      expect(result.current.values).toEqual({
916
        "red-1": initialValue[0],
917
        "blue-1": responseValue,
918
      });
919
    });
920
  });
921
  describe("test deleteResource callback", () => {
922
    it("deleteResource() changes status of specific entity from initial to pending. Status and value are removed when it completes.", async () => {
923
      const initialValue = [
924
        { id: 1, name: "one" },
925
        { id: 2, name: "two" },
926
      ];
927
      fetchMock.mock("*", 200, { delay: 5 });
928
      const { result, waitForNextUpdate } = renderHook(() =>
929
        useResourceIndex(endpoint, { initialValue }),
930
      );
931
      expect(result.current.entityStatus[2]).toEqual("initial");
932
      expect(result.current.entityStatus[1]).toEqual("initial");
933
      expect(result.current.indexStatus).toEqual("initial");
934
      await act(async () => {
935
        result.current.deleteResource({ id: 2, name: "two" });
936
        await waitForNextUpdate();
937
        expect(result.current.entityStatus[2]).toEqual("pending");
938
        expect(result.current.entityStatus[1]).toEqual("initial");
939
        expect(result.current.indexStatus).toEqual("initial");
940
        await waitForNextUpdate();
941
        expect(hasKey(result.current.entityStatus, 2)).toBe(false);
942
        expect(hasKey(result.current.values, 2)).toBe(false);
943
        expect(result.current.entityStatus[1]).toEqual("initial");
944
        expect(result.current.indexStatus).toEqual("initial");
945
      });
946
    });
947
    it("deleteResource() triggers a DELETE request to endpoint with id appended", async () => {
948
      const initialValue = [
949
        { id: 1, name: "one" },
950
        { id: 2, name: "two" },
951
      ];
952
      fetchMock.deleteOnce(`${endpoint}/2`, 200);
953
      const { result } = renderHook(() =>
954
        useResourceIndex(endpoint, { initialValue }),
955
      );
956
      await act(async () => {
957
        await result.current.deleteResource({ id: 2, name: "two" });
958
      });
959
      expect(fetchMock.called()).toBe(true);
960
    });
961
    it("If resolveEntityEndpoint is set, deleteResource() triggers a DELETE request to resulting endpoint", async () => {
962
      const initialValue = [
963
        { id: 1, name: "one" },
964
        { id: 2, name: "two" },
965
      ];
966
      const resolveEntityEndpoint = (baseEndpoint, entity) =>
967
        `${baseEndpoint}/resolveEntityTest/${entity.id}`;
968
      fetchMock.deleteOnce(
969
        resolveEntityEndpoint(endpoint, { id: 2, name: "two" }),
970
        200,
971
      );
972
      const { result } = renderHook(() =>
973
        useResourceIndex(endpoint, { initialValue, resolveEntityEndpoint }),
974
      );
975
      await act(async () => {
976
        await result.current.deleteResource({ id: 2, name: "two" });
977
      });
978
      expect(fetchMock.called()).toBe(true);
979
    });
980
    it("deleteResource() resolves when entity is removed from values", async () => {
981
      const initialValue = [
982
        { id: 1, name: "one" },
983
        { id: 2, name: "two" },
984
      ];
985
      fetchMock.mock("*", 200, { delay: 5 });
986
      const { result } = renderHook(() =>
987
        useResourceIndex(endpoint, { initialValue }),
988
      );
989
      await act(async () => {
990
        await result.current.deleteResource({ id: 2, name: "two" });
991
        expect(hasKey(result.current.entityStatus, 2)).toBe(false);
992
        expect(hasKey(result.current.values, 2)).toBe(false);
993
      });
994
    });
995
    it("deleteResource() rejects with an error (leaving values unchanged) when fetch returns a server error", async () => {
996
      const initialValue = [
997
        { id: 1, name: "one" },
998
        { id: 2, name: "two" },
999
      ];
1000
      fetchMock.deleteOnce("*", 404);
1001
      const { result } = renderHook(() =>
1002
        useResourceIndex(endpoint, { initialValue }),
1003
      );
1004
      await act(async () => {
1005
        await expect(
1006
          result.current.deleteResource({ id: 2, name: "two" }),
1007
        ).rejects.toBeInstanceOf(FetchError);
1008
      });
1009
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
1010
      expect(result.current.entityStatus[2]).toEqual("rejected");
1011
    });
1012
    it("when deleteResource() returns a server error, handleError is called", async () => {
1013
      fetchMock.deleteOnce("*", 500);
1014
      const handleError = jest.fn();
1015
      const initialValue = [
1016
        { id: 1, name: "one" },
1017
        { id: 2, name: "two" },
1018
      ];
1019
      const { result } = renderHook(() =>
1020
        useResourceIndex(endpoint, { initialValue, handleError }),
1021
      );
1022
      await act(async () => {
1023
        await expect(
1024
          result.current.deleteResource({ id: 2, name: "two" }),
1025
        ).rejects.toBeInstanceOf(FetchError);
1026
      });
1027
      // handleError was called once on initial fetch.
1028
      expect(handleError.mock.calls.length).toBe(1);
1029
      // Get error from argument to mocked function.
1030
      const initialError = handleError.mock.calls[0][0];
1031
      expect(initialError).toBeInstanceOf(FetchError);
1032
      expect(initialError.response.status).toBe(500);
1033
    });
1034
    it("when deleteResource() triggers a Fetch error, handleError is called", async () => {
1035
      fetchMock.deleteOnce("*", { throws: new Error("Failed to fetch") });
1036
      const handleError = jest.fn();
1037
      const initialValue = [
1038
        { id: 1, name: "one" },
1039
        { id: 2, name: "two" },
1040
      ];
1041
      const { result } = renderHook(() =>
1042
        useResourceIndex(endpoint, { initialValue, handleError }),
1043
      );
1044
      await act(async () => {
1045
        await expect(
1046
          result.current.deleteResource({ id: 2, name: "two" }),
1047
        ).rejects.toBeInstanceOf(Error);
1048
      });
1049
      // handleError was called once on initial fetch.
1050
      expect(handleError.mock.calls.length).toBe(1);
1051
      // Get error from argument to mocked function.
1052
      const initialError = handleError.mock.calls[0][0];
1053
      expect(initialError).toBeInstanceOf(Error);
1054
      expect(initialError).toEqual(new Error("Failed to fetch"));
1055
    });
1056
    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 () => {
1057
      const initialValue = [
1058
        { id: 1, name: "one" },
1059
        { id: 2, name: "two" },
1060
      ];
1061
      // First call will fail, subsequent calls will succeed.
1062
      fetchMock.deleteOnce(`${endpoint}/2`, 500);
1063
      fetchMock.delete("*", 200, { delay: 5 });
1064
      const { result } = renderHook(() =>
1065
        useResourceIndex(endpoint, { initialValue }),
1066
      );
1067
      await act(async () => {
1068
        const deletePromise1 = result.current.deleteResource({
1069
          id: 2,
1070
          name: "two",
1071
        });
1072
        const deletePromise2 = result.current.deleteResource({
1073
          id: 2,
1074
          name: "two",
1075
        });
1076
        const deletePromise3 = result.current.deleteResource({
1077
          id: 2,
1078
          name: "two",
1079
        });
1080
        await expect(deletePromise1).rejects.toThrow();
1081
        expect(result.current.entityStatus[2]).toBe("pending");
1082
        await deletePromise2;
1083
        const expectValue = { 1: { id: 1, name: "one" } };
1084
        expect(result.current.values).toEqual(expectValue);
1085
        await deletePromise3;
1086
        expect(result.current.values).toEqual(expectValue);
1087
      });
1088
    });
1089
  });
1090
});
1091