@sanity-labs/client-fake-for-test 0.11.0 → 0.13.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.
package/README.md CHANGED
@@ -77,10 +77,11 @@ More patterns — passing the fake to code typed against `SanityClient`, asserti
77
77
  | Transaction | `transaction()` chain → `.commit()` | Mutations: `.create`, `.createOrReplace`, `.createIfNotExists`, `.delete`, `.patch(id, recipe)`. Atomic — any throw rolls back the whole batch. Default result is a `MultipleMutationResult` (`{ transactionId, documentIds, results }`) |
78
78
  | Actions | `action(action \| action[])` | Content Lake release / version actions, applied atomically |
79
79
  | Releases | `releases.*`, `createVersion`, `unpublishVersion` | Content Releases lifecycle — create / edit / schedule / publish / archive / delete, plus version create / replace / discard / unpublish |
80
- | Config | `config()` | Returns `{ projectId, dataset, perspective, token?, useProjectHostname }` |
81
- | Config | `withConfig({ projectId?, dataset?, perspective?, accessControl?, token?, useProjectHostname? })` | Sibling handle into the same multi-dataset registry; `token` binds a registered identity (see the token→identity registry below), `accessControl` rebinds it inline — the two are mutually exclusive; `useProjectHostname: false` mints a global-hosted sibling |
82
- | Perspectives | `raw` / `published` / `drafts` / release stacks | Per-client default or per-`fetch` override; draft/version layering with `_originalId` projection |
83
- | HTTP | `request({ url? \| uri? })` | Present **only** when the handle has an acting identity (`accessControl`, or a bound `token`). Serves `/users/me` (host-aware: project-scoped record on the project host, `globalUser` on a global-hosted handle), the configured ACL path, `/projects/<projectId>` (the project's members: `{ id, isRobot?, roles? }` rows, one per distinct resolved project user), and `/projects/<projectId>/users/<ids>` (project-user records with `id` + `sanityUserId`). Calling it without one throws a guidance error. |
80
+ | Config | `config()` | Returns `{ projectId, dataset, resource?, perspective, token?, useProjectHostname }` |
81
+ | Config | `getDataUrl(operation, path?)` | The same URL `@sanity/client` builds: `<resource base>/<operation>/<path>` on a `resource` handle, `/data/<operation>/<dataset>/<path>` otherwise |
82
+ | Config | `withConfig({ projectId?, dataset?, resource?, perspective?, accessControl?, token?, useProjectHostname? })` | Sibling handle into the same multi-resource registry; `resource` points it at a Media Library, Canvas, dashboard or dataset (see [Resource types](#resource-types-media-library-canvas-dashboard)); `token` binds a registered identity (see the token→identity registry below), `accessControl` rebinds it inline — the two are mutually exclusive; `useProjectHostname: false` mints a global-hosted sibling |
83
+ | Perspectives | `raw` / `published` / `drafts` / release stacks / a single release name or comma-joined stack (`"rA"`, `"drafts,rA"`), as `@sanity/client` sends them | Per-client default or per-`fetch` override; draft/version layering with `_originalId` projection |
84
+ | HTTP | `request({ url? \| uri? })` | Present **only** when the handle has an acting identity (`accessControl`, or a bound `token`). Serves `/users/me` (host-aware: project-scoped record on the project host, `globalUser` on a global-hosted or `resource` handle), the handle's ACL (`<data base>/acl`, or an explicit `aclPath`), `/projects/<projectId>` (the project's members: `{ id, isRobot?, roles? }` rows, one per distinct resolved project user, plus `organizationId` when configured), `/projects/<projectId>/users/<ids>` (project-user records with `id` + `sanityUserId`), the History API, resource lookup and the Access API (see [Resource types](#resource-types-media-library-canvas-dashboard)). Calling it without one throws a guidance error. |
84
85
 
85
86
  #### Mutation return shapes
86
87
 
@@ -98,9 +99,9 @@ More patterns — passing the fake to code typed against `SanityClient`, asserti
98
99
 
99
100
  ### Gating writes — `mutationGuard`
100
101
 
101
- `createTestClient({ mutationGuard })` installs a pluggable write interceptor. It runs on every write through the document mutation API — `create` / `createOrReplace` / `createIfNotExists` / `patch` / `delete` and the equivalent `transaction()` / `mutate()` operations — _before_ the store changes, and **throws to reject** the write (the throw propagates out of the client call, like a real lake rejection). The release/version action API (`action`, `releases.*`, `createVersion`) is **not** intercepted.
102
+ `createTestClient({ mutationGuard })` installs a pluggable write interceptor. It runs once for each document's net change in a commit through the document mutation API — `create` / `createOrReplace` / `createIfNotExists` / `patch` / `delete` and the equivalent `transaction()` / `mutate()` operations. Document publish and unpublish actions are expanded into their published and draft mutations. Release and version actions (`releases.*`, `createVersion`, and `unpublishVersion`) are not intercepted.
102
103
 
103
- The client owns the _seam_; the caller owns the _policy_. This is the hook a workflow test bench uses to enforce Content Lake **mutation guards** at the write path without re-implementing guard evaluation. Each call receives the before/after image (`before` is `null` on create, `after` is `null` on delete), the `action`, and the acting `identity`.
104
+ The client owns the _seam_; the caller owns the _policy_. This is the hook a workflow test bench uses to enforce Content Lake **mutation guards** at the write path without re-implementing guard evaluation. Each call receives the original/final image (`before` is `null` on create, `after` is `null` on delete), the `action`, and the acting `identity`. Return every denying guard as `{ guardID, name? }`; the fake evaluates every net document mutation, aggregates the returned denials, deduplicates them by `guardID` in first-observed order, rolls back the commit, and throws a 403-shaped `GuardDeniedError` with `type: "guardDeniedError"` and `denials`. Other thrown callback errors propagate unchanged.
104
105
 
105
106
  ```ts
106
107
  const client = createTestClient({
@@ -108,13 +109,16 @@ const client = createTestClient({
108
109
  mutationGuard: ({ action, before, after }) => {
109
110
  // Freeze `body` once an article is locked.
110
111
  if (action === "update" && before?.locked && before.body !== after?.body) {
111
- throw new Error("body is locked");
112
+ return [{ guardID: "_.guards.article-body", name: "Locked article body" }];
112
113
  }
113
114
  },
114
115
  });
115
116
 
116
117
  await client.patch("article-1").set({ title: "ok" }).commit(); // allowed
117
- await expect(client.patch("article-1").set({ body: "no" }).commit()).rejects.toThrow();
118
+ await expect(client.patch("article-1").set({ body: "no" }).commit()).rejects.toMatchObject({
119
+ statusCode: 403,
120
+ type: "guardDeniedError",
121
+ });
118
122
  ```
119
123
 
120
124
  > [!NOTE]
@@ -140,6 +144,10 @@ const bob = alice.withConfig({ token: "t-bob" });
140
144
 
141
145
  Register more identities after creation with `client.addIdentity(token, { user, grants })` — an upsert into the family-shared registry; already-bound siblings resolve lazily and pick the change up immediately. For a one-off identity without a registry, `withConfig({ accessControl })` still works (mutually exclusive with `token` in the same call).
142
146
 
147
+ #### System-document grants
148
+
149
+ User-issued writes to system document IDs beginning with `_.` require the `manage` permission. Ordinary `create` and `update` grants do not authorize these writes; add `manage` to identities whose tests intentionally create, replace, patch, delete, transact, mutate, publish, or unpublish system documents. The fake's internal release/version bookkeeping remains outside access-control interception.
150
+
143
151
  #### Dual-namespace identities: global vs project user ids
144
152
 
145
153
  In production the same token resolves to a **different project-scoped user id in every project** (what lake `identity()` returns) and **one account-global id** (what the global API host's `/users/me` returns); robot tokens carry a single id on every host. An identity can model that split with `globalUser` (served by `/users/me` on a `useProjectHostname: false` handle) and `usersByProject` (per-project records; the handle's project falls back to `user`):
@@ -164,7 +172,39 @@ await client.request({ uri: "/projects/alpha/users/pAlpha" }); // { id: "pAlpha"
164
172
 
165
173
  The project directory endpoint (`/projects/<projectId>`) returns each resolved project member's `id`, project-specific `roles`, and robot marker. Roles are served in canonical order, and duplicate-token agreement ignores role and property order. Registering two tokens with the same project user id is supported when their served member records agree; conflicting roles fail the request instead of making the result depend on registration order.
166
174
 
167
- Grant-filter and mutation-guard `identity()` stay bound to the **project-scoped** record, matching the lake. Identities without `globalUser` serve one record everywhere — the production robot-token shape.
175
+ Grant-filter and mutation-guard `identity()` stay bound to the **project-scoped** record on a dataset, matching the lake. On a Media Library, Canvas or dashboard there is no project, so they see the account-global record (`globalUser`, falling back to `user`). Identities without `globalUser` serve one record everywhere — the production robot-token shape.
176
+
177
+ ### Resource types: Media Library, Canvas, dashboard
178
+
179
+ A handle configured with `resource: { type, id }` — the same config `@sanity/client` takes — reads and writes that resource's own store, and answers each capability the way the platform does on that resource type (staging probe, 2026-11-06). Where the platform says no, the fake throws the platform's status and body, shaped like `@sanity/client`'s `ClientError` (`statusCode`, `message`, `response.body`, `details`).
180
+
181
+ ```ts
182
+ const client = createTestClient({
183
+ resource: { type: "media-library", id: "ml1" },
184
+ organizationId: "org1",
185
+ identities: { "t-robot": robot },
186
+ token: "t-robot",
187
+ });
188
+ const canvas = client.withConfig({ resource: { type: "canvas", id: "c1" } });
189
+ await client.request({ url: client.getDataUrl("history", `documents/a?revision=${rev}`) });
190
+ await canvas.releases.create({ releaseId: "r1" }); // 404 Cannot POST /canvases/c1/actions
191
+ ```
192
+
193
+ | Capability | dataset | media-library | canvas | dashboard |
194
+ | --- | --- | --- | --- | --- |
195
+ | `drafts.` / `versions.` ids, perspectives | ✅ | ✅ | ✅ | ✅ |
196
+ | `/actions` (releases, version and document actions) | ✅ | ✅, except `release.create` → 403 `releaseLimitExceededError` | 404 `Cannot POST …/actions` | 404 `Cannot POST …/actions` |
197
+ | History: `documents/<ids>?revision=`, `transactions/<ids>` (NDJSON), `events/documents/<ids>` | ✅ | ✅ | ✅ | 404 `Cannot GET …` |
198
+ | ACL `<data base>/acl` | ✅ | ✅ | ✅ | 404 `Cannot GET …` |
199
+ | Access API `…/user-permissions/me`, `…/me/check` | ✅ | ✅ | ✅ | ✅ |
200
+ | Access API `…/users` | ✅ | ✅ | 403 | 403 |
201
+ | Access API `…/roles` | ✅ | 403 | 403 | 403 |
202
+ | Org lookup | `/projects/<projectId>` | `GET /media-libraries/<id>` | `GET /canvases/<id>` | `GET /dashboards/<id>` |
203
+
204
+ - **Access API.** Paths are `/access/<type>/<id>/…` with type `project`, `media-library`, `canvas` or `dashboard`. An identity's `access` entries, keyed `"<type>:<id>"`, hold its `roles` there (non-dataset membership) and the `permissions` the `user-permissions` endpoints report. Every identity is a project member with its project roles. Listings come back on one page (`nextCursor: null`). An unknown type answers 400 `Invalid resource type <type>`. An id the registry does not hold (never seeded, written or addressed) answers 404 `Media library not found` / `Canvas not found` / `Dashboard not found`, except `…/me/check`, which answers `false` as staging does. The 403 cells were not proven on staging (no token could list them), so they answer the way the platform refuses a missing permission: `{ statusCode: 403, message: "You do not have permission to access this resource" }`.
205
+ - **History.** Every committed write since the client was created is recorded per resource, authored by the acting identity; seeded documents can be read at their seeded `_rev`. A handle answers for its own resource, and hides documents its grants cannot read, as reads do (a transaction that also touched such a document is served without it). Events model `createLiveDocument` / `updateLiveDocument` only.
206
+ - **Org lookup.** `createTestClient({ organizationId })` names the organization every resource of the client belongs to.
207
+ - **Identity.** `@sanity/client` sends a `resource` client's requests to the global host, so `/users/me` answers `globalUser`.
168
208
 
169
209
  ### Test-only helpers (not on real `SanityClient`)
170
210
 
@@ -178,6 +218,7 @@ Useful in test bodies, but a giveaway that the code under test isn't using the r
178
218
  | `client.reset()` | Drop everything in the current dataset |
179
219
  | `client.store()` | Direct access to the underlying `TestStore` |
180
220
  | `client.datasets()` | `{ projectId, dataset }[]` known to the registry |
221
+ | `client.resources()` | Every resource known to the registry, `{ type, id }[]` (datasets as `id: "<projectId>.<dataset>"`) |
181
222
  | `client.mutations()` | Every committed mutation, flattened and in order — assert exactly what writes your code submitted |
182
223
  | `client.transactions()` | Every committed transaction (a direct mutation is a one-mutation transaction), each tagged with `transactionId` + `(projectId, dataset)` |
183
224
  | `client.clearLog()` | Reset the mutation / transaction log |
@@ -189,6 +230,7 @@ Useful in test bodies, but a giveaway that the code under test isn't using the r
189
230
  | `client.addIdentity(token, identity)` | Register (upsert) a token→identity entry in the family-shared registry |
190
231
  | `client.identities()` | Snapshot of the registered token→identity entries |
191
232
  | `createTestClient({ datasets: { "p:d": [...] } })` | Seed multiple `(projectId, dataset)` stores up front |
233
+ | `createTestClient({ resources: { "media-library:ml1": [...] } })` | Seed any resource type up front; keys are `"<type>:<id>"` |
192
234
 
193
235
  #### Two layers of observability
194
236
 
@@ -245,7 +287,7 @@ Nested access reports the full path (`client.assets.upload(...)` → `client.ass
245
287
  | --- | --- | --- |
246
288
  | `listen(...)` — real-time | ❌ Guarded | Test subscription logic separately, or `client.stub("listen", fn)` |
247
289
  | `clone()` | ❌ Guarded | `withConfig({})` returns a sibling handle |
248
- | `getUrl(uri)` / `getDataUrl(...)` — URL builders | ❌ Guarded | Hard-code in tests, or stub |
290
+ | `getUrl(uri)` — URL builder | ❌ Guarded | Hard-code in tests, or stub |
249
291
  | `request(...)` — generic HTTP | ⚠️ Only with an acting identity | Configure `accessControl` or bind a registered `token`, stub it, or use [MSW](https://mswjs.io) |
250
292
  | `agent.*` / `assets.*` / `projects.*` / `users.*` / `live.*` / `mediaLibrary.*` | ❌ Guarded | Seed docs directly, or `client.stub(name, fn)` |
251
293
  | `observable.*` — RxJS API | ❌ Guarded | Wrap the Promise API with `from()` if needed |