@memberjunction/server 6.2.0-edge.2 → 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 (150) 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 +928 -0
  11. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  12. package/dist/generated/graphql-schemas/__mj.js +8482 -3649
  13. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  14. package/dist/generic/ResolverBase.d.ts +43 -3
  15. package/dist/generic/ResolverBase.d.ts.map +1 -1
  16. package/dist/generic/ResolverBase.js +142 -27
  17. package/dist/generic/ResolverBase.js.map +1 -1
  18. package/dist/generic/RunViewResolver.d.ts +4 -0
  19. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  20. package/dist/generic/RunViewResolver.js +33 -1
  21. package/dist/generic/RunViewResolver.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +115 -49
  25. package/dist/index.js.map +1 -1
  26. package/dist/postgresqlCompat.d.ts +8 -0
  27. package/dist/postgresqlCompat.d.ts.map +1 -0
  28. package/dist/postgresqlCompat.js +19 -0
  29. package/dist/postgresqlCompat.js.map +1 -0
  30. package/dist/postgresqlPoolSettings.d.ts +108 -0
  31. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  32. package/dist/postgresqlPoolSettings.js +134 -0
  33. package/dist/postgresqlPoolSettings.js.map +1 -0
  34. package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
  35. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  36. package/dist/resolvers/AdhocQueryResolver.js +77 -167
  37. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  38. package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
  39. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  40. package/dist/resolvers/EntityRecordNameResolver.js +9 -47
  41. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  42. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  43. package/dist/resolvers/GetDataResolver.js +9 -0
  44. package/dist/resolvers/GetDataResolver.js.map +1 -1
  45. package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
  46. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
  47. package/dist/resolvers/HumanHandoffResolver.js +324 -0
  48. package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
  49. package/dist/resolvers/MeetingResolver.d.ts +69 -0
  50. package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
  51. package/dist/resolvers/MeetingResolver.js +381 -0
  52. package/dist/resolvers/MeetingResolver.js.map +1 -0
  53. package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
  54. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  55. package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
  56. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  57. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +1 -83
  58. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  59. package/dist/resolvers/RealtimeClientSessionResolver.js +4 -206
  60. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  61. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  62. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  63. package/dist/resolvers/adhocTimeout.js +12 -0
  64. package/dist/resolvers/adhocTimeout.js.map +1 -0
  65. package/dist/resolvers/meetings/index.d.ts +5 -0
  66. package/dist/resolvers/meetings/index.d.ts.map +1 -0
  67. package/dist/resolvers/meetings/index.js +5 -0
  68. package/dist/resolvers/meetings/index.js.map +1 -0
  69. package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
  70. package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
  71. package/dist/resolvers/meetings/meetingHelpers.js +212 -0
  72. package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
  73. package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
  74. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
  75. package/dist/resolvers/meetings/meetingMutations.js +418 -0
  76. package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
  77. package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
  78. package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
  79. package/dist/resolvers/meetings/meetingQueries.js +113 -0
  80. package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
  81. package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
  82. package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
  83. package/dist/resolvers/meetings/meetingTypes.js +345 -0
  84. package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
  85. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  86. package/dist/rest/ViewOperationsHandler.js +8 -5
  87. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  88. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  89. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  90. package/dist/services/TaskGraphActionRunner.js +27 -1
  91. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  92. package/dist/services/WorkQueueHostService.d.ts +36 -0
  93. package/dist/services/WorkQueueHostService.d.ts.map +1 -0
  94. package/dist/services/WorkQueueHostService.js +97 -0
  95. package/dist/services/WorkQueueHostService.js.map +1 -0
  96. package/dist/services/workQueueConfig.d.ts +103 -0
  97. package/dist/services/workQueueConfig.d.ts.map +1 -0
  98. package/dist/services/workQueueConfig.js +48 -0
  99. package/dist/services/workQueueConfig.js.map +1 -0
  100. package/dist/types.d.ts +5 -0
  101. package/dist/types.d.ts.map +1 -1
  102. package/dist/types.js.map +1 -1
  103. package/package.json +95 -94
  104. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  105. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  106. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  107. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  108. package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
  109. package/src/__tests__/MeetingResolver.test.ts +374 -0
  110. package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
  111. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
  112. package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
  113. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
  114. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +41 -0
  115. package/src/__tests__/WorkQueueHostService.test.ts +133 -0
  116. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  117. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  118. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  119. package/src/config.ts +130 -3
  120. package/src/context.ts +11 -24
  121. package/src/generated/graphql-schemas/__mj.ts +22138 -18770
  122. package/src/generic/ResolverBase.ts +171 -27
  123. package/src/generic/RunViewResolver.ts +33 -2
  124. package/src/index.ts +127 -50
  125. package/src/postgresqlCompat.ts +21 -0
  126. package/src/postgresqlPoolSettings.ts +213 -0
  127. package/src/resolvers/AdhocQueryResolver.ts +88 -188
  128. package/src/resolvers/EntityRecordNameResolver.ts +9 -51
  129. package/src/resolvers/GetDataResolver.ts +10 -0
  130. package/src/resolvers/HumanHandoffResolver.ts +304 -0
  131. package/src/resolvers/MeetingResolver.ts +438 -0
  132. package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
  133. package/src/resolvers/RealtimeClientSessionResolver.ts +6 -252
  134. package/src/resolvers/adhocTimeout.ts +12 -0
  135. package/src/resolvers/meetings/index.ts +4 -0
  136. package/src/resolvers/meetings/meetingHelpers.ts +307 -0
  137. package/src/resolvers/meetings/meetingMutations.ts +535 -0
  138. package/src/resolvers/meetings/meetingQueries.ts +172 -0
  139. package/src/resolvers/meetings/meetingTypes.ts +238 -0
  140. package/src/rest/ViewOperationsHandler.ts +8 -5
  141. package/src/services/TaskGraphActionRunner.ts +29 -1
  142. package/src/services/WorkQueueHostService.ts +129 -0
  143. package/src/services/workQueueConfig.ts +52 -0
  144. package/src/types.ts +5 -0
  145. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
  146. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  147. package/dist/resolvers/adhoc-query-helpers.js +0 -50
  148. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  149. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  150. package/src/resolvers/adhoc-query-helpers.ts +0 -67
@@ -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
+