@sanity-labs/client-fake-for-test 0.3.0 → 0.5.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 +39 -1
- package/dist/index.cjs +38 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +78 -0
- package/dist/index.d.ts +78 -0
- package/dist/index.js +38 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
@@ -861,7 +861,13 @@ const RECORDED_METHODS = /* @__PURE__ */ new Set([
|
|
|
861
861
|
return { projectId, dataset };
|
|
862
862
|
};
|
|
863
863
|
function createTestClient(options = {}) {
|
|
864
|
-
const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = {
|
|
864
|
+
const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = {
|
|
865
|
+
registry: /* @__PURE__ */ new Map(),
|
|
866
|
+
log: [],
|
|
867
|
+
stubs: /* @__PURE__ */ new Map(),
|
|
868
|
+
calls: [],
|
|
869
|
+
concurrentEdits: /* @__PURE__ */ new Map()
|
|
870
|
+
}, registry = backend.registry, ensureStore = (projectId, dataset) => {
|
|
865
871
|
const key = registryKey(projectId, dataset);
|
|
866
872
|
let store = registry.get(key);
|
|
867
873
|
return store === void 0 && (store = createTestStore(), registry.set(key, store)), store;
|
|
@@ -876,7 +882,8 @@ function createTestClient(options = {}) {
|
|
|
876
882
|
defaultProjectId,
|
|
877
883
|
defaultDataset,
|
|
878
884
|
options.accessControl,
|
|
879
|
-
options.perspective ?? "raw"
|
|
885
|
+
options.perspective ?? "raw",
|
|
886
|
+
options.mutationGuard
|
|
880
887
|
);
|
|
881
888
|
}
|
|
882
889
|
function dedupeIds(results) {
|
|
@@ -929,12 +936,12 @@ function toTxnMutations(operations) {
|
|
|
929
936
|
}
|
|
930
937
|
];
|
|
931
938
|
}
|
|
932
|
-
function makeHandle(backend, projectId, dataset, accessControl, perspective) {
|
|
939
|
+
function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard) {
|
|
933
940
|
const { registry } = backend, store = (() => {
|
|
934
941
|
const key = registryKey(projectId, dataset);
|
|
935
942
|
let s = registry.get(key);
|
|
936
943
|
return s === void 0 && (s = createTestStore(), registry.set(key, s)), s;
|
|
937
|
-
})(), record = (transactionId, mutations) => {
|
|
944
|
+
})(), concurrentEditKey = (id) => registryKey(projectId, dataset) + " " + id, record = (transactionId, mutations) => {
|
|
938
945
|
backend.log.push({ transactionId, projectId, dataset, mutations });
|
|
939
946
|
}, writeResult = (documents, results, transactionId, opts) => formatMutationResult(
|
|
940
947
|
documents,
|
|
@@ -962,6 +969,11 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
|
|
|
962
969
|
};
|
|
963
970
|
}, checkRevision = (id, ifRevisionId) => {
|
|
964
971
|
if (ifRevisionId === void 0) return;
|
|
972
|
+
const editKey = concurrentEditKey(id), pendingEdits = backend.concurrentEdits.get(editKey) ?? 0;
|
|
973
|
+
if (pendingEdits > 0)
|
|
974
|
+
throw backend.concurrentEdits.set(editKey, pendingEdits - 1), fakeError(
|
|
975
|
+
`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.)`
|
|
976
|
+
);
|
|
965
977
|
const existing = store.get(id);
|
|
966
978
|
if (existing === void 0)
|
|
967
979
|
throw fakeError(
|
|
@@ -978,6 +990,8 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
|
|
|
978
990
|
permission,
|
|
979
991
|
userId: accessControl.currentUser.id
|
|
980
992
|
});
|
|
993
|
+
}, guardGate = async (action, before, after) => {
|
|
994
|
+
mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity: accessControl?.currentUser.id });
|
|
981
995
|
}, canRead = async (document) => accessControl === void 0 ? !0 : canAccess({
|
|
982
996
|
document,
|
|
983
997
|
grants: accessControl.grants,
|
|
@@ -995,7 +1009,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
|
|
|
995
1009
|
_rev: cryptoRandomKey(),
|
|
996
1010
|
_updatedAt: now
|
|
997
1011
|
};
|
|
998
|
-
return store.put(next), next;
|
|
1012
|
+
return await guardGate("update", existing, next), store.put(next), next;
|
|
999
1013
|
}, applyTransaction = async (mutations, txnOptions) => {
|
|
1000
1014
|
const transactionId = txnOptions?.transactionId ?? cryptoRandomKey(), snapshot = /* @__PURE__ */ new Map(), touchedIds = [], operations = [], remember = (id) => {
|
|
1001
1015
|
snapshot.has(id) || snapshot.set(id, store.get(id));
|
|
@@ -1010,13 +1024,17 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
|
|
|
1010
1024
|
throw fakeError(
|
|
1011
1025
|
`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
1026
|
);
|
|
1013
|
-
await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
|
|
1027
|
+
await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
|
|
1014
1028
|
break;
|
|
1015
1029
|
}
|
|
1016
1030
|
case "createOrReplace": {
|
|
1017
1031
|
remember(mut.doc._id);
|
|
1018
1032
|
const promoted = promote2(mut.doc), existing = store.get(promoted._id);
|
|
1019
|
-
await gate(existing ?? promoted, existing !== void 0 ? "update" : "create")
|
|
1033
|
+
await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(
|
|
1034
|
+
existing !== void 0 ? "update" : "create",
|
|
1035
|
+
existing ?? null,
|
|
1036
|
+
promoted
|
|
1037
|
+
);
|
|
1020
1038
|
const wasPresent = existing !== void 0;
|
|
1021
1039
|
store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: wasPresent ? "update" : "create" });
|
|
1022
1040
|
break;
|
|
@@ -1024,14 +1042,14 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
|
|
|
1024
1042
|
case "createIfNotExists": {
|
|
1025
1043
|
if (remember(mut.doc._id), store.get(mut.doc._id) === void 0) {
|
|
1026
1044
|
const promoted = promote2(mut.doc);
|
|
1027
|
-
await gate(promoted, "create"), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
|
|
1045
|
+
await gate(promoted, "create"), await guardGate("create", null, promoted), store.put(promoted), touchedIds.push(promoted._id), operations.push({ id: promoted._id, operation: "create" });
|
|
1028
1046
|
}
|
|
1029
1047
|
break;
|
|
1030
1048
|
}
|
|
1031
1049
|
case "delete": {
|
|
1032
1050
|
remember(mut.id);
|
|
1033
1051
|
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" }));
|
|
1052
|
+
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
1053
|
break;
|
|
1036
1054
|
}
|
|
1037
1055
|
case "patch": {
|
|
@@ -1103,7 +1121,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
|
|
|
1103
1121
|
`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
1122
|
);
|
|
1105
1123
|
const promoted = promote2(doc);
|
|
1106
|
-
await gate(promoted, "create");
|
|
1124
|
+
await gate(promoted, "create"), await guardGate("create", null, promoted);
|
|
1107
1125
|
const transactionId = opts?.transactionId ?? cryptoRandomKey();
|
|
1108
1126
|
return opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "create", doc }])), writeResult(
|
|
1109
1127
|
[promoted],
|
|
@@ -1115,7 +1133,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
|
|
|
1115
1133
|
createOrReplace: async (doc, opts) => {
|
|
1116
1134
|
checkRevision(doc._id, opts?.ifRevisionId);
|
|
1117
1135
|
const promoted = promote2(doc), existing = store.get(doc._id);
|
|
1118
|
-
await gate(existing ?? promoted, existing !== void 0 ? "update" : "create");
|
|
1136
|
+
await gate(existing ?? promoted, existing !== void 0 ? "update" : "create"), await guardGate(existing !== void 0 ? "update" : "create", existing ?? null, promoted);
|
|
1119
1137
|
const transactionId = opts?.transactionId ?? cryptoRandomKey();
|
|
1120
1138
|
opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createOrReplace", doc }]));
|
|
1121
1139
|
const operation = existing !== void 0 ? "update" : "create";
|
|
@@ -1137,7 +1155,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
|
|
|
1137
1155
|
opts
|
|
1138
1156
|
);
|
|
1139
1157
|
const promoted = promote2(doc);
|
|
1140
|
-
return await gate(promoted, "create"), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }])), writeResult(
|
|
1158
|
+
return await gate(promoted, "create"), await guardGate("create", null, promoted), opts?.dryRun !== !0 && (store.put(promoted), record(transactionId, [{ kind: "createIfNotExists", doc }])), writeResult(
|
|
1141
1159
|
[promoted],
|
|
1142
1160
|
[{ id: promoted._id, operation: "create" }],
|
|
1143
1161
|
transactionId,
|
|
@@ -1147,7 +1165,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
|
|
|
1147
1165
|
delete: async (id, opts) => {
|
|
1148
1166
|
checkRevision(id, opts?.ifRevisionId);
|
|
1149
1167
|
const existing = store.get(id);
|
|
1150
|
-
existing !== void 0 && await gate(existing, "update");
|
|
1168
|
+
existing !== void 0 && await gate(existing, "update"), existing !== void 0 && await guardGate("delete", existing, null);
|
|
1151
1169
|
const transactionId = opts?.transactionId ?? cryptoRandomKey();
|
|
1152
1170
|
let removed = existing;
|
|
1153
1171
|
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,14 +1234,19 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective) {
|
|
|
1216
1234
|
backend,
|
|
1217
1235
|
config.projectId ?? projectId,
|
|
1218
1236
|
config.dataset ?? dataset,
|
|
1219
|
-
accessControl,
|
|
1220
|
-
config.perspective ?? perspective
|
|
1237
|
+
config.accessControl ?? accessControl,
|
|
1238
|
+
config.perspective ?? perspective,
|
|
1239
|
+
mutationGuard
|
|
1221
1240
|
),
|
|
1222
1241
|
seed: (documents) => store.seed(documents),
|
|
1223
1242
|
add: (documents) => store.add(documents),
|
|
1224
1243
|
reset: () => store.reset(),
|
|
1225
1244
|
snapshot: () => store.snapshot(),
|
|
1226
1245
|
store: () => store,
|
|
1246
|
+
simulateConcurrentEdit: (id, count = 1) => {
|
|
1247
|
+
const editKey = concurrentEditKey(id);
|
|
1248
|
+
backend.concurrentEdits.set(editKey, (backend.concurrentEdits.get(editKey) ?? 0) + count);
|
|
1249
|
+
},
|
|
1227
1250
|
datasets: () => [...registry.keys()].map(parseRegistryKey),
|
|
1228
1251
|
stub: (name, handler) => {
|
|
1229
1252
|
backend.stubs.set(name, handler);
|