@webex/internal-plugin-metrics 3.12.0-llmrefactor.1 → 3.12.0-llmrefactor.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 (45) hide show
  1. package/README.md +36 -0
  2. package/dist/config.js +3 -0
  3. package/dist/config.js.map +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/metrics.js +1 -1
  6. package/dist/metrics.types.js.map +1 -1
  7. package/dist/new-metrics.js +47 -11
  8. package/dist/new-metrics.js.map +1 -1
  9. package/dist/privacy-and-security-permission-enricher/constants.js +18 -0
  10. package/dist/privacy-and-security-permission-enricher/constants.js.map +1 -0
  11. package/dist/privacy-and-security-permission-enricher/index.js +143 -0
  12. package/dist/privacy-and-security-permission-enricher/index.js.map +1 -0
  13. package/dist/privacy-and-security-permission-enricher/types.js +7 -0
  14. package/dist/privacy-and-security-permission-enricher/types.js.map +1 -0
  15. package/dist/privacy-and-security-permission-enricher/utils.js +71 -0
  16. package/dist/privacy-and-security-permission-enricher/utils.js.map +1 -0
  17. package/dist/types/config.d.ts +3 -0
  18. package/dist/types/index.d.ts +2 -2
  19. package/dist/types/metrics.types.d.ts +3 -0
  20. package/dist/types/new-metrics.d.ts +13 -1
  21. package/dist/types/privacy-and-security-permission-enricher/constants.d.ts +6 -0
  22. package/dist/types/privacy-and-security-permission-enricher/index.d.ts +40 -0
  23. package/dist/types/privacy-and-security-permission-enricher/types.d.ts +14 -0
  24. package/dist/types/privacy-and-security-permission-enricher/utils.d.ts +5 -0
  25. package/dist/types/unhandled-exception-telemetry/index.d.ts +60 -0
  26. package/dist/types/unhandled-exception-telemetry/utils.d.ts +6 -0
  27. package/dist/unhandled-exception-telemetry/index.js +330 -0
  28. package/dist/unhandled-exception-telemetry/index.js.map +1 -0
  29. package/dist/unhandled-exception-telemetry/utils.js +105 -0
  30. package/dist/unhandled-exception-telemetry/utils.js.map +1 -0
  31. package/package.json +8 -8
  32. package/src/config.js +3 -0
  33. package/src/index.ts +2 -0
  34. package/src/metrics.types.ts +9 -0
  35. package/src/new-metrics.ts +44 -2
  36. package/src/privacy-and-security-permission-enricher/constants.ts +33 -0
  37. package/src/privacy-and-security-permission-enricher/index.ts +142 -0
  38. package/src/privacy-and-security-permission-enricher/types.ts +21 -0
  39. package/src/privacy-and-security-permission-enricher/utils.ts +82 -0
  40. package/src/unhandled-exception-telemetry/index.ts +403 -0
  41. package/src/unhandled-exception-telemetry/utils.ts +101 -0
  42. package/test/unit/spec/new-metrics.ts +168 -52
  43. package/test/unit/spec/privacy-and-security-permission-enricher.ts +318 -0
  44. package/test/unit/spec/unhandled-exception-telemetry/utils.ts +109 -0
  45. package/test/unit/spec/unhandled-exception-telemetry.ts +688 -0
