@rebasepro/server-core 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/package.json +14 -14
  2. package/src/api/errors.ts +16 -2
  3. package/src/api/graphql/graphql-schema-generator.ts +45 -10
  4. package/src/api/server.ts +10 -2
  5. package/src/api/types.ts +6 -0
  6. package/src/auth/adapter-middleware.ts +10 -2
  7. package/src/auth/admin-roles-route.ts +36 -0
  8. package/src/auth/admin-users-route.ts +302 -0
  9. package/src/auth/api-keys/api-key-middleware.ts +4 -3
  10. package/src/auth/api-keys/api-key-routes.ts +12 -2
  11. package/src/auth/api-keys/api-key-store.ts +83 -66
  12. package/src/auth/api-keys/api-key-types.ts +8 -0
  13. package/src/auth/apple-oauth.ts +2 -1
  14. package/src/auth/auth-hooks.ts +21 -0
  15. package/src/auth/bitbucket-oauth.ts +2 -1
  16. package/src/auth/builtin-auth-adapter.ts +28 -4
  17. package/src/auth/custom-auth-adapter.ts +2 -0
  18. package/src/auth/discord-oauth.ts +2 -1
  19. package/src/auth/facebook-oauth.ts +2 -1
  20. package/src/auth/github-oauth.ts +2 -1
  21. package/src/auth/gitlab-oauth.ts +2 -1
  22. package/src/auth/google-oauth.ts +8 -4
  23. package/src/auth/index.ts +2 -0
  24. package/src/auth/interfaces.ts +27 -0
  25. package/src/auth/linkedin-oauth.ts +2 -1
  26. package/src/auth/magic-link-routes.ts +167 -0
  27. package/src/auth/mfa-crypto.ts +91 -0
  28. package/src/auth/mfa-routes.ts +34 -10
  29. package/src/auth/microsoft-oauth.ts +2 -1
  30. package/src/auth/middleware.ts +10 -1
  31. package/src/auth/reset-password-admin.ts +17 -1
  32. package/src/auth/routes.ts +77 -8
  33. package/src/auth/session-routes.ts +15 -3
  34. package/src/auth/slack-oauth.ts +2 -1
  35. package/src/auth/spotify-oauth.ts +2 -1
  36. package/src/auth/twitter-oauth.ts +8 -1
  37. package/src/cron/cron-store.ts +25 -23
  38. package/src/email/index.ts +3 -2
  39. package/src/email/templates.ts +82 -0
  40. package/src/email/types.ts +16 -0
  41. package/src/init.ts +137 -5
  42. package/src/services/routed-realtime-service.ts +113 -0
  43. package/src/utils/dev-port.ts +13 -7
  44. package/test/auth-routes.test.ts +54 -4
  45. package/test/custom-auth-adapter.test.ts +20 -1
  46. package/test/env.test.ts +9 -19
  47. package/test/multi-datasource-routing.test.ts +113 -0
  48. package/test/routed-realtime-service.test.ts +86 -0
  49. package/test/transform-auth-response.test.ts +305 -0
  50. package/build-errors.txt +0 -52
  51. package/dist/api/ast-schema-editor.d.ts +0 -21
  52. package/dist/api/collections_for_test/callbacks_test_collection.d.ts +0 -2
  53. package/dist/api/errors.d.ts +0 -53
  54. package/dist/api/graphql/graphql-schema-generator.d.ts +0 -35
  55. package/dist/api/graphql/index.d.ts +0 -1
  56. package/dist/api/index.d.ts +0 -9
  57. package/dist/api/logs-routes.d.ts +0 -37
  58. package/dist/api/openapi-generator.d.ts +0 -16
  59. package/dist/api/rest/api-generator.d.ts +0 -88
  60. package/dist/api/rest/index.d.ts +0 -1
  61. package/dist/api/rest/query-parser.d.ts +0 -11
  62. package/dist/api/schema-editor-routes.d.ts +0 -3
  63. package/dist/api/server.d.ts +0 -40
  64. package/dist/api/types.d.ts +0 -98
  65. package/dist/auth/adapter-middleware.d.ts +0 -37
  66. package/dist/auth/admin-user-ops.d.ts +0 -79
  67. package/dist/auth/api-keys/api-key-middleware.d.ts +0 -39
  68. package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -32
  69. package/dist/auth/api-keys/api-key-routes.d.ts +0 -20
  70. package/dist/auth/api-keys/api-key-store.d.ts +0 -35
  71. package/dist/auth/api-keys/api-key-types.d.ts +0 -88
  72. package/dist/auth/api-keys/index.d.ts +0 -17
  73. package/dist/auth/apple-oauth.d.ts +0 -30
  74. package/dist/auth/auth-hooks.d.ts +0 -225
  75. package/dist/auth/bitbucket-oauth.d.ts +0 -11
  76. package/dist/auth/builtin-auth-adapter.d.ts +0 -49
  77. package/dist/auth/crypto-utils.d.ts +0 -16
  78. package/dist/auth/custom-auth-adapter.d.ts +0 -39
  79. package/dist/auth/discord-oauth.d.ts +0 -14
  80. package/dist/auth/facebook-oauth.d.ts +0 -14
  81. package/dist/auth/github-oauth.d.ts +0 -15
  82. package/dist/auth/gitlab-oauth.d.ts +0 -13
  83. package/dist/auth/google-oauth.d.ts +0 -47
  84. package/dist/auth/index.d.ts +0 -36
  85. package/dist/auth/interfaces.d.ts +0 -401
  86. package/dist/auth/jwt.d.ts +0 -55
  87. package/dist/auth/linkedin-oauth.d.ts +0 -18
  88. package/dist/auth/mfa-routes.d.ts +0 -6
  89. package/dist/auth/mfa.d.ts +0 -49
  90. package/dist/auth/microsoft-oauth.d.ts +0 -16
  91. package/dist/auth/middleware.d.ts +0 -106
  92. package/dist/auth/password.d.ts +0 -22
  93. package/dist/auth/rate-limiter.d.ts +0 -50
  94. package/dist/auth/reset-password-admin.d.ts +0 -29
  95. package/dist/auth/rls-scope.d.ts +0 -31
  96. package/dist/auth/routes.d.ts +0 -33
  97. package/dist/auth/session-routes.d.ts +0 -25
  98. package/dist/auth/slack-oauth.d.ts +0 -12
  99. package/dist/auth/spotify-oauth.d.ts +0 -12
  100. package/dist/auth/twitter-oauth.d.ts +0 -18
  101. package/dist/backend-CIxN4FVm.js +0 -15
  102. package/dist/backend-CIxN4FVm.js.map +0 -1
  103. package/dist/chunk-Dze3rakg.js +0 -42
  104. package/dist/collections/BackendCollectionRegistry.d.ts +0 -13
  105. package/dist/collections/loader.d.ts +0 -5
  106. package/dist/cron/cron-loader.d.ts +0 -17
  107. package/dist/cron/cron-routes.d.ts +0 -14
  108. package/dist/cron/cron-scheduler.d.ts +0 -106
  109. package/dist/cron/cron-store.d.ts +0 -32
  110. package/dist/cron/index.d.ts +0 -6
  111. package/dist/db/interfaces.d.ts +0 -18
  112. package/dist/dist-CZKP-Xz4.js +0 -832
  113. package/dist/dist-CZKP-Xz4.js.map +0 -1
  114. package/dist/email/index.d.ts +0 -6
  115. package/dist/email/smtp-email-service.d.ts +0 -25
  116. package/dist/email/templates.d.ts +0 -42
  117. package/dist/email/types.d.ts +0 -108
  118. package/dist/env.d.ts +0 -102
  119. package/dist/from-VbwD7xRf.js +0 -3849
  120. package/dist/from-VbwD7xRf.js.map +0 -1
  121. package/dist/functions/function-loader.d.ts +0 -17
  122. package/dist/functions/function-routes.d.ts +0 -10
  123. package/dist/functions/index.d.ts +0 -3
  124. package/dist/history/history-routes.d.ts +0 -23
  125. package/dist/history/index.d.ts +0 -1
  126. package/dist/index.d.ts +0 -33
  127. package/dist/index.es.js +0 -61731
  128. package/dist/index.es.js.map +0 -1
  129. package/dist/index.umd.js +0 -73221
  130. package/dist/index.umd.js.map +0 -1
  131. package/dist/init/docs.d.ts +0 -4
  132. package/dist/init/health.d.ts +0 -2
  133. package/dist/init/middlewares.d.ts +0 -10
  134. package/dist/init/shutdown.d.ts +0 -11
  135. package/dist/init/storage.d.ts +0 -5
  136. package/dist/init.d.ts +0 -258
  137. package/dist/jwt-DHcQRGC3.js +0 -4168
  138. package/dist/jwt-DHcQRGC3.js.map +0 -1
  139. package/dist/logger-BYU66ENZ.js +0 -94
  140. package/dist/logger-BYU66ENZ.js.map +0 -1
  141. package/dist/ms-BeBSuOXN.js +0 -125
  142. package/dist/ms-BeBSuOXN.js.map +0 -1
  143. package/dist/multipart-parser-CedBDOeC.js +0 -299
  144. package/dist/multipart-parser-CedBDOeC.js.map +0 -1
  145. package/dist/serve-spa.d.ts +0 -30
  146. package/dist/services/driver-registry.d.ts +0 -78
  147. package/dist/services/webhook-service.d.ts +0 -29
  148. package/dist/singleton.d.ts +0 -35
  149. package/dist/src-COaj0G3P.js +0 -1182
  150. package/dist/src-COaj0G3P.js.map +0 -1
  151. package/dist/storage/LocalStorageController.d.ts +0 -46
  152. package/dist/storage/S3StorageController.d.ts +0 -36
  153. package/dist/storage/image-transform.d.ts +0 -50
  154. package/dist/storage/index.d.ts +0 -28
  155. package/dist/storage/routes.d.ts +0 -38
  156. package/dist/storage/storage-registry.d.ts +0 -78
  157. package/dist/storage/tus-handler.d.ts +0 -51
  158. package/dist/storage/types.d.ts +0 -103
  159. package/dist/types/index.d.ts +0 -11
  160. package/dist/utils/dev-port.d.ts +0 -35
  161. package/dist/utils/logger.d.ts +0 -31
  162. package/dist/utils/logging.d.ts +0 -9
  163. package/dist/utils/request-id.d.ts +0 -4
  164. package/dist/utils/request-logger.d.ts +0 -19
  165. package/dist/utils/sql.d.ts +0 -27
