@contrail/flexplm 1.3.0-alpha.4 → 1.3.0-alpha.6

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/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2026-04-15
11
+ ### Added
12
+ - Added inbound thumbnail/primary content syncing from FlexPLM to VibeIQ via `ThumbnailUtil.syncThumbnailToVibeIQ`.
13
+ - Added `syncInboundImages` and `syncOutboundImages` methods to `TypeConversionUtils` for controlling image sync per map file configuration.
14
+ - Added `PRIMARY_CONTENT_UPDATED` status to `EventShortMessageStatus` for when only primary content changes are detected.
15
+
16
+ ### Changed
17
+ - `BaseEntityProcessor` update flow now distinguishes between primary-content-only changes and no changes, returning the updated entity when only the thumbnail was synced.
18
+ - Improved `FlexPLMConnect.getRequest` function and added unit tests.
19
+
10
20
  ## [1.2.1] - 2026-04-08
11
21
 
12
22
  ### Added
@@ -68,13 +68,10 @@ class BaseEntityProcessor {
68
68
  console.log(statusMsg);
69
69
  return createEntityResponse.earlyReturn;
70
70
  }
71
- const createdEntity = await this.createEntity(this.baseType, createEntityResponse.entity);
71
+ let createdEntity = await this.createEntity(this.baseType, createEntityResponse.entity);
72
72
  const shouldSyncThumbnail = await type_conversion_utils_1.TypeConversionUtils.syncInboundImages(this.transformMapFile, this.mapFileUtil, event.data);
73
73
  if (shouldSyncThumbnail) {
74
- const thumbnailUpdates = await new thumbnail_util_1.ThumbnailUtil(this.config).syncThumbnailToVibeIQ({ entityId: createdEntity.id, primaryViewableId: createdEntity.primaryViewableId, event, entityName: this.baseType });
75
- if (thumbnailUpdates) {
76
- await this.updateEntity(this.baseType, createdEntity, thumbnailUpdates);
77
- }
74
+ createdEntity = await new thumbnail_util_1.ThumbnailUtil(this.config).syncThumbnailToVibeIQ({ entityId: createdEntity.id, primaryViewableId: createdEntity.primaryViewableId, event, entityName: this.baseType }) || createdEntity;
78
75
  }
