@rebasepro/server-postgresql 0.0.1-canary.09e5ec5
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/LICENSE +6 -0
- package/README.md +106 -0
- package/build-errors.txt +37 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +58 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +22 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index.es.js +11298 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +11306 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
- package/dist/server-postgresql/src/auth/services.d.ts +192 -0
- package/dist/server-postgresql/src/cli.d.ts +1 -0
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
- package/dist/server-postgresql/src/connection.d.ts +40 -0
- package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
- package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
- package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
- package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
- package/dist/server-postgresql/src/index.d.ts +13 -0
- package/dist/server-postgresql/src/interfaces.d.ts +18 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
- package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
- package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
- package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
- package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
- package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
- package/dist/server-postgresql/src/services/index.d.ts +4 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
- package/dist/server-postgresql/src/websocket.d.ts +5 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +119 -0
- package/dist/types/src/controllers/client.d.ts +170 -0
- package/dist/types/src/controllers/collection_registry.d.ts +45 -0
- package/dist/types/src/controllers/customization_controller.d.ts +60 -0
- package/dist/types/src/controllers/data.d.ts +168 -0
- package/dist/types/src/controllers/data_driver.d.ts +160 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +18 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +54 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +171 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +105 -0
- package/dist/types/src/types/backend.d.ts +536 -0
- package/dist/types/src/types/builders.d.ts +15 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +856 -0
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +10 -0
- package/dist/types/src/types/entity_views.d.ts +61 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/index.d.ts +23 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +279 -0
- package/dist/types/src/types/properties.d.ts +1176 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +252 -0
- package/dist/types/src/types/translations.d.ts +870 -0
- package/dist/types/src/types/user_management_delegate.d.ts +121 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -0
- package/drizzle-test/0000_woozy_junta.sql +6 -0
- package/drizzle-test/0001_youthful_arachne.sql +1 -0
- package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
- package/drizzle-test/0003_mean_king_cobra.sql +2 -0
- package/drizzle-test/meta/0000_snapshot.json +47 -0
- package/drizzle-test/meta/0001_snapshot.json +48 -0
- package/drizzle-test/meta/0002_snapshot.json +38 -0
- package/drizzle-test/meta/0003_snapshot.json +48 -0
- package/drizzle-test/meta/_journal.json +34 -0
- package/drizzle-test-out/0000_tan_trauma.sql +6 -0
- package/drizzle-test-out/0001_rapid_drax.sql +1 -0
- package/drizzle-test-out/meta/0000_snapshot.json +44 -0
- package/drizzle-test-out/meta/0001_snapshot.json +54 -0
- package/drizzle-test-out/meta/_journal.json +20 -0
- package/drizzle.test.config.ts +10 -0
- package/jest-all.log +3128 -0
- package/jest.log +49 -0
- package/package.json +92 -0
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +1008 -0
- package/src/PostgresBootstrapper.ts +231 -0
- package/src/auth/ensure-tables.ts +381 -0
- package/src/auth/services.ts +799 -0
- package/src/cli.ts +648 -0
- package/src/collections/PostgresCollectionRegistry.ts +96 -0
- package/src/connection.ts +84 -0
- package/src/data-transformer.ts +608 -0
- package/src/databasePoolManager.ts +85 -0
- package/src/history/HistoryService.ts +248 -0
- package/src/history/ensure-history-table.ts +45 -0
- package/src/index.ts +13 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-schema.ts +169 -0
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +765 -0
- package/src/schema/generate-drizzle-schema.ts +151 -0
- package/src/schema/introspect-db-logic.ts +542 -0
- package/src/schema/introspect-db.ts +211 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +237 -0
- package/src/services/EntityFetchService.ts +1576 -0
- package/src/services/EntityPersistService.ts +349 -0
- package/src/services/RelationService.ts +1274 -0
- package/src/services/entity-helpers.ts +147 -0
- package/src/services/entityService.ts +211 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1034 -0
- package/src/utils/drizzle-conditions.ts +1000 -0
- package/src/websocket.ts +518 -0
- package/test/auth-services.test.ts +661 -0
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +367 -0
- package/test/data-transformer-hardening.test.ts +417 -0
- package/test/data-transformer.test.ts +175 -0
- package/test/doctor.test.ts +182 -0
- package/test/drizzle-conditions.test.ts +895 -0
- package/test/entityService.errors.test.ts +367 -0
- package/test/entityService.relations.test.ts +1008 -0
- package/test/entityService.subcollection-search.test.ts +566 -0
- package/test/entityService.test.ts +1035 -0
- package/test/generate-drizzle-schema.test.ts +988 -0
- package/test/historyService.test.ts +141 -0
- package/test/introspect-db-generation.test.ts +436 -0
- package/test/introspect-db-utils.test.ts +389 -0
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +648 -0
- package/test/realtimeService.test.ts +307 -0
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +1115 -0
- package/test/unmapped-tables-safety.test.ts +345 -0
- package/test-drizzle-bug.ts +18 -0
- package/test-drizzle-out/0000_cultured_freak.sql +7 -0
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
- package/test-drizzle-out/meta/0000_snapshot.json +55 -0
- package/test-drizzle-out/meta/0001_snapshot.json +63 -0
- package/test-drizzle-out/meta/_journal.json +20 -0
- package/test-drizzle-prompt.sh +2 -0
- package/test-policy-prompt.sh +3 -0
- package/test-programmatic.ts +30 -0
- package/test-programmatic2.ts +59 -0
- package/test-schema-no-policies.ts +12 -0
- package/test_drizzle_mock.js +3 -0
- package/test_find_changed.mjs +32 -0
- package/test_hash.js +14 -0
- package/test_output.txt +3145 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- package/vite.config.ts +82 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, it, expect, beforeEach } from "@jest/globals";
|
|
3
|
+
import { PostgresBackendDriver } from "../src/PostgresBackendDriver";
|
|
4
|
+
import { RealtimeService } from "../src/services/realtimeService";
|
|
5
|
+
import { EntityService } from "../src/services/entityService";
|
|
6
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
7
|
+
import { sql } from "drizzle-orm";
|
|
8
|
+
import type { EntityCollection, Entity } from "@rebasepro/types";
|
|
9
|
+
|
|
10
|
+
type MockTx = { execute: jest.Mock };
|
|
11
|
+
type MockUser = { uid: string; email?: string; roles?: unknown[] };
|
|
12
|
+
|
|
13
|
+
// Mock dependencies
|
|
14
|
+
const mockDb = {
|
|
15
|
+
transaction: jest.fn(),
|
|
16
|
+
execute: jest.fn(),
|
|
17
|
+
select: jest.fn().mockReturnThis(),
|
|
18
|
+
from: jest.fn().mockReturnThis(),
|
|
19
|
+
limit: jest.fn().mockReturnThis(),
|
|
20
|
+
where: jest.fn().mockReturnThis(),
|
|
21
|
+
orderBy: jest.fn().mockReturnThis()
|
|
22
|
+
} as unknown as NodePgDatabase;
|
|
23
|
+
|
|
24
|
+
const mockRealtimeService = {
|
|
25
|
+
registerDataDriverSubscription: jest.fn(),
|
|
26
|
+
addSubscriptionCallback: jest.fn(),
|
|
27
|
+
removeSubscriptionCallback: jest.fn(),
|
|
28
|
+
subscriptions: new Map(),
|
|
29
|
+
notifyEntityUpdate: jest.fn()
|
|
30
|
+
} as unknown as RealtimeService;
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
describe("PostgresBackendDriver", () => {
|
|
34
|
+
let delegate: PostgresBackendDriver;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
jest.clearAllMocks();
|
|
38
|
+
delegate = new PostgresBackendDriver(mockDb, mockRealtimeService);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should initialize correctly", () => {
|
|
42
|
+
expect(delegate).toBeDefined();
|
|
43
|
+
expect(delegate.key).toBe("postgres");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("withAuth", () => {
|
|
47
|
+
it("should return a new delegate instance", async () => {
|
|
48
|
+
const user = { uid: "test-user",
|
|
49
|
+
email: "test@example.com" };
|
|
50
|
+
const authDelegate = await delegate.withAuth(user);
|
|
51
|
+
|
|
52
|
+
expect(authDelegate).toBeDefined();
|
|
53
|
+
expect(authDelegate).not.toBe(delegate);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should wrap methods in a transaction", async () => {
|
|
57
|
+
const user = { uid: "test-user",
|
|
58
|
+
email: "test@example.com" };
|
|
59
|
+
const authDelegate = await delegate.withAuth(user);
|
|
60
|
+
|
|
61
|
+
const mockTx = { execute: jest.fn() };
|
|
62
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
63
|
+
return await cb(mockTx);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
jest.spyOn(PostgresBackendDriver.prototype, "fetchCollection").mockResolvedValueOnce([]);
|
|
67
|
+
|
|
68
|
+
await authDelegate.fetchCollection({ path: "test_collection",
|
|
69
|
+
collection: { slug: "test",
|
|
70
|
+
properties: {} } as unknown as EntityCollection });
|
|
71
|
+
|
|
72
|
+
expect(mockDb.transaction).toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should set app.user_id in the transaction for RLS", async () => {
|
|
76
|
+
const user = { uid: "test-user-123",
|
|
77
|
+
email: "test@example.com" };
|
|
78
|
+
const authDelegate = await delegate.withAuth(user);
|
|
79
|
+
|
|
80
|
+
const mockTx: MockTx = { execute: jest.fn() };
|
|
81
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
82
|
+
return await cb(mockTx);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
jest.spyOn(PostgresBackendDriver.prototype, "fetchCollection").mockResolvedValueOnce([]);
|
|
86
|
+
|
|
87
|
+
await authDelegate.fetchCollection({ path: "test",
|
|
88
|
+
collection: { slug: "test",
|
|
89
|
+
properties: {} } as unknown as EntityCollection });
|
|
90
|
+
|
|
91
|
+
expect(mockDb.transaction).toHaveBeenCalled();
|
|
92
|
+
expect(mockTx.execute).toHaveBeenCalled();
|
|
93
|
+
const sqlCall = mockTx.execute.mock.calls[0][0];
|
|
94
|
+
const callString = JSON.stringify(sqlCall);
|
|
95
|
+
expect(callString).toContain("set_config");
|
|
96
|
+
expect(callString).toContain("test-user-123");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should set app.user_roles handling array of strings correctly", async () => {
|
|
100
|
+
const user: MockUser = { uid: "test-user-123",
|
|
101
|
+
email: "test@example.com",
|
|
102
|
+
roles: ["admin", "editor"] };
|
|
103
|
+
const authDelegate = await delegate.withAuth(user);
|
|
104
|
+
|
|
105
|
+
const mockTx: MockTx = { execute: jest.fn() };
|
|
106
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
107
|
+
return await cb(mockTx);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
jest.spyOn(PostgresBackendDriver.prototype, "fetchCollection").mockResolvedValueOnce([]);
|
|
111
|
+
|
|
112
|
+
await authDelegate.fetchCollection({ path: "test",
|
|
113
|
+
collection: { slug: "test",
|
|
114
|
+
properties: {} } as unknown as EntityCollection });
|
|
115
|
+
|
|
116
|
+
expect(mockTx.execute).toHaveBeenCalledTimes(1);
|
|
117
|
+
const sqlCall = mockTx.execute.mock.calls[0][0];
|
|
118
|
+
const callString = JSON.stringify(sqlCall);
|
|
119
|
+
expect(callString).toContain("set_config");
|
|
120
|
+
expect(callString).toContain("admin,editor");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("should set app.user_roles handling array of objects correctly", async () => {
|
|
124
|
+
const user: MockUser = { uid: "test-user-123",
|
|
125
|
+
email: "test@example.com",
|
|
126
|
+
roles: [{ id: "admin" }, { id: "editor" }] };
|
|
127
|
+
const authDelegate = await delegate.withAuth(user);
|
|
128
|
+
|
|
129
|
+
const mockTx: MockTx = { execute: jest.fn() };
|
|
130
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
131
|
+
return await cb(mockTx);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
jest.spyOn(PostgresBackendDriver.prototype, "fetchCollection").mockResolvedValueOnce([]);
|
|
135
|
+
|
|
136
|
+
await authDelegate.fetchCollection({ path: "test",
|
|
137
|
+
collection: { slug: "test",
|
|
138
|
+
properties: {} } as unknown as EntityCollection });
|
|
139
|
+
|
|
140
|
+
expect(mockTx.execute).toHaveBeenCalledTimes(1);
|
|
141
|
+
const sqlCall = mockTx.execute.mock.calls[0][0];
|
|
142
|
+
const callString = JSON.stringify(sqlCall);
|
|
143
|
+
expect(callString).toContain("set_config");
|
|
144
|
+
expect(callString).toContain("admin,editor");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should fallback to anonymous and empty roles when missing from user", async () => {
|
|
148
|
+
const user = {} as unknown as MockUser; // Empty user object
|
|
149
|
+
const authDelegate = await delegate.withAuth(user);
|
|
150
|
+
|
|
151
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb: (tx: MockTx) => Promise<unknown>) => {
|
|
152
|
+
const mockTx: MockTx = { execute: jest.fn() };
|
|
153
|
+
return cb(mockTx);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// We mock fetchCollection to just return something and not crash
|
|
157
|
+
jest.spyOn(PostgresBackendDriver.prototype, "fetchCollection").mockResolvedValueOnce([]);
|
|
158
|
+
|
|
159
|
+
await authDelegate.fetchCollection({ path: "test",
|
|
160
|
+
collection: { slug: "test",
|
|
161
|
+
properties: {} } as unknown as EntityCollection });
|
|
162
|
+
|
|
163
|
+
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
|
164
|
+
|
|
165
|
+
const transactionCallback = (mockDb.transaction as jest.Mock).mock.calls[0][0];
|
|
166
|
+
const mockTx: MockTx = { execute: jest.fn() };
|
|
167
|
+
await transactionCallback(mockTx).catch(() => {});
|
|
168
|
+
|
|
169
|
+
const sqlCall = mockTx.execute.mock.calls[0][0];
|
|
170
|
+
const callString = JSON.stringify(sqlCall);
|
|
171
|
+
expect(callString).toContain("set_config");
|
|
172
|
+
expect(callString).toContain("anonymous");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should gracefully handle completely null or undefined user objects", async () => {
|
|
176
|
+
const authDelegate = await delegate.withAuth(null as unknown as MockUser);
|
|
177
|
+
|
|
178
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb: (tx: MockTx) => Promise<unknown>) => {
|
|
179
|
+
const mockTx: MockTx = { execute: jest.fn() };
|
|
180
|
+
return cb(mockTx);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
jest.spyOn(PostgresBackendDriver.prototype, "fetchCollection").mockResolvedValueOnce([]);
|
|
184
|
+
|
|
185
|
+
await authDelegate.fetchCollection({ path: "test",
|
|
186
|
+
collection: { slug: "test",
|
|
187
|
+
properties: {} } as unknown as EntityCollection });
|
|
188
|
+
|
|
189
|
+
const transactionCallback = (mockDb.transaction as jest.Mock).mock.calls[0][0];
|
|
190
|
+
const mockTx: MockTx = { execute: jest.fn() };
|
|
191
|
+
await transactionCallback(mockTx).catch(() => {});
|
|
192
|
+
|
|
193
|
+
const sqlCall = mockTx.execute.mock.calls[0][0];
|
|
194
|
+
const callString = JSON.stringify(sqlCall);
|
|
195
|
+
expect(callString).toContain("set_config");
|
|
196
|
+
expect(callString).toContain("anonymous");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("AuthenticatedPostgresBackendDriver Transactional Integrity", () => {
|
|
201
|
+
let authDelegate: Awaited<ReturnType<typeof delegate.withAuth>>;
|
|
202
|
+
|
|
203
|
+
beforeEach(async () => {
|
|
204
|
+
authDelegate = await delegate.withAuth({ uid: "test-user",
|
|
205
|
+
email: "test@example.com" });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should execute operation and flush notifications on success", async () => {
|
|
209
|
+
const mockTx = {
|
|
210
|
+
execute: jest.fn()
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
214
|
+
return await cb(mockTx);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Let's pretend the operation queues a notification
|
|
218
|
+
jest.spyOn(PostgresBackendDriver.prototype, "saveEntity").mockImplementationOnce(async function(this: { _pendingNotifications?: Array<Record<string, unknown>> }) {
|
|
219
|
+
this._pendingNotifications?.push({
|
|
220
|
+
path: "test",
|
|
221
|
+
entityId: "123",
|
|
222
|
+
entity: {} as unknown as Entity
|
|
223
|
+
});
|
|
224
|
+
return {} as unknown as Entity;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await authDelegate.saveEntity({ path: "test",
|
|
228
|
+
entityId: "123",
|
|
229
|
+
values: {},
|
|
230
|
+
collection: {} as unknown as EntityCollection,
|
|
231
|
+
status: "new" });
|
|
232
|
+
|
|
233
|
+
// Ensure transaction was called
|
|
234
|
+
expect(mockDb.transaction).toHaveBeenCalled();
|
|
235
|
+
|
|
236
|
+
// Ensure notification was flushed after commit
|
|
237
|
+
expect(mockRealtimeService.notifyEntityUpdate).toHaveBeenCalledWith("test", "123", {}, undefined);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("should NOT flush notifications if transaction throws an error", async () => {
|
|
241
|
+
const mockTx = {
|
|
242
|
+
execute: jest.fn()
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
246
|
+
return await cb(mockTx);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
jest.spyOn(PostgresBackendDriver.prototype, "saveEntity").mockImplementationOnce(async function(this: { _pendingNotifications?: Array<Record<string, unknown>> }) {
|
|
250
|
+
this._pendingNotifications?.push({
|
|
251
|
+
path: "test",
|
|
252
|
+
entityId: "123",
|
|
253
|
+
entity: {} as unknown as Entity
|
|
254
|
+
});
|
|
255
|
+
throw new Error("Transaction failed");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await expect(authDelegate.saveEntity({ path: "test",
|
|
259
|
+
entityId: "123",
|
|
260
|
+
values: {},
|
|
261
|
+
collection: {} as unknown as EntityCollection,
|
|
262
|
+
status: "new" })).rejects.toThrow("Transaction failed");
|
|
263
|
+
|
|
264
|
+
// Ensure notification was NOT flushed
|
|
265
|
+
expect(mockRealtimeService.notifyEntityUpdate).not.toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("should return successfully even if a deferred notification throw an error (resilience)", async () => {
|
|
269
|
+
const mockTx = {
|
|
270
|
+
execute: jest.fn()
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
274
|
+
return await cb(mockTx);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
jest.spyOn(PostgresBackendDriver.prototype, "saveEntity").mockImplementationOnce(async function(this: { _pendingNotifications?: Array<Record<string, unknown>> }) {
|
|
278
|
+
this._pendingNotifications?.push({
|
|
279
|
+
path: "test",
|
|
280
|
+
entityId: "buggy-123",
|
|
281
|
+
entity: {} as unknown as Entity
|
|
282
|
+
});
|
|
283
|
+
return { id: "success" } as unknown as Entity;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Mock the notification service intentionally crashing
|
|
287
|
+
const notifySpy = mockRealtimeService.notifyEntityUpdate as jest.Mock;
|
|
288
|
+
notifySpy.mockRejectedValueOnce(new Error("Network Failure on Notification"));
|
|
289
|
+
|
|
290
|
+
// Should still return the entity successfully despite the error
|
|
291
|
+
const result = await authDelegate.saveEntity({ path: "test",
|
|
292
|
+
entityId: "buggy-123",
|
|
293
|
+
values: {},
|
|
294
|
+
collection: {} as unknown as EntityCollection,
|
|
295
|
+
status: "new" });
|
|
296
|
+
|
|
297
|
+
expect(result).toEqual({ id: "success" });
|
|
298
|
+
expect(notifySpy).toHaveBeenCalledWith("test", "buggy-123", {}, undefined);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("should safely isolate completely concurrent transaction notifications from leaking across scopes", async () => {
|
|
302
|
+
const mockTx = {
|
|
303
|
+
execute: jest.fn()
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// This mock introduces slight async delay to allow concurrent operations to interleave execution
|
|
307
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
308
|
+
await new Promise(r => setTimeout(r, 10));
|
|
309
|
+
return await cb(mockTx);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Operation 1 flags a notification
|
|
313
|
+
const save1 = jest.spyOn(PostgresBackendDriver.prototype, "saveEntity").mockImplementationOnce(async function(this: { _pendingNotifications?: Array<Record<string, unknown>> }) {
|
|
314
|
+
this._pendingNotifications?.push({ path: "scope-1",
|
|
315
|
+
entityId: "1",
|
|
316
|
+
entity: {} as unknown as Entity });
|
|
317
|
+
return {} as unknown as Entity;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Operation 2 flags a different notification
|
|
321
|
+
const save2 = jest.spyOn(PostgresBackendDriver.prototype, "saveEntity").mockImplementationOnce(async function(this: { _pendingNotifications?: Array<Record<string, unknown>> }) {
|
|
322
|
+
this._pendingNotifications?.push({ path: "scope-2",
|
|
323
|
+
entityId: "2",
|
|
324
|
+
entity: {} as unknown as Entity });
|
|
325
|
+
return {} as unknown as Entity;
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Fire simultaneously
|
|
329
|
+
await Promise.all([
|
|
330
|
+
authDelegate.saveEntity({ path: "scope-1",
|
|
331
|
+
entityId: "1",
|
|
332
|
+
values: {},
|
|
333
|
+
collection: {} as unknown as EntityCollection,
|
|
334
|
+
status: "new" }),
|
|
335
|
+
authDelegate.saveEntity({ path: "scope-2",
|
|
336
|
+
entityId: "2",
|
|
337
|
+
values: {},
|
|
338
|
+
collection: {} as unknown as EntityCollection,
|
|
339
|
+
status: "new" })
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
// Ensure our notify was called with both exact combinations, but NOT cross-pollinated
|
|
343
|
+
expect(mockRealtimeService.notifyEntityUpdate).toHaveBeenCalledWith("scope-1", "1", {}, undefined);
|
|
344
|
+
expect(mockRealtimeService.notifyEntityUpdate).toHaveBeenCalledWith("scope-2", "2", {}, undefined);
|
|
345
|
+
|
|
346
|
+
// Check count
|
|
347
|
+
expect(mockRealtimeService.notifyEntityUpdate).toHaveBeenCalledTimes(2);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe("AuthenticatedPostgresBackendDriver Delegation", () => {
|
|
352
|
+
let authDelegate: Awaited<ReturnType<typeof delegate.withAuth>>;
|
|
353
|
+
|
|
354
|
+
beforeEach(async () => {
|
|
355
|
+
authDelegate = await delegate.withAuth({ uid: "test-user",
|
|
356
|
+
email: "test@example.com" });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("should delegate executeSql via admin without a transaction", async () => {
|
|
360
|
+
jest.spyOn(delegate, "executeSql").mockResolvedValueOnce([{ id: 1 }] as unknown as Record<string, unknown>[]);
|
|
361
|
+
|
|
362
|
+
const result = await authDelegate.admin.executeSql("SELECT 1");
|
|
363
|
+
|
|
364
|
+
expect(mockDb.transaction).not.toHaveBeenCalled();
|
|
365
|
+
expect(delegate.executeSql).toHaveBeenCalledWith("SELECT 1");
|
|
366
|
+
expect(result).toEqual([{ id: 1 }]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("should override listenCollection to inject auth context", () => {
|
|
370
|
+
const mockUnsubscribe = jest.fn();
|
|
371
|
+
|
|
372
|
+
// Clear original map instead of reassigning
|
|
373
|
+
mockRealtimeService.subscriptions.clear();
|
|
374
|
+
|
|
375
|
+
// The act of calling the delegated method should update the last subscription
|
|
376
|
+
jest.spyOn(delegate, "listenCollection").mockImplementationOnce(() => {
|
|
377
|
+
mockRealtimeService.subscriptions.set("sub1", { clientId: "driver",
|
|
378
|
+
authContext: undefined });
|
|
379
|
+
return mockUnsubscribe;
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const unsub = authDelegate.listenCollection({ path: "test",
|
|
383
|
+
collection: {} as unknown as EntityCollection,
|
|
384
|
+
callbacks: {} as unknown as Record<string, unknown> });
|
|
385
|
+
|
|
386
|
+
expect(unsub).toBe(mockUnsubscribe);
|
|
387
|
+
expect(mockRealtimeService.subscriptions.get("sub1").authContext).toEqual({ userId: "test-user",
|
|
388
|
+
roles: [] });
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("should override listenEntity to inject auth context", () => {
|
|
392
|
+
const mockUnsubscribe = jest.fn();
|
|
393
|
+
|
|
394
|
+
mockRealtimeService.subscriptions.clear();
|
|
395
|
+
|
|
396
|
+
jest.spyOn(delegate, "listenEntity").mockImplementationOnce(() => {
|
|
397
|
+
mockRealtimeService.subscriptions.set("sub2", { clientId: "driver",
|
|
398
|
+
authContext: undefined });
|
|
399
|
+
return mockUnsubscribe;
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const unsub = authDelegate.listenEntity({ path: "test",
|
|
403
|
+
entityId: "123",
|
|
404
|
+
collection: {} as unknown as EntityCollection,
|
|
405
|
+
callbacks: {} as unknown as Record<string, unknown> });
|
|
406
|
+
|
|
407
|
+
expect(unsub).toBe(mockUnsubscribe);
|
|
408
|
+
expect(mockRealtimeService.subscriptions.get("sub2").authContext).toEqual({ userId: "test-user",
|
|
409
|
+
roles: [] });
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("should handle listenCollection gracefully if delegate fails to add a subscription", () => {
|
|
413
|
+
const mockUnsubscribe = jest.fn();
|
|
414
|
+
mockRealtimeService.subscriptions.clear();
|
|
415
|
+
jest.spyOn(delegate, "listenCollection").mockImplementationOnce(() => mockUnsubscribe);
|
|
416
|
+
const unsub = authDelegate.listenCollection({ path: "empty-test",
|
|
417
|
+
collection: {} as unknown as EntityCollection,
|
|
418
|
+
callbacks: {} as unknown as Record<string, unknown> });
|
|
419
|
+
expect(unsub).toBe(mockUnsubscribe);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("should NOT skip authContext injection if subscription has a non-driver clientId", () => {
|
|
423
|
+
const mockUnsubscribe = jest.fn();
|
|
424
|
+
mockRealtimeService.subscriptions.clear();
|
|
425
|
+
jest.spyOn(delegate, "listenCollection").mockImplementationOnce(() => {
|
|
426
|
+
mockRealtimeService.subscriptions.set("sub-ext", { clientId: "external-client",
|
|
427
|
+
authContext: undefined });
|
|
428
|
+
return mockUnsubscribe;
|
|
429
|
+
});
|
|
430
|
+
authDelegate.listenCollection({ path: "test",
|
|
431
|
+
collection: {} as unknown as EntityCollection,
|
|
432
|
+
callbacks: {} as unknown as Record<string, unknown> });
|
|
433
|
+
// authContext should NOT be injected because clientId !== 'driver'
|
|
434
|
+
expect(mockRealtimeService.subscriptions.get("sub-ext").authContext).toBeUndefined();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("should delegate fetchAvailableDatabases via admin without a transaction", async () => {
|
|
438
|
+
jest.spyOn(delegate, "fetchAvailableDatabases").mockResolvedValueOnce(["db1", "db2"]);
|
|
439
|
+
const result = await authDelegate.admin.fetchAvailableDatabases!();
|
|
440
|
+
expect(mockDb.transaction).not.toHaveBeenCalled();
|
|
441
|
+
expect(result).toEqual(["db1", "db2"]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("should delegate fetchAvailableRoles via admin without a transaction", async () => {
|
|
445
|
+
jest.spyOn(delegate, "fetchAvailableRoles").mockResolvedValueOnce(["admin", "viewer"]);
|
|
446
|
+
const result = await authDelegate.admin.fetchAvailableRoles!();
|
|
447
|
+
expect(mockDb.transaction).not.toHaveBeenCalled();
|
|
448
|
+
expect(result).toEqual(["admin", "viewer"]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("should delegate fetchCurrentDatabase via admin without a transaction", async () => {
|
|
452
|
+
jest.spyOn(delegate, "fetchCurrentDatabase").mockResolvedValueOnce("my_db");
|
|
453
|
+
const result = await authDelegate.admin.fetchCurrentDatabase!();
|
|
454
|
+
expect(mockDb.transaction).not.toHaveBeenCalled();
|
|
455
|
+
expect(result).toBe("my_db");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("should delegate fetchUnmappedTables via admin without a transaction", async () => {
|
|
459
|
+
jest.spyOn(delegate, "fetchUnmappedTables").mockResolvedValueOnce(["orphan_table"]);
|
|
460
|
+
const result = await authDelegate.admin.fetchUnmappedTables!(["mapped"]);
|
|
461
|
+
expect(mockDb.transaction).not.toHaveBeenCalled();
|
|
462
|
+
expect(result).toEqual(["orphan_table"]);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("should delegate fetchTableMetadata via admin without a transaction", async () => {
|
|
466
|
+
jest.spyOn(delegate, "fetchTableMetadata").mockResolvedValueOnce([{ name: "id",
|
|
467
|
+
type: "int4" }] as unknown as Record<string, unknown>[]);
|
|
468
|
+
const result = await authDelegate.admin.fetchTableMetadata!("users");
|
|
469
|
+
expect(mockDb.transaction).not.toHaveBeenCalled();
|
|
470
|
+
expect(result).toEqual([{ name: "id",
|
|
471
|
+
type: "int4" }]);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
describe("AuthenticatedPostgresBackendDriver Security & Contract", () => {
|
|
476
|
+
it("should use parameterized queries (drizzle sql``) NOT string interpolation for set_config", async () => {
|
|
477
|
+
// A malicious uid should be passed as a parameter, not concatenated
|
|
478
|
+
const maliciousUser: MockUser = { uid: "admin'; DROP TABLE users; --",
|
|
479
|
+
email: "hacker@evil.com" };
|
|
480
|
+
const authDelegate = await delegate.withAuth(maliciousUser);
|
|
481
|
+
|
|
482
|
+
const mockTx: MockTx = { execute: jest.fn() };
|
|
483
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
484
|
+
return await cb(mockTx);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
jest.spyOn(PostgresBackendDriver.prototype, "fetchCollection").mockResolvedValueOnce([]);
|
|
488
|
+
|
|
489
|
+
await authDelegate.fetchCollection({ path: "x",
|
|
490
|
+
collection: { slug: "x",
|
|
491
|
+
properties: {} } as unknown as EntityCollection });
|
|
492
|
+
|
|
493
|
+
// The SQL template tag should have the userId as a parameter value, not embedded in the SQL string
|
|
494
|
+
const sqlObj = mockTx.execute.mock.calls[0][0];
|
|
495
|
+
const serialized = JSON.stringify(sqlObj);
|
|
496
|
+
expect(serialized).toContain("set_config");
|
|
497
|
+
// The malicious string should appear as a bound parameter, not as raw SQL
|
|
498
|
+
expect(serialized).toContain("admin'; DROP TABLE users; --");
|
|
499
|
+
// Verify it's using Drizzle's tagged template, which inherently parameterizes
|
|
500
|
+
expect(sqlObj).toHaveProperty("queryChunks");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("should produce a valid JWT payload in set_config even with exotic roles", async () => {
|
|
504
|
+
const user: MockUser = { uid: "u1",
|
|
505
|
+
roles: ['role"with"quotes', "role,with,commas", "rôle-spécial"] };
|
|
506
|
+
const authDelegate = await delegate.withAuth(user);
|
|
507
|
+
|
|
508
|
+
const mockTx: MockTx = { execute: jest.fn() };
|
|
509
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
510
|
+
return await cb(mockTx);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
jest.spyOn(PostgresBackendDriver.prototype, "fetchCollection").mockResolvedValueOnce([]);
|
|
514
|
+
|
|
515
|
+
await authDelegate.fetchCollection({ path: "x",
|
|
516
|
+
collection: { slug: "x",
|
|
517
|
+
properties: {} } as unknown as EntityCollection });
|
|
518
|
+
|
|
519
|
+
const serialized = JSON.stringify(mockTx.execute.mock.calls[0][0]);
|
|
520
|
+
// The JWT should be valid JSON.stringify output containing the roles
|
|
521
|
+
expect(serialized).toContain('role\\"with\\"quotes');
|
|
522
|
+
expect(serialized).toContain("rôle-spécial");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("should handle role objects missing the id field by falling back to String()", async () => {
|
|
526
|
+
const user: MockUser = { uid: "u1",
|
|
527
|
+
roles: [{ name: "viewer" }, 42, null] };
|
|
528
|
+
const authDelegate = await delegate.withAuth(user);
|
|
529
|
+
|
|
530
|
+
const mockTx: MockTx = { execute: jest.fn() };
|
|
531
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
532
|
+
return await cb(mockTx);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
jest.spyOn(PostgresBackendDriver.prototype, "fetchCollection").mockResolvedValueOnce([]);
|
|
536
|
+
|
|
537
|
+
await authDelegate.fetchCollection({ path: "x",
|
|
538
|
+
collection: { slug: "x",
|
|
539
|
+
properties: {} } as unknown as EntityCollection });
|
|
540
|
+
|
|
541
|
+
const serialized = JSON.stringify(mockTx.execute.mock.calls[0][0]);
|
|
542
|
+
// Objects without id → String({name:'viewer'}) = "[object Object]", 42 → "42", null → "null"
|
|
543
|
+
expect(serialized).toContain("set_config");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("should wrap deleteEntity in a transaction with RLS", async () => {
|
|
547
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
548
|
+
return await cb({ execute: jest.fn() });
|
|
549
|
+
});
|
|
550
|
+
jest.spyOn(PostgresBackendDriver.prototype, "deleteEntity").mockResolvedValueOnce(undefined);
|
|
551
|
+
|
|
552
|
+
const authDelegate = await delegate.withAuth({ uid: "deleter",
|
|
553
|
+
email: "del@test.com" } as MockUser);
|
|
554
|
+
await authDelegate.deleteEntity({ entity: { id: "1",
|
|
555
|
+
path: "x",
|
|
556
|
+
values: {} } as unknown as Entity });
|
|
557
|
+
|
|
558
|
+
expect(mockDb.transaction).toHaveBeenCalled();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("should wrap checkUniqueField in a transaction with RLS", async () => {
|
|
562
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
563
|
+
return await cb({ execute: jest.fn() });
|
|
564
|
+
});
|
|
565
|
+
jest.spyOn(PostgresBackendDriver.prototype, "checkUniqueField").mockResolvedValueOnce(true);
|
|
566
|
+
|
|
567
|
+
const authDelegate = await delegate.withAuth({ uid: "checker",
|
|
568
|
+
email: "c@test.com" } as MockUser);
|
|
569
|
+
const result = await authDelegate.checkUniqueField("path", "email", "test@x.com", "1");
|
|
570
|
+
|
|
571
|
+
expect(mockDb.transaction).toHaveBeenCalled();
|
|
572
|
+
expect(result).toBe(true);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("should wrap countEntities in a transaction with RLS", async () => {
|
|
576
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => {
|
|
577
|
+
return await cb({ execute: jest.fn() });
|
|
578
|
+
});
|
|
579
|
+
jest.spyOn(PostgresBackendDriver.prototype, "countEntities").mockResolvedValueOnce(42);
|
|
580
|
+
|
|
581
|
+
const authDelegate = await delegate.withAuth({ uid: "counter",
|
|
582
|
+
email: "c@test.com" } as MockUser);
|
|
583
|
+
const result = await authDelegate.countEntities({ path: "items",
|
|
584
|
+
collection: {} as unknown as EntityCollection });
|
|
585
|
+
|
|
586
|
+
expect(mockDb.transaction).toHaveBeenCalled();
|
|
587
|
+
expect(result).toBe(42);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('should expose key="postgres" and initialised=true on the authenticated wrapper', async () => {
|
|
591
|
+
const authDelegate = await delegate.withAuth({ uid: "u",
|
|
592
|
+
email: "u@t.com" } as MockUser);
|
|
593
|
+
expect(authDelegate.key).toBe("postgres");
|
|
594
|
+
expect(authDelegate.initialised).toBe(true);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("should NOT share user state if withAuth is called with different users", async () => {
|
|
598
|
+
const auth1 = await delegate.withAuth({ uid: "alice",
|
|
599
|
+
email: "a@t.com" } as MockUser);
|
|
600
|
+
const auth2 = await delegate.withAuth({ uid: "bob",
|
|
601
|
+
email: "b@t.com" } as MockUser);
|
|
602
|
+
|
|
603
|
+
expect(auth1.user.uid).toBe("alice");
|
|
604
|
+
expect(auth2.user.uid).toBe("bob");
|
|
605
|
+
// They share the same underlying delegate
|
|
606
|
+
expect(auth1.delegate).toBe(auth2.delegate);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("should create a fresh pendingNotifications array per withTransaction call (no accumulation)", async () => {
|
|
610
|
+
const authDelegate = await delegate.withAuth({ uid: "u1",
|
|
611
|
+
email: "u@t.com" } as MockUser);
|
|
612
|
+
const mockTx = { execute: jest.fn() };
|
|
613
|
+
|
|
614
|
+
(mockDb.transaction as jest.Mock).mockImplementation(async (cb) => await cb(mockTx));
|
|
615
|
+
|
|
616
|
+
jest.spyOn(PostgresBackendDriver.prototype, "saveEntity")
|
|
617
|
+
.mockImplementationOnce(async function(this: { _pendingNotifications?: Array<Record<string, unknown>> }) {
|
|
618
|
+
this._pendingNotifications?.push({ path: "call-1",
|
|
619
|
+
entityId: "1",
|
|
620
|
+
entity: {} as unknown as Entity });
|
|
621
|
+
return {} as unknown as Entity;
|
|
622
|
+
})
|
|
623
|
+
.mockImplementationOnce(async function(this: { _pendingNotifications?: Array<Record<string, unknown>> }) {
|
|
624
|
+
this._pendingNotifications?.push({ path: "call-2",
|
|
625
|
+
entityId: "2",
|
|
626
|
+
entity: {} as unknown as Entity });
|
|
627
|
+
return {} as unknown as Entity;
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
await authDelegate.saveEntity({ path: "call-1",
|
|
631
|
+
entityId: "1",
|
|
632
|
+
values: {},
|
|
633
|
+
collection: {} as unknown as EntityCollection,
|
|
634
|
+
status: "new" });
|
|
635
|
+
await authDelegate.saveEntity({ path: "call-2",
|
|
636
|
+
entityId: "2",
|
|
637
|
+
values: {},
|
|
638
|
+
collection: {} as unknown as EntityCollection,
|
|
639
|
+
status: "new" });
|
|
640
|
+
|
|
641
|
+
// Each call should have flushed exactly 1 notification, not accumulated
|
|
642
|
+
expect(mockRealtimeService.notifyEntityUpdate).toHaveBeenCalledTimes(2);
|
|
643
|
+
expect(mockRealtimeService.notifyEntityUpdate).toHaveBeenNthCalledWith(1, "call-1", "1", {}, undefined);
|
|
644
|
+
expect(mockRealtimeService.notifyEntityUpdate).toHaveBeenNthCalledWith(2, "call-2", "2", {}, undefined);
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|