@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 +15 -7
- package/dist/index.cjs +90 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +100 -12
- package/dist/index.d.ts +100 -12
- package/dist/index.js +90 -34
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -110,6 +110,37 @@ export declare interface CreateTestClientOptions {
|
|
|
110
110
|
* unconditionally — the legacy behavior.
|
|
111
111
|
*/
|
|
112
112
|
accessControl?: TestClientAccessControl;
|
|
113
|
+
/**
|
|
114
|
+
* Token→identity registry, shared by the whole `withConfig` family:
|
|
115
|
+
* multiple identities over one store, keyed by the token a handle is
|
|
116
|
+
* bound to — the token determines identity, exactly like production.
|
|
117
|
+
*
|
|
118
|
+
* ```ts
|
|
119
|
+
* const client = createTestClient({
|
|
120
|
+
* token: "t-admin",
|
|
121
|
+
* identities: {
|
|
122
|
+
* "t-admin": { user: { id: "admin", roles: [{ name: "administrator" }] }, grants: WILDCARD },
|
|
123
|
+
* "t-bob": { user: { id: "bob", roles: [{ name: "editor" }] }, grants: READ_ONLY },
|
|
124
|
+
* },
|
|
125
|
+
* });
|
|
126
|
+
* const bob = client.withConfig({ token: "t-bob" });
|
|
127
|
+
* // same store; bob's /users/me → bob; bob's ACL path → bob's grants
|
|
128
|
+
* ```
|
|
129
|
+
*
|
|
130
|
+
* A token-bound handle behaves exactly like one configured with
|
|
131
|
+
* `accessControl` derived from its identity (identity serving + grant
|
|
132
|
+
* gating; the ACL path defaults per `(projectId, dataset)`). Register
|
|
133
|
+
* more identities later with `addIdentity`.
|
|
134
|
+
*/
|
|
135
|
+
identities?: Record<string, TestClientIdentity>;
|
|
136
|
+
/**
|
|
137
|
+
* Token the root handle is bound to — must name an entry in
|
|
138
|
+
* `identities` (unknown tokens throw). Omit to leave the root handle
|
|
139
|
+
* identity-free (a bare factory for token-bound siblings): its
|
|
140
|
+
* `request` is undefined and its reads/writes are ungated. Mutually
|
|
141
|
+
* exclusive with `accessControl`.
|
|
142
|
+
*/
|
|
143
|
+
token?: string;
|
|
113
144
|
/**
|
|
114
145
|
* Pluggable write interceptor — runs on each write through the document
|
|
115
146
|
* mutation API (create/patch/delete/transaction; not the release/version
|
|
@@ -268,6 +299,8 @@ export declare type FakeMutation =
|
|
|
268
299
|
*
|
|
269
300
|
* - `seed`, `add`, `reset`, `snapshot`, `store` — bulk-load helpers,
|
|
270
301
|
* scoped to the current dataset.
|
|
302
|
+
* - `simulateConcurrentEdit()` — force the next N revision-guarded writes
|
|
303
|
+
* to a document to 409, for optimistic-locking retry tests.
|
|
271
304
|
* - `datasets()` — lists known dataset keys in this registry.
|
|
272
305
|
*
|
|
273
306
|
* Methods deliberately omitted:
|
|
@@ -657,7 +690,7 @@ export declare const SUPPORTED_SURFACE: readonly [
|
|
|
657
690
|
"releases",
|
|
658
691
|
"createVersion",
|
|
659
692
|
"unpublishVersion",
|
|
660
|
-
"request (only with accessControl)",
|
|
693
|
+
"request (only with accessControl or a bound token)",
|
|
661
694
|
"config",
|
|
662
695
|
"withConfig",
|
|
663
696
|
];
|
|
@@ -683,16 +716,12 @@ export declare interface TestClient {
|
|
|
683
716
|
options?: FetchOptions,
|
|
684
717
|
) => Promise<(T | null)[]>;
|
|
685
718
|
/**
|
|
686
|
-
* Sanity-style HTTP request hook. Present iff the
|
|
687
|
-
* created with `accessControl
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
* Test cases that want to exercise the engine's token-resolution
|
|
693
|
-
* path should configure `accessControl`; cases that want bench-style
|
|
694
|
-
* always-allow should leave it off and pass `access` through the
|
|
695
|
-
* bench.
|
|
719
|
+
* Sanity-style HTTP request hook. Present iff the handle has an
|
|
720
|
+
* acting identity — created with `accessControl`, or bound to a
|
|
721
|
+
* registered token (`identities` + `token`). The engine's access
|
|
722
|
+
* resolver pokes `/users/me` and the configured ACL path through
|
|
723
|
+
* this — without it the engine throws on any verb that needs an
|
|
724
|
+
* actor.
|
|
696
725
|
*/
|
|
697
726
|
request?: <T>(opts: {
|
|
698
727
|
url?: string;
|
|
@@ -750,11 +779,15 @@ export declare interface TestClient {
|
|
|
750
779
|
releaseId: string;
|
|
751
780
|
publishedId: string;
|
|
752
781
|
}) => Promise<ActionResult>;
|
|
753
|
-
/**
|
|
782
|
+
/**
|
|
783
|
+
* Current `(projectId, dataset)` and active perspective. `token` is
|
|
784
|
+
* present iff the handle is bound to a registered identity.
|
|
785
|
+
*/
|
|
754
786
|
config: () => {
|
|
755
787
|
projectId: string;
|
|
756
788
|
dataset: string;
|
|
757
789
|
perspective: Perspective;
|
|
790
|
+
token?: string;
|
|
758
791
|
};
|
|
759
792
|
/**
|
|
760
793
|
* Return a new client handle pointing at a different `(projectId, dataset)`
|
|
@@ -771,8 +804,18 @@ export declare interface TestClient {
|
|
|
771
804
|
* Rebind the acting identity (and its grants) on a sibling that shares
|
|
772
805
|
* the same store — the test analogue of a token-scoped client. Lets two
|
|
773
806
|
* actors write the same dataset, e.g. for `identity()`-based guards.
|
|
807
|
+
* Mutually exclusive with `token`.
|
|
774
808
|
*/
|
|
775
809
|
accessControl: TestClientAccessControl;
|
|
810
|
+
/**
|
|
811
|
+
* Bind the sibling to a token registered in the family's identity
|
|
812
|
+
* registry (`createTestClient({ identities })` / `addIdentity`): its
|
|
813
|
+
* `/users/me` serves that identity's user, its ACL path serves that
|
|
814
|
+
* identity's grants, and reads/writes are gated through them — the
|
|
815
|
+
* token determines identity, exactly like production. Unknown tokens
|
|
816
|
+
* throw. Mutually exclusive with `accessControl`.
|
|
817
|
+
*/
|
|
818
|
+
token: string;
|
|
776
819
|
}>,
|
|
777
820
|
) => TestClient;
|
|
778
821
|
/** Replace the current dataset with the given documents. */
|
|
@@ -785,6 +828,27 @@ export declare interface TestClient {
|
|
|
785
828
|
snapshot: () => readonly SanityDocument[];
|
|
786
829
|
/** Direct store access for the current dataset. */
|
|
787
830
|
store: () => TestStore;
|
|
831
|
+
/**
|
|
832
|
+
* Simulate `count` concurrent edits to `id` in the current dataset: the
|
|
833
|
+
* next `count` revision-guarded mutations (those passing `ifRevisionId`)
|
|
834
|
+
* to that document throw a 409-shaped conflict, as if another client
|
|
835
|
+
* committed between the caller's read and write. Mutations that don't
|
|
836
|
+
* pass `ifRevisionId` are unaffected.
|
|
837
|
+
*
|
|
838
|
+
* Reach for this when the code under test reads a document's `_rev` and
|
|
839
|
+
* commits with `ifRevisionId` internally — so you can't stage the race
|
|
840
|
+
* by mutating the document first (the code would just re-read the new
|
|
841
|
+
* revision and its commit would match). The counter is consumed one per
|
|
842
|
+
* guarded commit, so `count` lets a test drive a recover-after-N-losses
|
|
843
|
+
* path or exhaust a retry budget, deterministically and without relying
|
|
844
|
+
* on promise interleaving.
|
|
845
|
+
*
|
|
846
|
+
* Note: this forces the conflict *outcome* — it short-circuits the
|
|
847
|
+
* revision comparison rather than changing the stored `_rev`. The code
|
|
848
|
+
* under test sees the identical 409 error a real lost race produces; the
|
|
849
|
+
* stored revision is left untouched.
|
|
850
|
+
*/
|
|
851
|
+
simulateConcurrentEdit: (id: string, count?: number) => void;
|
|
788
852
|
/** List `(projectId, dataset)` pairs known in this registry. */
|
|
789
853
|
datasets: () => {
|
|
790
854
|
projectId: string;
|
|
@@ -800,6 +864,16 @@ export declare interface TestClient {
|
|
|
800
864
|
stub: (name: string, handler: unknown) => void;
|
|
801
865
|
/** The currently-registered stubs, keyed by member name. */
|
|
802
866
|
stubs: () => Record<string, unknown>;
|
|
867
|
+
/**
|
|
868
|
+
* Register (or upsert) a token→identity entry in the family's shared
|
|
869
|
+
* registry. Siblings already bound to `token` resolve lazily, so a
|
|
870
|
+
* re-registration takes effect on them immediately. Registering an
|
|
871
|
+
* identity does not bind it — pass `token` at `createTestClient` or
|
|
872
|
+
* `withConfig({ token })` to act as it.
|
|
873
|
+
*/
|
|
874
|
+
addIdentity: (token: string, identity: TestClientIdentity) => void;
|
|
875
|
+
/** Snapshot of the registered token→identity entries. */
|
|
876
|
+
identities: () => Record<string, TestClientIdentity>;
|
|
803
877
|
/**
|
|
804
878
|
* Every mutation committed through this client's registry, flattened
|
|
805
879
|
* and in order — `create` / `createOrReplace` / `createIfNotExists` /
|
|
@@ -844,6 +918,20 @@ export declare interface TestClientAccessControl {
|
|
|
844
918
|
aclPath?: string;
|
|
845
919
|
}
|
|
846
920
|
|
|
921
|
+
/**
|
|
922
|
+
* One entry in the token→identity registry (`createTestClient({ identities })`).
|
|
923
|
+
* A handle bound to the entry's token behaves exactly like one configured
|
|
924
|
+
* with `accessControl: { currentUser: user, grants }` — `/users/me` serves
|
|
925
|
+
* `user`, the default ACL path serves `grants`, and reads/writes are gated
|
|
926
|
+
* through them.
|
|
927
|
+
*/
|
|
928
|
+
export declare interface TestClientIdentity {
|
|
929
|
+
/** What `/users/me` returns for clients bound to this token. */
|
|
930
|
+
user: CurrentUser;
|
|
931
|
+
/** Grants served at the ACL path and enforced on reads/writes. */
|
|
932
|
+
grants: Grant[];
|
|
933
|
+
}
|
|
934
|
+
|
|
847
935
|
/**
|
|
848
936
|
* In-memory document store backing a TestClient. Closure-scoped — created
|
|
849
937
|
* fresh per test, no global state.
|
package/dist/index.d.ts
CHANGED
|
@@ -110,6 +110,37 @@ export declare interface CreateTestClientOptions {
|
|
|
110
110
|
* unconditionally — the legacy behavior.
|
|
111
111
|
*/
|
|
112
112
|
accessControl?: TestClientAccessControl;
|
|
113
|
+
/**
|
|
114
|
+
* Token→identity registry, shared by the whole `withConfig` family:
|
|
115
|
+
* multiple identities over one store, keyed by the token a handle is
|
|
116
|
+
* bound to — the token determines identity, exactly like production.
|
|
117
|
+
*
|
|
118
|
+
* ```ts
|
|
119
|
+
* const client = createTestClient({
|
|
120
|
+
* token: "t-admin",
|
|
121
|
+
* identities: {
|
|
122
|
+
* "t-admin": { user: { id: "admin", roles: [{ name: "administrator" }] }, grants: WILDCARD },
|
|
123
|
+
* "t-bob": { user: { id: "bob", roles: [{ name: "editor" }] }, grants: READ_ONLY },
|
|
124
|
+
* },
|
|
125
|
+
* });
|
|
126
|
+
* const bob = client.withConfig({ token: "t-bob" });
|
|
127
|
+
* // same store; bob's /users/me → bob; bob's ACL path → bob's grants
|
|
128
|
+
* ```
|
|
129
|
+
*
|
|
130
|
+
* A token-bound handle behaves exactly like one configured with
|
|
131
|
+
* `accessControl` derived from its identity (identity serving + grant
|
|
132
|
+
* gating; the ACL path defaults per `(projectId, dataset)`). Register
|
|
133
|
+
* more identities later with `addIdentity`.
|
|
134
|
+
*/
|
|
135
|
+
identities?: Record<string, TestClientIdentity>;
|
|
136
|
+
/**
|
|
137
|
+
* Token the root handle is bound to — must name an entry in
|
|
138
|
+
* `identities` (unknown tokens throw). Omit to leave the root handle
|
|
139
|
+
* identity-free (a bare factory for token-bound siblings): its
|
|
140
|
+
* `request` is undefined and its reads/writes are ungated. Mutually
|
|
141
|
+
* exclusive with `accessControl`.
|
|
142
|
+
*/
|
|
143
|
+
token?: string;
|
|
113
144
|
/**
|
|
114
145
|
* Pluggable write interceptor — runs on each write through the document
|
|
115
146
|
* mutation API (create/patch/delete/transaction; not the release/version
|
|
@@ -268,6 +299,8 @@ export declare type FakeMutation =
|
|
|
268
299
|
*
|
|
269
300
|
* - `seed`, `add`, `reset`, `snapshot`, `store` — bulk-load helpers,
|
|
270
301
|
* scoped to the current dataset.
|
|
302
|
+
* - `simulateConcurrentEdit()` — force the next N revision-guarded writes
|
|
303
|
+
* to a document to 409, for optimistic-locking retry tests.
|
|
271
304
|
* - `datasets()` — lists known dataset keys in this registry.
|
|
272
305
|
*
|
|
273
306
|
* Methods deliberately omitted:
|
|
@@ -657,7 +690,7 @@ export declare const SUPPORTED_SURFACE: readonly [
|
|
|
657
690
|
"releases",
|
|
658
691
|
"createVersion",
|
|
659
692
|
"unpublishVersion",
|
|
660
|
-
"request (only with accessControl)",
|
|
693
|
+
"request (only with accessControl or a bound token)",
|
|
661
694
|
"config",
|
|
662
695
|
"withConfig",
|
|
663
696
|
];
|
|
@@ -683,16 +716,12 @@ export declare interface TestClient {
|
|
|
683
716
|
options?: FetchOptions,
|
|
684
717
|
) => Promise<(T | null)[]>;
|
|
685
718
|
/**
|
|
686
|
-
* Sanity-style HTTP request hook. Present iff the
|
|
687
|
-
* created with `accessControl
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
* Test cases that want to exercise the engine's token-resolution
|
|
693
|
-
* path should configure `accessControl`; cases that want bench-style
|
|
694
|
-
* always-allow should leave it off and pass `access` through the
|
|
695
|
-
* bench.
|
|
719
|
+
* Sanity-style HTTP request hook. Present iff the handle has an
|
|
720
|
+
* acting identity — created with `accessControl`, or bound to a
|
|
721
|
+
* registered token (`identities` + `token`). The engine's access
|
|
722
|
+
* resolver pokes `/users/me` and the configured ACL path through
|
|
723
|
+
* this — without it the engine throws on any verb that needs an
|
|
724
|
+
* actor.
|
|
696
725
|
*/
|
|
697
726
|
request?: <T>(opts: {
|
|
698
727
|
url?: string;
|
|
@@ -750,11 +779,15 @@ export declare interface TestClient {
|
|
|
750
779
|
releaseId: string;
|
|
751
780
|
publishedId: string;
|
|
752
781
|
}) => Promise<ActionResult>;
|
|
753
|
-
/**
|
|
782
|
+
/**
|
|
783
|
+
* Current `(projectId, dataset)` and active perspective. `token` is
|
|
784
|
+
* present iff the handle is bound to a registered identity.
|
|
785
|
+
*/
|
|
754
786
|
config: () => {
|
|
755
787
|
projectId: string;
|
|
756
788
|
dataset: string;
|
|
757
789
|
perspective: Perspective;
|
|
790
|
+
token?: string;
|
|
758
791
|
};
|
|
759
792
|
/**
|
|
760
793
|
* Return a new client handle pointing at a different `(projectId, dataset)`
|
|
@@ -771,8 +804,18 @@ export declare interface TestClient {
|
|
|
771
804
|
* Rebind the acting identity (and its grants) on a sibling that shares
|
|
772
805
|
* the same store — the test analogue of a token-scoped client. Lets two
|
|
773
806
|
* actors write the same dataset, e.g. for `identity()`-based guards.
|
|
807
|
+
* Mutually exclusive with `token`.
|
|
774
808
|
*/
|
|
775
809
|
accessControl: TestClientAccessControl;
|
|
810
|
+
/**
|
|
811
|
+
* Bind the sibling to a token registered in the family's identity
|
|
812
|
+
* registry (`createTestClient({ identities })` / `addIdentity`): its
|
|
813
|
+
* `/users/me` serves that identity's user, its ACL path serves that
|
|
814
|
+
* identity's grants, and reads/writes are gated through them — the
|
|
815
|
+
* token determines identity, exactly like production. Unknown tokens
|
|
816
|
+
* throw. Mutually exclusive with `accessControl`.
|
|
817
|
+
*/
|
|
818
|
+
token: string;
|
|
776
819
|
}>,
|
|
777
820
|
) => TestClient;
|
|
778
821
|
/** Replace the current dataset with the given documents. */
|
|
@@ -785,6 +828,27 @@ export declare interface TestClient {
|
|
|
785
828
|
snapshot: () => readonly SanityDocument[];
|
|
786
829
|
/** Direct store access for the current dataset. */
|
|
787
830
|
store: () => TestStore;
|
|
831
|
+
/**
|
|
832
|
+
* Simulate `count` concurrent edits to `id` in the current dataset: the
|
|
833
|
+
* next `count` revision-guarded mutations (those passing `ifRevisionId`)
|
|
834
|
+
* to that document throw a 409-shaped conflict, as if another client
|
|
835
|
+
* committed between the caller's read and write. Mutations that don't
|
|
836
|
+
* pass `ifRevisionId` are unaffected.
|
|
837
|
+
*
|
|
838
|
+
* Reach for this when the code under test reads a document's `_rev` and
|
|
839
|
+
* commits with `ifRevisionId` internally — so you can't stage the race
|
|
840
|
+
* by mutating the document first (the code would just re-read the new
|
|
841
|
+
* revision and its commit would match). The counter is consumed one per
|
|
842
|
+
* guarded commit, so `count` lets a test drive a recover-after-N-losses
|
|
843
|
+
* path or exhaust a retry budget, deterministically and without relying
|
|
844
|
+
* on promise interleaving.
|
|
845
|
+
*
|
|
846
|
+
* Note: this forces the conflict *outcome* — it short-circuits the
|
|
847
|
+
* revision comparison rather than changing the stored `_rev`. The code
|
|
848
|
+
* under test sees the identical 409 error a real lost race produces; the
|
|
849
|
+
* stored revision is left untouched.
|
|
850
|
+
*/
|
|
851
|
+
simulateConcurrentEdit: (id: string, count?: number) => void;
|
|
788
852
|
/** List `(projectId, dataset)` pairs known in this registry. */
|
|
789
853
|
datasets: () => {
|
|
790
854
|
projectId: string;
|
|
@@ -800,6 +864,16 @@ export declare interface TestClient {
|
|
|
800
864
|
stub: (name: string, handler: unknown) => void;
|
|
801
865
|
/** The currently-registered stubs, keyed by member name. */
|
|
802
866
|
stubs: () => Record<string, unknown>;
|
|
867
|
+
/**
|
|
868
|
+
* Register (or upsert) a token→identity entry in the family's shared
|
|
869
|
+
* registry. Siblings already bound to `token` resolve lazily, so a
|
|
870
|
+
* re-registration takes effect on them immediately. Registering an
|
|
871
|
+
* identity does not bind it — pass `token` at `createTestClient` or
|
|
872
|
+
* `withConfig({ token })` to act as it.
|
|
873
|
+
*/
|
|
874
|
+
addIdentity: (token: string, identity: TestClientIdentity) => void;
|
|
875
|
+
/** Snapshot of the registered token→identity entries. */
|
|
876
|
+
identities: () => Record<string, TestClientIdentity>;
|
|
803
877
|
/**
|
|
804
878
|
* Every mutation committed through this client's registry, flattened
|
|
805
879
|
* and in order — `create` / `createOrReplace` / `createIfNotExists` /
|
|
@@ -844,6 +918,20 @@ export declare interface TestClientAccessControl {
|
|
|
844
918
|
aclPath?: string;
|
|
845
919
|
}
|
|
846
920
|
|
|
921
|
+
/**
|
|
922
|
+
* One entry in the token→identity registry (`createTestClient({ identities })`).
|
|
923
|
+
* A handle bound to the entry's token behaves exactly like one configured
|
|
924
|
+
* with `accessControl: { currentUser: user, grants }` — `/users/me` serves
|
|
925
|
+
* `user`, the default ACL path serves `grants`, and reads/writes are gated
|
|
926
|
+
* through them.
|
|
927
|
+
*/
|
|
928
|
+
export declare interface TestClientIdentity {
|
|
929
|
+
/** What `/users/me` returns for clients bound to this token. */
|
|
930
|
+
user: CurrentUser;
|
|
931
|
+
/** Grants served at the ACL path and enforced on reads/writes. */
|
|
932
|
+
grants: Grant[];
|
|
933
|
+
}
|
|
934
|
+
|
|
847
935
|
/**
|
|
848
936
|
* In-memory document store backing a TestClient. Closure-scoped — created
|
|
849
937
|
* fresh per test, no global state.
|
package/dist/index.js
CHANGED
|
@@ -59,16 +59,15 @@ async function enforceWriteAccess(args) {
|
|
|
59
59
|
function defaultAclPath(projectId, dataset) {
|
|
60
60
|
return `/projects/${projectId}/datasets/${dataset}/acl`;
|
|
61
61
|
}
|
|
62
|
-
function makeRequestImpl(
|
|
63
|
-
const aclPath = accessControl.aclPath ?? defaultAclPath(projectId, dataset);
|
|
62
|
+
function makeRequestImpl(getAccess, projectId, dataset) {
|
|
64
63
|
return async (opts) => {
|
|
65
|
-
const path = opts.uri ?? opts.url;
|
|
64
|
+
const accessControl = getAccess(), aclPath = accessControl.aclPath ?? defaultAclPath(projectId, dataset), path = opts.uri ?? opts.url;
|
|
66
65
|
if (path === "/users/me")
|
|
67
66
|
return accessControl.currentUser;
|
|
68
67
|
if (path === aclPath)
|
|
69
68
|
return accessControl.grants;
|
|
70
69
|
throw fakeError(
|
|
71
|
-
`request(): no handler for path "${path}". With
|
|
70
|
+
`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.`
|
|
72
71
|
);
|
|
73
72
|
};
|
|
74
73
|
}
|
|
@@ -688,7 +687,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
|
|
|
688
687
|
"releases",
|
|
689
688
|
"createVersion",
|
|
690
689
|
"unpublishVersion",
|
|
691
|
-
"request (only with accessControl)",
|
|
690
|
+
"request (only with accessControl or a bound token)",
|
|
692
691
|
"config",
|
|
693
692
|
"withConfig"
|
|
694
693
|
], UNSUPPORTED_MEMBERS = {
|
|
@@ -737,8 +736,8 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
|
|
|
737
736
|
workaround: "Out of scope \u2014 seed documents directly or stub the call site."
|
|
738
737
|
},
|
|
739
738
|
request: {
|
|
740
|
-
what: "generic HTTP request hook \u2014 only wired up when
|
|
741
|
-
workaround: "Create the client with `accessControl` to enable request() (it serves /users/me + the ACL path)
|
|
739
|
+
what: "generic HTTP request hook \u2014 only wired up when the handle has an acting identity",
|
|
740
|
+
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."
|
|
742
741
|
},
|
|
743
742
|
dryRun: {
|
|
744
743
|
what: "not a method \u2014 did you mean the `{ dryRun }` mutation option?",
|
|
@@ -854,12 +853,35 @@ const RECORDED_METHODS = /* @__PURE__ */ new Set([
|
|
|
854
853
|
"createVersion",
|
|
855
854
|
"unpublishVersion",
|
|
856
855
|
"request"
|
|
857
|
-
]), DEFAULT_PROJECT_ID = "test", DEFAULT_DATASET = "test"
|
|
856
|
+
]), DEFAULT_PROJECT_ID = "test", DEFAULT_DATASET = "test";
|
|
857
|
+
function requireIdentity(backend, token, site) {
|
|
858
|
+
if (backend.identities.has(token)) return;
|
|
859
|
+
const known = [...backend.identities.keys()];
|
|
860
|
+
throw fakeError(
|
|
861
|
+
`${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)."
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
function rejectAmbiguousIdentity(site, config) {
|
|
865
|
+
if (config.token !== void 0 && config.accessControl !== void 0)
|
|
866
|
+
throw fakeError(
|
|
867
|
+
`${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.`
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
const registryKey = (projectId, dataset) => `${projectId}:${dataset}`, parseRegistryKey = (key) => {
|
|
858
871
|
const [projectId, dataset] = key.split(":");
|
|
859
872
|
return { projectId, dataset };
|
|
860
873
|
};
|
|
861
874
|
function createTestClient(options = {}) {
|
|
862
|
-
const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = {
|
|
875
|
+
const defaultProjectId = options.projectId ?? DEFAULT_PROJECT_ID, defaultDataset = options.dataset ?? DEFAULT_DATASET, backend = {
|
|
876
|
+
registry: /* @__PURE__ */ new Map(),
|
|
877
|
+
log: [],
|
|
878
|
+
stubs: /* @__PURE__ */ new Map(),
|
|
879
|
+
calls: [],
|
|
880
|
+
concurrentEdits: /* @__PURE__ */ new Map(),
|
|
881
|
+
identities: new Map(Object.entries(options.identities ?? {}))
|
|
882
|
+
}, registry = backend.registry;
|
|
883
|
+
rejectAmbiguousIdentity("createTestClient()", options), options.token !== void 0 && requireIdentity(backend, options.token, "createTestClient()");
|
|
884
|
+
const ensureStore = (projectId, dataset) => {
|
|
863
885
|
const key = registryKey(projectId, dataset);
|
|
864
886
|
let store = registry.get(key);
|
|
865
887
|
return store === void 0 && (store = createTestStore(), registry.set(key, store)), store;
|
|
@@ -875,7 +897,8 @@ function createTestClient(options = {}) {
|
|
|
875
897
|
defaultDataset,
|
|
876
898
|
options.accessControl,
|
|
877
899
|
options.perspective ?? "raw",
|
|
878
|
-
options.mutationGuard
|
|
900
|
+
options.mutationGuard,
|
|
901
|
+
options.token
|
|
879
902
|
);
|
|
880
903
|
}
|
|
881
904
|
function dedupeIds(results) {
|
|
@@ -928,12 +951,23 @@ function toTxnMutations(operations) {
|
|
|
928
951
|
}
|
|
929
952
|
];
|
|
930
953
|
}
|
|
931
|
-
function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard) {
|
|
932
|
-
const { registry } = backend,
|
|
954
|
+
function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard, token) {
|
|
955
|
+
const { registry } = backend, activeAccess = () => {
|
|
956
|
+
if (token === void 0) return accessControl;
|
|
957
|
+
const identity = backend.identities.get(token);
|
|
958
|
+
if (identity === void 0)
|
|
959
|
+
throw fakeError(`no identity is registered for token "${token}" (was it removed?).`);
|
|
960
|
+
return { currentUser: identity.user, grants: identity.grants };
|
|
961
|
+
}, requireActiveAccess = () => {
|
|
962
|
+
const access = activeAccess();
|
|
963
|
+
if (access === void 0)
|
|
964
|
+
throw fakeError("this handle has no acting identity (no token or accessControl bound).");
|
|
965
|
+
return access;
|
|
966
|
+
}, store = (() => {
|
|
933
967
|
const key = registryKey(projectId, dataset);
|
|
934
968
|
let s = registry.get(key);
|
|
935
969
|
return s === void 0 && (s = createTestStore(), registry.set(key, s)), s;
|
|
936
|
-
})(), record = (transactionId, mutations) => {
|
|
970
|
+
})(), concurrentEditKey = (id) => registryKey(projectId, dataset) + " " + id, record = (transactionId, mutations) => {
|
|
937
971
|
backend.log.push({ transactionId, projectId, dataset, mutations });
|
|
938
972
|
}, writeResult = (documents, results, transactionId, opts) => formatMutationResult(
|
|
939
973
|
documents,
|
|
@@ -961,6 +995,11 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
|
|
|
961
995
|
};
|
|
962
996
|
}, checkRevision = (id, ifRevisionId) => {
|
|
963
997
|
if (ifRevisionId === void 0) return;
|
|
998
|
+
const editKey = concurrentEditKey(id), pendingEdits = backend.concurrentEdits.get(editKey) ?? 0;
|
|
999
|
+
if (pendingEdits > 0)
|
|
1000
|
+
throw backend.concurrentEdits.set(editKey, pendingEdits - 1), fakeError(
|
|
1001
|
+
`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.)`
|
|
1002
|
+
);
|
|
964
1003
|
const existing = store.get(id);
|
|
965
1004
|
if (existing === void 0)
|
|
966
1005
|
throw fakeError(
|
|
@@ -971,20 +1010,24 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
|
|
|
971
1010
|
`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.)`
|
|
972
1011
|
);
|
|
973
1012
|
}, gate = async (document, permission) => {
|
|
974
|
-
|
|
1013
|
+
const access = activeAccess();
|
|
1014
|
+
access !== void 0 && await enforceWriteAccess({
|
|
975
1015
|
document,
|
|
976
|
-
grants:
|
|
1016
|
+
grants: access.grants,
|
|
977
1017
|
permission,
|
|
978
|
-
userId:
|
|
1018
|
+
userId: access.currentUser.id
|
|
979
1019
|
});
|
|
980
1020
|
}, guardGate = async (action, before, after) => {
|
|
981
|
-
mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity:
|
|
982
|
-
}, canRead = async (document) =>
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1021
|
+
mutationGuard !== void 0 && await mutationGuard({ action, before, after, identity: activeAccess()?.currentUser.id });
|
|
1022
|
+
}, canRead = async (document) => {
|
|
1023
|
+
const access = activeAccess();
|
|
1024
|
+
return access === void 0 ? !0 : canAccess({
|
|
1025
|
+
document,
|
|
1026
|
+
grants: access.grants,
|
|
1027
|
+
permission: "read",
|
|
1028
|
+
userId: access.currentUser.id
|
|
1029
|
+
});
|
|
1030
|
+
}, applyPatch = async (documentId, ops, ifRev) => {
|
|
988
1031
|
const existing = store.get(documentId);
|
|
989
1032
|
if (existing === void 0)
|
|
990
1033
|
throw fakeError(
|
|
@@ -1060,7 +1103,7 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
|
|
|
1060
1103
|
);
|
|
1061
1104
|
}, accessVisibleDocs = async () => {
|
|
1062
1105
|
const all = store.all();
|
|
1063
|
-
if (
|
|
1106
|
+
if (activeAccess() === void 0) return all;
|
|
1064
1107
|
const out = [];
|
|
1065
1108
|
for (const doc of all)
|
|
1066
1109
|
await canRead(doc) && out.push(doc);
|
|
@@ -1215,26 +1258,39 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
|
|
|
1215
1258
|
versionId: `versions.${releaseId}.${publishedId}`,
|
|
1216
1259
|
publishedId
|
|
1217
1260
|
}),
|
|
1218
|
-
...accessControl !== void 0 ? { request: makeRequestImpl(
|
|
1219
|
-
config: () => ({ projectId, dataset, perspective }),
|
|
1220
|
-
withConfig: (config) =>
|
|
1221
|
-
backend,
|
|
1222
|
-
config.
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1261
|
+
...token !== void 0 || accessControl !== void 0 ? { request: makeRequestImpl(requireActiveAccess, projectId, dataset) } : {},
|
|
1262
|
+
config: () => ({ projectId, dataset, perspective, ...token !== void 0 ? { token } : {} }),
|
|
1263
|
+
withConfig: (config) => {
|
|
1264
|
+
rejectAmbiguousIdentity("withConfig()", config), config.token !== void 0 && requireIdentity(backend, config.token, "withConfig()");
|
|
1265
|
+
const rebinds = config.token !== void 0 || config.accessControl !== void 0;
|
|
1266
|
+
return makeHandle(
|
|
1267
|
+
backend,
|
|
1268
|
+
config.projectId ?? projectId,
|
|
1269
|
+
config.dataset ?? dataset,
|
|
1270
|
+
rebinds ? config.accessControl : accessControl,
|
|
1271
|
+
config.perspective ?? perspective,
|
|
1272
|
+
mutationGuard,
|
|
1273
|
+
rebinds ? config.token : token
|
|
1274
|
+
);
|
|
1275
|
+
},
|
|
1228
1276
|
seed: (documents) => store.seed(documents),
|
|
1229
1277
|
add: (documents) => store.add(documents),
|
|
1230
1278
|
reset: () => store.reset(),
|
|
1231
1279
|
snapshot: () => store.snapshot(),
|
|
1232
1280
|
store: () => store,
|
|
1281
|
+
simulateConcurrentEdit: (id, count = 1) => {
|
|
1282
|
+
const editKey = concurrentEditKey(id);
|
|
1283
|
+
backend.concurrentEdits.set(editKey, (backend.concurrentEdits.get(editKey) ?? 0) + count);
|
|
1284
|
+
},
|
|
1233
1285
|
datasets: () => [...registry.keys()].map(parseRegistryKey),
|
|
1234
1286
|
stub: (name, handler) => {
|
|
1235
1287
|
backend.stubs.set(name, handler);
|
|
1236
1288
|
},
|
|
1237
1289
|
stubs: () => Object.fromEntries(backend.stubs),
|
|
1290
|
+
addIdentity: (identityToken, identity) => {
|
|
1291
|
+
backend.identities.set(identityToken, identity);
|
|
1292
|
+
},
|
|
1293
|
+
identities: () => Object.fromEntries(backend.identities),
|
|
1238
1294
|
mutations: () => backend.log.flatMap((entry) => entry.mutations),
|
|
1239
1295
|
transactions: () => [...backend.log],
|
|
1240
1296
|
clearLog: () => {
|