79
76
  const statusMsg = this.getInboundStatusMessage({
80
77
  status: event_short_message_status_1.EventShortMessageStatus.SUCCESS,
@@ -88,12 +85,23 @@ class BaseEntityProcessor {
88
85
  }
89
86
  const diffs = await this.getUpdatesForEntity(entity, inboundData);
90
87
  const shouldSyncThumbnail = await type_conversion_utils_1.TypeConversionUtils.syncInboundImages(this.transformMapFile, this.mapFileUtil, event.data);
91
- let thumbnailUpdates;
88
+ let thumbnailEntity;
92
89
  if (shouldSyncThumbnail) {
93
- thumbnailUpdates = await new thumbnail_util_1.ThumbnailUtil(this.config).syncThumbnailToVibeIQ({ entityId: entity.id, primaryViewableId: entity.primaryViewableId, event, entityName: this.baseType });
90
+ thumbnailEntity = await new thumbnail_util_1.ThumbnailUtil(this.config).syncThumbnailToVibeIQ({ entityId: entity.id, primaryViewableId: entity.primaryViewableId, event, entityName: this.baseType });
94
91
  }
95
- const allUpdates = { ...diffs, ...thumbnailUpdates };
96
- if (Object.getOwnPropertyNames(allUpdates).length == 0) {
92
+ const hasPropertyChanges = Object.getOwnPropertyNames(diffs).length > 0;
93
+ if (!hasPropertyChanges && thumbnailEntity) {
94
+ const statusMsg = this.getInboundStatusMessage({
95
+ status: event_short_message_status_1.EventShortMessageStatus.SUCCESS,
96
+ statusMessage: event_short_message_status_1.EventShortMessageStatus.PRIMARY_CONTENT_UPDATED,
97
+ objectClass: event.objectClass,
98
+ entityId: entity.id,
99
+ federatedId: event.federatedId
100
+ });
101
+ console.log(statusMsg);
102
+ return thumbnailEntity;
103
+ }
104
+ if (!hasPropertyChanges) {
97
105
  const statusMsg = this.getInboundStatusMessage({
98
106
  status: event_short_message_status_1.EventShortMessageStatus.SUCCESS,
99
107
  statusMessage: event_short_message_status_1.EventShortMessageStatus.NO_CHANGES,
@@ -108,7 +116,7 @@ class BaseEntityProcessor {
108
116
  data: { message }
109
117
  };
110
118
  }
111
- const updatedEntity = await this.updateEntity(this.baseType, entity, allUpdates);
119
+ const updatedEntity = await this.updateEntity(this.baseType, entity, diffs);
112
120
  const statusMsg = this.getInboundStatusMessage({
113
121
  status: event_short_message_status_1.EventShortMessageStatus.SUCCESS,
114
122
  statusMessage: event_short_message_status_1.EventShortMessageStatus.UPDATED,
@@ -320,30 +320,14 @@ describe('BaseEntityProcessor', () => {
320
320
  syncInboundImagesSpy = jest.spyOn(type_conversion_utils_1.TypeConversionUtils, 'syncInboundImages');
321
321
  syncThumbnailSpy = jest.spyOn(thumbnail_util_1.ThumbnailUtil.prototype, 'syncThumbnailToVibeIQ').mockResolvedValue(undefined);
322
322
  });
323
- it('should call updateEntity with thumbnail updates after create when syncInboundImages returns true', async () => {
323
+ it('should call syncThumbnailToVibeIQ with entity after create when syncInboundImages returns true', async () => {
324
324
  jest.spyOn(btep, 'getIncomingEntity').mockResolvedValue({ entity: null });
325
325
  jest.spyOn(btep, 'getCreateEntity').mockResolvedValue({ entity: { name: 'new' } });
326
326
  jest.spyOn(btep, 'createEntity').mockResolvedValue(mockCreatedEntity);
327
- const updateEntitySpy = jest.spyOn(btep, 'updateEntity').mockResolvedValue(mockCreatedEntity);
328
327
  syncInboundImagesSpy.mockResolvedValue(true);
329
- const thumbnailUpdates = { primaryViewableId: 'pv-new', primaryFileUrl: 'https://files/new.png' };
330
- syncThumbnailSpy.mockResolvedValue(thumbnailUpdates);
331
328
  const result = await btep.handleIncomingUpsert(mockEvent);
332
329
  expect(syncThumbnailSpy).toBeCalledTimes(1);
333
330
  expect(syncThumbnailSpy).toBeCalledWith({ entityId: 'created-1', primaryViewableId: 'pv-created', event: mockEvent, entityName: 'test' });
334
- expect(updateEntitySpy).toBeCalledWith('test', mockCreatedEntity, thumbnailUpdates);
335
- expect(result).toBe(mockCreatedEntity);
336
- });
337
- it('should not call updateEntity after create when syncThumbnailToVibeIQ returns undefined', async () => {
338
- jest.spyOn(btep, 'getIncomingEntity').mockResolvedValue({ entity: null });
339
- jest.spyOn(btep, 'getCreateEntity').mockResolvedValue({ entity: { name: 'new' } });
340
- jest.spyOn(btep, 'createEntity').mockResolvedValue(mockCreatedEntity);
341
- const updateEntitySpy = jest.spyOn(btep, 'updateEntity');
342
- syncInboundImagesSpy.mockResolvedValue(true);
343
- syncThumbnailSpy.mockResolvedValue(undefined);
344
- const result = await btep.handleIncomingUpsert(mockEvent);
345
- expect(syncThumbnailSpy).toBeCalledTimes(1);
346
- expect(updateEntitySpy).not.toHaveBeenCalled();
347
331
  expect(result).toBe(mockCreatedEntity);
348
332
  });
349
333
  it('should not sync images after create when syncInboundImages returns false', async () => {
@@ -356,28 +340,15 @@ describe('BaseEntityProcessor', () => {
356
340
  expect(syncThumbnailSpy).toBeCalledTimes(0);
357
341
  expect(result).toBe(mockCreatedEntity);
358
342
  });
359
- it('should merge thumbnail updates with property diffs on update', async () => {
343
+ it('should call syncThumbnailToVibeIQ with entity and update with property diffs on update', async () => {
360
344
  jest.spyOn(btep, 'getIncomingEntity').mockResolvedValue({ entity: mockExistingEntity });
361
345
  jest.spyOn(btep, 'getUpdatesForEntity').mockResolvedValue({ name: 'changed' });
362
346
  const updateEntitySpy = jest.spyOn(btep, 'updateEntity').mockResolvedValue(mockUpdatedEntity);
363
347
  syncInboundImagesSpy.mockResolvedValue(true);
364
- const thumbnailUpdates = { primaryViewableId: 'pv-new' };
365
- syncThumbnailSpy.mockResolvedValue(thumbnailUpdates);
366
348
  const result = await btep.handleIncomingUpsert(mockEvent);
367
349
  expect(syncThumbnailSpy).toBeCalledTimes(1);
368
350
  expect(syncThumbnailSpy).toBeCalledWith({ entityId: 'existing-1', primaryViewableId: undefined, event: mockEvent, entityName: 'test' });
369
- expect(updateEntitySpy).toBeCalledWith('test', mockExistingEntity, { name: 'changed', primaryViewableId: 'pv-new' });
370
- expect(result).toBe(mockUpdatedEntity);
371
- });
372
- it('should update entity with only thumbnail changes when no property diffs', async () => {
373
- jest.spyOn(btep, 'getIncomingEntity').mockResolvedValue({ entity: mockExistingEntity });
374
- jest.spyOn(btep, 'getUpdatesForEntity').mockResolvedValue({});
375
- const updateEntitySpy = jest.spyOn(btep, 'updateEntity').mockResolvedValue(mockUpdatedEntity);
376
- syncInboundImagesSpy.mockResolvedValue(true);
377
- const thumbnailUpdates = { primaryViewableId: 'pv-new' };
378
- syncThumbnailSpy.mockResolvedValue(thumbnailUpdates);
379
- const result = await btep.handleIncomingUpsert(mockEvent);
380
- expect(updateEntitySpy).toBeCalledWith('test', mockExistingEntity, { primaryViewableId: 'pv-new' });
351
+ expect(updateEntitySpy).toBeCalledWith('test', mockExistingEntity, { name: 'changed' });
381
352
  expect(result).toBe(mockUpdatedEntity);
382
353
  });
383
354
  it('should not sync images on update when syncInboundImages returns false', async () => {
@@ -9,6 +9,7 @@ export declare enum EventShortMessageStatus {
9
9
  MISSING_INPUT = "Missing_input",
10
10
  NOT_CREATABLE = "Not_creatable",
11
11
  NO_CHANGES = "No_Changes",
12
+ PRIMARY_CONTENT_UPDATED = "Primary_Content_Updated",
12
13
  TOO_MANY_ENTITIES_FOUND = "Too_Many_Entities_Found",
13
14
  UPDATED = "Updated",
14
15
  NOT_PUBLISHABLE = "Not_Publishable",
@@ -13,6 +13,7 @@ var EventShortMessageStatus;
13
13
  EventShortMessageStatus["MISSING_INPUT"] = "Missing_input";
14
14
  EventShortMessageStatus["NOT_CREATABLE"] = "Not_creatable";
15
15
  EventShortMessageStatus["NO_CHANGES"] = "No_Changes";
16
+ EventShortMessageStatus["PRIMARY_CONTENT_UPDATED"] = "Primary_Content_Updated";
16
17
  EventShortMessageStatus["TOO_MANY_ENTITIES_FOUND"] = "Too_Many_Entities_Found";
17
18
  EventShortMessageStatus["UPDATED"] = "Updated";
18
19
  EventShortMessageStatus["NOT_PUBLISHABLE"] = "Not_Publishable";
@@ -119,8 +119,9 @@ class ThumbnailUtil {
119
119
  await this.entities.delete({ entityName: 'content', id: primaryViewableId });
120
120
  }
121
121
  const clearUpdates = await this.getClearPrimaryViewableUpdates();
122
- console.debug(`syncThumbnailToVibeIQ: returning clear updates for entityId=${entityId}`);
123
- return clearUpdates;
122
+ const updatedEntity = await this.entities.update({ entityName, id: entityId, object: clearUpdates });
123
+ console.debug(`syncThumbnailToVibeIQ: applied clear updates for entityId=${entityId}`);
124
+ return updatedEntity;
124
125
  }
125
126
  if (!thumbnailUrl) {
126
127
  console.debug(`syncThumbnailToVibeIQ: no thumbnail URL for entityId=${entityId}`);
@@ -130,8 +131,9 @@ class ThumbnailUtil {
130
131
  const content = await this.createContentFromFlexPLM(thumbnailUrl, entityId, entityName);
131
132
  await this.entities.update({ entityName: 'content', id: content.id, object: { flexplmThumbnailUrl: thumbnailUrl } });
132
133
  const primaryUpdates = await this.getPrimaryViewableUpdates(content);
134
+ const updatedEntity = await this.entities.update({ entityName, id: entityId, object: primaryUpdates });
133
135
  console.debug(`syncThumbnailToVibeIQ: created new content ${content.id} for entityId=${entityId}`);
134
- return primaryUpdates;
136
+ return updatedEntity;
135
137
  }
136
138
  const primaryViewable = await this.entities.get({ entityName: 'content', id: primaryViewableId });
137
139
  if (primaryViewable?.flexplmThumbnailUrl === thumbnailUrl) {
@@ -141,14 +143,18 @@ class ThumbnailUtil {
141
143
  const content = await this.createContentFromFlexPLM(thumbnailUrl, entityId, entityName);
142
144
  await this.entities.update({ entityName: 'content', id: content.id, object: { flexplmThumbnailUrl: thumbnailUrl } });
143
145
  const primaryUpdates = await this.getPrimaryViewableUpdates(content);
146
+ const updatedEntity = await this.entities.update({ entityName, id: entityId, object: primaryUpdates });
144
147
  await this.entities.delete({ entityName: 'content', id: primaryViewableId });
145
148
  console.debug(`syncThumbnailToVibeIQ: replaced content ${primaryViewableId} with ${content.id} for entityId=${entityId}`);
146
- return primaryUpdates;
149
+ return updatedEntity;
147
150
  }
148
151
  async createContentFromFlexPLM(thumbnailUrl, entityId, entityName) {
152
+ const urlParts = thumbnailUrl.split('/');
153
+ const fileName = urlParts[urlParts.length - 1] || 'thumbnail';
154
+ const encodedUrl = urlParts.map(part => encodeURIComponent(part)).join('/');
149
155
  const flexPLMConnect = new flexplm_connect_1.FlexPLMConnect(this.config);
150
156
  const response = await flexPLMConnect.getRequest({
151
- urlPath: thumbnailUrl,
157
+ urlPath: encodedUrl,
152
158
  includeUrlContext: false,
153
159
  returnFullResponse: true,
154
160
  });
@@ -156,8 +162,6 @@ class ThumbnailUtil {
156
162
  const buffer = Buffer.from(fileBuffer);
157
163
  const contentTypeHeader = response.headers.get('content-type');
158
164
  const contentType = contentTypeHeader ? contentTypeHeader.split(';')[0] : 'application/octet-stream';
159
- const urlParts = thumbnailUrl.split('/');
160
- const fileName = urlParts[urlParts.length - 1] || 'thumbnail';
161
165
  const contentHolderReference = `${entityName}:${entityId}`;
162
166
  const content = await new sdk_1.Content().create({
163
167
  fileBuffer: buffer,
@@ -276,34 +276,32 @@ describe('ThumbnailUtil Tests', () => {
276
276
  mockEntitiesUpdate.mockImplementation((opts) => Promise.resolve({ id: opts.id }));
277
277
  mockEntitiesDelete.mockImplementation((opts) => Promise.resolve({ id: opts.id }));
278
278
  });
279
- it('returns undefined when no thumbnail IDs in event data', async () => {
279
+ it('does not update when no thumbnail IDs in event data', async () => {
280
280
  const event = { data: {} };
281
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
282
- expect(result).toBeUndefined();
281
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
283
282
  expect(mockEntitiesUpdate).not.toHaveBeenCalled();
284
283
  expect(mockEntitiesDelete).not.toHaveBeenCalled();
285
284
  expect(mockContentCreate).not.toHaveBeenCalled();
286
285
  });
287
- it('returns undefined when event.data is undefined', async () => {
286
+ it('does not update when event.data is undefined', async () => {
288
287
  const event = {};
289
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
290
- expect(result).toBeUndefined();
288
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
291
289
  expect(mockEntitiesUpdate).not.toHaveBeenCalled();
292
290
  expect(mockEntitiesDelete).not.toHaveBeenCalled();
293
291
  });
294
- it('REMOVE_THUMBNAIL with existing primaryViewableId deletes content and returns clear updates', async () => {
292
+ it('REMOVE_THUMBNAIL with existing primaryViewableId deletes content and updates entity', async () => {
295
293
  const event = { data: { [thumbnail_util_1.ThumbnailUtil.NEW_THUMBNAIL_ID]: thumbnail_util_1.ThumbnailUtil.REMOVE_THUMBNAIL } };
296
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', primaryViewableId: 'pv1', event, entityName: 'color' });
294
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', primaryViewableId: 'pv1', event, entityName: 'color' });
297
295
  expect(mockEntitiesDelete).toHaveBeenCalledWith({ entityName: 'content', id: 'pv1' });
298
- expect(result).toEqual(expect.objectContaining({ primaryViewableId: null, primaryFileUrl: null }));
296
+ expect(mockEntitiesUpdate).toHaveBeenCalledWith(expect.objectContaining({ entityName: 'color', id: 'entity1' }));
299
297
  });
300
- it('REMOVE_THUMBNAIL with no primaryViewableId returns clear updates without deleting', async () => {
298
+ it('REMOVE_THUMBNAIL with no primaryViewableId updates entity without deleting', async () => {
301
299
  const event = { data: { [thumbnail_util_1.ThumbnailUtil.NEW_THUMBNAIL_ID]: thumbnail_util_1.ThumbnailUtil.REMOVE_THUMBNAIL } };
302
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'item' });
300
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'item' });
303
301
  expect(mockEntitiesDelete).not.toHaveBeenCalled();
304
- expect(result).toEqual(expect.objectContaining({ primaryViewableId: null }));
302
+ expect(mockEntitiesUpdate).toHaveBeenCalledWith(expect.objectContaining({ entityName: 'item', id: 'entity1' }));
305
303
  });
306
- it('creates new content when no primaryViewableId exists and returns primary viewable updates', async () => {
304
+ it('creates new content when no primaryViewableId exists and updates entity', async () => {
307
305
  const mockResponse = {
308
306
  arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)),
309
307
  headers: { get: jest.fn().mockReturnValue('image/png') },
@@ -322,7 +320,7 @@ describe('ThumbnailUtil Tests', () => {
322
320
  };
323
321
  mockContentCreate.mockResolvedValue(createdContent);
324
322
  const event = { data: { [thumbnail_util_1.ThumbnailUtil.NEW_THUMBNAIL_ID]: '/rest/thumbnail/thumb.png' } };
325
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
323
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
326
324
  expect(mockGetRequest).toHaveBeenCalledWith({ urlPath: '/rest/thumbnail/thumb.png', includeUrlContext: false, returnFullResponse: true });
327
325
  expect(mockContentCreate).toHaveBeenCalledWith(expect.objectContaining({
328
326
  fileName: 'thumb.png',
@@ -334,13 +332,52 @@ describe('ThumbnailUtil Tests', () => {
334
332
  id: 'newContent1',
335
333
  object: { flexplmThumbnailUrl: '/rest/thumbnail/thumb.png' },
336
334
  }));
337
- expect(result).toEqual(expect.objectContaining({
338
- primaryViewableId: 'newContent1',
339
- primaryFileUrl: 'https://files/primary.png',
340
- largeViewableDownloadUrl: 'https://files/large.png',
335
+ expect(mockEntitiesUpdate).toHaveBeenCalledWith(expect.objectContaining({
336
+ entityName: 'color',
337
+ id: 'entity1',
341
338
  }));
342
339
  });
343
- it('replaces content when primaryViewable.flexplmThumbnailUrl differs and returns updates', async () => {
340
+ it('encodes URL path segments with special characters', async () => {
341
+ const mockResponse = {
342
+ arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)),
343
+ headers: { get: jest.fn().mockReturnValue('image/png') },
344
+ };
345
+ mockGetRequest.mockResolvedValue(mockResponse);
346
+ mockContentCreate.mockResolvedValue({
347
+ id: 'c1', contentType: 'image/png', fileName: 'my image.png',
348
+ primaryFileUrl: 'https://files/primary.png', largeViewableUrl: null,
349
+ mediumLargeViewableUrl: null, mediumViewableUrl: null, smallViewableUrl: null, tinyViewableUrl: null,
350
+ });
351
+ const event = { data: { [thumbnail_util_1.ThumbnailUtil.NEW_THUMBNAIL_ID]: '/rest/thumbnail/my image.png' } };
352
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
353
+ expect(mockGetRequest).toHaveBeenCalledWith({
354
+ urlPath: '/rest/thumbnail/my%20image.png',
355
+ includeUrlContext: false,
356
+ returnFullResponse: true,
357
+ });
358
+ expect(mockContentCreate).toHaveBeenCalledWith(expect.objectContaining({ fileName: 'my image.png' }));
359
+ });
360
+ it('encodes URL path segments with unicode characters', async () => {
361
+ const mockResponse = {
362
+ arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)),
363
+ headers: { get: jest.fn().mockReturnValue('image/jpeg') },
364
+ };
365
+ mockGetRequest.mockResolvedValue(mockResponse);
366
+ mockContentCreate.mockResolvedValue({
367
+ id: 'c2', contentType: 'image/jpeg', fileName: 'café-logo.jpg',
368
+ primaryFileUrl: 'https://files/primary.jpg', largeViewableUrl: null,
369
+ mediumLargeViewableUrl: null, mediumViewableUrl: null, smallViewableUrl: null, tinyViewableUrl: null,
370
+ });
371
+ const event = { data: { [thumbnail_util_1.ThumbnailUtil.NEW_THUMBNAIL_ID]: '/rest/thumbnail/café-logo.jpg' } };
372
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'item' });
373
+ expect(mockGetRequest).toHaveBeenCalledWith({
374
+ urlPath: '/rest/thumbnail/caf%C3%A9-logo.jpg',
375
+ includeUrlContext: false,
376
+ returnFullResponse: true,
377
+ });
378
+ expect(mockContentCreate).toHaveBeenCalledWith(expect.objectContaining({ fileName: 'café-logo.jpg' }));
379
+ });
380
+ it('replaces content when primaryViewable.flexplmThumbnailUrl differs and updates entity', async () => {
344
381
  mockEntitiesGet.mockImplementation((opts) => {
345
382
  if (opts.entityName === 'content-custom-size')
346
383
  return Promise.resolve([]);
@@ -367,17 +404,17 @@ describe('ThumbnailUtil Tests', () => {
367
404
  };
368
405
  mockContentCreate.mockResolvedValue(createdContent);
369
406
  const event = { data: { [thumbnail_util_1.ThumbnailUtil.NEW_THUMBNAIL_ID]: '/rest/thumbnail/new.jpg' } };
370
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', primaryViewableId: 'oldPv', event, entityName: 'item' });
407
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', primaryViewableId: 'oldPv', event, entityName: 'item' });
371
408
  expect(mockContentCreate).toHaveBeenCalled();
372
409
  expect(mockEntitiesUpdate).toHaveBeenCalledWith(expect.objectContaining({
373
410
  entityName: 'content',
374
411
  id: 'newContent2',
375
412
  object: { flexplmThumbnailUrl: '/rest/thumbnail/new.jpg' },
376
413
  }));
377
- expect(result).toEqual(expect.objectContaining({ primaryViewableId: 'newContent2' }));
414
+ expect(mockEntitiesUpdate).toHaveBeenCalledWith(expect.objectContaining({ entityName: 'item', id: 'entity1' }));
378
415
  expect(mockEntitiesDelete).toHaveBeenCalledWith({ entityName: 'content', id: 'oldPv' });
379
416
  });
380
- it('returns undefined when primaryViewable.flexplmThumbnailUrl matches', async () => {
417
+ it('does not update when primaryViewable.flexplmThumbnailUrl matches', async () => {
381
418
  const thumbnailUrl = '/rest/thumbnail/same.png';
382
419
  mockEntitiesGet.mockImplementation((opts) => {
383
420
  if (opts.entityName === 'content-custom-size')
@@ -388,8 +425,7 @@ describe('ThumbnailUtil Tests', () => {
388
425
  return Promise.resolve({});
389
426
  });
390
427
  const event = { data: { [thumbnail_util_1.ThumbnailUtil.EXISTING_THUMBNAIL_ID]: thumbnailUrl } };
391
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', primaryViewableId: 'pv1', event, entityName: 'color' });
392
- expect(result).toBeUndefined();
428
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', primaryViewableId: 'pv1', event, entityName: 'color' });
393
429
  expect(mockContentCreate).not.toHaveBeenCalled();
394
430
  expect(mockEntitiesUpdate).not.toHaveBeenCalled();
395
431
  expect(mockEntitiesDelete).not.toHaveBeenCalled();
@@ -14,7 +14,7 @@ export declare class TypeConversionUtils {
14
14
  static getEntityTypePathFromOjbect(fileId: any, mapFileUtil: any, object: any): Promise<string>;
15
15
  static getIdentifierPropertiesFromObject(fileId: any, mapFileUtil: MapFileUtil, object: any): Promise<string[]>;
16
16
  static getInformationalPropertiesFromObject(fileId: any, mapFileUtil: MapFileUtil, object: any): Promise<string[]>;
17
- static getMapKeyFromObject(fileId: any, mapFileUtil: MapFileUtil, object: any, direction: string): Promise<string>;
17
+ static getMapKeyFromObject(fileId: any, mapFileUtil: MapFileUtil, object: any, direction: string): Promise<string | undefined>;
18
18
  static isInboundCreatableFromObject(fileId: string, mapFileUtil: MapFileUtil, object: any, context?: any): Promise<boolean>;
19
19
  static isOutboundCreatableFromEntity(fileId: string, mapFileUtil: MapFileUtil, entity: any, context?: any): Promise<boolean>;
20
20
  static syncInboundImages(fileId: string, mapFileUtil: MapFileUtil, object: any, context?: any): Promise<boolean>;
@@ -179,7 +179,6 @@ class TypeConversionUtils {
179
179
  }
180
180
  return type;
181
181
  }
182
- return '';
183
182
  }
184
183
  static async isInboundCreatableFromObject(fileId, mapFileUtil, object, context) {
185
184
  let isInboundCreatable = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrail/flexplm",
3
- "version": "1.3.0-alpha.4",
3
+ "version": "1.3.0-alpha.6",
4
4
  "description": "Library used for integration with flexplm.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -359,35 +359,16 @@ describe('BaseEntityProcessor', () =>{
359
359
  syncThumbnailSpy = jest.spyOn(ThumbnailUtil.prototype, 'syncThumbnailToVibeIQ').mockResolvedValue(undefined);
360
360
  });
361
361
 
362
- it('should call updateEntity with thumbnail updates after create when syncInboundImages returns true', async () =>{
362
+ it('should call syncThumbnailToVibeIQ with entity after create when syncInboundImages returns true', async () =>{
363
363
  jest.spyOn(btep, 'getIncomingEntity' as any).mockResolvedValue({ entity: null });
364
364
  jest.spyOn(btep, 'getCreateEntity' as any).mockResolvedValue({ entity: { name: 'new' } });
365
365
  jest.spyOn(btep, 'createEntity').mockResolvedValue(mockCreatedEntity);
366
- const updateEntitySpy = jest.spyOn(btep, 'updateEntity').mockResolvedValue(mockCreatedEntity);
367
366
  syncInboundImagesSpy.mockResolvedValue(true);
368
- const thumbnailUpdates = { primaryViewableId: 'pv-new', primaryFileUrl: 'https://files/new.png' };
369
- syncThumbnailSpy.mockResolvedValue(thumbnailUpdates);
370
367
 
371
368
  const result = await btep.handleIncomingUpsert(mockEvent);
372
369
 
373
370
  expect(syncThumbnailSpy).toBeCalledTimes(1);
374
371
  expect(syncThumbnailSpy).toBeCalledWith({ entityId: 'created-1', primaryViewableId: 'pv-created', event: mockEvent, entityName: 'test' });
375
- expect(updateEntitySpy).toBeCalledWith('test', mockCreatedEntity, thumbnailUpdates);
376
- expect(result).toBe(mockCreatedEntity);
377
- });
378
-
379
- it('should not call updateEntity after create when syncThumbnailToVibeIQ returns undefined', async () =>{
380
- jest.spyOn(btep, 'getIncomingEntity' as any).mockResolvedValue({ entity: null });
381
- jest.spyOn(btep, 'getCreateEntity' as any).mockResolvedValue({ entity: { name: 'new' } });
382
- jest.spyOn(btep, 'createEntity').mockResolvedValue(mockCreatedEntity);
383
- const updateEntitySpy = jest.spyOn(btep, 'updateEntity');
384
- syncInboundImagesSpy.mockResolvedValue(true);
385
- syncThumbnailSpy.mockResolvedValue(undefined);
386
-
387
- const result = await btep.handleIncomingUpsert(mockEvent);
388
-
389
- expect(syncThumbnailSpy).toBeCalledTimes(1);
390
- expect(updateEntitySpy).not.toHaveBeenCalled();
391
372
  expect(result).toBe(mockCreatedEntity);
392
373
  });
393
374
 
@@ -404,33 +385,17 @@ describe('BaseEntityProcessor', () =>{
404
385
  expect(result).toBe(mockCreatedEntity);
405
386
  });
406
387
 
407
- it('should merge thumbnail updates with property diffs on update', async () =>{
388
+ it('should call syncThumbnailToVibeIQ with entity and update with property diffs on update', async () =>{
408
389
  jest.spyOn(btep, 'getIncomingEntity' as any).mockResolvedValue({ entity: mockExistingEntity });
409
390
  jest.spyOn(btep, 'getUpdatesForEntity').mockResolvedValue({ name: 'changed' });
410
391
  const updateEntitySpy = jest.spyOn(btep, 'updateEntity').mockResolvedValue(mockUpdatedEntity);
411
392
  syncInboundImagesSpy.mockResolvedValue(true);
412
- const thumbnailUpdates = { primaryViewableId: 'pv-new' };
413
- syncThumbnailSpy.mockResolvedValue(thumbnailUpdates);
414
393
 
415
394
  const result = await btep.handleIncomingUpsert(mockEvent);
416
395
 
417
396
  expect(syncThumbnailSpy).toBeCalledTimes(1);
418
397
  expect(syncThumbnailSpy).toBeCalledWith({ entityId: 'existing-1', primaryViewableId: undefined, event: mockEvent, entityName: 'test' });
419
- expect(updateEntitySpy).toBeCalledWith('test', mockExistingEntity, { name: 'changed', primaryViewableId: 'pv-new' });
420
- expect(result).toBe(mockUpdatedEntity);
421
- });
422
-
423
- it('should update entity with only thumbnail changes when no property diffs', async () =>{
424
- jest.spyOn(btep, 'getIncomingEntity' as any).mockResolvedValue({ entity: mockExistingEntity });
425
- jest.spyOn(btep, 'getUpdatesForEntity').mockResolvedValue({});
426
- const updateEntitySpy = jest.spyOn(btep, 'updateEntity').mockResolvedValue(mockUpdatedEntity);
427
- syncInboundImagesSpy.mockResolvedValue(true);
428
- const thumbnailUpdates = { primaryViewableId: 'pv-new' };
429
- syncThumbnailSpy.mockResolvedValue(thumbnailUpdates);
430
-
431
- const result = await btep.handleIncomingUpsert(mockEvent);
432
-
433
- expect(updateEntitySpy).toBeCalledWith('test', mockExistingEntity, { primaryViewableId: 'pv-new' });
398
+ expect(updateEntitySpy).toBeCalledWith('test', mockExistingEntity, { name: 'changed' });
434
399
  expect(result).toBe(mockUpdatedEntity);
435
400
  });
436
401
 
@@ -84,15 +84,12 @@ export abstract class BaseEntityProcessor {
84
84
  console.log(statusMsg);
85
85
  return createEntityResponse.earlyReturn;
86
86
  }
87
- const createdEntity = await this.createEntity(this.baseType, createEntityResponse.entity);
87
+ let createdEntity = await this.createEntity(this.baseType, createEntityResponse.entity);
88
88
  const shouldSyncThumbnail = await TypeConversionUtils.syncInboundImages(
89
89
  this.transformMapFile, this.mapFileUtil, event.data
90
90
  );
91
91
  if (shouldSyncThumbnail) {
92
- const thumbnailUpdates = await new ThumbnailUtil(this.config).syncThumbnailToVibeIQ({ entityId: createdEntity.id, primaryViewableId: createdEntity.primaryViewableId, event, entityName: this.baseType });
93
- if (thumbnailUpdates) {
94
- await this.updateEntity(this.baseType, createdEntity, thumbnailUpdates);
95
- }
92
+ createdEntity = await new ThumbnailUtil(this.config).syncThumbnailToVibeIQ({ entityId: createdEntity.id, primaryViewableId: createdEntity.primaryViewableId, event, entityName: this.baseType }) || createdEntity;
96
93
  }
97
94
  const statusMsg = this.getInboundStatusMessage({
98
95
  status: EventShortMessageStatus.SUCCESS,
@@ -109,13 +106,26 @@ export abstract class BaseEntityProcessor {
109
106
  const shouldSyncThumbnail = await TypeConversionUtils.syncInboundImages(
110
107
  this.transformMapFile, this.mapFileUtil, event.data
111
108
  );
112
- let thumbnailUpdates;
109
+ let thumbnailEntity;
113
110
  if (shouldSyncThumbnail) {
114
- thumbnailUpdates = await new ThumbnailUtil(this.config).syncThumbnailToVibeIQ({ entityId: entity.id, primaryViewableId: entity.primaryViewableId, event, entityName: this.baseType });
111
+ thumbnailEntity = await new ThumbnailUtil(this.config).syncThumbnailToVibeIQ({ entityId: entity.id, primaryViewableId: entity.primaryViewableId, event, entityName: this.baseType });
115
112
  }
116
113
 
117
- const allUpdates = { ...diffs, ...thumbnailUpdates };
118
- if(Object.getOwnPropertyNames(allUpdates).length == 0){
114
+ const hasPropertyChanges = Object.getOwnPropertyNames(diffs).length > 0;
115
+
116
+ if (!hasPropertyChanges && thumbnailEntity) {
117
+ const statusMsg = this.getInboundStatusMessage({
118
+ status: EventShortMessageStatus.SUCCESS,
119
+ statusMessage: EventShortMessageStatus.PRIMARY_CONTENT_UPDATED,
120
+ objectClass: event.objectClass,
121
+ entityId: entity.id,
122
+ federatedId: event.federatedId
123
+ });
124
+ console.log(statusMsg);
125
+ return thumbnailEntity;
126
+ }
127
+
128
+ if (!hasPropertyChanges) {
119
129
  const statusMsg = this.getInboundStatusMessage({
120
130
  status: EventShortMessageStatus.SUCCESS,
121
131
  statusMessage: EventShortMessageStatus.NO_CHANGES,
@@ -131,7 +141,7 @@ export abstract class BaseEntityProcessor {
131
141
  };
132
142
  }
133
143
 
134
- const updatedEntity = await this.updateEntity(this.baseType, entity, allUpdates);
144
+ const updatedEntity = await this.updateEntity(this.baseType, entity, diffs);
135
145
  const statusMsg = this.getInboundStatusMessage({
136
146
  status: EventShortMessageStatus.SUCCESS,
137
147
  statusMessage: EventShortMessageStatus.UPDATED,
@@ -9,6 +9,7 @@ export enum EventShortMessageStatus {
9
9
  MISSING_INPUT = 'Missing_input',
10
10
  NOT_CREATABLE = 'Not_creatable',
11
11
  NO_CHANGES = 'No_Changes',
12
+ PRIMARY_CONTENT_UPDATED = 'Primary_Content_Updated',
12
13
  TOO_MANY_ENTITIES_FOUND = 'Too_Many_Entities_Found',
13
14
  UPDATED = 'Updated',
14
15
 
@@ -308,42 +308,40 @@ describe('ThumbnailUtil Tests', () =>{
308
308
  mockEntitiesDelete.mockImplementation((opts) => Promise.resolve({ id: opts.id }));
309
309
  });
310
310
 
311
- it('returns undefined when no thumbnail IDs in event data', async () => {
311
+ it('does not update when no thumbnail IDs in event data', async () => {
312
312
  const event = { data: {} };
313
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
313
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
314
314
 
315
- expect(result).toBeUndefined();
316
315
  expect(mockEntitiesUpdate).not.toHaveBeenCalled();
317
316
  expect(mockEntitiesDelete).not.toHaveBeenCalled();
318
317
  expect(mockContentCreate).not.toHaveBeenCalled();
319
318
  });
320
319
 
321
- it('returns undefined when event.data is undefined', async () => {
320
+ it('does not update when event.data is undefined', async () => {
322
321
  const event = {};
323
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
322
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
324
323
 
325
- expect(result).toBeUndefined();
326
324
  expect(mockEntitiesUpdate).not.toHaveBeenCalled();
327
325
  expect(mockEntitiesDelete).not.toHaveBeenCalled();
328
326
  });
329
327
 
330
- it('REMOVE_THUMBNAIL with existing primaryViewableId deletes content and returns clear updates', async () => {
328
+ it('REMOVE_THUMBNAIL with existing primaryViewableId deletes content and updates entity', async () => {
331
329
  const event = { data: { [ThumbnailUtil.NEW_THUMBNAIL_ID]: ThumbnailUtil.REMOVE_THUMBNAIL } };
332
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', primaryViewableId: 'pv1', event, entityName: 'color' });
330
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', primaryViewableId: 'pv1', event, entityName: 'color' });
333
331
 
334
332
  expect(mockEntitiesDelete).toHaveBeenCalledWith({ entityName: 'content', id: 'pv1' });
335
- expect(result).toEqual(expect.objectContaining({ primaryViewableId: null, primaryFileUrl: null }));
333
+ expect(mockEntitiesUpdate).toHaveBeenCalledWith(expect.objectContaining({ entityName: 'color', id: 'entity1' }));
336
334
  });
337
335
 
338
- it('REMOVE_THUMBNAIL with no primaryViewableId returns clear updates without deleting', async () => {
336
+ it('REMOVE_THUMBNAIL with no primaryViewableId updates entity without deleting', async () => {
339
337
  const event = { data: { [ThumbnailUtil.NEW_THUMBNAIL_ID]: ThumbnailUtil.REMOVE_THUMBNAIL } };
340
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'item' });
338
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'item' });
341
339
 
342
340
  expect(mockEntitiesDelete).not.toHaveBeenCalled();
343
- expect(result).toEqual(expect.objectContaining({ primaryViewableId: null }));
341
+ expect(mockEntitiesUpdate).toHaveBeenCalledWith(expect.objectContaining({ entityName: 'item', id: 'entity1' }));
344
342
  });
345
343
 
346
- it('creates new content when no primaryViewableId exists and returns primary viewable updates', async () => {
344
+ it('creates new content when no primaryViewableId exists and updates entity', async () => {
347
345
  const mockResponse = {
348
346
  arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)),
349
347
  headers: { get: jest.fn().mockReturnValue('image/png') },
@@ -364,7 +362,7 @@ describe('ThumbnailUtil Tests', () =>{
364
362
  mockContentCreate.mockResolvedValue(createdContent);
365
363
 
366
364
  const event = { data: { [ThumbnailUtil.NEW_THUMBNAIL_ID]: '/rest/thumbnail/thumb.png' } };
367
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
365
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
368
366
 
369
367
  expect(mockGetRequest).toHaveBeenCalledWith({ urlPath: '/rest/thumbnail/thumb.png', includeUrlContext: false, returnFullResponse: true });
370
368
  expect(mockContentCreate).toHaveBeenCalledWith(
@@ -382,15 +380,66 @@ describe('ThumbnailUtil Tests', () =>{
382
380
  object: { flexplmThumbnailUrl: '/rest/thumbnail/thumb.png' },
383
381
  }),
384
382
  );
385
- // Returns primary viewable updates for the entity
386
- expect(result).toEqual(expect.objectContaining({
387
- primaryViewableId: 'newContent1',
388
- primaryFileUrl: 'https://files/primary.png',
389
- largeViewableDownloadUrl: 'https://files/large.png',
390
- }));
383
+ // Updates the main entity
384
+ expect(mockEntitiesUpdate).toHaveBeenCalledWith(
385
+ expect.objectContaining({
386
+ entityName: 'color',
387
+ id: 'entity1',
388
+ }),
389
+ );
390
+ });
391
+
392
+ it('encodes URL path segments with special characters', async () => {
393
+ const mockResponse = {
394
+ arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)),
395
+ headers: { get: jest.fn().mockReturnValue('image/png') },
396
+ };
397
+ mockGetRequest.mockResolvedValue(mockResponse);
398
+ mockContentCreate.mockResolvedValue({
399
+ id: 'c1', contentType: 'image/png', fileName: 'my image.png',
400
+ primaryFileUrl: 'https://files/primary.png', largeViewableUrl: null,
401
+ mediumLargeViewableUrl: null, mediumViewableUrl: null, smallViewableUrl: null, tinyViewableUrl: null,
402
+ });
403
+
404
+ const event = { data: { [ThumbnailUtil.NEW_THUMBNAIL_ID]: '/rest/thumbnail/my image.png' } };
405
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'color' });
406
+
407
+ expect(mockGetRequest).toHaveBeenCalledWith({
408
+ urlPath: '/rest/thumbnail/my%20image.png',
409
+ includeUrlContext: false,
410
+ returnFullResponse: true,
411
+ });
412
+ expect(mockContentCreate).toHaveBeenCalledWith(
413
+ expect.objectContaining({ fileName: 'my image.png' }),
414
+ );
391
415
  });
392
416
 
393
- it('replaces content when primaryViewable.flexplmThumbnailUrl differs and returns updates', async () => {
417
+ it('encodes URL path segments with unicode characters', async () => {
418
+ const mockResponse = {
419
+ arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(8)),
420
+ headers: { get: jest.fn().mockReturnValue('image/jpeg') },
421
+ };
422
+ mockGetRequest.mockResolvedValue(mockResponse);
423
+ mockContentCreate.mockResolvedValue({
424
+ id: 'c2', contentType: 'image/jpeg', fileName: 'café-logo.jpg',
425
+ primaryFileUrl: 'https://files/primary.jpg', largeViewableUrl: null,
426
+ mediumLargeViewableUrl: null, mediumViewableUrl: null, smallViewableUrl: null, tinyViewableUrl: null,
427
+ });
428
+
429
+ const event = { data: { [ThumbnailUtil.NEW_THUMBNAIL_ID]: '/rest/thumbnail/café-logo.jpg' } };
430
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', event, entityName: 'item' });
431
+
432
+ expect(mockGetRequest).toHaveBeenCalledWith({
433
+ urlPath: '/rest/thumbnail/caf%C3%A9-logo.jpg',
434
+ includeUrlContext: false,
435
+ returnFullResponse: true,
436
+ });
437
+ expect(mockContentCreate).toHaveBeenCalledWith(
438
+ expect.objectContaining({ fileName: 'café-logo.jpg' }),
439
+ );
440
+ });
441
+
442
+ it('replaces content when primaryViewable.flexplmThumbnailUrl differs and updates entity', async () => {
394
443
  mockEntitiesGet.mockImplementation((opts) => {
395
444
  if (opts.entityName === 'content-custom-size') return Promise.resolve([]);
396
445
  if (opts.entityName === 'content' && opts.id === 'oldPv') {
@@ -419,7 +468,7 @@ describe('ThumbnailUtil Tests', () =>{
419
468
  mockContentCreate.mockResolvedValue(createdContent);
420
469
 
421
470
  const event = { data: { [ThumbnailUtil.NEW_THUMBNAIL_ID]: '/rest/thumbnail/new.jpg' } };
422
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', primaryViewableId: 'oldPv', event, entityName: 'item' });
471
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', primaryViewableId: 'oldPv', event, entityName: 'item' });
423
472
 
424
473
  // Creates new content
425
474
  expect(mockContentCreate).toHaveBeenCalled();
@@ -431,13 +480,15 @@ describe('ThumbnailUtil Tests', () =>{
431
480
  object: { flexplmThumbnailUrl: '/rest/thumbnail/new.jpg' },
432
481
  }),
433
482
  );
434
- // Returns primary viewable updates for the entity
435
- expect(result).toEqual(expect.objectContaining({ primaryViewableId: 'newContent2' }));
483
+ // Updates the main entity
484
+ expect(mockEntitiesUpdate).toHaveBeenCalledWith(
485
+ expect.objectContaining({ entityName: 'item', id: 'entity1' }),
486
+ );
436
487
  // Deletes old content
437
488
  expect(mockEntitiesDelete).toHaveBeenCalledWith({ entityName: 'content', id: 'oldPv' });
438
489
  });
439
490
 
440
- it('returns undefined when primaryViewable.flexplmThumbnailUrl matches', async () => {
491
+ it('does not update when primaryViewable.flexplmThumbnailUrl matches', async () => {
441
492
  const thumbnailUrl = '/rest/thumbnail/same.png';
442
493
  mockEntitiesGet.mockImplementation((opts) => {
443
494
  if (opts.entityName === 'content-custom-size') return Promise.resolve([]);
@@ -448,9 +499,7 @@ describe('ThumbnailUtil Tests', () =>{
448
499
  });
449
500
 
450
501
  const event = { data: { [ThumbnailUtil.EXISTING_THUMBNAIL_ID]: thumbnailUrl } };
451
- const result = await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', primaryViewableId: 'pv1', event, entityName: 'color' });
452
-
453
- expect(result).toBeUndefined();
502
+ await tu.syncThumbnailToVibeIQ({ entityId: 'entity1', primaryViewableId: 'pv1', event, entityName: 'color' });
454
503
  expect(mockContentCreate).not.toHaveBeenCalled();
455
504
  expect(mockEntitiesUpdate).not.toHaveBeenCalled();
456
505
  expect(mockEntitiesDelete).not.toHaveBeenCalled();
@@ -9,6 +9,7 @@ interface ContentCustomSize {
9
9
  name: string;
10
10
  };
11
11
  export class ThumbnailUtil {
12
+ /** The max_thumbnail_size is for limiting the size of the thumbnail being sent to FlexPLM. It is used when checking the size of the auto generated thumbnails (smallViewable, tinyViewable, etc.). */
12
13
  private max_thumbnail_size = 5 * 1_024 * 1_024;
13
14
  private entities: Entities;
14
15
  static NEW_THUMBNAIL_ID = 'NEW_THUMBNAIL_ID';
@@ -140,6 +141,15 @@ export class ThumbnailUtil {
140
141
  }
141
142
  }
142
143
 
144
+ /** Syncs the thumbnail from FlexPLM to VibeIQ. Handles creating, replacing, or removing
145
+ * the primary viewable content and persists the updates directly to the entity.
146
+ *
147
+ * @param entityId - The ID of the entity to update with thumbnail properties.
148
+ * @param primaryViewableId - The existing primary viewable content ID, if any.
149
+ * @param event - The inbound event containing thumbnail data (NEW_THUMBNAIL_ID / EXISTING_THUMBNAIL_ID).
150
+ * @param entityName - The entity type name (e.g. 'item', 'color') used for API calls.
151
+ * @returns The updated entity, or undefined if no thumbnail changes were needed.
152
+ */
143
153
  async syncThumbnailToVibeIQ({ entityId, primaryViewableId, event, entityName }: { entityId: string; primaryViewableId?: string; event: any; entityName: string }): Promise<any> {
144
154
  console.debug(`syncThumbnailToVibeIQ: entityId=${entityId}, primaryViewableId=${primaryViewableId}, entityName=${entityName}`);
145
155
  const eventData = event.data || {};
@@ -153,8 +163,9 @@ export class ThumbnailUtil {
153
163
  await this.entities.delete({ entityName: 'content', id: primaryViewableId });
154
164
  }
155
165
  const clearUpdates = await this.getClearPrimaryViewableUpdates();
156
- console.debug(`syncThumbnailToVibeIQ: returning clear updates for entityId=${entityId}`);
157
- return clearUpdates;
166
+ const updatedEntity = await this.entities.update({ entityName, id: entityId, object: clearUpdates });
167
+ console.debug(`syncThumbnailToVibeIQ: applied clear updates for entityId=${entityId}`);
168
+ return updatedEntity;
158
169
  }
159
170
 
160
171
  // Early return if no thumbnail URL
@@ -168,8 +179,9 @@ export class ThumbnailUtil {
168
179
  const content = await this.createContentFromFlexPLM(thumbnailUrl, entityId, entityName);
169
180
  await this.entities.update({ entityName: 'content', id: content.id, object: { flexplmThumbnailUrl: thumbnailUrl } });
170
181
  const primaryUpdates = await this.getPrimaryViewableUpdates(content);
182
+ const updatedEntity = await this.entities.update({ entityName, id: entityId, object: primaryUpdates });
171
183
  console.debug(`syncThumbnailToVibeIQ: created new content ${content.id} for entityId=${entityId}`);
172
- return primaryUpdates;
184
+ return updatedEntity;
173
185
  }
174
186
 
175
187
  // Case 3: Has primaryViewableId — check if thumbnail changed
@@ -182,15 +194,20 @@ export class ThumbnailUtil {
182
194
  const content = await this.createContentFromFlexPLM(thumbnailUrl, entityId, entityName);
183
195
  await this.entities.update({ entityName: 'content', id: content.id, object: { flexplmThumbnailUrl: thumbnailUrl } });
184
196
  const primaryUpdates = await this.getPrimaryViewableUpdates(content);
197
+ const updatedEntity = await this.entities.update({ entityName, id: entityId, object: primaryUpdates });
185
198
  await this.entities.delete({ entityName: 'content', id: primaryViewableId });
186
199
  console.debug(`syncThumbnailToVibeIQ: replaced content ${primaryViewableId} with ${content.id} for entityId=${entityId}`);
187
- return primaryUpdates;
200
+ return updatedEntity;
188
201
  }
189
202
 
190
203
  private async createContentFromFlexPLM(thumbnailUrl: string, entityId: string, entityName: string): Promise<any> {
204
+ const urlParts = thumbnailUrl.split('/');
205
+ const fileName = urlParts[urlParts.length - 1] || 'thumbnail';
206
+
207
+ const encodedUrl = urlParts.map(part => encodeURIComponent(part)).join('/');
191
208
  const flexPLMConnect = new FlexPLMConnect(this.config);
192
209
  const response = await flexPLMConnect.getRequest({
193
- urlPath: thumbnailUrl,
210
+ urlPath: encodedUrl,
194
211
  includeUrlContext: false,
195
212
  returnFullResponse: true,
196
213
  }) as Response;
@@ -200,9 +217,6 @@ export class ThumbnailUtil {
200
217
  const contentTypeHeader = response.headers.get('content-type');
201
218
  const contentType = contentTypeHeader ? contentTypeHeader.split(';')[0] : 'application/octet-stream';
202
219
 
203
- const urlParts = thumbnailUrl.split('/');
204
- const fileName = urlParts[urlParts.length - 1] || 'thumbnail';
205
-
206
220
  const contentHolderReference = `${entityName}:${entityId}`;
207
221
  const content = await new Content().create({
208
222
  fileBuffer: buffer,
@@ -323,7 +323,7 @@ export class TypeConversionUtils {
323
323
  return TypeDefaults.getDefaultInformationalPropertiesFromObject(object);
324
324
  }
325
325
 
326
- static async getMapKeyFromObject(fileId, mapFileUtil: MapFileUtil, object: any, direction:string): Promise<string> {
326
+ static async getMapKeyFromObject(fileId, mapFileUtil: MapFileUtil, object: any, direction:string): Promise<string | undefined> {
327
327
  const type = this.getObjectType(object);
328
328
  if(fileId){
329
329
  const mappingData = await mapFileUtil.getMappingSection(fileId, 'typeConversion', direction);
@@ -334,7 +334,6 @@ export class TypeConversionUtils {
334
334
  return type;
335
335
  }
336
336
  //TODO use TypeDefaults?
337
- return '';
338
337
  }
339
338
 
340
339
  static async isInboundCreatableFromObject(fileId: string, mapFileUtil: MapFileUtil, object: any, context?: any): Promise<boolean> {