@terreno/api 0.13.2 → 0.14.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.
Files changed (175) hide show
  1. package/dist/__tests__/versionCheckPlugin.test.js +53 -3
  2. package/dist/api.arrayOperations.test.js +1 -0
  3. package/dist/api.asyncHandler.test.d.ts +1 -0
  4. package/dist/api.asyncHandler.test.js +236 -0
  5. package/dist/api.d.ts +15 -4
  6. package/dist/api.errors.test.js +1 -0
  7. package/dist/api.hooks.test.js +1 -0
  8. package/dist/api.js +153 -104
  9. package/dist/api.query.test.js +1 -0
  10. package/dist/api.test.js +174 -0
  11. package/dist/auth.d.ts +10 -5
  12. package/dist/auth.js +163 -90
  13. package/dist/auth.test.js +159 -0
  14. package/dist/betterAuthApp.test.js +1 -0
  15. package/dist/betterAuthSetup.d.ts +5 -6
  16. package/dist/betterAuthSetup.js +17 -14
  17. package/dist/betterAuthSetup.test.js +1 -0
  18. package/dist/config.d.ts +48 -0
  19. package/dist/config.js +248 -0
  20. package/dist/config.test.d.ts +1 -0
  21. package/dist/config.test.js +328 -0
  22. package/dist/configuration.test.js +1 -0
  23. package/dist/configurationApp.d.ts +1 -1
  24. package/dist/configurationApp.js +17 -13
  25. package/dist/configurationPlugin.test.js +1 -0
  26. package/dist/consentApp.test.js +1 -0
  27. package/dist/envConfigurationPlugin.d.ts +2 -0
  28. package/dist/envConfigurationPlugin.js +173 -0
  29. package/dist/envConfigurationPlugin.test.d.ts +1 -0
  30. package/dist/envConfigurationPlugin.test.js +322 -0
  31. package/dist/errors.d.ts +18 -7
  32. package/dist/errors.js +106 -10
  33. package/dist/errors.test.js +16 -1
  34. package/dist/example.js +16 -7
  35. package/dist/expressServer.d.ts +10 -9
  36. package/dist/expressServer.js +62 -53
  37. package/dist/expressServer.test.js +53 -2
  38. package/dist/githubAuth.d.ts +2 -1
  39. package/dist/githubAuth.js +41 -26
  40. package/dist/githubAuth.test.js +1 -0
  41. package/dist/index.d.ts +4 -0
  42. package/dist/index.js +4 -0
  43. package/dist/logger.d.ts +1 -1
  44. package/dist/logger.js +42 -20
  45. package/dist/models/versionConfig.d.ts +2 -0
  46. package/dist/models/versionConfig.js +8 -0
  47. package/dist/notifiers/googleChatNotifier.js +14 -16
  48. package/dist/notifiers/googleChatNotifier.test.js +1 -0
  49. package/dist/notifiers/slackNotifier.js +16 -14
  50. package/dist/notifiers/slackNotifier.test.js +41 -3
  51. package/dist/notifiers/zoomNotifier.js +7 -10
  52. package/dist/notifiers/zoomNotifier.test.js +1 -0
  53. package/dist/openApi.d.ts +1 -1
  54. package/dist/openApi.test.js +1 -0
  55. package/dist/openApiBuilder.d.ts +39 -6
  56. package/dist/openApiBuilder.js +1 -31
  57. package/dist/openApiBuilder.test.js +1 -0
  58. package/dist/openApiValidator.js +1 -0
  59. package/dist/openApiValidator.test.js +65 -0
  60. package/dist/permissions.d.ts +4 -4
  61. package/dist/permissions.js +67 -65
  62. package/dist/permissions.middleware.test.js +1 -0
  63. package/dist/permissions.test.js +1 -0
  64. package/dist/plugins.d.ts +5 -5
  65. package/dist/plugins.js +18 -9
  66. package/dist/plugins.test.js +1 -1
  67. package/dist/populate.d.ts +15 -8
  68. package/dist/populate.js +23 -24
  69. package/dist/populate.test.js +1 -0
  70. package/dist/realtime/changeStreamWatcher.d.ts +73 -0
  71. package/dist/realtime/changeStreamWatcher.js +720 -0
  72. package/dist/realtime/index.d.ts +6 -0
  73. package/dist/realtime/index.js +27 -0
  74. package/dist/realtime/queryMatcher.d.ts +14 -0
  75. package/dist/realtime/queryMatcher.js +250 -0
  76. package/dist/realtime/queryStore.d.ts +37 -0
  77. package/dist/realtime/queryStore.js +195 -0
  78. package/dist/realtime/realtime.test.d.ts +10 -0
  79. package/dist/realtime/realtime.test.js +2158 -0
  80. package/dist/realtime/realtimeApp.d.ts +93 -0
  81. package/dist/realtime/realtimeApp.js +560 -0
  82. package/dist/realtime/registry.d.ts +40 -0
  83. package/dist/realtime/registry.js +38 -0
  84. package/dist/realtime/socketUser.d.ts +10 -0
  85. package/dist/realtime/socketUser.js +17 -0
  86. package/dist/realtime/types.d.ts +100 -0
  87. package/dist/realtime/types.js +2 -0
  88. package/dist/requestContext.d.ts +37 -0
  89. package/dist/requestContext.js +344 -0
  90. package/dist/requestContext.test.d.ts +1 -0
  91. package/dist/requestContext.test.js +241 -0
  92. package/dist/terrenoApp.d.ts +8 -0
  93. package/dist/terrenoApp.js +50 -13
  94. package/dist/terrenoApp.test.js +194 -21
  95. package/dist/terrenoPlugin.d.ts +11 -0
  96. package/dist/tests/bunSetup.js +1 -0
  97. package/dist/tests.js +1 -1
  98. package/dist/transformers.d.ts +2 -2
  99. package/dist/transformers.js +5 -3
  100. package/dist/transformers.test.js +90 -0
  101. package/dist/types/consentResponse.d.ts +6 -3
  102. package/dist/versionCheckPlugin.d.ts +2 -0
  103. package/dist/versionCheckPlugin.js +18 -12
  104. package/package.json +4 -2
  105. package/src/__tests__/versionCheckPlugin.test.ts +37 -3
  106. package/src/api.arrayOperations.test.ts +1 -0
  107. package/src/api.asyncHandler.test.ts +177 -0
  108. package/src/api.errors.test.ts +1 -0
  109. package/src/api.hooks.test.ts +1 -0
  110. package/src/api.query.test.ts +1 -0
  111. package/src/api.test.ts +132 -0
  112. package/src/api.ts +199 -84
  113. package/src/auth.test.ts +160 -0
  114. package/src/auth.ts +120 -50
  115. package/src/betterAuthApp.test.ts +1 -0
  116. package/src/betterAuthSetup.test.ts +1 -0
  117. package/src/betterAuthSetup.ts +46 -19
  118. package/src/config.test.ts +255 -0
  119. package/src/config.ts +206 -0
  120. package/src/configuration.test.ts +1 -0
  121. package/src/configurationApp.ts +59 -24
  122. package/src/configurationPlugin.test.ts +1 -0
  123. package/src/consentApp.test.ts +1 -0
  124. package/src/envConfigurationPlugin.test.ts +143 -0
  125. package/src/envConfigurationPlugin.ts +100 -0
  126. package/src/errors.test.ts +19 -1
  127. package/src/errors.ts +94 -20
  128. package/src/example.ts +46 -21
  129. package/src/express.d.ts +18 -1
  130. package/src/expressServer.test.ts +50 -2
  131. package/src/expressServer.ts +80 -50
  132. package/src/githubAuth.test.ts +1 -0
  133. package/src/githubAuth.ts +59 -38
  134. package/src/index.ts +4 -0
  135. package/src/logger.ts +47 -17
  136. package/src/models/versionConfig.ts +13 -2
  137. package/src/notifiers/googleChatNotifier.test.ts +1 -0
  138. package/src/notifiers/googleChatNotifier.ts +7 -9
  139. package/src/notifiers/slackNotifier.test.ts +29 -3
  140. package/src/notifiers/slackNotifier.ts +9 -7
  141. package/src/notifiers/zoomNotifier.test.ts +1 -0
  142. package/src/notifiers/zoomNotifier.ts +8 -11
  143. package/src/openApi.test.ts +1 -0
  144. package/src/openApi.ts +4 -4
  145. package/src/openApiBuilder.test.ts +1 -0
  146. package/src/openApiBuilder.ts +14 -11
  147. package/src/openApiValidator.test.ts +59 -0
  148. package/src/openApiValidator.ts +3 -2
  149. package/src/permissions.middleware.test.ts +1 -0
  150. package/src/permissions.test.ts +1 -0
  151. package/src/permissions.ts +30 -25
  152. package/src/plugins.test.ts +1 -1
  153. package/src/plugins.ts +21 -14
  154. package/src/populate.test.ts +1 -0
  155. package/src/populate.ts +44 -36
  156. package/src/realtime/changeStreamWatcher.ts +568 -0
  157. package/src/realtime/index.ts +34 -0
  158. package/src/realtime/queryMatcher.ts +179 -0
  159. package/src/realtime/queryStore.ts +132 -0
  160. package/src/realtime/realtime.test.ts +1755 -0
  161. package/src/realtime/realtimeApp.ts +478 -0
  162. package/src/realtime/registry.ts +64 -0
  163. package/src/realtime/socketUser.ts +25 -0
  164. package/src/realtime/types.ts +112 -0
  165. package/src/requestContext.test.ts +196 -0
  166. package/src/requestContext.ts +368 -0
  167. package/src/terrenoApp.test.ts +137 -11
  168. package/src/terrenoApp.ts +64 -17
  169. package/src/terrenoPlugin.ts +12 -0
  170. package/src/tests/bunSetup.ts +1 -0
  171. package/src/tests.ts +7 -2
  172. package/src/transformers.test.ts +70 -2
  173. package/src/transformers.ts +15 -7
  174. package/src/types/consentResponse.ts +8 -10
  175. package/src/versionCheckPlugin.ts +15 -7