@@ -8,20 +8,27 @@ import {
8
8
  import MockWebex from '@webex/test-helper-mock-webex';
9
9
  import sinon from 'sinon';
10
10
 
11
- describe('internal-plugin-metrics', () => {
11
+ import SourceNewMetrics from '@webex/internal-plugin-metrics/src/new-metrics';
12
+ import * as Telemetry from '@webex/internal-plugin-metrics/src/unhandled-exception-telemetry';
12
13
 
13
- const mockWebex = () => new MockWebex({
14
- children: {
15
- newMetrics: NewMetrics,
16
- },
17
- meetings: {
18
- },
19
- request: sinon.stub().resolves({}),
20
- logger: {
21
- log: sinon.stub(),
22
- error: sinon.stub(),
23
- }
24
- });
14
+ describe('internal-plugin-metrics', () => {
15
+ const mockWebex = (NewMetricsPlugin = NewMetrics) =>
16
+ new MockWebex({
17
+ children: {
18
+ newMetrics: NewMetricsPlugin,
19
+ },
20
+ meetings: {
21
+ getBasicMeetingInformation: sinon.stub().callsFake((meetingId) => ({
22
+ id: meetingId,
23
+ correlationId: `correlation-${meetingId}`,
24
+ })),
25
+ },
26
+ request: sinon.stub().resolves({}),
27
+ logger: {
28
+ log: sinon.stub(),
29
+ error: sinon.stub(),
30
+ },
31
+ });
25
32
 
26
33
  describe('check submitClientEvent, submitFeatureEvent when webex is not ready', () => {
27
34
  let webex;
@@ -50,12 +57,14 @@ describe('internal-plugin-metrics', () => {
50
57
  payload: {
51
58
  meetingSummaryInfo: {
52
59
  featureName: 'syncSystemMuteStatus',
53
- featureActions: [{
54
- actionName: 'syncMeetingMicUnmuteStatusToSystem',
55
- actionId: '14200',
56
- isInitialValue: false,
57
- clickCount: '1'
58
- }]
60
+ featureActions: [
61
+ {
62
+ actionName: 'syncMeetingMicUnmuteStatusToSystem',
63
+ actionId: '14200',
64
+ isInitialValue: false,
65
+ clickCount: '1',
66
+ },
67
+ ],
59
68
  },
60
69
  },
61
70
  });
@@ -67,8 +76,11 @@ describe('internal-plugin-metrics', () => {
67
76
  });
68
77
 
69
78
  describe('new-metrics contstructor', () => {
70
- it('checks callDiagnosticLatencies is defined before ready emit', () => {
79
+ afterEach(() => {
80
+ sinon.restore();
81
+ });
71
82
 
83
+ it('checks callDiagnosticLatencies is defined before ready emit', () => {
72
84
  const webex = mockWebex();
73
85
 
74
86
  assert.instanceOf(webex.internal.newMetrics.callDiagnosticLatencies, CallDiagnosticLatencies);
@@ -82,10 +94,12 @@ describe('internal-plugin-metrics', () => {
82
94
 
83
95
  it('can call buildClientEventFetchRequestOptions before ready', async () => {
84
96
  const webex = mockWebex();
85
- const stub = sinon.stub(
86
- webex.internal.newMetrics.callDiagnosticMetrics,
87
- 'buildClientEventFetchRequestOptions'
88
- ).resolves({url: 'https://metrics.example.com'});
97
+ const stub = sinon
98
+ .stub(
99
+ webex.internal.newMetrics.callDiagnosticMetrics,
100
+ 'buildClientEventFetchRequestOptions'
101
+ )
102
+ .resolves({url: 'https://metrics.example.com'});
89
103
 
90
104
  const result = await webex.internal.newMetrics.buildClientEventFetchRequestOptions({
91
105
  name: 'client.alert.displayed',
@@ -99,6 +113,17 @@ describe('internal-plugin-metrics', () => {
99
113
  });
100
114
  assert.deepEqual(result, {url: 'https://metrics.example.com'});
101
115
  });
116
+
117
+ it('starts unhandled exception telemetry when webex is ready', () => {
118
+ const start = sinon.stub(Telemetry, 'startUnhandledExceptionTelemetry');
119
+ const webex = mockWebex(SourceNewMetrics);
120
+
121
+ assert.notCalled(start);
122
+
123
+ webex.emit('ready');
124
+
125
+ assert.calledOnceWithExactly(start, webex);
126
+ });
102
127
  });
103
128
 
104
129
  describe('new-metrics', () => {
@@ -124,7 +149,7 @@ describe('internal-plugin-metrics', () => {
124
149
 
125
150
  afterEach(() => {
126
151
  sinon.restore();
127
- })
152
+ });
128
153
 
129
154
  it('lazy metrics backend initialization when checking if backend ready', () => {
130
155
  assert.isUndefined(webex.internal.newMetrics.behavioralMetrics);
@@ -135,10 +160,10 @@ describe('internal-plugin-metrics', () => {
135
160
  webex.internal.newMetrics.isReadyToSubmitOperationalEvents();
136
161
  assert.isDefined(webex.internal.newMetrics.operationalMetrics);
137
162
 
138
- assert.isUndefined(webex.internal.newMetrics.businessMetrics)
163
+ assert.isUndefined(webex.internal.newMetrics.businessMetrics);
139
164
  webex.internal.newMetrics.isReadyToSubmitBusinessEvents();
140
165
  assert.isDefined(webex.internal.newMetrics.businessMetrics);
141
- })
166
+ });
142
167
 
143
168
  it('returns the automated user classification', () => {
144
169
  assert.strictEqual(
@@ -148,7 +173,7 @@ describe('internal-plugin-metrics', () => {
148
173
  });
149
174
 
150
175
  it('passes the table through to the business metrics', () => {
151
- assert.isUndefined(webex.internal.newMetrics.businessMetrics)
176
+ assert.isUndefined(webex.internal.newMetrics.businessMetrics);
152
177
  webex.internal.newMetrics.isReadyToSubmitBusinessEvents();
153
178
  assert.isDefined(webex.internal.newMetrics.businessMetrics);
154
179
  webex.internal.newMetrics.businessMetrics.submitBusinessEvent = sinon.stub();
@@ -156,14 +181,14 @@ describe('internal-plugin-metrics', () => {
156
181
  name: 'foobar',
157
182
  payload: {},
158
183
  table: 'test',
159
- metadata: { foo: 'bar' },
184
+ metadata: {foo: 'bar'},
160
185
  });
161
186
 
162
187
  assert.calledWith(webex.internal.newMetrics.businessMetrics.submitBusinessEvent, {
163
188
  name: 'foobar',
164
189
  payload: {},
165
190
  table: 'test',
166
- metadata: { foo: 'bar' },
191
+ metadata: {foo: 'bar'},
167
192
  });
168
193
  });
169
194
 
@@ -187,6 +212,87 @@ describe('internal-plugin-metrics', () => {
187
212
  });
188
213
  });
189
214
 
215
+ describe('privacy and security permission enrichment', () => {
216
+ const permission = {
217
+ camera: {status: 'GRANTED' as const},
218
+ microphone: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const},
219
+ contentShare: {status: 'REQUESTING' as const},
220
+ };
221
+
222
+ it('enriches an eligible client event through the public metrics API', () => {
223
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission);
224
+ webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated'});
225
+
226
+ const submittedPayload =
227
+ webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0]
228
+ .payload;
229
+
230
+ assert.deepEqual(submittedPayload.privacyAndSecurityPermission, {
231
+ camera: permission.camera,
232
+ microphone: permission.microphone,
233
+ });
234
+ });
235
+
236
+ it('uses the meeting correlation id to preserve history across identifier transitions', () => {
237
+ webex.meetings.getBasicMeetingInformation
238
+ .withArgs('meeting-1')
239
+ .returns({id: 'meeting-1', correlationId: 'correlation-1'});
240
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission);
241
+
242
+ webex.internal.newMetrics.submitClientEvent({
243
+ name: 'client.call.initiated',
244
+ options: {correlationId: 'correlation-1'},
245
+ });
246
+ webex.internal.newMetrics.submitClientEvent({
247
+ name: 'client.ice.end',
248
+ payload: {},
249
+ options: {meetingId: 'meeting-1'},
250
+ });
251
+
252
+ const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args;
253
+
254
+ assert.property(submissions[0][0].payload, 'privacyAndSecurityPermission');
255
+ assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission');
256
+ });
257
+
258
+ it('uses the default scope when no correlation id can be resolved', () => {
259
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission);
260
+
261
+ webex.internal.newMetrics.submitClientEvent({
262
+ name: 'client.call.initiated',
263
+ options: {sessionCorrelationId: 'session-1'},
264
+ });
265
+ webex.internal.newMetrics.submitClientEvent({
266
+ name: 'client.ice.end',
267
+ payload: {},
268
+ options: {sessionCorrelationId: 'session-2'},
269
+ });
270
+
271
+ const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args;
272
+
273
+ assert.property(submissions[0][0].payload, 'privacyAndSecurityPermission');
274
+ assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission');
275
+ });
276
+
277
+ it('captures the permission snapshot before a delayed event is queued', () => {
278
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission);
279
+ webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: true});
280
+ webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated'});
281
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission({
282
+ camera: {status: 'DENIED' as const},
283
+ });
284
+
285
+ const submission =
286
+ webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0];
287
+
288
+ assert.isTrue(submission.delaySubmitEvent);
289
+ assert.deepEqual(submission.payload.privacyAndSecurityPermission, {
290
+ camera: permission.camera,
291
+ microphone: permission.microphone,
292
+ });
293
+ });
294
+ });
295
+
190
296
  it('submits feature Event successfully', () => {
191
297
  webex.internal.newMetrics.submitFeatureEvent({
192
298
  name: 'client.feature.meeting.summary',
@@ -196,12 +302,14 @@ describe('internal-plugin-metrics', () => {
196
302
  payload: {
197
303
  meetingSummaryInfo: {
198
304
  featureName: 'syncSystemMuteStatus',
199
- featureActions: [{
200
- actionName: 'syncMeetingMicUnmuteStatusToSystem',
201
- actionId: '14200',
202
- isInitialValue: false,
203
- clickCount: '1'
204
- }]
305
+ featureActions: [
306
+ {
307
+ actionName: 'syncMeetingMicUnmuteStatusToSystem',
308
+ actionId: '14200',
309
+ isInitialValue: false,
310
+ clickCount: '1',
311
+ },
312
+ ],
205
313
  },
206
314
  },
207
315
  });
@@ -215,12 +323,14 @@ describe('internal-plugin-metrics', () => {
215
323
  payload: {
216
324
  meetingSummaryInfo: {
217
325
  featureName: 'syncSystemMuteStatus',
218
- featureActions: [{
219
- actionName: 'syncMeetingMicUnmuteStatusToSystem',
220
- actionId: '14200',
221
- isInitialValue: false,
222
- clickCount: '1'
223
- }]
326
+ featureActions: [
327
+ {
328
+ actionName: 'syncMeetingMicUnmuteStatusToSystem',
329
+ actionId: '14200',
330
+ isInitialValue: false,
331
+ clickCount: '1',
332
+ },
333
+ ],
224
334
  },
225
335
  },
226
336
  options: {meetingId: '123'},
@@ -281,9 +391,9 @@ describe('internal-plugin-metrics', () => {
281
391
  method: 'POST',
282
392
  api: 'metrics',
283
393
  resource: 'clientmetrics',
284
- headers: { 'x-prelogin-userid': 'my-id' },
394
+ headers: {'x-prelogin-userid': 'my-id'},
285
395
  body: {},
286
- qs: { alias: true },
396
+ qs: {alias: true},
287
397
  });
288
398
  assert.calledWith(
289
399
  webex.logger.log,
@@ -292,8 +402,8 @@ describe('internal-plugin-metrics', () => {
292
402
  });
293
403
 
294
404
  it('handles failed request correctly', async () => {
295
- webex.request.rejects(new Error("test error"));
296
- sinon.stub(Utils, 'generateCommonErrorMetadata').returns('formattedError')
405
+ webex.request.rejects(new Error('test error'));
406
+ sinon.stub(Utils, 'generateCommonErrorMetadata').returns('formattedError');
297
407
  try {
298
408
  await webex.internal.newMetrics.clientMetricsAliasUser({event: 'test'}, 'my-id');
299
409
  } catch (err) {
@@ -375,19 +485,26 @@ describe('internal-plugin-metrics', () => {
375
485
  sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, true);
376
486
  sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {});
377
487
 
378
- webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false, overrides: {foo: 'bar'}});
488
+ webex.internal.newMetrics.setDelaySubmitClientEvents({
489
+ shouldDelay: false,
490
+ overrides: {foo: 'bar'},
491
+ });
379
492
 
380
- assert.calledOnce(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents);
381
- assert.calledWith(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents, {foo: 'bar'});
493
+ assert.calledOnce(
494
+ webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents
495
+ );
496
+ assert.calledWith(
497
+ webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents,
498
+ {foo: 'bar'}
499
+ );
382
500
 
383
501
  sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, false);
384
502
  sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {foo: 'bar'});
385
503
  });
386
504
 
387
505
  it('should not fail when called before webex is ready', () => {
388
-
389
506
  // Create mock
390
- webex = mockWebex()
507
+ webex = mockWebex();
391
508
 
392
509
  webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub();
393
510
  webex.internal.newMetrics.callDiagnosticLatencies.clearTimestamps = sinon.stub();
@@ -402,7 +519,6 @@ describe('internal-plugin-metrics', () => {
402
519
  webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false});
403
520
  // Webex is ready
404
521
  webex.emit('ready');
405
-
406
522
  });
407
523
  });
408
524
  });
@@ -0,0 +1,318 @@
1
+ import {assert} from '@webex/test-helper-chai';
2
+ import sinon from 'sinon';
3
+
4
+ import type {ClientEvent, PrivacyAndSecurityPermission} from '../../../src/metrics.types';
5
+ import PrivacyAndSecurityPermissionEnricher, {
6
+ PERMISSION_ENRICHMENT_RULES,
7
+ } from '../../../src/privacy-and-security-permission-enricher';
8
+
9
+ describe('PrivacyAndSecurityPermissionEnricher', () => {
10
+ const permission: PrivacyAndSecurityPermission = {
11
+ camera: {status: 'GRANTED'},
12
+ microphone: {status: 'DENIED', reason: 'DENIED_BY_USER'},
13
+ contentShare: {status: 'REQUESTING'},
14
+ };
15
+ let onEnrichmentError: sinon.SinonStub;
16
+ let enricher: PrivacyAndSecurityPermissionEnricher;
17
+
18
+ beforeEach(() => {
19
+ onEnrichmentError = sinon.stub();
20
+ enricher = new PrivacyAndSecurityPermissionEnricher(onEnrichmentError);
21
+ });
22
+
23
+ afterEach(() => {
24
+ sinon.restore();
25
+ });
26
+
27
+ describe('permission enrichment rules', () => {
28
+ it('registers each production event in only one rule', () => {
29
+ const registeredEvents = PERMISSION_ENRICHMENT_RULES.flatMap(({events}) => [...events]);
30
+
31
+ assert.lengthOf(new Set(registeredEvents), registeredEvents.length);
32
+ });
33
+
34
+ [
35
+ 'client.call.initiated',
36
+ 'client.media.capabilities',
37
+ 'client.ice.end',
38
+ 'client.locus.join.request',
39
+ 'client.locus.join.response',
40
+ 'client.media-engine.ready',
41
+ ].forEach((name) => {
42
+ it(`projects camera and microphone for ${name}`, () => {
43
+ enricher.setPermission(permission);
44
+
45
+ const payload = enricher.enrich({name: name as ClientEvent['name'], scope: 'meeting'});
46
+
47
+ assert.deepEqual(payload?.privacyAndSecurityPermission, {
48
+ camera: permission.camera,
49
+ microphone: permission.microphone,
50
+ });
51
+ });
52
+ });
53
+
54
+ ['client.media.tx.start', 'client.media.tx.stop'].forEach((name) => {
55
+ [
56
+ {mediaType: 'audio' as const, expected: {microphone: permission.microphone}},
57
+ {mediaType: 'video' as const, expected: {camera: permission.camera}},
58
+ {mediaType: 'share' as const, expected: {contentShare: permission.contentShare}},
59
+ ].forEach(({mediaType, expected}) => {
60
+ it(`projects ${mediaType} permission for ${name}`, () => {
61
+ enricher.setPermission(permission);
62
+
63
+ const payload = enricher.enrich({
64
+ name: name as ClientEvent['name'],
65
+ payload: {mediaType},
66
+ scope: 'meeting',
67
+ });
68
+
69
+ assert.deepEqual(payload?.privacyAndSecurityPermission, expected);
70
+ });
71
+ });
72
+ });
73
+
74
+ [
75
+ 'client.share.initiated',
76
+ 'client.share.floor-grant.request',
77
+ 'client.share.floor-granted.local',
78
+ ].forEach((name) => {
79
+ it(`projects content-share permission for ${name}`, () => {
80
+ enricher.setPermission(permission);
81
+
82
+ const payload = enricher.enrich({
83
+ name: name as ClientEvent['name'],
84
+ payload: {mediaType: 'share'},
85
+ scope: 'meeting',
86
+ });
87
+
88
+ assert.deepEqual(payload?.privacyAndSecurityPermission, {
89
+ contentShare: permission.contentShare,
90
+ });
91
+ });
92
+ });
93
+
94
+ ['client.call.leave', 'client.call.remote-ended', 'client.call.aborted'].forEach((name) => {
95
+ it(`projects every available permission for ${name}`, () => {
96
+ enricher.setPermission(permission);
97
+
98
+ const payload = enricher.enrich({name: name as ClientEvent['name'], scope: 'meeting'});
99
+
100
+ assert.deepEqual(payload?.privacyAndSecurityPermission, permission);
101
+ });
102
+ });
103
+ });
104
+
105
+ describe('setPermission', () => {
106
+ it('stores a defensive copy of the permission snapshot', () => {
107
+ const mutablePermission: PrivacyAndSecurityPermission = {
108
+ camera: {status: 'GRANTED'},
109
+ };
110
+
111
+ enricher.setPermission(mutablePermission);
112
+ mutablePermission.camera = {status: 'DENIED', reason: 'DENIED_BY_USER'};
113
+
114
+ const payload = enricher.enrich({name: 'client.call.initiated', scope: 'meeting'});
115
+
116
+ assert.deepEqual(payload?.privacyAndSecurityPermission, {
117
+ camera: {status: 'GRANTED'},
118
+ });
119
+ });
120
+ });
121
+
122
+ describe('enrich', () => {
123
+ it('does not mutate the input payload or stored permission state', () => {
124
+ const inputPayload = {mediaType: 'audio' as const};
125
+
126
+ enricher.setPermission(permission);
127
+ const payload = enricher.enrich({
128
+ name: 'client.call.initiated',
129
+ payload: inputPayload,
130
+ scope: 'meeting',
131
+ });
132
+
133
+ assert.notStrictEqual(payload, inputPayload);
134
+ assert.notProperty(inputPayload, 'privacyAndSecurityPermission');
135
+ assert.notStrictEqual(payload?.privacyAndSecurityPermission?.camera, permission.camera);
136
+ });
137
+
138
+ it('returns the original payload when the requested permission is unavailable', () => {
139
+ const inputPayload = {mediaType: 'audio' as const};
140
+
141
+ enricher.setPermission({camera: permission.camera});
142
+ const payload = enricher.enrich({
143
+ name: 'client.media.tx.start',
144
+ payload: inputPayload,
145
+ scope: 'meeting',
146
+ });
147
+
148
+ assert.strictEqual(payload, inputPayload);
149
+ });
150
+
151
+ it('returns the original payload for unsupported share media types', () => {
152
+ const inputPayload = {mediaType: 'whiteboard' as const};
153
+
154
+ enricher.setPermission(permission);
155
+ const payload = enricher.enrich({
156
+ name: 'client.share.initiated',
157
+ payload: inputPayload,
158
+ scope: 'meeting',
159
+ });
160
+
161
+ assert.strictEqual(payload, inputPayload);
162
+ });
163
+
164
+ it('reports initial permission once and suppresses an unchanged later event', () => {
165
+ enricher.setPermission(permission);
166
+
167
+ const initialPayload = enricher.enrich({name: 'client.call.initiated', scope: 'meeting'});
168
+ const unchangedPayload = enricher.enrich({name: 'client.ice.end', scope: 'meeting'});
169
+
170
+ assert.property(initialPayload, 'privacyAndSecurityPermission');
171
+ assert.isUndefined(unchangedPayload);
172
+ });
173
+
174
+ it('reports only permission resources that changed', () => {
175
+ enricher.setPermission(permission);
176
+ enricher.enrich({name: 'client.call.initiated', scope: 'meeting'});
177
+ enricher.setPermission({
178
+ ...permission,
179
+ camera: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'},
180
+ });
181
+
182
+ const payload = enricher.enrich({name: 'client.ice.end', scope: 'meeting'});
183
+
184
+ assert.deepEqual(payload?.privacyAndSecurityPermission, {
185
+ camera: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'},
186
+ });
187
+ });
188
+
189
+ it('reports a changed reason when status is unchanged', () => {
190
+ enricher.setPermission({microphone: {status: 'DENIED', reason: 'DENIED_BY_USER'}});
191
+ enricher.enrich({
192
+ name: 'client.media.tx.start',
193
+ payload: {mediaType: 'audio'},
194
+ scope: 'meeting',
195
+ });
196
+ enricher.setPermission({microphone: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'}});
197
+
198
+ const payload = enricher.enrich({
199
+ name: 'client.media.tx.stop',
200
+ payload: {mediaType: 'audio'},
201
+ scope: 'meeting',
202
+ });
203
+
204
+ assert.deepEqual(payload?.privacyAndSecurityPermission, {
205
+ microphone: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'},
206
+ });
207
+ });
208
+
209
+ it('shares resource history across enrichment rules', () => {
210
+ enricher.setPermission(permission);
211
+ enricher.enrich({name: 'client.call.initiated', scope: 'meeting'});
212
+
213
+ const payload = enricher.enrich({
214
+ name: 'client.media.tx.start',
215
+ payload: {mediaType: 'audio'},
216
+ scope: 'meeting',
217
+ });
218
+
219
+ assert.notProperty(payload, 'privacyAndSecurityPermission');
220
+ });
221
+
222
+ it('tracks permission history independently by scope', () => {
223
+ enricher.setPermission(permission);
224
+
225
+ const firstMeeting = enricher.enrich({name: 'client.call.initiated', scope: 'meeting-1'});
226
+ const secondMeeting = enricher.enrich({name: 'client.call.initiated', scope: 'meeting-2'});
227
+
228
+ assert.property(firstMeeting, 'privacyAndSecurityPermission');
229
+ assert.property(secondMeeting, 'privacyAndSecurityPermission');
230
+ });
231
+
232
+ it('clears only the terminal event scope', () => {
233
+ enricher.setPermission(permission);
234
+ enricher.enrich({name: 'client.call.initiated', scope: 'meeting-1'});
235
+ enricher.enrich({name: 'client.call.initiated', scope: 'meeting-2'});
236
+ enricher.enrich({name: 'client.call.leave', scope: 'meeting-1'});
237
+
238
+ const reusedScope = enricher.enrich({name: 'client.call.initiated', scope: 'meeting-1'});
239
+ const activeScope = enricher.enrich({name: 'client.ice.end', scope: 'meeting-2'});
240
+
241
+ assert.property(reusedScope, 'privacyAndSecurityPermission');
242
+ assert.isUndefined(activeScope);
243
+ });
244
+
245
+ it('preserves an explicit permission payload and uses it as the baseline', () => {
246
+ const explicitPayload = {
247
+ privacyAndSecurityPermission: {
248
+ camera: permission.camera,
249
+ microphone: permission.microphone,
250
+ },
251
+ };
252
+
253
+ enricher.setPermission(permission);
254
+ const returnedPayload = enricher.enrich({
255
+ name: 'client.call.initiated',
256
+ payload: explicitPayload,
257
+ scope: 'meeting',
258
+ });
259
+ const laterPayload = enricher.enrich({name: 'client.ice.end', scope: 'meeting'});
260
+
261
+ assert.strictEqual(returnedPayload, explicitPayload);
262
+ assert.isUndefined(laterPayload);
263
+ });
264
+
265
+ it('clears history after an explicit terminal payload', () => {
266
+ enricher.setPermission(permission);
267
+ enricher.enrich({name: 'client.call.initiated', scope: 'meeting'});
268
+ enricher.enrich({
269
+ name: 'client.call.leave',
270
+ payload: {privacyAndSecurityPermission: permission},
271
+ scope: 'meeting',
272
+ });
273
+
274
+ const payload = enricher.enrich({name: 'client.call.initiated', scope: 'meeting'});
275
+
276
+ assert.property(payload, 'privacyAndSecurityPermission');
277
+ });
278
+
279
+ it('returns unchanged before permission state is supplied', () => {
280
+ const inputPayload = {mediaType: 'audio' as const};
281
+
282
+ const payload = enricher.enrich({
283
+ name: 'client.call.initiated',
284
+ payload: inputPayload,
285
+ scope: 'meeting',
286
+ });
287
+
288
+ assert.strictEqual(payload, inputPayload);
289
+ });
290
+
291
+ it('does not enrich unrelated events', () => {
292
+ enricher.setPermission(permission);
293
+
294
+ const payload = enricher.enrich({name: 'client.alert.displayed', scope: 'meeting'});
295
+
296
+ assert.isUndefined(payload);
297
+ });
298
+
299
+ it('reports enrichment errors and returns the original payload', () => {
300
+ const inputPayload = {};
301
+
302
+ Object.defineProperty(inputPayload, 'privacyAndSecurityPermission', {
303
+ get: () => {
304
+ throw new Error('permission read failed');
305
+ },
306
+ });
307
+
308
+ const payload = enricher.enrich({
309
+ name: 'client.call.initiated',
310
+ payload: inputPayload,
311
+ scope: 'meeting',
312
+ });
313
+
314
+ assert.strictEqual(payload, inputPayload);
315
+ assert.calledOnce(onEnrichmentError);
316
+ });
317
+ });
318
+ });