@adobe/spacecat-shared-tier-client 1.3.12 → 1.3.13

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
@@ -1,3 +1,9 @@
1
+ ## [@adobe/spacecat-shared-tier-client-v1.3.13](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tier-client-v1.3.12...@adobe/spacecat-shared-tier-client-v1.3.13) (2026-03-01)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **tier-client:** avoid 414 URI Too Large on orgs with many enrollments ([#1390](https://github.com/adobe/spacecat-shared/issues/1390)) ([2e9de5e](https://github.com/adobe/spacecat-shared/commit/2e9de5e685c2a25801d4d50ad7317773ee706ef0))
6
+
1
7
  ## [@adobe/spacecat-shared-tier-client-v1.3.12](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tier-client-v1.3.11...@adobe/spacecat-shared-tier-client-v1.3.12) (2026-02-17)
2
8
 
3
9
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tier-client",
3
- "version": "1.3.12",
3
+ "version": "1.3.13",
4
4
  "description": "Shared modules of the Spacecat Services - Tier Client",
5
5
  "type": "module",
6
6
  "engines": {
@@ -239,10 +239,40 @@ class TierClient {
239
239
  return { entitlement, enrollments: [] };
240
240
  }
241
241
 
242
- // Fetch all sites using batchGetByKeys
242
+ // When a specific site is provided, skip batch fetch entirely.
243
+ // Just filter enrollments by site ID and verify org ownership with a single lookup.
244
+ if (this.site) {
245
+ const targetSiteId = this.site.getId();
246
+ const matchingEnrollments = allEnrollments.filter(
247
+ (se) => se.getSiteId() === targetSiteId,
248
+ );
249
+
250
+ if (matchingEnrollments.length === 0) {
251
+ return { entitlement, enrollments: [] };
252
+ }
253
+
254
+ const site = await this.Site.findById(targetSiteId);
255
+ if (!site || site.getOrganizationId() !== orgId) {
256
+ return { entitlement, enrollments: [] };
257
+ }
258
+
259
+ return { entitlement, enrollments: matchingEnrollments };
260
+ }
261
+
262
+ // Org-only path: fetch sites in chunks to avoid 414 URI Too Large.
263
+ // PostgREST uses GET with ?id=in.(...) which has URL length limits.
264
+ const CHUNK_SIZE = 50;
243
265
  const siteKeys = allEnrollments.map((enrollment) => ({ siteId: enrollment.getSiteId() }));
244
- const sitesResult = await this.Site.batchGetByKeys(siteKeys);
245
- const sitesMap = new Map(sitesResult.data.map((site) => [site.getId(), site]));
266
+ const sitesMap = new Map();
267
+
268
+ for (let i = 0; i < siteKeys.length; i += CHUNK_SIZE) {
269
+ const chunk = siteKeys.slice(i, i + CHUNK_SIZE);
270
+ // eslint-disable-next-line no-await-in-loop
271
+ const sitesResult = await this.Site.batchGetByKeys(chunk);
272
+ for (const site of sitesResult.data) {
273
+ sitesMap.set(site.getId(), site);
274
+ }
275
+ }
246
276
 
247
277
  // Filter enrollments where site's orgId matches the entitlement's orgId
248
278
  const validEnrollments = [];
@@ -250,27 +280,13 @@ class TierClient {
250
280
  for (const enrollment of allEnrollments) {
251
281
  const site = sitesMap.get(enrollment.getSiteId());
252
282
  if (!site) {
253
- // Site not found, log warning and skip
254
283
  this.log.warn(`Site not found for enrollment ${enrollment.getId()} with siteId ${enrollment.getSiteId()}`);
255
- } else {
256
- const siteOrgId = site.getOrganizationId();
257
- if (siteOrgId === orgId) {
258
- validEnrollments.push(enrollment);
259
- }
284
+ } else if (site.getOrganizationId() === orgId) {
285
+ validEnrollments.push(enrollment);
260
286
  }
261
287
  }
262
288
 
263
- if (this.site) {
264
- // Return site enrollments matching the entitlement and site
265
- const siteId = this.site.getId();
266
- const matchingEnrollments = validEnrollments.filter(
267
- (se) => se.getSiteId() === siteId,
268
- );
269
- return { entitlement, enrollments: matchingEnrollments };
270
- } else {
271
- // Return all valid enrollments for the entitlement
272
- return { entitlement, enrollments: validEnrollments };
273
- }
289
+ return { entitlement, enrollments: validEnrollments };
274
290
  } catch (error) {
275
291
  this.log.error(`Error getting all enrollments: ${error.message}`);
276
292
  throw error;
@@ -279,28 +295,51 @@ class TierClient {
279
295
 
280
296
  /**
281
297
  * Gets the first enrollment and its site, filtered by productCode.
282
- * - If site is provided: returns site enrollment for the entitlement matching productCode
283
- * - If org-only: returns first site enrollment for the entitlement matching productCode
298
+ * - If site is provided: finds matching enrollment and returns this.site directly
299
+ * - If org-only: iterates enrollments, fetches sites one at a time, returns first org match
284
300
  * @returns {Promise<object>} Object with entitlement, enrollment, and site.
285
301
  */
286
302
  async getFirstEnrollment() {
287
303
  try {
288
- const { entitlement, enrollments } = await this.getAllEnrollment();
304
+ const orgId = this.organization.getId();
305
+ const entitlement = await this.Entitlement
306
+ .findByOrganizationIdAndProductCode(orgId, this.productCode);
307
+
308
+ if (!entitlement) {
309
+ return { entitlement: null, enrollment: null, site: null };
310
+ }
311
+
312
+ const allEnrollments = await this.SiteEnrollment.allByEntitlementId(entitlement.getId());
289
313
 
290
- if (!entitlement || !enrollments?.length) {
314
+ if (!allEnrollments || allEnrollments.length === 0) {
291
315
  return { entitlement: null, enrollment: null, site: null };
292
316
  }
293
317
 
294
- const firstEnrollment = enrollments[0];
295
- const enrollmentSiteId = firstEnrollment.getSiteId();
296
- const site = await this.Site.findById(enrollmentSiteId);
318
+ // When a specific site is set, find its enrollment in memory — no fetch needed.
319
+ if (this.site) {
320
+ const targetSiteId = this.site.getId();
321
+ const matchingEnrollment = allEnrollments.find(
322
+ (se) => se.getSiteId() === targetSiteId,
323
+ );
324
+ if (matchingEnrollment) {
325
+ return { entitlement, enrollment: matchingEnrollment, site: this.site };
326
+ }
327
+ return { entitlement: null, enrollment: null, site: null };
328
+ }
297
329
 
298
- if (!site) {
299
- this.log.warn(`Site not found for enrollment ${firstEnrollment.getId()} with site ID ${enrollmentSiteId}`);
300
- return { entitlement, enrollment: firstEnrollment, site: null };
330
+ // Org-only: iterate enrollments, fetch site one at a time, return first org match.
331
+ // This avoids batch-fetching all sites (which causes 414 on large sets).
332
+ for (const enrollment of allEnrollments) {
333
+ // eslint-disable-next-line no-await-in-loop
334
+ const site = await this.Site.findById(enrollment.getSiteId());
335
+ if (!site) {
336
+ this.log.warn(`Site not found for enrollment ${enrollment.getId()} with siteId ${enrollment.getSiteId()}`);
337
+ } else if (site.getOrganizationId() === orgId) {
338
+ return { entitlement, enrollment, site };
339
+ }
301
340
  }
302
341
 
303
- return { entitlement, enrollment: firstEnrollment, site };
342
+ return { entitlement: null, enrollment: null, site: null };
304
343
  } catch (error) {
305
344
  this.log.error(`Error getting first enrollment: ${error.message}`);
306
345
  throw error;
@@ -764,20 +764,12 @@ describe('TierClient', () => {
764
764
  getOrganizationId: () => orgId,
765
765
  };
766
766
 
767
- const mockSiteForEnrollment2 = {
768
- getId: () => 'other-site-id',
769
- getOrganizationId: () => orgId,
770
- };
771
-
772
767
  mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
773
768
  mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([
774
769
  mockSiteEnrollment,
775
770
  mockEnrollment2,
776
771
  ]);
777
- mockDataAccess.Site.batchGetByKeys.resolves({
778
- data: [mockSiteForEnrollment1, mockSiteForEnrollment2],
779
- unprocessed: [],
780
- });
772
+ mockDataAccess.Site.findById.resolves(mockSiteForEnrollment1);
781
773
 
782
774
  const result = await tierClient.getAllEnrollment();
783
775
 
@@ -785,8 +777,8 @@ describe('TierClient', () => {
785
777
  entitlement: mockEntitlement,
786
778
  enrollments: [mockSiteEnrollment],
787
779
  });
788
- expect(mockDataAccess.SiteEnrollment.allByEntitlementId)
789
- .to.have.been.calledWith('entitlement-123');
780
+ expect(mockDataAccess.Site.batchGetByKeys).to.not.have.been.called;
781
+ expect(mockDataAccess.Site.findById).to.have.been.calledWith(siteId);
790
782
  });
791
783
 
792
784
  it('should return null entitlement and empty enrollments when no entitlement exists', async () => {
@@ -820,6 +812,38 @@ describe('TierClient', () => {
820
812
  expect(mockContext.log.error).to.have.been.calledWith('Error getting all enrollments: Database error');
821
813
  });
822
814
 
815
+ it('should return empty enrollments when site has no matching enrollments', async () => {
816
+ const nonMatchingEnrollment = {
817
+ getId: () => 'enrollment-999',
818
+ getSiteId: () => 'other-site-id',
819
+ getEntitlementId: () => 'entitlement-123',
820
+ };
821
+
822
+ mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
823
+ mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([nonMatchingEnrollment]);
824
+
825
+ const result = await tierClient.getAllEnrollment();
826
+
827
+ expect(result).to.deep.equal({ entitlement: mockEntitlement, enrollments: [] });
828
+ expect(mockDataAccess.Site.findById).to.not.have.been.called;
829
+ });
830
+
831
+ it('should return empty enrollments when site org does not match', async () => {
832
+ const wrongOrgSite = {
833
+ getId: () => siteId,
834
+ getOrganizationId: () => 'wrong-org-id',
835
+ };
836
+
837
+ mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
838
+ mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([mockSiteEnrollment]);
839
+ mockDataAccess.Site.findById.resolves(wrongOrgSite);
840
+
841
+ const result = await tierClient.getAllEnrollment();
842
+
843
+ expect(result).to.deep.equal({ entitlement: mockEntitlement, enrollments: [] });
844
+ expect(mockDataAccess.Site.findById).to.have.been.calledWith(siteId);
845
+ });
846
+
823
847
  it('should filter out enrollments not matching site ID', async () => {
824
848
  const mockEnrollment2 = {
825
849
  getId: () => 'enrollment-456',
@@ -838,26 +862,20 @@ describe('TierClient', () => {
838
862
  getOrganizationId: () => orgId,
839
863
  };
840
864
 
841
- const mockSiteForEnrollment2 = {
842
- getId: () => 'different-site-id',
843
- getOrganizationId: () => orgId,
844
- };
845
-
846
865
  mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
847
866
  mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([
848
867
  mockSiteEnrollment,
849
868
  mockEnrollment2,
850
869
  mockEnrollment3,
851
870
  ]);
852
- mockDataAccess.Site.batchGetByKeys.resolves({
853
- data: [mockSiteForEnrollment1, mockSiteForEnrollment2, mockSiteForEnrollment1],
854
- unprocessed: [],
855
- });
871
+ mockDataAccess.Site.findById.resolves(mockSiteForEnrollment1);
856
872
 
857
873
  const result = await tierClient.getAllEnrollment();
858
874
 
859
875
  expect(result.enrollments).to.have.lengthOf(2);
860
876
  expect(result.enrollments).to.deep.equal([mockSiteEnrollment, mockEnrollment3]);
877
+ expect(mockDataAccess.Site.batchGetByKeys).to.not.have.been.called;
878
+ expect(mockDataAccess.Site.findById).to.have.been.calledWith(siteId);
861
879
  });
862
880
 
863
881
  it('should filter out enrollments with mismatching orgId', async () => {
@@ -939,10 +957,6 @@ describe('TierClient', () => {
939
957
 
940
958
  mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
941
959
  mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([mockSiteEnrollment]);
942
- mockDataAccess.Site.batchGetByKeys.resolves({
943
- data: [mockSiteObject],
944
- unprocessed: [],
945
- });
946
960
  mockDataAccess.Site.findById.resolves(mockSiteObject);
947
961
 
948
962
  const tierClientWithoutSite = new TierClient(
@@ -960,6 +974,7 @@ describe('TierClient', () => {
960
974
  site: mockSiteObject,
961
975
  });
962
976
  expect(mockDataAccess.Site.findById).to.have.been.calledWith(siteId);
977
+ expect(mockDataAccess.Site.batchGetByKeys).to.not.have.been.called;
963
978
  });
964
979
 
965
980
  it('should return first enrollment when multiple exist', async () => {
@@ -969,12 +984,6 @@ describe('TierClient', () => {
969
984
  getOrganizationId: () => orgId,
970
985
  };
971
986
 
972
- const mockSiteObject2 = {
973
- getId: () => 'other-site-id',
974
- getName: () => 'Other Site',
975
- getOrganizationId: () => orgId,
976
- };
977
-
978
987
  const mockEnrollment2 = {
979
988
  getId: () => 'enrollment-456',
980
989
  getSiteId: () => 'other-site-id',
@@ -986,10 +995,6 @@ describe('TierClient', () => {
986
995
  mockSiteEnrollment,
987
996
  mockEnrollment2,
988
997
  ]);
989
- mockDataAccess.Site.batchGetByKeys.resolves({
990
- data: [mockSiteObject, mockSiteObject2],
991
- unprocessed: [],
992
- });
993
998
  mockDataAccess.Site.findById.resolves(mockSiteObject);
994
999
 
995
1000
  const tierClientWithoutSite = new TierClient(
@@ -1002,7 +1007,9 @@ describe('TierClient', () => {
1002
1007
  const result = await tierClientWithoutSite.getFirstEnrollment();
1003
1008
 
1004
1009
  expect(result.enrollment).to.equal(mockSiteEnrollment);
1005
- expect(mockDataAccess.Site.findById).to.have.been.calledWith(siteId);
1010
+ expect(result.site).to.equal(mockSiteObject);
1011
+ expect(mockDataAccess.Site.findById).to.have.been.calledOnceWith(siteId);
1012
+ expect(mockDataAccess.Site.batchGetByKeys).to.not.have.been.called;
1006
1013
  });
1007
1014
 
1008
1015
  it('should return nulls when no entitlement exists', async () => {
@@ -1032,19 +1039,9 @@ describe('TierClient', () => {
1032
1039
  expect(mockDataAccess.Site.findById).to.not.have.been.called;
1033
1040
  });
1034
1041
 
1035
- it('should return null site when site not found in database', async () => {
1036
- const mockSiteObject = {
1037
- getId: () => siteId,
1038
- getName: () => 'Test Site',
1039
- getOrganizationId: () => orgId,
1040
- };
1041
-
1042
+ it('should skip enrollment when site not found and return nulls', async () => {
1042
1043
  mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
1043
1044
  mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([mockSiteEnrollment]);
1044
- mockDataAccess.Site.batchGetByKeys.resolves({
1045
- data: [mockSiteObject],
1046
- unprocessed: [],
1047
- });
1048
1045
  mockDataAccess.Site.findById.resolves(null);
1049
1046
 
1050
1047
  const tierClientWithoutSite = new TierClient(
@@ -1057,12 +1054,12 @@ describe('TierClient', () => {
1057
1054
  const result = await tierClientWithoutSite.getFirstEnrollment();
1058
1055
 
1059
1056
  expect(result).to.deep.equal({
1060
- entitlement: mockEntitlement,
1061
- enrollment: mockSiteEnrollment,
1057
+ entitlement: null,
1058
+ enrollment: null,
1062
1059
  site: null,
1063
1060
  });
1064
1061
  expect(mockContext.log.warn).to.have.been.calledWith(
1065
- `Site not found for enrollment ${mockSiteEnrollment.getId()} with site ID ${siteId}`,
1062
+ `Site not found for enrollment ${mockSiteEnrollment.getId()} with siteId ${siteId}`,
1066
1063
  );
1067
1064
  });
1068
1065
 
@@ -1074,35 +1071,96 @@ describe('TierClient', () => {
1074
1071
  });
1075
1072
 
1076
1073
  it('should work with site-specific client', async () => {
1077
- const mockSiteObject = {
1078
- getId: () => siteId,
1079
- getName: () => 'Test Site',
1080
- getOrganizationId: () => orgId,
1081
- };
1082
-
1083
1074
  mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
1084
1075
  mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([mockSiteEnrollment]);
1085
- mockDataAccess.Site.batchGetByKeys.resolves({
1086
- data: [mockSiteObject],
1087
- unprocessed: [],
1088
- });
1089
- mockDataAccess.Site.findById.resolves(mockSiteObject);
1090
1076
 
1091
1077
  const result = await tierClient.getFirstEnrollment();
1092
1078
 
1093
1079
  expect(result).to.deep.equal({
1094
1080
  entitlement: mockEntitlement,
1095
1081
  enrollment: mockSiteEnrollment,
1096
- site: mockSiteObject,
1082
+ site: siteInstance,
1097
1083
  });
1084
+ expect(mockDataAccess.Site.findById).to.not.have.been.called;
1085
+ expect(mockDataAccess.Site.batchGetByKeys).to.not.have.been.called;
1098
1086
  });
1099
1087
 
1100
- it('should handle error when fetching site via batchGetByKeys', async () => {
1088
+ it('should handle error when fetching site via findById', async () => {
1101
1089
  mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
1102
1090
  mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([mockSiteEnrollment]);
1103
- mockDataAccess.Site.batchGetByKeys.rejects(new Error('Site fetch error'));
1091
+ mockDataAccess.Site.findById.rejects(new Error('Site fetch error'));
1092
+
1093
+ const tierClientWithoutSite = new TierClient(
1094
+ mockContext,
1095
+ organizationInstance,
1096
+ null,
1097
+ productCode,
1098
+ );
1099
+
1100
+ await expect(tierClientWithoutSite.getFirstEnrollment()).to.be.rejectedWith('Site fetch error');
1101
+ expect(mockDataAccess.Site.batchGetByKeys).to.not.have.been.called;
1102
+ });
1103
+
1104
+ it('should return nulls when site-specific client has no matching enrollment', async () => {
1105
+ const nonMatchingEnrollment = {
1106
+ getId: () => 'enrollment-999',
1107
+ getSiteId: () => 'other-site-id',
1108
+ getEntitlementId: () => 'entitlement-123',
1109
+ };
1110
+
1111
+ mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
1112
+ mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([nonMatchingEnrollment]);
1113
+
1114
+ const result = await tierClient.getFirstEnrollment();
1115
+
1116
+ expect(result).to.deep.equal({
1117
+ entitlement: null,
1118
+ enrollment: null,
1119
+ site: null,
1120
+ });
1121
+ expect(mockDataAccess.Site.findById).to.not.have.been.called;
1122
+ });
1123
+
1124
+ it('should skip enrollments with mismatching orgId and return first match', async () => {
1125
+ const wrongOrgSite = {
1126
+ getId: () => siteId,
1127
+ getOrganizationId: () => 'wrong-org-id',
1128
+ };
1129
+
1130
+ const correctSite = {
1131
+ getId: () => 'correct-site-id',
1132
+ getOrganizationId: () => orgId,
1133
+ };
1134
+
1135
+ const enrollment2 = {
1136
+ getId: () => 'enrollment-456',
1137
+ getSiteId: () => 'correct-site-id',
1138
+ getEntitlementId: () => 'entitlement-123',
1139
+ };
1140
+
1141
+ mockDataAccess.Entitlement.findByOrganizationIdAndProductCode.resolves(mockEntitlement);
1142
+ mockDataAccess.SiteEnrollment.allByEntitlementId.resolves([
1143
+ mockSiteEnrollment,
1144
+ enrollment2,
1145
+ ]);
1146
+ mockDataAccess.Site.findById.onFirstCall().resolves(wrongOrgSite);
1147
+ mockDataAccess.Site.findById.onSecondCall().resolves(correctSite);
1148
+
1149
+ const tierClientWithoutSite = new TierClient(
1150
+ mockContext,
1151
+ organizationInstance,
1152
+ null,
1153
+ productCode,
1154
+ );
1155
+
1156
+ const result = await tierClientWithoutSite.getFirstEnrollment();
1104
1157
 
1105
- await expect(tierClient.getFirstEnrollment()).to.be.rejectedWith('Site fetch error');
1158
+ expect(result).to.deep.equal({
1159
+ entitlement: mockEntitlement,
1160
+ enrollment: enrollment2,
1161
+ site: correctSite,
1162
+ });
1163
+ expect(mockDataAccess.Site.findById).to.have.been.calledTwice;
1106
1164
  });
1107
1165
  });
1108
1166
  });