@r0hitsharma/http-client-msw 0.12.0-rohit-fork-ci.1

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,119 @@
1
+ import { describe, expectTypeOf, it } from 'vitest';
2
+
3
+ import {
4
+ createMockApi,
5
+ type MockPathsFor,
6
+ type MockRequestBodyFor,
7
+ type MockResponseBodyFor,
8
+ } from './mock-api.js';
9
+ import type { ApiFault, TestPaths, Thing } from './test-fixtures.js';
10
+
11
+ /**
12
+ * Type-level coverage of the guarantee the package exists for: a handler's path,
13
+ * params, request body, status codes, and response bodies all come off the
14
+ * generated `TPaths`, so a fixture cannot drift from the contract while still
15
+ * compiling.
16
+ *
17
+ * `expectTypeOf` and `@ts-expect-error` are checked by `npm run type:check`, so
18
+ * the assertions live in a function that is never called. The `it` block below
19
+ * only keeps vitest from reporting the file as empty.
20
+ */
21
+
22
+ const mock = createMockApi<TestPaths>();
23
+
24
+ describe('createMockApi — inference', () => {
25
+ it('is asserted by tsc, not at runtime', () => {
26
+ expectTypeOf(mock.get).toBeFunction();
27
+ expectTypeOf(mock.untyped).toBeObject();
28
+ });
29
+ });
30
+
31
+ export function typeAssertions(): void {
32
+ // A type argument that is not an object shape is rejected, rather than
33
+ // compiling into a factory that offers no paths at all.
34
+ // @ts-expect-error — a string is not a `paths` type
35
+ createMockApi<'nonsense'>();
36
+ // @ts-expect-error — nor is a number
37
+ createMockApi<7>();
38
+
39
+ // Only paths that declare the method are offered per factory.
40
+ expectTypeOf<MockPathsFor<typeof mock.get>>().toEqualTypeOf<
41
+ '/things' | '/things/{id}'
42
+ >();
43
+ expectTypeOf<MockPathsFor<typeof mock.post>>().toEqualTypeOf<'/things'>();
44
+ expectTypeOf<
45
+ MockPathsFor<typeof mock.delete>
46
+ >().toEqualTypeOf<'/things/{id}'>();
47
+
48
+ // Bodies are read off the operation, not restated by the fixture.
49
+ expectTypeOf<MockResponseBodyFor<typeof mock.get, '/things'>>().toEqualTypeOf<
50
+ Thing[] | ApiFault
51
+ >();
52
+ expectTypeOf<
53
+ MockRequestBodyFor<typeof mock.post, '/things'>
54
+ >().toEqualTypeOf<{
55
+ name: string;
56
+ size: number;
57
+ }>();
58
+
59
+ mock.get('/things/{id}', ({ params, query, request, response }) => {
60
+ // Path params are stringified from the operation's `parameters.path`.
61
+ expectTypeOf(params).toEqualTypeOf<{ id: string }>();
62
+ // Query params are typed per name; an optional one may be absent.
63
+ expectTypeOf(query.get('expand')).toEqualTypeOf<string | null>();
64
+ expectTypeOf(request.url).toBeString();
65
+
66
+ return response(200).json({ id: params.id, name: 'Thing', size: 1 });
67
+ });
68
+
69
+ mock.get('/things', ({ query, response }) => {
70
+ // An array-valued query param stringifies to its element type.
71
+ expectTypeOf(query.getAll('tag')).toEqualTypeOf<string[]>();
72
+ expectTypeOf(query.get('limit')).toEqualTypeOf<string | null>();
73
+
74
+ return response(200).json([]);
75
+ });
76
+
77
+ mock.post('/things', async ({ request, response }) => {
78
+ expectTypeOf(await request.json()).toEqualTypeOf<{
79
+ name: string;
80
+ size: number;
81
+ }>();
82
+
83
+ return response(201).json({ id: 'new', name: 'New', size: 1 });
84
+ });
85
+
86
+ // A declared error status takes that status's own body shape.
87
+ mock.get('/things/{id}', ({ response }) =>
88
+ response(404).json({ message: 'gone' }),
89
+ );
90
+
91
+ // A no-content response takes no body at all.
92
+ mock.delete('/things/{id}', ({ response }) => response(204).empty());
93
+
94
+ // @ts-expect-error — the path is not in TestPaths
95
+ mock.get('/unknown', ({ response }) => response(200).json([]));
96
+
97
+ // @ts-expect-error — /things has no DELETE operation
98
+ mock.delete('/things', ({ response }) => response(204).empty());
99
+
100
+ mock.get('/things/{id}', ({ response }) =>
101
+ // @ts-expect-error — `size` is required by Thing and `name` must be a string
102
+ response(200).json({ id: 'x', name: 7 }),
103
+ );
104
+
105
+ mock.get('/things', ({ response }) =>
106
+ // @ts-expect-error — the 200 response is a list of Thing, not a bare Thing
107
+ response(200).json({ id: 'x', name: 'Thing', size: 1 }),
108
+ );
109
+
110
+ // @ts-expect-error — 418 is not one of the operation's declared responses
111
+ mock.get('/things', ({ response }) => response(418).json({ message: 'no' }));
112
+
113
+ mock.get('/things', ({ query, response }) => {
114
+ // @ts-expect-error — 'unknown' is not a declared query param
115
+ query.get('unknown');
116
+
117
+ return response(500).json({ message: 'boom' });
118
+ });
119
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ isTestEnvironment,
5
+ mockDelay,
6
+ resolveMockDelay,
7
+ } from './mock-delay.js';
8
+
9
+ describe('resolveMockDelay', () => {
10
+ it('skips a plain delay under test and honours it elsewhere', () => {
11
+ expect(resolveMockDelay(400, true)).toBe(0);
12
+ expect(resolveMockDelay(400, false)).toBe(400);
13
+ });
14
+
15
+ it('takes the per-environment value from an object', () => {
16
+ expect(resolveMockDelay({ dev: 400, test: 10 }, true)).toBe(10);
17
+ expect(resolveMockDelay({ dev: 400, test: 10 }, false)).toBe(400);
18
+ });
19
+
20
+ it('defaults an unspecified environment to no delay', () => {
21
+ expect(resolveMockDelay({ dev: 400 }, true)).toBe(0);
22
+ expect(resolveMockDelay({ test: 10 }, false)).toBe(0);
23
+ expect(resolveMockDelay({}, false)).toBe(0);
24
+ });
25
+ });
26
+
27
+ describe('isTestEnvironment', () => {
28
+ it('detects the runner this suite is running under', () => {
29
+ expect(isTestEnvironment()).toBe(true);
30
+ });
31
+ });
32
+
33
+ describe('mockDelay', () => {
34
+ it('resolves without a timer under test', async () => {
35
+ const startedAt = Date.now();
36
+ await mockDelay(5_000);
37
+
38
+ expect(Date.now() - startedAt).toBeLessThan(1_000);
39
+ });
40
+
41
+ it('still waits when a test delay is asked for', async () => {
42
+ const startedAt = Date.now();
43
+ await mockDelay({ dev: 5_000, test: 20 });
44
+
45
+ expect(Date.now() - startedAt).toBeGreaterThanOrEqual(15);
46
+ });
47
+
48
+ it('resolves immediately with no argument', async () => {
49
+ await expect(mockDelay()).resolves.toBeUndefined();
50
+ });
51
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Latency is what makes a mock exercise the states a real API forces an app
3
+ * through — pending spinners, skeletons, optimistic UI, race conditions. The same
4
+ * latency in a test suite is dead time, so the amount is per-environment and the
5
+ * default under test is none.
6
+ */
7
+
8
+ export type MockDelayInput =
9
+ | number
10
+ | {
11
+ /** Milliseconds under test. Defaults to `0`. */
12
+ test?: number;
13
+ /** Milliseconds everywhere else. Defaults to `0`. */
14
+ dev?: number;
15
+ };
16
+
17
+ /**
18
+ * True when running under a test runner: `NODE_ENV === 'test'` (vitest, jest) or
19
+ * Vite's `MODE === 'test'`. Read defensively because neither `process` nor
20
+ * `import.meta.env` exists in every environment this package runs in.
21
+ */
22
+ export function isTestEnvironment(): boolean {
23
+ // `process.env` is optional-chained as well as guarded: a browser shim that
24
+ // defines `process` without an `env` would otherwise throw from inside a
25
+ // request handler. The dot form is deliberate — it is what bundlers rewrite.
26
+ const nodeEnv =
27
+ typeof process === 'undefined' ? undefined : process.env?.NODE_ENV;
28
+ const viteMode = (import.meta as ImportMeta & { env?: { MODE?: string } }).env
29
+ ?.MODE;
30
+
31
+ return nodeEnv === 'test' || viteMode === 'test';
32
+ }
33
+
34
+ /** The pure resolution `mockDelay` applies, split out so both branches are testable. */
35
+ export function resolveMockDelay(
36
+ input: MockDelayInput,
37
+ isTest: boolean,
38
+ ): number {
39
+ if (typeof input === 'number') return isTest ? 0 : input;
40
+
41
+ return (isTest ? input.test : input.dev) ?? 0;
42
+ }
43
+
44
+ /**
45
+ * Waits, in dev; resolves immediately under test unless a test delay is asked
46
+ * for.
47
+ *
48
+ * ```ts
49
+ * mock.get('/things', async ({ response }) => {
50
+ * await mockDelay(400);
51
+ * return response(200).json(things.list());
52
+ * });
53
+ *
54
+ * // Keep a little latency under test, for a pending-state assertion.
55
+ * await mockDelay({ dev: 400, test: 10 });
56
+ * ```
57
+ */
58
+ export function mockDelay(input: MockDelayInput = 0): Promise<void> {
59
+ const ms = resolveMockDelay(input, isTestEnvironment());
60
+
61
+ // No timer at all for a zero delay: a mock that resolves in the same
62
+ // microtask keeps a suite's timing behaviour unchanged.
63
+ if (ms <= 0) return Promise.resolve();
64
+
65
+ return new Promise((resolve) => {
66
+ setTimeout(resolve, ms);
67
+ });
68
+ }
@@ -0,0 +1,263 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
2
+
3
+ import { createMockApi } from './mock-api.js';
4
+ import { createMockStore } from './mock-store.js';
5
+ import { setupMockServer } from './node.js';
6
+ import { setupMocks } from './setup.js';
7
+ import { type Thing, type TestPaths, seedThings } from './test-fixtures.js';
8
+
9
+ describe('createMockStore', () => {
10
+ it('starts from the seed, in insertion order', () => {
11
+ const store = createMockStore(seedThings);
12
+
13
+ expect(store.list()).toEqual(seedThings());
14
+ expect(store.size).toBe(2);
15
+ });
16
+
17
+ it('reads and writes by id', () => {
18
+ const store = createMockStore(seedThings);
19
+
20
+ expect(store.get('t1')?.name).toBe('First');
21
+ expect(store.has('t9')).toBe(false);
22
+
23
+ store.insert({ id: 't3', name: 'Third', size: 3 });
24
+
25
+ expect(store.size).toBe(3);
26
+ expect(store.list().at(-1)?.id).toBe('t3');
27
+ });
28
+
29
+ it('rejects a duplicate id as a fixture bug', () => {
30
+ const store = createMockStore(seedThings);
31
+
32
+ expect(() => store.insert({ id: 't1', name: 'Clash', size: 0 })).toThrow(
33
+ /already holds an item with id "t1"/,
34
+ );
35
+ });
36
+
37
+ it('reports an unknown id instead of throwing, so a handler can 404', () => {
38
+ const store = createMockStore(seedThings);
39
+
40
+ expect(store.update('nope', { name: 'x' })).toBeUndefined();
41
+ expect(store.remove('nope')).toBe(false);
42
+ });
43
+
44
+ it('merges a patch and keeps the rest of the item', () => {
45
+ const store = createMockStore(seedThings);
46
+
47
+ expect(store.update('t1', { size: 42 })).toEqual({
48
+ id: 't1',
49
+ name: 'First',
50
+ size: 42,
51
+ });
52
+ expect(store.get('t1')?.size).toBe(42);
53
+ });
54
+
55
+ it('hands out a fresh array each time, so a caller cannot reorder the store', () => {
56
+ const store = createMockStore(seedThings);
57
+ const list = store.list();
58
+ list.pop();
59
+ list.reverse();
60
+
61
+ expect(store.list()).toHaveLength(2);
62
+ expect(store.list().map((thing) => thing.id)).toEqual(['t1', 't2']);
63
+ expect(store.list()).not.toBe(store.list());
64
+ });
65
+
66
+ it('but the elements alias the stored items, like `get`', () => {
67
+ const store = createMockStore(seedThings);
68
+ const [first] = store.list();
69
+
70
+ expect(first).toBe(store.get('t1'));
71
+ if (!first) throw new Error('expected the seeded store to be non-empty');
72
+ first.name = 'Mutated';
73
+
74
+ expect(store.get('t1')?.name).toBe('Mutated');
75
+ });
76
+
77
+ it('re-seeds on reset', () => {
78
+ const store = createMockStore(seedThings);
79
+
80
+ store.remove('t2');
81
+ store.insert({ id: 't3', name: 'Third', size: 3 });
82
+
83
+ store.reset();
84
+
85
+ expect(store.list()).toEqual(seedThings());
86
+ });
87
+
88
+ it('undoes an in-place mutation when the seed constructs its items', () => {
89
+ const store = createMockStore(seedThings);
90
+
91
+ const stored = store.get('t1');
92
+ if (stored) stored.name = 'Overwritten';
93
+ expect(store.get('t1')?.name).toBe('Overwritten');
94
+
95
+ store.reset();
96
+
97
+ expect(store.get('t1')?.name).toBe('First');
98
+ });
99
+
100
+ it('cannot undo one when the seed returns a shared array', () => {
101
+ // The documented limit of the reset guarantee: the store holds the seed's
102
+ // own objects, so a seed that hands back the same ones every call has
103
+ // nothing to restore from.
104
+ const shared = [{ id: 't1', name: 'First', size: 1 }];
105
+ const store = createMockStore(() => shared);
106
+
107
+ const stored = store.get('t1');
108
+ if (stored) stored.name = 'Overwritten';
109
+ store.reset();
110
+
111
+ expect(store.get('t1')?.name).toBe('Overwritten');
112
+ });
113
+
114
+ it('replaces the whole collection', () => {
115
+ const store = createMockStore(seedThings);
116
+ store.replaceAll([{ id: 'only', name: 'Only', size: 1 }]);
117
+
118
+ expect(store.list()).toEqual([{ id: 'only', name: 'Only', size: 1 }]);
119
+ });
120
+
121
+ it('rejects a duplicate id on replaceAll and on the seed, like insert', () => {
122
+ const store = createMockStore(seedThings);
123
+
124
+ expect(() =>
125
+ store.replaceAll([
126
+ { id: 'x', name: 'One', size: 1 },
127
+ { id: 'x', name: 'Two', size: 2 },
128
+ ]),
129
+ ).toThrow(/two items with id "x"/);
130
+ // The rejected collection is not partially applied.
131
+ expect(store.list()).toEqual(seedThings());
132
+
133
+ expect(() =>
134
+ createMockStore(() => [
135
+ { id: 'y', name: 'One', size: 1 },
136
+ { id: 'y', name: 'Two', size: 2 },
137
+ ]),
138
+ ).toThrow(/two items with id "y"/);
139
+ });
140
+
141
+ it('keeps an item in place when updated, and stales an earlier reference', () => {
142
+ const store = createMockStore(seedThings);
143
+ const before = store.get('t1');
144
+
145
+ store.update('t1', { size: 99 });
146
+
147
+ expect(store.list().map((thing) => thing.id)).toEqual(['t1', 't2']);
148
+ expect(before?.size).toBe(1);
149
+ expect(store.get('t1')?.size).toBe(99);
150
+ });
151
+
152
+ it('re-keys when a patch carries a new id, in place', () => {
153
+ const store = createMockStore(seedThings);
154
+
155
+ expect(store.update('t1', { id: 'moved' })).toEqual({
156
+ id: 'moved',
157
+ name: 'First',
158
+ size: 1,
159
+ });
160
+ expect(store.get('moved')?.name).toBe('First');
161
+ expect(store.get('t1')).toBeUndefined();
162
+ expect(store.has('t1')).toBe(false);
163
+ expect(store.size).toBe(2);
164
+ // The re-key is not a remove-and-append: the item keeps its position.
165
+ expect(store.list().map((thing) => thing.id)).toEqual(['moved', 't2']);
166
+ });
167
+
168
+ it('re-keys through an explicit id selector too', () => {
169
+ const store = createMockStore<{ slug: string; name: string }>(
170
+ () => [{ slug: 'a', name: 'A' }],
171
+ { id: (item) => item.slug },
172
+ );
173
+
174
+ store.update('a', { slug: 'b' });
175
+
176
+ expect(store.get('b')?.name).toBe('A');
177
+ expect(store.get('a')).toBeUndefined();
178
+ });
179
+
180
+ it('rejects a re-key onto another existing id, leaving the store as it was', () => {
181
+ const store = createMockStore(seedThings);
182
+
183
+ expect(() => store.update('t1', { id: 't2', size: 9 })).toThrow(
184
+ /already holds an item with id "t2"/,
185
+ );
186
+ expect(store.list()).toEqual(seedThings());
187
+ });
188
+
189
+ it('takes an explicit id selector for items with no `id`', () => {
190
+ const store = createMockStore<{ slug: string }>(() => [{ slug: 'a' }], {
191
+ id: (item) => item.slug,
192
+ });
193
+
194
+ expect(store.get('a')).toEqual({ slug: 'a' });
195
+ });
196
+
197
+ it('explains itself when an item has no usable id', () => {
198
+ expect(() =>
199
+ createMockStore<{ name: string }>(() => [{ name: 'x' }]),
200
+ ).toThrow(/need a string or number `id`/);
201
+ });
202
+ });
203
+
204
+ describe('createMockStore — through the reset convention', () => {
205
+ const mock = createMockApi<TestPaths>({ baseUrl: '/api' });
206
+ const things = createMockStore(seedThings);
207
+
208
+ const mocks = setupMocks(
209
+ [
210
+ mock.get('/things', ({ response }) => response(200).json(things.list())),
211
+ mock.post('/things', async ({ request, response }) => {
212
+ const created = {
213
+ id: `t${things.size + 1}`,
214
+ ...(await request.json()),
215
+ };
216
+
217
+ return response(201).json(things.insert(created));
218
+ }),
219
+ mock.delete('/things/{id}', ({ params, response }) =>
220
+ things.remove(params.id)
221
+ ? response(204).empty()
222
+ : response(404).json({ message: 'not found' }),
223
+ ),
224
+ ],
225
+ { onReset: [things.reset] },
226
+ );
227
+
228
+ const mockServer = setupMockServer(mocks);
229
+
230
+ beforeAll(() => mockServer.listen());
231
+ afterEach(() => mockServer.reset());
232
+ afterAll(() => mockServer.close());
233
+
234
+ const list = async (): Promise<Thing[]> =>
235
+ (await (await fetch('http://localhost/api/things')).json()) as Thing[];
236
+
237
+ it('a write is visible to a later read in the same test', async () => {
238
+ const created = await fetch('http://localhost/api/things', {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ body: JSON.stringify({ name: 'Third', size: 3 }),
242
+ });
243
+
244
+ expect(created.status).toBe(201);
245
+ expect(await list()).toHaveLength(3);
246
+ });
247
+
248
+ it('and is gone by the next one', async () => {
249
+ expect(await list()).toEqual(seedThings());
250
+ });
251
+
252
+ it('a delete is undone too', async () => {
253
+ expect(
254
+ (await fetch('http://localhost/api/things/t1', { method: 'DELETE' }))
255
+ .status,
256
+ ).toBe(204);
257
+ expect(await list()).toHaveLength(1);
258
+ });
259
+
260
+ it('leaving the seed intact', async () => {
261
+ expect(await list()).toEqual(seedThings());
262
+ });
263
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The smallest amount of state a mock needs to behave like an API rather than a
3
+ * fixture dump: a POST that shows up in the following GET. Deliberately not a
4
+ * database — no queries, no indexes, no relations. A mock that needs those is a
5
+ * mock standing in for logic the real service owns.
6
+ */
7
+
8
+ export type MockStoreOptions<T> = {
9
+ /**
10
+ * How an item's identity is read. Defaults to its `id` property, which must be
11
+ * a string or a number.
12
+ */
13
+ id?: (item: T) => string;
14
+ };
15
+
16
+ /**
17
+ * Aliasing, since it is observable: `get`, `insert`, and the elements of
18
+ * `list` are the **stored** objects, so mutating one writes through to the
19
+ * store — only `list`'s array is a copy. `update` replaces the item with
20
+ * a merged copy instead, which leaves any reference taken before the update
21
+ * stale. Read again after an update rather than holding a reference across one.
22
+ */
23
+ export type MockStore<T> = {
24
+ /**
25
+ * Every item, in insertion order. The array is fresh, so sorting, slicing, or
26
+ * splicing it cannot reorder the store — but its elements are the stored
27
+ * objects, not copies, so mutating one writes through like `get` does.
28
+ */
29
+ list: () => T[];
30
+ get: (id: string) => T | undefined;
31
+ has: (id: string) => boolean;
32
+ readonly size: number;
33
+ /** Adds an item. Throws on a duplicate id, which is a fixture bug rather than an API condition. */
34
+ insert: (item: T) => T;
35
+ /**
36
+ * Merges `patch` into an existing item, keeping its position. `undefined`
37
+ * when the id is unknown — a handler turns that into its own 404.
38
+ *
39
+ * A patch may carry the id, which moves the item to it; the old id then
40
+ * misses. Throws if that id is already taken, like `insert`, and leaves the
41
+ * store untouched when it does.
42
+ */
43
+ update: (id: string, patch: Partial<T>) => T | undefined;
44
+ /** `false` when the id is unknown. */
45
+ remove: (id: string) => boolean;
46
+ /** Swaps in a whole collection. Throws if two items share an id, like `insert`. */
47
+ replaceAll: (items: readonly T[]) => void;
48
+ /** Re-seeds from the seed function. Pass this to `setupMocks({ onReset })`. */
49
+ reset: () => void;
50
+ };
51
+
52
+ function defaultIdOf<T>(item: T): string {
53
+ const id = (item as { id?: unknown }).id;
54
+
55
+ if (typeof id === 'string') return id;
56
+ if (typeof id === 'number') return String(id);
57
+
58
+ throw new Error(
59
+ 'http-client-msw: createMockStore items need a string or number `id`, or an explicit `id` selector',
60
+ );
61
+ }
62
+
63
+ /**
64
+ * An in-memory collection whose `reset` restores the seeded contents.
65
+ *
66
+ * The seed is a **function**, called on construction and again on every reset, so
67
+ * a handler that mutates an item in place cannot corrupt the next test's
68
+ * starting point. That holds only if the function *constructs* its items — a
69
+ * seed that returns a shared module-level array hands out the same objects every
70
+ * time, and a mutation to one of those does survive a reset.
71
+ *
72
+ * ```ts
73
+ * const things = createMockStore(seedThings);
74
+ *
75
+ * export const mocks = setupMocks(
76
+ * [
77
+ * mock.get('/things', ({ response }) => response(200).json(things.list())),
78
+ * mock.get('/things/{id}', ({ params, response }) => {
79
+ * const thing = things.get(params.id);
80
+ *
81
+ * return thing
82
+ * ? response(200).json(thing)
83
+ * : response(404).json({ message: 'not found' });
84
+ * }),
85
+ * ],
86
+ * { onReset: [things.reset] },
87
+ * );
88
+ * ```
89
+ */
90
+ export function createMockStore<T>(
91
+ seed: () => readonly T[],
92
+ options: MockStoreOptions<T> = {},
93
+ ): MockStore<T> {
94
+ const idOf = options.id ?? defaultIdOf;
95
+ const items = new Map<string, T>();
96
+
97
+ /**
98
+ * Built into a staging map first, so the duplicate-id check that `insert`
99
+ * enforces also covers construction and every reset — and so a rejected
100
+ * collection leaves the store as it was rather than half-cleared.
101
+ */
102
+ const replaceAll = (next: readonly T[]): void => {
103
+ const replacement = new Map<string, T>();
104
+
105
+ for (const item of next) {
106
+ const id = idOf(item);
107
+ if (replacement.has(id)) {
108
+ throw new Error(
109
+ `http-client-msw: createMockStore was given two items with id "${id}"`,
110
+ );
111
+ }
112
+ replacement.set(id, item);
113
+ }
114
+
115
+ items.clear();
116
+ for (const [id, item] of replacement) items.set(id, item);
117
+ };
118
+
119
+ replaceAll(seed());
120
+
121
+ return {
122
+ list: () => [...items.values()],
123
+ get: (id) => items.get(id),
124
+ has: (id) => items.has(id),
125
+ get size() {
126
+ return items.size;
127
+ },
128
+ insert: (item) => {
129
+ const id = idOf(item);
130
+ if (items.has(id)) {
131
+ throw new Error(
132
+ `http-client-msw: createMockStore already holds an item with id "${id}"`,
133
+ );
134
+ }
135
+ items.set(id, item);
136
+
137
+ return item;
138
+ },
139
+ update: (id, patch) => {
140
+ const existing = items.get(id);
141
+ if (existing === undefined) return undefined;
142
+
143
+ const updated = { ...existing, ...patch };
144
+ // `Partial<T>` admits the id, so the patch can move the item. Re-derived
145
+ // rather than assumed, because leaving it under the old key desyncs the
146
+ // map from the items in it: `get(item.id)` misses and `get(oldId)` hands
147
+ // back an item that no longer claims that id.
148
+ const nextId = idOf(updated);
149
+
150
+ if (nextId === id) {
151
+ items.set(id, updated);
152
+
153
+ return updated;
154
+ }
155
+
156
+ if (items.has(nextId)) {
157
+ throw new Error(
158
+ `http-client-msw: createMockStore already holds an item with id "${nextId}"`,
159
+ );
160
+ }
161
+
162
+ // Rebuilt in order rather than delete-then-set, which would move the item
163
+ // to the end: `update` keeps an item's position, and a patched id is
164
+ // still an update. The collision above throws before anything is
165
+ // touched, so a rejected patch leaves the store as it was.
166
+ const entries = [...items];
167
+
168
+ items.clear();
169
+ for (const [key, item] of entries) {
170
+ if (key === id) items.set(nextId, updated);
171
+ else items.set(key, item);
172
+ }
173
+
174
+ return updated;
175
+ },
176
+ remove: (id) => items.delete(id),
177
+ replaceAll,
178
+ reset: () => replaceAll(seed()),
179
+ };
180
+ }