@@ -17,6 +17,18 @@ import { configureJwt, generateAccessToken, hashRefreshToken } from "../src/auth
17
17
 
18
18
  jest.mock("../src/auth/password");
19
19
 
20
+ jest.mock("../src/utils/logger", () => {
21
+ return {
22
+ logger: {
23
+ info: jest.fn(),
24
+ warn: jest.fn(),
25
+ error: jest.fn(),
26
+ debug: jest.fn(),
27
+ child: jest.fn().mockReturnThis()
28
+ }
29
+ };
30
+ });
31
+
20
32
  // Bypass rate limiters — they share state across tests and cause 429s
21
33
  jest.mock("../src/auth/rate-limiter", () => {
22
34
  const passthrough = async (_c: unknown, next: () => Promise<void>) => next();
@@ -28,6 +40,7 @@ jest.mock("../src/auth/rate-limiter", () => {
28
40
  });
29
41
 
30
42
  import { hashPassword, verifyPassword, validatePasswordStrength } from "../src/auth/password";
43
+ import { logger } from "../src/utils/logger";
31
44
  import { z } from "zod";
32
45
 
33
46
  // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -139,15 +152,18 @@ verifyEmailUrl: "https://app.test" } : undefined,
139
152
  if (idToken === "link-token") return { providerId: "g-456",
140
153
  email: "existing@test.com",
141
154
  displayName: "Existing",
142
- photoUrl: null };
155
+ photoUrl: null,
156
+ emailVerified: true };
143
157
  if (idToken === "returning-token") return { providerId: "g-789",
144
158
  email: "returning@test.com",
145
159
  displayName: "Updated Name",
146
- photoUrl: "https://new-photo.url" };
160
+ photoUrl: "https://new-photo.url",
161
+ emailVerified: true };
147
162
  if (idToken === "valid-token") return { providerId: "g-123",
148
163
  email: "google@test.com",
149
164
  displayName: "Google User",
150
- photoUrl: "https://photo.url" };
165
+ photoUrl: "https://photo.url",
166
+ emailVerified: true };
151
167
  return null;
