@terreno/api 0.14.0 → 0.14.2

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.
@@ -9,15 +9,19 @@
9
9
  * - changeStreamWatcher.ts (serializeDoc — responseHandler fallback)
10
10
  */
11
11
 
12
- import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
12
+ import {afterEach, beforeAll, beforeEach, describe, expect, it, mock} from "bun:test";
13
13
  import express from "express";
14
+ import mongoose from "mongoose";
14
15
 
15
16
  import {
16
17
  emitToAuthorizedRoom,
17
18
  emitToDocumentAndQueryRooms,
19
+ ensureApiId,
18
20
  mapOperationType,
19
21
  resolveRooms,
20
22
  serializeDoc,
23
+ startChangeStreamWatcher,
24
+ stopChangeStreamWatcher,
21
25
  } from "./changeStreamWatcher";
22
26
  import {matchesQuery} from "./queryMatcher";
23
27
  import {
@@ -1734,6 +1738,1416 @@ describe("emitToDocumentAndQueryRooms", () => {
1734
1738
  // redactCredentials — Redis URL logging
1735
1739
  // ─────────────────────────────────────────────────────────────────────────────
1736
1740
 
1741
+ // ─────────────────────────────────────────────────────────────────────────────
1742
+ // startChangeStreamWatcher / stopChangeStreamWatcher — MongoDB change stream
1743
+ // ─────────────────────────────────────────────────────────────────────────────
1744
+
1745
+ describe("startChangeStreamWatcher", () => {
1746
+ let originalDb: typeof mongoose.connection.db;
1747
+
1748
+ beforeEach(() => {
1749
+ originalDb = mongoose.connection.db;
1750
+ clearRealtimeRegistry();
1751
+ });
1752
+
1753
+ afterEach(async () => {
1754
+ (mongoose.connection as any).db = originalDb;
1755
+ clearRealtimeRegistry();
1756
+ // Ensure the watcher is stopped between tests
1757
+ const {stopChangeStreamWatcher: stop} = await import("./changeStreamWatcher");
1758
+ await stop();
1759
+ });
1760
+
1761
+ const createMockChangeStream = () => {
1762
+ const listeners = new Map<string, (...args: any[]) => void>();
1763
+ return {
1764
+ close: mock(async () => {}),
1765
+ listeners,
1766
+ on(event: string, handler: (...args: any[]) => void) {
1767
+ listeners.set(event, handler);
1768
+ return this;
1769
+ },
1770
+ trigger(event: string, ...args: any[]) {
1771
+ const handler = listeners.get(event);
1772
+ if (handler) {
1773
+ handler(...args);
1774
+ }
1775
+ },
1776
+ };
1777
+ };
1778
+
1779
+ const createMockIo = () => {
1780
+ const rooms = new Map<string, Set<string>>();
1781
+ const sockets = new Map<string, any>();
1782
+ return {
1783
+ sockets: {
1784
+ adapter: {rooms},
1785
+ sockets,
1786
+ },
1787
+ to: (_room: string) => ({
1788
+ emit: (_event: string, _data: any) => {},
1789
+ }),
1790
+ } as unknown as import("socket.io").Server;
1791
+ };
1792
+
1793
+ it("initializes and registers change/error/close/end listeners", async () => {
1794
+ const mockStream = createMockChangeStream();
1795
+ const mockDb = {
1796
+ watch: mock(() => mockStream),
1797
+ };
1798
+ (mongoose.connection as any).db = mockDb;
1799
+
1800
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
1801
+ const io = createMockIo();
1802
+
1803
+ startChangeStreamWatcher(io, {}, true);
1804
+
1805
+ expect(mockDb.watch).toHaveBeenCalled();
1806
+ expect(mockStream.listeners.has("change")).toBe(true);
1807
+ expect(mockStream.listeners.has("error")).toBe(true);
1808
+ expect(mockStream.listeners.has("close")).toBe(true);
1809
+ expect(mockStream.listeners.has("end")).toBe(true);
1810
+ });
1811
+
1812
+ it("throws when mongoose connection db is unavailable", async () => {
1813
+ (mongoose.connection as any).db = undefined;
1814
+
1815
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
1816
+ const io = createMockIo();
1817
+
1818
+ expect(() => startChangeStreamWatcher(io)).toThrow(
1819
+ "MongoDB connection not available for change stream"
1820
+ );
1821
+ });
1822
+
1823
+ it("throws when watch returns null", async () => {
1824
+ const mockDb = {
1825
+ watch: mock(() => null),
1826
+ };
1827
+ (mongoose.connection as any).db = mockDb;
1828
+
1829
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
1830
+ const io = createMockIo();
1831
+
1832
+ expect(() => startChangeStreamWatcher(io)).toThrow("Failed to create change stream watcher");
1833
+ });
1834
+
1835
+ it("handles change events for registered models", async () => {
1836
+ const mockStream = createMockChangeStream();
1837
+ const mockDb = {
1838
+ watch: mock(() => mockStream),
1839
+ };
1840
+ (mongoose.connection as any).db = mockDb;
1841
+
1842
+ registerRealtime({
1843
+ collectionName: "todos",
1844
+ config: {
1845
+ methods: ["create", "update", "delete"],
1846
+ roomStrategy: "model",
1847
+ },
1848
+ modelName: "Todo",
1849
+ options: {
1850
+ permissions: {
1851
+ create: [() => true],
1852
+ delete: [() => true],
1853
+ list: [() => true],
1854
+ read: [() => true],
1855
+ update: [() => true],
1856
+ },
1857
+ },
1858
+ routePath: "/todos",
1859
+ });
1860
+
1861
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
1862
+ const io = createMockIo();
1863
+
1864
+ startChangeStreamWatcher(io, {}, true);
1865
+
1866
+ // Trigger an insert change event
1867
+ const changeHandler = mockStream.listeners.get("change");
1868
+ expect(changeHandler).toBeDefined();
1869
+ await changeHandler!({
1870
+ documentKey: {_id: "doc-1"},
1871
+ fullDocument: {_id: "doc-1", name: "Test Todo"},
1872
+ ns: {coll: "todos"},
1873
+ operationType: "insert",
1874
+ });
1875
+ });
1876
+
1877
+ it("skips events for unregistered collections", async () => {
1878
+ const mockStream = createMockChangeStream();
1879
+ const mockDb = {
1880
+ watch: mock(() => mockStream),
1881
+ };
1882
+ (mongoose.connection as any).db = mockDb;
1883
+
1884
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
1885
+ const io = createMockIo();
1886
+
1887
+ startChangeStreamWatcher(io, {}, true);
1888
+
1889
+ const changeHandler = mockStream.listeners.get("change");
1890
+ // Trigger for an unregistered collection — should not throw
1891
+ await changeHandler!({
1892
+ documentKey: {_id: "doc-1"},
1893
+ fullDocument: {_id: "doc-1"},
1894
+ ns: {coll: "unknown_collection"},
1895
+ operationType: "insert",
1896
+ });
1897
+ });
1898
+
1899
+ it("skips events when method is not enabled for the model", async () => {
1900
+ const mockStream = createMockChangeStream();
1901
+ const mockDb = {
1902
+ watch: mock(() => mockStream),
1903
+ };
1904
+ (mongoose.connection as any).db = mockDb;
1905
+
1906
+ registerRealtime({
1907
+ collectionName: "todos",
1908
+ config: {
1909
+ methods: ["create"], // only create enabled
1910
+ roomStrategy: "model",
1911
+ },
1912
+ modelName: "Todo",
1913
+ options: {
1914
+ permissions: {
1915
+ create: [() => true],
1916
+ delete: [],
1917
+ list: [() => true],
1918
+ read: [() => true],
1919
+ update: [],
1920
+ },
1921
+ },
1922
+ routePath: "/todos",
1923
+ });
1924
+
1925
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
1926
+ const io = createMockIo();
1927
+
1928
+ startChangeStreamWatcher(io, {}, true);
1929
+
1930
+ const changeHandler = mockStream.listeners.get("change");
1931
+ // Update event should be skipped because "update" not in methods
1932
+ await changeHandler!({
1933
+ documentKey: {_id: "doc-1"},
1934
+ fullDocument: {_id: "doc-1", name: "Updated"},
1935
+ ns: {coll: "todos"},
1936
+ operationType: "update",
1937
+ updateDescription: {updatedFields: {name: "Updated"}},
1938
+ });
1939
+ });
1940
+
1941
+ it("handles delete events for owner-strategy models", async () => {
1942
+ const mockStream = createMockChangeStream();
1943
+ const mockDb = {
1944
+ watch: mock(() => mockStream),
1945
+ };
1946
+ (mongoose.connection as any).db = mockDb;
1947
+
1948
+ registerRealtime({
1949
+ collectionName: "todos",
1950
+ config: {
1951
+ methods: ["create", "update", "delete"],
1952
+ roomStrategy: "owner",
1953
+ },
1954
+ modelName: "Todo",
1955
+ options: {
1956
+ permissions: {
1957
+ create: [() => true],
1958
+ delete: [() => true],
1959
+ list: [() => true],
1960
+ read: [() => true],
1961
+ update: [() => true],
1962
+ },
1963
+ },
1964
+ routePath: "/todos",
1965
+ });
1966
+
1967
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
1968
+ const io = createMockIo();
1969
+
1970
+ startChangeStreamWatcher(io, {}, true);
1971
+
1972
+ const changeHandler = mockStream.listeners.get("change");
1973
+ // Hard delete (no fullDocument)
1974
+ await changeHandler!({
1975
+ documentKey: {_id: "doc-1"},
1976
+ ns: {coll: "todos"},
1977
+ operationType: "delete",
1978
+ });
1979
+ });
1980
+
1981
+ it("handles delete events for broadcast-strategy models", async () => {
1982
+ const mockStream = createMockChangeStream();
1983
+ const mockDb = {
1984
+ watch: mock(() => mockStream),
1985
+ };
1986
+ (mongoose.connection as any).db = mockDb;
1987
+
1988
+ registerRealtime({
1989
+ collectionName: "broadcasts",
1990
+ config: {
1991
+ methods: ["create", "update", "delete"],
1992
+ roomStrategy: "broadcast",
1993
+ },
1994
+ modelName: "Broadcast",
1995
+ options: {
1996
+ permissions: {
1997
+ create: [() => true],
1998
+ delete: [() => true],
1999
+ list: [() => true],
2000
+ read: [() => true],
2001
+ update: [() => true],
2002
+ },
2003
+ },
2004
+ routePath: "/broadcasts",
2005
+ });
2006
+
2007
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
2008
+ const io = createMockIo();
2009
+
2010
+ startChangeStreamWatcher(io, {}, true);
2011
+
2012
+ const changeHandler = mockStream.listeners.get("change");
2013
+ // Hard delete for broadcast strategy
2014
+ await changeHandler!({
2015
+ documentKey: {_id: "doc-1"},
2016
+ ns: {coll: "broadcasts"},
2017
+ operationType: "delete",
2018
+ });
2019
+ });
2020
+
2021
+ it("includes updatedFields in event for update operations", async () => {
2022
+ const mockStream = createMockChangeStream();
2023
+ const mockDb = {
2024
+ watch: mock(() => mockStream),
2025
+ };
2026
+ (mongoose.connection as any).db = mockDb;
2027
+
2028
+ registerRealtime({
2029
+ collectionName: "todos",
2030
+ config: {
2031
+ methods: ["create", "update", "delete"],
2032
+ roomStrategy: "model",
2033
+ },
2034
+ modelName: "Todo",
2035
+ options: {
2036
+ permissions: {
2037
+ create: [() => true],
2038
+ delete: [() => true],
2039
+ list: [() => true],
2040
+ read: [() => true],
2041
+ update: [() => true],
2042
+ },
2043
+ },
2044
+ routePath: "/todos",
2045
+ });
2046
+
2047
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
2048
+ const io = createMockIo();
2049
+
2050
+ startChangeStreamWatcher(io, {}, true);
2051
+
2052
+ const changeHandler = mockStream.listeners.get("change");
2053
+ await changeHandler!({
2054
+ documentKey: {_id: "doc-1"},
2055
+ fullDocument: {_id: "doc-1", name: "Updated", status: "done"},
2056
+ ns: {coll: "todos"},
2057
+ operationType: "update",
2058
+ updateDescription: {updatedFields: {name: "Updated", status: "done"}},
2059
+ });
2060
+ });
2061
+
2062
+ it("respects ignoredCollections config", async () => {
2063
+ const mockStream = createMockChangeStream();
2064
+ const mockDb = {
2065
+ watch: mock(() => mockStream),
2066
+ };
2067
+ (mongoose.connection as any).db = mockDb;
2068
+
2069
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
2070
+ const io = createMockIo();
2071
+
2072
+ startChangeStreamWatcher(io, {ignoredCollections: ["audit_logs"]}, true);
2073
+
2074
+ // Verify the pipeline passed to watch includes the ignored collections
2075
+ const pipeline = (mockDb.watch.mock.calls[0] as any[])[0];
2076
+ const matchStage = pipeline[0].$match;
2077
+ expect(matchStage["ns.coll"].$nin).toContain("audit_logs");
2078
+ expect(matchStage["ns.coll"].$nin).toContain("socketio");
2079
+ expect(matchStage["ns.coll"].$nin).toContain("sessions");
2080
+ });
2081
+
2082
+ it("respects ignoredOperations config", async () => {
2083
+ const mockStream = createMockChangeStream();
2084
+ const mockDb = {
2085
+ watch: mock(() => mockStream),
2086
+ };
2087
+ (mongoose.connection as any).db = mockDb;
2088
+
2089
+ registerRealtime({
2090
+ collectionName: "todos",
2091
+ config: {
2092
+ methods: ["create", "update", "delete"],
2093
+ roomStrategy: "model",
2094
+ },
2095
+ modelName: "Todo",
2096
+ options: {
2097
+ permissions: {
2098
+ create: [() => true],
2099
+ delete: [() => true],
2100
+ list: [() => true],
2101
+ read: [() => true],
2102
+ update: [() => true],
2103
+ },
2104
+ },
2105
+ routePath: "/todos",
2106
+ });
2107
+
2108
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
2109
+ const io = createMockIo();
2110
+
2111
+ startChangeStreamWatcher(io, {ignoredOperations: ["insert"]}, true);
2112
+
2113
+ const changeHandler = mockStream.listeners.get("change");
2114
+ // This insert should be skipped because "insert" is ignored
2115
+ await changeHandler!({
2116
+ documentKey: {_id: "doc-1"},
2117
+ fullDocument: {_id: "doc-1"},
2118
+ ns: {coll: "todos"},
2119
+ operationType: "insert",
2120
+ });
2121
+ });
2122
+
2123
+ it("skips events with no collectionName or docId", async () => {
2124
+ const mockStream = createMockChangeStream();
2125
+ const mockDb = {
2126
+ watch: mock(() => mockStream),
2127
+ };
2128
+ (mongoose.connection as any).db = mockDb;
2129
+
2130
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
2131
+ const io = createMockIo();
2132
+
2133
+ startChangeStreamWatcher(io, {}, true);
2134
+
2135
+ const changeHandler = mockStream.listeners.get("change");
2136
+ // Missing ns.coll
2137
+ await changeHandler!({
2138
+ documentKey: {_id: "doc-1"},
2139
+ ns: {},
2140
+ operationType: "insert",
2141
+ });
2142
+ // Missing documentKey
2143
+ await changeHandler!({
2144
+ documentKey: {},
2145
+ ns: {coll: "todos"},
2146
+ operationType: "insert",
2147
+ });
2148
+ });
2149
+
2150
+ it("skips non-CRUD operation types", async () => {
2151
+ const mockStream = createMockChangeStream();
2152
+ const mockDb = {
2153
+ watch: mock(() => mockStream),
2154
+ };
2155
+ (mongoose.connection as any).db = mockDb;
2156
+
2157
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
2158
+ const io = createMockIo();
2159
+
2160
+ startChangeStreamWatcher(io, {}, true);
2161
+
2162
+ const changeHandler = mockStream.listeners.get("change");
2163
+ // "drop" is not in our pipeline filter, should be skipped
2164
+ await changeHandler!({
2165
+ operationType: "drop",
2166
+ });
2167
+ });
2168
+
2169
+ it("handles error/close/end events gracefully", async () => {
2170
+ const mockStream = createMockChangeStream();
2171
+ const mockDb = {
2172
+ watch: mock(() => mockStream),
2173
+ };
2174
+ (mongoose.connection as any).db = mockDb;
2175
+
2176
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
2177
+ const io = createMockIo();
2178
+
2179
+ startChangeStreamWatcher(io, {}, true);
2180
+
2181
+ // Trigger error, close, end — should not throw
2182
+ mockStream.trigger("error", new Error("test error"));
2183
+ mockStream.trigger("close");
2184
+ mockStream.trigger("end");
2185
+ });
2186
+
2187
+ it("uses custom batchSize and fullDocument config", async () => {
2188
+ const mockStream = createMockChangeStream();
2189
+ const mockDb = {
2190
+ watch: mock(() => mockStream),
2191
+ };
2192
+ (mongoose.connection as any).db = mockDb;
2193
+
2194
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
2195
+ const io = createMockIo();
2196
+
2197
+ startChangeStreamWatcher(io, {batchSize: 100, fullDocument: "whenAvailable"}, true);
2198
+
2199
+ const options = (mockDb.watch.mock.calls[0] as any[])[1];
2200
+ expect(options.batchSize).toBe(100);
2201
+ expect(options.fullDocument).toBe("whenAvailable");
2202
+ });
2203
+
2204
+ it("catches errors thrown in the change handler", async () => {
2205
+ const mockStream = createMockChangeStream();
2206
+ const mockDb = {
2207
+ watch: mock(() => mockStream),
2208
+ };
2209
+ (mongoose.connection as any).db = mockDb;
2210
+
2211
+ // Register with a model that will throw during permission check
2212
+ registerRealtime({
2213
+ collectionName: "todos",
2214
+ config: {
2215
+ methods: ["create", "update", "delete"],
2216
+ roomStrategy: "model",
2217
+ },
2218
+ modelName: "Todo",
2219
+ options: {
2220
+ permissions: {
2221
+ create: [() => true],
2222
+ delete: [() => true],
2223
+ list: [() => true],
2224
+ read: [
2225
+ () => {
2226
+ throw new Error("permission check error");
2227
+ },
2228
+ ],
2229
+ update: [() => true],
2230
+ },
2231
+ },
2232
+ routePath: "/todos",
2233
+ });
2234
+
2235
+ const {startChangeStreamWatcher} = await import("./changeStreamWatcher");
2236
+
2237
+ // Create an IO with a socket in the target room
2238
+ const emissions: any[] = [];
2239
+ const mockSocket = {
2240
+ decodedToken: {id: "user-1"},
2241
+ emit: (_event: string, _data: any) => {
2242
+ emissions.push({_data, _event});
2243
+ },
2244
+ id: "sock-1",
2245
+ };
2246
+ const rooms = new Map<string, Set<string>>();
2247
+ rooms.set("model:todos", new Set(["sock-1"]));
2248
+ const sockets = new Map<string, any>();
2249
+ sockets.set("sock-1", mockSocket);
2250
+ const io = {
2251
+ sockets: {
2252
+ adapter: {rooms},
2253
+ sockets,
2254
+ },
2255
+ to: () => ({emit: () => {}}),
2256
+ } as unknown as import("socket.io").Server;
2257
+
2258
+ startChangeStreamWatcher(io, {}, true);
2259
+
2260
+ const changeHandler = mockStream.listeners.get("change");
2261
+ // Should not throw even though permission check throws
2262
+ await changeHandler!({
2263
+ documentKey: {_id: "doc-1"},
2264
+ fullDocument: {_id: "doc-1", name: "Test"},
2265
+ ns: {coll: "todos"},
2266
+ operationType: "insert",
2267
+ });
2268
+ });
2269
+ });
2270
+
2271
+ describe("stopChangeStreamWatcher", () => {
2272
+ it("closes and nullifies the active watcher", async () => {
2273
+ const mockStream = {
2274
+ close: mock(async () => {}),
2275
+ on: () => {},
2276
+ };
2277
+ const mockDb = {
2278
+ watch: mock(() => mockStream),
2279
+ };
2280
+ const originalDb = mongoose.connection.db;
2281
+ (mongoose.connection as any).db = mockDb;
2282
+
2283
+ const {startChangeStreamWatcher, stopChangeStreamWatcher} = await import(
2284
+ "./changeStreamWatcher"
2285
+ );
2286
+ const io = {
2287
+ sockets: {
2288
+ adapter: {rooms: new Map()},
2289
+ sockets: new Map(),
2290
+ },
2291
+ to: () => ({emit: () => {}}),
2292
+ } as unknown as import("socket.io").Server;
2293
+
2294
+ startChangeStreamWatcher(io);
2295
+ await stopChangeStreamWatcher();
2296
+ expect(mockStream.close).toHaveBeenCalled();
2297
+
2298
+ // Calling again should be a no-op
2299
+ await stopChangeStreamWatcher();
2300
+
2301
+ (mongoose.connection as any).db = originalDb;
2302
+ });
2303
+ });
2304
+
2305
+ // ─────────────────────────────────────────────────────────────────────────────
2306
+ // RealtimeApp.onServerCreated / setupAdapter
2307
+ // ─────────────────────────────────────────────────────────────────────────────
2308
+
2309
+ describe("RealtimeApp.onServerCreated", () => {
2310
+ const servers: import("http").Server[] = [];
2311
+
2312
+ afterEach(async () => {
2313
+ for (const s of servers) {
2314
+ s.close();
2315
+ }
2316
+ servers.length = 0;
2317
+ });
2318
+
2319
+ const makeServer = (): import("http").Server => {
2320
+ const http = require("http");
2321
+ const server = http.createServer();
2322
+ servers.push(server);
2323
+ return server;
2324
+ };
2325
+
2326
+ it("throws when TOKEN_SECRET is missing", () => {
2327
+ const originalSecret = process.env.TOKEN_SECRET;
2328
+ process.env.TOKEN_SECRET = "";
2329
+
2330
+ const app = new RealtimeApp({tokenSecret: undefined});
2331
+ const server = makeServer();
2332
+
2333
+ expect(() => app.onServerCreated(server)).toThrow("TOKEN_SECRET is required");
2334
+
2335
+ process.env.TOKEN_SECRET = originalSecret;
2336
+ });
2337
+
2338
+ it("sets up Socket.io with valid config", async () => {
2339
+ const app = new RealtimeApp({
2340
+ adapter: "none",
2341
+ tokenSecret: "test-secret",
2342
+ });
2343
+ const server = makeServer();
2344
+
2345
+ // Mock mongoose.connection.db for changeStreamWatcher
2346
+ const originalDb = mongoose.connection.db;
2347
+ const mockStream = {close: async () => {}, on: () => mockStream};
2348
+ (mongoose.connection as any).db = {watch: () => mockStream};
2349
+
2350
+ app.onServerCreated(server);
2351
+ expect(app.getIo()).toBeDefined();
2352
+
2353
+ await app.close();
2354
+ (mongoose.connection as any).db = originalDb;
2355
+ });
2356
+ });
2357
+
2358
+ describe("RealtimeApp.setupAdapter (private, via onServerCreated config)", () => {
2359
+ const servers: import("http").Server[] = [];
2360
+
2361
+ afterEach(async () => {
2362
+ for (const s of servers) {
2363
+ s.close();
2364
+ }
2365
+ servers.length = 0;
2366
+ });
2367
+
2368
+ const makeServer = (): import("http").Server => {
2369
+ const http = require("http");
2370
+ const server = http.createServer();
2371
+ servers.push(server);
2372
+ return server;
2373
+ };
2374
+
2375
+ it("logs warning when redis adapter requested but no URL found", async () => {
2376
+ const originalValkey = process.env.VALKEY_URL;
2377
+ const originalRedis = process.env.REDIS_URL;
2378
+ const originalDb = mongoose.connection.db;
2379
+ process.env.VALKEY_URL = "";
2380
+ process.env.REDIS_URL = "";
2381
+
2382
+ const mockStream = {close: async () => {}, on: () => mockStream};
2383
+ (mongoose.connection as any).db = {watch: () => mockStream};
2384
+
2385
+ const app = new RealtimeApp({
2386
+ adapter: "redis",
2387
+ tokenSecret: "test-secret",
2388
+ });
2389
+ const server = makeServer();
2390
+
2391
+ app.onServerCreated(server);
2392
+ await app.close();
2393
+
2394
+ process.env.VALKEY_URL = originalValkey;
2395
+ process.env.REDIS_URL = originalRedis;
2396
+ (mongoose.connection as any).db = originalDb;
2397
+ });
2398
+
2399
+ it("logs info when redis adapter has a URL", async () => {
2400
+ const originalValkey = process.env.VALKEY_URL;
2401
+ const originalDb = mongoose.connection.db;
2402
+ process.env.VALKEY_URL = "redis://user:pass@localhost:6379/0";
2403
+
2404
+ const mockStream = {close: async () => {}, on: () => mockStream};
2405
+ (mongoose.connection as any).db = {watch: () => mockStream};
2406
+
2407
+ const app = new RealtimeApp({
2408
+ adapter: "redis",
2409
+ debug: true,
2410
+ tokenSecret: "test-secret",
2411
+ });
2412
+ const server = makeServer();
2413
+
2414
+ app.onServerCreated(server);
2415
+ await app.close();
2416
+
2417
+ process.env.VALKEY_URL = originalValkey;
2418
+ (mongoose.connection as any).db = originalDb;
2419
+ });
2420
+
2421
+ it("no-op adapter mode 'none'", async () => {
2422
+ const originalDb = mongoose.connection.db;
2423
+ const mockStream = {close: async () => {}, on: () => mockStream};
2424
+ (mongoose.connection as any).db = {watch: () => mockStream};
2425
+
2426
+ const app = new RealtimeApp({
2427
+ adapter: "none",
2428
+ tokenSecret: "test-secret",
2429
+ });
2430
+ const server = makeServer();
2431
+
2432
+ app.onServerCreated(server);
2433
+ expect(app.getIo()).toBeDefined();
2434
+
2435
+ await app.close();
2436
+ (mongoose.connection as any).db = originalDb;
2437
+ });
2438
+ });
2439
+
2440
+ // ─────────────────────────────────────────────────────────────────────────────
2441
+ // ensureApiId
2442
+ // ─────────────────────────────────────────────────────────────────────────────
2443
+
2444
+ describe("ensureApiId", () => {
2445
+ it("returns null as-is", () => {
2446
+ expect(ensureApiId(null)).toBeNull();
2447
+ });
2448
+
2449
+ it("returns undefined as-is", () => {
2450
+ expect(ensureApiId(undefined)).toBeUndefined();
2451
+ });
2452
+
2453
+ it("returns arrays as-is", () => {
2454
+ const arr = [1, 2, 3];
2455
+ expect(ensureApiId(arr)).toBe(arr);
2456
+ });
2457
+
2458
+ it("returns primitive values as-is (non-object)", () => {
2459
+ expect(ensureApiId("string")).toBe("string");
2460
+ });
2461
+
2462
+ it("adds id from _id when id is missing", () => {
2463
+ expect(ensureApiId({_id: "abc"})).toEqual({_id: "abc", id: "abc"});
2464
+ });
2465
+
2466
+ it("does not overwrite existing id", () => {
2467
+ expect(ensureApiId({_id: "abc", id: "existing"})).toEqual({_id: "abc", id: "existing"});
2468
+ });
2469
+
2470
+ it("returns object without _id unchanged", () => {
2471
+ const obj = {name: "test"};
2472
+ expect(ensureApiId(obj)).toBe(obj);
2473
+ });
2474
+ });
2475
+
2476
+ // ─────────────────────────────────────────────────────────────────────────────
2477
+ // startChangeStreamWatcher & stopChangeStreamWatcher
2478
+ // ─────────────────────────────────────────────────────────────────────────────
2479
+
2480
+ describe("startChangeStreamWatcher & stopChangeStreamWatcher", () => {
2481
+ const makeMockIo = (): any => {
2482
+ const emissions: any[] = [];
2483
+ const rooms = new Map<string, Set<string>>();
2484
+ const sockets = new Map<string, any>();
2485
+ return {
2486
+ emissions,
2487
+ sockets: {
2488
+ adapter: {rooms},
2489
+ sockets,
2490
+ },
2491
+ to: (_room: string) => ({
2492
+ emit: (): void => {},
2493
+ }),
2494
+ };
2495
+ };
2496
+
2497
+ afterEach(async () => {
2498
+ await stopChangeStreamWatcher();
2499
+ clearRealtimeRegistry();
2500
+ });
2501
+
2502
+ it("starts and stops without error when MongoDB is connected", async () => {
2503
+ const io = makeMockIo();
2504
+ expect(() => startChangeStreamWatcher(io, {}, false)).not.toThrow();
2505
+ await stopChangeStreamWatcher();
2506
+ });
2507
+
2508
+ it("starts with debug mode enabled", async () => {
2509
+ const io = makeMockIo();
2510
+ expect(() => startChangeStreamWatcher(io, {}, true)).not.toThrow();
2511
+ await stopChangeStreamWatcher();
2512
+ });
2513
+
2514
+ it("starts with custom config options", async () => {
2515
+ const io = makeMockIo();
2516
+ expect(() =>
2517
+ startChangeStreamWatcher(
2518
+ io,
2519
+ {
2520
+ batchSize: 10,
2521
+ fullDocument: "whenAvailable",
2522
+ ignoredCollections: ["logs"],
2523
+ ignoredOperations: ["delete"],
2524
+ },
2525
+ false
2526
+ )
2527
+ ).not.toThrow();
2528
+ await stopChangeStreamWatcher();
2529
+ });
2530
+
2531
+ it("stopChangeStreamWatcher is safe to call when no watcher is active", async () => {
2532
+ await expect(stopChangeStreamWatcher()).resolves.toBeUndefined();
2533
+ });
2534
+
2535
+ it("stopChangeStreamWatcher can be called multiple times", async () => {
2536
+ const io = makeMockIo();
2537
+ startChangeStreamWatcher(io, {}, false);
2538
+ await stopChangeStreamWatcher();
2539
+ await stopChangeStreamWatcher();
2540
+ });
2541
+ });
2542
+
2543
+ // Change streams require a MongoDB replica set. CI (api-ci.yml) runs standalone MongoDB,
2544
+ // so these tests are skipped when replica sets are not available.
2545
+ const hasReplicaSet = async (): Promise<boolean> => {
2546
+ try {
2547
+ const mongoose = require("mongoose");
2548
+ const admin = mongoose.connection.db.admin();
2549
+ const status = await admin.command({replSetGetStatus: 1});
2550
+ return !!status.ok;
2551
+ } catch {
2552
+ return false;
2553
+ }
2554
+ };
2555
+
2556
+ describe("startChangeStreamWatcher — change event integration", () => {
2557
+ const mongoose = require("mongoose");
2558
+ let replicaSetAvailable = false;
2559
+
2560
+ const realtimeTestSchema = new mongoose.Schema(
2561
+ {
2562
+ deleted: {default: false, type: Boolean},
2563
+ name: {type: String},
2564
+ ownerId: {type: String},
2565
+ },
2566
+ {collection: "realtimetests", strict: "throw"}
2567
+ );
2568
+
2569
+ let RealtimeTestModel: any;
2570
+ try {
2571
+ RealtimeTestModel = mongoose.model("RealtimeTest");
2572
+ } catch {
2573
+ RealtimeTestModel = mongoose.model("RealtimeTest", realtimeTestSchema);
2574
+ }
2575
+
2576
+ const makeTrackedIo = (): any => {
2577
+ const emissions: any[] = [];
2578
+ const rooms = new Map<string, Set<string>>();
2579
+ const sockets = new Map<string, any>();
2580
+
2581
+ const addSocketToRoom = (
2582
+ room: string,
2583
+ decodedToken: {id?: string; admin?: boolean} = {admin: true, id: "admin"}
2584
+ ): void => {
2585
+ const socketId = `socket-${Math.random().toString(36).slice(2, 9)}`;
2586
+ if (!rooms.has(room)) {
2587
+ rooms.set(room, new Set());
2588
+ }
2589
+ rooms.get(room)?.add(socketId);
2590
+ sockets.set(socketId, {
2591
+ decodedToken,
2592
+ emit: (event: string, payload: unknown): void => {
2593
+ emissions.push({event, payload, room, socketId});
2594
+ },
2595
+ id: socketId,
2596
+ });
2597
+ };
2598
+
2599
+ return {
2600
+ addSocketToRoom,
2601
+ emissions,
2602
+ sockets: {
2603
+ adapter: {rooms},
2604
+ sockets,
2605
+ },
2606
+ to: (room: string) => ({
2607
+ emit: (event: string, payload: unknown): void => {
2608
+ emissions.push({event, payload, room});
2609
+ },
2610
+ }),
2611
+ };
2612
+ };
2613
+
2614
+ beforeAll(async () => {
2615
+ replicaSetAvailable = await hasReplicaSet();
2616
+ });
2617
+
2618
+ beforeEach(async () => {
2619
+ clearRealtimeRegistry();
2620
+ clearQueryStore();
2621
+ await RealtimeTestModel.deleteMany({});
2622
+ });
2623
+
2624
+ afterEach(async () => {
2625
+ await stopChangeStreamWatcher();
2626
+ clearRealtimeRegistry();
2627
+ clearQueryStore();
2628
+ await RealtimeTestModel.deleteMany({});
2629
+ });
2630
+
2631
+ it("processes insert events from MongoDB change stream", async () => {
2632
+ if (!replicaSetAvailable) {
2633
+ return;
2634
+ }
2635
+ registerRealtime({
2636
+ collectionName: "realtimetests",
2637
+ config: {methods: ["create", "update", "delete"], roomStrategy: "model"},
2638
+ modelName: "RealtimeTest",
2639
+ options: {
2640
+ permissions: {
2641
+ create: [() => true],
2642
+ delete: [() => true],
2643
+ list: [() => true],
2644
+ read: [() => true],
2645
+ update: [() => true],
2646
+ },
2647
+ } as any,
2648
+ routePath: "/realtimetests",
2649
+ });
2650
+
2651
+ const io = makeTrackedIo();
2652
+ io.addSocketToRoom("model:realtimetests");
2653
+ startChangeStreamWatcher(io, {}, true);
2654
+
2655
+ await RealtimeTestModel.create({name: "test-item", ownerId: "user-1"});
2656
+
2657
+ // Wait for the change stream event to be processed
2658
+ await new Promise((resolve) => setTimeout(resolve, 1500));
2659
+
2660
+ const createEmissions = io.emissions.filter(
2661
+ (e: any) => e.event === "sync" && e.payload?.method === "create"
2662
+ );
2663
+ expect(createEmissions.length).toBeGreaterThanOrEqual(1);
2664
+ await stopChangeStreamWatcher();
2665
+ });
2666
+
2667
+ it("processes update events from MongoDB change stream", async () => {
2668
+ if (!replicaSetAvailable) {
2669
+ return;
2670
+ }
2671
+ registerRealtime({
2672
+ collectionName: "realtimetests",
2673
+ config: {methods: ["create", "update", "delete"], roomStrategy: "model"},
2674
+ modelName: "RealtimeTest",
2675
+ options: {
2676
+ permissions: {
2677
+ create: [() => true],
2678
+ delete: [() => true],
2679
+ list: [() => true],
2680
+ read: [() => true],
2681
+ update: [() => true],
2682
+ },
2683
+ } as any,
2684
+ routePath: "/realtimetests",
2685
+ });
2686
+
2687
+ const doc = await RealtimeTestModel.create({name: "item-to-update", ownerId: "user-1"});
2688
+
2689
+ const io = makeTrackedIo();
2690
+ io.addSocketToRoom("model:realtimetests");
2691
+ startChangeStreamWatcher(io, {}, true);
2692
+
2693
+ await RealtimeTestModel.updateOne({_id: doc._id}, {$set: {name: "updated-item"}});
2694
+
2695
+ await new Promise((resolve) => setTimeout(resolve, 1500));
2696
+
2697
+ const updateEmissions = io.emissions.filter(
2698
+ (e: any) => e.event === "sync" && e.payload?.method === "update"
2699
+ );
2700
+ expect(updateEmissions.length).toBeGreaterThanOrEqual(1);
2701
+ await stopChangeStreamWatcher();
2702
+ });
2703
+
2704
+ it("processes hard delete events from MongoDB change stream", async () => {
2705
+ if (!replicaSetAvailable) {
2706
+ return;
2707
+ }
2708
+ registerRealtime({
2709
+ collectionName: "realtimetests",
2710
+ config: {methods: ["create", "update", "delete"], roomStrategy: "model"},
2711
+ modelName: "RealtimeTest",
2712
+ options: {
2713
+ permissions: {
2714
+ create: [() => true],
2715
+ delete: [() => true],
2716
+ list: [() => true],
2717
+ read: [() => true],
2718
+ update: [() => true],
2719
+ },
2720
+ } as any,
2721
+ routePath: "/realtimetests",
2722
+ });
2723
+
2724
+ const doc = await RealtimeTestModel.create({name: "item-to-delete"});
2725
+
2726
+ const io = makeTrackedIo();
2727
+ io.addSocketToRoom("model:realtimetests");
2728
+ startChangeStreamWatcher(io, {}, true);
2729
+
2730
+ await RealtimeTestModel.deleteOne({_id: doc._id});
2731
+
2732
+ await new Promise((resolve) => setTimeout(resolve, 1500));
2733
+
2734
+ const deleteEmissions = io.emissions.filter(
2735
+ (e: any) => e.event === "sync" && e.payload?.method === "delete"
2736
+ );
2737
+ expect(deleteEmissions.length).toBeGreaterThanOrEqual(1);
2738
+ await stopChangeStreamWatcher();
2739
+ });
2740
+
2741
+ it("processes soft delete events from MongoDB change stream", async () => {
2742
+ if (!replicaSetAvailable) {
2743
+ return;
2744
+ }
2745
+ registerRealtime({
2746
+ collectionName: "realtimetests",
2747
+ config: {methods: ["create", "update", "delete"], roomStrategy: "model"},
2748
+ modelName: "RealtimeTest",
2749
+ options: {
2750
+ permissions: {
2751
+ create: [() => true],
2752
+ delete: [() => true],
2753
+ list: [() => true],
2754
+ read: [() => true],
2755
+ update: [() => true],
2756
+ },
2757
+ } as any,
2758
+ routePath: "/realtimetests",
2759
+ });
2760
+
2761
+ const doc = await RealtimeTestModel.create({name: "item-to-soft-delete"});
2762
+
2763
+ const io = makeTrackedIo();
2764
+ io.addSocketToRoom("model:realtimetests");
2765
+ startChangeStreamWatcher(io, {}, true);
2766
+
2767
+ await RealtimeTestModel.updateOne({_id: doc._id}, {$set: {deleted: true}});
2768
+
2769
+ await new Promise((resolve) => setTimeout(resolve, 1500));
2770
+
2771
+ const deleteEmissions = io.emissions.filter(
2772
+ (e: any) => e.event === "sync" && e.payload?.method === "delete"
2773
+ );
2774
+ expect(deleteEmissions.length).toBeGreaterThanOrEqual(1);
2775
+ await stopChangeStreamWatcher();
2776
+ });
2777
+
2778
+ it("includes updatedFields and emits to document rooms", async () => {
2779
+ if (!replicaSetAvailable) {
2780
+ return;
2781
+ }
2782
+ registerRealtime({
2783
+ collectionName: "realtimetests",
2784
+ config: {methods: ["create", "update", "delete"], roomStrategy: "model"},
2785
+ modelName: "RealtimeTest",
2786
+ options: {
2787
+ permissions: {
2788
+ create: [() => true],
2789
+ delete: [() => true],
2790
+ list: [() => true],
2791
+ read: [() => true],
2792
+ update: [() => true],
2793
+ },
2794
+ } as any,
2795
+ routePath: "/realtimetests",
2796
+ });
2797
+
2798
+ const doc = await RealtimeTestModel.create({name: "fields-test"});
2799
+ const docId = doc._id.toString();
2800
+
2801
+ const io = makeTrackedIo();
2802
+ io.addSocketToRoom("model:realtimetests");
2803
+ io.addSocketToRoom(`document:realtimetests:${docId}`);
2804
+ startChangeStreamWatcher(io, {}, true);
2805
+
2806
+ await RealtimeTestModel.updateOne({_id: doc._id}, {$set: {name: "fields-updated"}});
2807
+
2808
+ await new Promise((resolve) => setTimeout(resolve, 1500));
2809
+
2810
+ const updateEmissions = io.emissions.filter(
2811
+ (e: any) => e.event === "sync" && e.payload?.method === "update"
2812
+ );
2813
+ expect(updateEmissions.length).toBeGreaterThanOrEqual(1);
2814
+ if (updateEmissions.length > 0) {
2815
+ expect(updateEmissions[0].payload.updatedFields).toBeDefined();
2816
+ expect(updateEmissions[0].payload.updatedFields).toContain("name");
2817
+ }
2818
+ await stopChangeStreamWatcher();
2819
+ });
2820
+ });
2821
+
2822
+ // ─────────────────────────────────────────────────────────────────────────────
2823
+ // emitToDocumentAndQueryRooms — no-entry path
2824
+ // ─────────────────────────────────────────────────────────────────────────────
2825
+
2826
+ describe("emitToDocumentAndQueryRooms — no registry entry", () => {
2827
+ const makeIoSimple = (): any => {
2828
+ const emissions: Array<{room: string; event: string; payload: unknown}> = [];
2829
+ return {
2830
+ emissions,
2831
+ sockets: {
2832
+ adapter: {rooms: new Map()},
2833
+ sockets: new Map(),
2834
+ },
2835
+ to: (room: string) => ({
2836
+ emit: (event: string, payload: unknown): void => {
2837
+ emissions.push({event, payload, room});
2838
+ },
2839
+ }),
2840
+ };
2841
+ };
2842
+
2843
+ beforeEach(() => {
2844
+ clearQueryStore();
2845
+ });
2846
+
2847
+ afterEach(() => {
2848
+ clearQueryStore();
2849
+ });
2850
+
2851
+ it("emits to document room via io.to when no entry is provided", async () => {
2852
+ const io = makeIoSimple();
2853
+ const event: any = {
2854
+ collection: "items",
2855
+ id: "doc-1",
2856
+ method: "update",
2857
+ model: "Item",
2858
+ timestamp: 1,
2859
+ };
2860
+ await emitToDocumentAndQueryRooms(io, "items", event, {}, () => {});
2861
+ expect(io.emissions.some((e: any) => e.room === "document:items:doc-1")).toBe(true);
2862
+ });
2863
+
2864
+ it("emits hard deletes to query rooms via io.to when no entry", async () => {
2865
+ const queryId = computeQueryId("items", {status: "active"});
2866
+ addQuerySubscription("socket-a", "items", {status: "active"}, queryId);
2867
+ const io = makeIoSimple();
2868
+ const event: any = {
2869
+ collection: "items",
2870
+ id: "doc-1",
2871
+ method: "delete",
2872
+ model: "Item",
2873
+ timestamp: 1,
2874
+ };
2875
+ await emitToDocumentAndQueryRooms(io, "items", event, undefined, () => {});
2876
+ expect(io.emissions.some((e: any) => e.room === `query:${queryId}`)).toBe(true);
2877
+ });
2878
+
2879
+ it("emits soft delete to query rooms via io.to when no entry and doc matches", async () => {
2880
+ const queryId = computeQueryId("items", {status: "active"});
2881
+ addQuerySubscription("socket-a", "items", {status: "active"}, queryId);
2882
+ const io = makeIoSimple();
2883
+ const event: any = {
2884
+ collection: "items",
2885
+ id: "doc-1",
2886
+ method: "delete",
2887
+ model: "Item",
2888
+ timestamp: 1,
2889
+ };
2890
+ await emitToDocumentAndQueryRooms(io, "items", event, {status: "active"}, () => {});
2891
+ expect(io.emissions.some((e: any) => e.room === `query:${queryId}`)).toBe(true);
2892
+ });
2893
+
2894
+ it("emits create events to query rooms via io.to when no entry and doc matches", async () => {
2895
+ const queryId = computeQueryId("items", {status: "active"});
2896
+ addQuerySubscription("socket-a", "items", {status: "active"}, queryId);
2897
+ const io = makeIoSimple();
2898
+ const event: any = {
2899
+ collection: "items",
2900
+ id: "doc-1",
2901
+ method: "create",
2902
+ model: "Item",
2903
+ timestamp: 1,
2904
+ };
2905
+ await emitToDocumentAndQueryRooms(io, "items", event, {status: "active"}, () => {});
2906
+ expect(io.emissions.some((e: any) => e.room === `query:${queryId}`)).toBe(true);
2907
+ });
2908
+
2909
+ it("emits update events to query rooms via io.to when no entry and doc matches", async () => {
2910
+ const queryId = computeQueryId("items", {status: "active"});
2911
+ addQuerySubscription("socket-a", "items", {status: "active"}, queryId);
2912
+ const io = makeIoSimple();
2913
+ const event: any = {
2914
+ collection: "items",
2915
+ id: "doc-1",
2916
+ method: "update",
2917
+ model: "Item",
2918
+ timestamp: 1,
2919
+ };
2920
+ await emitToDocumentAndQueryRooms(io, "items", event, {status: "active"}, () => {});
2921
+ expect(io.emissions.some((e: any) => e.room === `query:${queryId}`)).toBe(true);
2922
+ });
2923
+
2924
+ it("emits delete to query rooms via io.to when update no longer matches and no entry", async () => {
2925
+ const queryId = computeQueryId("items", {status: "active"});
2926
+ addQuerySubscription("socket-a", "items", {status: "active"}, queryId);
2927
+ const io = makeIoSimple();
2928
+ const event: any = {
2929
+ collection: "items",
2930
+ id: "doc-1",
2931
+ method: "update",
2932
+ model: "Item",
2933
+ timestamp: 1,
2934
+ };
2935
+ await emitToDocumentAndQueryRooms(io, "items", event, {status: "inactive"}, () => {});
2936
+ const queryEmissions = io.emissions.filter((e: any) => e.room === `query:${queryId}`);
2937
+ expect(queryEmissions.length).toBe(1);
2938
+ expect(queryEmissions[0].payload).toMatchObject({method: "delete"});
2939
+ });
2940
+ });
2941
+
2942
+ // ─────────────────────────────────────────────────────────────────────────────
2943
+ // RealtimeApp — onServerCreated, setupAdapter, close
2944
+ // ─────────────────────────────────────────────────────────────────────────────
2945
+
2946
+ describe("RealtimeApp — onServerCreated and setupAdapter", () => {
2947
+ const originalEnv = process.env;
2948
+
2949
+ beforeEach(() => {
2950
+ process.env = {
2951
+ ...originalEnv,
2952
+ TOKEN_SECRET: "test-secret",
2953
+ };
2954
+ });
2955
+
2956
+ afterEach(async () => {
2957
+ process.env = originalEnv;
2958
+ clearRealtimeRegistry();
2959
+ });
2960
+
2961
+ it("register adds /realtime/health endpoint with debug flag", async () => {
2962
+ const expressApp = express();
2963
+ const app = new RealtimeApp({debug: true});
2964
+ app.register(expressApp);
2965
+ const supertest = await import("supertest");
2966
+ const st = supertest.default(expressApp);
2967
+ const res = await st.get("/realtime/health").expect(200);
2968
+ expect(res.body.status).toBe("not_started");
2969
+ expect(res.body.debug).toBe(true);
2970
+ expect(res.body.clients).toBe(0);
2971
+ });
2972
+
2973
+ it("onServerCreated sets up Socket.io with JWT auth", async () => {
2974
+ const http = await import("node:http");
2975
+ const app = new RealtimeApp({debug: true, tokenSecret: "test-secret"});
2976
+ const expressApp = express();
2977
+ app.register(expressApp);
2978
+ const server = http.createServer(expressApp);
2979
+
2980
+ app.onServerCreated(server);
2981
+ expect(app.getIo()).not.toBeNull();
2982
+
2983
+ await app.close();
2984
+ server.close();
2985
+ });
2986
+
2987
+ it("onServerCreated throws when TOKEN_SECRET is missing", () => {
2988
+ const http = require("node:http");
2989
+ const origSecret = process.env.TOKEN_SECRET;
2990
+ process.env.TOKEN_SECRET = "";
2991
+ const app = new RealtimeApp({});
2992
+ const expressApp = express();
2993
+ app.register(expressApp);
2994
+ const server = http.createServer(expressApp);
2995
+
2996
+ expect(() => app.onServerCreated(server)).toThrow("TOKEN_SECRET is required");
2997
+ process.env.TOKEN_SECRET = origSecret;
2998
+ server.close();
2999
+ });
3000
+
3001
+ it("onServerCreated uses default TOKEN_SECRET from env", async () => {
3002
+ const http = await import("node:http");
3003
+ process.env.TOKEN_SECRET = "env-secret";
3004
+ const app = new RealtimeApp({debug: false});
3005
+ const expressApp = express();
3006
+ app.register(expressApp);
3007
+ const server = http.createServer(expressApp);
3008
+
3009
+ app.onServerCreated(server);
3010
+ expect(app.getIo()).not.toBeNull();
3011
+
3012
+ await app.close();
3013
+ server.close();
3014
+ });
3015
+
3016
+ it("setupAdapter logs info for redis adapter with URL", async () => {
3017
+ const http = await import("node:http");
3018
+ const app = new RealtimeApp({
3019
+ adapter: "redis",
3020
+ debug: true,
3021
+ redisUrl: "redis://user:pass@localhost:6379",
3022
+ tokenSecret: "test-secret",
3023
+ });
3024
+ const expressApp = express();
3025
+ app.register(expressApp);
3026
+ const server = http.createServer(expressApp);
3027
+
3028
+ app.onServerCreated(server);
3029
+ expect(app.getIo()).not.toBeNull();
3030
+
3031
+ await app.close();
3032
+ server.close();
3033
+ });
3034
+
3035
+ it("setupAdapter warns when redis adapter has no URL", async () => {
3036
+ const http = await import("node:http");
3037
+ const origValkey = process.env.VALKEY_URL;
3038
+ const origRedis = process.env.REDIS_URL;
3039
+ delete process.env.VALKEY_URL;
3040
+ delete process.env.REDIS_URL;
3041
+
3042
+ const app = new RealtimeApp({
3043
+ adapter: "redis",
3044
+ debug: true,
3045
+ tokenSecret: "test-secret",
3046
+ });
3047
+ const expressApp = express();
3048
+ app.register(expressApp);
3049
+ const server = http.createServer(expressApp);
3050
+
3051
+ app.onServerCreated(server);
3052
+ expect(app.getIo()).not.toBeNull();
3053
+
3054
+ await app.close();
3055
+ server.close();
3056
+ process.env.VALKEY_URL = origValkey;
3057
+ process.env.REDIS_URL = origRedis;
3058
+ });
3059
+
3060
+ it("setupAdapter with none adapter does nothing extra", async () => {
3061
+ const http = await import("node:http");
3062
+ const app = new RealtimeApp({
3063
+ adapter: "none",
3064
+ debug: true,
3065
+ tokenSecret: "test-secret",
3066
+ });
3067
+ const expressApp = express();
3068
+ app.register(expressApp);
3069
+ const server = http.createServer(expressApp);
3070
+
3071
+ app.onServerCreated(server);
3072
+ expect(app.getIo()).not.toBeNull();
3073
+
3074
+ await app.close();
3075
+ server.close();
3076
+ });
3077
+
3078
+ it("close is safe after onServerCreated", async () => {
3079
+ const http = await import("node:http");
3080
+ const app = new RealtimeApp({tokenSecret: "test-secret"});
3081
+ const expressApp = express();
3082
+ app.register(expressApp);
3083
+ const server = http.createServer(expressApp);
3084
+
3085
+ app.onServerCreated(server);
3086
+ await app.close();
3087
+ expect(app.getIo()).toBeNull();
3088
+ server.close();
3089
+ });
3090
+
3091
+ it("health endpoint reports running after onServerCreated", async () => {
3092
+ const http = await import("node:http");
3093
+ const app = new RealtimeApp({tokenSecret: "test-secret"});
3094
+ const expressApp = express();
3095
+ app.register(expressApp);
3096
+ const server = http.createServer(expressApp);
3097
+
3098
+ app.onServerCreated(server);
3099
+
3100
+ const supertest = await import("supertest");
3101
+ const st = supertest.default(expressApp);
3102
+ const res = await st.get("/realtime/health").expect(200);
3103
+ expect(res.body.status).toBe("running");
3104
+
3105
+ await app.close();
3106
+ server.close();
3107
+ });
3108
+
3109
+ it("onServerCreated with custom cors option", async () => {
3110
+ const http = await import("node:http");
3111
+ const app = new RealtimeApp({
3112
+ cors: {methods: ["GET"], origin: "https://example.com"},
3113
+ tokenSecret: "test-secret",
3114
+ });
3115
+ const expressApp = express();
3116
+ app.register(expressApp);
3117
+ const server = http.createServer(expressApp);
3118
+
3119
+ app.onServerCreated(server);
3120
+ expect(app.getIo()).not.toBeNull();
3121
+
3122
+ await app.close();
3123
+ server.close();
3124
+ });
3125
+
3126
+ it("setupAdapter uses VALKEY_URL when redisUrl not provided", async () => {
3127
+ const http = await import("node:http");
3128
+ process.env.VALKEY_URL = "redis://localhost:6379";
3129
+ const app = new RealtimeApp({
3130
+ adapter: "redis",
3131
+ debug: true,
3132
+ tokenSecret: "test-secret",
3133
+ });
3134
+ const expressApp = express();
3135
+ app.register(expressApp);
3136
+ const server = http.createServer(expressApp);
3137
+
3138
+ app.onServerCreated(server);
3139
+ expect(app.getIo()).not.toBeNull();
3140
+
3141
+ await app.close();
3142
+ server.close();
3143
+ delete process.env.VALKEY_URL;
3144
+ });
3145
+ });
3146
+
3147
+ // ─────────────────────────────────────────────────────────────────────────────
3148
+ // redactCredentials — Redis URL logging
3149
+ // ─────────────────────────────────────────────────────────────────────────────
3150
+
1737
3151
  describe("redactCredentials", () => {
1738
3152
  it("redacts user:password@ in a redis URL", () => {
1739
3153
  expect(redactCredentials("redis://user:secret@host:6379/0")).toBe("redis://***@host:6379/0");