@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,378 @@
1
+ /**
2
+ * Tests for the REST entity CRUD implementation (rest/EntityCRUDHandler.ts).
3
+ *
4
+ * Verifies for every verb (create / read / update / delete):
5
+ * - permission enforcement happens BEFORE any load or mutation,
6
+ * - all mutations go through the BaseEntity object path (the entity returned
7
+ * by Metadata.GetEntityObject — NewRecord/Set/Validate/Save/Delete), never
8
+ * around it,
9
+ * - input validation (entity validation errors, save-option extraction,
10
+ * composite-key construction) and every error branch.
11
+ *
12
+ * The package boundary mocked is @memberjunction/core's Metadata; the real
13
+ * CompositeKey / EntitySaveOptions / EntityDeleteOptions / EntityPermissionType
14
+ * are used so the handler's contracts with them stay honest.
15
+ */
16
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
17
+
18
+ // ─── Hoisted mocks ──────────────────────────────────────────────────────────
19
+ const { mockGetEntityObject, mockLogError } = vi.hoisted(() => ({
20
+ mockGetEntityObject: vi.fn(),
21
+ mockLogError: vi.fn(),
22
+ }));
23
+
24
+ vi.mock('@memberjunction/core', async (importOriginal) => {
25
+ const actual = await importOriginal<typeof import('@memberjunction/core')>();
26
+ class MockMetadata {
27
+ public GetEntityObject(entityName: string, contextUser?: unknown): Promise<unknown> {
28
+ return mockGetEntityObject(entityName, contextUser);
29
+ }
30
+ }
31
+ return {
32
+ ...actual,
33
+ Metadata: MockMetadata,
34
+ LogError: mockLogError,
35
+ };
36
+ });
37
+
38
+ import {
39
+ CompositeKey,
40
+ EntityDeleteOptions,
41
+ EntityPermissionType,
42
+ EntitySaveOptions,
43
+ UserInfo,
44
+ } from '@memberjunction/core';
45
+ import { EntityCRUDHandler } from '../rest/EntityCRUDHandler.js';
46
+
47
+ // ─── Mock entity ────────────────────────────────────────────────────────────
48
+
49
+ interface MockEntityConfig {
50
+ allowed?: EntityPermissionType[];
51
+ loadSuccess?: boolean;
52
+ dirty?: boolean;
53
+ validateSuccess?: boolean;
54
+ saveSuccess?: boolean;
55
+ deleteSuccess?: boolean;
56
+ primaryKeys?: Array<{ Name: string }>;
57
+ }
58
+
59
+ interface MockEntity {
60
+ CheckPermissions: ReturnType<typeof vi.fn>;
61
+ NewRecord: ReturnType<typeof vi.fn>;
62
+ Set: ReturnType<typeof vi.fn>;
63
+ Validate: ReturnType<typeof vi.fn>;
64
+ Save: ReturnType<typeof vi.fn>;
65
+ Delete: ReturnType<typeof vi.fn>;
66
+ InnerLoad: ReturnType<typeof vi.fn>;
67
+ GetDataObject: ReturnType<typeof vi.fn>;
68
+ Dirty: boolean;
69
+ LatestResult: { Message: string };
70
+ EntityInfo: { PrimaryKeys: Array<{ Name: string }> };
71
+ }
72
+
73
+ const ENTITY_DATA = { ID: 'row-1', Name: 'Loaded Row' };
74
+
75
+ function makeEntity(config: MockEntityConfig = {}): MockEntity {
76
+ const allowed = new Set(config.allowed ?? [
77
+ EntityPermissionType.Create,
78
+ EntityPermissionType.Read,
79
+ EntityPermissionType.Update,
80
+ EntityPermissionType.Delete,
81
+ ]);
82
+ return {
83
+ CheckPermissions: vi.fn((type: EntityPermissionType) => allowed.has(type)),
84
+ NewRecord: vi.fn(),
85
+ Set: vi.fn(),
86
+ Validate: vi.fn(() => ({
87
+ Success: config.validateSuccess ?? true,
88
+ Errors: config.validateSuccess === false ? [{ Message: 'Name is required' }] : [],
89
+ })),
90
+ Save: vi.fn(async () => config.saveSuccess ?? true),
91
+ Delete: vi.fn(async () => config.deleteSuccess ?? true),
92
+ InnerLoad: vi.fn(async () => config.loadSuccess ?? true),
93
+ GetDataObject: vi.fn(async () => ({ ...ENTITY_DATA })),
94
+ Dirty: config.dirty ?? true,
95
+ LatestResult: { Message: 'underlying save/delete failure' },
96
+ EntityInfo: { PrimaryKeys: config.primaryKeys ?? [{ Name: 'ID' }] },
97
+ };
98
+ }
99
+
100
+ function makeUser(): UserInfo {
101
+ return { ID: 'user-1', Name: 'Testy McTester' } as unknown as UserInfo;
102
+ }
103
+
104
+ let entity: MockEntity;
105
+
106
+ beforeEach(() => {
107
+ vi.clearAllMocks();
108
+ entity = makeEntity();
109
+ mockGetEntityObject.mockImplementation(async () => entity);
110
+ });
111
+
112
+ // ─── createEntity ───────────────────────────────────────────────────────────
113
+
114
+ describe('EntityCRUDHandler.createEntity', () => {
115
+ it('resolves the entity object for the requesting user and saves through it (never around it)', async () => {
116
+ const user = makeUser();
117
+ const result = await EntityCRUDHandler.createEntity('Customers', { Name: 'Acme' }, user);
118
+
119
+ expect(result.success).toBe(true);
120
+ expect(result.entity).toEqual(ENTITY_DATA);
121
+ // Entity-object path: acquired via Metadata for THIS user, then mutated via the object
122
+ expect(mockGetEntityObject).toHaveBeenCalledWith('Customers', user);
123
+ expect(entity.NewRecord).toHaveBeenCalledTimes(1);
124
+ expect(entity.Set).toHaveBeenCalledWith('Name', 'Acme');
125
+ expect(entity.Validate).toHaveBeenCalledTimes(1);
126
+ expect(entity.Save).toHaveBeenCalledTimes(1);
127
+ expect(entity.Save.mock.calls[0][0]).toBeInstanceOf(EntitySaveOptions);
128
+ });
129
+
130
+ it('denies without touching the record when the user lacks Create permission', async () => {
131
+ entity = makeEntity({ allowed: [EntityPermissionType.Read] });
132
+
133
+ const result = await EntityCRUDHandler.createEntity('Customers', { Name: 'Acme' }, makeUser());
134
+
135
+ expect(result.success).toBe(false);
136
+ expect(result.error).toBe('User Testy McTester does not have permission to create Customers records');
137
+ // Permission check must precede ANY mutation of the entity object
138
+ expect(entity.NewRecord).not.toHaveBeenCalled();
139
+ expect(entity.Set).not.toHaveBeenCalled();
140
+ expect(entity.Save).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it('extracts save options from data.options and never writes "options" as a field', async () => {
144
+ const data: Record<string, unknown> = {
145
+ Name: 'Acme',
146
+ options: { IgnoreDirtyState: true, SkipEntityAIActions: 1, ReplayOnly: false },
147
+ };
148
+
149
+ const result = await EntityCRUDHandler.createEntity('Customers', data, makeUser());
150
+
151
+ expect(result.success).toBe(true);
152
+ const savedOptions = entity.Save.mock.calls[0][0] as EntitySaveOptions;
153
+ expect(savedOptions.IgnoreDirtyState).toBe(true);
154
+ expect(savedOptions.SkipEntityAIActions).toBe(true); // coerced with !!
155
+ expect(savedOptions.ReplayOnly).toBe(false);
156
+ const setKeys = entity.Set.mock.calls.map((call) => call[0] as string);
157
+ expect(setKeys).toEqual(['Name']); // 'options' was consumed, not persisted
158
+ });
159
+
160
+ it('returns validation errors without calling Save when Validate fails', async () => {
161
+ entity = makeEntity({ validateSuccess: false });
162
+
163
+ const result = await EntityCRUDHandler.createEntity('Customers', { Name: '' }, makeUser());
164
+
165
+ expect(result.success).toBe(false);
166
+ expect(result.error).toBe('Validation failed');
167
+ expect(result.validationErrors).toEqual([{ Message: 'Name is required' }]);
168
+ expect(entity.Save).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it('surfaces the entity LatestResult when Save returns false', async () => {
172
+ entity = makeEntity({ saveSuccess: false });
173
+
174
+ const result = await EntityCRUDHandler.createEntity('Customers', { Name: 'Acme' }, makeUser());
175
+
176
+ expect(result.success).toBe(false);
177
+ expect(result.error).toBe('underlying save/delete failure');
178
+ expect(result.details).toBe(entity.LatestResult);
179
+ });
180
+
181
+ it('catches thrown errors (e.g. unknown entity) and returns them as failures', async () => {
182
+ mockGetEntityObject.mockRejectedValue(new Error('Entity Nope not found in metadata'));
183
+
184
+ const result = await EntityCRUDHandler.createEntity('Nope', {}, makeUser());
185
+
186
+ expect(result.success).toBe(false);
187
+ expect(result.error).toBe('Entity Nope not found in metadata');
188
+ expect(mockLogError).toHaveBeenCalled();
189
+ });
190
+ });
191
+
192
+ // ─── getEntity ──────────────────────────────────────────────────────────────
193
+
194
+ describe('EntityCRUDHandler.getEntity', () => {
195
+ it('loads through InnerLoad with a single-PK composite key built from the id', async () => {
196
+ const result = await EntityCRUDHandler.getEntity('Customers', 42, null, makeUser());
197
+
198
+ expect(result.success).toBe(true);
199
+ expect(result.entity).toEqual(ENTITY_DATA);
200
+ const [key, related] = entity.InnerLoad.mock.calls[0] as [CompositeKey, string[] | null];
201
+ expect(key).toBeInstanceOf(CompositeKey);
202
+ expect(key.KeyValuePairs).toEqual([{ FieldName: 'ID', Value: '42' }]); // numeric id stringified
203
+ expect(related).toBeNull();
204
+ });
205
+
206
+ it('passes requested related entities through to InnerLoad', async () => {
207
+ await EntityCRUDHandler.getEntity('Customers', 'row-1', ['Orders', 'Invoices'], makeUser());
208
+
209
+ expect(entity.InnerLoad.mock.calls[0][1]).toEqual(['Orders', 'Invoices']);
210
+ });
211
+
212
+ it('denies before loading when the user lacks Read permission', async () => {
213
+ entity = makeEntity({ allowed: [EntityPermissionType.Create] });
214
+
215
+ const result = await EntityCRUDHandler.getEntity('Customers', 'row-1', null, makeUser());
216
+
217
+ expect(result.success).toBe(false);
218
+ expect(result.error).toContain('does not have permission to read Customers');
219
+ expect(entity.InnerLoad).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it('returns a not-found error when InnerLoad fails', async () => {
223
+ entity = makeEntity({ loadSuccess: false });
224
+
225
+ const result = await EntityCRUDHandler.getEntity('Customers', 'ghost', null, makeUser());
226
+
227
+ expect(result.success).toBe(false);
228
+ expect(result.error).toBe('Customers with ID ghost not found');
229
+ });
230
+
231
+ it('fails cleanly for composite-primary-key entities instead of guessing', async () => {
232
+ entity = makeEntity({ primaryKeys: [{ Name: 'A' }, { Name: 'B' }] });
233
+
234
+ const result = await EntityCRUDHandler.getEntity('LinkTable', 'a|b', null, makeUser());
235
+
236
+ expect(result.success).toBe(false);
237
+ expect(result.error).toContain('Composite primary keys are not supported');
238
+ expect(entity.InnerLoad).not.toHaveBeenCalled();
239
+ });
240
+ });
241
+
242
+ // ─── updateEntity ───────────────────────────────────────────────────────────
243
+
244
+ describe('EntityCRUDHandler.updateEntity', () => {
245
+ it('loads, sets fields, validates, and saves via the entity object', async () => {
246
+ const user = makeUser();
247
+ const result = await EntityCRUDHandler.updateEntity('Customers', 'row-1', { Name: 'New Name' }, user);
248
+
249
+ expect(result.success).toBe(true);
250
+ expect(mockGetEntityObject).toHaveBeenCalledWith('Customers', user);
251
+ expect(entity.InnerLoad).toHaveBeenCalledTimes(1);
252
+ expect(entity.Set).toHaveBeenCalledWith('Name', 'New Name');
253
+ expect(entity.Validate).toHaveBeenCalledTimes(1);
254
+ expect(entity.Save).toHaveBeenCalledTimes(1);
255
+ expect(entity.Save.mock.calls[0][0]).toBeInstanceOf(EntitySaveOptions);
256
+ });
257
+
258
+ it('denies before loading when the user lacks Update permission', async () => {
259
+ entity = makeEntity({ allowed: [EntityPermissionType.Read] });
260
+
261
+ const result = await EntityCRUDHandler.updateEntity('Customers', 'row-1', { Name: 'X' }, makeUser());
262
+
263
+ expect(result.success).toBe(false);
264
+ expect(result.error).toContain('does not have permission to update Customers');
265
+ expect(entity.InnerLoad).not.toHaveBeenCalled();
266
+ expect(entity.Save).not.toHaveBeenCalled();
267
+ });
268
+
269
+ it('returns not-found when the target record does not exist', async () => {
270
+ entity = makeEntity({ loadSuccess: false });
271
+
272
+ const result = await EntityCRUDHandler.updateEntity('Customers', 'ghost', { Name: 'X' }, makeUser());
273
+
274
+ expect(result.success).toBe(false);
275
+ expect(result.error).toBe('Customers with ID ghost not found');
276
+ expect(entity.Save).not.toHaveBeenCalled();
277
+ });
278
+
279
+ it('short-circuits a no-op update (not dirty) as success WITHOUT saving', async () => {
280
+ entity = makeEntity({ dirty: false });
281
+
282
+ const result = await EntityCRUDHandler.updateEntity('Customers', 'row-1', { Name: 'Loaded Row' }, makeUser());
283
+
284
+ expect(result.success).toBe(true);
285
+ expect(result.entity).toEqual(ENTITY_DATA);
286
+ expect(entity.Save).not.toHaveBeenCalled();
287
+ });
288
+
289
+ it('saves a clean entity anyway when options.IgnoreDirtyState is set', async () => {
290
+ entity = makeEntity({ dirty: false });
291
+
292
+ const result = await EntityCRUDHandler.updateEntity(
293
+ 'Customers', 'row-1',
294
+ { Name: 'Same', options: { IgnoreDirtyState: true } },
295
+ makeUser(),
296
+ );
297
+
298
+ expect(result.success).toBe(true);
299
+ expect(entity.Save).toHaveBeenCalledTimes(1);
300
+ expect((entity.Save.mock.calls[0][0] as EntitySaveOptions).IgnoreDirtyState).toBe(true);
301
+ });
302
+
303
+ it('returns validation errors without saving', async () => {
304
+ entity = makeEntity({ validateSuccess: false });
305
+
306
+ const result = await EntityCRUDHandler.updateEntity('Customers', 'row-1', { Name: '' }, makeUser());
307
+
308
+ expect(result.success).toBe(false);
309
+ expect(result.validationErrors).toEqual([{ Message: 'Name is required' }]);
310
+ expect(entity.Save).not.toHaveBeenCalled();
311
+ });
312
+
313
+ it('surfaces the entity LatestResult when Save returns false', async () => {
314
+ entity = makeEntity({ saveSuccess: false });
315
+
316
+ const result = await EntityCRUDHandler.updateEntity('Customers', 'row-1', { Name: 'X' }, makeUser());
317
+
318
+ expect(result.success).toBe(false);
319
+ expect(result.error).toBe('underlying save/delete failure');
320
+ });
321
+ });
322
+
323
+ // ─── deleteEntity ───────────────────────────────────────────────────────────
324
+
325
+ describe('EntityCRUDHandler.deleteEntity', () => {
326
+ it('loads then deletes via the entity object, passing the delete options through', async () => {
327
+ const options = new EntityDeleteOptions();
328
+ options.SkipEntityActions = true;
329
+
330
+ const result = await EntityCRUDHandler.deleteEntity('Customers', 'row-1', options, makeUser());
331
+
332
+ expect(result.success).toBe(true);
333
+ expect(entity.InnerLoad).toHaveBeenCalledTimes(1);
334
+ expect(entity.Delete).toHaveBeenCalledTimes(1);
335
+ expect(entity.Delete.mock.calls[0][0]).toBe(options); // the same options object, not a copy
336
+ });
337
+
338
+ it('denies before loading or deleting when the user lacks Delete permission', async () => {
339
+ entity = makeEntity({ allowed: [EntityPermissionType.Read, EntityPermissionType.Update] });
340
+
341
+ const result = await EntityCRUDHandler.deleteEntity('Customers', 'row-1', new EntityDeleteOptions(), makeUser());
342
+
343
+ expect(result.success).toBe(false);
344
+ expect(result.error).toBe('User Testy McTester does not have permission to delete Customers records');
345
+ expect(entity.InnerLoad).not.toHaveBeenCalled();
346
+ expect(entity.Delete).not.toHaveBeenCalled();
347
+ });
348
+
349
+ it('returns not-found without deleting when the record does not exist', async () => {
350
+ entity = makeEntity({ loadSuccess: false });
351
+
352
+ const result = await EntityCRUDHandler.deleteEntity('Customers', 'ghost', new EntityDeleteOptions(), makeUser());
353
+
354
+ expect(result.success).toBe(false);
355
+ expect(result.error).toBe('Customers with ID ghost not found');
356
+ expect(entity.Delete).not.toHaveBeenCalled();
357
+ });
358
+
359
+ it('surfaces the entity LatestResult when Delete returns false', async () => {
360
+ entity = makeEntity({ deleteSuccess: false });
361
+
362
+ const result = await EntityCRUDHandler.deleteEntity('Customers', 'row-1', new EntityDeleteOptions(), makeUser());
363
+
364
+ expect(result.success).toBe(false);
365
+ expect(result.error).toBe('underlying save/delete failure');
366
+ expect(result.details).toBe(entity.LatestResult);
367
+ });
368
+
369
+ it('catches thrown infrastructure errors and reports them', async () => {
370
+ mockGetEntityObject.mockRejectedValue(new Error('connection lost'));
371
+
372
+ const result = await EntityCRUDHandler.deleteEntity('Customers', 'row-1', new EntityDeleteOptions(), makeUser());
373
+
374
+ expect(result.success).toBe(false);
375
+ expect(result.error).toBe('connection lost');
376
+ expect(mockLogError).toHaveBeenCalled();
377
+ });
378
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * An apply rebuilds its source schema from ACTIVE IntegrationObject/IntegrationObjectField rows
3
+ * only. Declared rows that a rediscovery deactivated are therefore dropped from the schema it
4
+ * materializes — the column never appears, or a requested object is never created — and the apply
5
+ * output said nothing about it. These tests pin what the operator is now told, and what they are
6
+ * deliberately NOT told, since an unfiltered apply on a large catalog can carry hundreds of
7
+ * legitimately deactivated objects.
8
+ */
9
+ import { describe, it, expect } from 'vitest';
10
+ import { ComputeInactiveRowWarnings, SummarizeNames } from '../integration/InactiveRowWarnings.js';
11
+
12
+ const active = (Name: string) => ({ Name, Status: 'Active' });
13
+ const disabled = (Name: string) => ({ Name, Status: 'Disabled' });
14
+
15
+ describe('ComputeInactiveRowWarnings', () => {
16
+ it('says nothing when every declared row in scope is Active', () => {
17
+ expect(ComputeInactiveRowWarnings({
18
+ RequestedNames: ['Contacts'],
19
+ AllObjects: [active('Contacts')],
20
+ FieldsByObjectName: { Contacts: [active('ID'), active('Email')] },
21
+ })).toEqual([]);
22
+ });
23
+
24
+ it('names the deactivated fields of an object it DOES materialize', () => {
25
+ const [warning, ...rest] = ComputeInactiveRowWarnings({
26
+ RequestedNames: ['Contacts'],
27
+ AllObjects: [active('Contacts')],
28
+ FieldsByObjectName: { Contacts: [active('ID'), disabled('MiddleName')] },
29
+ });
30
+ expect(rest).toEqual([]);
31
+ expect(warning).toContain('Contacts:');
32
+ expect(warning).toContain('1 declared field(s)');
33
+ expect(warning).toContain('MiddleName (Disabled)');
34
+ });
35
+
36
+ it('reports a requested object that is not Active — the caller named it and got nothing', () => {
37
+ const warnings = ComputeInactiveRowWarnings({
38
+ RequestedNames: ['Contacts', 'Invoices'],
39
+ AllObjects: [active('Contacts'), disabled('Invoices')],
40
+ FieldsByObjectName: { Contacts: [active('ID')] },
41
+ });
42
+ expect(warnings).toHaveLength(1);
43
+ expect(warnings[0]).toContain('Invoices (Disabled)');
44
+ expect(warnings[0]).toContain('1 requested object(s)');
45
+ });
46
+
47
+ it('matches requested names case-insensitively, the way the apply path resolves them', () => {
48
+ const warnings = ComputeInactiveRowWarnings({
49
+ RequestedNames: ['invoices'],
50
+ AllObjects: [disabled('Invoices')],
51
+ FieldsByObjectName: {},
52
+ });
53
+ expect(warnings[0]).toContain('Invoices (Disabled)');
54
+ });
55
+
56
+ it('stays quiet about deactivated objects when nothing was requested by name', () => {
57
+ // The unfiltered apply: hundreds of deactivated audit objects are the normal state of a
58
+ // large catalog, and announcing them on every apply is noise that buries the field warning.
59
+ const warnings = ComputeInactiveRowWarnings({
60
+ RequestedNames: null,
61
+ AllObjects: [active('Contacts'), disabled('Invoices'), disabled('Invoices__History')],
62
+ FieldsByObjectName: { Contacts: [active('ID')] },
63
+ });
64
+ expect(warnings).toEqual([]);
65
+ });
66
+
67
+ it('treats any non-Active status as not materialized, and shows which one it is', () => {
68
+ const warnings = ComputeInactiveRowWarnings({
69
+ RequestedNames: null,
70
+ AllObjects: [active('Contacts')],
71
+ FieldsByObjectName: { Contacts: [{ Name: 'Legacy', Status: 'Deprecated' }, { Name: 'Blank', Status: null }] },
72
+ });
73
+ expect(warnings[0]).toContain('Legacy (Deprecated)');
74
+ expect(warnings[0]).toContain('Blank (no status)');
75
+ });
76
+ });
77
+
78
+ describe('SummarizeNames', () => {
79
+ it('lists everything when the list is short', () => {
80
+ expect(SummarizeNames(['a', 'b', 'c'])).toBe('a, b, c');
81
+ });
82
+
83
+ it('caps a long list and says how many were left out', () => {
84
+ const names = Array.from({ length: 15 }, (_, i) => `f${i}`);
85
+ expect(SummarizeNames(names)).toBe(`${names.slice(0, 12).join(', ')}, and 3 more`);
86
+ });
87
+ });