152
168
  }
153
169
  }
@@ -361,9 +377,43 @@ password: "Any1" }));
361
377
  mockAuthRepo.getUserByEmail.mockResolvedValueOnce(mockUser());
362
378
 
363
379
  await app.request("/auth/login", json({ email: "test@example.com",
364
- password: "ValidPass1" }));
380
+ password: "ValidPass1" }));
365
381
  expect(mockAuthRepo.createRefreshToken).toHaveBeenCalledTimes(1);
366
382
  });
383
+
384
+ it("emits a structured security audit log on successful login", async () => {
385
+ const app = createApp();
386
+ const user = mockUser({ passwordHash: "salt:hash" });
387
+ mockAuthRepo.getUserByEmail.mockResolvedValueOnce(user);
388
+
389
+ await app.request("/auth/login", json({ email: "test@example.com", password: "ValidPass1" }));
390
+
391
+ expect(logger.info).toHaveBeenCalledWith(
392
+ expect.stringContaining("[Security Audit] Auth login success"),
393
+ expect.objectContaining({
394
+ userId: "user-1",
395
+ email: "test@example.com",
396
+ eventType: "auth.login.success"
397
+ })
398
+ );
399
+ });
400
+
401
+ it("emits a structured security audit log on failed login (wrong password)", async () => {
402
+ const app = createApp();
403
+ mockAuthRepo.getUserByEmail.mockResolvedValueOnce(mockUser());
404
+ (verifyPassword as jest.Mock).mockResolvedValueOnce(false);
405
+
406
+ await app.request("/auth/login", json({ email: "test@example.com", password: "WrongPassword" }));
407
+
408
+ expect(logger.warn).toHaveBeenCalledWith(
409
+ expect.stringContaining("[Security Audit] Auth login failure"),
410
+ expect.objectContaining({
411
+ email: "test@example.com",
412
+ userId: "user-1",
413
+ eventType: "auth.login.failure"
414
+ })
415
+ );
416
+ });
367
417
  });
