@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.
- package/README.md +30 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/config.d.ts +2189 -71
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +123 -3
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +11 -23
- package/dist/context.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +928 -0
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +8482 -3649
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +43 -3
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +142 -27
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +4 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +33 -1
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +115 -49
- package/dist/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +77 -167
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +9 -47
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +9 -0
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
- package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
- package/dist/resolvers/HumanHandoffResolver.js +324 -0
- package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
- package/dist/resolvers/MeetingResolver.d.ts +69 -0
- package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
- package/dist/resolvers/MeetingResolver.js +381 -0
- package/dist/resolvers/MeetingResolver.js.map +1 -0
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +1 -83
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +4 -206
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/resolvers/meetings/index.d.ts +5 -0
- package/dist/resolvers/meetings/index.d.ts.map +1 -0
- package/dist/resolvers/meetings/index.js +5 -0
- package/dist/resolvers/meetings/index.js.map +1 -0
- package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
- package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingHelpers.js +212 -0
- package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
- package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
- package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingMutations.js +418 -0
- package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
- package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
- package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingQueries.js +113 -0
- package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
- package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
- package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingTypes.js +345 -0
- package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +8 -5
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +8 -0
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +27 -1
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/dist/services/WorkQueueHostService.d.ts +36 -0
- package/dist/services/WorkQueueHostService.d.ts.map +1 -0
- package/dist/services/WorkQueueHostService.js +97 -0
- package/dist/services/WorkQueueHostService.js.map +1 -0
- package/dist/services/workQueueConfig.d.ts +103 -0
- package/dist/services/workQueueConfig.d.ts.map +1 -0
- package/dist/services/workQueueConfig.js +48 -0
- package/dist/services/workQueueConfig.js.map +1 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +95 -94
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
- package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
- package/src/__tests__/MeetingResolver.test.ts +374 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
- package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +41 -0
- package/src/__tests__/WorkQueueHostService.test.ts +133 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/config.ts +130 -3
- package/src/context.ts +11 -24
- package/src/generated/graphql-schemas/__mj.ts +22138 -18770
- package/src/generic/ResolverBase.ts +171 -27
- package/src/generic/RunViewResolver.ts +33 -2
- package/src/index.ts +127 -50
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/resolvers/AdhocQueryResolver.ts +88 -188
- package/src/resolvers/EntityRecordNameResolver.ts +9 -51
- package/src/resolvers/GetDataResolver.ts +10 -0
- package/src/resolvers/HumanHandoffResolver.ts +304 -0
- package/src/resolvers/MeetingResolver.ts +438 -0
- package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
- package/src/resolvers/RealtimeClientSessionResolver.ts +6 -252
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/resolvers/meetings/index.ts +4 -0
- package/src/resolvers/meetings/meetingHelpers.ts +307 -0
- package/src/resolvers/meetings/meetingMutations.ts +535 -0
- package/src/resolvers/meetings/meetingQueries.ts +172 -0
- package/src/resolvers/meetings/meetingTypes.ts +238 -0
- package/src/rest/ViewOperationsHandler.ts +8 -5
- package/src/services/TaskGraphActionRunner.ts +29 -1
- package/src/services/WorkQueueHostService.ts +129 -0
- package/src/services/workQueueConfig.ts +52 -0
- package/src/types.ts +5 -0
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -50
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- 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`
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
15
|
+
const ACTING_USER = { ID: 'u-1', Email: 'restricted@test' } as unknown as UserInfo;
|
|
25
16
|
|
|
26
|
-
|
|
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
|
|
42
|
-
function
|
|
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: [
|
|
45
|
-
|
|
46
|
-
}
|
|
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 —
|
|
61
|
-
it('
|
|
62
|
-
const getName =
|
|
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
|
-
|
|
43
|
+
await new Probe().Inner(md, 'Clients', KEY, ACTING_USER);
|
|
66
44
|
|
|
67
|
-
expect(
|
|
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
|
|
76
|
-
const md =
|
|
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,
|
|
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('
|
|
85
|
-
|
|
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,
|
|
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('
|
|
103
|
-
const
|
|
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
|
-
|
|
109
|
-
});
|
|
69
|
+
const result = await new Probe().Inner(md, 'Clients', KEY, ACTING_USER);
|
|
110
70
|
|
|
111
|
-
|
|
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
|
+
|