@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.
@@ -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
- 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 };
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
- const hasCustomOrdering = store.queryset._orderBy && store.queryset._orderBy.length > 0;
49
- if (hasCustomOrdering) {
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>;
@@ -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.32",
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