@signalium/query 0.0.0

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.
@@ -0,0 +1,242 @@
1
+ import { watchOnce, watcher, withContexts } from 'signalium';
2
+ import { QueryClient, QueryClientContext } from '../QueryClient.js';
3
+ import { QueryStore } from '../QueryStore.js';
4
+ import { EntityStore } from '../EntityMap.js';
5
+
6
+ // Re-export watchOnce for convenience
7
+ export { watchOnce };
8
+
9
+ interface MockFetchOptions {
10
+ status?: number;
11
+ headers?: Record<string, string>;
12
+ delay?: number;
13
+ error?: Error;
14
+ jsonError?: Error;
15
+ }
16
+
17
+ interface MockFetch {
18
+ (url: string, options?: RequestInit): Promise<Response>;
19
+
20
+ get(url: string, response: unknown, opts?: MockFetchOptions): void;
21
+ post(url: string, response: unknown, opts?: MockFetchOptions): void;
22
+ put(url: string, response: unknown, opts?: MockFetchOptions): void;
23
+ delete(url: string, response: unknown, opts?: MockFetchOptions): void;
24
+ patch(url: string, response: unknown, opts?: MockFetchOptions): void;
25
+
26
+ reset(): void;
27
+ calls: Array<{ url: string; options: RequestInit }>;
28
+ }
29
+
30
+ interface MockRoute {
31
+ url: string;
32
+ method: string;
33
+ response: unknown;
34
+ options: MockFetchOptions;
35
+ used: boolean;
36
+ }
37
+
38
+ /**
39
+ * Creates a mock fetch function with a fluent API for setting up responses.
40
+ *
41
+ * @example
42
+ * const fetch = createMockFetch();
43
+ * fetch.get('/users/123', { id: 123, name: 'Alice' });
44
+ * fetch.post('/users', { id: 456, name: 'Bob' }, { status: 201 });
45
+ *
46
+ * const response = await fetch('/users/123', { method: 'GET' });
47
+ * const data = await response.json(); // { id: 123, name: 'Alice' }
48
+ */
49
+ export function createMockFetch(): MockFetch {
50
+ const routes: MockRoute[] = [];
51
+ const calls: Array<{ url: string; options: RequestInit }> = [];
52
+
53
+ const matchRoute = (url: string, method: string): MockRoute | undefined => {
54
+ // First try exact match
55
+ const exactMatch = routes.find(r => r.url === url && r.method === method && !r.used);
56
+ if (exactMatch) return exactMatch;
57
+
58
+ // Then try pattern matching (for URLs with query params or path segments)
59
+ return routes.find(r => {
60
+ if (r.method !== method || r.used) return false;
61
+
62
+ // Simple pattern: check if the route URL is a prefix or matches the base path
63
+ const routeBase = r.url.split('?')[0];
64
+ const urlBase = url.split('?')[0];
65
+
66
+ // Check if URL starts with the route (for exact matches)
67
+ if (urlBase === routeBase) return true;
68
+
69
+ // Check if route contains path params [...]
70
+ if (r.url.includes('[')) {
71
+ const routeParts = routeBase.split('/');
72
+ const urlParts = urlBase.split('/');
73
+
74
+ if (routeParts.length !== urlParts.length) return false;
75
+
76
+ return routeParts.every((part, i) => {
77
+ if (part.startsWith('[') && part.endsWith(']')) return true;
78
+ return part === urlParts[i];
79
+ });
80
+ }
81
+
82
+ return false;
83
+ });
84
+ };
85
+
86
+ const mockFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
87
+ const method = (options.method || 'GET').toUpperCase();
88
+
89
+ calls.push({ url, options });
90
+
91
+ const route = matchRoute(url, method);
92
+
93
+ if (!route) {
94
+ throw new Error(
95
+ `No mock response configured for ${method} ${url}\n` +
96
+ `Available routes:\n${routes.map(r => ` ${r.method} ${r.url}`).join('\n')}`,
97
+ );
98
+ }
99
+
100
+ route.used = true;
101
+
102
+ if (route.options.delay) {
103
+ await new Promise(resolve => setTimeout(resolve, route.options.delay));
104
+ }
105
+
106
+ if (route.options.error) {
107
+ throw route.options.error;
108
+ }
109
+
110
+ const status = route.options.status ?? 200;
111
+ const headers = route.options.headers ?? {};
112
+
113
+ // Create a mock Response object
114
+ const response = {
115
+ ok: status >= 200 && status < 300,
116
+ status,
117
+ statusText: status === 200 ? 'OK' : status === 201 ? 'Created' : status === 204 ? 'No Content' : 'Error',
118
+ headers: new Headers(headers),
119
+ json: async () => {
120
+ if (route.options.jsonError) {
121
+ throw route.options.jsonError;
122
+ }
123
+ return route.response;
124
+ },
125
+ text: async () => JSON.stringify(route.response),
126
+ blob: async () => new Blob([JSON.stringify(route.response)]),
127
+ arrayBuffer: async () => new TextEncoder().encode(JSON.stringify(route.response)).buffer,
128
+ clone: () => response,
129
+ } as Response;
130
+
131
+ return response;
132
+ };
133
+
134
+ const addRoute = (method: string, url: string, response: unknown, opts: MockFetchOptions = {}) => {
135
+ routes.push({
136
+ url,
137
+ method: method.toUpperCase(),
138
+ response,
139
+ options: opts,
140
+ used: false,
141
+ });
142
+ };
143
+
144
+ mockFetch.get = (url: string, response: unknown, opts?: MockFetchOptions) => {
145
+ addRoute('GET', url, response, opts);
146
+ };
147
+
148
+ mockFetch.post = (url: string, response: unknown, opts?: MockFetchOptions) => {
149
+ addRoute('POST', url, response, opts);
150
+ };
151
+
152
+ mockFetch.put = (url: string, response: unknown, opts?: MockFetchOptions) => {
153
+ addRoute('PUT', url, response, opts);
154
+ };
155
+
156
+ mockFetch.delete = (url: string, response: unknown, opts?: MockFetchOptions) => {
157
+ addRoute('DELETE', url, response, opts);
158
+ };
159
+
160
+ mockFetch.patch = (url: string, response: unknown, opts?: MockFetchOptions) => {
161
+ addRoute('PATCH', url, response, opts);
162
+ };
163
+
164
+ mockFetch.reset = () => {
165
+ routes.length = 0;
166
+ calls.length = 0;
167
+ };
168
+
169
+ mockFetch.calls = calls;
170
+
171
+ return mockFetch as MockFetch;
172
+ }
173
+
174
+ /**
175
+ * Creates a test watcher that tracks all values emitted by a reactive function.
176
+ * Returns an object with the values array and an unsubscribe function.
177
+ *
178
+ * Note: This creates a continuous watcher. For one-time execution, use `watchOnce` instead.
179
+ */
180
+ export function createTestWatcher<T>(fn: () => T): {
181
+ values: T[];
182
+ unsub: () => void;
183
+ } {
184
+ const values: T[] = [];
185
+
186
+ const w = watcher(() => {
187
+ const value = fn();
188
+ values.push(value);
189
+ });
190
+
191
+ const unsub = w.addListener(() => {});
192
+
193
+ return { values, unsub };
194
+ }
195
+
196
+ /**
197
+ * Test helper that combines query client context injection and automatic watcher cleanup.
198
+ * Wraps the test in a watcher and awaits it, keeping relays active during the test.
199
+ *
200
+ * @example
201
+ * await testWithClient(client, async () => {
202
+ * const relay = getItem({ id: '1' });
203
+ * await relay;
204
+ * expect(relay.value).toBeDefined();
205
+ * // Watcher is automatically cleaned up
206
+ * });
207
+ */
208
+ export async function testWithClient(client: QueryClient, fn: () => Promise<void>): Promise<void> {
209
+ return withContexts([[QueryClientContext, client]], () => watchOnce(fn));
210
+ }
211
+
212
+ export const sleep = (ms: number = 0) =>
213
+ new Promise(resolve => {
214
+ setTimeout(() => {
215
+ resolve(true);
216
+ }, ms);
217
+ });
218
+
219
+ /**
220
+ * Test helper to access the internal store of a QueryClient.
221
+ * Uses bracket notation to bypass TypeScript access checks.
222
+ */
223
+ export function getClientStore(client: QueryClient): QueryStore {
224
+ return client['store'];
225
+ }
226
+
227
+ /**
228
+ * Test helper to access the internal entity map of a QueryClient.
229
+ * Uses bracket notation to bypass TypeScript access checks.
230
+ */
231
+ export function getClientEntityMap(client: QueryClient): EntityStore {
232
+ return client['entityMap'];
233
+ }
234
+
235
+ /**
236
+ * Test helper to get the size of the entity map.
237
+ * EntityStore doesn't expose a size property, so we access the internal map.
238
+ */
239
+ export function getEntityMapSize(client: QueryClient): number {
240
+ const entityMap = getClientEntityMap(client);
241
+ return entityMap['map'].size;
242
+ }