@webex/plugin-meetings 3.12.0-next.50 → 3.12.0-next.51

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.
@@ -43,14 +43,21 @@ type WebexRequestMethod = (options: Record<string, any>) => Promise<any>;
43
43
  export declare const LocusInfoUpdateType: {
44
44
  readonly OBJECTS_UPDATED: "OBJECTS_UPDATED";
45
45
  readonly MEETING_ENDED: "MEETING_ENDED";
46
+ readonly LOCUS_NOT_FOUND: "LOCUS_NOT_FOUND";
46
47
  };
47
48
  export type LocusInfoUpdateType = Enum<typeof LocusInfoUpdateType>;
49
+ interface LocusUpdatePayloads {
50
+ [LocusInfoUpdateType.OBJECTS_UPDATED]: {
51
+ updatedObjects: HashTreeObject[];
52
+ };
53
+ [LocusInfoUpdateType.MEETING_ENDED]: unknown;
54
+ [LocusInfoUpdateType.LOCUS_NOT_FOUND]: unknown;
55
+ }
48
56
  export type LocusInfoUpdate = {
49
- updateType: typeof LocusInfoUpdateType.OBJECTS_UPDATED;
50
- updatedObjects: HashTreeObject[];
51
- } | {
52
- updateType: typeof LocusInfoUpdateType.MEETING_ENDED;
53
- };
57
+ [K in keyof LocusUpdatePayloads]: {
58
+ updateType: K;
59
+ } & LocusUpdatePayloads[K];
60
+ }[keyof LocusUpdatePayloads];
54
61
  export type LocusInfoUpdateCallback = (update: LocusInfoUpdate) => void;
55
62
  /**
56
63
  * This error is thrown if we receive information that the meeting has ended while we're processing some hash messages.
@@ -58,6 +65,13 @@ export type LocusInfoUpdateCallback = (update: LocusInfoUpdate) => void;
58
65
  */
59
66
  export declare class MeetingEndedError extends Error {
60
67
  }
68
+ /**
69
+ * This error is thrown when a 404 is received from Locus hash tree endpoints, indicating that the locus URL
70
+ * is no longer valid (e.g. participant moved to a breakout room, or meeting ended).
71
+ * It's handled internally by HashTreeParser and results in LOCUS_NOT_FOUND being sent up.
72
+ */
73
+ export declare class LocusNotFoundError extends Error {
74
+ }
61
75
  /**
62
76
  * Parses hash tree eventing locus data
63
77
  */
