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