@statezero/core 0.2.32 → 0.2.34
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/dist/flavours/django/makeApiCall.js +4 -0
- package/dist/flavours/django/manager.d.ts +6 -0
- package/dist/flavours/django/manager.js +8 -0
- package/dist/flavours/django/model.d.ts +4 -0
- package/dist/flavours/django/model.js +6 -0
- package/dist/flavours/django/queryExecutor.d.ts +9 -0
- package/dist/flavours/django/queryExecutor.js +14 -0
- package/dist/flavours/django/querySet.d.ts +8 -0
- package/dist/flavours/django/querySet.js +15 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -1
- package/dist/syncEngine/stores/operationEventHandlers.js +4 -2
- package/dist/testing.d.ts +63 -0
- package/dist/testing.js +175 -0
- package/package.json +5 -1
- package/readme.md +95 -0
|
@@ -146,6 +146,10 @@ export async function makeApiCall(querySet, operationType, args = {}, operationI
|
|
|
146
146
|
return response.data;
|
|
147
147
|
}
|
|
148
148
|
catch (error) {
|
|
149
|
+
if (error?.code === "ECONNREFUSED") {
|
|
150
|
+
const hint = "Connection refused. If you're running tests, start the test server with `python manage.py statezero_testserver`.";
|
|
151
|
+
throw new Error(`${hint} (${finalUrl})`);
|
|
152
|
+
}
|
|
149
153
|
if (error.response && error.response.data) {
|
|
150
154
|
const parsedError = parseStateZeroError(error.response.data);
|
|
151
155
|
if (Error.captureStackTrace) {
|
|
@@ -56,6 +56,12 @@ export class Manager {
|
|
|
56
56
|
* @returns {QuerySet} A new QuerySet instance.
|
|
57
57
|
*/
|
|
58
58
|
all(): QuerySet<any>;
|
|
59
|
+
/**
|
|
60
|
+
* Returns a QuerySet that executes remotely without updating local stores.
|
|
61
|
+
*
|
|
62
|
+
* @returns {QuerySet} A remote-only QuerySet instance.
|
|
63
|
+
*/
|
|
64
|
+
remote(): QuerySet<any>;
|
|
59
65
|
/**
|
|
60
66
|
* Deletes records in the QuerySet.
|
|
61
67
|
*
|
|
@@ -71,6 +71,14 @@ export class Manager {
|
|
|
71
71
|
all() {
|
|
72
72
|
return this.newQuerySet();
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Returns a QuerySet that executes remotely without updating local stores.
|
|
76
|
+
*
|
|
77
|
+
* @returns {QuerySet} A remote-only QuerySet instance.
|
|
78
|
+
*/
|
|
79
|
+
remote() {
|
|
80
|
+
return this.newQuerySet().remote();
|
|
81
|
+
}
|
|
74
82
|
/**
|
|
75
83
|
* Deletes records in the QuerySet.
|
|
76
84
|
*
|
|
@@ -22,6 +22,10 @@ export class Model {
|
|
|
22
22
|
* @param {any} [data={}] - The data for initialization.
|
|
23
23
|
*/
|
|
24
24
|
static instanceCache: Map<any, any>;
|
|
25
|
+
/**
|
|
26
|
+
* Remote-only manager that skips local store updates.
|
|
27
|
+
*/
|
|
28
|
+
static get remote(): any;
|
|
25
29
|
/**
|
|
26
30
|
* Instantiate from pk using queryset scoped singletons
|
|
27
31
|
*/
|
|
@@ -36,6 +36,12 @@ export class Model {
|
|
|
36
36
|
this.__version = 0;
|
|
37
37
|
return wrapReactiveModel(this);
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Remote-only manager that skips local store updates.
|
|
41
|
+
*/
|
|
42
|
+
static get remote() {
|
|
43
|
+
return this.objects.remote();
|
|
44
|
+
}
|
|
39
45
|
touch() {
|
|
40
46
|
this.__version++;
|
|
41
47
|
}
|
|
@@ -137,4 +137,13 @@ export class QueryExecutor {
|
|
|
137
137
|
* @returns {Promise<any>} The operation result.
|
|
138
138
|
*/
|
|
139
139
|
static execute(querySet: QuerySet, operationType?: string, args?: Object): Promise<any>;
|
|
140
|
+
/**
|
|
141
|
+
* Executes a query operation remotely without updating local stores.
|
|
142
|
+
*
|
|
143
|
+
* @param {QuerySet} querySet - The QuerySet to execute.
|
|
144
|
+
* @param {string} operationType - The operation type to perform.
|
|
145
|
+
* @param {Object} args - Additional arguments for the operation.
|
|
146
|
+
* @returns {Promise<any>} The raw API response data.
|
|
147
|
+
*/
|
|
148
|
+
static executeRemote(querySet: QuerySet, operationType?: string, args?: Object): Promise<any>;
|
|
140
149
|
}
|
|
@@ -539,6 +539,9 @@ export class QueryExecutor {
|
|
|
539
539
|
* @returns {Promise<any>} The operation result.
|
|
540
540
|
*/
|
|
541
541
|
static execute(querySet, operationType = "list", args = {}) {
|
|
542
|
+
if (querySet._remoteOnly) {
|
|
543
|
+
return this.executeRemote(querySet, operationType, args);
|
|
544
|
+
}
|
|
542
545
|
// execute the query and return the result
|
|
543
546
|
switch (operationType) {
|
|
544
547
|
case "get":
|
|
@@ -573,4 +576,15 @@ export class QueryExecutor {
|
|
|
573
576
|
}
|
|
574
577
|
throw new Error(`Invalid operation type: ${operationType}`);
|
|
575
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* Executes a query operation remotely without updating local stores.
|
|
581
|
+
*
|
|
582
|
+
* @param {QuerySet} querySet - The QuerySet to execute.
|
|
583
|
+
* @param {string} operationType - The operation type to perform.
|
|
584
|
+
* @param {Object} args - Additional arguments for the operation.
|
|
585
|
+
* @returns {Promise<any>} The raw API response data.
|
|
586
|
+
*/
|
|
587
|
+
static executeRemote(querySet, operationType = "list", args = {}) {
|
|
588
|
+
return makeApiCall(querySet, operationType, args);
|
|
589
|
+
}
|
|
576
590
|
}
|
|
@@ -41,6 +41,7 @@ export class QuerySet<T> {
|
|
|
41
41
|
_serializerOptions: any;
|
|
42
42
|
_materialized: boolean;
|
|
43
43
|
_optimisticOnly: any;
|
|
44
|
+
_remoteOnly: any;
|
|
44
45
|
__uuid: string;
|
|
45
46
|
__parent: any;
|
|
46
47
|
__reactivityId: any;
|
|
@@ -210,6 +211,13 @@ export class QuerySet<T> {
|
|
|
210
211
|
* @returns {QuerySet} A new QuerySet with optimistic-only mode enabled.
|
|
211
212
|
*/
|
|
212
213
|
get optimistic(): QuerySet<any>;
|
|
214
|
+
/**
|
|
215
|
+
* Returns a QuerySet marked as remote-only, meaning operations will
|
|
216
|
+
* hit the backend but skip local store updates and live thenables.
|
|
217
|
+
*
|
|
218
|
+
* @returns {QuerySet} A new QuerySet with remote-only mode enabled.
|
|
219
|
+
*/
|
|
220
|
+
remote(): QuerySet<any>;
|
|
213
221
|
/**
|
|
214
222
|
* Creates a new record in the QuerySet.
|
|
215
223
|
* @param {Object} data - The fields and values for the new record.
|
|
@@ -36,6 +36,7 @@ export class QuerySet {
|
|
|
36
36
|
this._serializerOptions = config.serializerOptions || {};
|
|
37
37
|
this._materialized = config.materialized || false;
|
|
38
38
|
this._optimisticOnly = config.optimisticOnly || false;
|
|
39
|
+
this._remoteOnly = config.remoteOnly || false;
|
|
39
40
|
this.__uuid = v7();
|
|
40
41
|
this.__parent = parent;
|
|
41
42
|
this.__reactivityId = parent?.__reactivityId;
|
|
@@ -57,6 +58,7 @@ export class QuerySet {
|
|
|
57
58
|
serializerOptions: { ...this._serializerOptions },
|
|
58
59
|
materialized: this._materialized,
|
|
59
60
|
optimisticOnly: this._optimisticOnly,
|
|
61
|
+
remoteOnly: this._remoteOnly,
|
|
60
62
|
}, this);
|
|
61
63
|
}
|
|
62
64
|
get semanticKey() {
|
|
@@ -480,6 +482,18 @@ export class QuerySet {
|
|
|
480
482
|
get optimistic() {
|
|
481
483
|
return this._optimistic();
|
|
482
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* Returns a QuerySet marked as remote-only, meaning operations will
|
|
487
|
+
* hit the backend but skip local store updates and live thenables.
|
|
488
|
+
*
|
|
489
|
+
* @returns {QuerySet} A new QuerySet with remote-only mode enabled.
|
|
490
|
+
*/
|
|
491
|
+
remote() {
|
|
492
|
+
return new QuerySet(this.ModelClass, {
|
|
493
|
+
...this._getConfig(),
|
|
494
|
+
remoteOnly: true,
|
|
495
|
+
}, this);
|
|
496
|
+
}
|
|
483
497
|
/**
|
|
484
498
|
* Creates a new record in the QuerySet.
|
|
485
499
|
* @param {Object} data - The fields and values for the new record.
|
|
@@ -682,6 +696,7 @@ export class QuerySet {
|
|
|
682
696
|
initialQueryset: this._initialQueryset,
|
|
683
697
|
serializerOptions: this._serializerOptions,
|
|
684
698
|
optimisticOnly: this._optimisticOnly,
|
|
699
|
+
remoteOnly: this._remoteOnly,
|
|
685
700
|
};
|
|
686
701
|
}
|
|
687
702
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -38,4 +38,9 @@ import { wrapReactiveModel } from "./reactiveAdaptor.js";
|
|
|
38
38
|
import { wrapReactiveQuerySet } from "./reactiveAdaptor.js";
|
|
39
39
|
import { serializeActionPayload } from "./flavours/django/serializers.js";
|
|
40
40
|
import { onStateZeroError } from "./errorHandler.js";
|
|
41
|
-
|
|
41
|
+
import { createTestConfig } from "./testing.js";
|
|
42
|
+
import { setupTestStateZero } from "./testing.js";
|
|
43
|
+
import { createActionMocker } from "./testing.js";
|
|
44
|
+
import { seedRemote } from "./testing.js";
|
|
45
|
+
import { resetRemote } from "./testing.js";
|
|
46
|
+
export { EventType, PusherEventReceiver, setEventReceiver, getEventReceiver, setNamespaceResolver, setupStateZero, resetStateZero, FileObject, querysetStoreRegistry, modelStoreRegistry, metricRegistry, syncManager, Operation, operationRegistry, Q, StateZeroError, ValidationError, DoesNotExist, PermissionDenied, MultipleObjectsReturned, ASTValidationError, ConfigError, parseStateZeroError, QuerySet, Manager, ResultTuple, Model, setConfig, getConfig, setBackendConfig, initializeEventReceiver, configInstance, getModelClass, initEventHandler, cleanupEventHandler, setAdapters, wrapReactiveModel, wrapReactiveQuerySet, serializeActionPayload, onStateZeroError, createTestConfig, setupTestStateZero, createActionMocker, seedRemote, resetRemote };
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { syncManager } from "./syncEngine/sync.js";
|
|
|
21
21
|
import { initEventHandler, cleanupEventHandler, } from "./syncEngine/stores/operationEventHandlers.js";
|
|
22
22
|
import { resetStateZero } from "./reset.js";
|
|
23
23
|
import { onStateZeroError } from "./errorHandler.js";
|
|
24
|
+
import { createTestConfig, setupTestStateZero, createActionMocker, seedRemote, resetRemote, } from "./testing.js";
|
|
24
25
|
// Explicitly export everything
|
|
25
26
|
export {
|
|
26
27
|
// Core event receivers
|
|
@@ -42,4 +43,6 @@ initEventHandler, cleanupEventHandler, setAdapters, wrapReactiveModel, wrapReact
|
|
|
42
43
|
// Action utilities
|
|
43
44
|
serializeActionPayload,
|
|
44
45
|
// Error handling
|
|
45
|
-
onStateZeroError,
|
|
46
|
+
onStateZeroError,
|
|
47
|
+
// Testing helpers
|
|
48
|
+
createTestConfig, setupTestStateZero, createActionMocker, seedRemote, resetRemote, };
|
|
@@ -45,8 +45,10 @@ function routeCreateOperation(operation, applyAction) {
|
|
|
45
45
|
const currentCount = store.groundTruthPks?.length || 0;
|
|
46
46
|
// At capacity: check if ordering could affect position
|
|
47
47
|
if (currentCount >= limit) {
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// Check explicit ordering on queryset OR implicit ordering from Django Meta
|
|
49
|
+
const hasExplicitOrdering = store.queryset._orderBy && store.queryset._orderBy.length > 0;
|
|
50
|
+
const hasImplicitOrdering = (modelClass.schema?.default_ordering?.length || 0) > 0;
|
|
51
|
+
if (hasExplicitOrdering || hasImplicitOrdering) {
|
|
50
52
|
// With ordering, new item could displace existing items - can't know without server
|
|
51
53
|
operationRegistry.setQuerysetState(operation.operationId, semanticKey, OperationMembership.MAYBE);
|
|
52
54
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export function createTestConfig({ apiUrl, backendKey, generatedTypesDir, generatedActionsDir, getAuthHeaders, testAuthUserId, testAuthHeaders, testAuthHeaderName, eventsType, fileRootURL, fileUploadMode, }: {
|
|
2
|
+
apiUrl: any;
|
|
3
|
+
backendKey?: string | undefined;
|
|
4
|
+
generatedTypesDir?: string | undefined;
|
|
5
|
+
generatedActionsDir: any;
|
|
6
|
+
getAuthHeaders: any;
|
|
7
|
+
testAuthUserId: any;
|
|
8
|
+
testAuthHeaders: any;
|
|
9
|
+
testAuthHeaderName?: string | undefined;
|
|
10
|
+
eventsType?: string | undefined;
|
|
11
|
+
fileRootURL: any;
|
|
12
|
+
fileUploadMode: any;
|
|
13
|
+
}): {
|
|
14
|
+
config: {
|
|
15
|
+
backendConfigs: {
|
|
16
|
+
[backendKey]: {
|
|
17
|
+
API_URL: any;
|
|
18
|
+
GENERATED_TYPES_DIR: string;
|
|
19
|
+
getAuthHeaders: () => any;
|
|
20
|
+
events: {
|
|
21
|
+
type: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
testHeaders: {
|
|
27
|
+
setSeeding(enabled: any): void;
|
|
28
|
+
setReset(enabled: any): void;
|
|
29
|
+
setAuthUserId(userId: any): void;
|
|
30
|
+
withSeeding(fn: any): any;
|
|
31
|
+
withReset(fn: any): any;
|
|
32
|
+
withAuthUserId(userId: any, fn: any): any;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
export function setupTestStateZero({ apiUrl, backendKey, generatedTypesDir, generatedActionsDir, getAuthHeaders, testAuthUserId, testAuthHeaders, testAuthHeaderName, eventsType, fileRootURL, fileUploadMode, getModelClass, adapters, }: {
|
|
36
|
+
apiUrl: any;
|
|
37
|
+
backendKey?: string | undefined;
|
|
38
|
+
generatedTypesDir?: string | undefined;
|
|
39
|
+
generatedActionsDir: any;
|
|
40
|
+
getAuthHeaders: any;
|
|
41
|
+
testAuthUserId: any;
|
|
42
|
+
testAuthHeaders: any;
|
|
43
|
+
testAuthHeaderName: any;
|
|
44
|
+
eventsType?: string | undefined;
|
|
45
|
+
fileRootURL: any;
|
|
46
|
+
fileUploadMode: any;
|
|
47
|
+
getModelClass: any;
|
|
48
|
+
adapters: any;
|
|
49
|
+
}): {
|
|
50
|
+
setSeeding(enabled: any): void;
|
|
51
|
+
setReset(enabled: any): void;
|
|
52
|
+
setAuthUserId(userId: any): void;
|
|
53
|
+
withSeeding(fn: any): any;
|
|
54
|
+
withReset(fn: any): any;
|
|
55
|
+
withAuthUserId(userId: any, fn: any): any;
|
|
56
|
+
};
|
|
57
|
+
export function createActionMocker(actionRegistry: any): {
|
|
58
|
+
mock(actionName: any, handler: any, backendKey?: string): void;
|
|
59
|
+
restore(actionName: any, backendKey?: string): void;
|
|
60
|
+
restoreAll(): void;
|
|
61
|
+
};
|
|
62
|
+
export function seedRemote(testHeaders: any, seedFn: any): Promise<any>;
|
|
63
|
+
export function resetRemote(testHeaders: any, resetFn: any): Promise<any>;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { setupStateZero } from "./setup.js";
|
|
2
|
+
function _normalizeHeaders(headers) {
|
|
3
|
+
return headers && typeof headers === "object" ? { ...headers } : {};
|
|
4
|
+
}
|
|
5
|
+
function _withFlag(state, key, fn) {
|
|
6
|
+
const previous = state[key];
|
|
7
|
+
state[key] = true;
|
|
8
|
+
try {
|
|
9
|
+
const result = fn();
|
|
10
|
+
if (result && typeof result.then === "function") {
|
|
11
|
+
return result.finally(() => {
|
|
12
|
+
state[key] = previous;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
state[key] = previous;
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
state[key] = previous;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function createTestConfig({ apiUrl, backendKey = "default", generatedTypesDir = "./src/models/", generatedActionsDir, getAuthHeaders, testAuthUserId, testAuthHeaders, testAuthHeaderName = "X-TEST-USER-ID", eventsType = "none", fileRootURL, fileUploadMode, }) {
|
|
24
|
+
const state = {
|
|
25
|
+
seeding: false,
|
|
26
|
+
reset: false,
|
|
27
|
+
authUserId: undefined,
|
|
28
|
+
};
|
|
29
|
+
const testHeaders = {
|
|
30
|
+
setSeeding(enabled) {
|
|
31
|
+
state.seeding = Boolean(enabled);
|
|
32
|
+
},
|
|
33
|
+
setReset(enabled) {
|
|
34
|
+
state.reset = Boolean(enabled);
|
|
35
|
+
},
|
|
36
|
+
setAuthUserId(userId) {
|
|
37
|
+
state.authUserId = userId;
|
|
38
|
+
},
|
|
39
|
+
withSeeding(fn) {
|
|
40
|
+
return _withFlag(state, "seeding", fn);
|
|
41
|
+
},
|
|
42
|
+
withReset(fn) {
|
|
43
|
+
return _withFlag(state, "reset", fn);
|
|
44
|
+
},
|
|
45
|
+
withAuthUserId(userId, fn) {
|
|
46
|
+
const previous = state.authUserId;
|
|
47
|
+
state.authUserId = userId;
|
|
48
|
+
try {
|
|
49
|
+
const result = fn();
|
|
50
|
+
if (result && typeof result.then === "function") {
|
|
51
|
+
return result.finally(() => {
|
|
52
|
+
state.authUserId = previous;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
state.authUserId = previous;
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
state.authUserId = previous;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const wrappedGetAuthHeaders = () => {
|
|
65
|
+
const headers = _normalizeHeaders(getAuthHeaders ? getAuthHeaders() : {});
|
|
66
|
+
const extraAuthHeaders = typeof testAuthHeaders === "function"
|
|
67
|
+
? testAuthHeaders()
|
|
68
|
+
: testAuthHeaders;
|
|
69
|
+
if (extraAuthHeaders && typeof extraAuthHeaders === "object") {
|
|
70
|
+
Object.assign(headers, extraAuthHeaders);
|
|
71
|
+
}
|
|
72
|
+
const activeUserId = state.authUserId ?? testAuthUserId;
|
|
73
|
+
if (activeUserId !== undefined && activeUserId !== null) {
|
|
74
|
+
headers[testAuthHeaderName] = String(activeUserId);
|
|
75
|
+
}
|
|
76
|
+
if (state.seeding) {
|
|
77
|
+
headers["X-TEST-SEEDING"] = "1";
|
|
78
|
+
}
|
|
79
|
+
if (state.reset) {
|
|
80
|
+
headers["X-TEST-RESET"] = "1";
|
|
81
|
+
}
|
|
82
|
+
return headers;
|
|
83
|
+
};
|
|
84
|
+
const backendConfig = {
|
|
85
|
+
API_URL: apiUrl,
|
|
86
|
+
GENERATED_TYPES_DIR: generatedTypesDir,
|
|
87
|
+
getAuthHeaders: wrappedGetAuthHeaders,
|
|
88
|
+
events: { type: eventsType },
|
|
89
|
+
};
|
|
90
|
+
if (generatedActionsDir) {
|
|
91
|
+
backendConfig.GENERATED_ACTIONS_DIR = generatedActionsDir;
|
|
92
|
+
}
|
|
93
|
+
if (fileRootURL) {
|
|
94
|
+
backendConfig.fileRootURL = fileRootURL;
|
|
95
|
+
}
|
|
96
|
+
if (fileUploadMode) {
|
|
97
|
+
backendConfig.fileUploadMode = fileUploadMode;
|
|
98
|
+
}
|
|
99
|
+
const config = {
|
|
100
|
+
backendConfigs: {
|
|
101
|
+
[backendKey]: backendConfig,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
return { config, testHeaders };
|
|
105
|
+
}
|
|
106
|
+
export function setupTestStateZero({ apiUrl, backendKey = "default", generatedTypesDir = "./src/models/", generatedActionsDir, getAuthHeaders, testAuthUserId, testAuthHeaders, testAuthHeaderName, eventsType = "none", fileRootURL, fileUploadMode, getModelClass, adapters, }) {
|
|
107
|
+
const { config, testHeaders } = createTestConfig({
|
|
108
|
+
apiUrl,
|
|
109
|
+
backendKey,
|
|
110
|
+
generatedTypesDir,
|
|
111
|
+
generatedActionsDir,
|
|
112
|
+
getAuthHeaders,
|
|
113
|
+
testAuthUserId,
|
|
114
|
+
testAuthHeaders,
|
|
115
|
+
testAuthHeaderName,
|
|
116
|
+
eventsType,
|
|
117
|
+
fileRootURL,
|
|
118
|
+
fileUploadMode,
|
|
119
|
+
});
|
|
120
|
+
setupStateZero(config, getModelClass, adapters);
|
|
121
|
+
return testHeaders;
|
|
122
|
+
}
|
|
123
|
+
export function createActionMocker(actionRegistry) {
|
|
124
|
+
const original = new Map();
|
|
125
|
+
function resolveRegistry(backendKey) {
|
|
126
|
+
if (!actionRegistry) {
|
|
127
|
+
throw new Error("actionRegistry is required");
|
|
128
|
+
}
|
|
129
|
+
return actionRegistry[backendKey] || actionRegistry;
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
mock(actionName, handler, backendKey = "default") {
|
|
133
|
+
const registry = resolveRegistry(backendKey);
|
|
134
|
+
if (!registry || !registry[actionName]) {
|
|
135
|
+
throw new Error(`Action '${actionName}' not found in registry`);
|
|
136
|
+
}
|
|
137
|
+
const key = `${backendKey}:${actionName}`;
|
|
138
|
+
if (!original.has(key)) {
|
|
139
|
+
original.set(key, registry[actionName]);
|
|
140
|
+
}
|
|
141
|
+
registry[actionName] = handler;
|
|
142
|
+
},
|
|
143
|
+
restore(actionName, backendKey = "default") {
|
|
144
|
+
const registry = resolveRegistry(backendKey);
|
|
145
|
+
const key = `${backendKey}:${actionName}`;
|
|
146
|
+
if (!original.has(key)) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
registry[actionName] = original.get(key);
|
|
150
|
+
original.delete(key);
|
|
151
|
+
},
|
|
152
|
+
restoreAll() {
|
|
153
|
+
for (const [key, fn] of original.entries()) {
|
|
154
|
+
const [backendKey, actionName] = key.split(":");
|
|
155
|
+
const registry = resolveRegistry(backendKey);
|
|
156
|
+
if (registry && actionName in registry) {
|
|
157
|
+
registry[actionName] = fn;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
original.clear();
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export async function seedRemote(testHeaders, seedFn) {
|
|
165
|
+
if (!testHeaders || typeof testHeaders.withSeeding !== "function") {
|
|
166
|
+
throw new Error("seedRemote requires testHeaders from createTestConfig/setupTestStateZero");
|
|
167
|
+
}
|
|
168
|
+
return testHeaders.withSeeding(seedFn);
|
|
169
|
+
}
|
|
170
|
+
export async function resetRemote(testHeaders, resetFn) {
|
|
171
|
+
if (!testHeaders || typeof testHeaders.withReset !== "function") {
|
|
172
|
+
throw new Error("resetRemote requires testHeaders from createTestConfig/setupTestStateZero");
|
|
173
|
+
}
|
|
174
|
+
return testHeaders.withReset(resetFn);
|
|
175
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statezero/core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.34",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"module": "ESNext",
|
|
6
6
|
"description": "The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate",
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"import": "./dist/vue-entry.js",
|
|
27
27
|
"require": "./dist/vue-entry.js"
|
|
28
28
|
},
|
|
29
|
+
"./testing": {
|
|
30
|
+
"import": "./dist/testing.js",
|
|
31
|
+
"require": "./dist/testing.js"
|
|
32
|
+
},
|
|
29
33
|
"./dist/*": "./dist/*"
|
|
30
34
|
},
|
|
31
35
|
"scripts": {
|
package/readme.md
CHANGED
|
@@ -174,6 +174,101 @@ const workTodos = Todo.objects.filter({
|
|
|
174
174
|
});
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
+
## Testing (Backend-Mode)
|
|
178
|
+
|
|
179
|
+
StateZero supports frontend tests that run against a real Django test server (no test-only views).
|
|
180
|
+
You opt-in via a test-only middleware that temporarily relaxes permissions and silences events
|
|
181
|
+
when a request includes special headers.
|
|
182
|
+
|
|
183
|
+
### Backend Setup (Django Test Settings)
|
|
184
|
+
|
|
185
|
+
Add the test middleware and enable test mode in your **test settings**:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
# tests/settings.py
|
|
189
|
+
STATEZERO_TEST_MODE = True
|
|
190
|
+
STATEZERO_TEST_SEEDING_SILENT = True # default behavior, silences events during seeding
|
|
191
|
+
|
|
192
|
+
MIDDLEWARE = [
|
|
193
|
+
# ...
|
|
194
|
+
"statezero.adaptors.django.testing.TestSeedingMiddleware",
|
|
195
|
+
# ...
|
|
196
|
+
]
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Behavior:
|
|
200
|
+
- `X-TEST-SEEDING: 1` → temporarily allows all permissions for the request
|
|
201
|
+
- `X-TEST-RESET: 1` → deletes all registered StateZero models for the request
|
|
202
|
+
|
|
203
|
+
Auth note:
|
|
204
|
+
- Test mode does **not** bypass authentication. If you use DRF TokenAuth, you must either
|
|
205
|
+
create a real token for tests, or add a test-only auth class/middleware that accepts
|
|
206
|
+
a header like `X-TEST-USER-ID` and returns a test user.
|
|
207
|
+
|
|
208
|
+
Start the test server:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
python manage.py statezero_testserver --addrport 8000
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Optional request hook:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
# tests/settings.py
|
|
218
|
+
STATEZERO_TEST_REQUEST_CONTEXT = "myapp.test_utils.statezero_test_context"
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Your factory should accept the request and return a context manager. This allows
|
|
222
|
+
libraries like django-ai-first to wrap each test request (e.g., time control).
|
|
223
|
+
|
|
224
|
+
### Frontend Setup (Vue / JS)
|
|
225
|
+
|
|
226
|
+
Use the testing helpers and the `remote` manager to call the backend directly without local updates.
|
|
227
|
+
|
|
228
|
+
```javascript
|
|
229
|
+
import {
|
|
230
|
+
setupTestStateZero,
|
|
231
|
+
seedRemote,
|
|
232
|
+
resetRemote,
|
|
233
|
+
createActionMocker,
|
|
234
|
+
} from "@statezero/core/testing";
|
|
235
|
+
import { getModelClass } from "../model-registry";
|
|
236
|
+
import { ACTION_REGISTRY } from "../action-registry";
|
|
237
|
+
import { Todo } from "../models/default/django_app/todo";
|
|
238
|
+
import { vueAdapters } from "./statezero-adapters";
|
|
239
|
+
|
|
240
|
+
const testHeaders = setupTestStateZero({
|
|
241
|
+
apiUrl: "http://localhost:8000/statezero",
|
|
242
|
+
getModelClass,
|
|
243
|
+
adapters: vueAdapters,
|
|
244
|
+
// Optional: test-only force-auth header support (backend must opt in)
|
|
245
|
+
// testAuthUserId: 1,
|
|
246
|
+
// testAuthHeaderName: "X-TEST-USER-ID",
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Reset: deletes all registered StateZero models on the backend
|
|
250
|
+
await resetRemote(testHeaders, () => Todo.remote.delete());
|
|
251
|
+
|
|
252
|
+
// Seed: run standard ORM writes with X-TEST-SEEDING enabled
|
|
253
|
+
await seedRemote(testHeaders, () =>
|
|
254
|
+
Todo.remote.create({ title: "Seeded todo" })
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// Optional: temporarily force-auth as a test user for a block
|
|
258
|
+
// await testHeaders.withAuthUserId(1, () =>
|
|
259
|
+
// Todo.remote.create({ title: "Seeded as user 1" })
|
|
260
|
+
// );
|
|
261
|
+
|
|
262
|
+
// Action mocking for frontend tests
|
|
263
|
+
const actionMocker = createActionMocker(ACTION_REGISTRY);
|
|
264
|
+
actionMocker.mock("send_notification", async () => ({ ok: true }));
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Notes:
|
|
268
|
+
- `Model.remote` (or `Model.objects.remote()`) uses the normal ORM AST/serializers,
|
|
269
|
+
but **skips local store updates** and **returns raw backend responses**.
|
|
270
|
+
- These helpers are intended for tests that run against a live Django test server.
|
|
271
|
+
|
|
177
272
|
## Installation
|
|
178
273
|
|
|
179
274
|
### Backend
|