@sanity-labs/client-fake-for-test 0.4.0 → 0.6.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,10 @@ 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 }` |
81
- | Config | `withConfig({ projectId?, dataset?, perspective?, accessControl? })` | Sibling handle into the same multi-dataset registry; `accessControl` rebinds the acting identity (a token-scoped sibling sharing the store) |
80
+ | Config | `config()` | Returns `{ projectId, dataset, perspective, token? }` |
81
+ | Config | `withConfig({ projectId?, dataset?, perspective?, accessControl?, token? })` | 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 |
82
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 client is created with `accessControl`. Serves `/users/me` + the configured ACL path. Calling it without `accessControl` throws a guidance error. |
83
+ | HTTP | `request({ url? \| uri? })` | Present **only** when the handle has an acting identity (`accessControl`, or a bound `token`). Serves `/users/me` + the configured ACL path. Calling it without one throws a guidance error. |
84
84
 
85
85
  #### Mutation return shapes
86
86
 
@@ -119,18 +119,24 @@ await expect(client.patch("article-1").set({ body: "no" }).commit()).rejects.toT
119
119
 
120
120
  #### Identity is the token
121
121
 
122
- `identity` is the id bound to the client's token — its `accessControl.currentUser`. To act as a different user against the **same** store (e.g. to exercise `identity()`-based guards), branch a token-scoped sibling with `withConfig({ accessControl })`:
122
+ `identity` is the id of the handle's acting identity. To act as different users against the **same** store (e.g. to exercise `identity()`-based guards), register a token→identity registry and mint token-scoped siblings — the token determines identity, exactly like production:
123
123
 
124
124
  ```ts
125
125
  const alice = createTestClient({
126
126
  documents: [article],
127
- accessControl: { currentUser: { id: "alice" }, grants },
127
+ token: "t-alice",
128
+ identities: {
129
+ "t-alice": { user: { id: "alice" }, grants },
130
+ "t-bob": { user: { id: "bob" }, grants },
131
+ },
128
132
  mutationGuard: onlyTheAssignedReviewerMayWrite,
129
133
  });
130
- const bob = alice.withConfig({ accessControl: { currentUser: { id: "bob" }, grants } });
134
+ const bob = alice.withConfig({ token: "t-bob" });
131
135
  // alice and bob share one store; each write carries its own identity().
132
136
  ```
133
137
 
138
+ 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).
139
+
134
140
  > [!NOTE]
135
141
  > Treat `before` / `after` as read-only — they are the live store images, not copies; mutating them corrupts the store.
136
142
 
@@ -154,6 +160,8 @@ Useful in test bodies, but a giveaway that the code under test isn't using the r
154
160
  | `client.clearCalls()` | Reset the call log |
155
161
  | `client.stub(name, handler)` | Register a handler for any unimplemented `@sanity/client` member (see below) |
156
162
  | `client.stubs()` | The currently-registered stubs |
163
+ | `client.addIdentity(token, identity)` | Register (upsert) a token→identity entry in the family-shared registry |
164
+ | `client.identities()` | Snapshot of the registered token→identity entries |
157
165
  | `createTestClient({ datasets: { "p:d": [...] } })` | Seed multiple `(projectId, dataset)` stores up front |
158
166
 
159
167
  #### Two layers of observability
@@ -212,7 +220,7 @@ Nested access reports the full path (`client.assets.upload(...)` → `client.ass
212
220
  | `listen(...)` — real-time | ❌ Guarded | Test subscription logic separately, or `client.stub("listen", fn)` |
213
221
  | `clone()` | ❌ Guarded | `withConfig({})` returns a sibling handle |
214
222
  | `getUrl(uri)` / `getDataUrl(...)` — URL builders | ❌ Guarded | Hard-code in tests, or stub |
