Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lucky-turkeys-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@data-client/rest': patch
---

RestEndpoint.remove.name is 'partialUpdate' since it uses 'PATCH'
24 changes: 17 additions & 7 deletions .cursor/skills/data-client-schema/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ to represent the data expected.
- [Entity](references/Entity.md) - represents a single unique object (denormalized)
- [EntityMixin](references/EntityMixin.md) - turn any pre-existing class into an Entity
- [new Union(Entity)](references/Union.md) - polymorphic objects (A | B)
- `{[key:string]: Schema}` - immutable objects
- [`{[key:string]: Schema}`](references/Object.md) - immutable objects
- [new Invalidate(Entity|Union)](references/Invalidate.md) - to delete an Entity
- [new Lazy(() => Schema)](references/Lazy.md) - break circular imports / defer deep recursive denormalization

### List

- [new Collection([Schema])](references/Collection.md) - mutable/growable lists
- `[Schema]` - immutable lists
- [`[Schema]`](references/Array.md) - immutable lists
- [new All(Entity|Union)](references/All.md) - list all Entities of a kind

### Map
Expand Down Expand Up @@ -65,8 +65,8 @@ to represent the data expected.

## 3. Entity lifecycle methods

- Normalize order: `process()` → `pk()` → [validate()](references/validation.md) → **visit nested schemas** (recurse into `schema` fields) → `mergeWithStore()` which calls `shouldUpdate()` and maybe `shouldReorder()` + `merge()`; metadata via `mergeMetaWithStore()`.
- Denormalize order: `createIfValid()` → [validate()](references/validation.md) → `fromJS()` → **unvisit nested schemas** (recurse into `schema` fields).
- **Normalize** (JSON response → cache): operates on POJOs; output is JSON-serializable plain data stored in the normalized cache. Order: `process()` → `pk()` → [validate()](references/validation.md) → **visit nested schemas** (recurse into `schema` fields) → if existing: `mergeWithStore()` which calls `shouldUpdate()` and maybe `shouldReorder()` + `merge()`; metadata via `mergeMetaWithStore()`.
- **Denormalize** (cache → component): creates Entity **class instances** via `fromJS()`, restoring prototype chain so getters, methods, and `schema` processing work. Order: `createIfValid()` → [validate()](references/validation.md) → `fromJS()` → **unvisit nested schemas** (recurse into `schema` fields).

---

