Skip to content

Commit 532f6b3

Browse files
committed
feat: Add MockPlugin
1 parent 4453a57 commit 532f6b3

24 files changed

+1098
-191
lines changed

.changeset/gentle-heads-rule.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
'@data-client/vue': patch
3+
---
4+
5+
Add MockPlugin
6+
7+
Example usage:
8+
9+
```ts
10+
import { createApp } from 'vue';
11+
import { DataClientPlugin } from '@data-client/vue';
12+
import { MockPlugin } from '@data-client/vue/test';
13+
14+
const app = createApp(App);
15+
app.use(DataClientPlugin);
16+
app.use(MockPlugin, {
17+
fixtures: [
18+
{
19+
endpoint: MyResource.get,
20+
args: [{ id: 1 }],
21+
response: { id: 1, name: 'Test' },
22+
},
23+
],
24+
});
25+
app.mount('#app');
26+
```
27+
28+
Interceptors allow dynamic responses based on request arguments:
29+
30+
```ts
31+
app.use(MockPlugin, {
32+
fixtures: [
33+
{
34+
endpoint: MyResource.get,
35+
response: (...args) => {
36+
const [{ id }] = args;
37+
return {
38+
id,
39+
name: `Dynamic ${id}`,
40+
};
41+
},
42+
},
43+
],
44+
});
45+
```
46+
47+
Interceptors can also maintain state across calls:
48+
49+
```ts
50+
const interceptorData = { count: 0 };
51+
52+
app.use(MockPlugin, {
53+
fixtures: [
54+
{
55+
endpoint: MyResource.get,
56+
response: function (this: { count: number }, ...args) {
57+
this.count++;
58+
const [{ id }] = args;
59+
return {
60+
id,
61+
name: `Call ${this.count}`,
62+
};
63+
},
64+
},
65+
],
66+
getInitialInterceptorData: () => interceptorData,
67+
});
68+
```

packages/core/package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
"next": [
1717
"lib/next/index.d.ts"
1818
],
19+
"mock": [
20+
"lib/mock/index.d.ts"
21+
],
1922
"*": [
2023
"lib/index.d.ts"
2124
]
@@ -27,6 +30,9 @@
2730
"next": [
2831
"ts3.4/next/index.d.ts"
2932
],
33+
"mock": [
34+
"ts3.4/mock/index.d.ts"
35+
],
3036
"*": [
3137
"ts3.4/index.d.ts"
3238
]
@@ -48,6 +54,13 @@
4854
"react-native": "./lib/next/index.js",
4955
"default": "./lib/next/index.js"
5056
},
57+
"./mock": {
58+
"types": "./lib/mock/index.d.ts",
59+
"require": "./lib/mock/index.js",
60+
"browser": "./lib/mock/index.js",
61+
"react-native": "./lib/mock/index.js",
62+
"default": "./lib/mock/index.js"
63+
},
5164
"./package.json": "./package.json"
5265
},
5366
"type": "module",
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import {
2+
actionTypes,
3+
Controller,
4+
DataClientDispatch,
5+
GenericDispatch,
6+
} from '../index.js';
7+
8+
import { collapseFixture } from './collapseFixture.js';
9+
import { createFixtureMap } from './createFixtureMap.js';
10+
import type { Fixture, Interceptor } from './fixtureTypes.js';
11+
import { MockProps } from './mockTypes.js';
12+
13+
export function MockController<TBase extends typeof Controller, T>(
14+
Base: TBase,
15+
{
16+
fixtures = [],
17+
getInitialInterceptorData = () => ({}) as any,
18+
}: MockProps<T>,
19+
): TBase {
20+
const [fixtureMap, interceptors] = createFixtureMap(fixtures);
21+
22+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
23+
// @ts-ignore
24+
return class MockedController<
25+
D extends GenericDispatch = DataClientDispatch,
26+
> extends Base<D> {
27+
// legacy compatibility (re-declaration)
28+
// TODO: drop when drop support for destructuring (0.14 and below)
29+
declare protected _dispatch: D;
30+
31+
fixtureMap: Map<string, Fixture> = fixtureMap;
32+
interceptors: Interceptor<any>[] = interceptors;
33+
interceptorData: T = getInitialInterceptorData();
34+
35+
constructor(...args: any[]) {
36+
super(...args);
37+
38+
// legacy compatibility
39+
// TODO: drop when drop support for destructuring (0.14 and below)
40+
if (!this._dispatch) {
41+
this._dispatch = (args[0] as any).dispatch;
42+
}
43+
}
44+
45+
// legacy compatibility - we need this to work with 0.14 and below as they do not have this setter
46+
// TODO: drop when drop support for destructuring (0.14 and below)
47+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
48+
// @ts-ignore
49+
set dispatch(dispatch: D) {
50+
this._dispatch = dispatch;
51+
}
52+
53+
get dispatch(): D {
54+
return ((action: Parameters<D>[0]): Promise<void> => {
55+
// support legacy that has _TYPE suffix
56+
if (action.type === (actionTypes.FETCH ?? actionTypes.FETCH_TYPE)) {
57+
// eslint-disable-next-line prefer-const
58+
let { key, args } = action;
59+
let fixture: Fixture | Interceptor | undefined;
60+
if (this.fixtureMap.has(key)) {
61+
fixture = this.fixtureMap.get(key) as Fixture;
62+
if (!args) args = fixture.args;
63+
// exact matches take priority; now test ComputedFixture
64+
} else {
65+
for (const cfix of this.interceptors) {
66+
if (cfix.endpoint.testKey(key)) {
67+
fixture = cfix;
68+
break;
69+
}
70+
}
71+
}
72+
// we have a match
73+
if (fixture !== undefined) {
74+
const replacedAction: typeof action = {
75+
...action,
76+
};
77+
const delayMs =
78+
typeof fixture.delay === 'function' ?
79+
fixture.delay(...(args as any))
80+
: (fixture.delay ?? 0);
81+
82+
if ('fetchResponse' in fixture) {
83+
const { fetchResponse } = fixture;
84+
fixture = {
85+
endpoint: fixture.endpoint,
86+
response(...args) {
87+
const endpoint = (action.endpoint as any).extend({
88+
fetchResponse: (input: RequestInfo, init: RequestInit) => {
89+
const ret = fetchResponse.call(this, input, init);
90+
return Promise.resolve(
91+
new Response(JSON.stringify(ret), {
92+
status: 200,
93+
headers: new Headers({
94+
'Content-Type': 'application/json',
95+
}),
96+
}),
97+
);
98+
},
99+
});
100+
return (endpoint as any)(...args);
101+
},
102+
};
103+
}
104+
const fetch = async () => {
105+
if (!fixture) {
106+
throw new Error('No fixture found');
107+
}
108+
// delayCollapse determines when the fixture function is 'collapsed' (aka 'run')
109+
// collapsed: https://en.wikipedia.org/wiki/Copenhagen_interpretation
110+
if (fixture.delayCollapse) {
111+
await new Promise(resolve => setTimeout(resolve, delayMs));
112+
}
113+
const result = await collapseFixture(
114+
fixture as any,
115+
args as any,
116+
this.interceptorData,
117+
);
118+
if (!fixture.delayCollapse && delayMs) {
119+
await new Promise(resolve => setTimeout(resolve, delayMs));
120+
}
121+
if (result.error) {
122+
throw result.response;
123+
}
124+
return result.response;
125+
};
126+
if (typeof (replacedAction.endpoint as any).extend === 'function') {
127+
replacedAction.endpoint = (replacedAction.endpoint as any).extend(
128+
{
129+
fetch,
130+
},
131+
);
132+
} else {
133+
// TODO: full testing of this
134+
replacedAction.endpoint = fetch as any;
135+
(replacedAction.endpoint as any).__proto__ = action.endpoint;
136+
}
137+
138+
// TODO: make super.dispatch (once we drop support for destructuring)
139+
return this._dispatch(replacedAction);
140+
}
141+
}
142+
// TODO: make super.dispatch (once we drop support for destructuring)
143+
return this._dispatch(action);
144+
}) as any;
145+
}
146+
};
147+
}
148+
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ export async function collapseFixture(
1919
}
2020
return { response, error };
2121
}
22+

