@terreno/api 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/versionCheckPlugin.test.js +36 -0
- package/dist/errors.test.js +25 -0
- package/dist/expressServer.test.js +119 -0
- package/dist/realtime/realtime.test.js +880 -3
- package/package.json +1 -1
- package/src/__tests__/versionCheckPlugin.test.ts +24 -0
- package/src/errors.test.ts +32 -0
- package/src/expressServer.test.ts +79 -0
- package/src/realtime/realtime.test.ts +704 -0
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
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,
|
|
@@ -1737,6 +1738,705 @@ describe("emitToDocumentAndQueryRooms", () => {
|
|
|
1737
1738
|
// redactCredentials — Redis URL logging
|
|
1738
1739
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1739
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
|
+
|
|
1740
2440
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1741
2441
|
// ensureApiId
|
|
1742
2442
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -2444,6 +3144,10 @@ describe("RealtimeApp — onServerCreated and setupAdapter", () => {
|
|
|
2444
3144
|
});
|
|
2445
3145
|
});
|
|
2446
3146
|
|
|
3147
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3148
|
+
// redactCredentials — Redis URL logging
|
|
3149
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3150
|
+
|
|
2447
3151
|
describe("redactCredentials", () => {
|
|
2448
3152
|
it("redacts user:password@ in a redis URL", () => {
|
|
2449
3153
|
expect(redactCredentials("redis://user:secret@host:6379/0")).toBe("redis://***@host:6379/0");
|