svelte-ag 1.0.59 → 1.0.60
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/entrypoint.unit.test.js +34 -16
- package/dist/lib/api/query/query.svelte.d.ts.map +1 -1
- package/dist/lib/api/query/query.svelte.js +6 -23
- package/dist/lib/api/query/query.unit.test.js +119 -101
- package/package.json +3 -4
- package/src/lib/api/query/entrypoint.unit.test.ts +40 -20
- package/src/lib/api/query/query.svelte.ts +6 -29
- package/src/lib/api/query/query.unit.test.ts +144 -125
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createApiRequest } from 'ts-ag';
|
|
3
|
+
import * as v from 'valibot';
|
|
4
|
+
const API_URL = 'https://api.example.test';
|
|
5
|
+
const schemas = {
|
|
6
|
+
'/users': {
|
|
7
|
+
POST: v.union([v.object({ id: v.number(), group: v.optional(v.string()) }), v.object({ ids: v.array(v.number()) })])
|
|
8
|
+
}
|
|
9
|
+
};
|
|
2
10
|
function getUserId(input) {
|
|
3
11
|
return 'id' in input ? input.id : input.ids[0];
|
|
4
12
|
}
|
|
5
|
-
function
|
|
13
|
+
function jsonFetchResponse(body, status = 200) {
|
|
6
14
|
return new Response(JSON.stringify(body), {
|
|
7
15
|
status,
|
|
8
16
|
headers: { 'content-type': 'application/json' }
|
|
@@ -17,40 +25,50 @@ describe('createQueryFunction', () => {
|
|
|
17
25
|
afterEach(() => {
|
|
18
26
|
vi.useRealTimers();
|
|
19
27
|
vi.restoreAllMocks();
|
|
28
|
+
vi.unstubAllGlobals();
|
|
20
29
|
});
|
|
21
30
|
it('returns the same query instance for the same path, method, and input', async () => {
|
|
22
31
|
const { createQueryFunction } = await import('./entrypoint.svelte.js');
|
|
23
|
-
const
|
|
24
|
-
const request = requestMock;
|
|
32
|
+
const request = createApiRequest(schemas, API_URL, 'test');
|
|
25
33
|
const createQuery = createQueryFunction(request, {});
|
|
26
|
-
const query1 = createQuery('/users', '
|
|
27
|
-
const query2 = createQuery('/users', '
|
|
28
|
-
const query3 = createQuery('/users', '
|
|
34
|
+
const query1 = createQuery('/users', 'POST', { id: 1 });
|
|
35
|
+
const query2 = createQuery('/users', 'POST', { id: 1 });
|
|
36
|
+
const query3 = createQuery('/users', 'POST', { id: 2 });
|
|
29
37
|
expect(query1).toBe(query2);
|
|
30
38
|
expect(query3).not.toBe(query1);
|
|
31
39
|
});
|
|
32
40
|
it('reuses requestors so separate queries can batch together', async () => {
|
|
33
41
|
const { createQueryFunction } = await import('./entrypoint.svelte.js');
|
|
34
|
-
const
|
|
35
|
-
|
|
42
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ 1: 'one', 2: 'two' }));
|
|
43
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
44
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
45
|
+
const request = createApiRequest(schemas, API_URL, 'test');
|
|
36
46
|
const createQuery = createQueryFunction(request, {
|
|
37
47
|
'/users': {
|
|
38
|
-
|
|
48
|
+
POST: {
|
|
39
49
|
canBatch: () => 'users',
|
|
40
50
|
batchInput: (inputs) => ({ ids: inputs.map(getUserId) }),
|
|
41
|
-
unBatchOutput: (inputs
|
|
51
|
+
unBatchOutput: async (inputs, outputs) => {
|
|
52
|
+
return inputs.map(() => {
|
|
53
|
+
return outputs;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
42
56
|
}
|
|
43
57
|
}
|
|
44
58
|
});
|
|
45
|
-
const query1 = createQuery('/users', '
|
|
46
|
-
const query2 = createQuery('/users', '
|
|
59
|
+
const query1 = createQuery('/users', 'POST', { id: 1 });
|
|
60
|
+
const query2 = createQuery('/users', 'POST', { id: 2 });
|
|
47
61
|
const p1 = query1.request();
|
|
48
62
|
const p2 = query2.request();
|
|
49
63
|
await vi.advanceTimersByTimeAsync(100);
|
|
50
|
-
expect(
|
|
51
|
-
expect(
|
|
64
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(fetchMock).toHaveBeenCalledWith(`${API_URL}//users`, expect.objectContaining({
|
|
66
|
+
method: 'POST',
|
|
67
|
+
body: JSON.stringify({ ids: [1, 2] }),
|
|
68
|
+
credentials: 'include'
|
|
69
|
+
}));
|
|
52
70
|
const [response1, response2] = await Promise.all([p1, p2]);
|
|
53
|
-
await expect(response1.json()).resolves.toEqual({
|
|
54
|
-
await expect(response2.json()).resolves.toEqual({
|
|
71
|
+
await expect(response1.json()).resolves.toEqual({ 1: 'one', 2: 'two' });
|
|
72
|
+
await expect(response2.json()).resolves.toEqual({ 1: 'one', 2: 'two' });
|
|
55
73
|
});
|
|
56
74
|
});
|
|
@@ -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;IA6BvB,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,25 +1,6 @@
|
|
|
1
1
|
import { stringify } from 'devalue';
|
|
2
2
|
import { cacheKey } from './utils.svelte.js';
|
|
3
3
|
import { RateLimiter } from './rate.svelte';
|
|
4
|
-
function copyOwnOverrides(source, target) {
|
|
5
|
-
for (const key of Reflect.ownKeys(source)) {
|
|
6
|
-
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
|
7
|
-
if (!descriptor)
|
|
8
|
-
continue;
|
|
9
|
-
Object.defineProperty(target, key, descriptor);
|
|
10
|
-
}
|
|
11
|
-
return target;
|
|
12
|
-
}
|
|
13
|
-
function cloneResponse(response) {
|
|
14
|
-
if (response !== null &&
|
|
15
|
-
typeof response === 'object' &&
|
|
16
|
-
'clone' in response &&
|
|
17
|
-
typeof response.clone === 'function') {
|
|
18
|
-
const source = response;
|
|
19
|
-
return copyOwnOverrides(source, response.clone());
|
|
20
|
-
}
|
|
21
|
-
return response;
|
|
22
|
-
}
|
|
23
4
|
export class Query {
|
|
24
5
|
// -------- Constants --------
|
|
25
6
|
#TIMEOUT = 1000 * 60 * 5; // 5 minutes
|
|
@@ -53,7 +34,7 @@ export class Query {
|
|
|
53
34
|
async request() {
|
|
54
35
|
const cachedValue = this.#cache.get(this.#cacheKey);
|
|
55
36
|
if (cachedValue !== null) {
|
|
56
|
-
return
|
|
37
|
+
return cachedValue;
|
|
57
38
|
}
|
|
58
39
|
this.#status = 'loading';
|
|
59
40
|
if (this.#pendingRequest === null) {
|
|
@@ -70,9 +51,9 @@ export class Query {
|
|
|
70
51
|
finally {
|
|
71
52
|
this.#pendingRequest = null;
|
|
72
53
|
}
|
|
73
|
-
const responseForState =
|
|
74
|
-
const responseForCaller =
|
|
75
|
-
this.#cache.set(this.#cacheKey,
|
|
54
|
+
const responseForState = res;
|
|
55
|
+
const responseForCaller = res;
|
|
56
|
+
this.#cache.set(this.#cacheKey, res);
|
|
76
57
|
if (responseForState.ok === false) {
|
|
77
58
|
const body = await responseForState.json();
|
|
78
59
|
this.#status = 'error';
|
|
@@ -147,6 +128,8 @@ export class Requestor {
|
|
|
147
128
|
*/
|
|
148
129
|
async flushBatchQueue(batchId) {
|
|
149
130
|
const queue = this.#batchQueue[batchId].splice(0);
|
|
131
|
+
// TODO maybe remove the unBatchOutput function and just always return the
|
|
132
|
+
// same response and then its on each consumer of each query to find the relevant records
|
|
150
133
|
try {
|
|
151
134
|
const batchedInput = this.#batchInput(queue.map((q) => q.input));
|
|
152
135
|
const res = await this.fetch(batchedInput);
|
|
@@ -4,15 +4,29 @@ import { Cache } from './cache.svelte.js';
|
|
|
4
4
|
import { Query, Requestor } from './query.svelte.js';
|
|
5
5
|
import { stringify } from 'devalue';
|
|
6
6
|
import * as v from 'valibot';
|
|
7
|
+
const API_URL = 'https://api.example.test';
|
|
8
|
+
const plainSchemas = {
|
|
9
|
+
'/users': {
|
|
10
|
+
GET: v.object({ id: v.number() })
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const batchedSchemas = {
|
|
14
|
+
'/users': {
|
|
15
|
+
POST: v.union([v.object({ id: v.number(), group: v.optional(v.string()) }), v.object({ ids: v.array(v.number()) })])
|
|
16
|
+
}
|
|
17
|
+
};
|
|
7
18
|
function getSingleId(input) {
|
|
8
19
|
return 'id' in input ? input.id : input.ids[0];
|
|
9
20
|
}
|
|
10
|
-
function
|
|
21
|
+
function jsonFetchResponse(body, status = 200) {
|
|
11
22
|
return new Response(JSON.stringify(body), {
|
|
12
23
|
status,
|
|
13
24
|
headers: { 'content-type': 'application/json' }
|
|
14
25
|
});
|
|
15
26
|
}
|
|
27
|
+
function jsonResponse(body, status = 200) {
|
|
28
|
+
return jsonFetchResponse(body, status);
|
|
29
|
+
}
|
|
16
30
|
function devalueFetchResponse(body, status = 200) {
|
|
17
31
|
return new Response(stringify(body), {
|
|
18
32
|
status,
|
|
@@ -39,6 +53,18 @@ function deferred() {
|
|
|
39
53
|
});
|
|
40
54
|
return { promise, resolve, reject };
|
|
41
55
|
}
|
|
56
|
+
function createPlainRequest() {
|
|
57
|
+
return createApiRequest(plainSchemas, API_URL, 'test');
|
|
58
|
+
}
|
|
59
|
+
function createBatchedRequest() {
|
|
60
|
+
return createApiRequest(batchedSchemas, API_URL, 'test');
|
|
61
|
+
}
|
|
62
|
+
function createPlainRequestor() {
|
|
63
|
+
return new Requestor('/users', 'GET', createPlainRequest(), new Cache());
|
|
64
|
+
}
|
|
65
|
+
function createBatchedRequestor(batchDetails) {
|
|
66
|
+
return new Requestor('/users', 'POST', createBatchedRequest(), new Cache(), batchDetails);
|
|
67
|
+
}
|
|
42
68
|
describe('Requestor', () => {
|
|
43
69
|
beforeEach(() => {
|
|
44
70
|
vi.useFakeTimers();
|
|
@@ -50,35 +76,33 @@ describe('Requestor', () => {
|
|
|
50
76
|
vi.unstubAllGlobals();
|
|
51
77
|
});
|
|
52
78
|
it('passes through non-batched requests', async () => {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
const requestor =
|
|
79
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ id: 1 }));
|
|
80
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
81
|
+
const requestor = createPlainRequestor();
|
|
56
82
|
const response = await requestor.request({ id: 1 });
|
|
57
|
-
expect(
|
|
58
|
-
expect(
|
|
83
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(fetchMock).toHaveBeenCalledWith(`${API_URL}//users?id=1`, expect.objectContaining({
|
|
85
|
+
method: 'GET',
|
|
86
|
+
credentials: 'include'
|
|
87
|
+
}));
|
|
59
88
|
await expect(response.json()).resolves.toEqual({ id: 1 });
|
|
60
89
|
});
|
|
61
90
|
it('devalue response', async () => {
|
|
62
91
|
const fetchMock = vi.fn(async () => devalueFetchResponse({ id: 1 }));
|
|
63
92
|
vi.stubGlobal('fetch', fetchMock);
|
|
64
|
-
const
|
|
65
|
-
'/users': {
|
|
66
|
-
GET: v.object({ id: v.number() })
|
|
67
|
-
}
|
|
68
|
-
}, 'https://api.example.test', 'test');
|
|
69
|
-
const requestor = new Requestor('/users', 'GET', request, new Cache());
|
|
93
|
+
const requestor = createPlainRequestor();
|
|
70
94
|
const response = await requestor.request({ id: 1 });
|
|
71
95
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
72
|
-
expect(fetchMock).toHaveBeenCalledWith(
|
|
96
|
+
expect(fetchMock).toHaveBeenCalledWith(`${API_URL}//users?id=1`, expect.objectContaining({
|
|
73
97
|
method: 'GET',
|
|
74
98
|
credentials: 'include'
|
|
75
99
|
}));
|
|
76
100
|
await expect(response.json()).resolves.toEqual({ id: 1 });
|
|
77
101
|
});
|
|
78
102
|
it('batches requests with the same batch id and preserves response order', async () => {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
const requestor =
|
|
103
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ ok: true }));
|
|
104
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
105
|
+
const requestor = createBatchedRequestor({
|
|
82
106
|
canBatch: (input) => ('group' in input && input.group) || false,
|
|
83
107
|
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
84
108
|
unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getSingleId(input) }))
|
|
@@ -86,25 +110,29 @@ describe('Requestor', () => {
|
|
|
86
110
|
const p1 = requestor.request({ id: 1, group: 'team' });
|
|
87
111
|
const p2 = requestor.request({ id: 2, group: 'team' });
|
|
88
112
|
await vi.advanceTimersByTimeAsync(99);
|
|
89
|
-
expect(
|
|
113
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
90
114
|
await vi.advanceTimersByTimeAsync(1);
|
|
91
|
-
expect(
|
|
92
|
-
expect(
|
|
115
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(fetchMock).toHaveBeenCalledWith(`${API_URL}//users`, expect.objectContaining({
|
|
117
|
+
method: 'POST',
|
|
118
|
+
body: JSON.stringify({ ids: [1, 2] }),
|
|
119
|
+
credentials: 'include'
|
|
120
|
+
}));
|
|
93
121
|
const [response1, response2] = await Promise.all([p1, p2]);
|
|
94
122
|
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
95
123
|
await expect(response2.json()).resolves.toEqual({ id: 2 });
|
|
96
124
|
});
|
|
97
125
|
it('rate limits separate batches by start time rather than completion time', async () => {
|
|
98
126
|
const starts = [];
|
|
99
|
-
const
|
|
127
|
+
const fetchMock = vi.fn(async (_url, init) => {
|
|
100
128
|
starts.push(Date.now());
|
|
101
|
-
if (
|
|
129
|
+
if (init?.body === JSON.stringify({ ids: [1] })) {
|
|
102
130
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
103
131
|
}
|
|
104
|
-
return
|
|
132
|
+
return jsonFetchResponse({ ok: true });
|
|
105
133
|
});
|
|
106
|
-
|
|
107
|
-
const requestor =
|
|
134
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
135
|
+
const requestor = createBatchedRequestor({
|
|
108
136
|
canBatch: (input) => ('group' in input && input.group) || false,
|
|
109
137
|
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
110
138
|
unBatchOutput: (_inputs, output) => [output]
|
|
@@ -117,13 +145,13 @@ describe('Requestor', () => {
|
|
|
117
145
|
expect(starts).toEqual([100, 200]);
|
|
118
146
|
await vi.runAllTimersAsync();
|
|
119
147
|
const [response1, response2] = await Promise.all([p1, p2]);
|
|
120
|
-
await expect(response1.json()).resolves.toEqual({
|
|
121
|
-
await expect(response2.json()).resolves.toEqual({
|
|
148
|
+
await expect(response1.json()).resolves.toEqual({ ok: true });
|
|
149
|
+
await expect(response2.json()).resolves.toEqual({ ok: true });
|
|
122
150
|
});
|
|
123
151
|
it('returns batched error responses without rejecting them', async () => {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
const requestor =
|
|
152
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ ok: false }, 207));
|
|
153
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
154
|
+
const requestor = createBatchedRequestor({
|
|
127
155
|
canBatch: () => 'team',
|
|
128
156
|
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
129
157
|
unBatchOutput: () => [jsonResponse({ message: 'bad request' }, 400)]
|
|
@@ -136,11 +164,11 @@ describe('Requestor', () => {
|
|
|
136
164
|
await expect(response.json()).resolves.toEqual({ message: 'bad request' });
|
|
137
165
|
});
|
|
138
166
|
it('rejects all queued callers when a batched fetch throws', async () => {
|
|
139
|
-
const
|
|
167
|
+
const fetchMock = vi.fn(async () => {
|
|
140
168
|
throw new Error('network down');
|
|
141
169
|
});
|
|
142
|
-
|
|
143
|
-
const requestor =
|
|
170
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
171
|
+
const requestor = createBatchedRequestor({
|
|
144
172
|
canBatch: () => 'team',
|
|
145
173
|
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
146
174
|
unBatchOutput: (_inputs, output) => [output]
|
|
@@ -161,43 +189,40 @@ describe('Query', () => {
|
|
|
161
189
|
afterEach(() => {
|
|
162
190
|
vi.useRealTimers();
|
|
163
191
|
vi.restoreAllMocks();
|
|
192
|
+
vi.unstubAllGlobals();
|
|
164
193
|
});
|
|
165
194
|
it('deduplicates concurrent requests and returns readable responses to each caller', async () => {
|
|
166
195
|
const pending = deferred();
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
request: requestMock
|
|
170
|
-
};
|
|
196
|
+
const fetchMock = vi.fn().mockReturnValue(pending.promise);
|
|
197
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
171
198
|
const query = new Query({
|
|
172
199
|
path: '/users',
|
|
173
200
|
method: 'GET',
|
|
174
201
|
input: { id: 1 },
|
|
175
|
-
requestor,
|
|
202
|
+
requestor: createPlainRequestor(),
|
|
176
203
|
cache: new Cache()
|
|
177
204
|
});
|
|
178
205
|
const p1 = query.request();
|
|
179
206
|
const p2 = query.request();
|
|
180
|
-
expect(
|
|
181
|
-
pending.resolve(
|
|
207
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
208
|
+
pending.resolve(jsonFetchResponse({ id: 1 }));
|
|
182
209
|
const [response1, response2] = await Promise.all([p1, p2]);
|
|
183
210
|
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
184
211
|
await expect(response2.json()).resolves.toEqual({ id: 1 });
|
|
185
212
|
});
|
|
186
|
-
it('caches responses and returns
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
request: requestMock
|
|
190
|
-
};
|
|
213
|
+
it('caches responses and returns readable responses on cache hits', async () => {
|
|
214
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ id: 1, name: 'Ada' }));
|
|
215
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
191
216
|
const query = new Query({
|
|
192
217
|
path: '/users',
|
|
193
218
|
method: 'GET',
|
|
194
219
|
input: { id: 1 },
|
|
195
|
-
requestor,
|
|
220
|
+
requestor: createPlainRequestor(),
|
|
196
221
|
cache: new Cache()
|
|
197
222
|
});
|
|
198
223
|
const first = await query.request();
|
|
199
224
|
const second = await query.request();
|
|
200
|
-
expect(
|
|
225
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
201
226
|
expect(query.isCached).toBe(true);
|
|
202
227
|
await expect(first.json()).resolves.toEqual({ id: 1, name: 'Ada' });
|
|
203
228
|
await expect(second.json()).resolves.toEqual({ id: 1, name: 'Ada' });
|
|
@@ -205,17 +230,11 @@ describe('Query', () => {
|
|
|
205
230
|
it('preserves devalue parsing for query state, returned responses, and cache hits', async () => {
|
|
206
231
|
const fetchMock = vi.fn(async () => devalueFetchResponse({ id: 1, createdAt: new Date('2024-01-01T00:00:00.000Z') }));
|
|
207
232
|
vi.stubGlobal('fetch', fetchMock);
|
|
208
|
-
const request = createApiRequest({
|
|
209
|
-
'/users': {
|
|
210
|
-
GET: v.object({ id: v.number() })
|
|
211
|
-
}
|
|
212
|
-
}, 'https://api.example.test', 'test');
|
|
213
|
-
const requestor = new Requestor('/users', 'GET', request, new Cache());
|
|
214
233
|
const query = new Query({
|
|
215
234
|
path: '/users',
|
|
216
235
|
method: 'GET',
|
|
217
236
|
input: { id: 1 },
|
|
218
|
-
requestor,
|
|
237
|
+
requestor: createPlainRequestor(),
|
|
219
238
|
cache: new Cache()
|
|
220
239
|
});
|
|
221
240
|
const firstResponse = await query.request();
|
|
@@ -233,37 +252,32 @@ describe('Query', () => {
|
|
|
233
252
|
createdAt: new Date('2024-01-01T00:00:00.000Z')
|
|
234
253
|
});
|
|
235
254
|
});
|
|
236
|
-
it('preserves arbitrary response overrides across query
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
const requestor = {
|
|
240
|
-
request: requestMock
|
|
241
|
-
};
|
|
255
|
+
it('preserves arbitrary response overrides across query responses and cache hits', async () => {
|
|
256
|
+
const fetchMock = vi.fn(async () => withResponseOverrides(jsonFetchResponse({ id: 1 })));
|
|
257
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
242
258
|
const query = new Query({
|
|
243
259
|
path: '/users',
|
|
244
260
|
method: 'GET',
|
|
245
261
|
input: { id: 1 },
|
|
246
|
-
requestor,
|
|
262
|
+
requestor: createPlainRequestor(),
|
|
247
263
|
cache: new Cache()
|
|
248
264
|
});
|
|
249
265
|
const first = (await query.request());
|
|
250
266
|
const second = (await query.request());
|
|
251
|
-
expect(
|
|
267
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
252
268
|
expect(first.extra()).toBe('copied');
|
|
253
269
|
expect(first.meta).toEqual({ source: 'custom' });
|
|
254
270
|
expect(second.extra()).toBe('copied');
|
|
255
271
|
expect(second.meta).toEqual({ source: 'custom' });
|
|
256
272
|
});
|
|
257
273
|
it('updates success state from successful responses', async () => {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
request: requestMock
|
|
261
|
-
};
|
|
274
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ id: 1, active: true }));
|
|
275
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
262
276
|
const query = new Query({
|
|
263
277
|
path: '/users',
|
|
264
278
|
method: 'GET',
|
|
265
279
|
input: { id: 1 },
|
|
266
|
-
requestor,
|
|
280
|
+
requestor: createPlainRequestor(),
|
|
267
281
|
cache: new Cache()
|
|
268
282
|
});
|
|
269
283
|
const response = await query.request();
|
|
@@ -273,15 +287,13 @@ describe('Query', () => {
|
|
|
273
287
|
await expect(response.json()).resolves.toEqual({ id: 1, active: true });
|
|
274
288
|
});
|
|
275
289
|
it('updates error state from error responses without throwing', async () => {
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
request: requestMock
|
|
279
|
-
};
|
|
290
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ message: 'missing' }, 404));
|
|
291
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
280
292
|
const query = new Query({
|
|
281
293
|
path: '/users',
|
|
282
294
|
method: 'GET',
|
|
283
295
|
input: { id: 99 },
|
|
284
|
-
requestor,
|
|
296
|
+
requestor: createPlainRequestor(),
|
|
285
297
|
cache: new Cache()
|
|
286
298
|
});
|
|
287
299
|
const response = await query.request();
|
|
@@ -292,62 +304,66 @@ describe('Query', () => {
|
|
|
292
304
|
await expect(response.json()).resolves.toEqual({ message: 'missing' });
|
|
293
305
|
});
|
|
294
306
|
it('clears the pending request when the request throws so later retries can succeed', async () => {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
307
|
+
let callCount = 0;
|
|
308
|
+
const fetchMock = vi.fn(async () => {
|
|
309
|
+
callCount += 1;
|
|
310
|
+
if (callCount === 1) {
|
|
311
|
+
throw new Error('network down');
|
|
312
|
+
}
|
|
313
|
+
return jsonFetchResponse({ id: 1, recovered: true });
|
|
314
|
+
});
|
|
315
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
302
316
|
const query = new Query({
|
|
303
317
|
path: '/users',
|
|
304
318
|
method: 'GET',
|
|
305
319
|
input: { id: 1 },
|
|
306
|
-
requestor,
|
|
320
|
+
requestor: createPlainRequestor(),
|
|
307
321
|
cache: new Cache()
|
|
308
322
|
});
|
|
309
323
|
await expect(query.request()).rejects.toThrow('network down');
|
|
310
324
|
expect(query.status).toBe('error');
|
|
311
|
-
const
|
|
312
|
-
|
|
325
|
+
const responsePromise = query.request();
|
|
326
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
327
|
+
const response = await responsePromise;
|
|
328
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
313
329
|
expect(query.status).toBe('success');
|
|
314
330
|
await expect(response.json()).resolves.toEqual({ id: 1, recovered: true });
|
|
315
331
|
});
|
|
316
332
|
it('resetCache forces the next request to fetch again', async () => {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
};
|
|
333
|
+
let callCount = 0;
|
|
334
|
+
const fetchMock = vi.fn(async () => {
|
|
335
|
+
callCount += 1;
|
|
336
|
+
return jsonFetchResponse({ call: callCount });
|
|
337
|
+
});
|
|
338
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
324
339
|
const query = new Query({
|
|
325
340
|
path: '/users',
|
|
326
341
|
method: 'GET',
|
|
327
342
|
input: { id: 1 },
|
|
328
|
-
requestor,
|
|
343
|
+
requestor: createPlainRequestor(),
|
|
329
344
|
cache: new Cache()
|
|
330
345
|
});
|
|
331
346
|
const first = await query.request();
|
|
332
347
|
query.resetCache();
|
|
333
|
-
const
|
|
334
|
-
|
|
348
|
+
const secondPromise = query.request();
|
|
349
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
350
|
+
const second = await secondPromise;
|
|
351
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
335
352
|
await expect(first.json()).resolves.toEqual({ call: 1 });
|
|
336
353
|
await expect(second.json()).resolves.toEqual({ call: 2 });
|
|
337
354
|
});
|
|
338
355
|
it('honors custom cache timeout options', async () => {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
};
|
|
356
|
+
let callCount = 0;
|
|
357
|
+
const fetchMock = vi.fn(async () => {
|
|
358
|
+
callCount += 1;
|
|
359
|
+
return jsonFetchResponse({ call: callCount });
|
|
360
|
+
});
|
|
361
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
346
362
|
const query = new Query({
|
|
347
363
|
path: '/users',
|
|
348
364
|
method: 'GET',
|
|
349
365
|
input: { id: 1 },
|
|
350
|
-
requestor,
|
|
366
|
+
requestor: createPlainRequestor(),
|
|
351
367
|
cache: new Cache(),
|
|
352
368
|
opts: {
|
|
353
369
|
cache: { timeout: 50 }
|
|
@@ -357,8 +373,10 @@ describe('Query', () => {
|
|
|
357
373
|
vi.advanceTimersByTime(49);
|
|
358
374
|
const cached = await query.request();
|
|
359
375
|
vi.advanceTimersByTime(1);
|
|
360
|
-
const
|
|
361
|
-
|
|
376
|
+
const refreshedPromise = query.request();
|
|
377
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
378
|
+
const refreshed = await refreshedPromise;
|
|
379
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
362
380
|
await expect(first.json()).resolves.toEqual({ call: 1 });
|
|
363
381
|
await expect(cached.json()).resolves.toEqual({ call: 1 });
|
|
364
382
|
await expect(refreshed.json()).resolves.toEqual({ call: 2 });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-ag",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.60",
|
|
4
4
|
"description": "Useful svelte components",
|
|
5
5
|
"bugs": "https://github.com/ageorgeh/svelte-ag/issues",
|
|
6
6
|
"repository": {
|
|
@@ -41,8 +41,7 @@
|
|
|
41
41
|
"svelte:sync": "svelte-kit sync",
|
|
42
42
|
"test": "vitest --run",
|
|
43
43
|
"test:e2e": "playwright test",
|
|
44
|
-
"test:e2e
|
|
45
|
-
"test:e2e:update": "playwright test --update-snapshots",
|
|
44
|
+
"test:e2e-local": "PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:3005/ pnpm test:e2e",
|
|
46
45
|
"watch": "svelte-package -i src -w"
|
|
47
46
|
},
|
|
48
47
|
"dependencies": {
|
|
@@ -71,7 +70,7 @@
|
|
|
71
70
|
"@iconify/types": "^2.0.0",
|
|
72
71
|
"@internationalized/date": "^3.12.0",
|
|
73
72
|
"@lucide/svelte": "^1.7.0",
|
|
74
|
-
"@playwright/test": "1.
|
|
73
|
+
"@playwright/test": "1.58.2",
|
|
75
74
|
"@sveltejs/kit": "^2.55.0",
|
|
76
75
|
"@sveltejs/package": "^2.5.7",
|
|
77
76
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import
|
|
2
|
+
import { createApiRequest, type ApiEndpoints } from 'ts-ag';
|
|
3
|
+
import * as v from 'valibot';
|
|
3
4
|
|
|
4
5
|
type TestResponse = ApiEndpoints['response'];
|
|
5
6
|
|
|
@@ -8,23 +9,29 @@ type BatchedInput = { ids: number[] };
|
|
|
8
9
|
|
|
9
10
|
type UsersApi = {
|
|
10
11
|
path: '/users';
|
|
11
|
-
method: '
|
|
12
|
+
method: 'POST';
|
|
12
13
|
requestInput: UserInput | BatchedInput;
|
|
13
14
|
requestOutput: null;
|
|
14
15
|
response: TestResponse;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
const API_URL = 'https://api.example.test';
|
|
19
|
+
|
|
20
|
+
const schemas = {
|
|
21
|
+
'/users': {
|
|
22
|
+
POST: v.union([v.object({ id: v.number(), group: v.optional(v.string()) }), v.object({ ids: v.array(v.number()) })])
|
|
23
|
+
}
|
|
24
|
+
};
|
|
18
25
|
|
|
19
26
|
function getUserId(input: UsersApi['requestInput']): number {
|
|
20
27
|
return 'id' in input ? input.id : input.ids[0]!;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
|
-
function
|
|
30
|
+
function jsonFetchResponse(body: unknown, status = 200): Response {
|
|
24
31
|
return new Response(JSON.stringify(body), {
|
|
25
32
|
status,
|
|
26
33
|
headers: { 'content-type': 'application/json' }
|
|
27
|
-
})
|
|
34
|
+
});
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
describe('createQueryFunction', () => {
|
|
@@ -37,17 +44,17 @@ describe('createQueryFunction', () => {
|
|
|
37
44
|
afterEach(() => {
|
|
38
45
|
vi.useRealTimers();
|
|
39
46
|
vi.restoreAllMocks();
|
|
47
|
+
vi.unstubAllGlobals();
|
|
40
48
|
});
|
|
41
49
|
|
|
42
50
|
it('returns the same query instance for the same path, method, and input', async () => {
|
|
43
51
|
const { createQueryFunction } = await import('./entrypoint.svelte.js');
|
|
44
|
-
const
|
|
45
|
-
const request = requestMock as unknown as UsersRequest;
|
|
52
|
+
const request = createApiRequest<UsersApi>(schemas, API_URL, 'test');
|
|
46
53
|
const createQuery = createQueryFunction<UsersApi>(request, {});
|
|
47
54
|
|
|
48
|
-
const query1 = createQuery('/users', '
|
|
49
|
-
const query2 = createQuery('/users', '
|
|
50
|
-
const query3 = createQuery('/users', '
|
|
55
|
+
const query1 = createQuery('/users', 'POST', { id: 1 });
|
|
56
|
+
const query2 = createQuery('/users', 'POST', { id: 1 });
|
|
57
|
+
const query3 = createQuery('/users', 'POST', { id: 2 });
|
|
51
58
|
|
|
52
59
|
expect(query1).toBe(query2);
|
|
53
60
|
expect(query3).not.toBe(query1);
|
|
@@ -55,31 +62,44 @@ describe('createQueryFunction', () => {
|
|
|
55
62
|
|
|
56
63
|
it('reuses requestors so separate queries can batch together', async () => {
|
|
57
64
|
const { createQueryFunction } = await import('./entrypoint.svelte.js');
|
|
58
|
-
const
|
|
59
|
-
|
|
65
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ 1: 'one', 2: 'two' }));
|
|
66
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
67
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
68
|
+
const request = createApiRequest<UsersApi>(schemas, API_URL, 'test');
|
|
60
69
|
const createQuery = createQueryFunction<UsersApi>(request, {
|
|
61
70
|
'/users': {
|
|
62
|
-
|
|
71
|
+
POST: {
|
|
63
72
|
canBatch: () => 'users',
|
|
64
73
|
batchInput: (inputs) => ({ ids: inputs.map(getUserId) }),
|
|
65
|
-
unBatchOutput: (inputs
|
|
74
|
+
unBatchOutput: async (inputs, outputs) => {
|
|
75
|
+
return inputs.map(() => {
|
|
76
|
+
return outputs;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
66
79
|
}
|
|
67
80
|
}
|
|
68
81
|
});
|
|
69
82
|
|
|
70
|
-
const query1 = createQuery('/users', '
|
|
71
|
-
const query2 = createQuery('/users', '
|
|
83
|
+
const query1 = createQuery('/users', 'POST', { id: 1 });
|
|
84
|
+
const query2 = createQuery('/users', 'POST', { id: 2 });
|
|
72
85
|
|
|
73
86
|
const p1 = query1.request();
|
|
74
87
|
const p2 = query2.request();
|
|
75
88
|
|
|
76
89
|
await vi.advanceTimersByTimeAsync(100);
|
|
77
90
|
|
|
78
|
-
expect(
|
|
79
|
-
expect(
|
|
91
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
93
|
+
`${API_URL}//users`,
|
|
94
|
+
expect.objectContaining({
|
|
95
|
+
method: 'POST',
|
|
96
|
+
body: JSON.stringify({ ids: [1, 2] }),
|
|
97
|
+
credentials: 'include'
|
|
98
|
+
})
|
|
99
|
+
);
|
|
80
100
|
|
|
81
101
|
const [response1, response2] = await Promise.all([p1, p2]);
|
|
82
|
-
await expect(response1.json()).resolves.toEqual({
|
|
83
|
-
await expect(response2.json()).resolves.toEqual({
|
|
102
|
+
await expect(response1.json()).resolves.toEqual({ 1: 'one', 2: 'two' });
|
|
103
|
+
await expect(response2.json()).resolves.toEqual({ 1: 'one', 2: 'two' });
|
|
84
104
|
});
|
|
85
105
|
});
|
|
@@ -9,31 +9,6 @@ import { RateLimiter } from './rate.svelte';
|
|
|
9
9
|
|
|
10
10
|
export type QueryStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
11
11
|
|
|
12
|
-
function copyOwnOverrides<T extends object>(source: T, target: T): T {
|
|
13
|
-
for (const key of Reflect.ownKeys(source)) {
|
|
14
|
-
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
|
15
|
-
if (!descriptor) continue;
|
|
16
|
-
|
|
17
|
-
Object.defineProperty(target, key, descriptor);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
return target;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function cloneResponse<T>(response: T): T {
|
|
24
|
-
if (
|
|
25
|
-
response !== null &&
|
|
26
|
-
typeof response === 'object' &&
|
|
27
|
-
'clone' in response &&
|
|
28
|
-
typeof response.clone === 'function'
|
|
29
|
-
) {
|
|
30
|
-
const source = response as T & object;
|
|
31
|
-
return copyOwnOverrides(source, response.clone() as T & object) as T;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return response;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
12
|
export class Query<
|
|
38
13
|
API extends ApiEndpoints,
|
|
39
14
|
Path extends API['path'],
|
|
@@ -97,7 +72,7 @@ export class Query<
|
|
|
97
72
|
async request(): Promise<ApiResponse<API, Path, Method>> {
|
|
98
73
|
const cachedValue = this.#cache.get(this.#cacheKey);
|
|
99
74
|
if (cachedValue !== null) {
|
|
100
|
-
return
|
|
75
|
+
return cachedValue;
|
|
101
76
|
}
|
|
102
77
|
|
|
103
78
|
this.#status = 'loading';
|
|
@@ -116,9 +91,9 @@ export class Query<
|
|
|
116
91
|
this.#pendingRequest = null;
|
|
117
92
|
}
|
|
118
93
|
|
|
119
|
-
const responseForState =
|
|
120
|
-
const responseForCaller =
|
|
121
|
-
this.#cache.set(this.#cacheKey,
|
|
94
|
+
const responseForState = res;
|
|
95
|
+
const responseForCaller = res;
|
|
96
|
+
this.#cache.set(this.#cacheKey, res);
|
|
122
97
|
|
|
123
98
|
if (responseForState.ok === false) {
|
|
124
99
|
const body = await responseForState.json();
|
|
@@ -223,6 +198,8 @@ export class Requestor<
|
|
|
223
198
|
private async flushBatchQueue(batchId: string): Promise<void> {
|
|
224
199
|
const queue = this.#batchQueue[batchId].splice(0);
|
|
225
200
|
|
|
201
|
+
// TODO maybe remove the unBatchOutput function and just always return the
|
|
202
|
+
// same response and then its on each consumer of each query to find the relevant records
|
|
226
203
|
try {
|
|
227
204
|
const batchedInput = this.#batchInput(queue.map((q) => q.input));
|
|
228
205
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { createApiRequest, type ApiEndpoints
|
|
2
|
+
import { createApiRequest, type ApiEndpoints } from 'ts-ag';
|
|
3
3
|
import { Cache } from './cache.svelte.js';
|
|
4
4
|
import { Query, Requestor } from './query.svelte.js';
|
|
5
5
|
import { stringify } from 'devalue';
|
|
@@ -21,25 +21,39 @@ type PlainUsersApi = {
|
|
|
21
21
|
|
|
22
22
|
type BatchedUsersApi = {
|
|
23
23
|
path: '/users';
|
|
24
|
-
method: '
|
|
24
|
+
method: 'POST';
|
|
25
25
|
requestInput: BatchedUserInput | BatchedRequestInput;
|
|
26
26
|
requestOutput: null;
|
|
27
27
|
response: TestResponse;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const API_URL = 'https://api.example.test';
|
|
31
|
+
|
|
32
|
+
const plainSchemas = {
|
|
33
|
+
'/users': {
|
|
34
|
+
GET: v.object({ id: v.number() })
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const batchedSchemas = {
|
|
39
|
+
'/users': {
|
|
40
|
+
POST: v.union([v.object({ id: v.number(), group: v.optional(v.string()) }), v.object({ ids: v.array(v.number()) })])
|
|
41
|
+
}
|
|
42
|
+
};
|
|
33
43
|
|
|
34
44
|
function getSingleId(input: BatchedUsersApi['requestInput']): number {
|
|
35
45
|
return 'id' in input ? input.id : input.ids[0]!;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
function
|
|
48
|
+
function jsonFetchResponse(body: unknown, status = 200): Response {
|
|
39
49
|
return new Response(JSON.stringify(body), {
|
|
40
50
|
status,
|
|
41
51
|
headers: { 'content-type': 'application/json' }
|
|
42
|
-
})
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function jsonResponse(body: unknown, status = 200): TestResponse {
|
|
56
|
+
return jsonFetchResponse(body, status) as TestResponse;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
function devalueFetchResponse(body: unknown, status = 200): Response {
|
|
@@ -81,6 +95,30 @@ function deferred<T>() {
|
|
|
81
95
|
return { promise, resolve, reject };
|
|
82
96
|
}
|
|
83
97
|
|
|
98
|
+
function createPlainRequest() {
|
|
99
|
+
return createApiRequest<PlainUsersApi>(plainSchemas, API_URL, 'test');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function createBatchedRequest() {
|
|
103
|
+
return createApiRequest<BatchedUsersApi>(batchedSchemas, API_URL, 'test');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createPlainRequestor() {
|
|
107
|
+
return new Requestor<PlainUsersApi, '/users', 'GET'>('/users', 'GET', createPlainRequest(), new Cache());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createBatchedRequestor(
|
|
111
|
+
batchDetails?: ConstructorParameters<typeof Requestor<BatchedUsersApi, '/users', 'POST'>>[4]
|
|
112
|
+
) {
|
|
113
|
+
return new Requestor<BatchedUsersApi, '/users', 'POST'>(
|
|
114
|
+
'/users',
|
|
115
|
+
'POST',
|
|
116
|
+
createBatchedRequest(),
|
|
117
|
+
new Cache(),
|
|
118
|
+
batchDetails
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
84
122
|
describe('Requestor', () => {
|
|
85
123
|
beforeEach(() => {
|
|
86
124
|
vi.useFakeTimers();
|
|
@@ -94,37 +132,33 @@ describe('Requestor', () => {
|
|
|
94
132
|
});
|
|
95
133
|
|
|
96
134
|
it('passes through non-batched requests', async () => {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
const requestor =
|
|
135
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ id: 1 }));
|
|
136
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
137
|
+
const requestor = createPlainRequestor();
|
|
100
138
|
|
|
101
139
|
const response = await requestor.request({ id: 1 });
|
|
102
140
|
|
|
103
|
-
expect(
|
|
104
|
-
expect(
|
|
141
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
142
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
143
|
+
`${API_URL}//users?id=1`,
|
|
144
|
+
expect.objectContaining({
|
|
145
|
+
method: 'GET',
|
|
146
|
+
credentials: 'include'
|
|
147
|
+
})
|
|
148
|
+
);
|
|
105
149
|
await expect(response.json()).resolves.toEqual({ id: 1 });
|
|
106
150
|
});
|
|
107
151
|
|
|
108
152
|
it('devalue response', async () => {
|
|
109
153
|
const fetchMock = vi.fn(async () => devalueFetchResponse({ id: 1 }));
|
|
110
154
|
vi.stubGlobal('fetch', fetchMock);
|
|
111
|
-
|
|
112
|
-
const request = createApiRequest<PlainUsersApi>(
|
|
113
|
-
{
|
|
114
|
-
'/users': {
|
|
115
|
-
GET: v.object({ id: v.number() })
|
|
116
|
-
}
|
|
117
|
-
},
|
|
118
|
-
'https://api.example.test',
|
|
119
|
-
'test'
|
|
120
|
-
);
|
|
121
|
-
const requestor = new Requestor<PlainUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache());
|
|
155
|
+
const requestor = createPlainRequestor();
|
|
122
156
|
|
|
123
157
|
const response = await requestor.request({ id: 1 });
|
|
124
158
|
|
|
125
159
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
126
160
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
127
|
-
|
|
161
|
+
`${API_URL}//users?id=1`,
|
|
128
162
|
expect.objectContaining({
|
|
129
163
|
method: 'GET',
|
|
130
164
|
credentials: 'include'
|
|
@@ -134,9 +168,9 @@ describe('Requestor', () => {
|
|
|
134
168
|
});
|
|
135
169
|
|
|
136
170
|
it('batches requests with the same batch id and preserves response order', async () => {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
const requestor =
|
|
171
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ ok: true }));
|
|
172
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
173
|
+
const requestor = createBatchedRequestor({
|
|
140
174
|
canBatch: (input) => ('group' in input && input.group) || false,
|
|
141
175
|
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
142
176
|
unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getSingleId(input) }))
|
|
@@ -146,11 +180,18 @@ describe('Requestor', () => {
|
|
|
146
180
|
const p2 = requestor.request({ id: 2, group: 'team' });
|
|
147
181
|
|
|
148
182
|
await vi.advanceTimersByTimeAsync(99);
|
|
149
|
-
expect(
|
|
183
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
150
184
|
|
|
151
185
|
await vi.advanceTimersByTimeAsync(1);
|
|
152
|
-
expect(
|
|
153
|
-
expect(
|
|
186
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
188
|
+
`${API_URL}//users`,
|
|
189
|
+
expect.objectContaining({
|
|
190
|
+
method: 'POST',
|
|
191
|
+
body: JSON.stringify({ ids: [1, 2] }),
|
|
192
|
+
credentials: 'include'
|
|
193
|
+
})
|
|
194
|
+
);
|
|
154
195
|
|
|
155
196
|
const [response1, response2] = await Promise.all([p1, p2]);
|
|
156
197
|
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
@@ -159,18 +200,17 @@ describe('Requestor', () => {
|
|
|
159
200
|
|
|
160
201
|
it('rate limits separate batches by start time rather than completion time', async () => {
|
|
161
202
|
const starts: number[] = [];
|
|
162
|
-
const
|
|
203
|
+
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
163
204
|
starts.push(Date.now());
|
|
164
205
|
|
|
165
|
-
if (
|
|
206
|
+
if (init?.body === JSON.stringify({ ids: [1] })) {
|
|
166
207
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
167
208
|
}
|
|
168
209
|
|
|
169
|
-
return
|
|
210
|
+
return jsonFetchResponse({ ok: true });
|
|
170
211
|
});
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
|
|
212
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
213
|
+
const requestor = createBatchedRequestor({
|
|
174
214
|
canBatch: (input) => ('group' in input && input.group) || false,
|
|
175
215
|
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
176
216
|
unBatchOutput: (_inputs, output) => [output]
|
|
@@ -188,14 +228,14 @@ describe('Requestor', () => {
|
|
|
188
228
|
await vi.runAllTimersAsync();
|
|
189
229
|
|
|
190
230
|
const [response1, response2] = await Promise.all([p1, p2]);
|
|
191
|
-
await expect(response1.json()).resolves.toEqual({
|
|
192
|
-
await expect(response2.json()).resolves.toEqual({
|
|
231
|
+
await expect(response1.json()).resolves.toEqual({ ok: true });
|
|
232
|
+
await expect(response2.json()).resolves.toEqual({ ok: true });
|
|
193
233
|
});
|
|
194
234
|
|
|
195
235
|
it('returns batched error responses without rejecting them', async () => {
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
const requestor =
|
|
236
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ ok: false }, 207));
|
|
237
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
238
|
+
const requestor = createBatchedRequestor({
|
|
199
239
|
canBatch: () => 'team',
|
|
200
240
|
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
201
241
|
unBatchOutput: () => [jsonResponse({ message: 'bad request' }, 400)]
|
|
@@ -212,11 +252,11 @@ describe('Requestor', () => {
|
|
|
212
252
|
});
|
|
213
253
|
|
|
214
254
|
it('rejects all queued callers when a batched fetch throws', async () => {
|
|
215
|
-
const
|
|
255
|
+
const fetchMock = vi.fn(async () => {
|
|
216
256
|
throw new Error('network down');
|
|
217
257
|
});
|
|
218
|
-
|
|
219
|
-
const requestor =
|
|
258
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
259
|
+
const requestor = createBatchedRequestor({
|
|
220
260
|
canBatch: () => 'team',
|
|
221
261
|
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
222
262
|
unBatchOutput: (_inputs, output) => [output]
|
|
@@ -242,53 +282,48 @@ describe('Query', () => {
|
|
|
242
282
|
afterEach(() => {
|
|
243
283
|
vi.useRealTimers();
|
|
244
284
|
vi.restoreAllMocks();
|
|
285
|
+
vi.unstubAllGlobals();
|
|
245
286
|
});
|
|
246
287
|
|
|
247
288
|
it('deduplicates concurrent requests and returns readable responses to each caller', async () => {
|
|
248
289
|
const pending = deferred<Response>();
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
request: requestMock as PlainUsersRequest
|
|
252
|
-
} as unknown as PlainUsersRequestor;
|
|
253
|
-
|
|
290
|
+
const fetchMock = vi.fn().mockReturnValue(pending.promise);
|
|
291
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
254
292
|
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
255
293
|
path: '/users',
|
|
256
294
|
method: 'GET',
|
|
257
295
|
input: { id: 1 },
|
|
258
|
-
requestor,
|
|
296
|
+
requestor: createPlainRequestor(),
|
|
259
297
|
cache: new Cache()
|
|
260
298
|
});
|
|
261
299
|
|
|
262
300
|
const p1 = query.request();
|
|
263
301
|
const p2 = query.request();
|
|
264
302
|
|
|
265
|
-
expect(
|
|
303
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
266
304
|
|
|
267
|
-
pending.resolve(
|
|
305
|
+
pending.resolve(jsonFetchResponse({ id: 1 }));
|
|
268
306
|
|
|
269
307
|
const [response1, response2] = await Promise.all([p1, p2]);
|
|
270
308
|
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
271
309
|
await expect(response2.json()).resolves.toEqual({ id: 1 });
|
|
272
310
|
});
|
|
273
311
|
|
|
274
|
-
it('caches responses and returns
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
request: requestMock as PlainUsersRequest
|
|
278
|
-
} as unknown as PlainUsersRequestor;
|
|
279
|
-
|
|
312
|
+
it('caches responses and returns readable responses on cache hits', async () => {
|
|
313
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ id: 1, name: 'Ada' }));
|
|
314
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
280
315
|
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
281
316
|
path: '/users',
|
|
282
317
|
method: 'GET',
|
|
283
318
|
input: { id: 1 },
|
|
284
|
-
requestor,
|
|
319
|
+
requestor: createPlainRequestor(),
|
|
285
320
|
cache: new Cache()
|
|
286
321
|
});
|
|
287
322
|
|
|
288
323
|
const first = await query.request();
|
|
289
324
|
const second = await query.request();
|
|
290
325
|
|
|
291
|
-
expect(
|
|
326
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
292
327
|
expect(query.isCached).toBe(true);
|
|
293
328
|
await expect(first.json()).resolves.toEqual({ id: 1, name: 'Ada' });
|
|
294
329
|
await expect(second.json()).resolves.toEqual({ id: 1, name: 'Ada' });
|
|
@@ -299,22 +334,11 @@ describe('Query', () => {
|
|
|
299
334
|
devalueFetchResponse({ id: 1, createdAt: new Date('2024-01-01T00:00:00.000Z') })
|
|
300
335
|
);
|
|
301
336
|
vi.stubGlobal('fetch', fetchMock);
|
|
302
|
-
|
|
303
|
-
const request = createApiRequest<PlainUsersApi>(
|
|
304
|
-
{
|
|
305
|
-
'/users': {
|
|
306
|
-
GET: v.object({ id: v.number() })
|
|
307
|
-
}
|
|
308
|
-
},
|
|
309
|
-
'https://api.example.test',
|
|
310
|
-
'test'
|
|
311
|
-
);
|
|
312
|
-
const requestor = new Requestor<PlainUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache());
|
|
313
337
|
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
314
338
|
path: '/users',
|
|
315
339
|
method: 'GET',
|
|
316
340
|
input: { id: 1 },
|
|
317
|
-
requestor,
|
|
341
|
+
requestor: createPlainRequestor(),
|
|
318
342
|
cache: new Cache()
|
|
319
343
|
});
|
|
320
344
|
|
|
@@ -337,18 +361,14 @@ describe('Query', () => {
|
|
|
337
361
|
});
|
|
338
362
|
});
|
|
339
363
|
|
|
340
|
-
it('preserves arbitrary response overrides across query
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
const requestor = {
|
|
344
|
-
request: requestMock as PlainUsersRequest
|
|
345
|
-
} as unknown as PlainUsersRequestor;
|
|
346
|
-
|
|
364
|
+
it('preserves arbitrary response overrides across query responses and cache hits', async () => {
|
|
365
|
+
const fetchMock = vi.fn(async () => withResponseOverrides(jsonFetchResponse({ id: 1 })));
|
|
366
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
347
367
|
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
348
368
|
path: '/users',
|
|
349
369
|
method: 'GET',
|
|
350
370
|
input: { id: 1 },
|
|
351
|
-
requestor,
|
|
371
|
+
requestor: createPlainRequestor(),
|
|
352
372
|
cache: new Cache()
|
|
353
373
|
});
|
|
354
374
|
|
|
@@ -361,7 +381,7 @@ describe('Query', () => {
|
|
|
361
381
|
meta: { source: string };
|
|
362
382
|
};
|
|
363
383
|
|
|
364
|
-
expect(
|
|
384
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
365
385
|
expect(first.extra()).toBe('copied');
|
|
366
386
|
expect(first.meta).toEqual({ source: 'custom' });
|
|
367
387
|
expect(second.extra()).toBe('copied');
|
|
@@ -369,16 +389,13 @@ describe('Query', () => {
|
|
|
369
389
|
});
|
|
370
390
|
|
|
371
391
|
it('updates success state from successful responses', async () => {
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
request: requestMock as PlainUsersRequest
|
|
375
|
-
} as unknown as PlainUsersRequestor;
|
|
376
|
-
|
|
392
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ id: 1, active: true }));
|
|
393
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
377
394
|
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
378
395
|
path: '/users',
|
|
379
396
|
method: 'GET',
|
|
380
397
|
input: { id: 1 },
|
|
381
|
-
requestor,
|
|
398
|
+
requestor: createPlainRequestor(),
|
|
382
399
|
cache: new Cache()
|
|
383
400
|
});
|
|
384
401
|
|
|
@@ -391,16 +408,13 @@ describe('Query', () => {
|
|
|
391
408
|
});
|
|
392
409
|
|
|
393
410
|
it('updates error state from error responses without throwing', async () => {
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
request: requestMock as PlainUsersRequest
|
|
397
|
-
} as unknown as PlainUsersRequestor;
|
|
398
|
-
|
|
411
|
+
const fetchMock = vi.fn(async () => jsonFetchResponse({ message: 'missing' }, 404));
|
|
412
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
399
413
|
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
400
414
|
path: '/users',
|
|
401
415
|
method: 'GET',
|
|
402
416
|
input: { id: 99 },
|
|
403
|
-
requestor,
|
|
417
|
+
requestor: createPlainRequestor(),
|
|
404
418
|
cache: new Cache()
|
|
405
419
|
});
|
|
406
420
|
|
|
@@ -414,72 +428,75 @@ describe('Query', () => {
|
|
|
414
428
|
});
|
|
415
429
|
|
|
416
430
|
it('clears the pending request when the request throws so later retries can succeed', async () => {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
.mockResolvedValueOnce(jsonResponse({ id: 1, recovered: true }));
|
|
421
|
-
const requestor = {
|
|
422
|
-
request: requestMock as PlainUsersRequest
|
|
423
|
-
} as unknown as PlainUsersRequestor;
|
|
431
|
+
let callCount = 0;
|
|
432
|
+
const fetchMock = vi.fn(async () => {
|
|
433
|
+
callCount += 1;
|
|
424
434
|
|
|
435
|
+
if (callCount === 1) {
|
|
436
|
+
throw new Error('network down');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return jsonFetchResponse({ id: 1, recovered: true });
|
|
440
|
+
});
|
|
441
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
425
442
|
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
426
443
|
path: '/users',
|
|
427
444
|
method: 'GET',
|
|
428
445
|
input: { id: 1 },
|
|
429
|
-
requestor,
|
|
446
|
+
requestor: createPlainRequestor(),
|
|
430
447
|
cache: new Cache()
|
|
431
448
|
});
|
|
432
449
|
|
|
433
450
|
await expect(query.request()).rejects.toThrow('network down');
|
|
434
451
|
expect(query.status).toBe('error');
|
|
435
452
|
|
|
436
|
-
const
|
|
453
|
+
const responsePromise = query.request();
|
|
454
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
455
|
+
const response = await responsePromise;
|
|
437
456
|
|
|
438
|
-
expect(
|
|
457
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
439
458
|
expect(query.status).toBe('success');
|
|
440
459
|
await expect(response.json()).resolves.toEqual({ id: 1, recovered: true });
|
|
441
460
|
});
|
|
442
461
|
|
|
443
462
|
it('resetCache forces the next request to fetch again', async () => {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
} as unknown as PlainUsersRequestor;
|
|
451
|
-
|
|
463
|
+
let callCount = 0;
|
|
464
|
+
const fetchMock = vi.fn(async () => {
|
|
465
|
+
callCount += 1;
|
|
466
|
+
return jsonFetchResponse({ call: callCount });
|
|
467
|
+
});
|
|
468
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
452
469
|
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
453
470
|
path: '/users',
|
|
454
471
|
method: 'GET',
|
|
455
472
|
input: { id: 1 },
|
|
456
|
-
requestor,
|
|
473
|
+
requestor: createPlainRequestor(),
|
|
457
474
|
cache: new Cache()
|
|
458
475
|
});
|
|
459
476
|
|
|
460
477
|
const first = await query.request();
|
|
461
478
|
query.resetCache();
|
|
462
|
-
const
|
|
479
|
+
const secondPromise = query.request();
|
|
480
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
481
|
+
const second = await secondPromise;
|
|
463
482
|
|
|
464
|
-
expect(
|
|
483
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
465
484
|
await expect(first.json()).resolves.toEqual({ call: 1 });
|
|
466
485
|
await expect(second.json()).resolves.toEqual({ call: 2 });
|
|
467
486
|
});
|
|
468
487
|
|
|
469
488
|
it('honors custom cache timeout options', async () => {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
} as unknown as PlainUsersRequestor;
|
|
477
|
-
|
|
489
|
+
let callCount = 0;
|
|
490
|
+
const fetchMock = vi.fn(async () => {
|
|
491
|
+
callCount += 1;
|
|
492
|
+
return jsonFetchResponse({ call: callCount });
|
|
493
|
+
});
|
|
494
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
478
495
|
const query = new Query<PlainUsersApi, '/users', 'GET'>({
|
|
479
496
|
path: '/users',
|
|
480
497
|
method: 'GET',
|
|
481
498
|
input: { id: 1 },
|
|
482
|
-
requestor,
|
|
499
|
+
requestor: createPlainRequestor(),
|
|
483
500
|
cache: new Cache(),
|
|
484
501
|
opts: {
|
|
485
502
|
cache: { timeout: 50 }
|
|
@@ -490,9 +507,11 @@ describe('Query', () => {
|
|
|
490
507
|
vi.advanceTimersByTime(49);
|
|
491
508
|
const cached = await query.request();
|
|
492
509
|
vi.advanceTimersByTime(1);
|
|
493
|
-
const
|
|
510
|
+
const refreshedPromise = query.request();
|
|
511
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
512
|
+
const refreshed = await refreshedPromise;
|
|
494
513
|
|
|
495
|
-
expect(
|
|
514
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
496
515
|
await expect(first.json()).resolves.toEqual({ call: 1 });
|
|
497
516
|
await expect(cached.json()).resolves.toEqual({ call: 1 });
|
|
498
517
|
await expect(refreshed.json()).resolves.toEqual({ call: 2 });
|