@capillarytech/creatives-library 9.1.1 → 9.1.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "9.1.1",
4
+ "version": "9.1.2",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
@@ -34,15 +34,20 @@ const HTTPS_STR = "https://"
34
34
  const ALLOWED_EXTENSIONS_STR = ".*(?:jpg|png|jpeg|gif)$"
35
35
  const ANY_STR = ".*"
36
36
 
37
+ /**
38
+ * Floor and ceiling for the EMAIL quality (config value and size-based override alike). The value itself comes from CDN_IMG_QUALITY_CFG.EMAIL.
39
+ */
40
+ export const EMAIL_MIN_QUALITY = 80;
41
+ export const EMAIL_MAX_QUALITY = 100;
42
+
37
43
  /**
38
44
  * CHANNEL_CONFIGS maintains the transformation details for each channel (and optionally subchannel ex. FACEBOOK/LINE)
39
45
  * In case of SUBCHANNELS, they are nested inside CHANNEL.
46
+ * EMAIL has no width/height: images keep their uploaded resolution (resizing to the <img> attributes looked pixelated on retina screens).
40
47
  */
41
48
  const CHANNEL_CONFIGS = {
42
49
  EMAIL: {
43
50
  transformations: {
44
- width: SPECIFIED,
45
- height: SPECIFIED,
46
51
  format: FORMAT_TYPES.AUTO,
47
52
  quality: QUALITY_TYPE.DECREASE,
48
53
  },
@@ -137,10 +142,19 @@ const getClusterSpecificS3CdnConfigFromUrl = (url) => {
137
142
  }
138
143
 
139
144
  /**
140
- *
141
- * @param {string} url : required
142
- * @param {number} height : optional | required when height needs to be manually specified for channels such as email.
143
- * @param {number} width : optional | required when width needs to be manually specified for channels such as email.
145
+ * Clamps an email image quality into [EMAIL_MIN_QUALITY, EMAIL_MAX_QUALITY].
146
+ * Non-numeric input is returned as-is so the caller's existing fallbacks still apply.
147
+ */
148
+ export const clampEmailQuality = (quality) => {
149
+ if (!isNumber(quality) || Number.isNaN(quality)) return quality;
150
+ return Math.min(EMAIL_MAX_QUALITY, Math.max(EMAIL_MIN_QUALITY, quality));
151
+ };
152
+
153
+ /**
154
+ *
155
+ * @param {string} url : required
156
+ * @param {number} height : optional | used only by channels whose CHANNEL_CONFIGS has height: SPECIFIED. Ignored for EMAIL (never resized).
157
+ * @param {number} width : optional | used only by channels whose CHANNEL_CONFIGS has width: SPECIFIED. Ignored for EMAIL (never resized).
144
158
  * @param {string} channelName : required
145
159
  * @param {string} channelSubType : optional | required when channel has multiple subtypes
146
160
  *
@@ -194,7 +208,15 @@ export const getCdnUrl = ({
194
208
  //remove all leading and trailing forward slash with empty string, if leading/trailing forward slash is present in node env by mistake then url will not be correct hence removing them.
195
209
  CREATIVES_S3_BUCKET_PATH = CREATIVES_S3_BUCKET_PATH?.replace(/^\/|\/$/g, '');
196
210
 
197
- const QUALITY = CREATIVES_CDN_QUALITY_CONFIG?.[channelName] || CREATIVES_CDN_QUALITY_CONFIG?.DEFAULT;
211
+ const isEmail = channelName === EMAIL;
212
+ let QUALITY = CREATIVES_CDN_QUALITY_CONFIG?.[channelName] || CREATIVES_CDN_QUALITY_CONFIG?.DEFAULT;
213
+ let overrideQuality = emailOverrideQuality;
214
+ //Email only: never go below EMAIL_MIN_QUALITY, whichever source the quality came from.
215
+ if (isEmail) {
216
+ QUALITY = clampEmailQuality(QUALITY);
217
+ overrideQuality = clampEmailQuality(overrideQuality);
218
+ }
219
+ //assetKey is everything after the bucket path, so an already transformed cdn url is rebuilt from scratch (old options dropped).
198
220
  const [_, assetKey] = url.split(CREATIVES_S3_BUCKET_PATH);
199
221
 
200
222
  const regexWithExtension = new RegExp(HTTPS_STR + ANY_STR + CREATIVES_S3_BUCKET_PATH + ANY_STR + ALLOWED_EXTENSIONS_STR)
@@ -255,10 +277,10 @@ export const getCdnUrl = ({
255
277
  case "quality":
256
278
  if (transformationParamVal === QUALITY_TYPE?.DECREASE && QUALITY && typeof QUALITY === "number") {
257
279
  if (
258
- emailOverrideQuality &&
259
- typeof emailOverrideQuality === "number"
280
+ overrideQuality &&
281
+ typeof overrideQuality === "number"
260
282
  ) {
261
- cdnUrl += `${transformationParam}=${emailOverrideQuality},`;
283
+ cdnUrl += `${transformationParam}=${overrideQuality},`;
262
284
  } else {
263
285
  cdnUrl += `${transformationParam}=${QUALITY},`;
264
286
  }
@@ -343,20 +365,40 @@ export const updateImagesInHtml = async (htmlContents) => {
343
365
  * @param {*} contents
344
366
  * @returns
345
367
  * Transforms the email template's html by images replacing the s3 url / cdn url with cdn url.
368
+ * Covers versions.base and every versions.history entry for every selected language (the api uploads history[0] per language),
369
+ * transforming each language node once even when base and history[0] are the same object.
346
370
  */
347
371
  export function transformEmailTemplates(contents){
348
372
  const contentsCopy = cloneDeep(contents);
349
373
  try {
350
374
  const base = contentsCopy?.versions?.base;
375
+ const history = contentsCopy?.versions?.history;
351
376
  const languages = base?.selectedLanguages;
377
+ const versionNodes = [base].concat(Array.isArray(history) ? history : []).filter(Boolean);
378
+ const transformedLangNodes = new Set();
379
+ const resultByHtml = new Map(); //identical html in base and history is transformed once (one file-size lookup)
352
380
  let promises = [];
353
- languages?.forEach((lang) => {
354
- const htmlContents = base?.[lang]?.["template-content"];
355
- promises.push(
356
- updateImagesInHtml(htmlContents).then(newHtmlContents=>{
357
- base[lang]["template-content"] = newHtmlContents;
358
- })
359
- )
381
+ versionNodes.forEach((versionNode) => {
382
+ const nodeLanguages = versionNode?.selectedLanguages || languages;
383
+ nodeLanguages?.forEach((lang) => {
384
+ const langNode = versionNode?.[lang];
385
+ if (!langNode || typeof langNode !== "object") {
386
+ //malformed input (listed language without a node): caller returns the contents untouched, as before.
387
+ throw new Error("transformEmailTemplates: missing language node " + lang);
388
+ }
389
+ if (transformedLangNodes.has(langNode)) return;
390
+ transformedLangNodes.add(langNode);
391
+ const htmlContents = langNode?.["template-content"];
392
+ if (typeof htmlContents !== "string") return;
393
+ if (!resultByHtml.has(htmlContents)) {
394
+ resultByHtml.set(htmlContents, updateImagesInHtml(htmlContents));
395
+ }
396
+ promises.push(
397
+ resultByHtml.get(htmlContents).then(newHtmlContents=>{
398
+ langNode["template-content"] = newHtmlContents;
399
+ })
400
+ )
401
+ });
360
402
  });
361
403
  return Promise.all(promises).then(_=>contentsCopy).catch(e=>{
362
404
  Bugsnag.leaveBreadcrumb(
@@ -648,9 +690,10 @@ export function checkValidUrl(url){
648
690
  }
649
691
 
650
692
  /**
651
- *
693
+ *
652
694
  * @param {string} url | Accepts cdn url
653
695
  * @returns quality param value by extracting it from the url
696
+ * No longer used by the email flow (an old cdn url's quality must not survive a re-save); kept exported for backward compatibility.
654
697
  */
655
698
  export function getCdnEmailImageQuality(url) {
656
699
  try {
@@ -697,11 +740,14 @@ export function getS3EmailImageQualityMapping(
697
740
  return {};
698
741
 
699
742
  const urlToImageQualityMapping = {};
743
+ //exact key match (not substring, so a.jpg never matches a.jpg.jpeg); s3, plain cdn and old cdn urls of one file share the key.
744
+ const urlKeys = (s3UrlList || []).map((url) => [url, getKeyFromS3Url(url)]);
700
745
  forOwn(urlKeysToFileSizeMapping, function (val, key, obj) {
701
- const s3Url = s3UrlList?.find((url) => url?.includes(key));
702
- if (s3Url) {
703
- urlToImageQualityMapping[s3Url] = getEmailImageOverrideQuality(val);
704
- }
746
+ urlKeys.forEach(([s3Url, urlKey]) => {
747
+ if (urlKey === key) {
748
+ urlToImageQualityMapping[s3Url] = getEmailImageOverrideQuality(val);
749
+ }
750
+ });
705
751
  });
706
752
  return urlToImageQualityMapping;
707
753
  }
@@ -761,33 +807,29 @@ export function getEmailImageOverrideQuality(fileSize) {
761
807
  * @param {array} urlList ex ['1.jpg','2.jpg'] // these can be any urls (s3, cdn or others).
762
808
  * This function accepts list of urls(can be s3, cdn or other).
763
809
  * This function returns quality corresponding to the url.
764
- *
765
- * If it is cdn url. It extracts quality from cdn url.
766
- * If it is s3 url:
767
- * a. If image is selected from gallery or image is manuall uploaded(not zip upload),
810
+ *
811
+ * For both s3 and cdn urls (a cdn url's old `quality=` is never reused):
812
+ * a. If image is selected from gallery or image is manuall uploaded(not zip upload),
768
813
  * then we have file size information in window object. We deterermine quality from it.
769
- * b. If not in a, we append the s3 url into nonSelectedS3Urls
770
- *
814
+ * b. If not in a, we append the url into nonSelectedS3Urls
815
+ *
771
816
  * If we have elements in nonSelectedS3Urls array. We make an api call to get file size details and then determine quality.
772
- *
817
+ * getKeyFromS3Url works on cdn urls too, so an already transformed cdn url resolves to the same file key as its s3 url.
818
+ *
773
819
  * We return the quality mapping, key being url and value being quality
774
- *
820
+ *
775
821
  */
776
822
  export async function getUrlsToQualityMapping(urlList){
777
823
  if(!urlList?.length) return {};
778
824
 
779
825
  const urlQualityMapping = {};
780
826
 
781
- const nonSelectedS3Urls = []; //we do not know file sizes of these s3 urls yet.
827
+ const nonSelectedS3Urls = []; //we do not know file sizes of these s3/cdn urls yet.
782
828
 
783
829
  urlList?.forEach(url=>{
784
830
  const {isS3Url, isCdnUrl} = checkValidUrl(url);
785
831
 
786
- if (isCdnUrl){
787
- urlQualityMapping[url] = getCdnEmailImageQuality(url);
788
- return;
789
- }
790
- if(isS3Url){
832
+ if(isS3Url || isCdnUrl){
791
833
  const s3FileQuality = getS3EmailImageQuality(url);
792
834
  if(s3FileQuality){
793
835
  urlQualityMapping[url] = s3FileQuality;
@@ -119,7 +119,7 @@ img.fullwidthOnMobile {
119
119
  <body class=\\"clean-body\\" style=\\"margin: 0; padding: 0; -webkit-text-size-adjust: 100%; background-color: #FFFFFF;\\">
120
120
  <p><!--[if IE]><div class=\\"ie-browser\\"><![endif]-->hello world</p>
121
121
 
122
- <p><img alt=\\"Capillary Image\\" height=\\"171\\" src=\\"https://storage.crm.n.content-cdn.io/cdn-cgi/image/width=257,height=171,format=auto,quality=75,/intouch_creative_assets/b4b63b22-8a81-41ee-a8df-6db967fc.jpg\\" width=\\"257\\"></p>
122
+ <p><img alt=\\"Capillary Image\\" height=\\"171\\" src=\\"https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/b4b63b22-8a81-41ee-a8df-6db967fc.jpg\\" width=\\"257\\"></p>
123
123
 
124
124
  <table bgcolor=\\"#FFFFFF\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"nl-container\\" role=\\"presentation\\" style=\\"background-color:#ffffff; border-collapse:collapse; border-spacing:0; margin:0 auto; min-width:320px; mso-table-lspace:0pt; mso-table-rspace:0pt; table-layout:fixed; vertical-align:top; width:100%\\" valign=\\"top\\">
125
125
  <tbody>
@@ -263,7 +263,7 @@ img.fullwidthOnMobile {
263
263
  <body class=\\"clean-body\\" style=\\"margin: 0; padding: 0; -webkit-text-size-adjust: 100%; background-color: #FFFFFF;\\">
264
264
  <p><!--[if IE]><div class=\\"ie-browser\\"><![endif]-->hello world</p>
265
265
 
266
- <p><img alt=\\"Capillary Image\\" height=\\"171\\" src=\\"https://crm-nightly-new-fileservice.s3.amazonaws.com/intouch_creative_assets/b4b63b22-8a81-41ee-a8df-6db967fc.jpg\\" width=\\"257\\" /></p>
266
+ <p><img alt=\\"Capillary Image\\" height=\\"171\\" src=\\"https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/b4b63b22-8a81-41ee-a8df-6db967fc.jpg\\" width=\\"257\\"></p>
267
267
 
268
268
  <table bgcolor=\\"#FFFFFF\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"nl-container\\" role=\\"presentation\\" style=\\"background-color:#ffffff; border-collapse:collapse; border-spacing:0; margin:0 auto; min-width:320px; mso-table-lspace:0pt; mso-table-rspace:0pt; table-layout:fixed; vertical-align:top; width:100%\\" valign=\\"top\\">
269
269
  <tbody>
@@ -226,7 +226,7 @@ img.fullwidthOnMobile {
226
226
  </style>
227
227
  </head>
228
228
  <body class="clean-body" style="margin: 0; padding: 0; -webkit-text-size-adjust: 100%; background-color: #FFFFFF;">
229
- <p><img alt="Capillary Image" height="184" src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/width=328,height=184,format=auto,quality=75,/intouch_creative_assets/d5679e9b-1ff4-4e7c-8f77-30160048.png" width="328"></p>
229
+ <p><img alt="Capillary Image" height="184" src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/d5679e9b-1ff4-4e7c-8f77-30160048.png" width="328"></p>
230
230
  <!--[if IE]><div class="ie-browser"><![endif]-->
231
231
 
232
232
  <p>Hello world</p>
@@ -564,7 +564,7 @@ img.fullwidthOnMobile {
564
564
  </style>
565
565
  </head>
566
566
  <body class="clean-body" style="margin: 0; padding: 0; -webkit-text-size-adjust: 100%; background-color: #FFFFFF;">
567
- <p><img alt="Capillary Image" src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=75,/intouch_creative_assets/8d1498ab-099d-4145-ad15-edd77f7.jpeg"></p>
567
+ <p><img alt="Capillary Image" src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=80,/intouch_creative_assets/8d1498ab-099d-4145-ad15-edd77f7.jpeg"></p>
568
568
 
569
569
  <p><img alt="Capillary Image" src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=90,/intouch_creative_assets/546a3d95-48be-4955-a07c-c4775da.jpeg"></p>
570
570
  <!--[if IE]><div class="ie-browser"><![endif]-->
@@ -691,9 +691,9 @@ img.fullwidthOnMobile {
691
691
  </style>
692
692
  </head>
693
693
  <body class="clean-body" style="margin: 0; padding: 0; -webkit-text-size-adjust: 100%; background-color: #FFFFFF;">
694
- <p><img alt="Capillary Image" src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=75,/intouch_creative_assets/8d1498ab-099d-4145-ad15-edd77f7.jpeg"></p>
694
+ <p><img alt="Capillary Image" src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/8d1498ab-099d-4145-ad15-edd77f7.jpeg"></p>
695
695
 
696
- <p><img alt="Capillary Image" src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=75,/intouch_creative_assets/546a3d95-48be-4955-a07c-c4775da.jpeg"></p>
696
+ <p><img alt="Capillary Image" src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/546a3d95-48be-4955-a07c-c4775da.jpeg"></p>
697
697
  <!--[if IE]><div class="ie-browser"><![endif]-->
698
698
 
699
699
  <table bgcolor="#FFFFFF" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" style="background-color:#ffffff; border-collapse:collapse; border-spacing:0; margin:0 auto; min-width:320px; mso-table-lspace:0pt; mso-table-rspace:0pt; table-layout:fixed; vertical-align:top; width:100%" valign="top">
@@ -744,7 +744,7 @@ export const emailRawTransformed2 = `<!DOCTYPE html>
744
744
  <body>
745
745
  <p>test for 3 urls, two are s3 and one is cdn url. Among two s3 url, one s3 url is in window object(since that was manually selected from gallery)</p>
746
746
  <img src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/394c053f-309b-49fc-ba74-6e700b1.jpeg">
747
- <img src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=75,/intouch_creative_assets/f98ce0c0-53f8-4fbd-b4e3-05953dda.jpg">
747
+ <img src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/f98ce0c0-53f8-4fbd-b4e3-05953dda.jpg">
748
748
  <img src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg">
749
749
  </body>
750
750
  </html>`
@@ -760,7 +760,7 @@ export const emailRawTransformed3 = `<!DOCTYPE html>
760
760
  <body>
761
761
  <p>test for 3 urls, two are s3 and one is cdn url. Among two s3 url, one s3 url is in window object(since that was manually selected from gallery)</p>
762
762
  <img src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/394c053f-309b-49fc-ba74-6e700b1.jpeg">
763
- <img src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=75,/intouch_creative_assets/f98ce0c0-53f8-4fbd-b4e3-05953dda.jpg">
763
+ <img src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/f98ce0c0-53f8-4fbd-b4e3-05953dda.jpg">
764
764
  <img src="https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg">
765
765
  </body>
766
766
  </html>`
@@ -15,7 +15,7 @@ beforeEach(() => {
15
15
  CREATIVES_S3_BUCKET_PATH: "intouch_creative_assets",
16
16
  CREATIVES_CDN_BASE_URL: "https://storage.crm.n.content-cdn.io",
17
17
  CREATIVES_CDN_QUALITY_CONFIG:
18
- '{"EMAIL":75,"RCS":75,"VIBER":75,"WHATSAPP":75,"MOBILE_PUSH":75,"FACEBOOK":75,"LINE":75,"DEFAULT":75}',
18
+ '{"EMAIL":85,"RCS":75,"VIBER":75,"WHATSAPP":75,"MOBILE_PUSH":75,"FACEBOOK":75,"LINE":75,"DEFAULT":75}',
19
19
  S3_CDN_MAP: '{"crm-nightly-new-fileservice.s3.amazonaws.com":{"cdn_host":"storage.crm.n.content-cdn.io","bucket_path":"intouch_creative_assets"}, "crm-prod-new-fileservice.s3.amazonaws.com": { "cdn_host": "storage.crm.p.content-cdn.io", "bucket_path": "fileservice.p/intouch_creative_assets" }}',
20
20
  };
21
21
  return {
@@ -40,7 +40,7 @@ beforeEach(() => {
40
40
  afterEach(() => {
41
41
  localStorage.setItem(
42
42
  "CREATIVES_CDN_QUALITY_CONFIG",
43
- '{"EMAIL":75,"RCS":75,"VIBER":75,"WHATSAPP":75,"MOBILE_PUSH":75,"FACEBOOK":75,"LINE":75,"DEFAULT":75}'
43
+ '{"EMAIL":85,"RCS":75,"VIBER":75,"WHATSAPP":75,"MOBILE_PUSH":75,"FACEBOOK":75,"LINE":75,"DEFAULT":75}'
44
44
  );
45
45
  localStorage.setItem(
46
46
  "CREATIVES_S3_BUCKET_PATH", "intouch_creative_assets"
@@ -83,11 +83,11 @@ describe("cdnTransformationTests", () => {
83
83
  url: TEST_S3_URL,
84
84
  channelName: "EMAIL",
85
85
  });
86
- const EXPECTED_CDN_URL = `https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=75,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
86
+ const EXPECTED_CDN_URL = `https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
87
87
  expect(GENERATED_CDN_URL).toEqual(EXPECTED_CDN_URL);
88
88
  });
89
89
 
90
- it("Should replace s3 url with cdn url for email when h/w are provided", () => {
90
+ it("Should replace s3 url with cdn url for email when h/w are provided (h/w are ignored for email)", () => {
91
91
  const TEST_S3_URL = `https://crm-nightly-new-fileservice.s3.amazonaws.com/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
92
92
  const GENERATED_CDN_URL = cdnUtils.getCdnUrl({
93
93
  url: TEST_S3_URL,
@@ -95,7 +95,7 @@ describe("cdnTransformationTests", () => {
95
95
  height: 100,
96
96
  width: 200,
97
97
  });
98
- const EXPECTED_CDN_URL = `https://storage.crm.n.content-cdn.io/cdn-cgi/image/width=200,height=100,format=auto,quality=75,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
98
+ const EXPECTED_CDN_URL = `https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
99
99
  expect(GENERATED_CDN_URL).toEqual(EXPECTED_CDN_URL);
100
100
  });
101
101
 
@@ -108,7 +108,7 @@ describe("cdnTransformationTests", () => {
108
108
  height: 100,
109
109
  width: 200,
110
110
  });
111
- const EXPECTED_CDN_URL = `https://storage.crm.p.content-cdn.io/cdn-cgi/image/width=200,height=100,format=auto,quality=75,/fileservice.p/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
111
+ const EXPECTED_CDN_URL = `https://storage.crm.p.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/fileservice.p/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
112
112
  expect(GENERATED_CDN_URL).toEqual(EXPECTED_CDN_URL);
113
113
  });
114
114
 
@@ -121,7 +121,7 @@ describe("cdnTransformationTests", () => {
121
121
  height: 100,
122
122
  width: 200,
123
123
  });
124
- const EXPECTED_CDN_URL = `https://storage.crm.p.content-cdn.io/cdn-cgi/image/width=200,height=100,format=auto,quality=75,/fileservice.p/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
124
+ const EXPECTED_CDN_URL = `https://storage.crm.p.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/fileservice.p/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
125
125
  expect(GENERATED_CDN_URL).toEqual(EXPECTED_CDN_URL);
126
126
  });
127
127
 
@@ -133,7 +133,7 @@ describe("cdnTransformationTests", () => {
133
133
  height: 100,
134
134
  width: 200,
135
135
  });
136
- const EXPECTED_CDN_URL = `https://storage.crm.n.content-cdn.io/cdn-cgi/image/width=200,height=100,format=auto,quality=75,/intouch_creative_assets/02557e68-6b7b-4d0e-ba6f-efcf8972.gif`;
136
+ const EXPECTED_CDN_URL = `https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/02557e68-6b7b-4d0e-ba6f-efcf8972.gif`;
137
137
  expect(GENERATED_CDN_URL).toEqual(EXPECTED_CDN_URL);
138
138
  });
139
139
 
@@ -149,15 +149,15 @@ describe("cdnTransformationTests", () => {
149
149
  expect(GENERATED_CDN_URL).toEqual(EXPECTED_CDN_URL);
150
150
  });
151
151
 
152
- it("should replace cdn url with new cdn url for email incase h/w etc changed", () => {
153
- const TEST_S3_URL = `https://storage.crm.n.content-cdn.io/cdn-cgi/image/width=200,height=100,format=auto,quality=75,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
152
+ it("should rebuild an already transformed cdn url for email, dropping old width/height/quality", () => {
153
+ const TEST_S3_URL = `https://storage.crm.n.content-cdn.io/cdn-cgi/image/width=200,height=100,format=auto,quality=65,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
154
154
  const GENERATED_CDN_URL = cdnUtils.getCdnUrl({
155
155
  url: TEST_S3_URL,
156
156
  channelName: "EMAIL",
157
157
  height: 400,
158
158
  width: 500,
159
159
  });
160
- const EXPECTED_CDN_URL = `https://storage.crm.n.content-cdn.io/cdn-cgi/image/width=500,height=400,format=auto,quality=75,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
160
+ const EXPECTED_CDN_URL = `https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
161
161
  expect(GENERATED_CDN_URL).toEqual(EXPECTED_CDN_URL);
162
162
  });
163
163
 
@@ -205,7 +205,7 @@ describe("cdnTransformationTests", () => {
205
205
  height: 100,
206
206
  width: 200,
207
207
  });
208
- const EXPECTED_CDN_URL = `https://storage.crm.n.content-cdn.io/cdn-cgi/image/width=200,height=100,format=auto,quality=100,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
208
+ const EXPECTED_CDN_URL = `https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=100,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg`;
209
209
 
210
210
  expect(GENERATED_CDN_URL).toEqual(EXPECTED_CDN_URL);
211
211
  });
@@ -246,6 +246,8 @@ describe("cdnTransformationTests", () => {
246
246
  "https://crm-nightly-new-fileservice.s3.amazonaws.com/intouch_creative_assets/394c053f-309b-49fc-ba74-6e700b1.jpeg": 76800, //this image has quality 85 since file size less than 80kb
247
247
  };
248
248
 
249
+ //The msw handler does not intercept this call (same as before this change), so f98ce0c0 and the cdn url 2c9233ed fall back to the config quality 85.
250
+ //The cdn url is looked up by file key like an s3 url; its own quality= is not reused.
249
251
  const htmlContentsInput = mockdata.emailRawInput2;
250
252
  const htmlContentsOutput = await cdnUtils.updateImagesInHtml(htmlContentsInput);
251
253
  const expected = mockdata.emailRawTransformed2;
@@ -278,6 +280,57 @@ describe("cdnTransformationTests", () => {
278
280
  await cdnUtils.transformEmailTemplates(emailTemplateInput);
279
281
  expect(emailTemplateOutput).toMatchSnapshot();
280
282
  });
283
+ it("should transform every version node: base and every history entry, all selected languages, old cdn urls included", async () => {
284
+ const OLD_EN = "https://storage.crm.n.content-cdn.io/cdn-cgi/image/width=600,height=auto,format=auto,quality=65,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg";
285
+ const S3_ES = "https://crm-nightly-new-fileservice.s3.amazonaws.com/intouch_creative_assets/02557e68-6b7b-4d0e-ba6f-efcf8972.gif";
286
+ const NEW_EN = "https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg";
287
+ const NEW_ES = "https://storage.crm.n.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/02557e68-6b7b-4d0e-ba6f-efcf8972.gif";
288
+ const makeNode = () => ({
289
+ selectedLanguages: ["en", "es"],
290
+ activeTab: "en",
291
+ en: { "template-content": `<img src="${OLD_EN}" width="600" height="auto">` },
292
+ es: { "template-content": `<img src="${S3_ES}">` },
293
+ });
294
+ const input = { type: "EMAIL", versions: { base: makeNode(), history: [makeNode(), makeNode()] } };
295
+
296
+ const output = await cdnUtils.transformEmailTemplates(input);
297
+
298
+ [output.versions.base, output.versions.history[0], output.versions.history[1]].forEach((node) => {
299
+ expect(node.en["template-content"]).toEqual(`<img src="${NEW_EN}" width="600" height="auto">`);
300
+ expect(node.es["template-content"]).toEqual(`<img src="${NEW_ES}">`);
301
+ });
302
+ // input is not mutated
303
+ expect(input.versions.history[0].en["template-content"]).toContain("quality=65");
304
+ });
305
+
306
+ it("should transform a language node once when base and history[0] are the same object, and skip nodes without html", async () => {
307
+ const S3 = "https://crm-nightly-new-fileservice.s3.amazonaws.com/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg";
308
+ const shared = { selectedLanguages: ["en", "fr"], activeTab: "en", en: { "template-content": `<img src="${S3}">` }, fr: { is_drag_drop: false } };
309
+ const input = { versions: { base: shared, history: [shared] } };
310
+ const parseSpy = jest.spyOn(nodeHtmlParser, "parse");
311
+
312
+ const output = await cdnUtils.transformEmailTemplates(input);
313
+
314
+ expect(output.versions.base).toBe(output.versions.history[0]); // shared reference survives cloneDeep
315
+ expect(output.versions.base.en["template-content"]).toContain("cdn-cgi/image/format=auto,quality=85,/");
316
+ expect(output.versions.base.fr).toEqual({ is_drag_drop: false }); // no "template-content" key added
317
+ expect(parseSpy).toHaveBeenCalledTimes(1);
318
+ });
319
+
320
+ it("should transform identical html held by distinct base and history nodes once", async () => {
321
+ const S3 = "https://crm-nightly-new-fileservice.s3.amazonaws.com/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg";
322
+ const makeNode = () => ({ selectedLanguages: ["en"], activeTab: "en", en: { "template-content": `<img src="${S3}" width="600">` } });
323
+ const input = { versions: { base: makeNode(), history: [makeNode()] } };
324
+ const parseSpy = jest.spyOn(nodeHtmlParser, "parse");
325
+
326
+ const output = await cdnUtils.transformEmailTemplates(input);
327
+
328
+ expect(output.versions.base).not.toBe(output.versions.history[0]);
329
+ expect(output.versions.history[0].en["template-content"]).toEqual(output.versions.base.en["template-content"]);
330
+ expect(output.versions.base.en["template-content"]).toContain("cdn-cgi/image/format=auto,quality=85,/");
331
+ expect(parseSpy).toHaveBeenCalledTimes(1);
332
+ });
333
+
281
334
  it("should return original contents transform email template throws an error inside. This might be due to improper input or unexpected error.", async () => {
282
335
  const emailTemplateInputWithError = mockdata.emailTemplateRawWithError;
283
336
  const emailTemplateOutput =
@@ -304,19 +357,22 @@ describe("cdnTransformationTests", () => {
304
357
  const emailTemplateInput = mockdata.emailTemplateOverrideInput1;
305
358
  const emailTemplateOutput =
306
359
  await cdnUtils.updateImagesInHtml(emailTemplateInput);
307
- expect(emailTemplateOutput).toEqual(mockdata.emailTemplateOverrideOutput1); //first image has q=75 since less than 400KB, second image has q=90 since less than 30kB
360
+ expect(emailTemplateOutput).toEqual(mockdata.emailTemplateOverrideOutput1); //first image maps to q=75 (less than 400KB) and is clamped to the email minimum 80, second image has q=90 since less than 30kB
308
361
 
309
362
  });
310
363
 
311
364
 
312
- it("should reuse the same previous quality from cdn url when overrideEmailQuality is set true", async () => {
365
+ it("should NOT reuse the previous quality from a cdn url when overrideEmailQuality is set true", async () => {
313
366
  localStorage.setItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY", "true");
314
367
  localStorage.setItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY_MAPPING", "[[30, 90], [80, 85], [150, 80], [400, 75], [600, 70], [1200, 65], [102400, 60]]");
315
368
 
369
+ //Input already carries quality=80 and quality=90. File sizes are unknown (no window entry, file size api not mocked),
370
+ //so the current email rule applies: the config quality (85) for both images, not the quality found in the url.
316
371
  const emailTemplateInput = mockdata.emailTemplateOverrideOutput1;
317
372
  const emailTemplateOutput =
318
373
  await cdnUtils.updateImagesInHtml(emailTemplateInput);
319
- expect(emailTemplateOutput).toEqual(mockdata.emailTemplateOverrideOutput1); // Both input and output should be exactly same.
374
+ expect(emailTemplateOutput).toEqual(mockdata.emailTemplateOverrideOutputWithWindowDeleted1);
375
+ expect(emailTemplateOutput).not.toEqual(emailTemplateInput);
320
376
  });
321
377
 
322
378
  it("should transform email template with qualtyCfg if window objects CREATIVES_S3_ASSET_FILESIZES is somehow deleted", async () => {
@@ -328,7 +384,7 @@ describe("cdnTransformationTests", () => {
328
384
  const emailTemplateInput = mockdata.emailTemplateOverrideInput1;
329
385
  const emailTemplateOutput =
330
386
  await cdnUtils.updateImagesInHtml(emailTemplateInput);
331
- expect(emailTemplateOutput).toEqual(mockdata.emailTemplateOverrideOutputWithWindowDeleted1); //first image has q=75 since less than 400KB, second image has q=90 since less than 30kB
387
+ expect(emailTemplateOutput).toEqual(mockdata.emailTemplateOverrideOutputWithWindowDeleted1); //file sizes unknown, both images get the config email quality (85)
332
388
 
333
389
  });
334
390
  });
@@ -825,4 +881,248 @@ describe("cdnTransformationTests", () => {
825
881
  });
826
882
 
827
883
  })
884
+
885
+ /**
886
+ * Email images are never resized, the email quality is clamped to [80, 100],
887
+ * and old transformed cdn urls are rebuilt from the file key with the current settings.
888
+ * Covers the eucrm, incrm, seacrm and asiacrm S3_CDN_MAP shapes (path-style buckets included).
889
+ */
890
+ describe("EMAIL channel: no resizing, clamped quality, old cdn urls rebuilt", () => {
891
+ const PROD_S3_CDN_MAP = {
892
+ "crm-nightly-new-fileservice.s3.amazonaws.com": { cdn_host: "storage.crm.n.content-cdn.io", bucket_path: "intouch_creative_assets" },
893
+ "asiacrm-fileservicepgxdbptutk.s3.ap-south-1.amazonaws.com": { cdn_host: "storage.crm.asia.content-cdn.io", bucket_path: "intouch_creative_assets" },
894
+ "s3-eu-west-1.amazonaws.com": { cdn_host: "storage.crm.eu.content-cdn.io", bucket_path: "fs.capillary.eu/intouch_creative_assets" },
895
+ "s3.amazonaws.com": { cdn_host: "storage.crm.in.content-cdn.io", bucket_path: "fileservice.in/intouch_creative_assets" },
896
+ "s3-ap-southeast-1.amazonaws.com": { cdn_host: "storage.crm.sg.content-cdn.io", bucket_path: "fs.capillary.sg/intouch_creative_assets" },
897
+ };
898
+
899
+ // For every cluster: the s3 url, the plain cdn url and an old transformed cdn url of ONE file must all become `expected`.
900
+ const CLUSTER_CASES = [
901
+ {
902
+ cluster: "eucrm",
903
+ s3: "https://s3-eu-west-1.amazonaws.com/fs.capillary.eu/intouch_creative_assets/26c03d91-ab03-42a9-904c-1840f093.png",
904
+ plainCdn: "https://storage.crm.eu.content-cdn.io/fs.capillary.eu/intouch_creative_assets/26c03d91-ab03-42a9-904c-1840f093.png",
905
+ oldCdn: "https://storage.crm.eu.content-cdn.io/cdn-cgi/image/width=600,height=auto,format=auto,quality=65,/fs.capillary.eu/intouch_creative_assets/26c03d91-ab03-42a9-904c-1840f093.png",
906
+ expected: "https://storage.crm.eu.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/fs.capillary.eu/intouch_creative_assets/26c03d91-ab03-42a9-904c-1840f093.png",
907
+ },
908
+ {
909
+ cluster: "incrm",
910
+ s3: "https://s3.amazonaws.com/fileservice.in/intouch_creative_assets/60221e2020ba572f6787.png",
911
+ plainCdn: "https://storage.crm.in.content-cdn.io/fileservice.in/intouch_creative_assets/60221e2020ba572f6787.png",
912
+ oldCdn: "https://storage.crm.in.content-cdn.io/cdn-cgi/image/width=371,height=82,format=auto,quality=90,/fileservice.in/intouch_creative_assets/60221e2020ba572f6787.png",
913
+ expected: "https://storage.crm.in.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/fileservice.in/intouch_creative_assets/60221e2020ba572f6787.png",
914
+ },
915
+ {
916
+ cluster: "seacrm",
917
+ s3: "https://s3-ap-southeast-1.amazonaws.com/fs.capillary.sg/intouch_creative_assets/7e3f2abd-6862-4f2d-8537-7deca610.png",
918
+ plainCdn: "https://storage.crm.sg.content-cdn.io/fs.capillary.sg/intouch_creative_assets/7e3f2abd-6862-4f2d-8537-7deca610.png",
919
+ oldCdn: "https://storage.crm.sg.content-cdn.io/cdn-cgi/image/quality=75,/fs.capillary.sg/intouch_creative_assets/7e3f2abd-6862-4f2d-8537-7deca610.png",
920
+ expected: "https://storage.crm.sg.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/fs.capillary.sg/intouch_creative_assets/7e3f2abd-6862-4f2d-8537-7deca610.png",
921
+ },
922
+ {
923
+ cluster: "asiacrm",
924
+ s3: "https://asiacrm-fileservicepgxdbptutk.s3.ap-south-1.amazonaws.com/intouch_creative_assets/8d1498ab-099d-4145-ad15-edd77f7.jpeg",
925
+ plainCdn: "https://storage.crm.asia.content-cdn.io/intouch_creative_assets/8d1498ab-099d-4145-ad15-edd77f7.jpeg",
926
+ oldCdn: "https://storage.crm.asia.content-cdn.io/cdn-cgi/image/width=1080,height=1080,format=auto,quality=60,/intouch_creative_assets/8d1498ab-099d-4145-ad15-edd77f7.jpeg",
927
+ expected: "https://storage.crm.asia.content-cdn.io/cdn-cgi/image/format=auto,quality=85,/intouch_creative_assets/8d1498ab-099d-4145-ad15-edd77f7.jpeg",
928
+ },
929
+ ];
930
+
931
+ const setQualityConfig = (cfg) => localStorage.setItem("CREATIVES_CDN_QUALITY_CONFIG", JSON.stringify(cfg));
932
+ // What updateImagesInHtml passes for a typical <img width="600" height="auto">.
933
+ const toEmailUrl = (url, width = 600, height = "auto") => cdnUtils.getCdnUrl({
934
+ url,
935
+ channelName: "EMAIL",
936
+ width,
937
+ height,
938
+ });
939
+ const EU = CLUSTER_CASES[0];
940
+ const EXTERNAL_URL_1 = "https://example.com/newsletter/banner.jpg";
941
+ const EXTERNAL_URL_2 = "https://d1oco4z2z1fhwp.cloudfront.net/templates/x.png";
942
+ const EMAIL_HTML = [
943
+ "<!DOCTYPE html><html><body>",
944
+ `<img src="${EU.oldCdn}" width="600" height="auto">`,
945
+ `<img src="${EU.s3}" width="300" height="200">`,
946
+ `<img src="${EU.plainCdn}">`,
947
+ `<img src="${EXTERNAL_URL_1}" width="100" height="50">`,
948
+ `<img src="${EXTERNAL_URL_2}">`,
949
+ "</body></html>",
950
+ ].join("");
951
+
952
+ beforeEach(() => {
953
+ localStorage.setItem("S3_CDN_MAP", JSON.stringify(PROD_S3_CDN_MAP));
954
+ setQualityConfig({
955
+ EMAIL: 85, RCS: 75, VIBER: 75, WHATSAPP: 75, MOBILE_PUSH: 75, FACEBOOK: 75, LINE: 75, DEFAULT: 75,
956
+ });
957
+ });
958
+
959
+ describe("getCdnUrl()", () => {
960
+ it("never adds width/height to email urls even when they are passed; format=auto and the config quality are present", () => {
961
+ const url = toEmailUrl(EU.s3);
962
+ expect(url).toEqual(EU.expected);
963
+ expect(url).not.toContain("width=");
964
+ expect(url).not.toContain("height=");
965
+ expect(url).toContain("format=auto,");
966
+ expect(url).toContain("quality=85,");
967
+
968
+ const urlWithNumericHw = toEmailUrl(EU.s3, 1200, 1258);
969
+ expect(urlWithNumericHw).toEqual(EU.expected);
970
+ });
971
+
972
+ it("clamps the email config quality into [80, 100]: 60->80, 85->85, 120->100", () => {
973
+ [[60, 80], [85, 85], [120, 100], [80, 80], [100, 100]].forEach(([configured, served]) => {
974
+ setQualityConfig({ EMAIL: configured, DEFAULT: 75 });
975
+ expect(cdnUtils.getCdnUrl({ url: EU.s3, channelName: "EMAIL" }))
976
+ .toEqual(EU.expected.replace("quality=85,", `quality=${served},`));
977
+ });
978
+ });
979
+
980
+ it("clamps emailOverrideQuality for email: 65->80, 90->90, 150->100", () => {
981
+ [[65, 80], [90, 90], [150, 100]].forEach(([override, served]) => {
982
+ expect(cdnUtils.getCdnUrl({ url: EU.s3, channelName: "EMAIL", emailOverrideQuality: override }))
983
+ .toEqual(EU.expected.replace("quality=85,", `quality=${served},`));
984
+ });
985
+ });
986
+
987
+ it("exposes the clamp helper and the bounds", () => {
988
+ expect(cdnUtils.EMAIL_MIN_QUALITY).toEqual(80);
989
+ expect(cdnUtils.EMAIL_MAX_QUALITY).toEqual(100);
990
+ expect(cdnUtils.clampEmailQuality(10)).toEqual(80);
991
+ expect(cdnUtils.clampEmailQuality(80)).toEqual(80);
992
+ expect(cdnUtils.clampEmailQuality(90)).toEqual(90);
993
+ expect(cdnUtils.clampEmailQuality(999)).toEqual(100);
994
+ expect(cdnUtils.clampEmailQuality(null)).toEqual(null);
995
+ expect(cdnUtils.clampEmailQuality("a")).toEqual("a");
996
+ });
997
+
998
+ it("does not clamp or resize other channels: a non-email channel configured at 70 still produces 70 with its own h/w", () => {
999
+ setQualityConfig({
1000
+ EMAIL: 85, RCS: 70, VIBER: 70, WHATSAPP: 70, DEFAULT: 70,
1001
+ });
1002
+ const S3_URL = "https://crm-nightly-new-fileservice.s3.amazonaws.com/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg";
1003
+ expect(cdnUtils.getCdnUrl({ url: S3_URL, channelName: "RCS" }))
1004
+ .toEqual("https://storage.crm.n.content-cdn.io/cdn-cgi/image/width=1440,height=720,quality=70,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg");
1005
+ expect(cdnUtils.getCdnUrl({ url: S3_URL, channelName: "VIBER" }))
1006
+ .toEqual("https://storage.crm.n.content-cdn.io/cdn-cgi/image/width=300,height=400,quality=70,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg");
1007
+ expect(cdnUtils.getCdnUrl({ url: S3_URL, channelName: "WHATSAPP" }))
1008
+ .toEqual("https://storage.crm.n.content-cdn.io/cdn-cgi/image/quality=70,/intouch_creative_assets/2c9233ed-0959-4aff-b749-9171b5c8.jpg");
1009
+ });
1010
+
1011
+ it("gives the identical email url for the s3 url, the plain cdn url and an old transformed cdn url (incl. height=auto), for every cluster map shape", () => {
1012
+ CLUSTER_CASES.forEach((c) => {
1013
+ expect([c.cluster, toEmailUrl(c.s3)]).toEqual([c.cluster, c.expected]);
1014
+ expect([c.cluster, toEmailUrl(c.plainCdn)]).toEqual([c.cluster, c.expected]);
1015
+ expect([c.cluster, toEmailUrl(c.oldCdn)]).toEqual([c.cluster, c.expected]);
1016
+ });
1017
+ });
1018
+
1019
+ it("leaves external (non S3_CDN_MAP) urls unchanged", () => {
1020
+ expect(toEmailUrl(EXTERNAL_URL_1, 100, 50)).toEqual(EXTERNAL_URL_1);
1021
+ expect(cdnUtils.getCdnUrl({ url: EXTERNAL_URL_2, channelName: "EMAIL" })).toEqual(EXTERNAL_URL_2);
1022
+ });
1023
+ });
1024
+
1025
+ describe("updateImagesInHtml()", () => {
1026
+ const expectRebuiltHtml = (html, quality) => {
1027
+ const root = nodeHtmlParser.parse(html);
1028
+ const srcs = root.querySelectorAll("img").map((img) => img.getAttribute("src"));
1029
+ const expected = EU.expected.replace("quality=85,", `quality=${quality},`);
1030
+ expect(srcs).toEqual([expected, expected, expected, EXTERNAL_URL_1, EXTERNAL_URL_2]);
1031
+ srcs.slice(0, 3).forEach((src) => {
1032
+ expect(src).not.toContain("width=");
1033
+ expect(src).not.toContain("height=");
1034
+ expect(src).not.toContain("quality=65");
1035
+ });
1036
+ // The <img> width/height attributes themselves are untouched; only src changes.
1037
+ expect(html).toContain(`<img src="${expected}" width="600" height="auto">`);
1038
+ expect(html).toContain(`<img src="${EXTERNAL_URL_1}" width="100" height="50">`);
1039
+ };
1040
+
1041
+ it("OVERRIDE_IMAGE_QUALITY off: rebuilds an old width=600,height=auto,quality=65 url with no width/height and the config quality; s3 and plain cdn urls give the identical url; external urls unchanged; idempotent", async () => {
1042
+ localStorage.removeItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY");
1043
+ localStorage.removeItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY_MAPPING");
1044
+
1045
+ const once = await cdnUtils.updateImagesInHtml(EMAIL_HTML);
1046
+ expectRebuiltHtml(once, 85);
1047
+
1048
+ const twice = await cdnUtils.updateImagesInHtml(once);
1049
+ expect(twice).toEqual(once);
1050
+ });
1051
+
1052
+ // POST /assets/files/metadata answer for the eucrm banner: 948.6 KB (the api returns KB) keyed by s3 key.
1053
+ // 948.6 KB falls in the [1200, 65] band of IMAGE_QUALITY_MAPPING, which the email clamp raises to 80.
1054
+ const FILE_SIZE_API_RESPONSE = {
1055
+ success: true,
1056
+ status: { isError: false, code: 200, message: "success" },
1057
+ response: { "26c03d91-ab03-42a9-904c-1840f093.png": 948.6 },
1058
+ };
1059
+
1060
+ it("OVERRIDE_IMAGE_QUALITY on: the old quality=65 is NOT reused; all three url forms resolve via the file size lookup and are clamped to 80; external urls unchanged; idempotent", async () => {
1061
+ localStorage.setItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY", "true");
1062
+ localStorage.setItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY_MAPPING", "[[30, 90], [80, 85], [150, 80], [400, 75], [600, 70], [1200, 65], [102400, 60]]");
1063
+ const apiSpy = jest.spyOn(Api, "getS3UrlFileSizes").mockResolvedValue(FILE_SIZE_API_RESPONSE);
1064
+
1065
+ const once = await cdnUtils.updateImagesInHtml(EMAIL_HTML);
1066
+ // 948.6 KB -> band 65 -> clamped to the email minimum 80 (never 65 from the old url, never below 80).
1067
+ expectRebuiltHtml(once, 80);
1068
+ // The old cdn url and the plain cdn url are looked up by the same file key as the s3 url, never by the quality= they carry.
1069
+ expect(apiSpy).toHaveBeenCalledTimes(1);
1070
+ expect(apiSpy.mock.calls[0][0].keys).toEqual([
1071
+ "26c03d91-ab03-42a9-904c-1840f093.png",
1072
+ "26c03d91-ab03-42a9-904c-1840f093.png",
1073
+ "26c03d91-ab03-42a9-904c-1840f093.png",
1074
+ ]);
1075
+ apiSpy.mockResolvedValue(FILE_SIZE_API_RESPONSE);
1076
+
1077
+ const twice = await cdnUtils.updateImagesInHtml(once);
1078
+ expect(twice).toEqual(once);
1079
+ });
1080
+
1081
+ it("OVERRIDE_IMAGE_QUALITY on but the file size api fails: falls back to the config quality for every url form", async () => {
1082
+ jest.spyOn(Api, "getS3UrlFileSizes").mockRejectedValue(new Error("network"));
1083
+ localStorage.setItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY", "true");
1084
+ localStorage.setItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY_MAPPING", "[[30, 90], [80, 85], [150, 80], [400, 75], [600, 70], [1200, 65], [102400, 60]]");
1085
+ const once = await cdnUtils.updateImagesInHtml(EMAIL_HTML);
1086
+ expectRebuiltHtml(once, 85);
1087
+ });
1088
+
1089
+ it("OVERRIDE_IMAGE_QUALITY on with the file size known from the window object (gallery/upload path): clamped to 80 for the s3 url and identical for the cdn forms", async () => {
1090
+ jest.spyOn(Api, "getS3UrlFileSizes").mockResolvedValue(FILE_SIZE_API_RESPONSE);
1091
+ localStorage.setItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY", "true");
1092
+ localStorage.setItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY_MAPPING", "[[30, 90], [80, 85], [150, 80], [400, 75], [600, 70], [1200, 65], [102400, 60]]");
1093
+ window[cdnUtils.CREATIVES_S3_ASSET_FILESIZES] = { [EU.s3]: 971372 };
1094
+
1095
+ const once = await cdnUtils.updateImagesInHtml(EMAIL_HTML);
1096
+ expectRebuiltHtml(once, 80);
1097
+ });
1098
+
1099
+ it("OVERRIDE_IMAGE_QUALITY on: file sizes are matched by exact key, so a.jpg never takes a.jpg.jpeg's size", async () => {
1100
+ localStorage.setItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY", "true");
1101
+ localStorage.setItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY_MAPPING", "[[30, 90], [80, 85], [150, 80], [400, 75], [600, 70], [1200, 65], [102400, 60]]");
1102
+ const SHORT = "https://s3-eu-west-1.amazonaws.com/fs.capillary.eu/intouch_creative_assets/a.jpg";
1103
+ const LONG = "https://s3-eu-west-1.amazonaws.com/fs.capillary.eu/intouch_creative_assets/a.jpg.jpeg";
1104
+ jest.spyOn(Api, "getS3UrlFileSizes").mockResolvedValue({
1105
+ success: true,
1106
+ status: { isError: false, code: 200, message: "success" },
1107
+ response: { "a.jpg.jpeg": 20, "a.jpg": 948.6 }, // 20 KB -> 90, 948.6 KB -> 65 -> clamped 80
1108
+ });
1109
+
1110
+ const once = await cdnUtils.updateImagesInHtml(`<img src="${LONG}"><img src="${SHORT}">`);
1111
+
1112
+ expect(once).toEqual(
1113
+ `<img src="https://storage.crm.eu.content-cdn.io/cdn-cgi/image/format=auto,quality=90,/fs.capillary.eu/intouch_creative_assets/a.jpg.jpeg">`
1114
+ + `<img src="https://storage.crm.eu.content-cdn.io/cdn-cgi/image/format=auto,quality=80,/fs.capillary.eu/intouch_creative_assets/a.jpg">`
1115
+ );
1116
+ });
1117
+
1118
+ it("OVERRIDE_IMAGE_QUALITY on: a file size that maps above 80 keeps that quality (clamp is a floor, not a constant)", async () => {
1119
+ localStorage.setItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY", "true");
1120
+ localStorage.setItem("CREATIVES_CDN_OVERRIDE_DEFAULT_EMAIL_QUALITY_MAPPING", "[[30, 90], [80, 85], [150, 80], [400, 75], [600, 70], [1200, 65], [102400, 60]]");
1121
+ window[cdnUtils.CREATIVES_S3_ASSET_FILESIZES] = { [EU.s3]: 20 * 1024 }; // 20 KB -> 90
1122
+
1123
+ const once = await cdnUtils.updateImagesInHtml(`<img src="${EU.s3}" width="600" height="auto">`);
1124
+ expect(once).toEqual(`<img src="${EU.expected.replace("quality=85,", "quality=90,")}" width="600" height="auto">`);
1125
+ });
1126
+ });
1127
+ });
828
1128
  });
@@ -177,7 +177,8 @@ export function* getBulkCustomerDetails({fetchedUserIds}) {
177
177
  (item) => {
178
178
  const profile = get(item, 'entity.profiles[0]', {});
179
179
  if (isEmpty(profile)) {
180
- throw new Error(item?.errors?.[0]?.message || ERROR_MESSAGES.PROFILE_IS_EMPTY);
180
+ // Skip customers without a profile (e.g. PII-deleted) instead of failing the whole list.
181
+ return null;
181
182
  }
182
183
  const mobileIdentifier = profile?.identifiers?.find(
183
184
  (identifier) => identifier.type === IDENTIFIER_TYPE_MOBILE,
@@ -193,7 +194,7 @@ export function* getBulkCustomerDetails({fetchedUserIds}) {
193
194
  email: emailIdentifier?.value,
194
195
  };
195
196
  },
196
- );
197
+ ).filter(Boolean);
197
198
  yield put({
198
199
  type: GET_TEST_CUSTOMERS_SUCCESS,
199
200
  payload: { customers: transformedData },
@@ -957,6 +957,61 @@ describe('CommonTestAndPreview Sagas', () => {
957
957
  });
958
958
  });
959
959
 
960
+ describe('getBulkCustomerDetails', () => {
961
+ const validItem = {
962
+ entity: {
963
+ id: 101,
964
+ profiles: [{
965
+ firstName: 'Jane',
966
+ lastName: 'Doe',
967
+ identifiers: [
968
+ { type: 'mobile', value: '919999999999' },
969
+ { type: 'email', value: 'jane@example.com' },
970
+ ],
971
+ }],
972
+ },
973
+ };
974
+
975
+ it('should map bulk customer profiles to test customers', () => {
976
+ const generator = getBulkCustomerDetails({ fetchedUserIds: [101] });
977
+
978
+ expect(generator.next().value).toEqual(
979
+ call(Api.getBulkCustomerDetails, { userIds: [101] })
980
+ );
981
+ expect(generator.next({ result: { data: [validItem] } }).value).toEqual(
982
+ put({
983
+ type: GET_TEST_CUSTOMERS_SUCCESS,
984
+ payload: {
985
+ customers: [{
986
+ userId: 101, name: 'Jane Doe', mobile: '919999999999', email: 'jane@example.com',
987
+ }],
988
+ },
989
+ })
990
+ );
991
+ });
992
+
993
+ it('should skip customers with an empty profile instead of failing the whole list', () => {
994
+ const emptyProfileItem = {
995
+ entity: { id: 102, profiles: [] },
996
+ errors: [{ message: 'Customer is deleted after PII delete request', code: 8109, status: false }],
997
+ success: false,
998
+ };
999
+ const generator = getBulkCustomerDetails({ fetchedUserIds: [101, 102] });
1000
+
1001
+ generator.next();
1002
+ expect(generator.next({ result: { data: [emptyProfileItem, validItem] } }).value).toEqual(
1003
+ put({
1004
+ type: GET_TEST_CUSTOMERS_SUCCESS,
1005
+ payload: {
1006
+ customers: [{
1007
+ userId: 101, name: 'Jane Doe', mobile: '919999999999', email: 'jane@example.com',
1008
+ }],
1009
+ },
1010
+ })
1011
+ );
1012
+ });
1013
+ });
1014
+
960
1015
  describe('fetchTestGroupsSaga', () => {
961
1016
  it('should fetch test groups successfully', () => {
962
1017
  const response = {