@memberjunction/server 6.2.0-edge.1 → 6.2.0-edge.3

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 (220) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/config.d.ts +2189 -71
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +123 -3
  6. package/dist/config.js.map +1 -1
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +11 -23
  9. package/dist/context.js.map +1 -1
  10. package/dist/generated/graphql-schemas/__mj.d.ts +2143 -205
  11. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  12. package/dist/generated/graphql-schemas/__mj.js +24441 -14433
  13. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  14. package/dist/generic/FireAndForgetHeartbeat.d.ts +15 -1
  15. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  16. package/dist/generic/FireAndForgetHeartbeat.js +16 -2
  17. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  18. package/dist/generic/OrphanedConversationDetailReconciler.d.ts +31 -0
  19. package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -0
  20. package/dist/generic/OrphanedConversationDetailReconciler.js +215 -0
  21. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -0
  22. package/dist/generic/PushStatusResolver.d.ts +38 -0
  23. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  24. package/dist/generic/PushStatusResolver.js +84 -1
  25. package/dist/generic/PushStatusResolver.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +56 -4
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +191 -74
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/generic/RunViewResolver.d.ts +4 -0
  31. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  32. package/dist/generic/RunViewResolver.js +33 -1
  33. package/dist/generic/RunViewResolver.js.map +1 -1
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +204 -50
  37. package/dist/index.js.map +1 -1
  38. package/dist/postgresqlCompat.d.ts +8 -0
  39. package/dist/postgresqlCompat.d.ts.map +1 -0
  40. package/dist/postgresqlCompat.js +19 -0
  41. package/dist/postgresqlCompat.js.map +1 -0
  42. package/dist/postgresqlPoolSettings.d.ts +108 -0
  43. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  44. package/dist/postgresqlPoolSettings.js +134 -0
  45. package/dist/postgresqlPoolSettings.js.map +1 -0
  46. package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
  47. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  48. package/dist/resolvers/AdhocQueryResolver.js +77 -167
  49. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  50. package/dist/resolvers/AgentRunStatusPublisher.d.ts +55 -0
  51. package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -0
  52. package/dist/resolvers/AgentRunStatusPublisher.js +141 -0
  53. package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -0
  54. package/dist/resolvers/ConversationRunEventsResolver.d.ts +90 -0
  55. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -0
  56. package/dist/resolvers/ConversationRunEventsResolver.js +283 -0
  57. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -0
  58. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts +98 -0
  59. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts.map +1 -0
  60. package/dist/resolvers/DuplicateEntryCheckResolver.js +227 -0
  61. package/dist/resolvers/DuplicateEntryCheckResolver.js.map +1 -0
  62. package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
  63. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  64. package/dist/resolvers/EntityRecordNameResolver.js +9 -47
  65. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  66. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  67. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -1
  68. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  69. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  70. package/dist/resolvers/GetDataResolver.js +9 -0
  71. package/dist/resolvers/GetDataResolver.js.map +1 -1
  72. package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
  73. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
  74. package/dist/resolvers/HumanHandoffResolver.js +324 -0
  75. package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
  76. package/dist/resolvers/MeetingResolver.d.ts +69 -0
  77. package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
  78. package/dist/resolvers/MeetingResolver.js +381 -0
  79. package/dist/resolvers/MeetingResolver.js.map +1 -0
  80. package/dist/resolvers/MergeRecordsResolver.d.ts +7 -0
  81. package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
  82. package/dist/resolvers/MergeRecordsResolver.js +16 -2
  83. package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
  84. package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
  85. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  86. package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
  87. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  88. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +45 -89
  89. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  90. package/dist/resolvers/RealtimeClientSessionResolver.js +94 -240
  91. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  92. package/dist/resolvers/RunAIAgentResolver.d.ts +0 -16
  93. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  94. package/dist/resolvers/RunAIAgentResolver.js +23 -153
  95. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  96. package/dist/resolvers/RunDecisionResolver.d.ts +149 -0
  97. package/dist/resolvers/RunDecisionResolver.d.ts.map +1 -0
  98. package/dist/resolvers/RunDecisionResolver.js +396 -0
  99. package/dist/resolvers/RunDecisionResolver.js.map +1 -0
  100. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  101. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  102. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  103. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  104. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  105. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  106. package/dist/resolvers/adhocTimeout.js +12 -0
  107. package/dist/resolvers/adhocTimeout.js.map +1 -0
  108. package/dist/resolvers/meetings/index.d.ts +5 -0
  109. package/dist/resolvers/meetings/index.d.ts.map +1 -0
  110. package/dist/resolvers/meetings/index.js +5 -0
  111. package/dist/resolvers/meetings/index.js.map +1 -0
  112. package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
  113. package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
  114. package/dist/resolvers/meetings/meetingHelpers.js +212 -0
  115. package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
  116. package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
  117. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
  118. package/dist/resolvers/meetings/meetingMutations.js +418 -0
  119. package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
  120. package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
  121. package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
  122. package/dist/resolvers/meetings/meetingQueries.js +113 -0
  123. package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
  124. package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
  125. package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
  126. package/dist/resolvers/meetings/meetingTypes.js +345 -0
  127. package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
  128. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  129. package/dist/rest/ViewOperationsHandler.js +8 -5
  130. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  131. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  132. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  133. package/dist/services/TaskGraphActionRunner.js +27 -1
  134. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  135. package/dist/services/WorkQueueHostService.d.ts +36 -0
  136. package/dist/services/WorkQueueHostService.d.ts.map +1 -0
  137. package/dist/services/WorkQueueHostService.js +97 -0
  138. package/dist/services/WorkQueueHostService.js.map +1 -0
  139. package/dist/services/workQueueConfig.d.ts +103 -0
  140. package/dist/services/workQueueConfig.d.ts.map +1 -0
  141. package/dist/services/workQueueConfig.js +48 -0
  142. package/dist/services/workQueueConfig.js.map +1 -0
  143. package/dist/types.d.ts +5 -0
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/types.js.map +1 -1
  146. package/package.json +95 -92
  147. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  148. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  149. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  150. package/src/__tests__/AgentRunStatusPublisher.test.ts +175 -0
  151. package/src/__tests__/DuplicateEntryCheckResolver.test.ts +300 -0
  152. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  153. package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
  154. package/src/__tests__/MeetingResolver.test.ts +374 -0
  155. package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
  156. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  157. package/src/__tests__/RemoteBrowserActionRecovery.test.ts +7 -1
  158. package/src/__tests__/RemoteBrowserAudioStream.test.ts +7 -1
  159. package/src/__tests__/RemoteBrowserSnapshot.test.ts +7 -1
  160. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
  161. package/src/__tests__/ResolverBase.clientOldValues.test.ts +52 -0
  162. package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
  163. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
  164. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +182 -12
  165. package/src/__tests__/RunAIAgentResolver.streaming.test.ts +18 -27
  166. package/src/__tests__/RunDecisionResolver.test.ts +699 -0
  167. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +43 -0
  168. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  169. package/src/__tests__/WorkQueueHostService.test.ts +133 -0
  170. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  171. package/src/__tests__/conversationRunEvents.test.ts +399 -0
  172. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +283 -0
  173. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  174. package/src/__tests__/pushStatusFanOut.test.ts +206 -0
  175. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  176. package/src/auth/__tests__/backward-compatibility.test.ts +1 -0
  177. package/src/config.ts +130 -3
  178. package/src/context.ts +11 -24
  179. package/src/generated/graphql-schemas/__mj.ts +24183 -17182
  180. package/src/generic/FireAndForgetHeartbeat.ts +16 -2
  181. package/src/generic/OrphanedConversationDetailReconciler.ts +267 -0
  182. package/src/generic/PushStatusResolver.ts +97 -1
  183. package/src/generic/ResolverBase.ts +220 -76
  184. package/src/generic/RunViewResolver.ts +33 -2
  185. package/src/index.ts +228 -51
  186. package/src/postgresqlCompat.ts +21 -0
  187. package/src/postgresqlPoolSettings.ts +213 -0
  188. package/src/resolvers/AdhocQueryResolver.ts +88 -188
  189. package/src/resolvers/AgentRunStatusPublisher.ts +175 -0
  190. package/src/resolvers/ConversationRunEventsResolver.ts +308 -0
  191. package/src/resolvers/DuplicateEntryCheckResolver.ts +232 -0
  192. package/src/resolvers/EntityRecordNameResolver.ts +9 -51
  193. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -1
  194. package/src/resolvers/GetDataResolver.ts +10 -0
  195. package/src/resolvers/HumanHandoffResolver.ts +304 -0
  196. package/src/resolvers/MeetingResolver.ts +438 -0
  197. package/src/resolvers/MergeRecordsResolver.ts +14 -2
  198. package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
  199. package/src/resolvers/RealtimeClientSessionResolver.ts +99 -287
  200. package/src/resolvers/RunAIAgentResolver.ts +28 -174
  201. package/src/resolvers/RunDecisionResolver.ts +435 -0
  202. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  203. package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +93 -0
  204. package/src/resolvers/adhocTimeout.ts +12 -0
  205. package/src/resolvers/meetings/index.ts +4 -0
  206. package/src/resolvers/meetings/meetingHelpers.ts +307 -0
  207. package/src/resolvers/meetings/meetingMutations.ts +535 -0
  208. package/src/resolvers/meetings/meetingQueries.ts +172 -0
  209. package/src/resolvers/meetings/meetingTypes.ts +238 -0
  210. package/src/rest/ViewOperationsHandler.ts +8 -5
  211. package/src/services/TaskGraphActionRunner.ts +29 -1
  212. package/src/services/WorkQueueHostService.ts +129 -0
  213. package/src/services/workQueueConfig.ts +52 -0
  214. package/src/types.ts +5 -0
  215. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
  216. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  217. package/dist/resolvers/adhoc-query-helpers.js +0 -50
  218. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  219. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  220. package/src/resolvers/adhoc-query-helpers.ts +0 -67