Expand Down Expand Up @@ -105,11 +105,20 @@ export const EventResource = resource({

### pk routing

`pk()` uses `nestKey(parent, key)` when nested in an Entity and available; otherwise it uses `argsKey(...args)`, then serializes the result. Without options, it defaults to `argsKey: params => ({ ...params })`, using all endpoint args as the collection key. Provide both `argsKey` and `nestKey` to reuse one Collection definition top-level and nested.
`pk()` uses `nestKey(parent, key)` when nested in an Entity and available; otherwise it uses `argsKey(...args)`, then serializes the result. Without options, it defaults to `argsKey: params => ({ ...params })`, using all endpoint args as the collection key.

- `argsKey` — derive pk from endpoint arguments (default)
- `nestKey` — derive pk from parent entity for nested shared-state collections

Define **both** on the same `Collection` to reuse one definition top-level and nested. When `argsKey(args)` and `nestKey(parent)` produce the same object shape, the top-level fetch and the nested read resolve to the **same (referentially equal) array/map** — push/unshift/assign/move/remove on either updates both:

```ts
const userTodos = new Collection([Todo], {
argsKey: ({ userId }: { userId?: string }) => ({ userId }),
nestKey: (parent: User) => ({ userId: parent.id }),
});
```

### nonFilterArgumentKeys

Default `createCollectionFilter` uses `nonFilterArgumentKeys` (default: keys starting with `'order'`) to exclude non-filter args when matching collections. This affects which existing collections receive new items from `push`/`unshift`/`assign`/`move`.
Expand Down Expand Up @@ -154,7 +163,8 @@ See [partial-entities](references/partial-entities.md) for patterns and examples

## 8. Common Mistakes to Avoid

- Don't forget to use `fromJS()` or assign default properties for class fields
- The normalized cache stores **plain JSON-serializable objects** (POJOs), not class instances.
- Don't forget to use `fromJS()` or assign default properties for class fields — bare TS field types emit no runtime defaults, so schema inference breaks
- Manually merging or 'enriching' data; instead use `Entity.schema` for client-side joins

# References
Expand All @@ -169,7 +179,7 @@ For detailed API documentation, see the [references](references/) directory:
- [Invalidate](references/Invalidate.md) - Delete entities
- [Lazy](references/Lazy.md) - Deferred / circular schemas
- [Scalar](references/Scalar.md) - Lens-dependent entity fields
- [Scalar demo](references/_ScalarDemo.mdx)
- [Scalar demo](references/_ScalarDemo.md)
- [Values](references/Values.md) - Map schemas
- [All](references/All.md) - List all entities of a kind
- [Array](references/Array.md) - Immutable list schema
Expand Down
88 changes: 40 additions & 48 deletions .cursor/skills/data-client-vue-testing/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: data-client-vue-testing
description: Test @data-client/vue composables and components - renderDataCompose, mountDataClient, fixtures, jest, nock, Vue 3 reactive props, useSuspense testing
description: Test @data-client/vue composables and components - renderDataCompose, mountDataClient, fixtures, jest, nock HTTP mocking, polling/subscription tests with fake timers, useSuspense, useLive, useSubscription, Vue 3 reactive props
license: Apache 2.0
---

Expand Down Expand Up @@ -251,73 +251,63 @@ await nextTick();

## Testing with nock (HTTP Mocking)

Use nock when a test must exercise the real fetch path — verifying URL construction, headers, request bodies, retries, or anything in your `RestEndpoint`/`Resource` networking layer. For pure store/state behavior, prefer `initialFixtures`/`resolverFixtures` (lighter and faster).

Minimal shape:

```typescript
import nock from 'nock';

beforeAll(() => {
nock(/.*/)
.persist()
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
.options(/.*/)
.reply(200)
.get('/article/5')
.reply(200, { id: 5, title: 'hi ho' });
.defaultReplyHeaders({ 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' })
.options(/.*/).reply(200) // CORS preflight (required in JSDOM)
.get('/article/5').reply(200, { id: 5, title: 'hi ho' });
});

afterAll(() => {
nock.cleanAll();
});
afterAll(() => nock.cleanAll());
```

**Dynamic responses with nock:**
```typescript
const fetchMock = jest.fn(() => payload);
nock(/.*/)
.get(`/article/${payload.id}`)
.reply(200, fetchMock);
For dynamic server state, mutating-closure replies, request spying with `jest.fn()`, error responses, and mixing nock with fixtures, see [references/nock-http-mocking.md](references/nock-http-mocking.md).

// Later verify:
expect(fetchMock).toHaveBeenCalledTimes(1);
```
## Testing Polling and Subscriptions

## Testing Polling/Subscriptions
For composables with `pollFrequency`, `useLive`, or `useSubscription`, use fake timers so polls fire deterministically. Core flow:

1. `jest.useFakeTimers()` **before** mount/render (so the interval is created under fake timers).
2. Render, then `jest.advanceTimersByTime(frequency)` to drive the initial fetch.
3. Mutate the response (e.g. `responseMock.mockReturnValue(...)`), advance time again, `await allSettled()` and `await nextTick()`.
4. Restore real timers in `afterEach`: `jest.useRealTimers()`.

Quick example:

```typescript
it('should poll and update', async () => {
jest.useFakeTimers();
let serverData = { id: 5, title: 'Original' };
jest.useFakeTimers();
const responseMock = jest.fn(() => payload);

nock(/.*/)
.persist()
.get('/article/5')
.reply(200, () => serverData);

const { wrapper } = mountDataClient(PollingComponent);

// Wait for initial render
for (let i = 0; i < 100 && !wrapper.find('h3').exists(); i++) {
await jest.advanceTimersByTimeAsync(frequency / 10);
await nextTick();
}
expect(wrapper.find('h3').text()).toBe('Original');
const { result, allSettled, waitForNextUpdate, cleanup } = await renderDataCompose(
() => useSuspense(PollingArticleResource.get, { id: payload.id }),
{ resolverFixtures: [{ endpoint: PollingArticleResource.get, response: responseMock }] },
);

// Simulate server update
serverData = { id: 5, title: 'Updated' };
jest.advanceTimersByTime(frequency);
await allSettled();
await waitForNextUpdate();
const articleRef = await result;

// Advance timers to trigger poll
for (let i = 0; i < 20 && wrapper.find('h3').text() !== 'Updated'; i++) {
await jest.advanceTimersByTimeAsync(frequency / 10);
await nextTick();
}
expect(wrapper.find('h3').text()).toBe('Updated');
responseMock.mockReturnValue({ ...payload, title: 'updated' });
jest.advanceTimersByTime(frequency);
await allSettled();
await nextTick();

jest.useRealTimers();
});
expect(articleRef!.value.title).toBe('updated');
jest.useRealTimers();
cleanup();
```

For unsubscribe patterns, component-level polling tests, fake-timer-safe `flushUntil`, polling via nock, and common pitfalls, see [references/polling-subscriptions.md](references/polling-subscriptions.md).

## Vue Suspense Behavior

**useSuspense() returns Promise → ComputedRef:**
Expand Down Expand Up @@ -380,6 +370,8 @@ For detailed API documentation, see the [references](references/) directory:

- [Fixtures](references/Fixtures.md) - Fixture format reference
- [unit-testing-hooks](references/unit-testing-hooks.md) - Hook/composable testing guide
- [nock-http-mocking](references/nock-http-mocking.md) - Full nock setup, dynamic server state, request spying, errors, pitfalls
- [polling-subscriptions](references/polling-subscriptions.md) - Fake-timer patterns for `useLive`/`useSubscription`/`pollFrequency`, unsubscribe verification, polling via nock

## Common Patterns

Expand Down
133 changes: 133 additions & 0 deletions .cursor/skills/data-client-vue-testing/references/nock-http-mocking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Nock HTTP Mocking (Vue Testing)

Use `nock` instead of fixtures when you want a real fetch round-trip — for example, when verifying URL construction, headers, request bodies, retry logic, or anything that exercises the actual networking layer of your `RestEndpoint`/`Resource` definitions. For pure store/state behavior, prefer `initialFixtures`/`resolverFixtures` (lighter, faster, no global setup).

## Standard Setup

CORS preflight handling and a permissive default header set are required because the test environment runs in JSDOM and `fetch` performs OPTIONS preflights for non-simple requests.

```typescript
import nock from 'nock';

beforeAll(() => {
nock(/.*/)
.persist()
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Access-Token',
'Content-Type': 'application/json',
})
.options(/.*/)
.reply(200)
.get(`/article/${payload.id}`)
.reply(200, payload)
.put(`/article/${payload.id}`)
.reply(200, (uri, requestBody: any) => ({
...payload,
...requestBody,
}));
});

afterAll(() => {
nock.cleanAll();
});
```

Key points:

- `nock(/.*/)` matches any host (matches your `urlPrefix` whether it's `/`, `https://example.com`, etc.).
- `.persist()` keeps the interceptors alive across multiple requests in the test file. Without it, each interceptor fires once and is consumed.
- `.options(/.*/).reply(200)` blanket-handles preflight — without it, mutations like PUT/POST/DELETE will hang or fail in JSDOM.

## Dynamic Server State

To simulate a server whose data changes during the test (mutations, polling, optimistic updates), reply with a function that returns a closure-bound variable. Reassign the variable to "update the server".

```typescript
let currentPayload = { ...payload };

beforeAll(() => {
nock(/.*/)
.persist()
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
.options(/.*/)
.reply(200)
.get(`/article/${payload.id}`)
.reply(200, () => currentPayload)
.put(`/article/${payload.id}`)
.reply(200, (uri, requestBody: any) => ({
...currentPayload,
...requestBody,
}));
});

it('reflects updated server data on next fetch', async () => {
// ... initial render ...

// Mutate "server" state
currentPayload = { ...currentPayload, title: 'updated' };

// Trigger a refetch (e.g. via controller.fetch, polling, or invalidate)
// ... assert new value ...
});
```

## Spying on Requests

Use `jest.fn()` as the reply handler to assert call counts and inspect request bodies.

```typescript
const fetchMock = jest.fn(() => payload);

nock(/.*/)
.persist()
.defaultReplyHeaders({ 'Access-Control-Allow-Origin': '*' })
.options(/.*/)
.reply(200)
.get(`/article/${payload.id}`)
.reply(200, fetchMock);

// ... run test interactions ...

expect(fetchMock).toHaveBeenCalledTimes(1);
```

For mutations, the second argument to the reply callback is the request body:

```typescript
const updateMock = jest.fn((uri, requestBody) => ({ ...payload, ...requestBody }));

nock(/.*/)
.persist()
.put(`/article/${payload.id}`)
.reply(200, updateMock);
```

## Errors and Status Codes

```typescript
nock(/.*/)
.get('/article/missing')
.reply(404, { detail: 'not found' });

nock(/.*/)
.get('/article/broken')
.replyWithError('network down');
```

When testing error paths in components, also catch the error in your composable or wrap with `AsyncBoundary` so the test doesn't fail with an unhandled rejection.

## Combining nock With Fixtures

You can mix both within the same test file: use `initialFixtures` to pre-populate store state cheaply, and let nock handle any subsequent live requests (refetch, polling, mutation). The store will hydrate from fixtures first, then nock interceptors take over for actual fetches.

## Common Pitfalls

- **Hanging mutations** → missing `.options(/.*/).reply(200)` for CORS preflight.
- **Interceptor consumed after one call** → forgot `.persist()`.
- **Stale data after "server update"** → you reassigned the closure variable but never triggered a new fetch (no poll, no invalidate, no controller.fetch).
- **`Nock: No match for request`** → the URL or method differs from what your endpoint actually sends. Add `nock.recorder.rec()` temporarily, or check `urlPrefix` and `path` template substitution.
- **Cross-test pollution** → always `nock.cleanAll()` in `afterAll` (or `afterEach` for stricter isolation).
Loading
Loading