@sanity-labs/client-fake-for-test 0.8.0 → 0.9.1
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 +29 -5
- package/dist/index.cjs +108 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +84 -13
- package/dist/index.d.ts +84 -13
- package/dist/index.js +108 -27
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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, 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 |
|
|
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
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`
|
|
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, and `/projects/<projectId>/users/<ids>` (project-user records with `id` + `sanityUserId`). Calling it without one throws a guidance error. |
|
|
84
84
|
|
|
85
85
|
#### Mutation return shapes
|
|
86
86
|
|
|
@@ -117,6 +117,9 @@ await client.patch("article-1").set({ title: "ok" }).commit(); // allowed
|
|
|
117
117
|
await expect(client.patch("article-1").set({ body: "no" }).commit()).rejects.toThrow();
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
+
> [!NOTE]
|
|
121
|
+
> Treat `before` / `after` as read-only — they are the live store images, not copies; mutating them corrupts the store.
|
|
122
|
+
|
|
120
123
|
#### Identity is the token
|
|
121
124
|
|
|
122
125
|
`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:
|
|
@@ -137,8 +140,29 @@ const bob = alice.withConfig({ token: "t-bob" });
|
|
|
137
140
|
|
|
138
141
|
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
142
|
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
#### Dual-namespace identities: global vs project user ids
|
|
144
|
+
|
|
145
|
+
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`):
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const client = createTestClient({
|
|
149
|
+
projectId: "alpha",
|
|
150
|
+
token: "t-snorre",
|
|
151
|
+
identities: {
|
|
152
|
+
"t-snorre": {
|
|
153
|
+
user: { id: "pDefault" },
|
|
154
|
+
globalUser: { id: "gSnorre" },
|
|
155
|
+
usersByProject: { alpha: { id: "pAlpha" }, beta: { id: "pBeta" } },
|
|
156
|
+
grants,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
await client.request({ uri: "/users/me" }); // { id: "pAlpha", ... }
|
|
161
|
+
await client.withConfig({ useProjectHostname: false }).request({ uri: "/users/me" }); // { id: "gSnorre", ... }
|
|
162
|
+
await client.request({ uri: "/projects/alpha/users/pAlpha" }); // { id: "pAlpha", sanityUserId: "gSnorre", ... } (ProjectUserRecord)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
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.
|
|
142
166
|
|
|
143
167
|
### Test-only helpers (not on real `SanityClient`)
|
|
144
168
|
|
package/dist/index.cjs
CHANGED
|
@@ -61,15 +61,62 @@ async function enforceWriteAccess(args) {
|
|
|
61
61
|
function defaultAclPath(projectId, dataset) {
|
|
62
62
|
return `/projects/${projectId}/datasets/${dataset}/acl`;
|
|
63
63
|
}
|
|
64
|
-
function
|
|
64
|
+
function projectUserOf(identity, projectId) {
|
|
65
|
+
return identity.usersByProject?.[projectId] ?? identity.user;
|
|
66
|
+
}
|
|
67
|
+
function serveProjectUsers(args) {
|
|
68
|
+
const { requestedIds, pathProjectId, identities } = args, toRecord = (identity) => {
|
|
69
|
+
const projectUser = projectUserOf(identity, pathProjectId);
|
|
70
|
+
return {
|
|
71
|
+
id: projectUser.id,
|
|
72
|
+
sanityUserId: identity.globalUser?.id ?? projectUser.id,
|
|
73
|
+
displayName: projectUser.name ?? projectUser.id,
|
|
74
|
+
...projectUser.email !== void 0 ? { email: projectUser.email } : {}
|
|
75
|
+
};
|
|
76
|
+
}, byId = /* @__PURE__ */ new Map();
|
|
77
|
+
for (const identity of identities) {
|
|
78
|
+
const resolvedId = projectUserOf(identity, pathProjectId).id, existing = byId.get(resolvedId);
|
|
79
|
+
if (existing !== void 0 && JSON.stringify(toRecord(existing)) !== JSON.stringify(toRecord(identity)))
|
|
80
|
+
throw fakeError(
|
|
81
|
+
`request(): two registered identities resolve to project user "${resolvedId}" in project "${pathProjectId}" with different records. The directory cannot answer unambiguously \u2014 fix the identity registrations so duplicate ids carry identical records.`
|
|
82
|
+
);
|
|
83
|
+
byId.set(resolvedId, identity);
|
|
84
|
+
}
|
|
85
|
+
const ids = requestedIds.split(","), [firstId] = ids;
|
|
86
|
+
if (ids.length === 1 && firstId !== void 0) {
|
|
87
|
+
const identity = byId.get(firstId);
|
|
88
|
+
if (identity === void 0)
|
|
89
|
+
throw Object.assign(
|
|
90
|
+
fakeError(
|
|
91
|
+
`request(): no registered identity resolves to project user "${firstId}" in project "${pathProjectId}". Register it via createTestClient({ identities }) or addIdentity().`
|
|
92
|
+
),
|
|
93
|
+
{ statusCode: 404 }
|
|
94
|
+
);
|
|
95
|
+
return toRecord(identity);
|
|
96
|
+
}
|
|
97
|
+
return ids.flatMap((id) => {
|
|
98
|
+
const identity = byId.get(id);
|
|
99
|
+
return identity === void 0 ? [] : [toRecord(identity)];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const PROJECT_USERS_PATH = /^\/projects\/([^/]+)\/users\/([^/]+)$/;
|
|
103
|
+
function makeRequestImpl(ctx) {
|
|
104
|
+
const { getAccess, projectId, dataset, useProjectHostname, listIdentities } = ctx;
|
|
65
105
|
return async (opts) => {
|
|
66
106
|
const accessControl = getAccess(), aclPath = accessControl.aclPath ?? defaultAclPath(projectId, dataset), path = opts.uri ?? opts.url;
|
|
67
107
|
if (path === "/users/me")
|
|
68
|
-
return accessControl.currentUser;
|
|
108
|
+
return useProjectHostname ? accessControl.currentUser : accessControl.globalUser ?? accessControl.currentUser;
|
|
69
109
|
if (path === aclPath)
|
|
70
110
|
return accessControl.grants;
|
|
111
|
+
const projectUsers = path?.match(PROJECT_USERS_PATH), [, pathProjectId, requestedIds] = projectUsers ?? [];
|
|
112
|
+
if (pathProjectId !== void 0 && requestedIds !== void 0)
|
|
113
|
+
return serveProjectUsers({
|
|
114
|
+
requestedIds,
|
|
115
|
+
pathProjectId,
|
|
116
|
+
identities: listIdentities()
|
|
117
|
+
});
|
|
71
118
|
throw fakeError(
|
|
72
|
-
`request(): no handler for path "${path}". With access control configured, the fake
|
|
119
|
+
`request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", "${aclPath}", and "/projects/<projectId>/users/<ids>". For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
|
|
73
120
|
);
|
|
74
121
|
};
|
|
75
122
|
}
|
|
@@ -780,7 +827,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
|
|
|
780
827
|
},
|
|
781
828
|
request: {
|
|
782
829
|
what: "generic HTTP request hook \u2014 only wired up when the handle has an acting identity",
|
|
783
|
-
workaround: "Create the client with `accessControl`, or bind a registered token (`identities` + `token` / `withConfig({ token })`), to enable request() (it serves /users/me
|
|
830
|
+
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, and /projects/<projectId>/users/<ids>). Or register a stub."
|
|
784
831
|
},
|
|
785
832
|
dryRun: {
|
|
786
833
|
what: "not a method \u2014 did you mean the `{ dryRun }` mutation option?",
|
|
@@ -963,15 +1010,17 @@ function createTestClient(options = {}) {
|
|
|
963
1010
|
const { projectId, dataset } = parseRegistryKey(key);
|
|
964
1011
|
ensureStore(projectId, dataset).seed(docs);
|
|
965
1012
|
}
|
|
966
|
-
return makeHandle(
|
|
1013
|
+
return makeHandle({
|
|
967
1014
|
backend,
|
|
968
|
-
defaultProjectId,
|
|
969
|
-
defaultDataset,
|
|
970
|
-
options.accessControl,
|
|
971
|
-
options.perspective ?? "raw",
|
|
972
|
-
options.
|
|
973
|
-
options.
|
|
974
|
-
|
|
1015
|
+
projectId: defaultProjectId,
|
|
1016
|
+
dataset: defaultDataset,
|
|
1017
|
+
accessControl: options.accessControl,
|
|
1018
|
+
perspective: options.perspective ?? "raw",
|
|
1019
|
+
requestTagPrefix: options.requestTagPrefix,
|
|
1020
|
+
mutationGuard: options.mutationGuard,
|
|
1021
|
+
token: options.token,
|
|
1022
|
+
useProjectHostname: options.useProjectHostname ?? !0
|
|
1023
|
+
});
|
|
975
1024
|
}
|
|
976
1025
|
function dedupeIds(results) {
|
|
977
1026
|
const seen = /* @__PURE__ */ new Set(), out = [];
|
|
@@ -1023,13 +1072,27 @@ function toTxnMutations(operations) {
|
|
|
1023
1072
|
}
|
|
1024
1073
|
];
|
|
1025
1074
|
}
|
|
1026
|
-
function makeHandle(
|
|
1027
|
-
const {
|
|
1075
|
+
function makeHandle(handleArgs) {
|
|
1076
|
+
const {
|
|
1077
|
+
backend,
|
|
1078
|
+
projectId,
|
|
1079
|
+
dataset,
|
|
1080
|
+
accessControl,
|
|
1081
|
+
perspective,
|
|
1082
|
+
requestTagPrefix,
|
|
1083
|
+
mutationGuard,
|
|
1084
|
+
token,
|
|
1085
|
+
useProjectHostname
|
|
1086
|
+
} = handleArgs, { registry } = backend, activeAccess = () => {
|
|
1028
1087
|
if (token === void 0) return accessControl;
|
|
1029
1088
|
const identity = backend.identities.get(token);
|
|
1030
1089
|
if (identity === void 0)
|
|
1031
1090
|
throw fakeError(`no identity is registered for token "${token}" (was it removed?).`);
|
|
1032
|
-
return {
|
|
1091
|
+
return {
|
|
1092
|
+
currentUser: projectUserOf(identity, projectId),
|
|
1093
|
+
...identity.globalUser !== void 0 ? { globalUser: identity.globalUser } : {},
|
|
1094
|
+
grants: identity.grants
|
|
1095
|
+
};
|
|
1033
1096
|
}, requireActiveAccess = () => {
|
|
1034
1097
|
const access = activeAccess();
|
|
1035
1098
|
if (access === void 0)
|
|
@@ -1040,14 +1103,15 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
|
|
|
1040
1103
|
let s = registry.get(key);
|
|
1041
1104
|
return s === void 0 && (s = createTestStore(), registry.set(key, s)), s;
|
|
1042
1105
|
})(), concurrentEditKey = (id) => registryKey(projectId, dataset) + " " + id, record = (transactionId, mutations, tag) => {
|
|
1106
|
+
const effectiveTag = effectiveRequestTag(tag);
|
|
1043
1107
|
backend.log.push({
|
|
1044
1108
|
transactionId,
|
|
1045
1109
|
projectId,
|
|
1046
1110
|
dataset,
|
|
1047
1111
|
mutations,
|
|
1048
|
-
...
|
|
1112
|
+
...effectiveTag !== void 0 ? { tag: effectiveTag } : {}
|
|
1049
1113
|
});
|
|
1050
|
-
}, writeResult = (documents, results, transactionId, opts) => formatMutationResult(
|
|
1114
|
+
}, effectiveRequestTag = (tag) => requestTagPrefix === void 0 ? tag : tag ? `${requestTagPrefix}.${tag}` : requestTagPrefix, writeResult = (documents, results, transactionId, opts) => formatMutationResult(
|
|
1051
1115
|
documents,
|
|
1052
1116
|
results,
|
|
1053
1117
|
transactionId,
|
|
@@ -1350,20 +1414,37 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
|
|
|
1350
1414
|
versionId: `versions.${releaseId}.${publishedId}`,
|
|
1351
1415
|
publishedId
|
|
1352
1416
|
}),
|
|
1353
|
-
...token !== void 0 || accessControl !== void 0 ? {
|
|
1354
|
-
|
|
1417
|
+
...token !== void 0 || accessControl !== void 0 ? {
|
|
1418
|
+
request: makeRequestImpl({
|
|
1419
|
+
getAccess: requireActiveAccess,
|
|
1420
|
+
projectId,
|
|
1421
|
+
dataset,
|
|
1422
|
+
useProjectHostname,
|
|
1423
|
+
listIdentities: () => [...backend.identities.values()]
|
|
1424
|
+
})
|
|
1425
|
+
} : {},
|
|
1426
|
+
config: () => ({
|
|
1427
|
+
projectId,
|
|
1428
|
+
dataset,
|
|
1429
|
+
perspective,
|
|
1430
|
+
...requestTagPrefix !== void 0 ? { requestTagPrefix } : {},
|
|
1431
|
+
...token !== void 0 ? { token } : {},
|
|
1432
|
+
useProjectHostname
|
|
1433
|
+
}),
|
|
1355
1434
|
withConfig: (config) => {
|
|
1356
1435
|
rejectAmbiguousIdentity("withConfig()", config), config.token !== void 0 && requireIdentity(backend, config.token, "withConfig()");
|
|
1357
1436
|
const rebinds = config.token !== void 0 || config.accessControl !== void 0;
|
|
1358
|
-
return makeHandle(
|
|
1437
|
+
return makeHandle({
|
|
1359
1438
|
backend,
|
|
1360
|
-
config.projectId ?? projectId,
|
|
1361
|
-
config.dataset ?? dataset,
|
|
1362
|
-
rebinds ? config.accessControl : accessControl,
|
|
1363
|
-
config.perspective ?? perspective,
|
|
1439
|
+
projectId: config.projectId ?? projectId,
|
|
1440
|
+
dataset: config.dataset ?? dataset,
|
|
1441
|
+
accessControl: rebinds ? config.accessControl : accessControl,
|
|
1442
|
+
perspective: config.perspective ?? perspective,
|
|
1443
|
+
requestTagPrefix: config.requestTagPrefix ?? requestTagPrefix,
|
|
1364
1444
|
mutationGuard,
|
|
1365
|
-
rebinds ? config.token : token
|
|
1366
|
-
|
|
1445
|
+
token: rebinds ? config.token : token,
|
|
1446
|
+
useProjectHostname: config.useProjectHostname ?? useProjectHostname
|
|
1447
|
+
});
|
|
1367
1448
|
},
|
|
1368
1449
|
seed: (documents) => store.seed(documents),
|
|
1369
1450
|
add: (documents) => store.add(documents),
|
|
@@ -1397,7 +1478,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
|
|
|
1397
1478
|
return wrapWithGuards(client, {
|
|
1398
1479
|
getStub: (name) => backend.stubs.get(name),
|
|
1399
1480
|
onCall: (method, args) => {
|
|
1400
|
-
const rec = { method, args, projectId, dataset }, tag = tagFromArgs(method, args);
|
|
1481
|
+
const rec = { method, args, projectId, dataset }, tag = OPTIONS_ARG_POSITION[method] === void 0 ? void 0 : effectiveRequestTag(tagFromArgs(method, args));
|
|
1401
1482
|
return tag !== void 0 && (rec.tag = tag), backend.calls.push(rec), (outcome) => {
|
|
1402
1483
|
"error" in outcome ? rec.error = outcome.error : rec.result = outcome.result;
|
|
1403
1484
|
};
|