Skip to content

Commit abef482

Browse files
constantantclaude
andcommitted
feat(openapi-resource-mocks/testing): add response sequence mocking
mockResource now accepts { sequence: [entry1, entry2, ...] } as second arg. Each factory call consumes the next entry (ProviderInitialBehavior<T>); reload() also advances the sequence when in resolved/local state via its internal _notifyRequest. Last entry repeats when the sequence is exhausted. Enables pagination, retry, and error-then-success patterns without manual ref manipulation mid-test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent afedfa6 commit abef482

2 files changed

Lines changed: 127 additions & 5 deletions

File tree

tools/openapi-resource-mocks/src/testing.spec.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,4 +166,100 @@ describe('mockResource', () => {
166166
expect(handle.ref.status()).toBe('error');
167167
});
168168
});
169+
170+
describe('response sequence', () => {
171+
it('applies first entry on initial factory call', () => {
172+
const handle = mockResource<string[]>(TOKEN as never, {
173+
sequence: [{ value: ['a'] }, { value: ['b'] }],
174+
});
175+
const fn = handle.useFactory!() as FakeFn;
176+
fn();
177+
expect(handle.ref.status()).toBe('resolved');
178+
expect(handle.ref.value()).toEqual(['a']);
179+
});
180+
181+
it('advances to the next entry on each factory call', () => {
182+
const handle = mockResource<string[]>(TOKEN as never, {
183+
sequence: [{ value: ['first'] }, { value: ['second'] }],
184+
});
185+
const fn = handle.useFactory!() as FakeFn;
186+
fn();
187+
expect(handle.ref.value()).toEqual(['first']);
188+
fn();
189+
expect(handle.ref.value()).toEqual(['second']);
190+
});
191+
192+
it('also advances on reload() when in resolved state', () => {
193+
const handle = mockResource<string[]>(TOKEN as never, {
194+
sequence: [{ value: ['v1'] }, { value: ['v2'] }],
195+
});
196+
const fn = handle.useFactory!() as FakeFn;
197+
fn();
198+
expect(handle.ref.value()).toEqual(['v1']);
199+
handle.ref.reload(); // reload() calls _notifyRequest internally → advances sequence
200+
expect(handle.ref.value()).toEqual(['v2']);
201+
});
202+
203+
it('error-then-success pattern', () => {
204+
const err = new Error('timeout');
205+
const handle = mockResource<number>(TOKEN as never, {
206+
sequence: [{ error: err }, { value: 42 }],
207+
});
208+
const fn = handle.useFactory!() as FakeFn;
209+
fn(); // call 1 → error
210+
expect(handle.ref.status()).toBe('error');
211+
expect(handle.ref.error()).toBe(err);
212+
fn(); // call 2 → success (simulates param change / retry)
213+
expect(handle.ref.status()).toBe('resolved');
214+
expect(handle.ref.value()).toBe(42);
215+
});
216+
217+
it('loading → resolved pattern', () => {
218+
const handle = mockResource<string>(TOKEN as never, {
219+
sequence: [{ loading: true }, { value: 'done' }],
220+
});
221+
const fn = handle.useFactory!() as FakeFn;
222+
fn(); // call 1 → loading
223+
expect(handle.ref.isLoading()).toBe(true);
224+
fn(); // call 2 → resolved
225+
expect(handle.ref.value()).toBe('done');
226+
});
227+
228+
it('repeats last entry when sequence is exhausted', () => {
229+
const handle = mockResource<number>(TOKEN as never, {
230+
sequence: [{ value: 1 }, { value: 2 }],
231+
});
232+
const fn = handle.useFactory!() as FakeFn;
233+
fn();
234+
expect(handle.ref.value()).toBe(1);
235+
fn();
236+
expect(handle.ref.value()).toBe(2);
237+
fn(); // beyond sequence — last entry repeats
238+
expect(handle.ref.value()).toBe(2);
239+
});
240+
241+
it('still tracks calls in sequence mode', () => {
242+
const handle = mockResource<string>(TOKEN as never, {
243+
sequence: [{ value: 'x' }],
244+
});
245+
const fn = handle.useFactory!() as FakeFn;
246+
fn({ param: 1 });
247+
expect(handle.calls).toHaveLength(1);
248+
expect(handle.calls[0]).toEqual([{ param: 1 }]);
249+
});
250+
251+
it('delay in sequence entry: loading immediately then resolves after delay', () => {
252+
vi.useFakeTimers();
253+
const handle = mockResource<string>(TOKEN as never, {
254+
sequence: [{ value: 'slow', delay: 300 }],
255+
});
256+
const fn = handle.useFactory!() as FakeFn;
257+
fn();
258+
expect(handle.ref.isLoading()).toBe(true);
259+
vi.advanceTimersByTime(300);
260+
expect(handle.ref.status()).toBe('resolved');
261+
expect(handle.ref.value()).toBe('slow');
262+
vi.useRealTimers();
263+
});
264+
});
169265
});