368
418
 
369
419
  // ── Google OAuth ────────────────────────────────────────────────────
@@ -1,5 +1,5 @@
1
1
  import { createCustomAuthAdapter } from "../src/auth/custom-auth-adapter";
2
- import type { AuthenticatedUser, CustomAuthAdapterOptions } from "@rebasepro/types";
2
+ import type { AuthenticatedUser, AuthResponsePayload, CustomAuthAdapterOptions } from "@rebasepro/types";
3
3
 
4
4
  const TEST_USER: AuthenticatedUser = {
5
5
  uid: "user-123",
@@ -118,6 +118,7 @@ describe("createCustomAuthAdapter", () => {
118
118
  sessionManagement: false,
119
119
  profileUpdate: false,
120
120
  emailVerification: false,
121
+ magicLink: false,
121
122
  enabledProviders: []
122
123
  });
123
124
  });
@@ -167,4 +168,22 @@ registration: true }
167
168
  });
168
169
  expect(adapter.userManagement).toBeUndefined();
169
170
  });
171
+
172
+ // ── transformAuthResponse ────────────────────────────────────────────
173
+
174
+ it("passes through transformAuthResponse when provided", () => {
175
+ const transformFn = jest.fn(async (response: AuthResponsePayload) => response);
176
+ const adapter = createCustomAuthAdapter({
177
+ verifyRequest: async () => null,
178
+ transformAuthResponse: transformFn
179
+ });
180
+ expect(adapter.transformAuthResponse).toBe(transformFn);
181
+ });
182
+
183
+ it("omits transformAuthResponse when not provided", () => {
184
+ const adapter = createCustomAuthAdapter({
185
+ verifyRequest: async () => null
186
+ });
187
+ expect(adapter.transformAuthResponse).toBeUndefined();
188
+ });
170
189
  });
package/test/env.test.ts CHANGED
@@ -1,32 +1,22 @@
1
- import { z } from "zod";
2
1
  import { loadEnv } from "../src/env";
