@webex/internal-plugin-ai-assistant 0.0.0-next.1

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.
@@ -0,0 +1,363 @@
1
+ /*!
2
+ * Copyright (c) 2015-2022 Cisco Systems, Inc. See LICENSE file.
3
+ */
4
+ /* eslint-disable no-underscore-dangle */
5
+ import chai from 'chai';
6
+ import chaiAsPromised from 'chai-as-promised';
7
+ import {assert, expect} from '@webex/test-helper-chai';
8
+ import AIAssistant from '@webex/internal-plugin-ai-assistant';
9
+ import MockWebex from '@webex/test-helper-mock-webex';
10
+ import sinon from 'sinon';
11
+ import {set} from 'lodash';
12
+ import uuid from 'uuid';
13
+ import {Timer} from '@webex/common-timers';
14
+ import config from '@webex/internal-plugin-ai-assistant/src/config';
15
+ import { AI_ASSISTANT_ERROR_CODES, AI_ASSISTANT_ERRORS } from '@webex/internal-plugin-ai-assistant/src/constants';
16
+
17
+ const waitForAsync = () =>
18
+ new Promise<void>((resolve) =>
19
+ setImmediate(() => {
20
+ return resolve();
21
+ })
22
+ );
23
+
24
+
25
+ chai.use(chaiAsPromised);
26
+ describe('plugin-ai-assistant', () => {
27
+ describe('AIAssistant', () => {
28
+ let webex;
29
+ let uuidStub;
30
+ let mercuryCallbacks;
31
+ let clock;
32
+ let timerSpy;
33
+
34
+ beforeEach(() => {
35
+ webex = MockWebex({
36
+ canAuthorize: false,
37
+ children: {
38
+ aiAssistant: AIAssistant,
39
+ },
40
+ });
41
+
42
+ // Set up default configuration
43
+ webex.config.aiassistant = config;
44
+
45
+ uuidStub = sinon.stub(uuid, 'v4').returns('test-request-id');
46
+
47
+ webex.canAuthorize = true;
48
+
49
+ mercuryCallbacks = {};
50
+
51
+ webex.internal.mercury = {
52
+ connect: sinon.stub().returns(Promise.resolve()),
53
+ disconnect: sinon.stub().returns(Promise.resolve()),
54
+ on: sinon.stub().callsFake((event, callback) => {
55
+ mercuryCallbacks[event] = callback;
56
+ }),
57
+ off: sinon.spy(),
58
+ };
59
+
60
+ clock = sinon.useFakeTimers();
61
+
62
+ // Stub Timer so we can control it in tests
63
+ timerSpy = sinon.spy(Timer.prototype, 'start');
64
+ });
65
+
66
+ afterEach(() => {
67
+ uuidStub.restore();
68
+ clock.restore();
69
+ if (timerSpy) {
70
+ timerSpy.restore();
71
+ }
72
+ });
73
+
74
+ describe('#register()', () => {
75
+ it('registers correctly', async () => {
76
+ await webex.internal.aiAssistant.register();
77
+
78
+ assert.callCount(webex.internal.mercury.on, 1);
79
+
80
+ const callArgs = webex.internal.mercury.on.getCall(0).args;
81
+
82
+ expect(callArgs[0]).to.equal('event:assistant-api.response');
83
+ expect(callArgs[1]).to.be.a('function');
84
+
85
+ assert.equal(webex.internal.aiAssistant.registered, true);
86
+ });
87
+
88
+ it('rejects when it cannot authorize', async () => {
89
+ webex.canAuthorize = false;
90
+
91
+ await expect(webex.internal.aiAssistant.register()).to.be.rejectedWith(
92
+ Error,
93
+ 'SDK cannot authorize'
94
+ );
95
+
96
+ assert.equal(webex.internal.aiAssistant.registered, false);
97
+ });
98
+
99
+ it('resolves immediately when already registered', async () => {
100
+ webex.internal.aiAssistant.registered = true;
101
+
102
+ await webex.internal.aiAssistant.register();
103
+
104
+ assert.callCount(webex.internal.mercury.connect, 0);
105
+ assert.equal(webex.internal.aiAssistant.registered, true);
106
+ });
107
+ });
108
+
109
+ describe('#unregister()', () => {
110
+ it('unregisters correctly', async () => {
111
+ webex.internal.aiAssistant.registered = true;
112
+
113
+ await webex.internal.aiAssistant.unregister();
114
+
115
+ assert.callCount(webex.internal.mercury.off, 1);
116
+
117
+ const callArgs = webex.internal.mercury.off.getCall(0).args;
118
+
119
+ expect(callArgs[0]).to.equal('event:assistant-api.response');
120
+
121
+ assert.equal(webex.internal.aiAssistant.registered, false);
122
+ });
123
+
124
+ it('resolves immediately when not registered', async () => {
125
+ webex.internal.aiAssistant.registered = false;
126
+
127
+ const result = await webex.internal.aiAssistant.unregister();
128
+
129
+ expect(result).to.be.undefined;
130
+ assert.callCount(webex.internal.mercury.disconnect, 0);
131
+ assert.equal(webex.internal.aiAssistant.registered, false);
132
+ });
133
+ });
134
+
135
+ // Interface for the data object used in testing events
136
+ interface AssistantEventData {
137
+ clientRequestId: any;
138
+ response: {
139
+ errorMessage?: string;
140
+ errorCode?: string;
141
+ [key: string]: any;
142
+ };
143
+ finished?: boolean;
144
+ [key: string]: any;
145
+ }
146
+
147
+ // Helper function to create data objects for testing events
148
+ const createData = (
149
+ requestId: any,
150
+ finished?: boolean,
151
+ dataPath?: string,
152
+ value?: any,
153
+ encryptionKeyUrl?: string,
154
+ errorMessage?: string,
155
+ errorCode?: string
156
+ ): AssistantEventData => {
157
+ const data: AssistantEventData = {
158
+ clientRequestId: requestId,
159
+ response: {}
160
+ };
161
+
162
+ if (finished !== undefined) {
163
+ data.finished = finished;
164
+ }
165
+
166
+ if (value !== undefined && encryptionKeyUrl !== undefined) {
167
+ set(data, dataPath, {
168
+ value,
169
+ encryptionKeyUrl
170
+ });
171
+ }
172
+
173
+ if (errorMessage !== undefined) {
174
+ data.response.errorMessage = errorMessage;
175
+ }
176
+
177
+ if (errorCode !== undefined) {
178
+ data.response.errorCode = errorCode;
179
+ }
180
+
181
+ return data;
182
+ };
183
+
184
+ describe('#_request', () => {
185
+ beforeEach(() => {
186
+ webex.request = sinon.stub().resolves({
187
+ body: {
188
+ sessionId: 'test-session-id'
189
+ }
190
+ });
191
+
192
+ // Mock encryption functions
193
+ webex.internal.encryption = {
194
+ decryptText: sinon.stub().callsFake(async (keyUrl, value) => {
195
+ return `decrypted-${value}`;
196
+ })
197
+ };
198
+ });
199
+
200
+ it('makes a request to the assistant API', async () => {
201
+ const requestPromise = webex.internal.aiAssistant._request({
202
+ resource: 'test-resource',
203
+ params: { param1: 'value1' },
204
+ dataPath: 'response.content'
205
+ });
206
+
207
+ expect(webex.request.getCall(0).args[0]).to.deep.equal({
208
+ service: 'assistant-api',
209
+ resource: 'test-resource',
210
+ method: 'POST',
211
+ contentType: 'application/json',
212
+ body: {
213
+ clientRequestId: 'test-request-id',
214
+ param1: 'value1'
215
+ }
216
+ });
217
+
218
+ const result = await requestPromise;
219
+
220
+ expect(result).to.deep.equal({
221
+ requestId: 'test-request-id',
222
+ sessionId: 'test-session-id',
223
+ streamEventName: 'aiassistant:stream:test-request-id'
224
+ });
225
+ });
226
+
227
+ it('decrypts and emits data when receiving event data', async () => {
228
+ const triggerSpy = sinon.spy(webex.internal.aiAssistant, 'trigger');
229
+
230
+ await webex.internal.aiAssistant._request({
231
+ resource: 'test-resource',
232
+ params: { param1: 'value1' },
233
+ dataPath: 'response.content'
234
+ });
235
+
236
+ // Simulate mercury event with data
237
+ webex.internal.aiAssistant._handleEvent(
238
+ createData('test-request-id', true, 'response.content', 'test-value', 'test-key-url')
239
+ );
240
+
241
+ await waitForAsync();
242
+
243
+ // Verify decryption was called
244
+ expect(webex.internal.encryption.decryptText.calledOnce).to.be.true;
245
+ expect(webex.internal.encryption.decryptText.getCall(0).args).to.deep.equal([
246
+ 'test-key-url',
247
+ 'test-value'
248
+ ]);
249
+
250
+ // Verify event was triggered with decrypted data
251
+ expect(triggerSpy.calledTwice).to.be.true; // Called once for streamEvent, once for resultEvent
252
+ expect(triggerSpy.getCall(1).args[0]).to.equal('aiassistant:stream:test-request-id');
253
+ expect(triggerSpy.getCall(1).args[1]).to.deep.include({
254
+ message: 'decrypted-test-value',
255
+ requestId: 'test-request-id',
256
+ finished: true,
257
+ });
258
+ });
259
+
260
+ it('concatenates streamed messages for non-finished events', async () => {
261
+ const triggerSpy = sinon.spy(webex.internal.aiAssistant, 'trigger');
262
+
263
+ await webex.internal.aiAssistant._request({
264
+ resource: 'test-resource',
265
+ params: { param1: 'value1' },
266
+ dataPath: 'response.content'
267
+ });
268
+
269
+ // Simulate first message chunk
270
+ webex.internal.aiAssistant._handleEvent(
271
+ createData('test-request-id', false, 'response.content', 'first-part-', 'test-key-url')
272
+ );
273
+
274
+ await waitForAsync();
275
+
276
+ // Check first chunk
277
+ expect(triggerSpy.getCall(1).args[0]).to.equal('aiassistant:stream:test-request-id');
278
+ expect(triggerSpy.getCall(1).args[1]).to.deep.include({
279
+ message: 'decrypted-first-part-',
280
+ finished: false
281
+ });
282
+
283
+ // Simulate second message chunk
284
+ webex.internal.aiAssistant._handleEvent(
285
+ createData('test-request-id', false, 'response.content', 'second-part', 'test-key-url')
286
+ );
287
+
288
+ await waitForAsync();
289
+
290
+ // Check second chunk - should include first and second part concatenated
291
+ expect(triggerSpy.getCall(3).args[0]).to.equal('aiassistant:stream:test-request-id');
292
+ expect(triggerSpy.getCall(3).args[1]).to.deep.include({
293
+ message: 'decrypted-first-part-decrypted-second-part',
294
+ finished: false
295
+ });
296
+
297
+ // Simulate final message
298
+ webex.internal.aiAssistant._handleEvent(
299
+ createData('test-request-id', true, 'response.content', 'final-part', 'test-key-url')
300
+ );
301
+
302
+ await waitForAsync();
303
+
304
+ // Check all trigger calls - first two should have concatenated message
305
+ expect(triggerSpy.callCount).to.equal(6); // Three event pairs for result and stream
306
+
307
+ // Check final message - stops concatenation, only returns the final version
308
+ expect(triggerSpy.getCall(5).args[0]).to.equal('aiassistant:stream:test-request-id');
309
+ expect(triggerSpy.getCall(5).args[1]).to.deep.include({
310
+ message: 'decrypted-final-part',
311
+ finished: true
312
+ });
313
+ });
314
+
315
+ it('rejects with AIAssistantTimeoutError when server does not respond in time', async () => {
316
+ const triggerSpy = sinon.spy(webex.internal.aiAssistant, 'trigger');
317
+ await webex.internal.aiAssistant._request({
318
+ resource: 'test-resource',
319
+ params: { param1: 'value1' },
320
+ dataPath: 'response.content'
321
+ });
322
+
323
+ // Advance the clock past the timeout
324
+ await clock.tickAsync(30001); // Default timeout + 1ms
325
+
326
+ await waitForAsync();
327
+
328
+ expect(triggerSpy.getCall(0).args[0]).to.equal('aiassistant:stream:test-request-id');
329
+ expect(triggerSpy.getCall(0).args[1]).to.deep.include({
330
+ requestId: 'test-request-id',
331
+ finished: true,
332
+ errorMessage: AI_ASSISTANT_ERRORS.AI_ASSISTANT_TIMEOUT,
333
+ errorCode: AI_ASSISTANT_ERROR_CODES.AI_ASSISTANT_TIMEOUT
334
+ });
335
+ });
336
+
337
+ it('includes error information when server returns an error', async () => {
338
+ const triggerSpy = sinon.spy(webex.internal.aiAssistant, 'trigger');
339
+
340
+ await webex.internal.aiAssistant._request({
341
+ resource: 'test-resource',
342
+ params: { param1: 'value1' },
343
+ dataPath: 'response.content'
344
+ });
345
+
346
+ // Simulate mercury event with error
347
+ webex.internal.aiAssistant._handleEvent(
348
+ createData('test-request-id', true, null, null, null, 'Error message', 'ERROR_CODE')
349
+ );
350
+
351
+ expect(triggerSpy.calledTwice).to.be.true;
352
+ expect(triggerSpy.getCall(1).args[0]).to.equal('aiassistant:stream:test-request-id');
353
+ expect(triggerSpy.getCall(1).args[1]).to.deep.include({
354
+ message: '',
355
+ requestId: 'test-request-id',
356
+ finished: true,
357
+ errorMessage: 'Error message',
358
+ errorCode: 'ERROR_CODE'
359
+ });
360
+ });
361
+ });
362
+ });
363
+ });