215
- | `request(...)` — generic HTTP | ⚠️ Only with `accessControl` | Configure `accessControl`, stub it, or use [MSW](https://mswjs.io) |
223
+ | `request(...)` — generic HTTP | ⚠️ Only with an acting identity | Configure `accessControl` or bind a registered `token`, stub it, or use [MSW](https://mswjs.io) |
216
224
  | `agent.*` / `assets.*` / `projects.*` / `users.*` / `live.*` / `mediaLibrary.*` | ❌ Guarded | Seed docs directly, or `client.stub(name, fn)` |
217
225
  | `observable.*` — RxJS API | ❌ Guarded | Wrap the Promise API with `from()` if needed |
218
226
  | Patch ops: `.diffMatchPatch`, `.merge`, `.append`, `.prepend`, `.replace` | ❌ Absent | `.insert("after", "field[-1]", items)` for append; `.insert("before", "field[0]", items)` for prepend |
package/dist/index.cjs CHANGED
@@ -61,16 +61,15 @@ async function enforceWriteAccess(args) {
61
61
  function defaultAclPath(projectId, dataset) {
62
62
  return `/projects/${projectId}/datasets/${dataset}/acl`;
63
63
  }
64
- function makeRequestImpl(accessControl, projectId, dataset) {
65
- const aclPath = accessControl.aclPath ?? defaultAclPath(projectId, dataset);
64
+ function makeRequestImpl(getAccess, projectId, dataset) {
66
65
  return async (opts) => {
67
- const path = opts.uri ?? opts.url;
66
+ const accessControl = getAccess(), aclPath = accessControl.aclPath ?? defaultAclPath(projectId, dataset), path = opts.uri ?? opts.url;
68
67
  if (path === "/users/me")
69
68
  return accessControl.currentUser;
70
69
  if (path === aclPath)
71
70
  return accessControl.grants;
72
71
  throw fakeError(
73
- `request(): no handler for path "${path}". With accessControl configured, the fake only serves "/users/me" and "${aclPath}". For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
72
+ `request(): no handler for path "${path}". With access control configured, the fake only serves "/users/me" and "${aclPath}". For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
74
73
  );
75
74
  };
76
75
  }
@@ -690,7 +689,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
690
689
  "releases",
691
690
  "createVersion",
692
691
  "unpublishVersion",
693
- "request (only with accessControl)",
692
+ "request (only with accessControl or a bound token)",
694
693
  "config",
695
694
  "withConfig"
696
695
  ], UNSUPPORTED_MEMBERS = {
@@ -739,8 +738,8 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
739
738
  workaround: "Out of scope \u2014 seed documents directly or stub the call site."
740
739
  },
741
740
  request: {
742
- what: "generic HTTP request hook \u2014 only wired up when accessControl is configured",
743
- workaround: "Create the client with `accessControl` to enable request() (it serves /users/me + the ACL path), or register a stub."
741
+ what: "generic HTTP request hook \u2014 only wired up when the handle has an acting identity",
742
+ workaround: "Create the client with `accessControl`, or bind a registered token (`identities` + `token` / `withConfig({ token })`), to enable request() (it serves /users/me + the ACL path). Or register a stub."
744
743
  },
745
744
  dryRun: {
746
745
  what: "not a method \u2014 did you mean the `{ dryRun }` mutation option?",
@@ -856,12 +855,35 @@ const RECORDED_METHODS = /* @__PURE__ */ new Set([
856
855
  "createVersion",
857
856
  "unpublishVersion",
858
857
  "request"
859
- ]), DEFAULT_PROJECT_ID = "test", DEFAULT_DATASET = "test", registryKey = (projectId, dataset) => `${projectId}:${dataset}`, parseRegistryKey = (key) => {
858
+ ]), DEFAULT_PROJECT_ID = "test", DEFAULT_DATASET = "test";
859
+ function requireIdentity(backend, token, site) {
860
+ if (backend.identities.has(token)) return;
861
+ const known = [...backend.identities.keys()];
862
+ throw fakeError(
863
+ `${site}: unknown token "${token}" \u2014 no identity is registered for it. ` + (known.length > 0 ? `Registered tokens: ${known.map((t) => `"${t}"`).join(", ")}. ` : "No identities are registered. ") + "Register identities via createTestClient({ identities }) or client.addIdentity(token, identity)."
864
+ );
865
+ }
866
+ function rejectAmbiguousIdentity(site, config) {
867
+ if (config.token !== void 0 && config.accessControl !== void 0)
868
+ throw fakeError(
869
+ `${site}: \`token\` and \`accessControl\` are mutually exclusive \u2014 a handle acts either as a registered identity (token) or as an inline accessControl config, not both.`
870
+ );
871
+ }
872
+ const registryKey = (projectId, dataset) => `${projectId}:${dataset}`, parseRegistryKey = (key) => {
860
873
  const [projectId, dataset] = key.split(":");
861
874
  return { projectId, dataset };
862
875
  };
863
876
  function createTestClient(options = {}) {
864
- const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = { registry: /* @__PURE__ */ new Map(), log: [], stubs: /* @__PURE__ */ new Map(), calls: [] }, registry = backend.registry, ensureStore = (projectId, dataset) => {
877
+ const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = {
878
+ registry: /* @__PURE__ */ new Map(),
879
+ log: [],
880
+ stubs: /* @__PURE__ */ new Map(),
881
+ calls: [],
882
+ concurrentEdits: /* @__PURE__ */ new Map(),
883
+ identities: new Map(Object.entries(options.identities ?? {}))
884
+ }, registry = backend.registry;
885
+ rejectAmbiguousIdentity("createTestClient()", options), options.token !== void 0 && requireIdentity(backend, options.token, "createTestClient()");
886
+ const ensureStore = (projectId, dataset) => {
865
887
  const key = registryKey(projectId, dataset);
866
888
  let store = registry.get(key);
867
889
  return store === void 0 && (store = createTestStore(), registry.set(key, store)), store;
@@ -877,7 +899,8 @@ function createTestClient(options = {}) {
877
899
  defaultDataset,
878
900
  options.accessControl,
879
901
  options.perspective ?? "raw",
880
- options.mutationGuard
902
+ options.mutationGuard,
903
+ options.token
881
904
  );
882
905
  }
883
906
  function dedupeIds(results) {
@@ -930,12 +953,23 @@ function toTxnMutations(operations) {
930
953
  }
931
954
  ];
932
955
  }
933
- function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard) {
934
- const { registry } = backend, store = (() => {
956
+ function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard, token) {
957
+ const { registry } = backend, activeAccess = () => {
958
+ if (token === void 0) return accessControl;
959
+ const identity = backend.identities.get(token);
960
+ if (identity === void 0)
961
+ throw fakeError(`no identity is registered for token "${token}" (was it removed?).`);
962
+ return { currentUser: identity.user, grants: identity.grants };
963
+ }, requireActiveAccess = () => {
964
+ const access = activeAccess();
965
+ if (access === void 0)
966
+ throw fakeError("this handle has no acting identity (no token or accessControl bound).");
967
+ return access;
968
+ }, store = (() => {
935
969
  const key = registryKey(projectId, dataset);
936
970
  let s = registry.get(key);
937
971
  return s === void 0 && (s = createTestStore(), registry.set(key, s)), s;
938
- })(), record = (transactionId, mutations) => {
972
+ })(), concurrentEditKey = (id) => registryKey(projectId, dataset) + " " + id, record = (transactionId, mutations) => {
939
973
  backend.log.push({ transactionId, projectId, dataset, mutations });
940
974
  }, writeResult = (documents, results, transactionId, opts) => formatMutationResult(
941
975
  documents,
@@ -963,6 +997,11 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
963
997
  };
964
998
  }, checkRevision = (id, ifRevisionId) => {
965
999
  if (ifRevisionId === void 0) return;
1000
+ const editKey = concurrentEditKey(id), pendingEdits = backend.concurrentEdits.get(editKey) ?? 0;
1001
+ if (pendingEdits > 0)
1002
+ throw backend.concurrentEdits.set(editKey, pendingEdits - 1), fakeError(
1003
+ `ifRevisionId check failed: document "${id}" was concurrently edited (simulated via simulateConcurrentEdit) \u2014 the revision-guarded mutation lost the race; re-read and retry. (This mirrors Sanity's optimistic-locking 409.)`
1004
+ );
966
1005
  const existing = store.get(id);
967
1006
  if (existing === void 0)
968
1007
  throw fakeError(
@@ -973,20 +1012,24 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
973
1012
  `ifRevisionId check failed: document "${id}" is at _rev="${existing._rev}" but the mutation required _rev="${ifRevisionId}". The document changed since you read it \u2014 re-read it and retry. (This mirrors Sanity's optimistic-locking 409.)`
974
1013
  );
975
1014
  }, gate = async (document, permission) => {
976
- accessControl !== void 0 && await enforceWriteAccess({
1015
+ const access = activeAccess();
1016
+ access !== void 0 && await enforceWriteAccess({
977
1017
  document,
978
- grants: accessControl.grants,
1018
+ grants: access.grants,
979
1019
  permission,
980
- userId: accessControl.currentUser.id
1020
+ userId: access.currentUser.id
981
1021
  });
982
1022
  }, guardGate = async (action, before, after) => {
983
- mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity: accessControl?.currentUser.id });
984
- }, canRead = async (document) => accessControl === void 0 ? !0 : canAccess({
985
- document,
986
- grants: accessControl.grants,
987
- permission: "read",
988
- userId: accessControl.currentUser.id
989
- }), applyPatch = async (documentId, ops, ifRev) => {
1023
+ mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity: activeAccess()?.currentUser.id });
1024
+ }, canRead = async (document) => {
1025
+ const access = activeAccess();
1026
+ return access === void 0 ? !0 : canAccess({
1027
+ document,
1028
+ grants: access.grants,
1029
+ permission: "read",
1030
+ userId: access.currentUser.id
1031
+ });
1032
+ }, applyPatch = async (documentId, ops, ifRev) => {
990
1033
  const existing = store.get(documentId);
991
1034
  if (existing === void 0)
992
1035
  throw fakeError(
@@ -1062,7 +1105,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
1062
1105
  );
1063
1106
  }, accessVisibleDocs = async () => {
1064
1107
  const all = store.all();
1065
- if (accessControl === void 0) return all;
1108
+ if (activeAccess() === void 0) return all;
1066
1109
  const out = [];
1067
1110
  for (const doc of all)
1068
1111
  await canRead(doc) && out.push(doc);
@@ -1217,26 +1260,39 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
1217
1260
  versionId: `versions.${releaseId}.${publishedId}`,
1218
1261
  publishedId
1219
1262
  }),
1220
- ...accessControl !== void 0 ? { request: makeRequestImpl(accessControl, projectId, dataset) } : {},
1221
- config: () => ({ projectId, dataset, perspective }),
1222
- withConfig: (config) => makeHandle(
1223
- backend,
1224
- config.projectId ?? projectId,
1225
- config.dataset ?? dataset,
1226
- config.accessControl ?? accessControl,
1227
- config.perspective ?? perspective,
1228
- mutationGuard
1229
- ),
1263
+ ...token !== void 0 || accessControl !== void 0 ? { request: makeRequestImpl(requireActiveAccess, projectId, dataset) } : {},
1264
+ config: () => ({ projectId, dataset, perspective, ...token !== void 0 ? { token } : {} }),
1265
+ withConfig: (config) => {
1266
+ rejectAmbiguousIdentity("withConfig()", config), config.token !== void 0 && requireIdentity(backend, config.token, "withConfig()");
1267
+ const rebinds = config.token !== void 0 || config.accessControl !== void 0;
1268
+ return makeHandle(
1269
+ backend,
1270
+ config.projectId ?? projectId,
1271
+ config.dataset ?? dataset,
1272
+ rebinds ? config.accessControl : accessControl,
1273
+ config.perspective ?? perspective,
1274
+ mutationGuard,
1275
+ rebinds ? config.token : token
1276
+ );
1277
+ },
1230
1278
  seed: (documents) => store.seed(documents),
1231
1279
  add: (documents) => store.add(documents),
1232
1280
  reset: () => store.reset(),
1233
1281
  snapshot: () => store.snapshot(),
1234
1282
  store: () => store,
1283
+ simulateConcurrentEdit: (id, count = 1) => {
1284
+ const editKey = concurrentEditKey(id);
1285
+ backend.concurrentEdits.set(editKey, (backend.concurrentEdits.get(editKey) ?? 0) + count);
1286
+ },
1235
1287
  datasets: () => [...registry.keys()].map(parseRegistryKey),
1236
1288
  stub: (name, handler) => {
1237
1289
  backend.stubs.set(name, handler);
1238
1290
  },
1239
1291
  stubs: () => Object.fromEntries(backend.stubs),
1292
+ addIdentity: (identityToken, identity) => {
1293
+ backend.identities.set(identityToken, identity);
1294
+ },
1295
+ identities: () => Object.fromEntries(backend.identities),
1240
1296
  mutations: () => backend.log.flatMap((entry) => entry.mutations),
1241
1297
  transactions: () => [...backend.log],
1242
1298
  clearLog: () => {