tools/openapi-resource-mocks/src/testing.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { InjectionToken, FactoryProvider } from '@angular/core';
22
import { httpResource } from '@angular/common/http';
33
import { createMockResourceRef } from './lib/mock-resource-ref';
4-
import type { MockResourceRef } from './lib/mock-resource-ref';
4+
import type { MockResourceRef, MockResourceRefInternal } from './lib/mock-resource-ref';
55
import type { ProviderInitialBehavior } from './lib/provide-mock-resource';
66

7+
/** One step in a response sequence — same shape as the single initialBehavior. */
8+
export type MockSequenceEntry<T> = ProviderInitialBehavior<T>;
9+
710
/** A FactoryProvider that also exposes the underlying ref and call history for assertions. */
811
export interface MockResourceHandle<T> extends FactoryProvider {
912
/** The underlying MockResourceRef — use to change state mid-test. */
@@ -55,29 +58,52 @@ function deepEqual(a: unknown, b: unknown): boolean {
5558
* Creates a lightweight mock provider for an httpResource token — no MockResourceBus,
5659
* no DOM events. Drop the handle directly into TestBed `providers: []`.
5760
*
61+
* **Single behavior:**
5862
* ```ts
5963
* const petsMock = mockResource(FIND_PETS_BY_STATUS, { value: [] });
6064
* TestBed.configureTestingModule({ providers: [petsMock] });
61-
* // after rendering:
6265
* petsMock.expectCalled();
6366
* petsMock.expectCalledWith({ status: 'active' });
64-
* petsMock.ref.resolve(newPets);
67+
* petsMock.ref.resolve(newPets); // change mid-test
68+
* ```
69+
*
70+
* **Response sequence** (each call / reload consumes the next entry; last repeats):
71+
* ```ts
72+
* const petsMock = mockResource(FIND_PETS_BY_STATUS, {
73+
* sequence: [{ loading: true }, { error: new Error('timeout') }, { value: pets }],
74+
* });
6575
* ```
6676
*/
6777
export function mockResource<T>(
6878
token: InjectionToken<(...args: unknown[]) => ReturnType<typeof httpResource<T>>>,
69-
initialBehavior?: ProviderInitialBehavior<T>,
79+
behaviorOrOptions?: ProviderInitialBehavior<T> | { sequence: MockSequenceEntry<T>[] },
7080
): MockResourceHandle<T> {
7181
const ref = createMockResourceRef<T>();
82+
const internal = ref as MockResourceRefInternal<T>;
7283
const calls: unknown[][] = [];
7384

74-
if (initialBehavior) applyBehavior(ref, initialBehavior);
85+
let notifyOnCall = false;
86+
87+
if (!behaviorOrOptions) {
88+
// no-op: ref stays idle
89+
} else if ('sequence' in behaviorOrOptions) {
90+
const sequence = behaviorOrOptions.sequence;
91+
let idx = 0;
92+
ref.onRequest(() => {
93+
const entry = idx < sequence.length ? sequence[idx++] : sequence[sequence.length - 1];
94+
applyBehavior(ref, entry);
95+
});
96+
notifyOnCall = true;
97+
} else {
98+
applyBehavior(ref, behaviorOrOptions);
99+
}
75100

76101
const handle: MockResourceHandle<T> = {
77102
provide: token,
78103
useFactory: () =>
79104
(...args: unknown[]) => {
80105
calls.push(args);
106+
if (notifyOnCall) internal._notifyRequest(args);
81107
return ref as unknown as ReturnType<typeof httpResource<T>>;
82108
},
83109
get ref() {

0 commit comments

Comments
 (0)