@memberjunction/server 6.1.0-edge.2 → 6.1.0-edge.4

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 (210) hide show
  1. package/LICENSE +180 -4
  2. package/dist/agentSessions/SessionManager.d.ts +19 -5
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +27 -9
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
  7. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
  8. package/dist/auth/AuthProviderCatalogRouter.js +69 -0
  9. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
  10. package/dist/auth/AuthProviderEngine.d.ts +96 -0
  11. package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
  12. package/dist/auth/AuthProviderEngine.js +245 -0
  13. package/dist/auth/AuthProviderEngine.js.map +1 -0
  14. package/dist/auth/index.d.ts +12 -0
  15. package/dist/auth/index.d.ts.map +1 -1
  16. package/dist/auth/index.js +57 -13
  17. package/dist/auth/index.js.map +1 -1
  18. package/dist/auth/initializeProviders.d.ts +64 -1
  19. package/dist/auth/initializeProviders.d.ts.map +1 -1
  20. package/dist/auth/initializeProviders.js +117 -5
  21. package/dist/auth/initializeProviders.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +89 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +52 -35
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts.map +1 -1
  31. package/dist/context.js +21 -1
  32. package/dist/context.js.map +1 -1
  33. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  34. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +8 -7
  35. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  36. package/dist/generated/generated.d.ts +619 -1597
  37. package/dist/generated/generated.d.ts.map +1 -1
  38. package/dist/generated/generated.js +3341 -17021
  39. package/dist/generated/generated.js.map +1 -1
  40. package/dist/generic/ResolverBase.d.ts +28 -1
  41. package/dist/generic/ResolverBase.d.ts.map +1 -1
  42. package/dist/generic/ResolverBase.js +57 -8
  43. package/dist/generic/ResolverBase.js.map +1 -1
  44. package/dist/generic/RunViewResolver.d.ts +4 -0
  45. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  46. package/dist/generic/RunViewResolver.js +31 -0
  47. package/dist/generic/RunViewResolver.js.map +1 -1
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +147 -14
  51. package/dist/index.js.map +1 -1
  52. package/dist/integration/CustomColumnPromoter.d.ts +79 -15
  53. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  54. package/dist/integration/CustomColumnPromoter.js +472 -97
  55. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  56. package/dist/integration/InactiveRowWarnings.d.ts +48 -0
  57. package/dist/integration/InactiveRowWarnings.d.ts.map +1 -0
  58. package/dist/integration/InactiveRowWarnings.js +57 -0
  59. package/dist/integration/InactiveRowWarnings.js.map +1 -0
  60. package/dist/integration/RSUProgressBridge.d.ts +83 -0
  61. package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
  62. package/dist/integration/RSUProgressBridge.js +230 -0
  63. package/dist/integration/RSUProgressBridge.js.map +1 -0
  64. package/dist/integration/SchemaRefreshLaunch.d.ts +62 -0
  65. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
  66. package/dist/integration/SchemaRefreshLaunch.js +67 -0
  67. package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
  68. package/dist/providerConfigUnits.d.ts +20 -0
  69. package/dist/providerConfigUnits.d.ts.map +1 -0
  70. package/dist/providerConfigUnits.js +22 -0
  71. package/dist/providerConfigUnits.js.map +1 -0
  72. package/dist/resolvers/AdhocQueryResolver.d.ts +4 -0
  73. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  74. package/dist/resolvers/AdhocQueryResolver.js +20 -6
  75. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  76. package/dist/resolvers/FileResolver.d.ts +34 -4
  77. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  78. package/dist/resolvers/FileResolver.js +218 -28
  79. package/dist/resolvers/FileResolver.js.map +1 -1
  80. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts +37 -0
  81. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts.map +1 -0
  82. package/dist/resolvers/IdentityClaimRedemptionResolver.js +220 -0
  83. package/dist/resolvers/IdentityClaimRedemptionResolver.js.map +1 -0
  84. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +108 -3
  85. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  86. package/dist/resolvers/IntegrationDiscoveryResolver.js +374 -87
  87. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  88. package/dist/resolvers/QueryResolver.d.ts +5 -4
  89. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  90. package/dist/resolvers/QueryResolver.js +29 -14
  91. package/dist/resolvers/QueryResolver.js.map +1 -1
  92. package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
  93. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  94. package/dist/resolvers/QuerySystemUserResolver.js +33 -14
  95. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  96. package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
  97. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  98. package/dist/resolvers/RunAIAgentResolver.js +20 -7
  99. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  100. package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
  101. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  102. package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
  103. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  104. package/dist/resolvers/UserResolver.d.ts +19 -17
  105. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  106. package/dist/resolvers/UserResolver.js +66 -67
  107. package/dist/resolvers/UserResolver.js.map +1 -1
  108. package/dist/resolvers/currentUserRoles.d.ts +19 -0
  109. package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
  110. package/dist/resolvers/currentUserRoles.js +36 -0
  111. package/dist/resolvers/currentUserRoles.js.map +1 -0
  112. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
  113. package/dist/resolvers/meetingRecordingRegistration.js +3 -6
  114. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
  115. package/dist/rest/MediaAccessKeys.d.ts +17 -0
  116. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  117. package/dist/rest/MediaAccessKeys.js +42 -1
  118. package/dist/rest/MediaAccessKeys.js.map +1 -1
  119. package/dist/rest/MediaStreamHandler.d.ts +3 -2
  120. package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
  121. package/dist/rest/MediaStreamHandler.js +83 -2
  122. package/dist/rest/MediaStreamHandler.js.map +1 -1
  123. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  124. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  125. package/dist/rest/OAuthCallbackHandler.js +129 -43
  126. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  127. package/dist/rest/UploadTokenManager.d.ts +90 -0
  128. package/dist/rest/UploadTokenManager.d.ts.map +1 -0
  129. package/dist/rest/UploadTokenManager.js +156 -0
  130. package/dist/rest/UploadTokenManager.js.map +1 -0
  131. package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
  132. package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
  133. package/dist/services/IntegrationSyncWorkerService.js +132 -0
  134. package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
  135. package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
  136. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
  137. package/dist/services/StartTaskGraphDispatcher.js +21 -1
  138. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  139. package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
  140. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  141. package/dist/services/TaskGraphAgentRunner.js +23 -0
  142. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  143. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  144. package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
  145. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  146. package/dist/types.d.ts +14 -0
  147. package/dist/types.d.ts.map +1 -1
  148. package/dist/types.js.map +1 -1
  149. package/package.json +92 -92
  150. package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
  151. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  152. package/src/__tests__/AuthProviderEngine.test.ts +278 -0
  153. package/src/__tests__/CustomColumnPromoterOnePass.test.ts +387 -0
  154. package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
  155. package/src/__tests__/InactiveRowWarnings.test.ts +87 -0
  156. package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
  157. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  158. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  159. package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
  160. package/src/__tests__/RSUProgressBridge.test.ts +250 -0
  161. package/src/__tests__/ReactivateRefreshMessage.test.ts +85 -0
  162. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
  163. package/src/__tests__/ResolverBase.filterEscaping.test.ts +147 -0
  164. package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
  165. package/src/__tests__/SessionManager.test.ts +55 -0
  166. package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
  167. package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
  168. package/src/__tests__/config-units.test.ts +74 -0
  169. package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
  170. package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
  171. package/src/__tests__/newUsers.test.ts +726 -0
  172. package/src/__tests__/uploadTokenManager.test.ts +159 -0
  173. package/src/agentSessions/SessionManager.ts +27 -9
  174. package/src/auth/AuthProviderCatalogRouter.ts +76 -0
  175. package/src/auth/AuthProviderEngine.ts +253 -0
  176. package/src/auth/index.ts +60 -15
  177. package/src/auth/initializeProviders.ts +128 -7
  178. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  179. package/src/config.ts +55 -36
  180. package/src/context.ts +26 -2
  181. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +8 -7
  182. package/src/generated/generated.ts +2347 -10551
  183. package/src/generic/ResolverBase.ts +67 -8
  184. package/src/generic/RunViewResolver.ts +31 -0
  185. package/src/index.ts +165 -16
  186. package/src/integration/CustomColumnPromoter.ts +520 -98
  187. package/src/integration/InactiveRowWarnings.ts +85 -0
  188. package/src/integration/RSUProgressBridge.ts +248 -0
  189. package/src/integration/SchemaRefreshLaunch.ts +93 -0
  190. package/src/providerConfigUnits.ts +21 -0
  191. package/src/resolvers/AdhocQueryResolver.ts +21 -6
  192. package/src/resolvers/FileResolver.ts +200 -40
  193. package/src/resolvers/IdentityClaimRedemptionResolver.ts +184 -0
  194. package/src/resolvers/IntegrationDiscoveryResolver.ts +390 -71
  195. package/src/resolvers/QueryResolver.ts +25 -11
  196. package/src/resolvers/QuerySystemUserResolver.ts +28 -13
  197. package/src/resolvers/RunAIAgentResolver.ts +21 -6
  198. package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
  199. package/src/resolvers/UserResolver.ts +99 -60
  200. package/src/resolvers/currentUserRoles.ts +47 -0
  201. package/src/resolvers/meetingRecordingRegistration.ts +3 -7
  202. package/src/rest/MediaAccessKeys.ts +47 -1
  203. package/src/rest/MediaStreamHandler.ts +98 -3
  204. package/src/rest/OAuthCallbackHandler.ts +149 -41
  205. package/src/rest/UploadTokenManager.ts +211 -0
  206. package/src/services/IntegrationSyncWorkerService.ts +141 -0
  207. package/src/services/StartTaskGraphDispatcher.ts +21 -1
  208. package/src/services/TaskGraphAgentRunner.ts +23 -0
  209. package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
  210. package/src/types.ts +14 -0
