@sanity-labs/client-fake-for-test 0.3.0 → 0.4.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
@@ -78,7 +78,7 @@ More patterns — passing the fake to code typed against `SanityClient`, asserti
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
80
  | Config | `config()` | Returns `{ projectId, dataset, perspective }` |
81
- | Config | `withConfig({ projectId?, dataset?, perspective? })` | Sibling handle into the same multi-dataset registry |
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) |
82
82
  | Perspectives | `raw` / `published` / `drafts` / release stacks | Per-client default or per-`fetch` override; draft/version layering with `_originalId` projection |
83
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. |
84
84
 
@@ -96,6 +96,44 @@ More patterns — passing the fake to code typed against `SanityClient`, asserti
96
96
  > [!NOTE]
97
97
  > The static return type of `create` / `delete` reflects the **default** shape (the document / `{ _id } | null`). When you pass `returnDocuments: false` to get an id-shaped result, supply a type argument or cast — the runtime value is correct either way.
98
98
 
99
+ ### Gating writes — `mutationGuard`
100
+
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
+
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
+
105
+ ```ts
106
+ const client = createTestClient({
107
+ documents: [{ _id: "article-1", _type: "article", body: "draft" }],
108
+ mutationGuard: ({ action, before, after }) => {
109
+ // Freeze `body` once an article is locked.
110
+ if (action === "update" && before?.locked && before.body !== after?.body) {
111
+ throw new Error("body is locked");
112
+ }
113
+ },
114
+ });
115
+
116
+ await client.patch("article-1").set({ title: "ok" }).commit(); // allowed
117
+ await expect(client.patch("article-1").set({ body: "no" }).commit()).rejects.toThrow();
118
+ ```
119
+
120
+ #### Identity is the token
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 })`:
123
+
124
+ ```ts
125
+ const alice = createTestClient({
126
+ documents: [article],
127
+ accessControl: { currentUser: { id: "alice" }, grants },
128
+ mutationGuard: onlyTheAssignedReviewerMayWrite,
129
+ });
130
+ const bob = alice.withConfig({ accessControl: { currentUser: { id: "bob" }, grants } });
131
+ // alice and bob share one store; each write carries its own identity().
132
+ ```
133
+
134
+ > [!NOTE]
135
+ > Treat `before` / `after` as read-only — they are the live store images, not copies; mutating them corrupts the store.
136
+
99
137
  ### Test-only helpers (not on real `SanityClient`)
100
138
 
101
139
  Useful in test bodies, but a giveaway that the code under test isn't using the real client. Keep them in the test file, not in the code being tested.
package/dist/index.cjs CHANGED
@@ -876,7 +876,8 @@ function createTestClient(options = {}) {
876
876
  defaultProjectId,
877
877
  defaultDataset,
878
878
  options.accessControl,
879
- options.perspective ?? "raw"
879
+ options.perspective ?? "raw",
880
+ options.mutationGuard
880
881
  );
881
882
  }
882
883
  function dedupeIds(results) {
@@ -929,7 +930,7 @@ function toTxnMutations(operations) {
929
930
  }
930
931
  ];
931
932
  }
932
- function makeHandle(backend, projectId, dataset, accessControl, perspective) {
933
+ function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard) {
933
934
  const { registry } = backend, store = (() => {
934
935
  const key = registryKey(projectId, dataset);
935
936
  let s = registry.get(key);
@@ -978,6 +979,8 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
978
979
  permission,
979
980
  userId: accessControl.currentUser.id
980
981
  });
982
+ }, guardGate = async (action, before, after) => {
983
+ mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity: accessControl?.currentUser.id });
981
984
  }, canRead = async (document) => accessControl === void 0 ? !0 : canAccess({
982
985
  document,
983
986
  grants: accessControl.grants,
@@ -995,7 +998,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
995
998
  _rev: cryptoRandomKey(),
996
999
  _updatedAt: now
997
1000
  };
998
- return store.put(next), next;
1001
+ return await guardGate("update", existing, next), store.put(next), next;
999
1002
  }, applyTransaction = async (mutations, txnOptions) => {
1000
1003
  const transactionId = txnOptions?.transactionId ?? cryptoRandomKey(), snapshot = /* @__PURE__ */ new Map(), touchedIds = [], operations = [], remember = (id) => {
1001
1004
  snapshot.has(id) || snapshot.set(id, store.get(id));
@@ -1010,13 +1013,17 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1010
1013
  throw fakeError(
1011
1014
  `create() failed: a document with _id "${promoted._id}" already exists in dataset "${projectId}:${dataset}". Use createOrReplace() to overwrite it, or createIfNotExists() to leave the existing one untouched.`
1012
1015
  );
1013
- await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1016
+ await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1014
1017
  break;
1015
1018
  }
1016
1019
  case "createOrReplace": {
1017
1020
  remember(mut.doc._id);
1018
1021
  const promoted = promote2(mut.doc), existing = store.get(promoted._id);
1019
- await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
1022
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(
1023
+ existing !== void 0 ? "update" : "create",
1024
+ existing ?? null,
1025
+ promoted
1026
+ );
1020
1027
  const wasPresent = existing !== void 0;
1021
1028
  store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: wasPresent ? "update" : "create" });
1022
1029
  break;
@@ -1024,14 +1031,14 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1024
1031
  case "createIfNotExists": {
1025
1032
  if (remember(mut.doc._id), store.get(mut.doc._id) === void 0) {
1026
1033
  const promoted = promote2(mut.doc);
1027
- await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1034
+ await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
1028
1035
  }
1029
1036
  break;
1030
1037
  }
1031
1038
  case "delete": {
1032
1039
  remember(mut.id);
1033
1040
  const existing = store.get(mut.id);
1034
- existing !== void 0 && await gate(existing, "update"), store.remove(mut.id) && (touchedIds.push(mut.id), operations.push({ id: mut.id, operation: "delete" }));
1041
+ existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardGate("delete", existing, null), store.remove(mut.id) && (touchedIds.push(mut.id), operations.push({ id: mut.id, operation: "delete" }));
1035
1042
  break;
1036
1043
  }
1037
1044
  case "patch": {
@@ -1103,7 +1110,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1103
1110
  `create() failed: a document with _id "${doc._id}" already exists in dataset "${projectId}:${dataset}". Use createOrReplace() to overwrite it, or createIfNotExists() to leave the existing one untouched.`
1104
1111
  );
1105
1112
  const promoted = promote2(doc);
1106
- await gate(promoted, "create");
1113
+ await gate(promoted, "create"), await guardGate("create", null, promoted);
1107
1114
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1108
1115
  return opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "create", doc }])), writeResult(
1109
1116
  [promoted],
@@ -1115,7 +1122,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1115
1122
  createOrReplace: async (doc, opts) => {
1116
1123
  checkRevision(doc._id, opts?.ifRevisionId);
1117
1124
  const promoted = promote2(doc), existing = store.get(doc._id);
1118
- await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
1125
+ await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(existing !== void 0 ? "update" : "create", existing ?? null, promoted);
1119
1126
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1120
1127
  opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createOrReplace", doc }]));
1121
1128
  const operation = existing !== void 0 ? "update" : "create";
@@ -1137,7 +1144,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1137
1144
  opts
1138
1145
  );
1139
1146
  const promoted = promote2(doc);
1140
- return await gate(promoted, "create"), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }])), writeResult(
1147
+ return await gate(promoted, "create"), await guardGate("create", null, promoted), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }])), writeResult(
1141
1148
  [promoted],
1142
1149
  [{ id: promoted._id, operation: "create" }],
1143
1150
  transactionId,
@@ -1147,7 +1154,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1147
1154
  delete: async (id, opts) => {
1148
1155
  checkRevision(id, opts?.ifRevisionId);
1149
1156
  const existing = store.get(id);
1150
- existing !== void 0 && await gate(existing, "update");
1157
+ existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardGate("delete", existing, null);
1151
1158
  const transactionId = opts?.transactionId ?? cryptoRandomKey();
1152
1159
  let removed = existing;
1153
1160
  return opts?.dryRun !== !0 && (removed = store.remove(id), removed !== void 0 && record(transactionId, [{ kind: "delete", id }])), opts?.returnDocuments === void 0 && opts?.returnFirst === void 0 ? removed !== void 0 ? { _id: id } : null : formatMutationResult(
@@ -1216,8 +1223,9 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
1216
1223
  backend,
1217
1224
  config.projectId ?? projectId,
1218
1225
  config.dataset ?? dataset,
1219
- accessControl,
1220
- config.perspective ?? perspective
1226
+ config.accessControl ?? accessControl,
1227
+ config.perspective ?? perspective,
1228
+ mutationGuard
1221
1229
  ),
1222
1230
  seed: (documents) => store.seed(documents),
1223
1231
  add: (documents) => store.add(documents),