packages/vue/src/test/createFixtureMap.ts renamed to packages/core/src/mock/createFixtureMap.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Fixture, Interceptor } from './fixtureTypes';
1+
import type { Fixture, Interceptor } from './fixtureTypes.js';
22

33
export function createFixtureMap(fixtures: (Fixture | Interceptor)[] = []) {
44
const map: Map<string, Fixture> = new Map();
@@ -24,3 +24,4 @@ Treating as Interceptor`,
2424
}
2525
return [map, computed] as const;
2626
}
27+
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { EndpointInterface, ResolveType } from '@data-client/core';
1+
import type { EndpointInterface, ResolveType } from '@data-client/endpoint';
22

33
type Updater = (
44
result: any,
@@ -119,3 +119,4 @@ export type ErrorFixture<
119119
export type Fixture<
120120
E extends EndpointInterface & { update?: Updater } = EndpointInterface,
121121
> = FixtureEndpoint<E>;
122+

packages/core/src/mock/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export { MockController } from './MockController.js';
2+
export type { MockProps } from './mockTypes.js';
3+
export type {
4+
Fixture,
5+
SuccessFixture,
6+
ErrorFixture,
7+
Interceptor,
8+
ResponseInterceptor,
9+
FetchInterceptor,
10+
FixtureEndpoint,
11+
SuccessFixtureEndpoint,
12+
ErrorFixtureEndpoint,
13+
} from './fixtureTypes.js';
14+
export { collapseFixture } from './collapseFixture.js';
15+
export { createFixtureMap } from './createFixtureMap.js';
16+
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { Fixture, Interceptor } from './fixtureTypes.js';
2+
3+
export interface MockProps<T = any> {
4+
readonly fixtures?: (Fixture | Interceptor<T>)[];
5+
getInitialInterceptorData?: () => T;
6+
}

packages/test/src/MockController.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1+
import {
2+
collapseFixture,
3+
createFixtureMap,
4+
type Fixture,
5+
type Interceptor,
6+
} from '@data-client/core/mock';
17
import {
28
actionTypes,
39
Controller,
410
DataClientDispatch,
511
GenericDispatch,
612
} from '@data-client/react';
713

8-
import { collapseFixture } from './collapseFixture.js';
9-
import { createFixtureMap } from './createFixtureMap.js';
10-
import type { Fixture, Interceptor } from './fixtureTypes.js';
1114
import { MockProps } from './mockTypes.js';
1215

1316
export function MockController<TBase extends typeof Controller, T>(

packages/test/src/MockResolver.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
'use client';
2+
import { MockController, type MockProps } from '@data-client/core/mock';
23
import { ControllerContext, useController } from '@data-client/react';
34
import { useMemo } from 'react';
45
import React from 'react';
56

6-
import { MockController } from './MockController.js';
7-
import { MockProps } from './mockTypes.js';
8-
97
export interface MockResolverProps<T> extends MockProps<T> {
108
children: React.ReactNode;
119
silenceMissing?: boolean;

0 commit comments

Comments
 (0)