@@ -0,0 +1,278 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ /**
4
+ * Tests for the metadata-driven auth provider catalog.
5
+ *
6
+ * The focus is the security boundary: `GetPublicCatalog` feeds an UNAUTHENTICATED endpoint, so
7
+ * these assert what may not escape it — server-only configuration, credential linkage, and any
8
+ * non-primitive value that could carry a secret. Also covers the registration path's isolation
9
+ * (one bad row must not cost a deployment its other providers) and the config projection.
10
+ */
11
+
12
+ // vi.mock factories are hoisted above module-level consts, so the shared spies have to be
13
+ // created inside vi.hoisted() to exist by the time a factory runs.
14
+ const { registerMock, createProviderMock, clearMock, getCredentialMock } = vi.hoisted(() => ({
15
+ registerMock: vi.fn(),
16
+ createProviderMock: vi.fn(),
17
+ clearMock: vi.fn(),
18
+ getCredentialMock: vi.fn()
19
+ }));
20
+
21
+ vi.mock('@memberjunction/auth-providers', () => ({
22
+ AuthProviderFactory: {
23
+ Instance: {
24
+ register: registerMock,
25
+ clear: clearMock
26
+ },
27
+ createProvider: createProviderMock
28
+ }
29
+ }));
30
+
31
+ vi.mock('@memberjunction/credentials', () => ({
32
+ CredentialEngine: {
33
+ Instance: {
34
+ Config: vi.fn(async () => undefined),
35
+ getCredential: getCredentialMock
36
+ }
37
+ }
38
+ }));
39
+
40
+ vi.mock('@memberjunction/core-entities', () => ({}));
41
+
42
+ vi.mock('@memberjunction/core', () => ({
43
+ BaseEngine: class {
44
+ protected async Load(): Promise<void> {
45
+ /* no-op: tests inject rows directly */
46
+ }
47
+ protected GetConfigData<T>(propertyName: string): T[] {
48
+ return (this as unknown as Record<string, T[]>)[propertyName] ?? [];
49
+ }
50
+ public static getInstance<T>(this: new () => T): T {
51
+ return new this();
52
+ }
53
+ },
54
+ RegisterForStartup: () => (target: unknown) => target,
55
+ LogError: vi.fn(),
56
+ LogStatus: vi.fn(),
57
+ LogStatusEx: vi.fn()
58
+ }));
59
+
60
+ import { AuthProviderEngine } from '../auth/AuthProviderEngine.js';
61
+
62
+ /** Minimal stand-in for a generated MJAuthenticationProviderEntity row. */
63
+ type Row = {
64
+ Name: string;
65
+ DriverClass: string;
66
+ Issuer: string | null;
67
+ Audience: string | null;
68
+ JWKSUri: string | null;
69
+ ClientID: string | null;
70
+ Domain: string | null;
71
+ Scopes: string | null;
72
+ AdditionalConfiguration: string | null;
73
+ ClientConfiguration: string | null;
74
+ CredentialID: string | null;
75
+ Credential: string | null;
76
+ Status: string;
77
+ IsDefault: boolean;
78
+ ClientVisible: boolean;
79
+ DisplayName: string | null;
80
+ Icon: string | null;
81
+ Sequence: number;
82
+ };
83
+
84
+ function row(overrides: Partial<Row> & { Name: string; DriverClass: string }): Row {
85
+ return {
86
+ Issuer: null,
87
+ Audience: null,
88
+ JWKSUri: null,
89
+ ClientID: null,
90
+ Domain: null,
91
+ Scopes: null,
92
+ AdditionalConfiguration: null,
93
+ ClientConfiguration: null,
94
+ CredentialID: null,
95
+ Credential: null,
96
+ Status: 'Active',
97
+ IsDefault: false,
98
+ ClientVisible: true,
99
+ DisplayName: null,
100
+ Icon: null,
101
+ Sequence: 0,
102
+ ...overrides
103
+ };
104
+ }
105
+
106
+ /** Builds an engine whose cached rows are the supplied ones. */
107
+ function engineWith(rows: Row[]): AuthProviderEngine {
108
+ const engine = new AuthProviderEngine();
109
+ (engine as unknown as { _providers: Row[] })._providers = rows;
110
+ return engine;
111
+ }
112
+
113
+ beforeEach(() => {
114
+ vi.clearAllMocks();
115
+ createProviderMock.mockImplementation((config: unknown) => ({ config }));
116
+ });
117
+
118
+ describe('AuthProviderEngine.GetPublicCatalog', () => {
119
+ it('never publishes server-only configuration or credential linkage', () => {
120
+ const catalog = engineWith([
121
+ row({
122
+ Name: 'WorkOS Production',
123
+ DriverClass: 'workos',
124
+ ClientID: 'client_123',
125
+ Issuer: 'https://api.workos.com/user_management/client_123',
126
+ AdditionalConfiguration: JSON.stringify({ managementApiKey: 'sk_live_SECRET' }),
127
+ CredentialID: 'CRED-1',
128
+ Credential: 'WorkOS Secret'
129
+ })
130
+ ]).GetPublicCatalog();
131
+
132
+ const serialized = JSON.stringify(catalog);
133
+ expect(serialized).not.toContain('sk_live_SECRET');
134
+ expect(serialized).not.toContain('managementApiKey');
135
+ expect(serialized).not.toContain('CRED-1');
136
+ expect(catalog[0]).not.toHaveProperty('credentialID');
137
+ expect(catalog[0]).not.toHaveProperty('additionalConfiguration');
138
+ });
139
+
140
+ it('excludes providers that are not client-visible', () => {
141
+ const catalog = engineWith([
142
+ row({ Name: 'Interactive', DriverClass: 'okta' }),
143
+ row({ Name: 'Machine to machine', DriverClass: 'auth0', ClientVisible: false })
144
+ ]).GetPublicCatalog();
145
+
146
+ expect(catalog.map((p) => p.name)).toEqual(['Interactive']);
147
+ });
148
+
149
+ it('falls back to Name when DisplayName is unset', () => {
150
+ const [info] = engineWith([row({ Name: 'Corporate Azure AD', DriverClass: 'msal' })]).GetPublicCatalog();
151
+ expect(info.displayName).toBe('Corporate Azure AD');
152
+ });
153
+
154
+ it('publishes ClientConfiguration primitives for the browser driver', () => {
155
+ const [info] = engineWith([
156
+ row({
157
+ Name: 'WorkOS',
158
+ DriverClass: 'workos',
159
+ ClientConfiguration: JSON.stringify({ redirectUri: 'https://app.example.com', devMode: false })
160
+ })
161
+ ]).GetPublicCatalog();
162
+
163
+ expect(info.clientConfiguration).toEqual({ redirectUri: 'https://app.example.com', devMode: false });
164
+ });
165
+
166
+ it('drops non-primitive ClientConfiguration values so a nested blob cannot ride along', () => {
167
+ const [info] = engineWith([
168
+ row({
169
+ Name: 'WorkOS',
170
+ DriverClass: 'workos',
171
+ ClientConfiguration: JSON.stringify({ redirectUri: 'https://app.example.com', secrets: { apiKey: 'sk_live_SECRET' } })
172
+ })
173
+ ]).GetPublicCatalog();
174
+
175
+ expect(info.clientConfiguration).toEqual({ redirectUri: 'https://app.example.com' });
176
+ expect(JSON.stringify(info)).not.toContain('sk_live_SECRET');
177
+ });
178
+
179
+ it('publishes scopes pre-parsed as an array, never the raw delimited column', () => {
180
+ const [info] = engineWith([row({ Name: 'Okta', DriverClass: 'okta', Scopes: 'openid, profile email' })]).GetPublicCatalog();
181
+ expect(info.scopes).toEqual(['openid', 'profile', 'email']);
182
+ });
183
+
184
+ it('omits scopes entirely for a whitespace-only Scopes column', () => {
185
+ const [info] = engineWith([row({ Name: 'Okta', DriverClass: 'okta', Scopes: ' ' })]).GetPublicCatalog();
186
+ expect(info.scopes).toBeUndefined();
187
+ });
188
+
189
+ it('treats malformed ClientConfiguration as absent rather than failing the catalog', () => {
190
+ const [info] = engineWith([row({ Name: 'Broken', DriverClass: 'okta', ClientConfiguration: '{not json' })]).GetPublicCatalog();
191
+ expect(info.clientConfiguration).toBeUndefined();
192
+ expect(info.name).toBe('Broken');
193
+ });
194
+ });
195
+
196
+ describe('AuthProviderEngine.RegisterAll', () => {
197
+ it('registers every active provider', async () => {
198
+ const count = await engineWith([
199
+ row({ Name: 'A', DriverClass: 'okta' }),
200
+ row({ Name: 'B', DriverClass: 'auth0' })
201
+ ]).RegisterAll();
202
+
203
+ expect(count).toBe(2);
204
+ expect(registerMock).toHaveBeenCalledTimes(2);
205
+ });
206
+
207
+ it('isolates a failing row so the other providers still register', async () => {
208
+ createProviderMock.mockImplementation((config: { type: string }) => {
209
+ if (config.type === 'broken') {
210
+ throw new Error('no driver registered');
211
+ }
212
+ return { config };
213
+ });
214
+
215
+ const count = await engineWith([
216
+ row({ Name: 'Broken', DriverClass: 'broken' }),
217
+ row({ Name: 'Good', DriverClass: 'okta' })
218
+ ]).RegisterAll();
219
+
220
+ expect(count).toBe(1);
221
+ expect(registerMock).toHaveBeenCalledTimes(1);
222
+ });
223
+
224
+ it('projects the row onto the driver config, splitting scopes into an array', async () => {
225
+ await engineWith([
226
+ row({
227
+ Name: 'Okta Prod',
228
+ DriverClass: 'okta',
229
+ ClientID: 'cid',
230
+ Domain: 'example.okta.com',
231
+ Issuer: 'https://example.okta.com',
232
+ Audience: 'api://default',
233
+ JWKSUri: 'https://example.okta.com/keys',
234
+ Scopes: 'openid profile email'
235
+ })
236
+ ]).RegisterAll();
237
+
238
+ expect(createProviderMock).toHaveBeenCalledWith(
239
+ expect.objectContaining({
240
+ name: 'Okta Prod',
241
+ type: 'okta',
242
+ clientId: 'cid',
243
+ domain: 'example.okta.com',
244
+ issuer: 'https://example.okta.com',
245
+ audience: 'api://default',
246
+ jwksUri: 'https://example.okta.com/keys',
247
+ scopes: ['openid', 'profile', 'email']
248
+ })
249
+ );
250
+ });
251
+
252
+ it('does not let AdditionalConfiguration override a modelled column', async () => {
253
+ await engineWith([
254
+ row({
255
+ Name: 'Okta',
256
+ DriverClass: 'okta',
257
+ Issuer: 'https://real-issuer.example.com',
258
+ AdditionalConfiguration: JSON.stringify({ issuer: 'https://attacker.example.com' })
259
+ })
260
+ ]).RegisterAll();
261
+
262
+ expect(createProviderMock).toHaveBeenCalledWith(expect.objectContaining({ issuer: 'https://real-issuer.example.com' }));
263
+ });
264
+
265
+ it('merges decrypted credential values when the row links one', async () => {
266
+ getCredentialMock.mockResolvedValue({ values: { clientSecret: 'shhh' } });
267
+
268
+ await engineWith([row({ Name: 'Confidential', DriverClass: 'okta', CredentialID: 'CRED-1', Credential: 'Okta Secret' })]).RegisterAll();
269
+
270
+ expect(getCredentialMock).toHaveBeenCalledWith('Okta Secret', expect.objectContaining({ subsystem: 'Authentication' }));
271
+ expect(createProviderMock).toHaveBeenCalledWith(expect.objectContaining({ clientSecret: 'shhh' }));
272
+ });
273
+
274
+ it('does not touch CredentialEngine when no credential is linked', async () => {
275
+ await engineWith([row({ Name: 'Public JWKS', DriverClass: 'okta' })]).RegisterAll();
276
+ expect(getCredentialMock).not.toHaveBeenCalled();
277
+ });
278
+ });
@@ -0,0 +1,387 @@
1
+ /**
2
+ * The promoter's one-pass batch, spread recovery, and idempotent spread.
3
+ *
4
+ * Before: PromoteForSync ran the FULL RSU pipeline (migrate + CodeGen + compile) once per entity —
5
+ * a sync touching N entities with candidates paid N passes where the batch API exists to pay one.
6
+ * And a run interrupted between ADD COLUMN and the value spread was a dead end: the column and
7
+ * field map existed, so the terminate check skipped the key as done, capture stopped (the key was
8
+ * no longer unmapped), and rows kept the value only in the overflow JSON forever.
9
+ *
10
+ * These tests drive PromoteForSync with plan/metadata/spread stubbed at the seam and
11
+ * RuntimeSchemaManager mocked, plus the two pure-ish decision points (resolveWorkItems,
12
+ * spreadOneRow) directly.
13
+ */
14
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
15
+
16
+ const runPipelineBatchMock = vi.fn();
17
+ vi.mock('@memberjunction/schema-engine', () => ({
18
+ RuntimeSchemaManager: {
19
+ get Instance() {
20
+ return { RunPipelineBatch: runPipelineBatchMock };
21
+ },
22
+ },
23
+ }));
24
+
25
+ vi.mock('@memberjunction/integration-schema-builder', () => ({
26
+ DDLGenerator: class {
27
+ GenerateAlterTableAddColumn(schema: string, table: string, col: { ColumnName: string }): string {
28
+ return `ALTER TABLE [${schema}].[${table}] ADD [${col.ColumnName}]`;
29
+ }
30
+ },
31
+ }));
32
+
33
+ // The engine package registers a singleton hook at import; neutralize it.
34
+ vi.mock('@memberjunction/integration-engine', () => ({
35
+ IntegrationEngine: { Instance: { SetPostSyncSchemaPromotionCallback: vi.fn() } },
36
+ CUSTOM_OVERFLOW_COLUMN: '__mj_integration_CustomFields',
37
+ CONTENT_HASH_COLUMN: '__mj_integration_ContentHash',
38
+ computeContentHash: (mapped: Record<string, unknown>) => `hash:${Object.keys(mapped).sort().join(',')}`,
39
+ // Mirror of the real sanitizer, close enough for these fixtures (plain identifiers pass through).
40
+ sanitizeColumnName: (key: string) => key.replace(/[^A-Za-z0-9_]/g, '_'),
41
+ }));
42
+
43
+ import { IntegrationCustomColumnPromoter } from '../integration/CustomColumnPromoter.js';
44
+ import type { UserInfo, IMetadataProvider, EntityInfo } from '@memberjunction/core';
45
+
46
+ type AnyPromoter = IntegrationCustomColumnPromoter & Record<string, unknown>;
47
+
48
+ const user = { ID: 'u1' } as unknown as UserInfo;
49
+
50
+ function makeProvider() {
51
+ return {
52
+ Refresh: vi.fn().mockResolvedValue(undefined),
53
+ EntityByName: vi.fn().mockReturnValue(undefined),
54
+ PlatformKey: 'sqlserver',
55
+ } as unknown as IMetadataProvider;
56
+ }
57
+
58
+ function workItem(sourceKey: string, opts: Partial<{ needsColumn: boolean; needsFieldMap: boolean; recoverSpread: boolean }> = {}) {
59
+ return {
60
+ candidate: { Key: sourceKey, Inferred: { SchemaFieldType: 'nvarchar(100)' } },
61
+ sourceKey,
62
+ columnName: sourceKey,
63
+ needsColumn: opts.needsColumn ?? true,
64
+ needsFieldMap: opts.needsFieldMap ?? true,
65
+ recoverSpread: opts.recoverSpread ?? false,
66
+ };
67
+ }
68
+
69
+ function stubPromoter(
70
+ provider: IMetadataProvider,
71
+ planByEntity: Record<string, ReturnType<typeof workItem>[]>,
72
+ staleByEntity: Record<string, Array<{ sourceKey: string; columnName: string }>> = {},
73
+ ) {
74
+ const promoter = new IntegrationCustomColumnPromoter(user, provider) as AnyPromoter;
75
+ const p = promoter as unknown as {
76
+ resolveIntegrationID: unknown; planWorkForEntity: unknown;
77
+ createIntegrationObjectFields: unknown; createFieldMaps: unknown; spreadAndRebaseline: unknown;
78
+ buildSchemaInput: unknown; activeFieldMaps: unknown; purgeStaleOverflowKeys: unknown;
79
+ };
80
+ // CompletePromotion re-reads the active field maps (it must: post-restart it has no plan to
81
+ // trust). That is a RunView, so it is stubbed at the seam like the other DB touches.
82
+ p.activeFieldMaps = vi.fn().mockResolvedValue(new Map<string, string>());
83
+ p.resolveIntegrationID = vi.fn().mockResolvedValue('int-1');
84
+ p.planWorkForEntity = vi.fn().mockImplementation(async (_ci: string, entityName: string) => {
85
+ const work = planByEntity[entityName];
86
+ if (!work) return null;
87
+ return {
88
+ entityInfo: { Name: entityName, SchemaName: 'mjc', BaseTable: entityName } as unknown as EntityInfo,
89
+ entityMap: { ID: `em-${entityName}`, ExternalObjectName: entityName.toLowerCase() },
90
+ work,
91
+ stale: staleByEntity[entityName] ?? [],
92
+ };
93
+ });
94
+ const iof = vi.fn().mockResolvedValue(undefined);
95
+ const maps = vi.fn().mockResolvedValue(undefined);
96
+ const spread = vi.fn().mockResolvedValue(undefined);
97
+ const purge = vi.fn().mockResolvedValue(undefined);
98
+ p.createIntegrationObjectFields = iof;
99
+ p.createFieldMaps = maps;
100
+ p.spreadAndRebaseline = spread;
101
+ p.purgeStaleOverflowKeys = purge;
102
+ return { promoter: promoter as IntegrationCustomColumnPromoter, iof, maps, spread, purge };
103
+ }
104
+
105
+ beforeEach(() => {
106
+ runPipelineBatchMock.mockReset();
107
+ });
108
+
109
+ describe('PromoteForSync — one batched RSU pass', () => {
110
+ it('runs ONE RunPipelineBatch for all entities instead of one pipeline per entity', async () => {
111
+ const provider = makeProvider();
112
+ const { promoter } = stubPromoter(provider, {
113
+ A: [workItem('a1')],
114
+ B: [workItem('b1')],
115
+ C: [workItem('c1')],
116
+ });
117
+ runPipelineBatchMock.mockResolvedValue({
118
+ Results: [{ Success: true }, { Success: true }, { Success: true }],
119
+ SuccessCount: 3, FailureCount: 0, TotalCount: 3,
120
+ });
121
+
122
+ const result = await promoter.PromoteForSync('ci-1', ['A', 'B', 'C']);
123
+
124
+ expect(runPipelineBatchMock).toHaveBeenCalledTimes(1);
125
+ expect(runPipelineBatchMock.mock.calls[0][0]).toHaveLength(3);
126
+ expect(result.Promoted).toBe(true);
127
+ expect(result.ColumnsAdded).toEqual([
128
+ { EntityName: 'A', ColumnName: 'a1' },
129
+ { EntityName: 'B', ColumnName: 'b1' },
130
+ { EntityName: 'C', ColumnName: 'c1' },
131
+ ]);
132
+ // ONE metadata refresh for the whole batch — before any spread reads the field list.
133
+ expect((provider as unknown as { Refresh: ReturnType<typeof vi.fn> }).Refresh).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ it('registers the follow-up as promote-columns PendingWork, and does NOT skip the restart', async () => {
137
+ // The restart is what loads the regenerated entity classes, so everything downstream of the
138
+ // DDL belongs after it. Skipping the restart to finish inline is what left promoted columns
139
+ // invisible to GraphQL; skipping the commit left the database carrying columns git had no
140
+ // record of. Both flags are optional and RSU treats absent as false, so omitting them IS
141
+ // commit + restart — no caller or platform change required.
142
+ const provider = makeProvider();
143
+ const { promoter } = stubPromoter(provider, { A: [workItem('a1')], B: [workItem('b1')] });
144
+ runPipelineBatchMock.mockResolvedValue({
145
+ Results: [{ Success: true }, { Success: true }], SuccessCount: 2, FailureCount: 0, TotalCount: 2,
146
+ });
147
+
148
+ await promoter.PromoteForSync('ci-1', ['A', 'B']);
149
+
150
+ const inputs = runPipelineBatchMock.mock.calls[0][0] as Array<Record<string, unknown>>;
151
+ for (const input of inputs) {
152
+ expect(input.SkipRestart).toBeUndefined();
153
+ expect(input.SkipGitCommit).toBeUndefined();
154
+ }
155
+ // Carried on the FIRST input: RSU restarts once for the whole batch, so one payload
156
+ // describes every entity in the pass.
157
+ const pending = (inputs[0].PendingWork as Array<Record<string, unknown>>)[0];
158
+ expect(pending.WorkType).toBe('promote-columns');
159
+ expect(pending.CompanyIntegrationID).toBe('ci-1');
160
+ expect(inputs[1].PendingWork).toBeUndefined();
161
+
162
+ // The destination names are CARRIED, not recomputed — uniqueColumnName may have suffixed
163
+ // one to dodge a collision, and re-deriving it post-restart could pick a different name.
164
+ const promoted = pending.PromotedColumns as Array<{ EntityName: string; Columns: Array<{ SourceKey: string; ColumnName: string }> }>;
165
+ expect(promoted.map(p => p.EntityName)).toEqual(['A', 'B']);
166
+ expect(promoted[0].Columns[0]).toMatchObject({ ColumnName: 'a1' });
167
+ });
168
+
169
+ it('a failed migration leaves ITS entity captured for retry and does not stop the others', async () => {
170
+ const provider = makeProvider();
171
+ const { promoter, spread } = stubPromoter(provider, {
172
+ A: [workItem('a1')],
173
+ B: [workItem('b1')],
174
+ });
175
+ runPipelineBatchMock.mockResolvedValue({
176
+ Results: [{ Success: false, ErrorMessage: 'boom' }, { Success: true }],
177
+ SuccessCount: 1, FailureCount: 1, TotalCount: 2,
178
+ });
179
+
180
+ const result = await promoter.PromoteForSync('ci-1', ['A', 'B']);
181
+
182
+ // A's DDL failed → nothing written, not counted, left captured for retry.
183
+ // B's DDL succeeded → COUNTED (SchemaUpdatePending is derived from this list, and the
184
+ // client keys its "workspace updating" state off it), but its metadata and spread are NOT
185
+ // done here: the batch restarts, so the post-restart consumer owns them. Doing them inline
186
+ // as well would duplicate the work — and for B it would not run at all, since `pm2 restart`
187
+ // ends this process.
188
+ expect(result.ColumnsAdded).toEqual([{ EntityName: 'B', ColumnName: 'b1' }]);
189
+ expect(spread).not.toHaveBeenCalled();
190
+ });
191
+
192
+ it('purges stale staged keys BEFORE the pipeline, even with nothing to promote', async () => {
193
+ // The residue case: every key is already promoted, so there is no work and no DDL — but the
194
+ // staged JSON still carries the keys and nothing else will ever clear them. The purge must
195
+ // run anyway, and must run before any migration (it is not evidence a column is missing).
196
+ const provider = makeProvider();
197
+ const { promoter, purge } = stubPromoter(
198
+ provider,
199
+ { A: [] },
200
+ { A: [{ sourceKey: 'roundId', columnName: 'roundId' }] },
201
+ );
202
+
203
+ const result = await promoter.PromoteForSync('ci-1', ['A']);
204
+
205
+ expect(purge).toHaveBeenCalledTimes(1);
206
+ expect(purge.mock.calls[0][3]).toEqual([{ sourceKey: 'roundId', columnName: 'roundId' }]);
207
+ expect(runPipelineBatchMock).not.toHaveBeenCalled();
208
+ expect(result.Promoted).toBe(false);
209
+ });
210
+
211
+ it('a spread-recovery-only pass runs NO pipeline and reports no columns added', async () => {
212
+ const provider = makeProvider();
213
+ const { promoter, spread } = stubPromoter(provider, {
214
+ A: [workItem('a1', { needsColumn: false, needsFieldMap: false, recoverSpread: true })],
215
+ });
216
+
217
+ const result = await promoter.PromoteForSync('ci-1', ['A']);
218
+
219
+ // No DDL to run — the batch API is never touched, and SchemaUpdatePending stays honest.
220
+ expect(runPipelineBatchMock).not.toHaveBeenCalled();
221
+ expect(result.Promoted).toBe(false);
222
+ expect(result.ColumnsAdded).toEqual([]);
223
+ // But the backfill DID run — that is the whole point of the recovery item.
224
+ expect(spread).toHaveBeenCalledTimes(1);
225
+ });
226
+ });
227
+
228
+ describe('resolveWorkItems — the terminate check becomes spread recovery', () => {
229
+ /** fieldMaps: source key -> the destination column the field map names (as the data records it). */
230
+ function resolve(
231
+ promoter: IntegrationCustomColumnPromoter,
232
+ candidates: string[],
233
+ entityFields: string[],
234
+ fieldMaps: Record<string, string>,
235
+ ) {
236
+ const entityInfo = { Fields: entityFields.map(n => ({ Name: n })) } as unknown as EntityInfo;
237
+ return (promoter as unknown as {
238
+ resolveWorkItems: (p: Array<{ Key: string }>, e: EntityInfo, f: ReadonlyMap<string, string>) => Array<Record<string, unknown>>;
239
+ }).resolveWorkItems(
240
+ candidates.map(Key => ({ Key })),
241
+ entityInfo,
242
+ new Map(Object.entries(fieldMaps).map(([k, v]) => [k.toLowerCase(), v])),
243
+ );
244
+ }
245
+
246
+ it('a key whose column AND field map exist becomes a recoverSpread item, not a silent skip', () => {
247
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
248
+ const items = resolve(promoter, ['roundId'], ['ID', 'roundId'], { roundId: 'roundId' });
249
+ expect(items).toHaveLength(1);
250
+ expect(items[0]).toMatchObject({ recoverSpread: true, needsColumn: false, needsFieldMap: false, columnName: 'roundId' });
251
+ });
252
+
253
+ it('a brand-new key still plans column + field map, with no recovery flag', () => {
254
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
255
+ const items = resolve(promoter, ['newKey'], ['ID'], {});
256
+ expect(items).toHaveLength(1);
257
+ expect(items[0]).toMatchObject({ needsColumn: true, needsFieldMap: true });
258
+ expect(items[0].recoverSpread).toBeFalsy();
259
+ });
260
+
261
+ it('a collision-suffixed column is found via the field map, not re-promoted', () => {
262
+ // The real column is `roundId_2` — re-sanitizing 'roundId' can never produce that name, so the
263
+ // old check read "no column yet" and minted a THIRD column beside the working one.
264
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
265
+ const items = resolve(promoter, ['roundId'], ['ID', 'roundId_2'], { roundId: 'roundId_2' });
266
+ expect(items).toHaveLength(1);
267
+ expect(items[0]).toMatchObject({ recoverSpread: true, needsColumn: false, columnName: 'roundId_2' });
268
+ });
269
+
270
+ it('a field map whose column is missing from stale in-process metadata is still terminal', () => {
271
+ // entityInfo predates the ADD COLUMN in THIS process. The map names the column, so the key is
272
+ // already promoted — planning another column for it is the duplicate-column bug.
273
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
274
+ const items = resolve(promoter, ['roundId'], ['ID'], { roundId: 'roundId' });
275
+ expect(items).toHaveLength(1);
276
+ expect(items[0]).toMatchObject({ needsColumn: false, needsFieldMap: false, columnName: 'roundId' });
277
+ });
278
+ });
279
+
280
+ describe('purgeOneRow — what the per-pass budget actually spends', () => {
281
+ function makeRow(values: Record<string, unknown>) {
282
+ const data = { ...values };
283
+ return {
284
+ Get: vi.fn((f: string) => data[f]),
285
+ Set: vi.fn((f: string, v: unknown) => { data[f] = v; }),
286
+ Save: vi.fn().mockResolvedValue(true),
287
+ _data: data,
288
+ };
289
+ }
290
+ function purge(row: ReturnType<typeof makeRow>, stale: Array<{ sourceKey: string; columnName: string }>) {
291
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
292
+ return (promoter as unknown as {
293
+ purgeOneRow: (r: unknown, s: unknown, h: boolean, m: string[]) => Promise<{ wrote: boolean; leftSet: boolean }>;
294
+ }).purgeOneRow(row, stale, false, []);
295
+ }
296
+
297
+ it('a row an earlier pass already cleaned costs NO budget and no write', async () => {
298
+ // This is what lets a capped pass reach residue further down the table: already-purged rows
299
+ // fall through in memory instead of being re-written.
300
+ const row = makeRow({ __mj_integration_CustomFields: JSON.stringify({ somethingElse: 1 }) });
301
+ const res = await purge(row, [{ sourceKey: 'eventCode', columnName: 'eventCode' }]);
302
+ expect(res).toEqual({ wrote: false, leftSet: false });
303
+ expect(row.Save).not.toHaveBeenCalled();
304
+ });
305
+
306
+ it('a row that keeps other unmapped keys is WRITTEN but stays in the filtered set', async () => {
307
+ // wrote and leftSet are different questions: the first spends budget, the second shifts the
308
+ // paged offsets. Conflating them made the walk skip rows.
309
+ const row = makeRow({
310
+ __mj_integration_CustomFields: JSON.stringify({ eventCode: 'ABC', notYetPromoted: 'x' }),
311
+ eventCode: null,
312
+ });
313
+ const res = await purge(row, [{ sourceKey: 'eventCode', columnName: 'eventCode' }]);
314
+ expect(res).toEqual({ wrote: true, leftSet: false });
315
+ expect(row._data['eventCode']).toBe('ABC');
316
+ expect(JSON.parse(row._data['__mj_integration_CustomFields'] as string)).toEqual({ notYetPromoted: 'x' });
317
+ });
318
+
319
+ it('the last key leaving takes the row out of the set', async () => {
320
+ const row = makeRow({ __mj_integration_CustomFields: JSON.stringify({ eventCode: 'ABC' }), eventCode: null });
321
+ const res = await purge(row, [{ sourceKey: 'eventCode', columnName: 'eventCode' }]);
322
+ expect(res).toEqual({ wrote: true, leftSet: true });
323
+ expect(row._data['__mj_integration_CustomFields']).toBeNull();
324
+ });
325
+
326
+ it('never overwrites a column that already has a value, but still strips the key', async () => {
327
+ const row = makeRow({ __mj_integration_CustomFields: JSON.stringify({ eventCode: 'ABC' }), eventCode: 'SETTLED' });
328
+ const res = await purge(row, [{ sourceKey: 'eventCode', columnName: 'eventCode' }]);
329
+ expect(row._data['eventCode']).toBe('SETTLED');
330
+ expect(res.leftSet).toBe(true);
331
+ });
332
+ });
333
+
334
+ describe('spreadOneRow — idempotent backfill', () => {
335
+ function makeRow(values: Record<string, unknown>) {
336
+ const data = { ...values };
337
+ return {
338
+ Get: vi.fn((f: string) => data[f]),
339
+ Set: vi.fn((f: string, v: unknown) => { data[f] = v; }),
340
+ Save: vi.fn().mockResolvedValue(true),
341
+ _data: data,
342
+ };
343
+ }
344
+
345
+ async function spread(row: ReturnType<typeof makeRow>, named: Array<ReturnType<typeof workItem>>) {
346
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
347
+ await (promoter as unknown as {
348
+ spreadOneRow: (r: unknown, n: unknown, h: boolean, m: string[]) => Promise<boolean>;
349
+ }).spreadOneRow(row, named, false, []);
350
+ }
351
+
352
+ it('fills a still-null destination from the overflow JSON, strips the key, and saves', async () => {
353
+ const row = makeRow({ __mj_integration_CustomFields: JSON.stringify({ roundId: 42 }), roundId: null });
354
+ await spread(row, [workItem('roundId')]);
355
+ expect(row._data['roundId']).toBe(42);
356
+ // The key was the only one staged, so the column goes null — the same shape the engine's
357
+ // own eviction writes, and what drops this row out of the `IS NOT NULL` scan.
358
+ expect(row._data['__mj_integration_CustomFields']).toBeNull();
359
+ expect(row.Save).toHaveBeenCalledTimes(1);
360
+ });
361
+
362
+ it('leaves an already-backfilled VALUE alone but still strips the key', async () => {
363
+ // The destination is settled, so the value is not rewritten. The key is removed regardless:
364
+ // it has a column and an active field map, so it is no longer unmapped, and leaving it staged
365
+ // is exactly what re-offers it as a phantom new column forever.
366
+ const row = makeRow({ __mj_integration_CustomFields: JSON.stringify({ roundId: 42 }), roundId: 99 });
367
+ await spread(row, [workItem('roundId', { recoverSpread: true })]);
368
+ expect(row._data['roundId']).toBe(99);
369
+ expect(row._data['__mj_integration_CustomFields']).toBeNull();
370
+ expect(row.Save).toHaveBeenCalledTimes(1);
371
+ });
372
+
373
+ it('keeps the still-unmapped keys and reports the row as staying in the scan', async () => {
374
+ const row = makeRow({
375
+ __mj_integration_CustomFields: JSON.stringify({ roundId: 42, notYetPromoted: 'x' }),
376
+ roundId: null,
377
+ });
378
+ const promoter = new IntegrationCustomColumnPromoter(user, makeProvider());
379
+ const left = await (promoter as unknown as {
380
+ spreadOneRow: (r: unknown, n: unknown, h: boolean, m: string[]) => Promise<boolean>;
381
+ }).spreadOneRow(row, [workItem('roundId')], false, []);
382
+ expect(JSON.parse(row._data['__mj_integration_CustomFields'] as string)).toEqual({ notYetPromoted: 'x' });
383
+ // Still carries overflow, so it is still in the filtered set — the paged walk must NOT count
384
+ // it as removed or it will skip a later row.
385
+ expect(left).toBe(false);
386
+ });
387
+ });