@@ -0,0 +1,1755 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mocks use dynamic shapes for registry entries and documents
2
+ /**
3
+ * Tests for the realtime module's pure functions and classes:
4
+ * - queryMatcher.ts (matchesQuery)
5
+ * - queryStore.ts (addQuerySubscription, removeQuerySubscription, etc.)
6
+ * - registry.ts (registerRealtime, getRealtimeRegistry, etc.)
7
+ * - realtimeApp.ts (RealtimeApp class — register, getIo, close)
8
+ * - realtimeApp.ts (installRealtimeSocketHandlers — permission and rate-limit logic)
9
+ * - changeStreamWatcher.ts (serializeDoc — responseHandler fallback)
10
+ */
11
+
12
+ import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
13
+ import express from "express";
14
+
15
+ import {
16
+ emitToAuthorizedRoom,
17
+ emitToDocumentAndQueryRooms,
18
+ mapOperationType,
19
+ resolveRooms,
20
+ serializeDoc,
21
+ } from "./changeStreamWatcher";
22
+ import {matchesQuery} from "./queryMatcher";
23
+ import {
24
+ addQuerySubscription,
25
+ clearQueryStore,
26
+ computeQueryId,
27
+ getQuerySubscriptionsForCollection,
28
+ removeAllSocketQueries,
29
+ removeQuerySubscription,
30
+ } from "./queryStore";
31
+ import {
32
+ installRealtimeSocketHandlers,
33
+ MAX_MODEL_SUBSCRIPTIONS,
34
+ MAX_QUERY_SUBSCRIPTIONS,
35
+ RealtimeApp,
36
+ type RealtimeSocketLike,
37
+ redactCredentials,
38
+ } from "./realtimeApp";
39
+ import {
40
+ clearRealtimeRegistry,
41
+ findRegistryEntryByCollection,
42
+ findRegistryEntryByRoutePath,
43
+ getRealtimeRegistry,
44
+ registerRealtime,
45
+ } from "./registry";
46
+
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+ // queryMatcher tests
49
+ // ─────────────────────────────────────────────────────────────────────────────
50
+
51
+ describe("matchesQuery", () => {
52
+ describe("direct equality", () => {
53
+ it("matches string equality", () => {
54
+ expect(matchesQuery({name: "Alice"}, {name: "Alice"})).toBe(true);
55
+ });
56
+
57
+ it("returns false for non-matching string", () => {
58
+ expect(matchesQuery({name: "Alice"}, {name: "Bob"})).toBe(false);
59
+ });
60
+
61
+ it("matches numeric equality", () => {
62
+ expect(matchesQuery({count: 5}, {count: 5})).toBe(true);
63
+ });
64
+
65
+ it("returns false for non-matching number", () => {
66
+ expect(matchesQuery({count: 5}, {count: 6})).toBe(false);
67
+ });
68
+
69
+ it("matches boolean true", () => {
70
+ expect(matchesQuery({active: true}, {active: true})).toBe(true);
71
+ });
72
+
73
+ it("returns false for non-matching boolean", () => {
74
+ expect(matchesQuery({active: true}, {active: false})).toBe(false);
75
+ });
76
+
77
+ it("matches null value", () => {
78
+ expect(matchesQuery({deleted: null}, {deleted: null})).toBe(true);
79
+ });
80
+
81
+ it("matches undefined/missing field", () => {
82
+ expect(matchesQuery({}, {deleted: undefined})).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe("nested field access", () => {
87
+ it("accesses nested fields via dot notation", () => {
88
+ const doc = {user: {age: 30, name: "Alice"}};
89
+ expect(matchesQuery(doc, {"user.name": "Alice"})).toBe(true);
90
+ });
91
+
92
+ it("returns false when nested path doesn't match", () => {
93
+ const doc = {user: {name: "Alice"}};
94
+ expect(matchesQuery(doc, {"user.name": "Bob"})).toBe(false);
95
+ });
96
+
97
+ it("returns false when nested path is undefined", () => {
98
+ const doc = {user: {}};
99
+ expect(matchesQuery(doc, {"user.name": "Alice"})).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe("$eq operator", () => {
104
+ it("matches with $eq", () => {
105
+ expect(matchesQuery({count: 5}, {count: {$eq: 5}})).toBe(true);
106
+ });
107
+
108
+ it("returns false with $eq mismatch", () => {
109
+ expect(matchesQuery({count: 5}, {count: {$eq: 6}})).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe("$ne operator", () => {
114
+ it("matches when value is not equal", () => {
115
+ expect(matchesQuery({status: "active"}, {status: {$ne: "inactive"}})).toBe(true);
116
+ });
117
+
118
+ it("returns false when value equals $ne operand", () => {
119
+ expect(matchesQuery({status: "active"}, {status: {$ne: "active"}})).toBe(false);
120
+ });
121
+ });
122
+
123
+ describe("$gt, $gte operators", () => {
124
+ it("matches $gt", () => {
125
+ expect(matchesQuery({score: 10}, {score: {$gt: 5}})).toBe(true);
126
+ });
127
+
128
+ it("returns false when not $gt", () => {
129
+ expect(matchesQuery({score: 5}, {score: {$gt: 5}})).toBe(false);
130
+ });
131
+
132
+ it("matches $gte for equal value", () => {
133
+ expect(matchesQuery({score: 5}, {score: {$gte: 5}})).toBe(true);
134
+ });
135
+
136
+ it("matches $gte for greater value", () => {
137
+ expect(matchesQuery({score: 6}, {score: {$gte: 5}})).toBe(true);
138
+ });
139
+
140
+ it("returns false when not $gte", () => {
141
+ expect(matchesQuery({score: 4}, {score: {$gte: 5}})).toBe(false);
142
+ });
143
+ });
144
+
145
+ describe("$lt, $lte operators", () => {
146
+ it("matches $lt", () => {
147
+ expect(matchesQuery({score: 3}, {score: {$lt: 5}})).toBe(true);
148
+ });
149
+
150
+ it("returns false when not $lt", () => {
151
+ expect(matchesQuery({score: 5}, {score: {$lt: 5}})).toBe(false);
152
+ });
153
+
154
+ it("matches $lte for equal value", () => {
155
+ expect(matchesQuery({score: 5}, {score: {$lte: 5}})).toBe(true);
156
+ });
157
+
158
+ it("matches $lte for lesser value", () => {
159
+ expect(matchesQuery({score: 4}, {score: {$lte: 5}})).toBe(true);
160
+ });
161
+
162
+ it("returns false when not $lte", () => {
163
+ expect(matchesQuery({score: 6}, {score: {$lte: 5}})).toBe(false);
164
+ });
165
+ });
166
+
167
+ describe("$in operator", () => {
168
+ it("matches when value is in array", () => {
169
+ expect(matchesQuery({status: "active"}, {status: {$in: ["active", "pending"]}})).toBe(true);
170
+ });
171
+
172
+ it("returns false when value is not in array", () => {
173
+ expect(matchesQuery({status: "inactive"}, {status: {$in: ["active", "pending"]}})).toBe(
174
+ false
175
+ );
176
+ });
177
+
178
+ it("returns false when operand is not an array", () => {
179
+ expect(matchesQuery({status: "active"}, {status: {$in: "active"}})).toBe(false);
180
+ });
181
+ });
182
+
183
+ describe("$nin operator", () => {
184
+ it("matches when value is not in array", () => {
185
+ expect(matchesQuery({status: "inactive"}, {status: {$nin: ["active", "pending"]}})).toBe(
186
+ true
187
+ );
188
+ });
189
+
190
+ it("returns false when value is in array", () => {
191
+ expect(matchesQuery({status: "active"}, {status: {$nin: ["active", "pending"]}})).toBe(false);
192
+ });
193
+
194
+ it("returns false when operand is not an array", () => {
195
+ expect(matchesQuery({status: "active"}, {status: {$nin: "active"}})).toBe(false);
196
+ });
197
+ });
198
+
199
+ describe("$exists operator", () => {
200
+ it("matches when field exists and $exists is true", () => {
201
+ expect(matchesQuery({name: "Alice"}, {name: {$exists: true}})).toBe(true);
202
+ });
203
+
204
+ it("returns false when field is missing and $exists is true", () => {
205
+ expect(matchesQuery({}, {name: {$exists: true}})).toBe(false);
206
+ });
207
+
208
+ it("matches when field is missing and $exists is false", () => {
209
+ expect(matchesQuery({}, {name: {$exists: false}})).toBe(true);
210
+ });
211
+
212
+ it("returns false when field exists and $exists is false", () => {
213
+ expect(matchesQuery({name: "Alice"}, {name: {$exists: false}})).toBe(false);
214
+ });
215
+ });
216
+
217
+ describe("$not operator", () => {
218
+ it("negates a condition with $not", () => {
219
+ expect(matchesQuery({count: 5}, {count: {$not: {$gt: 10}}})).toBe(true);
220
+ });
221
+
222
+ it("returns false when negated condition matches", () => {
223
+ expect(matchesQuery({count: 15}, {count: {$not: {$gt: 10}}})).toBe(false);
224
+ });
225
+ });
226
+
227
+ describe("$and operator", () => {
228
+ it("matches when all conditions are true", () => {
229
+ expect(
230
+ matchesQuery({count: 5, status: "active"}, {$and: [{status: "active"}, {count: 5}]})
231
+ ).toBe(true);
232
+ });
233
+
234
+ it("returns false when one condition fails", () => {
235
+ expect(
236
+ matchesQuery({count: 3, status: "active"}, {$and: [{status: "active"}, {count: 5}]})
237
+ ).toBe(false);
238
+ });
239
+
240
+ it("returns false when $and operand is not an array", () => {
241
+ expect(matchesQuery({status: "active"}, {$and: "invalid" as any})).toBe(false);
242
+ });
243
+ });
244
+
245
+ describe("$or operator", () => {
246
+ it("matches when any condition is true", () => {
247
+ expect(
248
+ matchesQuery({status: "inactive"}, {$or: [{status: "active"}, {status: "inactive"}]})
249
+ ).toBe(true);
250
+ });
251
+
252
+ it("returns false when no conditions match", () => {
253
+ expect(
254
+ matchesQuery({status: "pending"}, {$or: [{status: "active"}, {status: "inactive"}]})
255
+ ).toBe(false);
256
+ });
257
+
258
+ it("returns false when $or operand is not an array", () => {
259
+ expect(matchesQuery({status: "active"}, {$or: "invalid" as any})).toBe(false);
260
+ });
261
+ });
262
+
263
+ describe("unknown operator", () => {
264
+ it("returns false for unknown operators (fail closed)", () => {
265
+ expect(matchesQuery({name: "Alice"}, {name: {$regex: "Al"}})).toBe(false);
266
+ });
267
+ });
268
+
269
+ describe("ObjectId-like values", () => {
270
+ it("matches ObjectId-like objects via toString", () => {
271
+ const fakeObjectId = {
272
+ constructor: {name: "ObjectId"},
273
+ toString: () => "507f1f77bcf86cd799439011",
274
+ };
275
+ const doc = {ownerId: fakeObjectId};
276
+ expect(matchesQuery(doc, {ownerId: "507f1f77bcf86cd799439011"})).toBe(true);
277
+ });
278
+ });
279
+
280
+ describe("array equality", () => {
281
+ it("matches array conditions by JSON serialization", () => {
282
+ expect(matchesQuery({tags: ["a", "b"]}, {tags: ["a", "b"]})).toBe(true);
283
+ });
284
+
285
+ it("returns false for different arrays", () => {
286
+ expect(matchesQuery({tags: ["a", "b"]}, {tags: ["a", "c"]})).toBe(false);
287
+ });
288
+ });
289
+
290
+ describe("empty query", () => {
291
+ it("matches any document with empty query", () => {
292
+ expect(matchesQuery({count: 5, name: "Alice"}, {})).toBe(true);
293
+ });
294
+ });
295
+ });
296
+
297
+ // ─────────────────────────────────────────────────────────────────────────────
298
+ // queryStore tests
299
+ // ─────────────────────────────────────────────────────────────────────────────
300
+
301
+ describe("queryStore", () => {
302
+ beforeEach(() => {
303
+ clearQueryStore();
304
+ });
305
+
306
+ afterEach(() => {
307
+ clearQueryStore();
308
+ });
309
+
310
+ describe("computeQueryId", () => {
311
+ it("produces a deterministic id from collection and query", () => {
312
+ const id1 = computeQueryId("todos", {completed: false});
313
+ const id2 = computeQueryId("todos", {completed: false});
314
+ expect(id1).toBe(id2);
315
+ });
316
+
317
+ it("normalizes key order before computing id", () => {
318
+ const id1 = computeQueryId("todos", {completed: false, ownerId: "abc"});
319
+ const id2 = computeQueryId("todos", {completed: false, ownerId: "abc"});
320
+ expect(id1).toBe(id2);
321
+ });
322
+
323
+ it("includes the collection name in the id", () => {
324
+ const id = computeQueryId("todos", {completed: false});
325
+ expect(id.startsWith("todos:")).toBe(true);
326
+ });
327
+
328
+ it("produces different ids for different collections", () => {
329
+ const id1 = computeQueryId("todos", {completed: false});
330
+ const id2 = computeQueryId("items", {completed: false});
331
+ expect(id1).not.toBe(id2);
332
+ });
333
+
334
+ it("produces different ids for different queries", () => {
335
+ const id1 = computeQueryId("todos", {completed: false});
336
+ const id2 = computeQueryId("todos", {completed: true});
337
+ expect(id1).not.toBe(id2);
338
+ });
339
+ });
340
+
341
+ describe("addQuerySubscription", () => {
342
+ it("stores a subscription and makes it retrievable", () => {
343
+ addQuerySubscription("socket1", "todos", {completed: false}, "todos:q1");
344
+ const subs = getQuerySubscriptionsForCollection("todos");
345
+ expect(subs).toHaveLength(1);
346
+ expect(subs[0].queryId).toBe("todos:q1");
347
+ });
348
+
349
+ it("allows the same socket to subscribe to multiple queries", () => {
350
+ addQuerySubscription("socket1", "todos", {completed: false}, "todos:q1");
351
+ addQuerySubscription("socket1", "todos", {completed: true}, "todos:q2");
352
+ const subs = getQuerySubscriptionsForCollection("todos");
353
+ expect(subs).toHaveLength(2);
354
+ });
355
+
356
+ it("allows multiple sockets to subscribe to the same query", () => {
357
+ addQuerySubscription("socket1", "todos", {completed: false}, "todos:q1");
358
+ addQuerySubscription("socket2", "todos", {completed: false}, "todos:q1");
359
+ const subs = getQuerySubscriptionsForCollection("todos");
360
+ // Same queryId — deduplicated in the store
361
+ expect(subs).toHaveLength(1);
362
+ });
363
+ });
364
+
365
+ describe("removeQuerySubscription", () => {
366
+ it("removes a specific query for a socket", () => {
367
+ addQuerySubscription("socket1", "todos", {completed: false}, "todos:q1");
368
+ removeQuerySubscription("socket1", "todos:q1");
369
+ const subs = getQuerySubscriptionsForCollection("todos");
370
+ expect(subs).toHaveLength(0);
371
+ });
372
+
373
+ it("does not remove a query if another socket still uses it", () => {
374
+ addQuerySubscription("socket1", "todos", {completed: false}, "todos:q1");
375
+ addQuerySubscription("socket2", "todos", {completed: false}, "todos:q1");
376
+ removeQuerySubscription("socket1", "todos:q1");
377
+ const subs = getQuerySubscriptionsForCollection("todos");
378
+ expect(subs).toHaveLength(1);
379
+ });
380
+
381
+ it("is safe to call for a socket that has no subscriptions", () => {
382
+ expect(() => removeQuerySubscription("nonexistent", "todos:q1")).not.toThrow();
383
+ });
384
+ });
385
+
386
+ describe("removeAllSocketQueries", () => {
387
+ it("removes all subscriptions for a socket", () => {
388
+ addQuerySubscription("socket1", "todos", {completed: false}, "todos:q1");
389
+ addQuerySubscription("socket1", "todos", {completed: true}, "todos:q2");
390
+ removeAllSocketQueries("socket1");
391
+ const subs = getQuerySubscriptionsForCollection("todos");
392
+ expect(subs).toHaveLength(0);
393
+ });
394
+
395
+ it("preserves subscriptions for other sockets", () => {
396
+ addQuerySubscription("socket1", "todos", {completed: false}, "todos:q1");
397
+ addQuerySubscription("socket2", "todos", {completed: true}, "todos:q2");
398
+ removeAllSocketQueries("socket1");
399
+ const subs = getQuerySubscriptionsForCollection("todos");
400
+ expect(subs).toHaveLength(1);
401
+ expect(subs[0].queryId).toBe("todos:q2");
402
+ });
403
+
404
+ it("is safe to call for a socket that has no subscriptions", () => {
405
+ expect(() => removeAllSocketQueries("nonexistent")).not.toThrow();
406
+ });
407
+
408
+ it("only removes a shared query if no other sockets use it", () => {
409
+ addQuerySubscription("socket1", "todos", {completed: false}, "todos:q1");
410
+ addQuerySubscription("socket2", "todos", {completed: false}, "todos:q1");
411
+ removeAllSocketQueries("socket1");
412
+ // socket2 still subscribes — query should remain
413
+ const subs = getQuerySubscriptionsForCollection("todos");
414
+ expect(subs).toHaveLength(1);
415
+ });
416
+ });
417
+
418
+ describe("getQuerySubscriptionsForCollection", () => {
419
+ it("returns only subscriptions for the given collection", () => {
420
+ addQuerySubscription("socket1", "todos", {completed: false}, "todos:q1");
421
+ addQuerySubscription("socket2", "items", {status: "active"}, "items:q1");
422
+ const todoSubs = getQuerySubscriptionsForCollection("todos");
423
+ expect(todoSubs).toHaveLength(1);
424
+ expect(todoSubs[0].queryId).toBe("todos:q1");
425
+ });
426
+
427
+ it("returns an empty array when no subscriptions exist for collection", () => {
428
+ const subs = getQuerySubscriptionsForCollection("nonexistent");
429
+ expect(subs).toHaveLength(0);
430
+ });
431
+
432
+ it("returns query data for each subscription", () => {
433
+ const query = {completed: false, ownerId: "user1"};
434
+ addQuerySubscription("socket1", "todos", query, "todos:q1");
435
+ const subs = getQuerySubscriptionsForCollection("todos");
436
+ expect(subs[0].query).toEqual(query);
437
+ });
438
+ });
439
+
440
+ describe("clearQueryStore", () => {
441
+ it("removes all subscriptions", () => {
442
+ addQuerySubscription("socket1", "todos", {completed: false}, "todos:q1");
443
+ addQuerySubscription("socket2", "items", {status: "active"}, "items:q1");
444
+ clearQueryStore();
445
+ expect(getQuerySubscriptionsForCollection("todos")).toHaveLength(0);
446
+ expect(getQuerySubscriptionsForCollection("items")).toHaveLength(0);
447
+ });
448
+ });
449
+ });
450
+
451
+ // ─────────────────────────────────────────────────────────────────────────────
452
+ // registry tests
453
+ // ─────────────────────────────────────────────────────────────────────────────
454
+
455
+ describe("realtimeRegistry", () => {
456
+ const makeEntry = (overrides: Partial<Parameters<typeof registerRealtime>[0]> = {}) => ({
457
+ collectionName: "todos",
458
+ config: {
459
+ methods: ["create" as const, "update" as const, "delete" as const],
460
+ roomStrategy: "owner" as const,
461
+ },
462
+ modelName: "Todo",
463
+ options: {} as any,
464
+ routePath: "/todos",
465
+ ...overrides,
466
+ });
467
+
468
+ beforeEach(() => {
469
+ clearRealtimeRegistry();
470
+ });
471
+
472
+ afterEach(() => {
473
+ clearRealtimeRegistry();
474
+ });
475
+
476
+ describe("registerRealtime", () => {
477
+ it("adds an entry to the registry", () => {
478
+ registerRealtime(makeEntry());
479
+ expect(getRealtimeRegistry()).toHaveLength(1);
480
+ });
481
+
482
+ it("allows multiple entries", () => {
483
+ registerRealtime(makeEntry({modelName: "Todo", routePath: "/todos"}));
484
+ registerRealtime(
485
+ makeEntry({collectionName: "items", modelName: "Item", routePath: "/items"})
486
+ );
487
+ expect(getRealtimeRegistry()).toHaveLength(2);
488
+ });
489
+ });
490
+
491
+ describe("getRealtimeRegistry", () => {
492
+ it("returns all registered entries", () => {
493
+ registerRealtime(makeEntry({modelName: "Todo"}));
494
+ registerRealtime(
495
+ makeEntry({collectionName: "items", modelName: "Item", routePath: "/items"})
496
+ );
497
+ const registry = getRealtimeRegistry();
498
+ expect(registry).toHaveLength(2);
499
+ expect(registry.map((e) => e.modelName)).toEqual(["Todo", "Item"]);
500
+ });
501
+
502
+ it("returns empty array when nothing is registered", () => {
503
+ expect(getRealtimeRegistry()).toHaveLength(0);
504
+ });
505
+ });
506
+
507
+ describe("findRegistryEntryByCollection", () => {
508
+ it("finds an entry by collection name", () => {
509
+ registerRealtime(makeEntry({collectionName: "todos"}));
510
+ const entry = findRegistryEntryByCollection("todos");
511
+ expect(entry).toBeDefined();
512
+ expect(entry?.collectionName).toBe("todos");
513
+ });
514
+
515
+ it("returns undefined for unknown collection", () => {
516
+ const entry = findRegistryEntryByCollection("nonexistent");
517
+ expect(entry).toBeUndefined();
518
+ });
519
+
520
+ it("returns first match when multiple entries exist", () => {
521
+ registerRealtime(makeEntry({collectionName: "todos", modelName: "Todo1"}));
522
+ registerRealtime(makeEntry({collectionName: "todos", modelName: "Todo2"}));
523
+ const entry = findRegistryEntryByCollection("todos");
524
+ expect(entry?.modelName).toBe("Todo1");
525
+ });
526
+ });
527
+
528
+ describe("findRegistryEntryByRoutePath", () => {
529
+ it("finds an entry by exact route path with leading slash", () => {
530
+ registerRealtime(makeEntry({routePath: "/todos"}));
531
+ const entry = findRegistryEntryByRoutePath("todos");
532
+ expect(entry).toBeDefined();
533
+ expect(entry?.routePath).toBe("/todos");
534
+ });
535
+
536
+ it("finds an entry when collection matches routePath exactly", () => {
537
+ registerRealtime(makeEntry({routePath: "todos"}));
538
+ const entry = findRegistryEntryByRoutePath("todos");
539
+ expect(entry).toBeDefined();
540
+ });
541
+
542
+ it("returns undefined for unknown route path", () => {
543
+ registerRealtime(makeEntry({routePath: "/todos"}));
544
+ const entry = findRegistryEntryByRoutePath("items");
545
+ expect(entry).toBeUndefined();
546
+ });
547
+ });
548
+
549
+ describe("clearRealtimeRegistry", () => {
550
+ it("removes all entries", () => {
551
+ registerRealtime(makeEntry());
552
+ clearRealtimeRegistry();
553
+ expect(getRealtimeRegistry()).toHaveLength(0);
554
+ });
555
+ });
556
+ });
557
+
558
+ // ─────────────────────────────────────────────────────────────────────────────
559
+ // RealtimeApp tests
560
+ // ─────────────────────────────────────────────────────────────────────────────
561
+
562
+ describe("RealtimeApp", () => {
563
+ describe("constructor", () => {
564
+ it("creates an instance with empty config", () => {
565
+ const app = new RealtimeApp();
566
+ expect(app).toBeDefined();
567
+ });
568
+
569
+ it("creates an instance with provided config", () => {
570
+ const app = new RealtimeApp({adapter: "none", debug: true});
571
+ expect(app).toBeDefined();
572
+ });
573
+ });
574
+
575
+ describe("getIo", () => {
576
+ it("returns null before the server is created", () => {
577
+ const app = new RealtimeApp();
578
+ expect(app.getIo()).toBeNull();
579
+ });
580
+ });
581
+
582
+ describe("register", () => {
583
+ it("health endpoint returns status not_started when io is not initialized", async () => {
584
+ const expressApp = express();
585
+ const app = new RealtimeApp();
586
+ app.register(expressApp);
587
+
588
+ const supertest = await import("supertest");
589
+ const st = supertest.default(expressApp);
590
+ const res = await st.get("/realtime/health").expect(200);
591
+ expect(res.body.status).toBe("not_started");
592
+ expect(res.body.clients).toBe(0);
593
+ });
594
+ });
595
+
596
+ describe("close", () => {
597
+ it("closes gracefully when io is null", async () => {
598
+ const app = new RealtimeApp();
599
+ // Should not throw
600
+ await expect(app.close()).resolves.toBeUndefined();
601
+ });
602
+ });
603
+ });
604
+
605
+ // ─────────────────────────────────────────────────────────────────────────────
606
+ // installRealtimeSocketHandlers — permission and rate-limit logic
607
+ // ─────────────────────────────────────────────────────────────────────────────
608
+
609
+ interface MockSocket extends RealtimeSocketLike {
610
+ rooms: Set<string>;
611
+ emitted: {event: string; payload: unknown}[];
612
+ listeners: Map<string, (...args: any[]) => any>;
613
+ trigger: (event: string, ...args: any[]) => Promise<void>;
614
+ }
615
+
616
+ const createMockSocket = (decodedToken?: {id?: string; admin?: boolean}): MockSocket => {
617
+ const rooms = new Set<string>();
618
+ const emitted: {event: string; payload: unknown}[] = [];
619
+ const listeners = new Map<string, (...args: any[]) => any>();
620
+
621
+ const socket: MockSocket = {
622
+ decodedToken,
623
+ emit: (event, payload) => {
624
+ emitted.push({event, payload});
625
+ },
626
+ emitted,
627
+ id: `socket-${Math.random().toString(36).slice(2, 9)}`,
628
+ join: async (room: string) => {
629
+ rooms.add(room);
630
+ },
631
+ leave: async (room: string) => {
632
+ rooms.delete(room);
633
+ },
634
+ listeners,
635
+ on: (event, handler) => {
636
+ listeners.set(event, handler);
637
+ },
638
+ rooms,
639
+ trigger: async (event, ...args) => {
640
+ const handler = listeners.get(event);
641
+ if (handler) {
642
+ await handler(...args);
643
+ }
644
+ },
645
+ };
646
+
647
+ return socket;
648
+ };
649
+
650
+ describe("installRealtimeSocketHandlers", () => {
651
+ beforeEach(() => {
652
+ clearRealtimeRegistry();
653
+ clearQueryStore();
654
+ });
655
+
656
+ afterEach(() => {
657
+ clearRealtimeRegistry();
658
+ clearQueryStore();
659
+ });
660
+
661
+ const registerOwnerCollection = (): void => {
662
+ registerRealtime({
663
+ collectionName: "todos",
664
+ config: {methods: ["create", "update", "delete"], roomStrategy: "owner"},
665
+ modelName: "Todo",
666
+ options: {
667
+ permissions: {
668
+ create: [() => true],
669
+ delete: [() => true],
670
+ list: [() => true],
671
+ read: [() => true],
672
+ update: [() => true],
673
+ },
674
+ } as any,
675
+ routePath: "/todos",
676
+ });
677
+ };
678
+
679
+ const registerModelCollection = (): void => {
680
+ registerRealtime({
681
+ collectionName: "broadcasts",
682
+ config: {methods: ["create", "update", "delete"], roomStrategy: "model"},
683
+ modelName: "Broadcast",
684
+ options: {
685
+ permissions: {
686
+ create: [() => true],
687
+ delete: [() => true],
688
+ list: [() => true],
689
+ read: [() => true],
690
+ update: [() => true],
691
+ },
692
+ } as any,
693
+ routePath: "/broadcasts",
694
+ });
695
+ };
696
+
697
+ const registerAdminOnlyCollection = (): void => {
698
+ registerRealtime({
699
+ collectionName: "secrets",
700
+ config: {methods: ["create", "update", "delete"], roomStrategy: "model"},
701
+ modelName: "Secret",
702
+ options: {
703
+ permissions: {
704
+ create: [(_method: string, user?: {admin?: boolean}) => user?.admin === true],
705
+ delete: [(_method: string, user?: {admin?: boolean}) => user?.admin === true],
706
+ list: [(_method: string, user?: {admin?: boolean}) => user?.admin === true],
707
+ read: [(_method: string, user?: {admin?: boolean}) => user?.admin === true],
708
+ update: [(_method: string, user?: {admin?: boolean}) => user?.admin === true],
709
+ },
710
+ } as any,
711
+ routePath: "/secrets",
712
+ });
713
+ };
714
+
715
+ describe("connection setup", () => {
716
+ it("joins user-specific and authenticated rooms when token has userId", async () => {
717
+ const socket = createMockSocket({id: "user1"});
718
+ installRealtimeSocketHandlers(socket);
719
+ // joinUserRooms is fire-and-forget — give microtasks a chance to flush.
720
+ await Promise.resolve();
721
+ await Promise.resolve();
722
+ expect(socket.rooms.has("user:user1")).toBe(true);
723
+ expect(socket.rooms.has("authenticated")).toBe(true);
724
+ });
725
+
726
+ it("joins admin room for admin tokens", async () => {
727
+ const socket = createMockSocket({admin: true, id: "admin1"});
728
+ installRealtimeSocketHandlers(socket);
729
+ await Promise.resolve();
730
+ await Promise.resolve();
731
+ expect(socket.rooms.has("admin")).toBe(true);
732
+ });
733
+
734
+ it("does not join admin room for non-admin tokens", async () => {
735
+ const socket = createMockSocket({admin: false, id: "user1"});
736
+ installRealtimeSocketHandlers(socket);
737
+ await Promise.resolve();
738
+ await Promise.resolve();
739
+ expect(socket.rooms.has("admin")).toBe(false);
740
+ });
741
+ });
742
+
743
+ describe("subscribe:model permission", () => {
744
+ it("denies unregistered collections", async () => {
745
+ const socket = createMockSocket({id: "user1"});
746
+ installRealtimeSocketHandlers(socket);
747
+ await socket.trigger("subscribe:model", "nonexistent");
748
+ expect(socket.rooms.has("model:nonexistent")).toBe(false);
749
+ });
750
+
751
+ it("denies owner-strategy model room for non-admin users", async () => {
752
+ registerOwnerCollection();
753
+ const socket = createMockSocket({admin: false, id: "user1"});
754
+ installRealtimeSocketHandlers(socket);
755
+ await socket.trigger("subscribe:model", "todos");
756
+ expect(socket.rooms.has("model:todos")).toBe(false);
757
+ });
758
+
759
+ it("allows owner-strategy model room for admins", async () => {
760
+ registerOwnerCollection();
761
+ const socket = createMockSocket({admin: true, id: "admin1"});
762
+ installRealtimeSocketHandlers(socket);
763
+ await socket.trigger("subscribe:model", "todos");
764
+ expect(socket.rooms.has("model:todos")).toBe(true);
765
+ });
766
+
767
+ it("allows non-admins to subscribe to model-strategy collections", async () => {
768
+ registerModelCollection();
769
+ const socket = createMockSocket({admin: false, id: "user1"});
770
+ installRealtimeSocketHandlers(socket);
771
+ await socket.trigger("subscribe:model", "broadcasts");
772
+ expect(socket.rooms.has("model:broadcasts")).toBe(true);
773
+ });
774
+
775
+ it("denies model-strategy collections when modelRouter list permission fails", async () => {
776
+ registerAdminOnlyCollection();
777
+ const socket = createMockSocket({admin: false, id: "user1"});
778
+ installRealtimeSocketHandlers(socket);
779
+ await socket.trigger("subscribe:model", "secrets");
780
+ expect(socket.rooms.has("model:secrets")).toBe(false);
781
+ });
782
+
783
+ it("allows model-strategy collections when modelRouter list permission passes", async () => {
784
+ registerAdminOnlyCollection();
785
+ const socket = createMockSocket({admin: true, id: "admin1"});
786
+ installRealtimeSocketHandlers(socket);
787
+ await socket.trigger("subscribe:model", "secrets");
788
+ expect(socket.rooms.has("model:secrets")).toBe(true);
789
+ });
790
+
791
+ it("ignores empty or non-string model names", async () => {
792
+ registerModelCollection();
793
+ const socket = createMockSocket({id: "user1"});
794
+ installRealtimeSocketHandlers(socket);
795
+ await socket.trigger("subscribe:model", "");
796
+ await socket.trigger("subscribe:model", 123 as any);
797
+ await socket.trigger("subscribe:model", null as any);
798
+ const modelRooms = Array.from(socket.rooms).filter((r) => r.startsWith("model:"));
799
+ expect(modelRooms).toHaveLength(0);
800
+ });
801
+
802
+ it("enforces MAX_MODEL_SUBSCRIPTIONS cap", async () => {
803
+ // Register MAX + 5 model-strategy collections.
804
+ for (let i = 0; i < MAX_MODEL_SUBSCRIPTIONS + 5; i++) {
805
+ registerRealtime({
806
+ collectionName: `coll${i}`,
807
+ config: {methods: ["create"], roomStrategy: "model"},
808
+ modelName: `Coll${i}`,
809
+ options: {
810
+ permissions: {
811
+ create: [() => true],
812
+ delete: [() => true],
813
+ list: [() => true],
814
+ read: [() => true],
815
+ update: [() => true],
816
+ },
817
+ } as any,
818
+ routePath: `/coll${i}`,
819
+ });
820
+ }
821
+ const socket = createMockSocket({id: "user1"});
822
+ installRealtimeSocketHandlers(socket);
823
+ for (let i = 0; i < MAX_MODEL_SUBSCRIPTIONS + 5; i++) {
824
+ await socket.trigger("subscribe:model", `coll${i}`);
825
+ }
826
+ const modelRooms = Array.from(socket.rooms).filter((r) => r.startsWith("model:"));
827
+ expect(modelRooms.length).toBe(MAX_MODEL_SUBSCRIPTIONS);
828
+ });
829
+ });
830
+
831
+ describe("subscribe:document permission", () => {
832
+ it("denies unregistered collections", async () => {
833
+ const socket = createMockSocket({id: "user1"});
834
+ installRealtimeSocketHandlers(socket);
835
+ await socket.trigger("subscribe:document", {collection: "nonexistent", id: "abc"});
836
+ const docRooms = Array.from(socket.rooms).filter((r) => r.startsWith("document:"));
837
+ expect(docRooms).toHaveLength(0);
838
+ });
839
+
840
+ it("denies owner-strategy document subscription for non-admin", async () => {
841
+ registerOwnerCollection();
842
+ const socket = createMockSocket({admin: false, id: "user1"});
843
+ installRealtimeSocketHandlers(socket);
844
+ await socket.trigger("subscribe:document", {collection: "todos", id: "doc1"});
845
+ expect(socket.rooms.has("document:todos:doc1")).toBe(false);
846
+ });
847
+
848
+ it("allows owner-strategy document subscription for admins", async () => {
849
+ registerOwnerCollection();
850
+ const socket = createMockSocket({admin: true, id: "admin1"});
851
+ installRealtimeSocketHandlers(socket);
852
+ await socket.trigger("subscribe:document", {collection: "todos", id: "doc1"});
853
+ expect(socket.rooms.has("document:todos:doc1")).toBe(true);
854
+ });
855
+
856
+ it("allows model-strategy document subscription for non-admin", async () => {
857
+ registerModelCollection();
858
+ const socket = createMockSocket({admin: false, id: "user1"});
859
+ installRealtimeSocketHandlers(socket);
860
+ await socket.trigger("subscribe:document", {collection: "broadcasts", id: "doc1"});
861
+ expect(socket.rooms.has("document:broadcasts:doc1")).toBe(true);
862
+ });
863
+
864
+ it("denies document subscriptions when modelRouter read permission fails", async () => {
865
+ registerAdminOnlyCollection();
866
+ const socket = createMockSocket({admin: false, id: "user1"});
867
+ installRealtimeSocketHandlers(socket);
868
+ await socket.trigger("subscribe:document", {collection: "secrets", id: "doc1"});
869
+ expect(socket.rooms.has("document:secrets:doc1")).toBe(false);
870
+ });
871
+
872
+ it("ignores malformed payloads", async () => {
873
+ registerModelCollection();
874
+ const socket = createMockSocket({id: "user1"});
875
+ installRealtimeSocketHandlers(socket);
876
+ await socket.trigger("subscribe:document", null);
877
+ await socket.trigger("subscribe:document", {});
878
+ await socket.trigger("subscribe:document", {collection: "broadcasts"});
879
+ await socket.trigger("subscribe:document", {id: "doc1"});
880
+ await socket.trigger("subscribe:document", {collection: 123 as any, id: "doc1"});
881
+ const docRooms = Array.from(socket.rooms).filter((r) => r.startsWith("document:"));
882
+ expect(docRooms).toHaveLength(0);
883
+ });
884
+ });
885
+
886
+ describe("subscribe:query permission", () => {
887
+ it("denies unregistered collections", async () => {
888
+ const socket = createMockSocket({id: "user1"});
889
+ installRealtimeSocketHandlers(socket);
890
+ await socket.trigger("subscribe:query", {collection: "nope", query: {a: 1}});
891
+ const queryRooms = Array.from(socket.rooms).filter((r) => r.startsWith("query:"));
892
+ expect(queryRooms).toHaveLength(0);
893
+ });
894
+
895
+ it("injects ownerId for owner-strategy non-admin subscribers", async () => {
896
+ registerOwnerCollection();
897
+ const socket = createMockSocket({admin: false, id: "user1"});
898
+ installRealtimeSocketHandlers(socket);
899
+ await socket.trigger("subscribe:query", {
900
+ collection: "todos",
901
+ query: {completed: false},
902
+ });
903
+
904
+ // queryId emitted back must encode the injected ownerId
905
+ const subscribed = socket.emitted.find((e) => e.event === "query:subscribed");
906
+ expect(subscribed).toBeDefined();
907
+ expect((subscribed?.payload as any).queryId).toContain("user1");
908
+ });
909
+
910
+ it("does NOT inject ownerId for admins (admins see all)", async () => {
911
+ registerOwnerCollection();
912
+ const socket = createMockSocket({admin: true, id: "admin1"});
913
+ installRealtimeSocketHandlers(socket);
914
+ await socket.trigger("subscribe:query", {
915
+ collection: "todos",
916
+ query: {completed: false},
917
+ });
918
+ const subscribed = socket.emitted.find((e) => e.event === "query:subscribed");
919
+ expect(subscribed).toBeDefined();
920
+ expect((subscribed?.payload as any).queryId).not.toContain("admin1");
921
+ });
922
+
923
+ it("ignores subscriptions when user has no id (anonymous) for owner strategy", async () => {
924
+ registerOwnerCollection();
925
+ const socket = createMockSocket({admin: false}); // no id
926
+ installRealtimeSocketHandlers(socket);
927
+ await socket.trigger("subscribe:query", {
928
+ collection: "todos",
929
+ query: {completed: false},
930
+ });
931
+ const queryRooms = Array.from(socket.rooms).filter((r) => r.startsWith("query:"));
932
+ expect(queryRooms).toHaveLength(0);
933
+ });
934
+
935
+ it("computes the queryId server-side regardless of client-provided value", async () => {
936
+ registerOwnerCollection();
937
+ const socket = createMockSocket({admin: true, id: "admin1"});
938
+ installRealtimeSocketHandlers(socket);
939
+ await socket.trigger("subscribe:query", {
940
+ collection: "todos",
941
+ query: {completed: false},
942
+ queryId: "EVIL_HIJACK", // client-provided is ignored
943
+ });
944
+ const subscribed = socket.emitted.find((e) => e.event === "query:subscribed");
945
+ const payload = subscribed?.payload as {queryId: string};
946
+ expect(payload.queryId).not.toBe("EVIL_HIJACK");
947
+ // Must match what the server computes
948
+ expect(payload.queryId).toBe(computeQueryId("todos", {completed: false}));
949
+ });
950
+
951
+ it("denies query subscriptions when modelRouter list permission fails", async () => {
952
+ registerAdminOnlyCollection();
953
+ const socket = createMockSocket({admin: false, id: "user1"});
954
+ installRealtimeSocketHandlers(socket);
955
+ await socket.trigger("subscribe:query", {
956
+ collection: "secrets",
957
+ query: {classification: "restricted"},
958
+ });
959
+ const queryRooms = Array.from(socket.rooms).filter((r) => r.startsWith("query:"));
960
+ expect(queryRooms).toHaveLength(0);
961
+ expect(getQuerySubscriptionsForCollection("secrets")).toHaveLength(0);
962
+ });
963
+
964
+ it("applies modelRouter queryFilter before storing a query subscription", async () => {
965
+ registerRealtime({
966
+ collectionName: "filtered",
967
+ config: {methods: ["create"], roomStrategy: "model"},
968
+ modelName: "Filtered",
969
+ options: {
970
+ permissions: {
971
+ create: [() => true],
972
+ delete: [() => true],
973
+ list: [() => true],
974
+ read: [() => true],
975
+ update: [() => true],
976
+ },
977
+ queryFilter: () => ({tenantId: "tenant-1"}),
978
+ } as any,
979
+ routePath: "/filtered",
980
+ });
981
+ const socket = createMockSocket({id: "user1"});
982
+ installRealtimeSocketHandlers(socket);
983
+ await socket.trigger("subscribe:query", {
984
+ collection: "filtered",
985
+ query: {status: "active"},
986
+ });
987
+ const subs = getQuerySubscriptionsForCollection("filtered");
988
+ expect(subs).toHaveLength(1);
989
+ expect(subs[0].query).toEqual({status: "active", tenantId: "tenant-1"});
990
+ });
991
+
992
+ it("denies query subscriptions when modelRouter queryFilter throws", async () => {
993
+ registerRealtime({
994
+ collectionName: "filtered",
995
+ config: {methods: ["create"], roomStrategy: "model"},
996
+ modelName: "Filtered",
997
+ options: {
998
+ permissions: {
999
+ create: [() => true],
1000
+ delete: [() => true],
1001
+ list: [() => true],
1002
+ read: [() => true],
1003
+ update: [() => true],
1004
+ },
1005
+ queryFilter: () => {
1006
+ throw new Error("tenant lookup failed");
1007
+ },
1008
+ } as any,
1009
+ routePath: "/filtered",
1010
+ });
1011
+ const socket = createMockSocket({id: "user1"});
1012
+ installRealtimeSocketHandlers(socket);
1013
+ await socket.trigger("subscribe:query", {
1014
+ collection: "filtered",
1015
+ query: {status: "active"},
1016
+ });
1017
+ const queryRooms = Array.from(socket.rooms).filter((r) => r.startsWith("query:"));
1018
+ expect(queryRooms).toHaveLength(0);
1019
+ expect(getQuerySubscriptionsForCollection("filtered")).toHaveLength(0);
1020
+ expect(socket.emitted.some((e) => e.event === "query:subscribed")).toBe(false);
1021
+ });
1022
+
1023
+ it("ignores malformed query payloads", async () => {
1024
+ registerOwnerCollection();
1025
+ const socket = createMockSocket({id: "user1"});
1026
+ installRealtimeSocketHandlers(socket);
1027
+ await socket.trigger("subscribe:query", null);
1028
+ await socket.trigger("subscribe:query", {});
1029
+ await socket.trigger("subscribe:query", {collection: "todos"});
1030
+ await socket.trigger("subscribe:query", {collection: "todos", query: [1, 2, 3]});
1031
+ const queryRooms = Array.from(socket.rooms).filter((r) => r.startsWith("query:"));
1032
+ expect(queryRooms).toHaveLength(0);
1033
+ });
1034
+
1035
+ it("enforces MAX_QUERY_SUBSCRIPTIONS cap", async () => {
1036
+ registerOwnerCollection();
1037
+ const socket = createMockSocket({admin: true, id: "admin1"});
1038
+ installRealtimeSocketHandlers(socket);
1039
+ for (let i = 0; i < MAX_QUERY_SUBSCRIPTIONS + 5; i++) {
1040
+ await socket.trigger("subscribe:query", {
1041
+ collection: "todos",
1042
+ query: {priority: i},
1043
+ });
1044
+ }
1045
+ const queryRooms = Array.from(socket.rooms).filter((r) => r.startsWith("query:"));
1046
+ expect(queryRooms.length).toBe(MAX_QUERY_SUBSCRIPTIONS);
1047
+ });
1048
+ });
1049
+
1050
+ describe("unsubscribe and counters", () => {
1051
+ it("unsubscribe:model frees a slot so further subscriptions are allowed", async () => {
1052
+ registerModelCollection();
1053
+ registerRealtime({
1054
+ collectionName: "other",
1055
+ config: {methods: ["create"], roomStrategy: "model"},
1056
+ modelName: "Other",
1057
+ options: {
1058
+ permissions: {
1059
+ create: [() => true],
1060
+ delete: [() => true],
1061
+ list: [() => true],
1062
+ read: [() => true],
1063
+ update: [() => true],
1064
+ },
1065
+ } as any,
1066
+ routePath: "/other",
1067
+ });
1068
+ const socket = createMockSocket({id: "user1"});
1069
+ installRealtimeSocketHandlers(socket);
1070
+ await socket.trigger("subscribe:model", "broadcasts");
1071
+ expect(socket.rooms.has("model:broadcasts")).toBe(true);
1072
+ await socket.trigger("unsubscribe:model", "broadcasts");
1073
+ expect(socket.rooms.has("model:broadcasts")).toBe(false);
1074
+ // can re-subscribe
1075
+ await socket.trigger("subscribe:model", "other");
1076
+ expect(socket.rooms.has("model:other")).toBe(true);
1077
+ });
1078
+
1079
+ it("unsubscribe:document removes the room and decrements the counter", async () => {
1080
+ registerModelCollection();
1081
+ const socket = createMockSocket({id: "user1"});
1082
+ installRealtimeSocketHandlers(socket);
1083
+ await socket.trigger("subscribe:document", {collection: "broadcasts", id: "doc1"});
1084
+ expect(socket.rooms.has("document:broadcasts:doc1")).toBe(true);
1085
+ await socket.trigger("unsubscribe:document", {collection: "broadcasts", id: "doc1"});
1086
+ expect(socket.rooms.has("document:broadcasts:doc1")).toBe(false);
1087
+ });
1088
+
1089
+ it("unsubscribe:document ignores malformed payloads", async () => {
1090
+ const socket = createMockSocket({id: "user1"});
1091
+ installRealtimeSocketHandlers(socket);
1092
+ // None of these should throw or affect rooms
1093
+ await socket.trigger("unsubscribe:document", null);
1094
+ await socket.trigger("unsubscribe:document", {});
1095
+ await socket.trigger("unsubscribe:document", {collection: "broadcasts"});
1096
+ await socket.trigger("unsubscribe:document", {id: "doc1"});
1097
+ const docRooms = Array.from(socket.rooms).filter((r) => r.startsWith("document:"));
1098
+ expect(docRooms).toHaveLength(0);
1099
+ });
1100
+
1101
+ it("unsubscribe:query removes the query subscription and leaves the room", async () => {
1102
+ registerModelCollection();
1103
+ const socket = createMockSocket({id: "user1"});
1104
+ installRealtimeSocketHandlers(socket);
1105
+ await socket.trigger("subscribe:query", {collection: "broadcasts", query: {priority: 1}});
1106
+ const subscribed = socket.emitted.find((e) => e.event === "query:subscribed");
1107
+ expect(subscribed).toBeDefined();
1108
+ const queryId = (subscribed?.payload as {queryId: string}).queryId;
1109
+ expect(socket.rooms.has(`query:${queryId}`)).toBe(true);
1110
+ expect(getQuerySubscriptionsForCollection("broadcasts").length).toBe(1);
1111
+ await socket.trigger("unsubscribe:query", {queryId});
1112
+ expect(socket.rooms.has(`query:${queryId}`)).toBe(false);
1113
+ expect(getQuerySubscriptionsForCollection("broadcasts").length).toBe(0);
1114
+ });
1115
+
1116
+ it("unsubscribe:query ignores malformed payloads", async () => {
1117
+ const socket = createMockSocket({id: "user1"});
1118
+ installRealtimeSocketHandlers(socket);
1119
+ await socket.trigger("unsubscribe:query", null);
1120
+ await socket.trigger("unsubscribe:query", {});
1121
+ // No assertion needed — just exercises the no-op path
1122
+ });
1123
+
1124
+ it("disconnect removes all query subscriptions for the socket", async () => {
1125
+ registerOwnerCollection();
1126
+ const socket = createMockSocket({admin: true, id: "admin1"});
1127
+ installRealtimeSocketHandlers(socket);
1128
+ await socket.trigger("subscribe:query", {collection: "todos", query: {priority: 1}});
1129
+ await socket.trigger("subscribe:query", {collection: "todos", query: {priority: 2}});
1130
+ expect(getQuerySubscriptionsForCollection("todos").length).toBeGreaterThan(0);
1131
+ await socket.trigger("disconnect");
1132
+ // The mock leaves rooms intact but the store should be cleared for this socket.
1133
+ // Other sockets aren't subscribed, so the store should be empty.
1134
+ expect(getQuerySubscriptionsForCollection("todos").length).toBe(0);
1135
+ });
1136
+ });
1137
+ });
1138
+
1139
+ // ─────────────────────────────────────────────────────────────────────────────
1140
+ // serializeDoc — responseHandler fallback for change stream events
1141
+ // ─────────────────────────────────────────────────────────────────────────────
1142
+
1143
+ describe("serializeDoc (change stream serializer)", () => {
1144
+ const makeEntry = (overrides: any = {}) => ({
1145
+ collectionName: "users",
1146
+ config: {methods: ["create", "update", "delete"] as const, roomStrategy: "model" as const},
1147
+ modelName: "User",
1148
+ options: {} as any,
1149
+ routePath: "/users",
1150
+ ...overrides,
1151
+ });
1152
+
1153
+ it("prefers realtimeResponseHandler when provided", async () => {
1154
+ const entry = makeEntry({
1155
+ config: {
1156
+ methods: ["update"],
1157
+ realtimeResponseHandler: (doc: any) => ({customized: doc.name}),
1158
+ roomStrategy: "model",
1159
+ },
1160
+ });
1161
+ const result = await serializeDoc(entry as any, {name: "Alice", secret: "x"}, "update");
1162
+ expect(result).toEqual({customized: "Alice"});
1163
+ });
1164
+
1165
+ it("falls back to modelRouter responseHandler when no realtime handler is set", async () => {
1166
+ // Mimics a stripping responseHandler like the example-backend users router.
1167
+ const responseHandler = mock(async (doc: any) => {
1168
+ const {hash, salt, ...rest} = doc;
1169
+ return rest;
1170
+ });
1171
+ const entry = makeEntry({options: {responseHandler}});
1172
+ const result = await serializeDoc(
1173
+ entry as any,
1174
+ {email: "a@b.com", hash: "h", name: "Alice", salt: "s"},
1175
+ "update"
1176
+ );
1177
+ expect(result).toEqual({email: "a@b.com", name: "Alice"});
1178
+ expect(responseHandler).toHaveBeenCalled();
1179
+ });
1180
+
1181
+ it("maps 'delete' method to 'read' when invoking the REST responseHandler", async () => {
1182
+ let observedMethod: string | undefined;
1183
+ const responseHandler = async (doc: any, method: string) => {
1184
+ observedMethod = method;
1185
+ return doc;
1186
+ };
1187
+ const entry = makeEntry({options: {responseHandler}});
1188
+ await serializeDoc(entry as any, {name: "Alice"}, "delete");
1189
+ expect(observedMethod).toBe("read");
1190
+ });
1191
+
1192
+ it("re-throws when modelRouter responseHandler throws (event dropped, no leak)", async () => {
1193
+ // Critical: do NOT fall back to toJSON, which would skip the handler's sanitization
1194
+ // (e.g. stripping hash/salt) and leak the raw document.
1195
+ const responseHandler = async (): Promise<any> => {
1196
+ throw new Error("boom");
1197
+ };
1198
+ const entry = makeEntry({options: {responseHandler}});
1199
+ const doc = {hash: "h", name: "Alice", salt: "s", toJSON: () => ({hash: "h", name: "Alice"})};
1200
+ await expect(serializeDoc(entry as any, doc, "update")).rejects.toThrow("boom");
1201
+ });
1202
+
1203
+ it("re-throws when realtimeResponseHandler throws (event dropped, no leak)", async () => {
1204
+ const entry = makeEntry({
1205
+ config: {
1206
+ methods: ["update"],
1207
+ realtimeResponseHandler: () => {
1208
+ throw new Error("boom");
1209
+ },
1210
+ roomStrategy: "model",
1211
+ },
1212
+ });
1213
+ const doc = {name: "Alice", toJSON: () => ({name: "Alice-json"})};
1214
+ await expect(serializeDoc(entry as any, doc, "update")).rejects.toThrow("boom");
1215
+ });
1216
+
1217
+ it("returns toJSON output when no handlers are configured", async () => {
1218
+ const entry = makeEntry();
1219
+ const doc = {name: "Alice", toJSON: () => ({id: "1", name: "Alice"})};
1220
+ const result = await serializeDoc(entry as any, doc, "create");
1221
+ expect(result).toEqual({id: "1", name: "Alice"});
1222
+ });
1223
+
1224
+ it("returns raw doc when toJSON is missing and no handlers configured", async () => {
1225
+ const entry = makeEntry();
1226
+ const result = await serializeDoc(entry as any, {name: "Alice"}, "create");
1227
+ expect(result).toEqual({name: "Alice"});
1228
+ });
1229
+
1230
+ it("adds id from _id when handlers omit it (change stream raw document shape)", async () => {
1231
+ const entry = makeEntry();
1232
+ const result = await serializeDoc(
1233
+ entry as any,
1234
+ {_id: "507f1f77bcf86cd799439011", name: "Alice"},
1235
+ "create"
1236
+ );
1237
+ expect(result).toEqual({
1238
+ _id: "507f1f77bcf86cd799439011",
1239
+ id: "507f1f77bcf86cd799439011",
1240
+ name: "Alice",
1241
+ });
1242
+ });
1243
+ });
1244
+
1245
+ // ─────────────────────────────────────────────────────────────────────────────
1246
+ // changeStreamWatcher — internal helpers
1247
+ // ─────────────────────────────────────────────────────────────────────────────
1248
+
1249
+ describe("mapOperationType", () => {
1250
+ it("maps insert to create", () => {
1251
+ expect(mapOperationType("insert", {} as any)).toBe("create");
1252
+ });
1253
+
1254
+ it("maps update to update by default", () => {
1255
+ expect(
1256
+ mapOperationType("update", {
1257
+ updateDescription: {updatedFields: {title: "x"}},
1258
+ } as any)
1259
+ ).toBe("update");
1260
+ });
1261
+
1262
+ it("maps replace to update", () => {
1263
+ expect(mapOperationType("replace", {} as any)).toBe("update");
1264
+ });
1265
+
1266
+ it("maps update with deleted=true to delete (soft delete) when delete is enabled", () => {
1267
+ expect(
1268
+ mapOperationType("update", {updateDescription: {updatedFields: {deleted: true}}} as any, [
1269
+ "create",
1270
+ "update",
1271
+ "delete",
1272
+ ])
1273
+ ).toBe("delete");
1274
+ });
1275
+
1276
+ it("keeps soft delete as update when delete is NOT in the enabled methods", () => {
1277
+ // A model configured with methods: ["create", "update"] must still see
1278
+ // soft-delete events as updates — otherwise they'd be silently dropped.
1279
+ expect(
1280
+ mapOperationType("update", {updateDescription: {updatedFields: {deleted: true}}} as any, [
1281
+ "create",
1282
+ "update",
1283
+ ])
1284
+ ).toBe("update");
1285
+ });
1286
+
1287
+ it("maps delete to delete", () => {
1288
+ expect(mapOperationType("delete", {} as any)).toBe("delete");
1289
+ });
1290
+
1291
+ it("returns null for unknown operation types", () => {
1292
+ expect(mapOperationType("invalidate", {} as any)).toBeNull();
1293
+ expect(mapOperationType("drop", {} as any)).toBeNull();
1294
+ });
1295
+ });
1296
+
1297
+ describe("resolveRooms", () => {
1298
+ const baseEntry: any = {
1299
+ collectionName: "todos",
1300
+ config: {methods: ["create", "update", "delete"]},
1301
+ modelName: "Todo",
1302
+ options: {},
1303
+ routePath: "/todos",
1304
+ };
1305
+
1306
+ it("returns user-specific room for owner strategy with ownerId", () => {
1307
+ const entry = {...baseEntry, config: {...baseEntry.config, roomStrategy: "owner"}};
1308
+ const rooms = resolveRooms(entry, {ownerId: "user-1"}, "create");
1309
+ expect(rooms).toEqual(["user:user-1"]);
1310
+ });
1311
+
1312
+ it("converts ObjectId-like ownerId to string", () => {
1313
+ const entry = {...baseEntry, config: {...baseEntry.config, roomStrategy: "owner"}};
1314
+ const ownerId = {toString: (): string => "owner-from-obj"};
1315
+ const rooms = resolveRooms(entry, {ownerId}, "create");
1316
+ expect(rooms).toEqual(["user:owner-from-obj"]);
1317
+ });
1318
+
1319
+ it("falls back to model room when owner strategy has no ownerId", () => {
1320
+ const entry = {...baseEntry, config: {...baseEntry.config, roomStrategy: "owner"}};
1321
+ const rooms = resolveRooms(entry, {}, "create");
1322
+ expect(rooms).toEqual(["model:todos"]);
1323
+ });
1324
+
1325
+ it("returns model room for model strategy", () => {
1326
+ const entry = {...baseEntry, config: {...baseEntry.config, roomStrategy: "model"}};
1327
+ const rooms = resolveRooms(entry, {}, "create");
1328
+ expect(rooms).toEqual(["model:todos"]);
1329
+ });
1330
+
1331
+ it("returns authenticated room for broadcast strategy", () => {
1332
+ const entry = {...baseEntry, config: {...baseEntry.config, roomStrategy: "broadcast"}};
1333
+ const rooms = resolveRooms(entry, {}, "create");
1334
+ expect(rooms).toEqual(["authenticated"]);
1335
+ });
1336
+
1337
+ it("defaults to model room for unknown strategy", () => {
1338
+ const entry = {...baseEntry, config: {...baseEntry.config, roomStrategy: "unknown" as any}};
1339
+ const rooms = resolveRooms(entry, {}, "create");
1340
+ expect(rooms).toEqual(["model:todos"]);
1341
+ });
1342
+
1343
+ it("invokes custom function room resolver", () => {
1344
+ const entry = {
1345
+ ...baseEntry,
1346
+ config: {
1347
+ ...baseEntry.config,
1348
+ roomStrategy: (doc: any, method: string): string[] => [`custom:${method}:${doc.id}`],
1349
+ },
1350
+ };
1351
+ const rooms = resolveRooms(entry, {id: "42"}, "update");
1352
+ expect(rooms).toEqual(["custom:update:42"]);
1353
+ });
1354
+ });
1355
+
1356
+ describe("emitToDocumentAndQueryRooms", () => {
1357
+ const permissiveOptions = {
1358
+ permissions: {
1359
+ create: [() => true],
1360
+ delete: [() => true],
1361
+ list: [() => true],
1362
+ read: [() => true],
1363
+ update: [() => true],
1364
+ },
1365
+ };
1366
+
1367
+ const makeIo = (): {
1368
+ addSocketToRoom: (room: string, decodedToken?: {id?: string; admin?: boolean}) => void;
1369
+ emissions: Array<{room: string; event: string; payload: unknown}>;
1370
+ io: any;
1371
+ } => {
1372
+ const emissions: Array<{room: string; event: string; payload: unknown}> = [];
1373
+ const roomSockets = new Map<string, Set<string>>();
1374
+ const sockets = new Map<string, any>();
1375
+ let nextSocketId = 1;
1376
+ const addSocketToRoom = (
1377
+ room: string,
1378
+ decodedToken: {id?: string; admin?: boolean} = {admin: true, id: "admin"}
1379
+ ): void => {
1380
+ const socketId = `socket-${nextSocketId}`;
1381
+ nextSocketId += 1;
1382
+ if (!roomSockets.has(room)) {
1383
+ roomSockets.set(room, new Set());
1384
+ }
1385
+ roomSockets.get(room)?.add(socketId);
1386
+ sockets.set(socketId, {
1387
+ decodedToken,
1388
+ emit: (event: string, payload: unknown): void => {
1389
+ emissions.push({event, payload, room});
1390
+ },
1391
+ id: socketId,
1392
+ });
1393
+ };
1394
+ const io = {
1395
+ sockets: {
1396
+ adapter: {rooms: roomSockets},
1397
+ sockets,
1398
+ },
1399
+ to: (room: string) => ({
1400
+ emit: (event: string, payload: unknown): void => {
1401
+ emissions.push({event, payload, room});
1402
+ },
1403
+ }),
1404
+ };
1405
+ return {addSocketToRoom, emissions, io};
1406
+ };
1407
+
1408
+ beforeEach(() => {
1409
+ clearQueryStore();
1410
+ });
1411
+
1412
+ afterEach(() => {
1413
+ clearQueryStore();
1414
+ });
1415
+
1416
+ it("emits to the document room", async () => {
1417
+ const {emissions, io} = makeIo();
1418
+ const event: any = {
1419
+ collection: "todos",
1420
+ id: "doc-1",
1421
+ method: "update",
1422
+ model: "Todo",
1423
+ timestamp: 1,
1424
+ };
1425
+ await emitToDocumentAndQueryRooms(io, "todos", event, {}, () => {});
1426
+ expect(emissions.some((e) => e.room === "document:todos:doc-1")).toBe(true);
1427
+ });
1428
+
1429
+ it("forwards hard deletes to every query room for non-owner strategies", async () => {
1430
+ const queryId = computeQueryId("todos", {priority: 1});
1431
+ addQuerySubscription("socket-a", "todos", {priority: 1}, queryId);
1432
+ const {addSocketToRoom, emissions, io} = makeIo();
1433
+ addSocketToRoom(`query:${queryId}`);
1434
+ const event: any = {
1435
+ collection: "todos",
1436
+ id: "doc-1",
1437
+ method: "delete",
1438
+ model: "Todo",
1439
+ timestamp: 1,
1440
+ };
1441
+ const entry: any = {
1442
+ collectionName: "todos",
1443
+ config: {methods: ["delete"], roomStrategy: "model"},
1444
+ modelName: "Todo",
1445
+ options: permissiveOptions,
1446
+ routePath: "/todos",
1447
+ };
1448
+ await emitToDocumentAndQueryRooms(io, "todos", event, undefined, () => {}, entry);
1449
+ expect(emissions.some((e) => e.room === `query:${queryId}` && e.event === "sync")).toBe(true);
1450
+ });
1451
+
1452
+ it("does NOT forward hard deletes to query rooms for owner-strategy collections", async () => {
1453
+ const queryId = computeQueryId("todos", {ownerId: "user-1"});
1454
+ addQuerySubscription("socket-a", "todos", {ownerId: "user-1"}, queryId);
1455
+ const {addSocketToRoom, emissions, io} = makeIo();
1456
+ addSocketToRoom("document:todos:doc-1");
1457
+ addSocketToRoom(`query:${queryId}`);
1458
+ const event: any = {
1459
+ collection: "todos",
1460
+ id: "doc-1",
1461
+ method: "delete",
1462
+ model: "Todo",
1463
+ timestamp: 1,
1464
+ };
1465
+ const entry: any = {
1466
+ collectionName: "todos",
1467
+ config: {methods: ["delete"], roomStrategy: "owner"},
1468
+ modelName: "Todo",
1469
+ options: permissiveOptions,
1470
+ routePath: "/todos",
1471
+ };
1472
+ await emitToDocumentAndQueryRooms(io, "todos", event, undefined, () => {}, entry);
1473
+ // Document room still receives the event, but query rooms must not — otherwise users
1474
+ // would see deletes for docs they don't own.
1475
+ expect(emissions.some((e) => e.room === `query:${queryId}`)).toBe(false);
1476
+ expect(emissions.some((e) => e.room === "document:todos:doc-1")).toBe(true);
1477
+ });
1478
+
1479
+ it("forwards soft deletes only to query rooms whose filter the document satisfies", async () => {
1480
+ const matchingQueryId = computeQueryId("todos", {priority: 1});
1481
+ const nonMatchingQueryId = computeQueryId("todos", {priority: 9});
1482
+ addQuerySubscription("socket-a", "todos", {priority: 1}, matchingQueryId);
1483
+ addQuerySubscription("socket-b", "todos", {priority: 9}, nonMatchingQueryId);
1484
+ const {addSocketToRoom, emissions, io} = makeIo();
1485
+ addSocketToRoom(`query:${matchingQueryId}`);
1486
+ addSocketToRoom(`query:${nonMatchingQueryId}`);
1487
+ const event: any = {
1488
+ collection: "todos",
1489
+ data: {deleted: true, priority: 1},
1490
+ id: "doc-1",
1491
+ method: "delete",
1492
+ model: "Todo",
1493
+ timestamp: 1,
1494
+ };
1495
+ const entry: any = {
1496
+ collectionName: "todos",
1497
+ config: {methods: ["delete"], roomStrategy: "owner"},
1498
+ modelName: "Todo",
1499
+ options: permissiveOptions,
1500
+ routePath: "/todos",
1501
+ };
1502
+ await emitToDocumentAndQueryRooms(
1503
+ io,
1504
+ "todos",
1505
+ event,
1506
+ {deleted: true, priority: 1},
1507
+ () => {},
1508
+ entry
1509
+ );
1510
+ expect(emissions.some((e) => e.room === `query:${matchingQueryId}`)).toBe(true);
1511
+ expect(emissions.some((e) => e.room === `query:${nonMatchingQueryId}`)).toBe(false);
1512
+ });
1513
+
1514
+ it("skips matching when fullDocument is missing on non-delete events", async () => {
1515
+ const queryId = computeQueryId("todos", {priority: 1});
1516
+ addQuerySubscription("socket-a", "todos", {priority: 1}, queryId);
1517
+ const {emissions, io} = makeIo();
1518
+ const event: any = {
1519
+ collection: "todos",
1520
+ id: "doc-1",
1521
+ method: "create",
1522
+ model: "Todo",
1523
+ timestamp: 1,
1524
+ };
1525
+ await emitToDocumentAndQueryRooms(io, "todos", event, undefined, () => {});
1526
+ expect(emissions.some((e) => e.room === `query:${queryId}`)).toBe(false);
1527
+ });
1528
+
1529
+ it("emits create events to query rooms when the doc matches", async () => {
1530
+ const queryId = computeQueryId("todos", {priority: 1});
1531
+ addQuerySubscription("socket-a", "todos", {priority: 1}, queryId);
1532
+ const {emissions, io} = makeIo();
1533
+ const event: any = {
1534
+ collection: "todos",
1535
+ id: "doc-1",
1536
+ method: "create",
1537
+ model: "Todo",
1538
+ timestamp: 1,
1539
+ };
1540
+ await emitToDocumentAndQueryRooms(io, "todos", event, {priority: 1}, () => {});
1541
+ const queryEmissions = emissions.filter((e) => e.room === `query:${queryId}`);
1542
+ expect(queryEmissions.length).toBe(1);
1543
+ expect(queryEmissions[0].payload).toMatchObject({method: "create"});
1544
+ });
1545
+
1546
+ it("does not emit create events to query rooms when the doc does not match", async () => {
1547
+ const queryId = computeQueryId("todos", {priority: 1});
1548
+ addQuerySubscription("socket-a", "todos", {priority: 1}, queryId);
1549
+ const {emissions, io} = makeIo();
1550
+ const event: any = {
1551
+ collection: "todos",
1552
+ id: "doc-1",
1553
+ method: "create",
1554
+ model: "Todo",
1555
+ timestamp: 1,
1556
+ };
1557
+ await emitToDocumentAndQueryRooms(io, "todos", event, {priority: 9}, () => {});
1558
+ expect(emissions.some((e) => e.room === `query:${queryId}`)).toBe(false);
1559
+ });
1560
+
1561
+ it("emits update as-is when the document still matches the query", async () => {
1562
+ const queryId = computeQueryId("todos", {priority: 1});
1563
+ addQuerySubscription("socket-a", "todos", {priority: 1}, queryId);
1564
+ const {emissions, io} = makeIo();
1565
+ const event: any = {
1566
+ collection: "todos",
1567
+ id: "doc-1",
1568
+ method: "update",
1569
+ model: "Todo",
1570
+ timestamp: 1,
1571
+ };
1572
+ await emitToDocumentAndQueryRooms(io, "todos", event, {priority: 1}, () => {});
1573
+ const queryEmissions = emissions.filter((e) => e.room === `query:${queryId}`);
1574
+ expect(queryEmissions.length).toBe(1);
1575
+ expect(queryEmissions[0].payload).toMatchObject({method: "update"});
1576
+ });
1577
+
1578
+ it("converts updates that no longer match into delete events for the query", async () => {
1579
+ const queryId = computeQueryId("todos", {priority: 1});
1580
+ addQuerySubscription("socket-a", "todos", {priority: 1}, queryId);
1581
+ const {emissions, io} = makeIo();
1582
+ const event: any = {
1583
+ collection: "todos",
1584
+ id: "doc-1",
1585
+ method: "update",
1586
+ model: "Todo",
1587
+ timestamp: 1,
1588
+ };
1589
+ await emitToDocumentAndQueryRooms(io, "todos", event, {priority: 9}, () => {});
1590
+ const queryEmissions = emissions.filter((e) => e.room === `query:${queryId}`);
1591
+ expect(queryEmissions.length).toBe(1);
1592
+ expect(queryEmissions[0].payload).toMatchObject({method: "delete"});
1593
+ });
1594
+
1595
+ it("filters socket emissions by object read permission and responseHandler", async () => {
1596
+ const {addSocketToRoom, emissions, io} = makeIo();
1597
+ addSocketToRoom("model:todos", {id: "owner-1"});
1598
+ addSocketToRoom("model:todos", {id: "other-user"});
1599
+ const entry: any = {
1600
+ collectionName: "todos",
1601
+ config: {methods: ["update"], roomStrategy: "model"},
1602
+ modelName: "Todo",
1603
+ options: {
1604
+ permissions: {
1605
+ create: [() => true],
1606
+ delete: [() => true],
1607
+ list: [() => true],
1608
+ read: [
1609
+ (_method: string, user?: {admin?: boolean; id?: string}, obj?: {ownerId?: string}) =>
1610
+ user?.admin === true || user?.id === obj?.ownerId,
1611
+ ],
1612
+ update: [() => true],
1613
+ },
1614
+ responseHandler: (doc: any, _method: string, req: any) => ({
1615
+ id: doc._id,
1616
+ title: doc.title,
1617
+ visibleTo: req.user?.id,
1618
+ }),
1619
+ },
1620
+ routePath: "/todos",
1621
+ };
1622
+
1623
+ await emitToAuthorizedRoom(
1624
+ io,
1625
+ "model:todos",
1626
+ {
1627
+ collection: "todos",
1628
+ id: "todo-1",
1629
+ method: "update",
1630
+ model: "Todo",
1631
+ timestamp: 1,
1632
+ },
1633
+ entry,
1634
+ {_id: "todo-1", ownerId: "owner-1", secret: "hidden", title: "Visible"},
1635
+ () => {}
1636
+ );
1637
+
1638
+ expect(emissions).toHaveLength(1);
1639
+ expect(emissions[0].payload).toMatchObject({
1640
+ data: {id: "todo-1", title: "Visible", visibleTo: "owner-1"},
1641
+ });
1642
+ });
1643
+
1644
+ it("continues emitting to other sockets when per-socket serialization fails", async () => {
1645
+ const {addSocketToRoom, emissions, io} = makeIo();
1646
+ addSocketToRoom("model:todos", {id: "bad-user"});
1647
+ addSocketToRoom("model:todos", {id: "good-user"});
1648
+ const entry: any = {
1649
+ collectionName: "todos",
1650
+ config: {methods: ["update"], roomStrategy: "model"},
1651
+ modelName: "Todo",
1652
+ options: {
1653
+ permissions: {
1654
+ create: [() => true],
1655
+ delete: [() => true],
1656
+ list: [() => true],
1657
+ read: [() => true],
1658
+ update: [() => true],
1659
+ },
1660
+ responseHandler: (doc: any, _method: string, req: any) => {
1661
+ if (req.user?.id === "bad-user") {
1662
+ throw new Error("cannot serialize for bad user");
1663
+ }
1664
+
1665
+ return {...doc, visibleTo: req.user?.id};
1666
+ },
1667
+ },
1668
+ routePath: "/todos",
1669
+ };
1670
+
1671
+ await emitToAuthorizedRoom(
1672
+ io,
1673
+ "model:todos",
1674
+ {
1675
+ collection: "todos",
1676
+ id: "todo-1",
1677
+ method: "update",
1678
+ model: "Todo",
1679
+ timestamp: 1,
1680
+ },
1681
+ entry,
1682
+ {_id: "todo-1", title: "Visible"},
1683
+ () => {}
1684
+ );
1685
+
1686
+ expect(emissions).toHaveLength(1);
1687
+ expect(emissions[0].payload).toMatchObject({
1688
+ data: {id: "todo-1", title: "Visible", visibleTo: "good-user"},
1689
+ });
1690
+ });
1691
+
1692
+ it("does not emit hard delete metadata when read permission requires an object owner", async () => {
1693
+ const {addSocketToRoom, emissions, io} = makeIo();
1694
+ addSocketToRoom("model:todos", {id: "other-user"});
1695
+ const entry: any = {
1696
+ collectionName: "todos",
1697
+ config: {methods: ["delete"], roomStrategy: "model"},
1698
+ modelName: "Todo",
1699
+ options: {
1700
+ permissions: {
1701
+ create: [() => true],
1702
+ delete: [() => true],
1703
+ list: [() => true],
1704
+ read: [
1705
+ (_method: string, user?: {admin?: boolean; id?: string}, obj?: {ownerId?: string}) =>
1706
+ user?.admin === true || user?.id === obj?.ownerId,
1707
+ ],
1708
+ update: [() => true],
1709
+ },
1710
+ },
1711
+ routePath: "/todos",
1712
+ };
1713
+
1714
+ await emitToAuthorizedRoom(
1715
+ io,
1716
+ "model:todos",
1717
+ {
1718
+ collection: "todos",
1719
+ id: "todo-1",
1720
+ method: "delete",
1721
+ model: "Todo",
1722
+ timestamp: 1,
1723
+ },
1724
+ entry,
1725
+ undefined,
1726
+ () => {}
1727
+ );
1728
+
1729
+ expect(emissions).toEqual([]);
1730
+ });
1731
+ });
1732
+
1733
+ // ─────────────────────────────────────────────────────────────────────────────
1734
+ // redactCredentials — Redis URL logging
1735
+ // ─────────────────────────────────────────────────────────────────────────────
1736
+
1737
+ describe("redactCredentials", () => {
1738
+ it("redacts user:password@ in a redis URL", () => {
1739
+ expect(redactCredentials("redis://user:secret@host:6379/0")).toBe("redis://***@host:6379/0");
1740
+ });
1741
+
1742
+ it("redacts password-only userinfo", () => {
1743
+ expect(redactCredentials("redis://:secret@host:6379")).toBe("redis://***@host:6379");
1744
+ });
1745
+
1746
+ it("returns the URL unchanged when there are no credentials", () => {
1747
+ expect(redactCredentials("redis://host:6379/0")).toBe("redis://host:6379/0");
1748
+ });
1749
+
1750
+ it("falls back to regex replacement on unparsable URLs", () => {
1751
+ // Some non-URL strings still match the userinfo regex.
1752
+ expect(redactCredentials("rediss://u:p@example.com")).toContain("***@");
1753
+ expect(redactCredentials("rediss://u:p@example.com")).not.toContain("u:p");
1754
+ });
1755
+ });