2
+ import { z } from "zod";
3
3
 
4
4
  describe("env configuration and localhost validation", () => {
5
- let originalEnv: NodeJS.ProcessEnv;
5
+ const originalEnv = { ...process.env };
6
6
 
7
7
  beforeEach(() => {
8
- // Save a backup of the original process.env
9
- originalEnv = { ...process.env };
10
- // Clear env vars that might interfere with tests
11
- delete process.env.NODE_ENV;
12
- delete process.env.DATABASE_URL;
13
- delete process.env.ADMIN_CONNECTION_STRING;
14
- delete process.env.JWT_SECRET;
15
- delete process.env.FRONTEND_URL;
16
- delete process.env.CORS_ORIGINS;
17
- delete process.env.ALLOW_LOCALHOST_IN_PRODUCTION;
8
+ // Clear process.env to ensure fresh test runs
9
+ process.env = {};
10
+ // Add basic fallback requirements to prevent bootstrap crashes
11
+ process.env.DATABASE_URL = "postgresql://localhost:5432/rebase";
12
+ process.env.JWT_SECRET = "super-secret-jwt-key-must-be-long-long-long";
18
13
  });
19
14
 
20
15
  afterEach(() => {
21
- // Restore process.env after each test
22
- process.env = originalEnv;
16
+ process.env = { ...originalEnv };
23
17
  });
24
18
 
25
- it("should allow localhost URLs in development mode", () => {
26
- process.env.NODE_ENV = "development";
27
- process.env.DATABASE_URL = "postgresql://localhost:5432/rebase";
28
- process.env.JWT_SECRET = "12345678901234567890123456789012";
29
-
19
+ it("should parse default env variables in development mode", () => {
30
20
  expect(() => loadEnv()).not.toThrow();
31
21
  const env = loadEnv();
32
22
  expect(env.DATABASE_URL).toBe("postgresql://localhost:5432/rebase");
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, beforeAll } from "@jest/globals";
2
+ import { Hono, Context } from "hono";
3
+ import { configureJwt, generateAccessToken } from "../src/auth/jwt";
4
+ import { createAuthMiddleware } from "../src/auth/middleware";
5
+ import { createAdapterAuthMiddleware } from "../src/auth/adapter-middleware";
6
+ import type { HonoEnv } from "../src/api/types";
7
+ import type { DataDriver, AuthAdapter } from "@rebasepro/types";
8
+
9
+ const TEST_SECRET = "test-secret-key-for-multi-datasource-routing-1234567890";
10
+
11
+ // A mock driver that tags itself so we can assert which one was scoped into
12
+ // the request context. The Postgres-like one implements withAuth (RLS), the
13
+ // Mongo-like one does not (scopeDataDriver must no-op for it).
14
+ function mockDriver(key: string, withRls: boolean): DataDriver {
15
+ const base: Record<string, unknown> = {
16
+ key,
17
+ fetchCollection: async () => [],
18
+ fetchEntity: async () => undefined,
19
+ saveEntity: async () => ({ id: "x", path: key, values: {} }),
20
+ deleteEntity: async () => undefined
21
+ };
22
+ if (withRls) {
23
+ // Returns a scoped clone that keeps the same key tag.
24
+ base.withAuth = async () => ({ ...base, scoped: true });
25
+ }
26
+ return base as unknown as DataDriver;
27
+ }
28
+
29
+ describe("Multi-data-source request routing (middleware)", () => {
30
+
31
+ beforeAll(() => {
32
+ configureJwt({ secret: TEST_SECRET, accessExpiresIn: "1h" });
33
+ });
34
+
35
+ const pg = mockDriver("postgres", true);
36
+ const mongo = mockDriver("mongodb", false);
37
+
38
+ // Route by first path segment: /products → pg, /events → mongo.
39
+ const resolveDriver = (c: Context<HonoEnv>): DataDriver => {
40
+ const slug = c.req.path.replace(/^\/+/, "").split("/")[0];
41
+ return slug === "events" ? mongo : pg;
42
+ };
43
+
44
+ function createApp() {
45
+ const app = new Hono<HonoEnv>();
46
+ app.use("/*", createAuthMiddleware({ driver: pg, resolveDriver, requireAuth: false }));
47
+ app.get("/:slug", (c: Context<HonoEnv>) => {
48
+ const driver = c.get("driver") as DataDriver | undefined;
49
+ return c.json({ key: driver?.key });
50
+ });
51
+ return app;
52
+ }
53
+
54
+ it("scopes the Postgres delegate for a Postgres collection path", async () => {
55
+ const token = generateAccessToken("u1", ["admin"]);
56
+ const res = await createApp().request("/products", { headers: { Authorization: `Bearer ${token}` } });
57
+ const body = await res.json() as { key: string };
58
+ expect(body.key).toBe("postgres");
59
+ });
60
+
61
+ it("scopes the Mongo delegate for a Mongo collection path", async () => {
62
+ const token = generateAccessToken("u1", ["admin"]);
63
+ const res = await createApp().request("/events", { headers: { Authorization: `Bearer ${token}` } });
64
+ const body = await res.json() as { key: string };
65
+ expect(body.key).toBe("mongodb");
66
+ });
67
+
68
+ it("routes per request based on the resolver, not a fixed driver", async () => {
69
+ const token = generateAccessToken("u1", ["admin"]);
70
+ const app = createApp();
71
+ const a = await (await app.request("/events", { headers: { Authorization: `Bearer ${token}` } })).json() as { key: string };
72
+ const b = await (await app.request("/products", { headers: { Authorization: `Bearer ${token}` } })).json() as { key: string };
73
+ expect(a.key).toBe("mongodb");
74
+ expect(b.key).toBe("postgres");
75
+ });
76
+
77
+ it("falls back to the base driver when no resolver is provided", async () => {
78
+ const app = new Hono<HonoEnv>();
79
+ app.use("/*", createAuthMiddleware({ driver: mongo, requireAuth: false }));
80
+ app.get("/:slug", (c: Context<HonoEnv>) => c.json({ key: (c.get("driver") as DataDriver | undefined)?.key }));
81
+ const res = await app.request("/anything");
82
+ const body = await res.json() as { key: string };
83
+ expect(body.key).toBe("mongodb");
84
+ });
85
+
86
+ it("routes a nested/subcollection path by its top-level segment", async () => {
87
+ const token = generateAccessToken("u1", ["admin"]);
88
+ const app = new Hono<HonoEnv>();
89
+ app.use("/*", createAuthMiddleware({ driver: pg, resolveDriver, requireAuth: false }));
90
+ app.get("/events/:id/attendees", (c: Context<HonoEnv>) =>
91
+ c.json({ key: (c.get("driver") as DataDriver | undefined)?.key }));
92
+ const res = await app.request("/events/e1/attendees", { headers: { Authorization: `Bearer ${token}` } });
93
+ const body = await res.json() as { key: string };
94
+ expect(body.key).toBe("mongodb");
95
+ });
96
+
97
+ it("routes per request through the adapter auth middleware too", async () => {
98
+ const adapter: AuthAdapter = {
99
+ id: "test-adapter",
100
+ serviceKey: undefined,
101
+ verifyRequest: async () => ({ uid: "u1", roles: ["admin"] })
102
+ } as unknown as AuthAdapter;
103
+
104
+ const app = new Hono<HonoEnv>();
105
+ app.use("/*", createAdapterAuthMiddleware({ adapter, driver: pg, resolveDriver, requireAuth: false }));
106
+ app.get("/:slug", (c: Context<HonoEnv>) => c.json({ key: (c.get("driver") as DataDriver | undefined)?.key }));
107
+
108
+ const a = await (await app.request("/events")).json() as { key: string };
109
+ const b = await (await app.request("/products")).json() as { key: string };
110
+ expect(a.key).toBe("mongodb");
111
+ expect(b.key).toBe("postgres");
112
+ });
113
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, jest, beforeEach } from "@jest/globals";
2
+ import { createRoutedRealtimeService } from "../src/services/routed-realtime-service";
3
+ import type { RealtimeProvider } from "@rebasepro/types";
4
+
5
+ function mockProvider() {
6
+ return {
7
+ addClient: jest.fn(),
8
+ handleClientMessage: jest.fn(async () => {}),
9
+ subscribeToCollection: jest.fn(),
10
+ subscribeToEntity: jest.fn(),
11
+ unsubscribe: jest.fn(),
12
+ notifyEntityUpdate: jest.fn(async () => {}),
13
+ onServerReady: jest.fn(),
14
+ destroy: jest.fn(async () => {}),
15
+ stopListening: jest.fn(async () => {})
16
+ };
17
+ }
18
+
19
+ describe("createRoutedRealtimeService", () => {
20
+ let pg: ReturnType<typeof mockProvider>;
21
+ let mongo: ReturnType<typeof mockProvider>;
22
+ let routed: ReturnType<typeof createRoutedRealtimeService>;
23
+
24
+ beforeEach(() => {
25
+ pg = mockProvider();
26
+ mongo = mockProvider();
27
+ routed = createRoutedRealtimeService({
28
+ providers: { "(default)": pg as unknown as RealtimeProvider, mongo: mongo as unknown as RealtimeProvider },
29
+ defaultKey: "(default)",
30
+ resolveKey: (path) => (path.split("/")[0] === "events" ? "mongo" : "(default)")
31
+ });
32
+ });
33
+
34
+ it("routes subscribe_collection to the provider owning the collection", async () => {
35
+ await routed.handleClientMessage("c1", { type: "subscribe_collection", payload: { path: "events", subscriptionId: "s1" } });
36
+ expect(mongo.handleClientMessage).toHaveBeenCalledTimes(1);
37
+ expect(pg.handleClientMessage).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it("routes subscribe_entity by path", async () => {
41
+ await routed.handleClientMessage("c1", { type: "subscribe_entity", payload: { path: "products", subscriptionId: "s2" } });
42
+ expect(pg.handleClientMessage).toHaveBeenCalledTimes(1);
43
+ expect(mongo.handleClientMessage).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it("forwards unsubscribe to all providers (owner acts, others no-op)", async () => {
47
+ await routed.handleClientMessage("c1", { type: "unsubscribe", subscriptionId: "s1" });
48
+ expect(pg.handleClientMessage).toHaveBeenCalledTimes(1);
49
+ expect(mongo.handleClientMessage).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ it("sends channel/presence/broadcast to the default provider only", async () => {
53
+ await routed.handleClientMessage("c1", { type: "broadcast", payload: { channel: "room", event: "x" } });
54
+ await routed.handleClientMessage("c1", { type: "join_channel", payload: { channel: "room" } });
55
+ await routed.handleClientMessage("c1", { type: "presence_track", payload: { channel: "room" } });
56
+ expect(pg.handleClientMessage).toHaveBeenCalledTimes(3);
57
+ expect(mongo.handleClientMessage).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it("addClient is forwarded to every provider", () => {
61
+ const ws = {};
62
+ routed.addClient("c1", ws);
63
+ expect(pg.addClient).toHaveBeenCalledWith("c1", ws);
64
+ expect(mongo.addClient).toHaveBeenCalledWith("c1", ws);
65
+ });
66
+
67
+ it("notifyEntityUpdate routes by path", async () => {
68
+ await routed.notifyEntityUpdate("events", "e1", null);
69
+ expect(mongo.notifyEntityUpdate).toHaveBeenCalledTimes(1);
70
+ expect(pg.notifyEntityUpdate).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it("unknown path falls back to the default provider", async () => {
74
+ await routed.handleClientMessage("c1", { type: "subscribe_collection", payload: { path: "mystery", subscriptionId: "s9" } });
75
+ expect(pg.handleClientMessage).toHaveBeenCalledTimes(1);
76
+ });
77
+
78
+ it("lifecycle methods fan out to all providers", async () => {
79
+ routed.onServerReady?.({ port: 3000 });
80
+ await routed.destroy?.();
81
+ expect(pg.onServerReady).toHaveBeenCalledTimes(1);
82
+ expect(mongo.onServerReady).toHaveBeenCalledTimes(1);
83
+ expect(pg.destroy).toHaveBeenCalledTimes(1);
84
+ expect(mongo.destroy).toHaveBeenCalledTimes(1);
85
+ });
86
+ });