@colixsystems/datastore-client 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 +94 -34
- package/dist/client.js +364 -93
- package/dist/client.test.js +298 -63
- package/dist/index.d.ts +133 -78
- package/package.json +2 -2
package/dist/client.test.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
// Wire-contract tests for createDatastoreClient. A capturing fetch
|
|
2
|
-
// implementation records the method + URL each namespace method emits
|
|
3
|
-
// we lock the client to the backend's real REST surface
|
|
4
|
-
//
|
|
2
|
+
// implementation records the method + URL + body each namespace method emits
|
|
3
|
+
// so we lock the client to the backend's real snake_case REST surface
|
|
4
|
+
// (REQ-GEN-09). These run under `node --test` with NO npm install.
|
|
5
5
|
|
|
6
6
|
import { test } from "node:test";
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
8
|
import { createDatastoreClient } from "./client.js";
|
|
9
9
|
|
|
10
|
-
function makeClient(captured, responseBody = { data: [], meta: {} }) {
|
|
10
|
+
function makeClient(captured, responseBody = { data: [], meta: {} }, extra = {}) {
|
|
11
11
|
const fetchImpl = async (url, init) => {
|
|
12
12
|
captured.url = url;
|
|
13
13
|
captured.method = init.method;
|
|
14
14
|
captured.body = init.body;
|
|
15
|
+
captured.headers = init.headers;
|
|
15
16
|
return {
|
|
16
17
|
ok: true,
|
|
17
18
|
status: 200,
|
|
@@ -23,10 +24,63 @@ function makeClient(captured, responseBody = { data: [], meta: {} }) {
|
|
|
23
24
|
getToken: () => "Bearer as_testkey",
|
|
24
25
|
getTenantId: () => "tenant-1",
|
|
25
26
|
fetchImpl,
|
|
27
|
+
...extra,
|
|
26
28
|
});
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
test("
|
|
31
|
+
test("factory validates opts", () => {
|
|
32
|
+
assert.throws(() => createDatastoreClient(), TypeError);
|
|
33
|
+
assert.throws(
|
|
34
|
+
() => createDatastoreClient({ getToken: () => "", getTenantId: () => "" }),
|
|
35
|
+
TypeError,
|
|
36
|
+
);
|
|
37
|
+
assert.throws(
|
|
38
|
+
() => createDatastoreClient({ baseUrl: "x", getTenantId: () => "" }),
|
|
39
|
+
TypeError,
|
|
40
|
+
);
|
|
41
|
+
assert.throws(
|
|
42
|
+
() => createDatastoreClient({ baseUrl: "x", getToken: () => "" }),
|
|
43
|
+
TypeError,
|
|
44
|
+
);
|
|
45
|
+
assert.throws(
|
|
46
|
+
() =>
|
|
47
|
+
createDatastoreClient({
|
|
48
|
+
baseUrl: "x",
|
|
49
|
+
getToken: () => "",
|
|
50
|
+
getTenantId: () => "",
|
|
51
|
+
getRequestHeaders: "nope",
|
|
52
|
+
}),
|
|
53
|
+
TypeError,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("tables.list / tables.get paths return the envelope/schema verbatim", async () => {
|
|
58
|
+
const cap = {};
|
|
59
|
+
const client = makeClient(cap, { data: [{ id: "t1", name: "Tasks" }], meta: { total: 1, limit: 50, offset: 0 } });
|
|
60
|
+
const list = await client.tables.list();
|
|
61
|
+
assert.equal(cap.method, "GET");
|
|
62
|
+
assert.equal(new URL(cap.url).pathname, "/api/v1/tables");
|
|
63
|
+
assert.deepEqual(list, { data: [{ id: "t1", name: "Tasks" }], meta: { total: 1, limit: 50, offset: 0 } });
|
|
64
|
+
|
|
65
|
+
const tableSchema = { id: "t1", name: "Tasks", columns: [{ id: "c1", table_id: "t1", name: "title", data_type: "STRING" }] };
|
|
66
|
+
const cap2 = {};
|
|
67
|
+
const client2 = makeClient(cap2, tableSchema);
|
|
68
|
+
const got = await client2.tables.get("Tasks");
|
|
69
|
+
assert.equal(new URL(cap2.url).pathname, "/api/v1/tables/Tasks");
|
|
70
|
+
assert.deepEqual(got, tableSchema);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("schema(t) is an alias of tables.get", async () => {
|
|
74
|
+
const cap = {};
|
|
75
|
+
const tableSchema = { id: "t1", name: "Tasks", columns: [] };
|
|
76
|
+
const client = makeClient(cap, tableSchema);
|
|
77
|
+
const got = await client.schema("t1");
|
|
78
|
+
assert.equal(cap.method, "GET");
|
|
79
|
+
assert.equal(new URL(cap.url).pathname, "/api/v1/tables/t1");
|
|
80
|
+
assert.deepEqual(got, tableSchema);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("records.list serialises limit/offset/q/filter_mode/sort/filter[col]", async () => {
|
|
30
84
|
const cap = {};
|
|
31
85
|
const client = makeClient(cap);
|
|
32
86
|
await client.records("Tasks").list({
|
|
@@ -34,6 +88,7 @@ test("records.list serialises limit/offset/q/filter_mode/filter[col]", async ()
|
|
|
34
88
|
offset: 50,
|
|
35
89
|
q: "urgent",
|
|
36
90
|
filterMode: "or",
|
|
91
|
+
sort: "-created_at",
|
|
37
92
|
filter: { status: "eq:Open", priority: "gte:3" },
|
|
38
93
|
});
|
|
39
94
|
assert.equal(cap.method, "GET");
|
|
@@ -43,17 +98,26 @@ test("records.list serialises limit/offset/q/filter_mode/filter[col]", async ()
|
|
|
43
98
|
assert.equal(u.searchParams.get("offset"), "50");
|
|
44
99
|
assert.equal(u.searchParams.get("q"), "urgent");
|
|
45
100
|
assert.equal(u.searchParams.get("filter_mode"), "or");
|
|
101
|
+
assert.equal(u.searchParams.get("sort"), "-created_at");
|
|
46
102
|
assert.equal(u.searchParams.get("filter[status]"), "eq:Open");
|
|
47
103
|
assert.equal(u.searchParams.get("filter[priority]"), "gte:3");
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("records.list returns the { data, meta } envelope verbatim (no unwrap)", async () => {
|
|
107
|
+
const cap = {};
|
|
108
|
+
const body = {
|
|
109
|
+
data: [{ id: "r1", title: "x", created_at: "2026-01-01T00:00:00Z" }],
|
|
110
|
+
meta: { total: 1, limit: 50, offset: 0 },
|
|
111
|
+
};
|
|
112
|
+
const client = makeClient(cap, body);
|
|
113
|
+
const res = await client.records("Tasks").list();
|
|
114
|
+
assert.deepEqual(res, body);
|
|
51
115
|
});
|
|
52
116
|
|
|
53
117
|
test("records.aggregate is a GET on /records/aggregate with group_by/sum_field", async () => {
|
|
54
118
|
const cap = {};
|
|
55
119
|
const client = makeClient(cap, []);
|
|
56
|
-
await client.records("Sales").aggregate({
|
|
120
|
+
const res = await client.records("Sales").aggregate({
|
|
57
121
|
groupBy: "region",
|
|
58
122
|
sumField: "amount",
|
|
59
123
|
filter: { status: "eq:Closed" },
|
|
@@ -65,9 +129,10 @@ test("records.aggregate is a GET on /records/aggregate with group_by/sum_field",
|
|
|
65
129
|
assert.equal(u.searchParams.get("group_by"), "region");
|
|
66
130
|
assert.equal(u.searchParams.get("sum_field"), "amount");
|
|
67
131
|
assert.equal(u.searchParams.get("filter[status]"), "eq:Closed");
|
|
132
|
+
assert.deepEqual(res, []);
|
|
68
133
|
});
|
|
69
134
|
|
|
70
|
-
test("record CRUD verbs + paths", async () => {
|
|
135
|
+
test("record CRUD verbs + paths; create/update send the body verbatim", async () => {
|
|
71
136
|
const cap = {};
|
|
72
137
|
const client = makeClient(cap, { id: "r1" });
|
|
73
138
|
const ns = client.records("Tasks");
|
|
@@ -76,53 +141,24 @@ test("record CRUD verbs + paths", async () => {
|
|
|
76
141
|
assert.equal(cap.method, "GET");
|
|
77
142
|
assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records/r1");
|
|
78
143
|
|
|
79
|
-
await ns.create({ title: "x" });
|
|
144
|
+
await ns.create({ title: "x", due_date: "2026-01-01" });
|
|
80
145
|
assert.equal(cap.method, "POST");
|
|
81
146
|
assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records");
|
|
147
|
+
assert.deepEqual(JSON.parse(cap.body), { title: "x", due_date: "2026-01-01" });
|
|
82
148
|
|
|
83
149
|
await ns.update("r1", { title: "y" });
|
|
84
150
|
assert.equal(cap.method, "PATCH");
|
|
85
151
|
assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records/r1");
|
|
152
|
+
assert.deepEqual(JSON.parse(cap.body), { title: "y" });
|
|
86
153
|
|
|
87
154
|
await ns.delete("r1");
|
|
88
155
|
assert.equal(cap.method, "DELETE");
|
|
89
156
|
assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks/records/r1");
|
|
90
157
|
});
|
|
91
158
|
|
|
92
|
-
test("
|
|
93
|
-
const cap = {};
|
|
94
|
-
const client = makeClient(cap, { id: "u1", name: "A", email: "a@b.c" });
|
|
95
|
-
await client.users.me();
|
|
96
|
-
assert.equal(cap.method, "GET");
|
|
97
|
-
assert.equal(new URL(cap.url).pathname, "/api/v1/auth/app/me");
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("users.get targets /app/users/{id}", async () => {
|
|
159
|
+
test("records.permissions.list returns { data, meta } snake_case verbatim", async () => {
|
|
101
160
|
const cap = {};
|
|
102
|
-
const
|
|
103
|
-
await client.users.get("u9");
|
|
104
|
-
assert.equal(new URL(cap.url).pathname, "/api/v1/app/users/u9");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("groups.listMine targets /app/groups/mine", async () => {
|
|
108
|
-
const cap = {};
|
|
109
|
-
const client = makeClient(cap);
|
|
110
|
-
await client.groups.listMine();
|
|
111
|
-
assert.equal(new URL(cap.url).pathname, "/api/v1/app/groups/mine");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("tables.list / tables.get paths", async () => {
|
|
115
|
-
const cap = {};
|
|
116
|
-
const client = makeClient(cap);
|
|
117
|
-
await client.tables.list();
|
|
118
|
-
assert.equal(new URL(cap.url).pathname, "/api/v1/tables");
|
|
119
|
-
await client.tables.get("Tasks");
|
|
120
|
-
assert.equal(new URL(cap.url).pathname, "/api/v1/tables/Tasks");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("records.permissions.list maps {data,meta} rows to camelCase", async () => {
|
|
124
|
-
const cap = {};
|
|
125
|
-
const client = makeClient(cap, {
|
|
161
|
+
const body = {
|
|
126
162
|
data: [
|
|
127
163
|
{
|
|
128
164
|
id: "p1",
|
|
@@ -141,54 +177,61 @@ test("records.permissions.list maps {data,meta} rows to camelCase", async () =>
|
|
|
141
177
|
},
|
|
142
178
|
],
|
|
143
179
|
meta: { total: 1, limit: 1, offset: 0 },
|
|
144
|
-
}
|
|
180
|
+
};
|
|
181
|
+
const client = makeClient(cap, body);
|
|
145
182
|
const res = await client.records("Channels").permissions("c1").list();
|
|
146
183
|
assert.equal(cap.method, "GET");
|
|
147
184
|
assert.equal(
|
|
148
185
|
new URL(cap.url).pathname,
|
|
149
186
|
"/api/v1/tables/Channels/records/c1/permissions",
|
|
150
187
|
);
|
|
151
|
-
|
|
152
|
-
assert.
|
|
153
|
-
assert.equal(res.data[0].userId, "u1");
|
|
154
|
-
assert.equal(res.data[0].canRead, true);
|
|
155
|
-
assert.equal(res.data[0].canWrite, false);
|
|
188
|
+
// No transform — the row is returned snake_case verbatim.
|
|
189
|
+
assert.deepEqual(res, body);
|
|
156
190
|
});
|
|
157
191
|
|
|
158
|
-
test("records.permissions.grant POSTs
|
|
192
|
+
test("records.permissions.grant POSTs the snake_case body verbatim and returns the row verbatim", async () => {
|
|
159
193
|
const cap = {};
|
|
160
|
-
const
|
|
194
|
+
const row = {
|
|
161
195
|
id: "p2",
|
|
162
196
|
table_id: "Channels",
|
|
163
197
|
record_id: "c1",
|
|
164
198
|
user_id: "u2",
|
|
199
|
+
group_id: null,
|
|
165
200
|
can_read: true,
|
|
166
201
|
can_write: true,
|
|
167
|
-
|
|
168
|
-
|
|
202
|
+
can_delete: false,
|
|
203
|
+
can_grant: false,
|
|
204
|
+
source_kind: null,
|
|
205
|
+
source_id: null,
|
|
206
|
+
created_at: "2026-01-01T00:00:00Z",
|
|
207
|
+
updated_at: "2026-01-01T00:00:00Z",
|
|
208
|
+
};
|
|
209
|
+
const client = makeClient(cap, row);
|
|
210
|
+
const out = await client
|
|
169
211
|
.records("Channels")
|
|
170
212
|
.permissions("c1")
|
|
171
|
-
.grant({
|
|
213
|
+
.grant({ user_id: "u2", can_read: true, can_write: true });
|
|
172
214
|
assert.equal(cap.method, "POST");
|
|
173
215
|
assert.equal(
|
|
174
216
|
new URL(cap.url).pathname,
|
|
175
217
|
"/api/v1/tables/Channels/records/c1/permissions",
|
|
176
218
|
);
|
|
177
|
-
|
|
178
|
-
assert.deepEqual(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
219
|
+
// Body is forwarded verbatim — no camel->snake mapping.
|
|
220
|
+
assert.deepEqual(JSON.parse(cap.body), {
|
|
221
|
+
user_id: "u2",
|
|
222
|
+
can_read: true,
|
|
223
|
+
can_write: true,
|
|
224
|
+
});
|
|
225
|
+
assert.deepEqual(out, row);
|
|
183
226
|
});
|
|
184
227
|
|
|
185
|
-
test("records.permissions.update PUTs flags by permissionId", async () => {
|
|
228
|
+
test("records.permissions.update PUTs flags by permissionId, verbatim", async () => {
|
|
186
229
|
const cap = {};
|
|
187
230
|
const client = makeClient(cap, { id: "p2", can_delete: true });
|
|
188
231
|
await client
|
|
189
232
|
.records("Channels")
|
|
190
233
|
.permissions("c1")
|
|
191
|
-
.update("p2", {
|
|
234
|
+
.update("p2", { can_delete: true });
|
|
192
235
|
assert.equal(cap.method, "PUT");
|
|
193
236
|
assert.equal(
|
|
194
237
|
new URL(cap.url).pathname,
|
|
@@ -213,3 +256,195 @@ test("records.permissions requires a non-empty recordId", () => {
|
|
|
213
256
|
const client = makeClient(cap);
|
|
214
257
|
assert.throws(() => client.records("Channels").permissions(""), TypeError);
|
|
215
258
|
});
|
|
259
|
+
|
|
260
|
+
test("records(table) requires a non-empty table", () => {
|
|
261
|
+
const cap = {};
|
|
262
|
+
const client = makeClient(cap);
|
|
263
|
+
assert.throws(() => client.records(""), TypeError);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("request sends auth + tenant headers; adds Bearer prefix when missing", async () => {
|
|
267
|
+
const cap = {};
|
|
268
|
+
const client = makeClient(cap, { data: [], meta: {} }, {
|
|
269
|
+
getToken: () => "as_rawkey",
|
|
270
|
+
});
|
|
271
|
+
await client.tables.list();
|
|
272
|
+
assert.equal(cap.headers.authorization, "Bearer as_rawkey");
|
|
273
|
+
assert.equal(cap.headers["x-tenant-id"], "tenant-1");
|
|
274
|
+
assert.equal(cap.headers.accept, "application/json");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("getRequestHeaders is invoked per request with {namespace, operation} and merged", async () => {
|
|
278
|
+
const cap = {};
|
|
279
|
+
const seen = [];
|
|
280
|
+
const client = makeClient(cap, { data: [], meta: {} }, {
|
|
281
|
+
getRequestHeaders: ({ namespace, operation }) => {
|
|
282
|
+
seen.push(`${namespace}:${operation}`);
|
|
283
|
+
return { "X-Widget-Scopes": "records:read" };
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
await client.records("Tasks").list();
|
|
287
|
+
assert.equal(cap.headers["X-Widget-Scopes"], "records:read");
|
|
288
|
+
assert.deepEqual(seen, ["records:list"]);
|
|
289
|
+
|
|
290
|
+
await client.records("Tasks").permissions("r1").grant({ user_id: "u1" });
|
|
291
|
+
assert.deepEqual(seen, ["records:list", "permissions:grant"]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("data-plane only: no users/groups/files/payments namespaces", () => {
|
|
295
|
+
const cap = {};
|
|
296
|
+
const client = makeClient(cap);
|
|
297
|
+
assert.equal(client.users, undefined);
|
|
298
|
+
assert.equal(client.groups, undefined);
|
|
299
|
+
assert.equal(client.files, undefined);
|
|
300
|
+
assert.equal(client.payments, undefined);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("non-OK responses throw a typed error", async () => {
|
|
304
|
+
const fetchImpl = async () => ({
|
|
305
|
+
ok: false,
|
|
306
|
+
status: 404,
|
|
307
|
+
text: async () => JSON.stringify({ error: { message: "nope" } }),
|
|
308
|
+
});
|
|
309
|
+
const client = createDatastoreClient({
|
|
310
|
+
baseUrl: "https://api.example.com/api/v1",
|
|
311
|
+
getToken: () => "Bearer x",
|
|
312
|
+
getTenantId: () => "t1",
|
|
313
|
+
fetchImpl,
|
|
314
|
+
});
|
|
315
|
+
await assert.rejects(() => client.records("Tasks").get("r1"), (err) => {
|
|
316
|
+
assert.equal(err.status, 404);
|
|
317
|
+
assert.equal(err.code, "NOT_FOUND");
|
|
318
|
+
return true;
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// REQ-RT-07 — records(t).subscribe() realtime transport.
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
// Minimal WebSocket double: records the URL, exposes the on* handlers the
|
|
327
|
+
// client assigns, and lets the test drive open/message events + inspect sends.
|
|
328
|
+
class MockWS {
|
|
329
|
+
constructor(url) {
|
|
330
|
+
this.url = url;
|
|
331
|
+
this.sent = [];
|
|
332
|
+
this.closed = false;
|
|
333
|
+
MockWS.last = this;
|
|
334
|
+
}
|
|
335
|
+
send(data) {
|
|
336
|
+
this.sent.push(JSON.parse(data));
|
|
337
|
+
}
|
|
338
|
+
close() {
|
|
339
|
+
this.closed = true;
|
|
340
|
+
if (this.onclose) this.onclose();
|
|
341
|
+
}
|
|
342
|
+
emitOpen() {
|
|
343
|
+
if (this.onopen) this.onopen();
|
|
344
|
+
}
|
|
345
|
+
emitMessage(obj) {
|
|
346
|
+
if (this.onmessage) this.onmessage({ data: JSON.stringify(obj) });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// connect() awaits getToken + getTenantId, so the socket is created on a
|
|
351
|
+
// later microtask/timer turn — flush with a macrotask tick.
|
|
352
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
353
|
+
|
|
354
|
+
function subClient(extra = {}) {
|
|
355
|
+
return createDatastoreClient({
|
|
356
|
+
baseUrl: "https://api.example.com/api/v1",
|
|
357
|
+
getToken: () => "Bearer as_testkey",
|
|
358
|
+
getTenantId: () => "tenant-1",
|
|
359
|
+
fetchImpl: async () => ({ ok: true, status: 200, text: async () => "{}" }),
|
|
360
|
+
webSocketImpl: MockWS,
|
|
361
|
+
...extra,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
test("subscribe: derives wss URL, sends subscribe on open, dispatches record.* + ping", async () => {
|
|
366
|
+
MockWS.last = null;
|
|
367
|
+
const events = [];
|
|
368
|
+
const statuses = [];
|
|
369
|
+
const stop = subClient().records("t1").subscribe({
|
|
370
|
+
onCreated: (r) => events.push(["created", r]),
|
|
371
|
+
onUpdated: (r) => events.push(["updated", r]),
|
|
372
|
+
onDeleted: (r) => events.push(["deleted", r]),
|
|
373
|
+
onStatus: (s) => statuses.push(s),
|
|
374
|
+
});
|
|
375
|
+
await tick();
|
|
376
|
+
|
|
377
|
+
const ws = MockWS.last;
|
|
378
|
+
assert.ok(ws, "socket constructed");
|
|
379
|
+
// wss (from https), the /api/v1 mount + /datastore/ws, raw token (Bearer stripped).
|
|
380
|
+
assert.match(ws.url, /^wss:\/\/api\.example\.com\/api\/v1\/datastore\/ws\?/);
|
|
381
|
+
assert.match(ws.url, /token=as_testkey/);
|
|
382
|
+
assert.ok(!ws.url.includes("Bearer"), "Bearer prefix stripped from query token");
|
|
383
|
+
|
|
384
|
+
ws.emitOpen();
|
|
385
|
+
assert.deepEqual(ws.sent[0], { type: "subscribe", tableId: "t1" });
|
|
386
|
+
|
|
387
|
+
ws.emitMessage({ type: "subscribed" });
|
|
388
|
+
assert.ok(statuses.includes("live"), "subscribed → live");
|
|
389
|
+
|
|
390
|
+
ws.emitMessage({ type: "record.created", record: { id: "r1" } });
|
|
391
|
+
ws.emitMessage({ type: "record.updated", record: { id: "r1", x: 2 } });
|
|
392
|
+
ws.emitMessage({ type: "record.deleted", record: { id: "r1" } });
|
|
393
|
+
assert.deepEqual(events, [
|
|
394
|
+
["created", { id: "r1" }],
|
|
395
|
+
["updated", { id: "r1", x: 2 }],
|
|
396
|
+
["deleted", { id: "r1" }],
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
ws.emitMessage({ type: "ping" });
|
|
400
|
+
assert.deepEqual(ws.sent[ws.sent.length - 1], { type: "pong" });
|
|
401
|
+
|
|
402
|
+
stop();
|
|
403
|
+
assert.equal(ws.closed, true, "unsubscribe closes the socket");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("subscribe: anonymous (no token) routes via tenantId query", async () => {
|
|
407
|
+
MockWS.last = null;
|
|
408
|
+
const stop = subClient({ getToken: () => "" })
|
|
409
|
+
.records("t1")
|
|
410
|
+
.subscribe({ onStatus: () => {} });
|
|
411
|
+
await tick();
|
|
412
|
+
assert.match(MockWS.last.url, /tenantId=tenant-1/);
|
|
413
|
+
assert.ok(!MockWS.last.url.includes("token="), "no token param when anonymous");
|
|
414
|
+
stop();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("subscribe: FORBIDDEN reject → fallback + socket closed", async () => {
|
|
418
|
+
MockWS.last = null;
|
|
419
|
+
const statuses = [];
|
|
420
|
+
const stop = subClient()
|
|
421
|
+
.records("t1")
|
|
422
|
+
.subscribe({ onStatus: (s) => statuses.push(s) });
|
|
423
|
+
await tick();
|
|
424
|
+
MockWS.last.emitMessage({ type: "error", code: "FORBIDDEN" });
|
|
425
|
+
assert.ok(statuses.includes("fallback"), "ACL reject → fallback");
|
|
426
|
+
assert.equal(MockWS.last.closed, true);
|
|
427
|
+
stop();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("subscribe: emits fallback when the first subscribe never lands", async () => {
|
|
431
|
+
MockWS.last = null;
|
|
432
|
+
const statuses = [];
|
|
433
|
+
const stop = subClient()
|
|
434
|
+
.records("t1")
|
|
435
|
+
.subscribe({ onStatus: (s) => statuses.push(s) }, { fallbackAfterMs: 5 });
|
|
436
|
+
await new Promise((r) => setTimeout(r, 30)); // never emit "subscribed"
|
|
437
|
+
assert.ok(statuses.includes("fallback"), "timeout → fallback");
|
|
438
|
+
stop();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("subscribe: no WebSocket impl → immediate fallback", async () => {
|
|
442
|
+
const statuses = [];
|
|
443
|
+
// A non-function impl trips the same guard as a missing global.
|
|
444
|
+
const stop = subClient({ webSocketImpl: 0 })
|
|
445
|
+
.records("t1")
|
|
446
|
+
.subscribe({ onStatus: (s) => statuses.push(s) });
|
|
447
|
+
await tick();
|
|
448
|
+
assert.deepEqual(statuses, ["fallback"]);
|
|
449
|
+
stop();
|
|
450
|
+
});
|