svelte-ag 1.0.57 → 1.0.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/api/query/cache.unit.test.d.ts +2 -0
- package/dist/lib/api/query/cache.unit.test.d.ts.map +1 -0
- package/dist/lib/api/query/cache.unit.test.js +50 -0
- package/dist/lib/api/query/entrypoint.unit.test.d.ts +2 -0
- package/dist/lib/api/query/entrypoint.unit.test.d.ts.map +1 -0
- package/dist/lib/api/query/entrypoint.unit.test.js +56 -0
- package/dist/lib/api/query/query.svelte.d.ts +1 -1
- package/dist/lib/api/query/query.svelte.d.ts.map +1 -1
- package/dist/lib/api/query/query.svelte.js +49 -23
- package/dist/lib/api/query/query.unit.test.d.ts +2 -0
- package/dist/lib/api/query/query.unit.test.d.ts.map +1 -0
- package/dist/lib/api/query/query.unit.test.js +276 -0
- package/dist/test/vitest.setup.d.ts +1 -0
- package/dist/test/vitest.setup.d.ts.map +1 -0
- package/dist/test/vitest.setup.js +4 -0
- package/package.json +2 -2
- package/src/lib/api/query/cache.unit.test.ts +66 -0
- package/src/lib/api/query/entrypoint.unit.test.ts +85 -0
- package/src/lib/api/query/query.svelte.ts +57 -26
- package/src/lib/api/query/query.unit.test.ts +367 -0
- package/src/test/vitest.setup.ts +3 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.unit.test.d.ts","sourceRoot":"","sources":["../../../../src/lib/api/query/cache.unit.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { Cache } from './cache.svelte.js';
|
|
3
|
+
describe('Cache', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
vi.useFakeTimers();
|
|
6
|
+
vi.setSystemTime(0);
|
|
7
|
+
});
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.useRealTimers();
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
it('stores and expires values based on timeout', () => {
|
|
13
|
+
const cache = new Cache();
|
|
14
|
+
cache.register('user', { timeout: 100 });
|
|
15
|
+
cache.set('user', { id: 1 });
|
|
16
|
+
expect(cache.has('user')).toBe(true);
|
|
17
|
+
expect(cache.get('user')).toEqual({ id: 1 });
|
|
18
|
+
vi.advanceTimersByTime(99);
|
|
19
|
+
expect(cache.has('user')).toBe(true);
|
|
20
|
+
expect(cache.get('user')).toEqual({ id: 1 });
|
|
21
|
+
vi.advanceTimersByTime(1);
|
|
22
|
+
expect(cache.has('user')).toBe(false);
|
|
23
|
+
expect(cache.get('user')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
it('supports infinite timeout and reset', () => {
|
|
26
|
+
const cache = new Cache();
|
|
27
|
+
cache.register('settings', { timeout: 'inf' });
|
|
28
|
+
cache.set('settings', { theme: 'light' });
|
|
29
|
+
vi.advanceTimersByTime(10_000);
|
|
30
|
+
expect(cache.has('settings')).toBe(true);
|
|
31
|
+
expect(cache.get('settings')).toEqual({ theme: 'light' });
|
|
32
|
+
cache.reset('settings');
|
|
33
|
+
expect(cache.has('settings')).toBe(false);
|
|
34
|
+
expect(cache.get('settings')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
it('deregisters keys completely', () => {
|
|
37
|
+
const cache = new Cache();
|
|
38
|
+
cache.register('token', { timeout: 100 });
|
|
39
|
+
cache.set('token', 'abc');
|
|
40
|
+
cache.deregister('token');
|
|
41
|
+
expect(cache.has('token')).toBe(false);
|
|
42
|
+
expect(() => cache.get('token')).toThrow('The key token is not registered in the cache');
|
|
43
|
+
});
|
|
44
|
+
it('throws when mutating an unregistered key', () => {
|
|
45
|
+
const cache = new Cache();
|
|
46
|
+
expect(() => cache.set('missing', 1)).toThrow('The key missing is not registered in the cache');
|
|
47
|
+
expect(() => cache.get('missing')).toThrow('The key missing is not registered in the cache');
|
|
48
|
+
expect(() => cache.reset('missing')).toThrow('The key missing is not registered in the cache');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entrypoint.unit.test.d.ts","sourceRoot":"","sources":["../../../../src/lib/api/query/entrypoint.unit.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
function getUserId(input) {
|
|
3
|
+
return 'id' in input ? input.id : input.ids[0];
|
|
4
|
+
}
|
|
5
|
+
function jsonResponse(body, status = 200) {
|
|
6
|
+
return new Response(JSON.stringify(body), {
|
|
7
|
+
status,
|
|
8
|
+
headers: { 'content-type': 'application/json' }
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
describe('createQueryFunction', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.useFakeTimers();
|
|
14
|
+
vi.setSystemTime(0);
|
|
15
|
+
vi.resetModules();
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.useRealTimers();
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
it('returns the same query instance for the same path, method, and input', async () => {
|
|
22
|
+
const { createQueryFunction } = await import('./entrypoint.svelte.js');
|
|
23
|
+
const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
|
|
24
|
+
const request = requestMock;
|
|
25
|
+
const createQuery = createQueryFunction(request, {});
|
|
26
|
+
const query1 = createQuery('/users', 'GET', { id: 1 });
|
|
27
|
+
const query2 = createQuery('/users', 'GET', { id: 1 });
|
|
28
|
+
const query3 = createQuery('/users', 'GET', { id: 2 });
|
|
29
|
+
expect(query1).toBe(query2);
|
|
30
|
+
expect(query3).not.toBe(query1);
|
|
31
|
+
});
|
|
32
|
+
it('reuses requestors so separate queries can batch together', async () => {
|
|
33
|
+
const { createQueryFunction } = await import('./entrypoint.svelte.js');
|
|
34
|
+
const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
|
|
35
|
+
const request = requestMock;
|
|
36
|
+
const createQuery = createQueryFunction(request, {
|
|
37
|
+
'/users': {
|
|
38
|
+
GET: {
|
|
39
|
+
canBatch: () => 'users',
|
|
40
|
+
batchInput: (inputs) => ({ ids: inputs.map(getUserId) }),
|
|
41
|
+
unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getUserId(input) }))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const query1 = createQuery('/users', 'GET', { id: 1 });
|
|
46
|
+
const query2 = createQuery('/users', 'GET', { id: 2 });
|
|
47
|
+
const p1 = query1.request();
|
|
48
|
+
const p2 = query2.request();
|
|
49
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
50
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
|
|
52
|
+
const [response1, response2] = await Promise.all([p1, p2]);
|
|
53
|
+
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
54
|
+
await expect(response2.json()).resolves.toEqual({ id: 2 });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -6,7 +6,7 @@ export declare class Query<API extends ApiEndpoints, Path extends API['path'], M
|
|
|
6
6
|
path: Path;
|
|
7
7
|
}>['method']> {
|
|
8
8
|
#private;
|
|
9
|
-
constructor({ path, method, input, requestor, cache }: {
|
|
9
|
+
constructor({ path, method, input, requestor, cache, opts }: {
|
|
10
10
|
path: Path;
|
|
11
11
|
method: Method;
|
|
12
12
|
input: ApiInput<API, Path, Method>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/api/query/query.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,kBAAkB,EAAE,cAAc,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AAGnH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAG5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGxD,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"query.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/api/query/query.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,kBAAkB,EAAE,cAAc,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AAGnH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAG5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGxD,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC;AAenE,qBAAa,KAAK,CAChB,GAAG,SAAS,YAAY,EACxB,IAAI,SAAS,GAAG,CAAC,MAAM,CAAC,EACxB,MAAM,SAAS,OAAO,CAAC,GAAG,EAAE;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,CAAC,CAAC,QAAQ,CAAC;;gBAyBzC,EACV,IAAI,EACJ,MAAM,EACN,KAAK,EACL,SAAS,EACT,KAAK,EACL,IAAI,EACL,EAAE;QACD,IAAI,EAAE,IAAI,CAAC;QACX,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,QAAQ,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;QACnC,SAAS,EAAE,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;QACxC,KAAK,EAAE,KAAK,CAAC;QACb,IAAI,CAAC,EAAE;YACL,KAAK,CAAC,EAAE,UAAU,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;SAC1C,CAAC;KACH;IAgBK,OAAO,IAAI,OAAO,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;IAyCxD,IAAI,QAAQ,WAEX;IACD,IAAI,QAAQ,YAEX;IACD,UAAU;IAMV,IAAI,MAAM,gBAET;IACD,IAAI,IAAI,IAAI,cAAc,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,IAAI,CAEnD;IACD,IAAI,SAAS,IAAI,YAAY,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,IAAI,CAEtD;CACF;AAED,qBAAa,SAAS,CACpB,GAAG,SAAS,YAAY,EACxB,IAAI,SAAS,GAAG,CAAC,MAAM,CAAC,EACxB,MAAM,SAAS,OAAO,CAAC,GAAG,EAAE;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,CAAC,CAAC,QAAQ,CAAC;;gBA6BnD,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,kBAAkB,CAAC,GAAG,CAAC,EAChC,MAAM,EAAE,KAAK,EACb,YAAY,CAAC,EAAE,YAAY,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC;YAelC,KAAK;IAOnB;;;OAGG;YACW,eAAe;IA2BvB,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;CAmB3F"}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { stringify } from 'devalue';
|
|
2
2
|
import { cacheKey } from './utils.svelte.js';
|
|
3
3
|
import { RateLimiter } from './rate.svelte';
|
|
4
|
+
function cloneResponse(response) {
|
|
5
|
+
if (response !== null &&
|
|
6
|
+
typeof response === 'object' &&
|
|
7
|
+
'clone' in response &&
|
|
8
|
+
typeof response.clone === 'function') {
|
|
9
|
+
return response.clone();
|
|
10
|
+
}
|
|
11
|
+
return response;
|
|
12
|
+
}
|
|
4
13
|
export class Query {
|
|
5
14
|
// -------- Constants --------
|
|
6
15
|
#TIMEOUT = 1000 * 60 * 5; // 5 minutes
|
|
@@ -20,7 +29,7 @@ export class Query {
|
|
|
20
29
|
#data = $state(null);
|
|
21
30
|
#errorData = $state(null);
|
|
22
31
|
// -------- Functions --------
|
|
23
|
-
constructor({ path, method, input, requestor, cache }) {
|
|
32
|
+
constructor({ path, method, input, requestor, cache, opts }) {
|
|
24
33
|
this.#requestor = requestor;
|
|
25
34
|
this.#cache = cache;
|
|
26
35
|
this.#path = path;
|
|
@@ -29,32 +38,43 @@ export class Query {
|
|
|
29
38
|
this.#input = input;
|
|
30
39
|
this.#inputString = stringify(input);
|
|
31
40
|
this.#cacheKey = cacheKey(path, method, input);
|
|
32
|
-
this.#cache.register(this.#cacheKey, { timeout: this.#TIMEOUT });
|
|
41
|
+
this.#cache.register(this.#cacheKey, opts?.cache ?? { timeout: this.#TIMEOUT });
|
|
33
42
|
}
|
|
34
43
|
async request() {
|
|
35
44
|
const cachedValue = this.#cache.get(this.#cacheKey);
|
|
36
45
|
if (cachedValue !== null) {
|
|
37
|
-
return cachedValue;
|
|
46
|
+
return cloneResponse(cachedValue);
|
|
38
47
|
}
|
|
39
48
|
this.#status = 'loading';
|
|
40
49
|
if (this.#pendingRequest === null) {
|
|
41
50
|
this.#pendingRequest = this.#requestor.request(this.#input);
|
|
42
51
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
let res;
|
|
53
|
+
try {
|
|
54
|
+
res = await this.#pendingRequest;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
this.#status = 'error';
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
this.#pendingRequest = null;
|
|
62
|
+
}
|
|
63
|
+
const responseForState = cloneResponse(res);
|
|
64
|
+
const responseForCaller = cloneResponse(res);
|
|
65
|
+
this.#cache.set(this.#cacheKey, cloneResponse(res));
|
|
66
|
+
if (responseForState.ok === false) {
|
|
67
|
+
const body = await responseForState.json();
|
|
48
68
|
this.#status = 'error';
|
|
49
69
|
// @ts-expect-error Generics not working for some reason
|
|
50
70
|
this.#errorData = body;
|
|
51
|
-
return
|
|
71
|
+
return responseForCaller;
|
|
52
72
|
}
|
|
53
73
|
else {
|
|
54
|
-
const body = await
|
|
74
|
+
const body = await responseForState.json();
|
|
55
75
|
this.#status = 'success';
|
|
56
76
|
this.#data = body;
|
|
57
|
-
return
|
|
77
|
+
return responseForCaller;
|
|
58
78
|
}
|
|
59
79
|
}
|
|
60
80
|
get cacheKey() {
|
|
@@ -117,17 +137,22 @@ export class Requestor {
|
|
|
117
137
|
*/
|
|
118
138
|
async flushBatchQueue(batchId) {
|
|
119
139
|
const queue = this.#batchQueue[batchId].splice(0);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (output
|
|
125
|
-
|
|
140
|
+
try {
|
|
141
|
+
const batchedInput = this.#batchInput(queue.map((q) => q.input));
|
|
142
|
+
const res = await this.fetch(batchedInput);
|
|
143
|
+
const output = await this.#unBatchOutput(queue.map((q) => q.input), res);
|
|
144
|
+
if (output.length !== queue.length) {
|
|
145
|
+
throw new Error(`Batch output length mismatch for ${batchId}`);
|
|
126
146
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
}
|
|
147
|
+
queue.forEach(({ resolve }, i) => {
|
|
148
|
+
resolve(output[i]);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
queue.forEach(({ reject }) => {
|
|
153
|
+
reject(err);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
131
156
|
}
|
|
132
157
|
// Performs a request for a given input. Batches it if possible
|
|
133
158
|
async request(input) {
|
|
@@ -139,8 +164,9 @@ export class Requestor {
|
|
|
139
164
|
this.#batchQueue[batchId].push({ input, resolve, reject });
|
|
140
165
|
if (!this.#batchTimers[batchId]) {
|
|
141
166
|
this.#batchTimers[batchId] = setTimeout(() => {
|
|
142
|
-
this.flushBatchQueue(batchId)
|
|
143
|
-
|
|
167
|
+
void this.flushBatchQueue(batchId).finally(() => {
|
|
168
|
+
delete this.#batchTimers[batchId];
|
|
169
|
+
});
|
|
144
170
|
}, this.#batchDelay);
|
|
145
171
|
}
|
|
146
172
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.unit.test.d.ts","sourceRoot":"","sources":["../../../../src/lib/api/query/query.unit.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { Cache } from './cache.svelte.js';
|
|
3
|
+
import { Query, Requestor } from './query.svelte.js';
|
|
4
|
+
function getSingleId(input) {
|
|
5
|
+
return 'id' in input ? input.id : input.ids[0];
|
|
6
|
+
}
|
|
7
|
+
function jsonResponse(body, status = 200) {
|
|
8
|
+
return new Response(JSON.stringify(body), {
|
|
9
|
+
status,
|
|
10
|
+
headers: { 'content-type': 'application/json' }
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
function deferred() {
|
|
14
|
+
let resolve;
|
|
15
|
+
let reject;
|
|
16
|
+
const promise = new Promise((res, rej) => {
|
|
17
|
+
resolve = res;
|
|
18
|
+
reject = rej;
|
|
19
|
+
});
|
|
20
|
+
return { promise, resolve, reject };
|
|
21
|
+
}
|
|
22
|
+
describe('Requestor', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.useFakeTimers();
|
|
25
|
+
vi.setSystemTime(0);
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
it('passes through non-batched requests', async () => {
|
|
32
|
+
const requestMock = vi.fn(async () => jsonResponse({ id: 1 }));
|
|
33
|
+
const request = requestMock;
|
|
34
|
+
const requestor = new Requestor('/users', 'GET', request, new Cache());
|
|
35
|
+
const response = await requestor.request({ id: 1 });
|
|
36
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { id: 1 });
|
|
38
|
+
await expect(response.json()).resolves.toEqual({ id: 1 });
|
|
39
|
+
});
|
|
40
|
+
it('batches requests with the same batch id and preserves response order', async () => {
|
|
41
|
+
const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
|
|
42
|
+
const request = requestMock;
|
|
43
|
+
const requestor = new Requestor('/users', 'GET', request, new Cache(), {
|
|
44
|
+
canBatch: (input) => ('group' in input && input.group) || false,
|
|
45
|
+
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
46
|
+
unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getSingleId(input) }))
|
|
47
|
+
});
|
|
48
|
+
const p1 = requestor.request({ id: 1, group: 'team' });
|
|
49
|
+
const p2 = requestor.request({ id: 2, group: 'team' });
|
|
50
|
+
await vi.advanceTimersByTimeAsync(99);
|
|
51
|
+
expect(requestMock).not.toHaveBeenCalled();
|
|
52
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
53
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
|
|
55
|
+
const [response1, response2] = await Promise.all([p1, p2]);
|
|
56
|
+
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
57
|
+
await expect(response2.json()).resolves.toEqual({ id: 2 });
|
|
58
|
+
});
|
|
59
|
+
it('rate limits separate batches by start time rather than completion time', async () => {
|
|
60
|
+
const starts = [];
|
|
61
|
+
const requestMock = vi.fn(async (_path, _method, input) => {
|
|
62
|
+
starts.push(Date.now());
|
|
63
|
+
if ('ids' in input && input.ids[0] === 1) {
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
65
|
+
}
|
|
66
|
+
return jsonResponse({ ids: 'ids' in input ? input.ids : [input.id] });
|
|
67
|
+
});
|
|
68
|
+
const request = requestMock;
|
|
69
|
+
const requestor = new Requestor('/users', 'GET', request, new Cache(), {
|
|
70
|
+
canBatch: (input) => ('group' in input && input.group) || false,
|
|
71
|
+
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
72
|
+
unBatchOutput: (_inputs, output) => [output]
|
|
73
|
+
});
|
|
74
|
+
const p1 = requestor.request({ id: 1, group: 'a' });
|
|
75
|
+
const p2 = requestor.request({ id: 2, group: 'b' });
|
|
76
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
77
|
+
expect(starts).toEqual([100]);
|
|
78
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
79
|
+
expect(starts).toEqual([100, 200]);
|
|
80
|
+
await vi.runAllTimersAsync();
|
|
81
|
+
const [response1, response2] = await Promise.all([p1, p2]);
|
|
82
|
+
await expect(response1.json()).resolves.toEqual({ ids: [1] });
|
|
83
|
+
await expect(response2.json()).resolves.toEqual({ ids: [2] });
|
|
84
|
+
});
|
|
85
|
+
it('returns batched error responses without rejecting them', async () => {
|
|
86
|
+
const requestMock = vi.fn(async () => jsonResponse({ ok: false }, 207));
|
|
87
|
+
const request = requestMock;
|
|
88
|
+
const requestor = new Requestor('/users', 'GET', request, new Cache(), {
|
|
89
|
+
canBatch: () => 'team',
|
|
90
|
+
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
91
|
+
unBatchOutput: () => [jsonResponse({ message: 'bad request' }, 400)]
|
|
92
|
+
});
|
|
93
|
+
const responsePromise = requestor.request({ id: 1 });
|
|
94
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
95
|
+
const response = await responsePromise;
|
|
96
|
+
expect(response.ok).toBe(false);
|
|
97
|
+
expect(response.status).toBe(400);
|
|
98
|
+
await expect(response.json()).resolves.toEqual({ message: 'bad request' });
|
|
99
|
+
});
|
|
100
|
+
it('rejects all queued callers when a batched fetch throws', async () => {
|
|
101
|
+
const requestMock = vi.fn(async () => {
|
|
102
|
+
throw new Error('network down');
|
|
103
|
+
});
|
|
104
|
+
const request = requestMock;
|
|
105
|
+
const requestor = new Requestor('/users', 'GET', request, new Cache(), {
|
|
106
|
+
canBatch: () => 'team',
|
|
107
|
+
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
108
|
+
unBatchOutput: (_inputs, output) => [output]
|
|
109
|
+
});
|
|
110
|
+
const p1 = requestor.request({ id: 1 });
|
|
111
|
+
const p2 = requestor.request({ id: 2 });
|
|
112
|
+
const p1Expectation = expect(p1).rejects.toThrow('network down');
|
|
113
|
+
const p2Expectation = expect(p2).rejects.toThrow('network down');
|
|
114
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
115
|
+
await Promise.all([p1Expectation, p2Expectation]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe('Query', () => {
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
vi.useFakeTimers();
|
|
121
|
+
vi.setSystemTime(0);
|
|
122
|
+
});
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
vi.useRealTimers();
|
|
125
|
+
vi.restoreAllMocks();
|
|
126
|
+
});
|
|
127
|
+
it('deduplicates concurrent requests and returns readable responses to each caller', async () => {
|
|
128
|
+
const pending = deferred();
|
|
129
|
+
const requestMock = vi.fn().mockReturnValue(pending.promise);
|
|
130
|
+
const requestor = {
|
|
131
|
+
request: requestMock
|
|
132
|
+
};
|
|
133
|
+
const query = new Query({
|
|
134
|
+
path: '/users',
|
|
135
|
+
method: 'GET',
|
|
136
|
+
input: { id: 1 },
|
|
137
|
+
requestor,
|
|
138
|
+
cache: new Cache()
|
|
139
|
+
});
|
|
140
|
+
const p1 = query.request();
|
|
141
|
+
const p2 = query.request();
|
|
142
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
143
|
+
pending.resolve(jsonResponse({ id: 1 }));
|
|
144
|
+
const [response1, response2] = await Promise.all([p1, p2]);
|
|
145
|
+
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
146
|
+
await expect(response2.json()).resolves.toEqual({ id: 1 });
|
|
147
|
+
});
|
|
148
|
+
it('caches responses and returns a fresh readable clone on cache hits', async () => {
|
|
149
|
+
const requestMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, name: 'Ada' }));
|
|
150
|
+
const requestor = {
|
|
151
|
+
request: requestMock
|
|
152
|
+
};
|
|
153
|
+
const query = new Query({
|
|
154
|
+
path: '/users',
|
|
155
|
+
method: 'GET',
|
|
156
|
+
input: { id: 1 },
|
|
157
|
+
requestor,
|
|
158
|
+
cache: new Cache()
|
|
159
|
+
});
|
|
160
|
+
const first = await query.request();
|
|
161
|
+
const second = await query.request();
|
|
162
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(query.isCached).toBe(true);
|
|
164
|
+
await expect(first.json()).resolves.toEqual({ id: 1, name: 'Ada' });
|
|
165
|
+
await expect(second.json()).resolves.toEqual({ id: 1, name: 'Ada' });
|
|
166
|
+
});
|
|
167
|
+
it('updates success state from successful responses', async () => {
|
|
168
|
+
const requestMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, active: true }));
|
|
169
|
+
const requestor = {
|
|
170
|
+
request: requestMock
|
|
171
|
+
};
|
|
172
|
+
const query = new Query({
|
|
173
|
+
path: '/users',
|
|
174
|
+
method: 'GET',
|
|
175
|
+
input: { id: 1 },
|
|
176
|
+
requestor,
|
|
177
|
+
cache: new Cache()
|
|
178
|
+
});
|
|
179
|
+
const response = await query.request();
|
|
180
|
+
expect(query.status).toBe('success');
|
|
181
|
+
expect(query.data).toEqual({ id: 1, active: true });
|
|
182
|
+
expect(query.errorData).toBeNull();
|
|
183
|
+
await expect(response.json()).resolves.toEqual({ id: 1, active: true });
|
|
184
|
+
});
|
|
185
|
+
it('updates error state from error responses without throwing', async () => {
|
|
186
|
+
const requestMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'missing' }, 404));
|
|
187
|
+
const requestor = {
|
|
188
|
+
request: requestMock
|
|
189
|
+
};
|
|
190
|
+
const query = new Query({
|
|
191
|
+
path: '/users',
|
|
192
|
+
method: 'GET',
|
|
193
|
+
input: { id: 99 },
|
|
194
|
+
requestor,
|
|
195
|
+
cache: new Cache()
|
|
196
|
+
});
|
|
197
|
+
const response = await query.request();
|
|
198
|
+
expect(query.status).toBe('error');
|
|
199
|
+
expect(query.data).toBeNull();
|
|
200
|
+
expect(query.errorData).toEqual({ message: 'missing' });
|
|
201
|
+
expect(response.ok).toBe(false);
|
|
202
|
+
await expect(response.json()).resolves.toEqual({ message: 'missing' });
|
|
203
|
+
});
|
|
204
|
+
it('clears the pending request when the request throws so later retries can succeed', async () => {
|
|
205
|
+
const requestMock = vi
|
|
206
|
+
.fn()
|
|
207
|
+
.mockRejectedValueOnce(new Error('network down'))
|
|
208
|
+
.mockResolvedValueOnce(jsonResponse({ id: 1, recovered: true }));
|
|
209
|
+
const requestor = {
|
|
210
|
+
request: requestMock
|
|
211
|
+
};
|
|
212
|
+
const query = new Query({
|
|
213
|
+
path: '/users',
|
|
214
|
+
method: 'GET',
|
|
215
|
+
input: { id: 1 },
|
|
216
|
+
requestor,
|
|
217
|
+
cache: new Cache()
|
|
218
|
+
});
|
|
219
|
+
await expect(query.request()).rejects.toThrow('network down');
|
|
220
|
+
expect(query.status).toBe('error');
|
|
221
|
+
const response = await query.request();
|
|
222
|
+
expect(requestMock).toHaveBeenCalledTimes(2);
|
|
223
|
+
expect(query.status).toBe('success');
|
|
224
|
+
await expect(response.json()).resolves.toEqual({ id: 1, recovered: true });
|
|
225
|
+
});
|
|
226
|
+
it('resetCache forces the next request to fetch again', async () => {
|
|
227
|
+
const requestMock = vi
|
|
228
|
+
.fn()
|
|
229
|
+
.mockResolvedValueOnce(jsonResponse({ call: 1 }))
|
|
230
|
+
.mockResolvedValueOnce(jsonResponse({ call: 2 }));
|
|
231
|
+
const requestor = {
|
|
232
|
+
request: requestMock
|
|
233
|
+
};
|
|
234
|
+
const query = new Query({
|
|
235
|
+
path: '/users',
|
|
236
|
+
method: 'GET',
|
|
237
|
+
input: { id: 1 },
|
|
238
|
+
requestor,
|
|
239
|
+
cache: new Cache()
|
|
240
|
+
});
|
|
241
|
+
const first = await query.request();
|
|
242
|
+
query.resetCache();
|
|
243
|
+
const second = await query.request();
|
|
244
|
+
expect(requestMock).toHaveBeenCalledTimes(2);
|
|
245
|
+
await expect(first.json()).resolves.toEqual({ call: 1 });
|
|
246
|
+
await expect(second.json()).resolves.toEqual({ call: 2 });
|
|
247
|
+
});
|
|
248
|
+
it('honors custom cache timeout options', async () => {
|
|
249
|
+
const requestMock = vi
|
|
250
|
+
.fn()
|
|
251
|
+
.mockResolvedValueOnce(jsonResponse({ call: 1 }))
|
|
252
|
+
.mockResolvedValueOnce(jsonResponse({ call: 2 }));
|
|
253
|
+
const requestor = {
|
|
254
|
+
request: requestMock
|
|
255
|
+
};
|
|
256
|
+
const query = new Query({
|
|
257
|
+
path: '/users',
|
|
258
|
+
method: 'GET',
|
|
259
|
+
input: { id: 1 },
|
|
260
|
+
requestor,
|
|
261
|
+
cache: new Cache(),
|
|
262
|
+
opts: {
|
|
263
|
+
cache: { timeout: 50 }
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
const first = await query.request();
|
|
267
|
+
vi.advanceTimersByTime(49);
|
|
268
|
+
const cached = await query.request();
|
|
269
|
+
vi.advanceTimersByTime(1);
|
|
270
|
+
const refreshed = await query.request();
|
|
271
|
+
expect(requestMock).toHaveBeenCalledTimes(2);
|
|
272
|
+
await expect(first.json()).resolves.toEqual({ call: 1 });
|
|
273
|
+
await expect(cached.json()).resolves.toEqual({ call: 1 });
|
|
274
|
+
await expect(refreshed.json()).resolves.toEqual({ call: 2 });
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//# sourceMappingURL=vitest.setup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vitest.setup.d.ts","sourceRoot":"","sources":["../../src/test/vitest.setup.ts"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-ag",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.58",
|
|
4
4
|
"description": "Useful svelte components",
|
|
5
5
|
"bugs": "https://github.com/ageorgeh/svelte-ag/issues",
|
|
6
6
|
"repository": {
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@iconify/tailwind4": "^1.2.3",
|
|
71
71
|
"@iconify/types": "^2.0.0",
|
|
72
72
|
"@internationalized/date": "^3.12.0",
|
|
73
|
-
"@lucide/svelte": "^
|
|
73
|
+
"@lucide/svelte": "^1.7.0",
|
|
74
74
|
"@playwright/test": "1.57.0",
|
|
75
75
|
"@sveltejs/kit": "^2.55.0",
|
|
76
76
|
"@sveltejs/package": "^2.5.7",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { Cache } from './cache.svelte.js';
|
|
3
|
+
|
|
4
|
+
describe('Cache', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
vi.setSystemTime(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('stores and expires values based on timeout', () => {
|
|
16
|
+
const cache = new Cache();
|
|
17
|
+
|
|
18
|
+
cache.register('user', { timeout: 100 });
|
|
19
|
+
cache.set('user', { id: 1 });
|
|
20
|
+
|
|
21
|
+
expect(cache.has('user')).toBe(true);
|
|
22
|
+
expect(cache.get('user')).toEqual({ id: 1 });
|
|
23
|
+
|
|
24
|
+
vi.advanceTimersByTime(99);
|
|
25
|
+
expect(cache.has('user')).toBe(true);
|
|
26
|
+
expect(cache.get('user')).toEqual({ id: 1 });
|
|
27
|
+
|
|
28
|
+
vi.advanceTimersByTime(1);
|
|
29
|
+
expect(cache.has('user')).toBe(false);
|
|
30
|
+
expect(cache.get('user')).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('supports infinite timeout and reset', () => {
|
|
34
|
+
const cache = new Cache();
|
|
35
|
+
|
|
36
|
+
cache.register('settings', { timeout: 'inf' });
|
|
37
|
+
cache.set('settings', { theme: 'light' });
|
|
38
|
+
|
|
39
|
+
vi.advanceTimersByTime(10_000);
|
|
40
|
+
expect(cache.has('settings')).toBe(true);
|
|
41
|
+
expect(cache.get('settings')).toEqual({ theme: 'light' });
|
|
42
|
+
|
|
43
|
+
cache.reset('settings');
|
|
44
|
+
expect(cache.has('settings')).toBe(false);
|
|
45
|
+
expect(cache.get('settings')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('deregisters keys completely', () => {
|
|
49
|
+
const cache = new Cache();
|
|
50
|
+
|
|
51
|
+
cache.register('token', { timeout: 100 });
|
|
52
|
+
cache.set('token', 'abc');
|
|
53
|
+
cache.deregister('token');
|
|
54
|
+
|
|
55
|
+
expect(cache.has('token')).toBe(false);
|
|
56
|
+
expect(() => cache.get('token')).toThrow('The key token is not registered in the cache');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws when mutating an unregistered key', () => {
|
|
60
|
+
const cache = new Cache();
|
|
61
|
+
|
|
62
|
+
expect(() => cache.set('missing', 1)).toThrow('The key missing is not registered in the cache');
|
|
63
|
+
expect(() => cache.get('missing')).toThrow('The key missing is not registered in the cache');
|
|
64
|
+
expect(() => cache.reset('missing')).toThrow('The key missing is not registered in the cache');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { ApiEndpoints, ApiRequestFunction } from 'ts-ag';
|
|
3
|
+
|
|
4
|
+
type TestResponse = ApiEndpoints['response'];
|
|
5
|
+
|
|
6
|
+
type UserInput = { id: number; group?: string };
|
|
7
|
+
type BatchedInput = { ids: number[] };
|
|
8
|
+
|
|
9
|
+
type UsersApi = {
|
|
10
|
+
path: '/users';
|
|
11
|
+
method: 'GET';
|
|
12
|
+
requestInput: UserInput | BatchedInput;
|
|
13
|
+
requestOutput: null;
|
|
14
|
+
response: TestResponse;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type UsersRequest = ApiRequestFunction<UsersApi>;
|
|
18
|
+
|
|
19
|
+
function getUserId(input: UsersApi['requestInput']): number {
|
|
20
|
+
return 'id' in input ? input.id : input.ids[0]!;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function jsonResponse(body: unknown, status = 200): TestResponse {
|
|
24
|
+
return new Response(JSON.stringify(body), {
|
|
25
|
+
status,
|
|
26
|
+
headers: { 'content-type': 'application/json' }
|
|
27
|
+
}) as TestResponse;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('createQueryFunction', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.useFakeTimers();
|
|
33
|
+
vi.setSystemTime(0);
|
|
34
|
+
vi.resetModules();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.useRealTimers();
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns the same query instance for the same path, method, and input', async () => {
|
|
43
|
+
const { createQueryFunction } = await import('./entrypoint.svelte.js');
|
|
44
|
+
const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
|
|
45
|
+
const request = requestMock as unknown as UsersRequest;
|
|
46
|
+
const createQuery = createQueryFunction<UsersApi>(request, {});
|
|
47
|
+
|
|
48
|
+
const query1 = createQuery('/users', 'GET', { id: 1 });
|
|
49
|
+
const query2 = createQuery('/users', 'GET', { id: 1 });
|
|
50
|
+
const query3 = createQuery('/users', 'GET', { id: 2 });
|
|
51
|
+
|
|
52
|
+
expect(query1).toBe(query2);
|
|
53
|
+
expect(query3).not.toBe(query1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('reuses requestors so separate queries can batch together', async () => {
|
|
57
|
+
const { createQueryFunction } = await import('./entrypoint.svelte.js');
|
|
58
|
+
const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
|
|
59
|
+
const request = requestMock as unknown as UsersRequest;
|
|
60
|
+
const createQuery = createQueryFunction<UsersApi>(request, {
|
|
61
|
+
'/users': {
|
|
62
|
+
GET: {
|
|
63
|
+
canBatch: () => 'users',
|
|
64
|
+
batchInput: (inputs) => ({ ids: inputs.map(getUserId) }),
|
|
65
|
+
unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getUserId(input) }))
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const query1 = createQuery('/users', 'GET', { id: 1 });
|
|
71
|
+
const query2 = createQuery('/users', 'GET', { id: 2 });
|
|
72
|
+
|
|
73
|
+
const p1 = query1.request();
|
|
74
|
+
const p2 = query2.request();
|
|
75
|
+
|
|
76
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
77
|
+
|
|
78
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
79
|
+
expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
|
|
80
|
+
|
|
81
|
+
const [response1, response2] = await Promise.all([p1, p2]);
|
|
82
|
+
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
83
|
+
await expect(response2.json()).resolves.toEqual({ id: 2 });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -9,6 +9,19 @@ import { RateLimiter } from './rate.svelte';
|
|
|
9
9
|
|
|
10
10
|
export type QueryStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
11
11
|
|
|
12
|
+
function cloneResponse<T>(response: T): T {
|
|
13
|
+
if (
|
|
14
|
+
response !== null &&
|
|
15
|
+
typeof response === 'object' &&
|
|
16
|
+
'clone' in response &&
|
|
17
|
+
typeof response.clone === 'function'
|
|
18
|
+
) {
|
|
19
|
+
return response.clone();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return response;
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
export class Query<
|
|
13
26
|
API extends ApiEndpoints,
|
|
14
27
|
Path extends API['path'],
|
|
@@ -42,7 +55,8 @@ export class Query<
|
|
|
42
55
|
method,
|
|
43
56
|
input,
|
|
44
57
|
requestor,
|
|
45
|
-
cache
|
|
58
|
+
cache,
|
|
59
|
+
opts
|
|
46
60
|
}: {
|
|
47
61
|
path: Path;
|
|
48
62
|
method: Method;
|
|
@@ -65,13 +79,13 @@ export class Query<
|
|
|
65
79
|
this.#inputString = stringify(input);
|
|
66
80
|
this.#cacheKey = cacheKey(path, method, input);
|
|
67
81
|
|
|
68
|
-
this.#cache.register(this.#cacheKey, { timeout: this.#TIMEOUT });
|
|
82
|
+
this.#cache.register(this.#cacheKey, opts?.cache ?? { timeout: this.#TIMEOUT });
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
async request(): Promise<ApiResponse<API, Path, Method>> {
|
|
72
86
|
const cachedValue = this.#cache.get(this.#cacheKey);
|
|
73
87
|
if (cachedValue !== null) {
|
|
74
|
-
return cachedValue;
|
|
88
|
+
return cloneResponse(cachedValue);
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
this.#status = 'loading';
|
|
@@ -79,23 +93,33 @@ export class Query<
|
|
|
79
93
|
if (this.#pendingRequest === null) {
|
|
80
94
|
this.#pendingRequest = this.#requestor.request(this.#input);
|
|
81
95
|
}
|
|
82
|
-
const res = await this.#pendingRequest;
|
|
83
|
-
this.#pendingRequest = null;
|
|
84
96
|
|
|
85
|
-
|
|
97
|
+
let res: ApiResponse<API, Path, Method>;
|
|
98
|
+
try {
|
|
99
|
+
res = await this.#pendingRequest;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
this.#status = 'error';
|
|
102
|
+
throw err;
|
|
103
|
+
} finally {
|
|
104
|
+
this.#pendingRequest = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const responseForState = cloneResponse(res);
|
|
108
|
+
const responseForCaller = cloneResponse(res);
|
|
109
|
+
this.#cache.set(this.#cacheKey, cloneResponse(res));
|
|
86
110
|
|
|
87
|
-
if (
|
|
88
|
-
const body = await
|
|
111
|
+
if (responseForState.ok === false) {
|
|
112
|
+
const body = await responseForState.json();
|
|
89
113
|
this.#status = 'error';
|
|
90
114
|
|
|
91
115
|
// @ts-expect-error Generics not working for some reason
|
|
92
116
|
this.#errorData = body;
|
|
93
|
-
return
|
|
117
|
+
return responseForCaller;
|
|
94
118
|
} else {
|
|
95
|
-
const body = await
|
|
119
|
+
const body = await responseForState.json();
|
|
96
120
|
this.#status = 'success';
|
|
97
121
|
this.#data = body;
|
|
98
|
-
return
|
|
122
|
+
return responseForCaller;
|
|
99
123
|
}
|
|
100
124
|
}
|
|
101
125
|
|
|
@@ -147,7 +171,7 @@ export class Requestor<
|
|
|
147
171
|
string,
|
|
148
172
|
{
|
|
149
173
|
resolve: (value: ApiResponse<API, Path, Method>) => void;
|
|
150
|
-
reject: (err:
|
|
174
|
+
reject: (err: unknown) => void;
|
|
151
175
|
input: ApiInput<API, Path, Method>;
|
|
152
176
|
}[]
|
|
153
177
|
> = {};
|
|
@@ -187,21 +211,27 @@ export class Requestor<
|
|
|
187
211
|
private async flushBatchQueue(batchId: string): Promise<void> {
|
|
188
212
|
const queue = this.#batchQueue[batchId].splice(0);
|
|
189
213
|
|
|
190
|
-
|
|
214
|
+
try {
|
|
215
|
+
const batchedInput = this.#batchInput(queue.map((q) => q.input));
|
|
191
216
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
217
|
+
const res = await this.fetch(batchedInput);
|
|
218
|
+
const output = await this.#unBatchOutput(
|
|
219
|
+
queue.map((q) => q.input),
|
|
220
|
+
res
|
|
221
|
+
);
|
|
197
222
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
resolve(output[i]);
|
|
201
|
-
} else {
|
|
202
|
-
reject(output[i]);
|
|
223
|
+
if (output.length !== queue.length) {
|
|
224
|
+
throw new Error(`Batch output length mismatch for ${batchId}`);
|
|
203
225
|
}
|
|
204
|
-
|
|
226
|
+
|
|
227
|
+
queue.forEach(({ resolve }, i) => {
|
|
228
|
+
resolve(output[i]!);
|
|
229
|
+
});
|
|
230
|
+
} catch (err) {
|
|
231
|
+
queue.forEach(({ reject }) => {
|
|
232
|
+
reject(err);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
205
235
|
}
|
|
206
236
|
|
|
207
237
|
// Performs a request for a given input. Batches it if possible
|
|
@@ -214,8 +244,9 @@ export class Requestor<
|
|
|
214
244
|
this.#batchQueue[batchId].push({ input, resolve, reject });
|
|
215
245
|
if (!this.#batchTimers[batchId]) {
|
|
216
246
|
this.#batchTimers[batchId] = setTimeout(() => {
|
|
217
|
-
this.flushBatchQueue(batchId)
|
|
218
|
-
|
|
247
|
+
void this.flushBatchQueue(batchId).finally(() => {
|
|
248
|
+
delete this.#batchTimers[batchId];
|
|
249
|
+
});
|
|
219
250
|
}, this.#batchDelay);
|
|
220
251
|
}
|
|
221
252
|
});
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { ApiEndpoints, ApiRequestFunction } from 'ts-ag';
|
|
3
|
+
import { Cache } from './cache.svelte.js';
|
|
4
|
+
import { Query, Requestor } from './query.svelte.js';
|
|
5
|
+
|
|
6
|
+
type PlainUserInput = { id: number };
|
|
7
|
+
type BatchedUserInput = { id: number; group?: string };
|
|
8
|
+
type BatchedRequestInput = { ids: number[] };
|
|
9
|
+
|
|
10
|
+
type TestResponse = ApiEndpoints['response'];
|
|
11
|
+
|
|
12
|
+
type PlainUsersApi = {
|
|
13
|
+
path: '/users';
|
|
14
|
+
method: 'GET';
|
|
15
|
+
requestInput: PlainUserInput;
|
|
16
|
+
requestOutput: null;
|
|
17
|
+
response: TestResponse;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type BatchedUsersApi = {
|
|
21
|
+
path: '/users';
|
|
22
|
+
method: 'GET';
|
|
23
|
+
requestInput: BatchedUserInput | BatchedRequestInput;
|
|
24
|
+
requestOutput: null;
|
|
25
|
+
response: TestResponse;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type PlainUsersRequestor = Requestor<PlainUsersApi, '/users', 'GET'>;
|
|
29
|
+
type PlainUsersRequest = ApiRequestFunction<PlainUsersApi>;
|
|
30
|
+
type BatchedUsersRequest = ApiRequestFunction<BatchedUsersApi>;
|
|
31
|
+
|
|
32
|
+
function getSingleId(input: BatchedUsersApi['requestInput']): number {
|
|
33
|
+
return 'id' in input ? input.id : input.ids[0]!;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function jsonResponse(body: unknown, status = 200): TestResponse {
|
|
37
|
+
return new Response(JSON.stringify(body), {
|
|
38
|
+
status,
|
|
39
|
+
headers: { 'content-type': 'application/json' }
|
|
40
|
+
}) as TestResponse;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function deferred<T>() {
|
|
44
|
+
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
45
|
+
let reject!: (reason?: unknown) => void;
|
|
46
|
+
const promise = new Promise<T>((res, rej) => {
|
|
47
|
+
resolve = res;
|
|
48
|
+
reject = rej;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return { promise, resolve, reject };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('Requestor', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.useFakeTimers();
|
|
57
|
+
vi.setSystemTime(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.useRealTimers();
|
|
62
|
+
vi.restoreAllMocks();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('passes through non-batched requests', async () => {
|
|
66
|
+
const requestMock = vi.fn(async () => jsonResponse({ id: 1 }));
|
|
67
|
+
const request = requestMock as unknown as PlainUsersRequest;
|
|
68
|
+
const requestor = new Requestor<PlainUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache());
|
|
69
|
+
|
|
70
|
+
const response = await requestor.request({ id: 1 });
|
|
71
|
+
|
|
72
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { id: 1 });
|
|
74
|
+
await expect(response.json()).resolves.toEqual({ id: 1 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('batches requests with the same batch id and preserves response order', async () => {
|
|
78
|
+
const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
|
|
79
|
+
const request = requestMock as unknown as BatchedUsersRequest;
|
|
80
|
+
const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
|
|
81
|
+
canBatch: (input) => ('group' in input && input.group) || false,
|
|
82
|
+
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
83
|
+
unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getSingleId(input) }))
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const p1 = requestor.request({ id: 1, group: 'team' });
|
|
87
|
+
const p2 = requestor.request({ id: 2, group: 'team' });
|
|
88
|
+
|
|
89
|
+
await vi.advanceTimersByTimeAsync(99);
|
|
90
|
+
expect(requestMock).not.toHaveBeenCalled();
|
|
91
|
+
|
|
92
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
93
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
|
|
95
|
+
|
|
96
|
+
const [response1, response2] = await Promise.all([p1, p2]);
|
|
97
|
+
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
98
|
+
await expect(response2.json()).resolves.toEqual({ id: 2 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('rate limits separate batches by start time rather than completion time', async () => {
|
|
102
|
+
const starts: number[] = [];
|
|
103
|
+
const requestMock = vi.fn(async (_path: '/users', _method: 'GET', input: BatchedUsersApi['requestInput']) => {
|
|
104
|
+
starts.push(Date.now());
|
|
105
|
+
|
|
106
|
+
if ('ids' in input && input.ids[0] === 1) {
|
|
107
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return jsonResponse({ ids: 'ids' in input ? input.ids : [input.id] });
|
|
111
|
+
});
|
|
112
|
+
const request = requestMock as unknown as BatchedUsersRequest;
|
|
113
|
+
|
|
114
|
+
const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
|
|
115
|
+
canBatch: (input) => ('group' in input && input.group) || false,
|
|
116
|
+
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
117
|
+
unBatchOutput: (_inputs, output) => [output]
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const p1 = requestor.request({ id: 1, group: 'a' });
|
|
121
|
+
const p2 = requestor.request({ id: 2, group: 'b' });
|
|
122
|
+
|
|
123
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
124
|
+
expect(starts).toEqual([100]);
|
|
125
|
+
|
|
126
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
127
|
+
expect(starts).toEqual([100, 200]);
|
|
128
|
+
|
|
129
|
+
await vi.runAllTimersAsync();
|
|
130
|
+
|
|
131
|
+
const [response1, response2] = await Promise.all([p1, p2]);
|
|
132
|
+
await expect(response1.json()).resolves.toEqual({ ids: [1] });
|
|
133
|
+
await expect(response2.json()).resolves.toEqual({ ids: [2] });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns batched error responses without rejecting them', async () => {
|
|
137
|
+
const requestMock = vi.fn(async () => jsonResponse({ ok: false }, 207));
|
|
138
|
+
const request = requestMock as unknown as BatchedUsersRequest;
|
|
139
|
+
const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
|
|
140
|
+
canBatch: () => 'team',
|
|
141
|
+
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
142
|
+
unBatchOutput: () => [jsonResponse({ message: 'bad request' }, 400)]
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const responsePromise = requestor.request({ id: 1 });
|
|
146
|
+
|
|
147
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
148
|
+
|
|
149
|
+
const response = await responsePromise;
|
|
150
|
+
expect(response.ok).toBe(false);
|
|
151
|
+
expect(response.status).toBe(400);
|
|
152
|
+
await expect(response.json()).resolves.toEqual({ message: 'bad request' });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rejects all queued callers when a batched fetch throws', async () => {
|
|
156
|
+
const requestMock = vi.fn(async () => {
|
|
157
|
+
throw new Error('network down');
|
|
158
|
+
});
|
|
159
|
+
const request = requestMock as unknown as BatchedUsersRequest;
|
|
160
|
+
const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
|
|
161
|
+
canBatch: () => 'team',
|
|
162
|
+
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
163
|
+
unBatchOutput: (_inputs, output) => [output]
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const p1 = requestor.request({ id: 1 });
|
|
167
|
+
const p2 = requestor.request({ id: 2 });
|
|
168
|
+
const p1Expectation = expect(p1).rejects.toThrow('network down');
|
|
169
|
+
const p2Expectation = expect(p2).rejects.toThrow('network down');
|
|
170
|
+
|
|
171
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
172
|
+
|
|
173
|
+
await Promise.all([p1Expectation, p2Expectation]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('Query', () => {
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
vi.useFakeTimers();
|
|
180
|
+
vi.setSystemTime(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
afterEach(() => {
|
|
184
|
+
vi.useRealTimers();
|
|
185
|
+
vi.restoreAllMocks();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('deduplicates concurrent requests and returns readable responses to each caller', async () => {
|
|
189
|
+
const pending = deferred<Response>();
|
|
190
|
+
const requestMock = vi.fn().mockReturnValue(pending.promise);
|
|
191
|
+
const requestor = {
|
|
192
|
+
request: requestMock as PlainUsersRequest
|
|
193
|
+
} as unknown as PlainUsersRequestor;
|
|
194
|
+
|
|
195
|
+
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
196
|
+
path: '/users',
|
|
197
|
+
method: 'GET',
|
|
198
|
+
input: { id: 1 },
|
|
199
|
+
requestor,
|
|
200
|
+
cache: new Cache()
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const p1 = query.request();
|
|
204
|
+
const p2 = query.request();
|
|
205
|
+
|
|
206
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
207
|
+
|
|
208
|
+
pending.resolve(jsonResponse({ id: 1 }));
|
|
209
|
+
|
|
210
|
+
const [response1, response2] = await Promise.all([p1, p2]);
|
|
211
|
+
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
212
|
+
await expect(response2.json()).resolves.toEqual({ id: 1 });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('caches responses and returns a fresh readable clone on cache hits', async () => {
|
|
216
|
+
const requestMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, name: 'Ada' }));
|
|
217
|
+
const requestor = {
|
|
218
|
+
request: requestMock as PlainUsersRequest
|
|
219
|
+
} as unknown as PlainUsersRequestor;
|
|
220
|
+
|
|
221
|
+
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
222
|
+
path: '/users',
|
|
223
|
+
method: 'GET',
|
|
224
|
+
input: { id: 1 },
|
|
225
|
+
requestor,
|
|
226
|
+
cache: new Cache()
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const first = await query.request();
|
|
230
|
+
const second = await query.request();
|
|
231
|
+
|
|
232
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
233
|
+
expect(query.isCached).toBe(true);
|
|
234
|
+
await expect(first.json()).resolves.toEqual({ id: 1, name: 'Ada' });
|
|
235
|
+
await expect(second.json()).resolves.toEqual({ id: 1, name: 'Ada' });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('updates success state from successful responses', async () => {
|
|
239
|
+
const requestMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, active: true }));
|
|
240
|
+
const requestor = {
|
|
241
|
+
request: requestMock as PlainUsersRequest
|
|
242
|
+
} as unknown as PlainUsersRequestor;
|
|
243
|
+
|
|
244
|
+
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
245
|
+
path: '/users',
|
|
246
|
+
method: 'GET',
|
|
247
|
+
input: { id: 1 },
|
|
248
|
+
requestor,
|
|
249
|
+
cache: new Cache()
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const response = await query.request();
|
|
253
|
+
|
|
254
|
+
expect(query.status).toBe('success');
|
|
255
|
+
expect(query.data).toEqual({ id: 1, active: true });
|
|
256
|
+
expect(query.errorData).toBeNull();
|
|
257
|
+
await expect(response.json()).resolves.toEqual({ id: 1, active: true });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('updates error state from error responses without throwing', async () => {
|
|
261
|
+
const requestMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'missing' }, 404));
|
|
262
|
+
const requestor = {
|
|
263
|
+
request: requestMock as PlainUsersRequest
|
|
264
|
+
} as unknown as PlainUsersRequestor;
|
|
265
|
+
|
|
266
|
+
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
267
|
+
path: '/users',
|
|
268
|
+
method: 'GET',
|
|
269
|
+
input: { id: 99 },
|
|
270
|
+
requestor,
|
|
271
|
+
cache: new Cache()
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const response = await query.request();
|
|
275
|
+
|
|
276
|
+
expect(query.status).toBe('error');
|
|
277
|
+
expect(query.data).toBeNull();
|
|
278
|
+
expect(query.errorData).toEqual({ message: 'missing' });
|
|
279
|
+
expect(response.ok).toBe(false);
|
|
280
|
+
await expect(response.json()).resolves.toEqual({ message: 'missing' });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('clears the pending request when the request throws so later retries can succeed', async () => {
|
|
284
|
+
const requestMock = vi
|
|
285
|
+
.fn()
|
|
286
|
+
.mockRejectedValueOnce(new Error('network down'))
|
|
287
|
+
.mockResolvedValueOnce(jsonResponse({ id: 1, recovered: true }));
|
|
288
|
+
const requestor = {
|
|
289
|
+
request: requestMock as PlainUsersRequest
|
|
290
|
+
} as unknown as PlainUsersRequestor;
|
|
291
|
+
|
|
292
|
+
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
293
|
+
path: '/users',
|
|
294
|
+
method: 'GET',
|
|
295
|
+
input: { id: 1 },
|
|
296
|
+
requestor,
|
|
297
|
+
cache: new Cache()
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await expect(query.request()).rejects.toThrow('network down');
|
|
301
|
+
expect(query.status).toBe('error');
|
|
302
|
+
|
|
303
|
+
const response = await query.request();
|
|
304
|
+
|
|
305
|
+
expect(requestMock).toHaveBeenCalledTimes(2);
|
|
306
|
+
expect(query.status).toBe('success');
|
|
307
|
+
await expect(response.json()).resolves.toEqual({ id: 1, recovered: true });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('resetCache forces the next request to fetch again', async () => {
|
|
311
|
+
const requestMock = vi
|
|
312
|
+
.fn()
|
|
313
|
+
.mockResolvedValueOnce(jsonResponse({ call: 1 }))
|
|
314
|
+
.mockResolvedValueOnce(jsonResponse({ call: 2 }));
|
|
315
|
+
const requestor = {
|
|
316
|
+
request: requestMock as PlainUsersRequest
|
|
317
|
+
} as unknown as PlainUsersRequestor;
|
|
318
|
+
|
|
319
|
+
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
320
|
+
path: '/users',
|
|
321
|
+
method: 'GET',
|
|
322
|
+
input: { id: 1 },
|
|
323
|
+
requestor,
|
|
324
|
+
cache: new Cache()
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const first = await query.request();
|
|
328
|
+
query.resetCache();
|
|
329
|
+
const second = await query.request();
|
|
330
|
+
|
|
331
|
+
expect(requestMock).toHaveBeenCalledTimes(2);
|
|
332
|
+
await expect(first.json()).resolves.toEqual({ call: 1 });
|
|
333
|
+
await expect(second.json()).resolves.toEqual({ call: 2 });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('honors custom cache timeout options', async () => {
|
|
337
|
+
const requestMock = vi
|
|
338
|
+
.fn()
|
|
339
|
+
.mockResolvedValueOnce(jsonResponse({ call: 1 }))
|
|
340
|
+
.mockResolvedValueOnce(jsonResponse({ call: 2 }));
|
|
341
|
+
const requestor = {
|
|
342
|
+
request: requestMock as PlainUsersRequest
|
|
343
|
+
} as unknown as PlainUsersRequestor;
|
|
344
|
+
|
|
345
|
+
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
346
|
+
path: '/users',
|
|
347
|
+
method: 'GET',
|
|
348
|
+
input: { id: 1 },
|
|
349
|
+
requestor,
|
|
350
|
+
cache: new Cache(),
|
|
351
|
+
opts: {
|
|
352
|
+
cache: { timeout: 50 }
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const first = await query.request();
|
|
357
|
+
vi.advanceTimersByTime(49);
|
|
358
|
+
const cached = await query.request();
|
|
359
|
+
vi.advanceTimersByTime(1);
|
|
360
|
+
const refreshed = await query.request();
|
|
361
|
+
|
|
362
|
+
expect(requestMock).toHaveBeenCalledTimes(2);
|
|
363
|
+
await expect(first.json()).resolves.toEqual({ call: 1 });
|
|
364
|
+
await expect(cached.json()).resolves.toEqual({ call: 1 });
|
|
365
|
+
await expect(refreshed.json()).resolves.toEqual({ call: 2 });
|
|
366
|
+
});
|
|
367
|
+
});
|