@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 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` + the configured ACL path. Calling it without one 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` (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
- > [!NOTE]
141
- > Treat `before` / `after` as read-only — they are the live store images, not copies; mutating them corrupts the store.
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 makeRequestImpl(getAccess, projectId, dataset) {
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 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.`
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 + the ACL path). Or register a stub."
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.mutationGuard,
973
- options.token
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(backend, projectId, dataset, accessControl, perspective, mutationGuard, token) {
1027
- const { registry } = backend, activeAccess = () => {
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 { currentUser: identity.user, grants: identity.grants };
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
- ...tag !== void 0 ? { tag } : {}
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 ? { request: makeRequestImpl(requireActiveAccess, projectId, dataset) } : {},
1354
- config: () => ({ projectId, dataset, perspective, ...token !== void 0 ? { token } : {} }),
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
  };