@@ -0,0 +1,300 @@
1
+ // type-graphql decorators call `Reflect.getMetadata`, which only exists once this polyfill is
2
+ // loaded. MUST precede any import that pulls in the resolver file.
3
+ import 'reflect-metadata';
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+
7
+ /** The slice of `APIKeyEngine.Authorize` that ResolverBase.CheckAPIKeyScopeAuthorization calls. */
8
+ type AuthorizeFn = (
9
+ apiKeyHash: string,
10
+ application: string,
11
+ scopePath: string,
12
+ resource: string
13
+ ) => Promise<{ Allowed: boolean; Reason: string }>;
14
+
15
+ const { mockAuthorize, mockLogError } = vi.hoisted(() => ({
16
+ mockAuthorize: vi.fn<AuthorizeFn>(),
17
+ mockLogError: vi.fn<(message: string) => void>(),
18
+ }));
19
+
20
+ // As in RunDecisionResolver.test.ts: vitest's esbuild transform does not apply
21
+ // `emitDecoratorMetadata`, so the decorators are no-ops and the resolver runs as a plain class.
22
+ vi.mock('type-graphql', () => {
23
+ const noopDecorator = () => () => undefined;
24
+ return {
25
+ Resolver: noopDecorator, Mutation: noopDecorator, Query: noopDecorator, Subscription: noopDecorator,
26
+ ObjectType: noopDecorator, InputType: noopDecorator, Field: noopDecorator, Arg: noopDecorator,
27
+ Args: noopDecorator, Ctx: noopDecorator, PubSub: noopDecorator, Root: noopDecorator,
28
+ Directive: noopDecorator, Authorized: noopDecorator, UseMiddleware: noopDecorator,
29
+ createUnionType: () => class {},
30
+ AuthorizationError: class AuthorizationError extends Error {
31
+ constructor(message?: string) {
32
+ super(message);
33
+ this.name = 'AuthorizationError';
34
+ }
35
+ },
36
+ Float: class {}, Int: class {}, ID: class {},
37
+ };
38
+ });
39
+
40
+ // Only logging is replaced, so a test can tell a logged fault from a quiet answer.
41
+ vi.mock('@memberjunction/core', async (importOriginal) => {
42
+ const actual = await importOriginal<Record<string, unknown>>();
43
+ return { ...actual, LogError: mockLogError };
44
+ });
45
+
46
+ // Only the API-key engine is replaced; ResolverBase's scope check itself is the production code.
47
+ vi.mock('@memberjunction/api-keys', async (importOriginal) => {
48
+ const actual = await importOriginal<Record<string, unknown>>();
49
+ return { ...actual, GetAPIKeyEngine: () => ({ Authorize: mockAuthorize }) };
50
+ });
51
+
52
+ // The resolver's tests replace the detector through CreateEntryChecker, so the detector package
53
+ // is never exercised here; it has its own specs.
54
+ vi.mock('@memberjunction/ai-vector-dupe', () => ({ DuplicateRecordDetector: class {} }));
55
+
56
+ import { EntityInfo, Metadata, UserInfo } from '@memberjunction/core';
57
+ import { UserCache } from '@memberjunction/generic-database-provider';
58
+ import type { DuplicateEntryCheckResult } from '@memberjunction/ai-vector-dupe';
59
+ import {
60
+ DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH,
61
+ DuplicateEntryCheckResolver,
62
+ type DuplicateEntryChecker,
63
+ } from '../resolvers/DuplicateEntryCheckResolver.js';
64
+ import type { UserPayload } from '../types.js';
65
+
66
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
67
+
68
+ const USER = new UserInfo(null, {
69
+ ID: 'user-1',
70
+ Name: 'Entry Person',
71
+ Email: 'person@example.com',
72
+ UserRoles: [{ UserID: 'user-1', RoleID: 'role-ui' }],
73
+ });
74
+ const SYSTEM_USER = new UserInfo(null, { ID: 'user-system', Name: 'System', Email: 'system@example.com' });
75
+
76
+ /** An entity the user's role may read. */
77
+ const ACCOUNTS = new EntityInfo({
78
+ ID: 'entity-accounts',
79
+ Name: 'Accounts',
80
+ Status: 'Active',
81
+ Fields: [
82
+ { Name: 'ID', Type: 'uniqueidentifier', IsPrimaryKey: true },
83
+ { Name: 'Name', Type: 'nvarchar', IsNameField: true, AllowUpdateAPI: true },
84
+ { Name: 'City', Type: 'nvarchar', AllowUpdateAPI: true },
85
+ { Name: 'Owner', Type: 'nvarchar', AllowUpdateAPI: false },
86
+ { Name: '__mj_CreatedAt', Type: 'datetimeoffset', AllowUpdateAPI: false },
87
+ ],
88
+ EntityPermissions: [{ RoleID: 'role-ui', CanRead: true }],
89
+ });
90
+
91
+ /** An entity only another role may read. */
92
+ const PAYROLL = new EntityInfo({
93
+ ID: 'entity-payroll',
94
+ Name: 'Payroll',
95
+ Status: 'Active',
96
+ Fields: [{ Name: 'ID', Type: 'uniqueidentifier', IsPrimaryKey: true }],
97
+ EntityPermissions: [{ RoleID: 'role-finance', CanRead: true }],
98
+ });
99
+
100
+ const CHECKED: DuplicateEntryCheckResult = {
101
+ Status: 'Checked',
102
+ Candidates: [{ RecordID: 'acct-7', DisplayName: 'Acme Corp', VectorScore: 0.93, Probability: 0.81 }],
103
+ ElapsedMs: 212,
104
+ };
105
+
106
+ /** A session authenticated by JWT: no API key, so no scope check. */
107
+ const sessionPayload = (): UserPayload => ({ email: USER.Email, userRecord: USER, sessionId: 'session-1' });
108
+ /** A session authenticated by an MJ API key: scope-checked. */
109
+ const apiKeyPayload = (): UserPayload => ({ ...sessionPayload(), apiKeyId: 'key-1', apiKeyHash: 'hash-1' });
110
+
111
+ /** The resolver with its detector replaced by a double. */
112
+ class TestResolver extends DuplicateEntryCheckResolver {
113
+ public readonly Checker = { CheckRecordValues: vi.fn<DuplicateEntryChecker['CheckRecordValues']>() };
114
+
115
+ protected override CreateEntryChecker(): DuplicateEntryChecker {
116
+ return this.Checker;
117
+ }
118
+ }
119
+
120
+ let resolver: TestResolver;
121
+
122
+ const check = (entityName: string, valuesJSON: string, payload: UserPayload = sessionPayload()) =>
123
+ resolver.RunEntryCheck(entityName, valuesJSON, payload, null);
124
+
125
+ beforeEach(() => {
126
+ vi.restoreAllMocks();
127
+ mockAuthorize.mockReset();
128
+ mockLogError.mockReset();
129
+ vi.spyOn(Metadata.prototype, 'EntityByName').mockImplementation((name: string) =>
130
+ [ACCOUNTS, PAYROLL].find(e => e.Name.toLowerCase() === name.trim().toLowerCase())
131
+ );
132
+ vi.spyOn(UserCache.prototype, 'GetSystemUser').mockReturnValue(SYSTEM_USER);
133
+ resolver = new TestResolver();
134
+ resolver.Checker.CheckRecordValues.mockResolvedValue(CHECKED);
135
+ });
136
+
137
+ // ─── Authorization ───────────────────────────────────────────────────────────
138
+
139
+ /** The scope checks made, other than the full_access fast path, as [scope, resource]. */
140
+ const scopeChecks = () => mockAuthorize.mock.calls.filter(call => call[2] !== 'full_access').map(call => [call[2], call[3]]);
141
+
142
+ describe('CheckDuplicateEntry: authorization', () => {
143
+ it('answers NotAuthorized, without logging, for a user who cannot read the entity', async () => {
144
+ const result = await check('Payroll', '{"ID":"x"}');
145
+
146
+ expect(result.Status).toBe('NotAuthorized');
147
+ expect(result.ErrorMessage).toContain('does not have read permissions on Payroll');
148
+ expect(result.Candidates).toEqual([]);
149
+ expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
150
+ expect(mockLogError).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it('fails an unknown entity without running the check', async () => {
154
+ const result = await check('No Such Entity', '{}');
155
+
156
+ expect(result).toMatchObject({ Status: 'Failed', ErrorMessage: 'Entity not found in metadata' });
157
+ expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('applies the view:run API-key scope for the entity, and a denial is a status', async () => {
161
+ mockAuthorize.mockResolvedValue({ Allowed: false, Reason: 'scope not granted' });
162
+
163
+ const result = await check('Accounts', '{"Name":"Acme"}', apiKeyPayload());
164
+
165
+ expect(scopeChecks()).toEqual([['view:run', 'Accounts']]);
166
+ expect(result.Status).toBe('NotAuthorized');
167
+ expect(result.ErrorMessage).toContain("requires the 'view:run' scope");
168
+ expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
169
+ expect(mockLogError).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it('also requires prompt:execute, so a read-only key cannot spend on model calls', async () => {
173
+ mockAuthorize.mockImplementation(async (_hash, _app, scopePath) =>
174
+ scopePath === 'view:run' ? { Allowed: true, Reason: '' } : { Allowed: false, Reason: 'scope not granted' }
175
+ );
176
+
177
+ const result = await check('Accounts', '{"Name":"Acme"}', apiKeyPayload());
178
+
179
+ expect(scopeChecks()).toEqual([['view:run', 'Accounts'], ['prompt:execute', '*']]);
180
+ expect(result.Status).toBe('NotAuthorized');
181
+ expect(result.ErrorMessage).toContain("requires the 'prompt:execute' scope");
182
+ expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
183
+ });
184
+
185
+ it('runs the check for a key holding both scopes', async () => {
186
+ mockAuthorize.mockImplementation(async (_hash, _app, scopePath) =>
187
+ ({ Allowed: scopePath !== 'full_access', Reason: '' })
188
+ );
189
+
190
+ const result = await check('Accounts', '{"Name":"Acme"}', apiKeyPayload());
191
+
192
+ expect(scopeChecks()).toEqual([['view:run', 'Accounts'], ['prompt:execute', '*']]);
193
+ expect(result.Status).toBe('Checked');
194
+ });
195
+ });
196
+
197
+ // ─── The values ──────────────────────────────────────────────────────────────
198
+
199
+ describe('CheckDuplicateEntry: the values', () => {
200
+ it('returns an error status, not a throw, for values that are not JSON', async () => {
201
+ const result = await check('Accounts', '{ Name: ');
202
+
203
+ expect(result.Status).toBe('Failed');
204
+ expect(result.ErrorMessage).toMatch(/^valuesJSON is not valid JSON: /);
205
+ expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it('returns an error status for JSON that is not an object', async () => {
209
+ const result = await check('Accounts', '["Acme"]');
210
+
211
+ expect(result).toMatchObject({ Status: 'Failed', ErrorMessage: 'valuesJSON must be the JSON of an object of field values' });
212
+ });
213
+
214
+ it('ignores the fields the entity does not have, and keys the rest by the real field name', async () => {
215
+ await check('Accounts', JSON.stringify({ name: 'Acme', City: 'Boston', Revenue: 5, NotAField: 'x' }));
216
+
217
+ expect(resolver.Checker.CheckRecordValues).toHaveBeenCalledWith('Accounts', { Name: 'Acme', City: 'Boston' }, USER);
218
+ });
219
+
220
+ it('ignores primary keys, read-only fields and MJ system fields', async () => {
221
+ await check('Accounts', JSON.stringify({ ID: 'acct-7', Name: 'Acme', Owner: 'someone', __mj_CreatedAt: '2026-01-01' }));
222
+
223
+ expect(resolver.Checker.CheckRecordValues).toHaveBeenCalledWith('Accounts', { Name: 'Acme' }, USER);
224
+ });
225
+
226
+ it('accepts strings, numbers, booleans and null', async () => {
227
+ await check('Accounts', JSON.stringify({ Name: 'Acme', City: null, Revenue: 5, Active: true }));
228
+
229
+ expect(resolver.Checker.CheckRecordValues).toHaveBeenCalledWith('Accounts', { Name: 'Acme', City: null }, USER);
230
+ });
231
+
232
+ it.each([
233
+ ['an array', { Name: ['Acme', 'Corp'] }],
234
+ ['an object', { Name: { first: 'Acme' } }],
235
+ ])('refuses a value that is %s', async (_kind, values) => {
236
+ const result = await check('Accounts', JSON.stringify(values));
237
+
238
+ expect(result).toMatchObject({ Status: 'Failed', ErrorMessage: "valuesJSON field 'Name' must be a string, number, boolean or null" });
239
+ expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
240
+ });
241
+
242
+ it('refuses a value of an unknown field that is not a scalar', async () => {
243
+ const result = await check('Accounts', JSON.stringify({ Name: 'Acme', Extra: [1, 2, 3] }));
244
+
245
+ expect(result.Status).toBe('Failed');
246
+ expect(resolver.Checker.CheckRecordValues).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it('accepts valuesJSON up to DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH characters and refuses one more', async () => {
250
+ const padding = DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH - JSON.stringify({ Name: '' }).length;
251
+ const atLimit = JSON.stringify({ Name: 'x'.repeat(padding) });
252
+ const overLimit = JSON.stringify({ Name: 'x'.repeat(padding + 1) });
253
+ expect(atLimit).toHaveLength(DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH);
254
+
255
+ expect((await check('Accounts', atLimit)).Status).toBe('Checked');
256
+ const refused = await check('Accounts', overLimit);
257
+
258
+ expect(refused).toMatchObject({
259
+ Status: 'Failed',
260
+ ErrorMessage: `valuesJSON is ${DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH + 1} characters; the limit is ${DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH}`,
261
+ });
262
+ expect(resolver.Checker.CheckRecordValues).toHaveBeenCalledTimes(1);
263
+ });
264
+
265
+ it('keeps the limit to a few KB', () => {
266
+ expect(DUPLICATE_ENTRY_CHECK_MAX_VALUES_JSON_LENGTH).toBeLessThanOrEqual(32 * 1024);
267
+ });
268
+ });
269
+
270
+ // ─── The result ──────────────────────────────────────────────────────────────
271
+
272
+ describe('CheckDuplicateEntry: the result', () => {
273
+ it('returns the detector result', async () => {
274
+ const result = await check('Accounts', '{"Name":"Acme"}');
275
+
276
+ expect(result).toEqual({
277
+ Status: 'Checked',
278
+ ErrorMessage: undefined,
279
+ Candidates: [{ RecordID: 'acct-7', DisplayName: 'Acme Corp', VectorScore: 0.93, Probability: 0.81 }],
280
+ ElapsedMs: 212,
281
+ });
282
+ });
283
+
284
+ it('passes NotConfigured through', async () => {
285
+ resolver.Checker.CheckRecordValues.mockResolvedValue({ Status: 'NotConfigured', Candidates: [], ElapsedMs: 3 });
286
+
287
+ const result = await check('Accounts', '{"Name":"Acme"}');
288
+
289
+ expect(result).toMatchObject({ Status: 'NotConfigured', Candidates: [] });
290
+ });
291
+
292
+ it('returns Failed, not a throw, when the check throws, and logs it', async () => {
293
+ resolver.Checker.CheckRecordValues.mockRejectedValue(new Error('vector index unreachable'));
294
+
295
+ const result = await check('Accounts', '{"Name":"Acme"}');
296
+
297
+ expect(result).toMatchObject({ Status: 'Failed', ErrorMessage: 'vector index unreachable', Candidates: [] });
298
+ expect(mockLogError).toHaveBeenCalledTimes(1);
299
+ });
300
+ });
@@ -7,43 +7,22 @@ import type { DatabaseProviderBase, EntityInfo, UserInfo } from '@memberjunction
7
7
  import { EntityRecordNameResolver } from '../resolvers/EntityRecordNameResolver.js';
8
8
 
9
9
  /**
10
- * `GetEntityRecordName` must not hand back a name the caller cannot READ.
11
- *
12
- * This resolver predates field-level security and took no user context at all, which made it an
13
- * open side channel: the name never travelled through `MapFieldNamesToCodeNames`, the boundary
14
- * that strips denied fields, so a caller denied READ on an entity's name field could still obtain
15
- * that name from here.
16
- *
17
- * FLS turned the latent gap into a live bypass, and the shape of it is worth remembering: the
18
- * form-field FK control falls through to this query PRECISELY WHEN the joined display column is
19
- * denied. The fallback that exists to handle a denial was the thing that defeated it. Found in
20
- * manual Phase 5 testing — a Contract form showed its Client's name while the grid, correctly,
21
- * did not.
10
+ * `GetEntityRecordName` hands the acting user to the provider, which applies field- and row-level
11
+ * security for them, and reports a withheld name exactly as it reports a missing record. The
12
+ * field-level rule itself is covered by MJCore's DatabaseProviderBase record-name tests.
22
13
  */
23
14
 
24
- const DENIED_USER = { ID: 'u-1', Email: 'restricted@test' } as unknown as UserInfo;
15
+ const ACTING_USER = { ID: 'u-1', Email: 'restricted@test' } as unknown as UserInfo;
25
16
 
26
- /** An entity whose name field is denied or allowed, per the flag. */
27
- function entityInfo(opts: { fls: boolean; deniedNames?: string[]; nameField?: string }): EntityInfo {
28
- const nameFieldName = opts.nameField ?? 'Name';
29
- return {
30
- Name: 'Clients',
31
- EnableFieldLevelSecurity: opts.fls,
32
- Fields: [
33
- { Name: 'ID', IsNameField: false },
34
- { Name: nameFieldName, IsNameField: true },
35
- { Name: 'Industry', IsNameField: false },
36
- ],
37
- GetDeniedReadFields: () => new Set(opts.deniedNames ?? []),
38
- } as unknown as EntityInfo;
39
- }
17
+ const ENTITY = { Name: 'Clients' } as unknown as EntityInfo;
40
18
 
41
- /** A provider that would happily return the name — the resolver must decide not to ask. */
42
- function providerWith(entity: EntityInfo, getName = vi.fn().mockResolvedValue('Acme Manufacturing')) {
19
+ /** A provider whose lookup answers with `name` ('' when it withholds or finds nothing). */
20
+ function providerAnswering(name: string) {
21
+ const getName = vi.fn().mockResolvedValue(name);
43
22
  return {
44
- Entities: [entity],
45
- GetEntityRecordName: getName,
46
- } as unknown as DatabaseProviderBase & { GetEntityRecordName: typeof getName };
23
+ md: { Entities: [ENTITY], GetEntityRecordName: getName } as unknown as DatabaseProviderBase,
24
+ getName,
25
+ };
47
26
  }
48
27
 
49
28
  /** Exposes the inner method so the test does not need a GraphQL context. */
@@ -57,62 +36,38 @@ class Probe extends EntityRecordNameResolver {
57
36
 
58
37
  const KEY = new CompositeKey([{ FieldName: 'ID', Value: 'c-1' }]);
59
38
 
60
- describe('GetEntityRecordName — field-security gate', () => {
61
- it('withholds the name when the name field is read-denied', async () => {
62
- const getName = vi.fn().mockResolvedValue('Acme Manufacturing');
63
- const md = providerWith(entityInfo({ fls: true, deniedNames: ['name'] }), getName);
39
+ describe('GetEntityRecordName — security is applied for the acting user', () => {
40
+ it('passes the acting user to the provider lookup', async () => {
41
+ const { md, getName } = providerAnswering('Acme Manufacturing');
64
42
 
65
- const result = await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
43
+ await new Probe().Inner(md, 'Clients', KEY, ACTING_USER);
66
44
 
67
- expect(result.Success).toBe(false);
68
- expect(result.RecordName).toBeUndefined();
69
- // Not merely filtered after the fact — the lookup must not run at all. The provider's
70
- // record-name cache is keyed by entity + primary key and NOT by user, so a name fetched
71
- // here would be visible to any later caller.
72
- expect(getName).not.toHaveBeenCalled();
45
+ expect(getName).toHaveBeenCalledWith('Clients', expect.anything(), ACTING_USER);
73
46
  });
74
47
 
75
- it('returns the name when the user may read the name field', async () => {
76
- const md = providerWith(entityInfo({ fls: true, deniedNames: ['industry'] }));
48
+ it('returns the name the provider allows', async () => {
49
+ const { md } = providerAnswering('Acme Manufacturing');
77
50
 
78
- const result = await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
51
+ const result = await new Probe().Inner(md, 'Clients', KEY, ACTING_USER);
79
52
 
80
53
  expect(result.Success).toBe(true);
81
54
  expect(result.RecordName).toBe('Acme Manufacturing');
82
55
  });
83
56
 
84
- it('is inert when the entity has field security switched off', async () => {
85
- // Nearly every entity. A denial set that would otherwise match must not apply.
86
- const md = providerWith(entityInfo({ fls: false, deniedNames: ['name'] }));
57
+ it('reports a withheld name as unsuccessful, with no name', async () => {
58
+ const { md } = providerAnswering('');
87
59
 
88
- const result = await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
89
-
90
- expect(result.Success).toBe(true);
91
- expect(result.RecordName).toBe('Acme Manufacturing');
92
- });
93
-
94
- it('honours a designated name field that is not literally called "Name"', async () => {
95
- const md = providerWith(entityInfo({ fls: true, nameField: 'Title', deniedNames: ['title'] }));
96
-
97
- const result = await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
60
+ const result = await new Probe().Inner(md, 'Clients', KEY, ACTING_USER);
98
61
 
99
62
  expect(result.Success).toBe(false);
63
+ expect(result.RecordName).toBeUndefined();
100
64
  });
101
65
 
102
- it('passes the acting user to the provider so row-level security has an identity', async () => {
103
- const getName = vi.fn().mockResolvedValue('Acme Manufacturing');
104
- const md = providerWith(entityInfo({ fls: true }), getName);
105
-
106
- await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
66
+ it('does not disclose which of "missing" or "restricted" applies', async () => {
67
+ const { md } = providerAnswering('');
107
68
 
108
- expect(getName).toHaveBeenCalledWith('Clients', expect.anything(), DENIED_USER);
109
- });
69
+ const result = await new Probe().Inner(md, 'Clients', KEY, ACTING_USER);
110
70
 
111
- it('does not disclose which of "missing" or "restricted" applies', async () => {
112
- // The denial status must read the same as a genuine not-found, so the query cannot be
113
- // used to probe for which entities carry restricted name fields.
114
- const denied = await new Probe().Inner(
115
- providerWith(entityInfo({ fls: true, deniedNames: ['name'] })), 'Clients', KEY, DENIED_USER);
116
- expect(denied.Status).not.toMatch(/denied|permission|restricted|field.security/i);
71
+ expect(result.Status).not.toMatch(/denied|permission|restricted|field.security|RLS/i);
117
72
  });
118
73
  });
@@ -0,0 +1,233 @@
1
+ // type-graphql decorators on the resolver call Reflect.getMetadata, which only exists when this polyfill
2
+ // is loaded first. Vitest does not bring it in automatically — this MUST precede the resolver import.
3
+ import 'reflect-metadata';
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
7
+
8
+ const h = vi.hoisted(() => ({
9
+ configure: vi.fn(),
10
+ listOffersForUser: vi.fn(),
11
+ acceptOffer: vi.fn(),
12
+ declineOffer: vi.fn(),
13
+ notificationConfig: vi.fn(async () => undefined),
14
+ sendNotification: vi.fn(async () => ({ success: true, deliveryChannels: { inApp: true, email: false, sms: false } })),
15
+ }));
16
+
17
+ vi.mock('@memberjunction/livekit-room-server', () => ({
18
+ RoomHandoffEngine: {
19
+ Instance: {
20
+ Configure: h.configure,
21
+ ListOffersForUser: h.listOffersForUser,
22
+ AcceptOffer: h.acceptOffer,
23
+ DeclineOffer: h.declineOffer,
24
+ },
25
+ },
26
+ LiveKitSipService: class {},
27
+ }));
28
+ vi.mock('@memberjunction/notifications', () => ({
29
+ NotificationEngine: { Instance: { Config: h.notificationConfig, SendNotification: h.sendNotification } },
30
+ }));
31
+
32
+ import {
33
+ HumanHandoffResolver,
34
+ HandoffOfferChangeFilter,
35
+ NotificationHandoffNotifier,
36
+ PubSubHandoffPublisher,
37
+ SetHandoffOfferPublishHook,
38
+ ParseReplicatedHandoffOfferUpdate,
39
+ HANDOFF_OFFER_TOPIC,
40
+ } from '../resolvers/HumanHandoffResolver';
41
+ import { PubSubManager } from '../generic/PubSubManager.js';
42
+ import type { AppContext } from '../types.js';
43
+
44
+ /** Captured at module load: the resolver binds the engine's server-only collaborators then. */
45
+ const wiredAtLoad = h.configure.mock.calls[0]?.[0] as { Presence: unknown; Publisher: unknown; Notifier: unknown };
46
+
47
+ class TestableResolver extends HumanHandoffResolver {
48
+ public user: UserInfo | undefined = { ID: 'U1', Name: 'Dana', Email: 'dana@x.com' } as unknown as UserInfo;
49
+ protected override GetUserFromPayload(): UserInfo | undefined {
50
+ return this.user;
51
+ }
52
+ }
53
+
54
+ const ctx = {} as AppContext;
55
+
56
+ const VIEW = {
57
+ OfferID: 'offer-1',
58
+ RoomName: 'call-1',
59
+ Mode: 'warm',
60
+ Summary: 'Wants a refund',
61
+ CallerLabel: 'Phone caller ****0123',
62
+ AgentName: 'Sage',
63
+ Status: 'Pending',
64
+ CreatedAt: '2026-10-03T10:00:00.000Z',
65
+ ExpiresAt: '2026-10-03T10:00:45.000Z',
66
+ InteractionID: 'int-123',
67
+ };
68
+
69
+ describe('HumanHandoffResolver', () => {
70
+ let resolver: TestableResolver;
71
+
72
+ beforeEach(() => {
73
+ resolver = new TestableResolver();
74
+ h.listOffersForUser.mockReset();
75
+ h.acceptOffer.mockReset();
76
+ h.declineOffer.mockReset();
77
+ h.sendNotification.mockClear();
78
+ h.notificationConfig.mockClear();
79
+ });
80
+
81
+ it('gives the handoff engine its server-only collaborators when the module loads', () => {
82
+ expect(wiredAtLoad.Presence).toBeDefined();
83
+ expect(wiredAtLoad.Publisher).toBeInstanceOf(PubSubHandoffPublisher);
84
+ expect(wiredAtLoad.Notifier).toBeInstanceOf(NotificationHandoffNotifier);
85
+ });
86
+
87
+ describe('MyHandoffOffers', () => {
88
+ it('lists the signed-in user\'s offers, and only asks the engine about that user', async () => {
89
+ h.listOffersForUser.mockReturnValue([VIEW]);
90
+ const offers = await resolver.MyHandoffOffers(ctx);
91
+ expect(h.listOffersForUser).toHaveBeenCalledWith('U1', resolver.user);
92
+ expect(offers).toEqual([VIEW]);
93
+ });
94
+
95
+ it('returns nothing when there is no signed-in user', async () => {
96
+ resolver.user = undefined;
97
+ expect(await resolver.MyHandoffOffers(ctx)).toEqual([]);
98
+ expect(h.listOffersForUser).not.toHaveBeenCalled();
99
+ });
100
+ });
101
+
102
+ describe('AcceptHandoffOffer', () => {
103
+ it('accepts as the signed-in user (never a user id from the client) and returns the room to join', async () => {
104
+ h.acceptOffer.mockReturnValue({ Ok: true, Offer: { ...VIEW, Status: 'Accepted' } });
105
+ const result = await resolver.AcceptHandoffOffer('offer-1', ctx);
106
+ expect(h.acceptOffer).toHaveBeenCalledWith('offer-1', 'U1', resolver.user);
107
+ expect(result).toMatchObject({ Success: true, RoomName: 'call-1', Offer: { Status: 'Accepted' } });
108
+ });
109
+
110
+ it('passes the engine\'s refusal back without a room', async () => {
111
+ h.acceptOffer.mockReturnValue({ Ok: false, Reason: 'This conversation offer is no longer available.' });
112
+ expect(await resolver.AcceptHandoffOffer('offer-1', ctx)).toEqual({ Success: false, ErrorMessage: 'This conversation offer is no longer available.', RoomName: '' });
113
+ });
114
+
115
+ it('requires a signed-in user', async () => {
116
+ resolver.user = undefined;
117
+ const result = await resolver.AcceptHandoffOffer('offer-1', ctx);
118
+ expect(result.Success).toBe(false);
119
+ expect(h.acceptOffer).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('turns an engine failure into a plain failure', async () => {
123
+ h.acceptOffer.mockImplementation(() => {
124
+ throw new Error('boom');
125
+ });
126
+ expect(await resolver.AcceptHandoffOffer('offer-1', ctx)).toMatchObject({ Success: false, RoomName: '' });
127
+ });
128
+ });
129
+
130
+ describe('DeclineHandoffOffer', () => {
131
+ it('declines as the signed-in user', async () => {
132
+ h.declineOffer.mockReturnValue({ Ok: true });
133
+ expect(await resolver.DeclineHandoffOffer('offer-1', ctx)).toEqual({ Success: true });
134
+ expect(h.declineOffer).toHaveBeenCalledWith('offer-1', 'U1', resolver.user);
135
+ });
136
+
137
+ it('passes a refusal back, and requires a user', async () => {
138
+ h.declineOffer.mockReturnValue({ Ok: false, Reason: 'gone' });
139
+ expect(await resolver.DeclineHandoffOffer('offer-1', ctx)).toEqual({ Success: false, ErrorMessage: 'gone' });
140
+ resolver.user = undefined;
141
+ expect((await resolver.DeclineHandoffOffer('offer-1', ctx)).Success).toBe(false);
142
+ });
143
+ });
144
+
145
+ describe('HandoffOfferChanges (subscription)', () => {
146
+ it('shapes the payload for the client and drops the server-side delivery key', () => {
147
+ const change = resolver.HandoffOfferChanges({ UserID: 'U1', Kind: 'offered', Offer: VIEW as never });
148
+ expect(change).toEqual({ Kind: 'offered', Offer: VIEW });
149
+ expect(JSON.stringify(change)).not.toContain('U1');
150
+ });
151
+ });
152
+ });
153
+
154
+ describe('HandoffOfferChangeFilter', () => {
155
+ const payload = { UserID: 'AAAAAAAA-0000-0000-0000-000000000001', Kind: 'offered' as const, Offer: VIEW as never };
156
+ const context = (id?: string) => ({ userPayload: { userRecord: { ID: id } } });
157
+
158
+ it('delivers a change only to the person it belongs to, comparing ids case-insensitively', () => {
159
+ expect(HandoffOfferChangeFilter({ payload, context: context('aaaaaaaa-0000-0000-0000-000000000001') })).toBe(true);
160
+ expect(HandoffOfferChangeFilter({ payload, context: context('BBBBBBBB-0000-0000-0000-000000000002') })).toBe(false);
161
+ });
162
+
163
+ it('fails closed when either identity is missing', () => {
164
+ expect(HandoffOfferChangeFilter({ payload, context: undefined })).toBe(false);
165
+ expect(HandoffOfferChangeFilter({ payload, context: context(undefined) })).toBe(false);
166
+ expect(HandoffOfferChangeFilter({ payload: { ...payload, UserID: '' }, context: context('x') })).toBe(false);
167
+ });
168
+ });
169
+
170
+ describe('PubSubHandoffPublisher', () => {
171
+ it('publishes the change on the offer topic, keyed by the person it belongs to', () => {
172
+ const publish = vi.spyOn(PubSubManager.Instance, 'Publish').mockImplementation(() => undefined);
173
+ new PubSubHandoffPublisher().Publish({ UserID: 'U1', Kind: 'updated', Offer: VIEW as never });
174
+ expect(publish).toHaveBeenCalledWith(HANDOFF_OFFER_TOPIC, { UserID: 'U1', Kind: 'updated', Offer: VIEW });
175
+ publish.mockRestore();
176
+ });
177
+
178
+ it('forwards the change to the cluster hook if set', () => {
179
+ const hook = vi.fn();
180
+ SetHandoffOfferPublishHook(hook);
181
+ new PubSubHandoffPublisher().Publish({ UserID: 'U1', Kind: 'updated', Offer: VIEW as never });
182
+ expect(hook).toHaveBeenCalledWith(expect.objectContaining({ UserID: 'U1', Kind: 'updated', Offer: VIEW }));
183
+ SetHandoffOfferPublishHook(null);
184
+ });
185
+ });
186
+
187
+ describe('ParseReplicatedHandoffOfferUpdate', () => {
188
+ it('parses valid replicated offer payload', () => {
189
+ const raw = JSON.stringify({
190
+ UserID: 'U1',
191
+ Kind: 'updated',
192
+ Offer: VIEW,
193
+ });
194
+ const parsed = ParseReplicatedHandoffOfferUpdate(raw);
195
+ expect(parsed).toMatchObject({
196
+ UserID: 'U1',
197
+ Kind: 'updated',
198
+ Offer: { OfferID: 'offer-1' },
199
+ });
200
+ });
201
+
202
+ it('rejects invalid payloads cleanly', () => {
203
+ expect(ParseReplicatedHandoffOfferUpdate('invalid json')).toBeNull();
204
+ expect(ParseReplicatedHandoffOfferUpdate(JSON.stringify({ Kind: 'unknown' }))).toBeNull();
205
+ expect(ParseReplicatedHandoffOfferUpdate(JSON.stringify({ Kind: 'updated' }))).toBeNull();
206
+ });
207
+ });
208
+
209
+ describe('NotificationHandoffNotifier', () => {
210
+ const user = { ID: 'run-as' } as unknown as UserInfo;
211
+ const provider = {} as unknown as IMetadataProvider;
212
+
213
+ it('sends a notification to the person, routed to the handoff console, through the dedicated handoff offer type', async () => {
214
+ await new NotificationHandoffNotifier().NotifyOffer(VIEW as never, 'U1', user, provider);
215
+ expect(h.notificationConfig).toHaveBeenCalledWith(false, user, provider);
216
+ expect(h.sendNotification).toHaveBeenCalledWith(
217
+ expect.objectContaining({
218
+ userId: 'U1',
219
+ typeNameOrId: 'Conversation Handoff Offer',
220
+ title: expect.stringContaining('Phone caller ****0123'),
221
+ message: expect.stringContaining('Wants a refund'),
222
+ resourceConfiguration: { type: 'handoff-offer', offerId: 'offer-1', room: 'call-1' },
223
+ }),
224
+ user,
225
+ );
226
+ });
227
+
228
+ it('throws (so the engine logs it) when the notification engine reports a failure', async () => {
229
+ h.sendNotification.mockResolvedValueOnce({ success: false, errors: ['type not found'], deliveryChannels: { inApp: false, email: false, sms: false } } as never);
230
+ await expect(new NotificationHandoffNotifier().NotifyOffer(VIEW as never, 'U1', user, provider)).rejects.toThrow('type not found');
231
+ });
232
+ });
233
+