@@ -193,6 +207,13 @@ declare class HashTreeParser {
193
207
  * @returns {void}
194
208
  */
195
209
  private handleRootHashHeartBeatMessage;
210
+ /**
211
+ * Handles known errors that can happen during syncs
212
+ *
213
+ * @param {any} error - The error to handle
214
+ * @returns {boolean} true if the error was recognized and handled, false otherwise
215
+ */
216
+ private handleSyncErrors;
196
217
  /**
197
218
  * Asynchronously initializes new visible data sets
198
219
  *
@@ -75,3 +75,7 @@ export type ReplacesInfo = {
75
75
  replacedAt: string;
76
76
  sessionId: string;
77
77
  };
78
+ export declare const LocusErrorCodes: {
79
+ readonly LOCUS_INACTIVE: 2403004;
80
+ };
81
+ export type LocusErrorCodes = Enum<typeof LocusErrorCodes>;
@@ -494,8 +494,9 @@ export default class Meetings extends WebexPlugin {
494
494
  * @public
495
495
  * @memberof Meetings
496
496
  */
497
- syncMeetings({ keepOnlyLocusMeetings }?: {
497
+ syncMeetings({ keepOnlyLocusMeetings, skipHashTreeSync, }?: {
498
498
  keepOnlyLocusMeetings?: boolean;
499
+ skipHashTreeSync?: boolean;
499
500
  }): Promise<void>;
500
501
  /**
501
502
  * sort out locus array for initial creating
@@ -774,7 +774,7 @@ var Webinar = _webexCore.WebexPlugin.extend({
774
774
  }, _callee1);
775
775
  }))();
776
776
  },
777
- version: "3.12.0-next.50"
777
+ version: "3.12.0-next.51"
778
778
  });
779
779
  var _default = exports.default = Webinar;
780
780
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -94,5 +94,5 @@
94
94
  "//": [
95
95
  "TODO: upgrade jwt-decode when moving to node 18"
96
96
  ],
97
- "version": "3.12.0-next.50"
97
+ "version": "3.12.0-next.51"
98
98
  }
@@ -6,7 +6,7 @@ import BEHAVIORAL_METRICS from '../metrics/constants';
6
6
  import {Enum, HTTP_VERBS} from '../constants';
7
7
  import {DataSetNames, DATA_SET_INIT_PRIORITY, EMPTY_HASH} from './constants';
8
8
  import {ObjectType, HtMeta, HashTreeObject} from './types';
9
- import {LocusDTO} from '../locus-info/types';
9
+ import {LocusDTO, LocusErrorCodes} from '../locus-info/types';
10
10
  import {deleteNestedObjectsWithHtMeta, isMetadata, sortByInitPriority} from './utils';
11
11
 
12
12
  export interface DataSet {
@@ -56,17 +56,23 @@ type WebexRequestMethod = (options: Record<string, any>) => Promise<any>;
56
56
  export const LocusInfoUpdateType = {
57
57
  OBJECTS_UPDATED: 'OBJECTS_UPDATED',
58
58
  MEETING_ENDED: 'MEETING_ENDED',
59
+ LOCUS_NOT_FOUND: 'LOCUS_NOT_FOUND',
59
60
  } as const;
60
61
 
61
62
  export type LocusInfoUpdateType = Enum<typeof LocusInfoUpdateType>;
62
- export type LocusInfoUpdate =
63
- | {
64
- updateType: typeof LocusInfoUpdateType.OBJECTS_UPDATED;
65
- updatedObjects: HashTreeObject[];
66
- }
67
- | {
68
- updateType: typeof LocusInfoUpdateType.MEETING_ENDED;
69
- };
63
+
64
+ interface LocusUpdatePayloads {
65
+ [LocusInfoUpdateType.OBJECTS_UPDATED]: {updatedObjects: HashTreeObject[]};
66
+ [LocusInfoUpdateType.MEETING_ENDED]: unknown; // No extra data
67
+ [LocusInfoUpdateType.LOCUS_NOT_FOUND]: unknown; // No extra data
68
+ }
69
+
70
+ export type LocusInfoUpdate = {
71
+ [K in keyof LocusUpdatePayloads]: {
72
+ updateType: K;
73
+ } & LocusUpdatePayloads[K];
74
+ }[keyof LocusUpdatePayloads];
75
+
70
76
  export type LocusInfoUpdateCallback = (update: LocusInfoUpdate) => void;
71
77
 
72
78
  interface LeafInfo {
@@ -82,6 +88,13 @@ interface LeafInfo {
82
88
  */
83
89
  export class MeetingEndedError extends Error {}
84
90
 
91
+ /**
92
+ * This error is thrown when a 404 is received from Locus hash tree endpoints, indicating that the locus URL
93
+ * is no longer valid (e.g. participant moved to a breakout room, or meeting ended).
94
+ * It's handled internally by HashTreeParser and results in LOCUS_NOT_FOUND being sent up.
95
+ */
96
+ export class LocusNotFoundError extends Error {}
97
+
85
98
  /* Currently Locus always sends Metadata objects only in the "self" dataset.
86
99
  * If this ever changes, update all the code that relies on this constant.
87
100
  */
@@ -552,6 +565,32 @@ class HashTreeParser {
552
565
  });
553
566
  }
554
567
 
568
+ /**
569
+ * Handles known errors that can happen during syncs
570
+ *
571
+ * @param {any} error - The error to handle
572
+ * @returns {boolean} true if the error was recognized and handled, false otherwise
573
+ */
574
+ private handleSyncErrors(error: any) {
575
+ if (error instanceof MeetingEndedError) {
576
+ this.callLocusInfoUpdateCallback({
577
+ updateType: LocusInfoUpdateType.MEETING_ENDED,
578
+ });
579
+
580
+ return true;
581
+ }
582
+ if (error instanceof LocusNotFoundError) {
583
+ this.callLocusInfoUpdateCallback({
584
+ updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND,
585
+ });
586
+ this.stop();
587
+
588
+ return true;
589
+ }
590
+
591
+ return false;
592
+ }
593
+
555
594
  /**
556
595
  * Asynchronously initializes new visible data sets
557
596
  *
@@ -568,11 +607,7 @@ class HashTreeParser {
568
607
  );
569
608
  queueMicrotask(() => {
570
609
  this.initializeNewVisibleDataSets(dataSetsRequiringInitialization).catch((error) => {
571
- if (error instanceof MeetingEndedError) {
572
- this.callLocusInfoUpdateCallback({
573
- updateType: LocusInfoUpdateType.MEETING_ENDED,
574
- });
575
- } else {
610
+ if (!this.handleSyncErrors(error)) {
576
611
  LoggerProxy.logger.warn(
577
612
  `HashTreeParser#queueInitForNewVisibleDataSets --> ${
578
613
  this.debugId
@@ -1271,11 +1306,7 @@ class HashTreeParser {
1271
1306
  this.handleMessage(syncResponse, 'via sync API');
1272
1307
  }
1273
1308
  } catch (error) {
1274
- if (error instanceof MeetingEndedError) {
1275
- this.callLocusInfoUpdateCallback({
1276
- updateType: LocusInfoUpdateType.MEETING_ENDED,
1277
- });
1278
- } else {
1309
+ if (!this.handleSyncErrors(error)) {
1279
1310
  LoggerProxy.logger.warn(
1280
1311
  `HashTreeParser#performSync --> ${this.debugId} error during sync for data set "${dataSet.name}":`,
1281
1312
  error
@@ -1603,17 +1634,28 @@ class HashTreeParser {
1603
1634
  }
1604
1635
 
1605
1636
  private checkForSentinelHttpResponse(error: any, dataSetName?: string) {
1637
+ // 404 for any dataset means the locus is no longer available at this URL - could be replaced or ended
1638
+ // if a dataset is just not visible, we would get a 400
1639
+ if (error.statusCode === 404) {
1640
+ LoggerProxy.logger.info(
1641
+ `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received 404 for data set "${dataSetName}", locus not found`
1642
+ );
1643
+ this.stopAllTimers();
1644
+
1645
+ throw new LocusNotFoundError();
1646
+ }
1647
+
1606
1648
  const isValidDataSetForSentinel =
1607
1649
  dataSetName === undefined ||
1608
1650
  PossibleSentinelMessageDataSetNames.includes(dataSetName.toLowerCase());
1609
1651
 
1610
1652
  if (
1611
- ((error.statusCode === 409 && error.body?.errorCode === 2403004) ||
1612
- error.statusCode === 404) &&
1653
+ error.statusCode === 409 &&
1654
+ error.body?.errorCode === LocusErrorCodes.LOCUS_INACTIVE &&
1613
1655
  isValidDataSetForSentinel
1614
1656
  ) {
1615
1657
  LoggerProxy.logger.info(
1616
- `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received ${error.statusCode} for data set "${dataSetName}", indicating that the meeting has ended`
1658
+ `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received ${error.statusCode}/${error.body?.errorCode} for data set "${dataSetName}", indicating that the meeting has ended`
1617
1659
  );
1618
1660
  this.stopAllTimers();
1619
1661
 
@@ -1344,6 +1344,21 @@ export default class LocusInfo extends EventsScope {
1344
1344
  );
1345
1345
  this.webex.meetings.destroy(meeting, MEETING_REMOVED_REASON.SELF_REMOVED);
1346
1346
  }
1347
+ break;
1348
+ }
1349
+
1350
+ case LocusInfoUpdateType.LOCUS_NOT_FOUND: {
1351
+ LoggerProxy.logger.info(
1352
+ `Locus-info:index#updateFromHashTree --> received LOCUS_NOT_FOUND for ${locusUrl}, triggering syncMeetings`
1353
+ );
1354
+ this.webex.meetings
1355
+ .syncMeetings({keepOnlyLocusMeetings: false, skipHashTreeSync: true})
1356
+ .catch((syncError) => {
1357
+ LoggerProxy.logger.error(
1358
+ `Locus-info:index#updateFromHashTree --> syncMeetings failed after LOCUS_NOT_FOUND: ${syncError}`
1359
+ );
1360
+ });
1361
+ break;
1347
1362
  }
1348
1363
  }
1349
1364
  }
@@ -77,3 +77,9 @@ export type ReplacesInfo = {
77
77
  replacedAt: string;
78
78
  sessionId: string;
79
79
  };
80
+
81
+ export const LocusErrorCodes = {
82
+ LOCUS_INACTIVE: 2403004,
83
+ } as const;
84
+
85
+ export type LocusErrorCodes = Enum<typeof LocusErrorCodes>;
@@ -1924,7 +1924,10 @@ export default class Meetings extends WebexPlugin {
1924
1924
  * @public
1925
1925
  * @memberof Meetings
1926
1926
  */
1927
- public async syncMeetings({keepOnlyLocusMeetings = true} = {}): Promise<void> {
1927
+ public async syncMeetings({
1928
+ keepOnlyLocusMeetings = true,
1929
+ skipHashTreeSync = false,
1930
+ } = {}): Promise<void> {
1928
1931
  // @ts-ignore
1929
1932
  if (this.webex.credentials.isUnverifiedGuest) {
1930
1933
  LoggerProxy.logger.info(
@@ -1984,18 +1987,20 @@ export default class Meetings extends WebexPlugin {
1984
1987
  }
1985
1988
  }
1986
1989
 
1987
- // Trigger hash tree syncs for all remaining meetings
1988
- const remainingMeetings = this.meetingCollection.getAll();
1989
- const syncPromises = [];
1990
+ if (!skipHashTreeSync) {
1991
+ // Trigger hash tree syncs for all remaining meetings
1992
+ const remainingMeetings = this.meetingCollection.getAll();
1993
+ const syncPromises = [];
1990
1994
 
1991
- for (const meeting of Object.values(remainingMeetings) as any[]) {
1992
- if (meeting.locusInfo) {
1993
- syncPromises.push(meeting.locusInfo.syncAllHashTreeDatasets());
1995
+ for (const meeting of Object.values(remainingMeetings) as any[]) {
1996
+ if (meeting.locusInfo) {
1997
+ syncPromises.push(meeting.locusInfo.syncAllHashTreeDatasets());
1998
+ }
1994
1999
  }
1995
- }
1996
2000
 
1997
- if (syncPromises.length > 0) {
1998
- await Promise.all(syncPromises);
2001
+ if (syncPromises.length > 0) {
2002
+ await Promise.all(syncPromises);
2003
+ }
1999
2004
  }
2000
2005
  }
2001
2006
 
@@ -1,6 +1,7 @@
1
1
  import HashTreeParser, {
2
2
  LocusInfoUpdateType,
3
3
  MeetingEndedError,
4
+ LocusNotFoundError,
4
5
  } from '@webex/plugin-meetings/src/hashTree/hashTreeParser';
5
6
  import HashTree from '@webex/plugin-meetings/src/hashTree/hashTree';
6
7
  import {expect} from '@webex/test-helper-chai';
@@ -708,8 +709,11 @@ describe('HashTreeParser', () => {
708
709
  assert.notCalled(callback);
709
710
  });
710
711
 
711
- [404, 409].forEach((errorCode) => {
712
- it(`emits MeetingEndedError if getting visible datasets returns ${errorCode}`, async () => {
712
+ [
713
+ {errorCode: 404, expectedError: LocusNotFoundError},
714
+ {errorCode: 409, expectedError: MeetingEndedError},
715
+ ].forEach(({errorCode, expectedError}) => {
716
+ it(`throws ${expectedError.name} if getting visible datasets returns ${errorCode}`, async () => {
713
717
  const minimalInitialLocus = {
714
718
  dataSets: [],
715
719
  locus: null,
@@ -732,7 +736,6 @@ describe('HashTreeParser', () => {
732
736
  )
733
737
  .rejects(error);
734
738
 
735
- // initializeFromMessage should throw MeetingEndedError
736
739
  let thrownError;
737
740
  try {
738
741
  await parser.initializeFromMessage({
@@ -744,7 +747,7 @@ describe('HashTreeParser', () => {
744
747
  thrownError = e;
745
748
  }
746
749
 
747
- expect(thrownError).to.be.instanceOf(MeetingEndedError);
750
+ expect(thrownError).to.be.instanceOf(expectedError);
748
751
  });
749
752
  });
750
753
  });
@@ -1766,12 +1769,10 @@ describe('HashTreeParser', () => {
1766
1769
  });
1767
1770
  });
1768
1771
 
1769
- describe('emits MEETING_ENDED', () => {
1770
- [404, 409].forEach((statusCode) => {
1771
- it(`when /hashtree returns ${statusCode}`, async () => {
1772
+ describe('emits MEETING_ENDED when 409/2403004 is returned', () => {
1773
+ it('when /hashtree returns 409', async () => {
1772
1774
  const parser = createHashTreeParser();
1773
1775
 
1774
- // Send a message to trigger sync algorithm
1775
1776
  const message = {
1776
1777
  dataSets: [createDataSet('main', 16, 1100)],
1777
1778
  visibleDataSetsUrl,
@@ -1796,12 +1797,9 @@ describe('HashTreeParser', () => {
1796
1797
 
1797
1798
  const mainDataSetUrl = parser.dataSets.main.url;
1798
1799
 
1799
- // Mock getHashesFromLocus to reject with the sentinel error
1800
- const error: any = new Error(`Request failed with status ${statusCode}`);
1801
- error.statusCode = statusCode;
1802
- if (statusCode === 409) {
1803
- error.body = {errorCode: 2403004};
1804
- }
1800
+ const error: any = new Error('Request failed with status 409');
1801
+ error.statusCode = 409;
1802
+ error.body = {errorCode: 2403004};
1805
1803
  webexRequest
1806
1804
  .withArgs(
1807
1805
  sinon.match({
@@ -1811,13 +1809,10 @@ describe('HashTreeParser', () => {
1811
1809
  )
1812
1810
  .rejects(error);
1813
1811
 
1814
- // Trigger sync by advancing time
1815
1812
  await clock.tickAsync(1000);
1816
1813
 
1817
- // Verify callback was called with MEETING_ENDED
1818
1814
  assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1819
1815
 
1820
- // Verify all timers are stopped
1821
1816
  Object.values(parser.dataSets).forEach((ds: any) => {
1822
1817
  assert.isUndefined(ds.timer);
1823
1818
  assert.isUndefined(ds.heartbeatWatchdogTimer);
@@ -1827,10 +1822,9 @@ describe('HashTreeParser', () => {
1827
1822
  assert.notCalled(metricsStub);
1828
1823
  });
1829
1824
 
1830
- it(`when /sync returns ${statusCode}`, async () => {
1825
+ it('when /sync returns 409', async () => {
1831
1826
  const parser = createHashTreeParser();
1832
1827
 
1833
- // Send a message to trigger sync algorithm
1834
1828
  const message = {
1835
1829
  dataSets: [createDataSet('main', 16, 1100)],
1836
1830
  visibleDataSetsUrl,
@@ -1855,19 +1849,15 @@ describe('HashTreeParser', () => {
1855
1849
 
1856
1850
  const mainDataSetUrl = parser.dataSets.main.url;
1857
1851
 
1858
- // Mock getHashesFromLocus to succeed
1859
1852
  mockGetHashesFromLocusResponse(
1860
1853
  mainDataSetUrl,
1861
1854
  new Array(16).fill('00000000000000000000000000000000'),
1862
1855
  createDataSet('main', 16, 1101)
1863
1856
  );
1864
1857
 
1865
- // Mock sendSyncRequestToLocus to reject with the sentinel error
1866
- const error: any = new Error(`Request failed with status ${statusCode}`);
1867
- error.statusCode = statusCode;
1868
- if (statusCode === 409) {
1869
- error.body = {errorCode: 2403004};
1870
- }
1858
+ const error: any = new Error('Request failed with status 409');
1859
+ error.statusCode = 409;
1860
+ error.body = {errorCode: 2403004};
1871
1861
  webexRequest
1872
1862
  .withArgs(
1873
1863
  sinon.match({
@@ -1877,12 +1867,121 @@ describe('HashTreeParser', () => {
1877
1867
  )
1878
1868
  .rejects(error);
1879
1869
 
1880
- // Trigger sync by advancing time
1881
1870
  await clock.tickAsync(1000);
1882
1871
 
1883
- // Verify callback was called with MEETING_ENDED
1884
1872
  assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
1885
1873
 
1874
+ Object.values(parser.dataSets).forEach((ds: any) => {
1875
+ assert.isUndefined(ds.timer);
1876
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1877
+ });
1878
+ });
1879
+ });
1880
+
1881
+ describe('emits LOCUS_NOT_FOUND and stops parser when 404 is returned', () => {
1882
+ it('when /hashtree returns 404', async () => {
1883
+ const parser = createHashTreeParser();
1884
+
1885
+ const message = {
1886
+ dataSets: [createDataSet('main', 16, 1100)],
1887
+ visibleDataSetsUrl,
1888
+ locusUrl,
1889
+ locusStateElements: [
1890
+ {
1891
+ htMeta: {
1892
+ elementId: {
1893
+ type: 'locus' as const,
1894
+ id: 0,
1895
+ version: 201,
1896
+ },
1897
+ dataSetNames: ['main'],
1898
+ },
1899
+ data: {info: {id: 'initial-update'}},
1900
+ },
1901
+ ],
1902
+ };
1903
+
1904
+ parser.handleMessage(message, 'initial message');
1905
+ callback.resetHistory();
1906
+
1907
+ const mainDataSetUrl = parser.dataSets.main.url;
1908
+
1909
+ const error: any = new Error('Request failed with status 404');
1910
+ error.statusCode = 404;
1911
+ webexRequest
1912
+ .withArgs(
1913
+ sinon.match({
1914
+ method: 'GET',
1915
+ uri: `${mainDataSetUrl}/hashtree`,
1916
+ })
1917
+ )
1918
+ .rejects(error);
1919
+
1920
+ await clock.tickAsync(1000);
1921
+
1922
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
1923
+
1924
+ // Verify parser is stopped
1925
+ expect(parser.state).to.equal('stopped');
1926
+
1927
+ // Verify all timers are stopped
1928
+ Object.values(parser.dataSets).forEach((ds: any) => {
1929
+ assert.isUndefined(ds.timer);
1930
+ assert.isUndefined(ds.heartbeatWatchdogTimer);
1931
+ });
1932
+ });
1933
+
1934
+ it('when /sync returns 404', async () => {
1935
+ const parser = createHashTreeParser();
1936
+
1937
+ const message = {
1938
+ dataSets: [createDataSet('main', 16, 1100)],
1939
+ visibleDataSetsUrl,
1940
+ locusUrl,
1941
+ locusStateElements: [
1942
+ {
1943
+ htMeta: {
1944
+ elementId: {
1945
+ type: 'locus' as const,
1946
+ id: 0,
1947
+ version: 201,
1948
+ },
1949
+ dataSetNames: ['main'],
1950
+ },
1951
+ data: {info: {id: 'initial-update'}},
1952
+ },
1953
+ ],
1954
+ };
1955
+
1956
+ parser.handleMessage(message, 'initial message');
1957
+ callback.resetHistory();
1958
+
1959
+ const mainDataSetUrl = parser.dataSets.main.url;
1960
+
1961
+ mockGetHashesFromLocusResponse(
1962
+ mainDataSetUrl,
1963
+ new Array(16).fill('00000000000000000000000000000000'),
1964
+ createDataSet('main', 16, 1101)
1965
+ );
1966
+
1967
+ const error: any = new Error('Request failed with status 404');
1968
+ error.statusCode = 404;
1969
+ webexRequest
1970
+ .withArgs(
1971
+ sinon.match({
1972
+ method: 'POST',
1973
+ uri: `${mainDataSetUrl}/sync`,
1974
+ })
1975
+ )
1976
+ .rejects(error);
1977
+
1978
+ await clock.tickAsync(1000);
1979
+
1980
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
1981
+
1982
+ // Verify parser is stopped
1983
+ expect(parser.state).to.equal('stopped');
1984
+
1886
1985
  // Verify all timers are stopped
1887
1986
  Object.values(parser.dataSets).forEach((ds: any) => {
1888
1987
  assert.isUndefined(ds.timer);
@@ -1892,7 +1991,6 @@ describe('HashTreeParser', () => {
1892
1991
  // Verify no sync failure metric was sent for end-meeting sentinel
1893
1992
  assert.notCalled(metricsStub);
1894
1993
  });
1895
- });
1896
1994
  });
1897
1995
 
1898
1996
  it('requests only mismatched hashes during sync', async () => {
@@ -2554,7 +2652,7 @@ describe('HashTreeParser', () => {
2554
2652
  expect(syncCalls[1].args[0].uri).to.equal(`${newAtdActiveDataSet.url}/sync`);
2555
2653
  });
2556
2654
 
2557
- it('emits MEETING_ENDED if async init of a new visible dataset fails with 404', async () => {
2655
+ it('emits LOCUS_NOT_FOUND if async init of a new visible dataset fails with 404', async () => {
2558
2656
  const parser = createHashTreeParser();
2559
2657
 
2560
2658
  // Stub updateItems on self hash tree to return true
@@ -2619,8 +2717,8 @@ describe('HashTreeParser', () => {
2619
2717
  // Wait for the async initialization (queueMicrotask) to complete
2620
2718
  await clock.tickAsync(0);
2621
2719
 
2622
- // Verify callback was called with MEETING_ENDED
2623
- assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.MEETING_ENDED});
2720
+ // Verify callback was called with LOCUS_NOT_FOUND (404 means locus URL is stale, not necessarily meeting ended)
2721
+ assert.calledOnceWithExactly(callback, {updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND});
2624
2722
  });
2625
2723
 
2626
2724
  it('handles removal of visible data set', async () => {
@@ -332,6 +332,7 @@ describe('plugin-meetings', () => {
332
332
  describe('should setup correct locusInfoUpdateCallback when creating HashTreeParser', () => {
333
333
  const OBJECTS_UPDATED = HashTreeParserModule.LocusInfoUpdateType.OBJECTS_UPDATED;
334
334
  const MEETING_ENDED = HashTreeParserModule.LocusInfoUpdateType.MEETING_ENDED;
335
+ const LOCUS_NOT_FOUND = HashTreeParserModule.LocusInfoUpdateType.LOCUS_NOT_FOUND;
335
336
 
336
337
  let locusInfoUpdateCallback;
337
338
  let onDeltaLocusStub;
@@ -1001,6 +1002,37 @@ describe('plugin-meetings', () => {
1001
1002
  assert.notCalled(destroyStub);
1002
1003
  });
1003
1004
 
1005
+ it('should handle LOCUS_NOT_FOUND by calling syncMeetings with skipHashTreeSync', () => {
1006
+ const syncMeetingsStub = sinon.stub(locusInfo.webex.meetings, 'syncMeetings').resolves();
1007
+
1008
+ locusInfoUpdateCallback({updateType: LOCUS_NOT_FOUND});
1009
+
1010
+ assert.calledOnceWithExactly(syncMeetingsStub, {keepOnlyLocusMeetings: false, skipHashTreeSync: true});
1011
+ });
1012
+
1013
+ it('should handle LOCUS_NOT_FOUND and log error if syncMeetings fails', async () => {
1014
+ const syncError = new Error('sync failed');
1015
+ const syncMeetingsStub = sinon.stub(locusInfo.webex.meetings, 'syncMeetings').rejects(syncError);
1016
+ const logErrorStub = LoggerProxy.logger.error?.isSinonProxy
1017
+ ? LoggerProxy.logger.error
1018
+ : sinon.stub(LoggerProxy.logger, 'error');
1019
+
1020
+ logErrorStub.resetHistory();
1021
+
1022
+ locusInfoUpdateCallback({updateType: LOCUS_NOT_FOUND});
1023
+
1024
+ assert.calledOnceWithExactly(syncMeetingsStub, {keepOnlyLocusMeetings: false, skipHashTreeSync: true});
1025
+
1026
+ // wait for the promise rejection to be handled
1027
+ await testUtils.flushPromises();
1028
+
1029
+ assert.calledOnce(logErrorStub);
1030
+ assert.match(
1031
+ logErrorStub.firstCall.args[0],
1032
+ /syncMeetings failed after LOCUS_NOT_FOUND/
1033
+ );
1034
+ });
1035
+
1004
1036
  it('should set forceReplaceMembers to true on the first update for a locusUrl (initializedFromHashTree is false)', () => {
1005
1037
  const createdHashTreeParser = locusInfo.hashTreeParsers.get('fake-locus-url');
1006
1038
  createdHashTreeParser.initializedFromHashTree = false;
@@ -1714,6 +1714,40 @@ describe('plugin-meetings', () => {
1714
1714
  });
1715
1715
  });
1716
1716
 
1717
+ describe('skipHashTreeSync parameter', () => {
1718
+ it('should skip syncAllHashTreeDatasets when skipHashTreeSync is true', async () => {
1719
+ const mockLocusInfo = {
1720
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
1721
+ };
1722
+
1723
+ webex.meetings.request.getActiveMeetings = sinon.stub().resolves({loci: []});
1724
+ webex.meetings.meetingCollection.getAll = sinon.stub().returns({
1725
+ meeting1: {locusInfo: mockLocusInfo},
1726
+ });
1727
+
1728
+ await webex.meetings.syncMeetings({keepOnlyLocusMeetings: false, skipHashTreeSync: true});
1729
+
1730
+ assert.calledOnce(webex.meetings.request.getActiveMeetings);
1731
+ assert.notCalled(mockLocusInfo.syncAllHashTreeDatasets);
1732
+ });
1733
+
1734
+ it('should call syncAllHashTreeDatasets when skipHashTreeSync is false (default)', async () => {
1735
+ const mockLocusInfo = {
1736
+ syncAllHashTreeDatasets: sinon.stub().resolves(),
1737
+ };
1738
+
1739
+ webex.meetings.request.getActiveMeetings = sinon.stub().resolves({loci: []});
1740
+ webex.meetings.meetingCollection.getAll = sinon.stub().returns({
1741
+ meeting1: {locusInfo: mockLocusInfo},
1742
+ });
1743
+
1744
+ await webex.meetings.syncMeetings({keepOnlyLocusMeetings: false, skipHashTreeSync: false});
1745
+
1746
+ assert.calledOnce(webex.meetings.request.getActiveMeetings);
1747
+ assert.calledOnce(mockLocusInfo.syncAllHashTreeDatasets);
1748
+ });
1749
+ });
1750
+
1717
1751
  describe('syncAllHashTreeDatasets in syncMeetings', () => {
1718
1752
  it('should call syncAllHashTreeDatasets for multiple meetings, skipping those without locusInfo', async () => {
1719
1753
  const mockLocusInfo1 = {