@terreno/api 0.14.0 → 0.14.1

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,18 @@
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
14
 
15
15
  import {
16
16
  emitToAuthorizedRoom,
17
17
  emitToDocumentAndQueryRooms,
18
+ ensureApiId,
18
19
  mapOperationType,
19
20
  resolveRooms,
20
21
  serializeDoc,
22
+ startChangeStreamWatcher,
23
+ stopChangeStreamWatcher,
21
24
  } from "./changeStreamWatcher";
22
25
  import {matchesQuery} from "./queryMatcher";
23
26
  import {
@@ -1734,6 +1737,713 @@ describe("emitToDocumentAndQueryRooms", () => {
1734
1737
  // redactCredentials — Redis URL logging
1735
1738
  // ─────────────────────────────────────────────────────────────────────────────
1736
1739
 
1740
+ // ─────────────────────────────────────────────────────────────────────────────
1741
+ // ensureApiId
1742
+ // ─────────────────────────────────────────────────────────────────────────────
1743
+
1744
+ describe("ensureApiId", () => {
1745
+ it("returns null as-is", () => {
1746
+ expect(ensureApiId(null)).toBeNull();
1747
+ });
1748
+
1749
+ it("returns undefined as-is", () => {
1750
+ expect(ensureApiId(undefined)).toBeUndefined();
1751
+ });
1752
+
1753
+ it("returns arrays as-is", () => {
1754
+ const arr = [1, 2, 3];
1755
+ expect(ensureApiId(arr)).toBe(arr);
1756
+ });
1757
+
1758
+ it("returns primitive values as-is (non-object)", () => {
1759
+ expect(ensureApiId("string")).toBe("string");
1760
+ });
1761
+
1762
+ it("adds id from _id when id is missing", () => {
1763
+ expect(ensureApiId({_id: "abc"})).toEqual({_id: "abc", id: "abc"});
1764
+ });
1765
+
1766
+ it("does not overwrite existing id", () => {
1767
+ expect(ensureApiId({_id: "abc", id: "existing"})).toEqual({_id: "abc", id: "existing"});
1768
+ });
1769
+
1770
+ it("returns object without _id unchanged", () => {
1771
+ const obj = {name: "test"};
1772
+ expect(ensureApiId(obj)).toBe(obj);
1773
+ });
1774
+ });
1775
+
1776
+ // ─────────────────────────────────────────────────────────────────────────────
1777
+ // startChangeStreamWatcher & stopChangeStreamWatcher
1778
+ // ─────────────────────────────────────────────────────────────────────────────
1779
+
1780
+ describe("startChangeStreamWatcher & stopChangeStreamWatcher", () => {
1781
+ const makeMockIo = (): any => {
1782
+ const emissions: any[] = [];
1783
+ const rooms = new Map<string, Set<string>>();
1784
+ const sockets = new Map<string, any>();
1785
+ return {
1786
+ emissions,
1787
+ sockets: {
1788
+ adapter: {rooms},
1789
+ sockets,
1790
+ },
1791
+ to: (_room: string) => ({
1792
+ emit: (): void => {},
1793
+ }),
1794
+ };
1795
+ };
1796
+
1797
+ afterEach(async () => {
1798
+ await stopChangeStreamWatcher();
1799
+ clearRealtimeRegistry();
1800
+ });
1801
+
1802
+ it("starts and stops without error when MongoDB is connected", async () => {
1803
+ const io = makeMockIo();
1804
+ expect(() => startChangeStreamWatcher(io, {}, false)).not.toThrow();
1805
+ await stopChangeStreamWatcher();
1806
+ });
1807
+
1808
+ it("starts with debug mode enabled", async () => {
1809
+ const io = makeMockIo();
1810
+ expect(() => startChangeStreamWatcher(io, {}, true)).not.toThrow();
1811
+ await stopChangeStreamWatcher();
1812
+ });
1813
+
1814
+ it("starts with custom config options", async () => {
1815
+ const io = makeMockIo();
1816
+ expect(() =>
1817
+ startChangeStreamWatcher(
1818
+ io,
1819
+ {
1820
+ batchSize: 10,
1821
+ fullDocument: "whenAvailable",
1822
+ ignoredCollections: ["logs"],
1823
+ ignoredOperations: ["delete"],
1824
+ },
1825
+ false
1826
+ )
1827
+ ).not.toThrow();
1828
+ await stopChangeStreamWatcher();
1829
+ });
1830
+
1831
+ it("stopChangeStreamWatcher is safe to call when no watcher is active", async () => {
1832
+ await expect(stopChangeStreamWatcher()).resolves.toBeUndefined();
1833
+ });
1834
+
1835
+ it("stopChangeStreamWatcher can be called multiple times", async () => {
1836
+ const io = makeMockIo();
1837
+ startChangeStreamWatcher(io, {}, false);
1838
+ await stopChangeStreamWatcher();
1839
+ await stopChangeStreamWatcher();
1840
+ });
1841
+ });
1842
+
1843
+ // Change streams require a MongoDB replica set. CI (api-ci.yml) runs standalone MongoDB,
1844
+ // so these tests are skipped when replica sets are not available.
1845
+ const hasReplicaSet = async (): Promise<boolean> => {
1846
+ try {
1847
+ const mongoose = require("mongoose");
1848
+ const admin = mongoose.connection.db.admin();
1849
+ const status = await admin.command({replSetGetStatus: 1});
1850
+ return !!status.ok;
1851
+ } catch {
1852
+ return false;
1853
+ }
1854
+ };
1855
+
1856
+ describe("startChangeStreamWatcher — change event integration", () => {
1857
+ const mongoose = require("mongoose");
1858
+ let replicaSetAvailable = false;
1859
+
1860
+ const realtimeTestSchema = new mongoose.Schema(
1861
+ {
1862
+ deleted: {default: false, type: Boolean},
1863
+ name: {type: String},
1864
+ ownerId: {type: String},
1865
+ },
1866
+ {collection: "realtimetests", strict: "throw"}
1867
+ );
1868
+
1869
+ let RealtimeTestModel: any;
1870
+ try {
1871
+ RealtimeTestModel = mongoose.model("RealtimeTest");
1872
+ } catch {
1873
+ RealtimeTestModel = mongoose.model("RealtimeTest", realtimeTestSchema);
1874
+ }
1875
+
1876
+ const makeTrackedIo = (): any => {
1877
+ const emissions: any[] = [];
1878
+ const rooms = new Map<string, Set<string>>();
1879
+ const sockets = new Map<string, any>();
1880
+
1881
+ const addSocketToRoom = (
1882
+ room: string,
1883
+ decodedToken: {id?: string; admin?: boolean} = {admin: true, id: "admin"}
1884
+ ): void => {
1885
+ const socketId = `socket-${Math.random().toString(36).slice(2, 9)}`;
1886
+ if (!rooms.has(room)) {
1887
+ rooms.set(room, new Set());
1888
+ }
1889
+ rooms.get(room)?.add(socketId);
1890
+ sockets.set(socketId, {
1891
+ decodedToken,
1892
+ emit: (event: string, payload: unknown): void => {
1893
+ emissions.push({event, payload, room, socketId});
1894
+ },
1895
+ id: socketId,
1896
+ });
1897
+ };
1898
+
1899
+ return {
1900
+ addSocketToRoom,
1901
+ emissions,
1902
+ sockets: {
1903
+ adapter: {rooms},
1904
+ sockets,
1905
+ },
1906
+ to: (room: string) => ({
1907
+ emit: (event: string, payload: unknown): void => {
1908
+ emissions.push({event, payload, room});
1909
+ },
1910
+ }),
1911
+ };
1912
+ };
1913
+
1914
+ beforeAll(async () => {
1915
+ replicaSetAvailable = await hasReplicaSet();
1916
+ });
1917
+
1918
+ beforeEach(async () => {
1919
+ clearRealtimeRegistry();
1920
+ clearQueryStore();
1921
+ await RealtimeTestModel.deleteMany({});
1922
+ });
1923
+
1924
+ afterEach(async () => {
1925
+ await stopChangeStreamWatcher();
1926
+ clearRealtimeRegistry();
1927
+ clearQueryStore();
1928
+ await RealtimeTestModel.deleteMany({});
1929
+ });
1930
+
1931
+ it("processes insert events from MongoDB change stream", async () => {
1932
+ if (!replicaSetAvailable) {
1933
+ return;
1934
+ }
1935
+ registerRealtime({
1936
+ collectionName: "realtimetests",
1937
+ config: {methods: ["create", "update", "delete"], roomStrategy: "model"},
1938
+ modelName: "RealtimeTest",
1939
+ options: {
1940
+ permissions: {
1941
+ create: [() => true],
1942
+ delete: [() => true],
1943
+ list: [() => true],
1944
+ read: [() => true],
1945
+ update: [() => true],
1946
+ },
1947
+ } as any,
1948
+ routePath: "/realtimetests",
1949
+ });
1950
+
1951
+ const io = makeTrackedIo();
1952
+ io.addSocketToRoom("model:realtimetests");
1953
+ startChangeStreamWatcher(io, {}, true);
1954
+
1955
+ await RealtimeTestModel.create({name: "test-item", ownerId: "user-1"});
1956
+
1957
+ // Wait for the change stream event to be processed
1958
+ await new Promise((resolve) => setTimeout(resolve, 1500));
1959
+
1960
+ const createEmissions = io.emissions.filter(
1961
+ (e: any) => e.event === "sync" && e.payload?.method === "create"
1962
+ );
1963
+ expect(createEmissions.length).toBeGreaterThanOrEqual(1);
1964
+ await stopChangeStreamWatcher();
1965
+ });
1966
+
1967
+ it("processes update events from MongoDB change stream", async () => {
1968
+ if (!replicaSetAvailable) {
1969
+ return;
1970
+ }
1971
+ registerRealtime({
1972
+ collectionName: "realtimetests",
1973
+ config: {methods: ["create", "update", "delete"], roomStrategy: "model"},
1974
+ modelName: "RealtimeTest",
1975
+ options: {
1976
+ permissions: {
1977
+ create: [() => true],
1978
+ delete: [() => true],
1979
+ list: [() => true],
1980
+ read: [() => true],
1981
+ update: [() => true],
1982
+ },
1983
+ } as any,
1984
+ routePath: "/realtimetests",
1985
+ });
1986
+
1987
+ const doc = await RealtimeTestModel.create({name: "item-to-update", ownerId: "user-1"});
1988
+
1989
+ const io = makeTrackedIo();
1990
+ io.addSocketToRoom("model:realtimetests");
1991
+ startChangeStreamWatcher(io, {}, true);
1992
+
1993
+ await RealtimeTestModel.updateOne({_id: doc._id}, {$set: {name: "updated-item"}});
1994
+
1995
+ await new Promise((resolve) => setTimeout(resolve, 1500));
1996
+
1997
+ const updateEmissions = io.emissions.filter(
1998
+ (e: any) => e.event === "sync" && e.payload?.method === "update"
1999
+ );
2000
+ expect(updateEmissions.length).toBeGreaterThanOrEqual(1);
2001
+ await stopChangeStreamWatcher();
2002
+ });
2003
+
2004
+ it("processes hard delete events from MongoDB change stream", async () => {
2005
+ if (!replicaSetAvailable) {
2006
+ return;
2007
+ }
2008
+ registerRealtime({
2009
+ collectionName: "realtimetests",
2010
+ config: {methods: ["create", "update", "delete"], roomStrategy: "model"},
2011
+ modelName: "RealtimeTest",
2012
+ options: {
2013
+ permissions: {
2014
+ create: [() => true],
2015
+ delete: [() => true],
2016
+ list: [() => true],
2017
+ read: [() => true],
2018
+ update: [() => true],
2019
+ },
2020
+ } as any,
2021
+ routePath: "/realtimetests",
2022
+ });
2023
+
2024
+ const doc = await RealtimeTestModel.create({name: "item-to-delete"});
2025
+
2026
+ const io = makeTrackedIo();
2027
+ io.addSocketToRoom("model:realtimetests");
2028
+ startChangeStreamWatcher(io, {}, true);
2029
+
2030
+ await RealtimeTestModel.deleteOne({_id: doc._id});
2031
+
2032
+ await new Promise((resolve) => setTimeout(resolve, 1500));
2033
+
2034
+ const deleteEmissions = io.emissions.filter(
2035
+ (e: any) => e.event === "sync" && e.payload?.method === "delete"
2036
+ );
2037
+ expect(deleteEmissions.length).toBeGreaterThanOrEqual(1);
2038
+ await stopChangeStreamWatcher();
2039
+ });
2040
+
2041
+ it("processes soft delete events from MongoDB change stream", async () => {
2042
+ if (!replicaSetAvailable) {
2043
+ return;
2044
+ }
2045
+ registerRealtime({
2046
+ collectionName: "realtimetests",
2047
+ config: {methods: ["create", "update", "delete"], roomStrategy: "model"},
2048
+ modelName: "RealtimeTest",
2049
+ options: {
2050
+ permissions: {
2051
+ create: [() => true],
2052
+ delete: [() => true],
2053
+ list: [() => true],
2054
+ read: [() => true],
2055
+ update: [() => true],
2056
+ },
2057
+ } as any,
2058
+ routePath: "/realtimetests",
2059
+ });
2060
+
2061
+ const doc = await RealtimeTestModel.create({name: "item-to-soft-delete"});
2062
+
2063
+ const io = makeTrackedIo();
2064
+ io.addSocketToRoom("model:realtimetests");
2065
+ startChangeStreamWatcher(io, {}, true);
2066
+
2067
+ await RealtimeTestModel.updateOne({_id: doc._id}, {$set: {deleted: true}});
2068
+
2069
+ await new Promise((resolve) => setTimeout(resolve, 1500));
2070
+
2071
+ const deleteEmissions = io.emissions.filter(
2072
+ (e: any) => e.event === "sync" && e.payload?.method === "delete"
2073
+ );
2074
+ expect(deleteEmissions.length).toBeGreaterThanOrEqual(1);
2075
+ await stopChangeStreamWatcher();
2076
+ });
2077
+
2078
+ it("includes updatedFields and emits to document rooms", async () => {
2079
+ if (!replicaSetAvailable) {
2080
+ return;
2081
+ }
2082
+ registerRealtime({
2083
+ collectionName: "realtimetests",
2084
+ config: {methods: ["create", "update", "delete"], roomStrategy: "model"},
2085
+ modelName: "RealtimeTest",
2086
+ options: {
2087
+ permissions: {
2088
+ create: [() => true],
2089
+ delete: [() => true],
2090
+ list: [() => true],
2091
+ read: [() => true],
2092
+ update: [() => true],
2093
+ },
2094
+ } as any,
2095
+ routePath: "/realtimetests",
2096
+ });
2097
+
2098
+ const doc = await RealtimeTestModel.create({name: "fields-test"});
2099
+ const docId = doc._id.toString();
2100
+
2101
+ const io = makeTrackedIo();
2102
+ io.addSocketToRoom("model:realtimetests");
2103
+ io.addSocketToRoom(`document:realtimetests:${docId}`);
2104
+ startChangeStreamWatcher(io, {}, true);
2105
+
2106
+ await RealtimeTestModel.updateOne({_id: doc._id}, {$set: {name: "fields-updated"}});
2107
+
2108
+ await new Promise((resolve) => setTimeout(resolve, 1500));
2109
+
2110
+ const updateEmissions = io.emissions.filter(
2111
+ (e: any) => e.event === "sync" && e.payload?.method === "update"
2112
+ );
2113
+ expect(updateEmissions.length).toBeGreaterThanOrEqual(1);
2114
+ if (updateEmissions.length > 0) {
2115
+ expect(updateEmissions[0].payload.updatedFields).toBeDefined();
2116
+ expect(updateEmissions[0].payload.updatedFields).toContain("name");
2117
+ }
2118
+ await stopChangeStreamWatcher();
2119
+ });
2120
+ });
2121
+
2122
+ // ─────────────────────────────────────────────────────────────────────────────
2123
+ // emitToDocumentAndQueryRooms — no-entry path
2124
+ // ─────────────────────────────────────────────────────────────────────────────
2125
+
2126
+ describe("emitToDocumentAndQueryRooms — no registry entry", () => {
2127
+ const makeIoSimple = (): any => {
2128
+ const emissions: Array<{room: string; event: string; payload: unknown}> = [];
2129
+ return {
2130
+ emissions,
2131
+ sockets: {
2132
+ adapter: {rooms: new Map()},
2133
+ sockets: new Map(),
2134
+ },
2135
+ to: (room: string) => ({
2136
+ emit: (event: string, payload: unknown): void => {
2137
+ emissions.push({event, payload, room});
2138
+ },
2139
+ }),
2140
+ };
2141
+ };
2142
+
2143
+ beforeEach(() => {
2144
+ clearQueryStore();
2145
+ });
2146
+
2147
+ afterEach(() => {
2148
+ clearQueryStore();
2149
+ });
2150
+
2151
+ it("emits to document room via io.to when no entry is provided", async () => {
2152
+ const io = makeIoSimple();
2153
+ const event: any = {
2154
+ collection: "items",
2155
+ id: "doc-1",
2156
+ method: "update",
2157
+ model: "Item",
2158
+ timestamp: 1,
2159
+ };
2160
+ await emitToDocumentAndQueryRooms(io, "items", event, {}, () => {});
2161
+ expect(io.emissions.some((e: any) => e.room === "document:items:doc-1")).toBe(true);
2162
+ });
2163
+
2164
+ it("emits hard deletes to query rooms via io.to when no entry", async () => {
2165
+ const queryId = computeQueryId("items", {status: "active"});
2166
+ addQuerySubscription("socket-a", "items", {status: "active"}, queryId);
2167
+ const io = makeIoSimple();
2168
+ const event: any = {
2169
+ collection: "items",
2170
+ id: "doc-1",
2171
+ method: "delete",
2172
+ model: "Item",
2173
+ timestamp: 1,
2174
+ };
2175
+ await emitToDocumentAndQueryRooms(io, "items", event, undefined, () => {});
2176
+ expect(io.emissions.some((e: any) => e.room === `query:${queryId}`)).toBe(true);
2177
+ });
2178
+
2179
+ it("emits soft delete to query rooms via io.to when no entry and doc matches", async () => {
2180
+ const queryId = computeQueryId("items", {status: "active"});
2181
+ addQuerySubscription("socket-a", "items", {status: "active"}, queryId);
2182
+ const io = makeIoSimple();
2183
+ const event: any = {
2184
+ collection: "items",
2185
+ id: "doc-1",
2186
+ method: "delete",
2187
+ model: "Item",
2188
+ timestamp: 1,
2189
+ };
2190
+ await emitToDocumentAndQueryRooms(io, "items", event, {status: "active"}, () => {});
2191
+ expect(io.emissions.some((e: any) => e.room === `query:${queryId}`)).toBe(true);
2192
+ });
2193
+
2194
+ it("emits create events to query rooms via io.to when no entry and doc matches", async () => {
2195
+ const queryId = computeQueryId("items", {status: "active"});
2196
+ addQuerySubscription("socket-a", "items", {status: "active"}, queryId);
2197
+ const io = makeIoSimple();
2198
+ const event: any = {
2199
+ collection: "items",
2200
+ id: "doc-1",
2201
+ method: "create",
2202
+ model: "Item",
2203
+ timestamp: 1,
2204
+ };
2205
+ await emitToDocumentAndQueryRooms(io, "items", event, {status: "active"}, () => {});
2206
+ expect(io.emissions.some((e: any) => e.room === `query:${queryId}`)).toBe(true);
2207
+ });
2208
+
2209
+ it("emits update events to query rooms via io.to when no entry and doc matches", async () => {
2210
+ const queryId = computeQueryId("items", {status: "active"});
2211
+ addQuerySubscription("socket-a", "items", {status: "active"}, queryId);
2212
+ const io = makeIoSimple();
2213
+ const event: any = {
2214
+ collection: "items",
2215
+ id: "doc-1",
2216
+ method: "update",
2217
+ model: "Item",
2218
+ timestamp: 1,
2219
+ };
2220
+ await emitToDocumentAndQueryRooms(io, "items", event, {status: "active"}, () => {});
2221
+ expect(io.emissions.some((e: any) => e.room === `query:${queryId}`)).toBe(true);
2222
+ });
2223
+
2224
+ it("emits delete to query rooms via io.to when update no longer matches and no entry", async () => {
2225
+ const queryId = computeQueryId("items", {status: "active"});
2226
+ addQuerySubscription("socket-a", "items", {status: "active"}, queryId);
2227
+ const io = makeIoSimple();
2228
+ const event: any = {
2229
+ collection: "items",
2230
+ id: "doc-1",
2231
+ method: "update",
2232
+ model: "Item",
2233
+ timestamp: 1,
2234
+ };
2235
+ await emitToDocumentAndQueryRooms(io, "items", event, {status: "inactive"}, () => {});
2236
+ const queryEmissions = io.emissions.filter((e: any) => e.room === `query:${queryId}`);
2237
+ expect(queryEmissions.length).toBe(1);
2238
+ expect(queryEmissions[0].payload).toMatchObject({method: "delete"});
2239
+ });
2240
+ });
2241
+
2242
+ // ─────────────────────────────────────────────────────────────────────────────
2243
+ // RealtimeApp — onServerCreated, setupAdapter, close
2244
+ // ─────────────────────────────────────────────────────────────────────────────
2245
+
2246
+ describe("RealtimeApp — onServerCreated and setupAdapter", () => {
2247
+ const originalEnv = process.env;
2248
+
2249
+ beforeEach(() => {
2250
+ process.env = {
2251
+ ...originalEnv,
2252
+ TOKEN_SECRET: "test-secret",
2253
+ };
2254
+ });
2255
+
2256
+ afterEach(async () => {
2257
+ process.env = originalEnv;
2258
+ clearRealtimeRegistry();
2259
+ });
2260
+
2261
+ it("register adds /realtime/health endpoint with debug flag", async () => {
2262
+ const expressApp = express();
2263
+ const app = new RealtimeApp({debug: true});
2264
+ app.register(expressApp);
2265
+ const supertest = await import("supertest");
2266
+ const st = supertest.default(expressApp);
2267
+ const res = await st.get("/realtime/health").expect(200);
2268
+ expect(res.body.status).toBe("not_started");
2269
+ expect(res.body.debug).toBe(true);
2270
+ expect(res.body.clients).toBe(0);
2271
+ });
2272
+
2273
+ it("onServerCreated sets up Socket.io with JWT auth", async () => {
2274
+ const http = await import("node:http");
2275
+ const app = new RealtimeApp({debug: true, tokenSecret: "test-secret"});
2276
+ const expressApp = express();
2277
+ app.register(expressApp);
2278
+ const server = http.createServer(expressApp);
2279
+
2280
+ app.onServerCreated(server);
2281
+ expect(app.getIo()).not.toBeNull();
2282
+
2283
+ await app.close();
2284
+ server.close();
2285
+ });
2286
+
2287
+ it("onServerCreated throws when TOKEN_SECRET is missing", () => {
2288
+ const http = require("node:http");
2289
+ const origSecret = process.env.TOKEN_SECRET;
2290
+ process.env.TOKEN_SECRET = "";
2291
+ const app = new RealtimeApp({});
2292
+ const expressApp = express();
2293
+ app.register(expressApp);
2294
+ const server = http.createServer(expressApp);
2295
+
2296
+ expect(() => app.onServerCreated(server)).toThrow("TOKEN_SECRET is required");
2297
+ process.env.TOKEN_SECRET = origSecret;
2298
+ server.close();
2299
+ });
2300
+
2301
+ it("onServerCreated uses default TOKEN_SECRET from env", async () => {
2302
+ const http = await import("node:http");
2303
+ process.env.TOKEN_SECRET = "env-secret";
2304
+ const app = new RealtimeApp({debug: false});
2305
+ const expressApp = express();
2306
+ app.register(expressApp);
2307
+ const server = http.createServer(expressApp);
2308
+
2309
+ app.onServerCreated(server);
2310
+ expect(app.getIo()).not.toBeNull();
2311
+
2312
+ await app.close();
2313
+ server.close();
2314
+ });
2315
+
2316
+ it("setupAdapter logs info for redis adapter with URL", async () => {
2317
+ const http = await import("node:http");
2318
+ const app = new RealtimeApp({
2319
+ adapter: "redis",
2320
+ debug: true,
2321
+ redisUrl: "redis://user:pass@localhost:6379",
2322
+ tokenSecret: "test-secret",
2323
+ });
2324
+ const expressApp = express();
2325
+ app.register(expressApp);
2326
+ const server = http.createServer(expressApp);
2327
+
2328
+ app.onServerCreated(server);
2329
+ expect(app.getIo()).not.toBeNull();
2330
+
2331
+ await app.close();
2332
+ server.close();
2333
+ });
2334
+
2335
+ it("setupAdapter warns when redis adapter has no URL", async () => {
2336
+ const http = await import("node:http");
2337
+ const origValkey = process.env.VALKEY_URL;
2338
+ const origRedis = process.env.REDIS_URL;
2339
+ delete process.env.VALKEY_URL;
2340
+ delete process.env.REDIS_URL;
2341
+
2342
+ const app = new RealtimeApp({
2343
+ adapter: "redis",
2344
+ debug: true,
2345
+ tokenSecret: "test-secret",
2346
+ });
2347
+ const expressApp = express();
2348
+ app.register(expressApp);
2349
+ const server = http.createServer(expressApp);
2350
+
2351
+ app.onServerCreated(server);
2352
+ expect(app.getIo()).not.toBeNull();
2353
+
2354
+ await app.close();
2355
+ server.close();
2356
+ process.env.VALKEY_URL = origValkey;
2357
+ process.env.REDIS_URL = origRedis;
2358
+ });
2359
+
2360
+ it("setupAdapter with none adapter does nothing extra", async () => {
2361
+ const http = await import("node:http");
2362
+ const app = new RealtimeApp({
2363
+ adapter: "none",
2364
+ debug: true,
2365
+ tokenSecret: "test-secret",
2366
+ });
2367
+ const expressApp = express();
2368
+ app.register(expressApp);
2369
+ const server = http.createServer(expressApp);
2370
+
2371
+ app.onServerCreated(server);
2372
+ expect(app.getIo()).not.toBeNull();
2373
+
2374
+ await app.close();
2375
+ server.close();
2376
+ });
2377
+
2378
+ it("close is safe after onServerCreated", async () => {
2379
+ const http = await import("node:http");
2380
+ const app = new RealtimeApp({tokenSecret: "test-secret"});
2381
+ const expressApp = express();
2382
+ app.register(expressApp);
2383
+ const server = http.createServer(expressApp);
2384
+
2385
+ app.onServerCreated(server);
2386
+ await app.close();
2387
+ expect(app.getIo()).toBeNull();
2388
+ server.close();
2389
+ });
2390
+
2391
+ it("health endpoint reports running after onServerCreated", async () => {
2392
+ const http = await import("node:http");
2393
+ const app = new RealtimeApp({tokenSecret: "test-secret"});
2394
+ const expressApp = express();
2395
+ app.register(expressApp);
2396
+ const server = http.createServer(expressApp);
2397
+
2398
+ app.onServerCreated(server);
2399
+
2400
+ const supertest = await import("supertest");
2401
+ const st = supertest.default(expressApp);
2402
+ const res = await st.get("/realtime/health").expect(200);
2403
+ expect(res.body.status).toBe("running");
2404
+
2405
+ await app.close();
2406
+ server.close();
2407
+ });
2408
+
2409
+ it("onServerCreated with custom cors option", async () => {
2410
+ const http = await import("node:http");
2411
+ const app = new RealtimeApp({
2412
+ cors: {methods: ["GET"], origin: "https://example.com"},
2413
+ tokenSecret: "test-secret",
2414
+ });
2415
+ const expressApp = express();
2416
+ app.register(expressApp);
2417
+ const server = http.createServer(expressApp);
2418
+
2419
+ app.onServerCreated(server);
2420
+ expect(app.getIo()).not.toBeNull();
2421
+
2422
+ await app.close();
2423
+ server.close();
2424
+ });
2425
+
2426
+ it("setupAdapter uses VALKEY_URL when redisUrl not provided", async () => {
2427
+ const http = await import("node:http");
2428
+ process.env.VALKEY_URL = "redis://localhost:6379";
2429
+ const app = new RealtimeApp({
2430
+ adapter: "redis",
2431
+ debug: true,
2432
+ tokenSecret: "test-secret",
2433
+ });
2434
+ const expressApp = express();
2435
+ app.register(expressApp);
2436
+ const server = http.createServer(expressApp);
2437
+
2438
+ app.onServerCreated(server);
2439
+ expect(app.getIo()).not.toBeNull();
2440
+
2441
+ await app.close();
2442
+ server.close();
2443
+ delete process.env.VALKEY_URL;
2444
+ });
2445
+ });
2446
+
1737
2447
  describe("redactCredentials", () => {
1738
2448
  it("redacts user:password@ in a redis URL", () => {
1739
2449
  expect(redactCredentials("redis://user:secret@host:6379/0")).toBe("redis://***@host:6379/0");