@adobe/spacecat-shared-tokowaka-client 1.4.4 → 1.4.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
@@ -1,3 +1,17 @@
1
+ # [@adobe/spacecat-shared-tokowaka-client-v1.4.6](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.4.5...@adobe/spacecat-shared-tokowaka-client-v1.4.6) (2026-01-13)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * add api to create/update s3 edge config ([#1260](https://github.com/adobe/spacecat-shared/issues/1260)) ([b5cfff2](https://github.com/adobe/spacecat-shared/commit/b5cfff29cdb6fa29e1b440f86eb86cf8dd471e43))
7
+
8
+ # [@adobe/spacecat-shared-tokowaka-client-v1.4.5](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.4.4...@adobe/spacecat-shared-tokowaka-client-v1.4.5) (2026-01-07)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * fetch api key from s3 and fix invalidation sequence ([#1251](https://github.com/adobe/spacecat-shared/issues/1251)) ([fe66bd6](https://github.com/adobe/spacecat-shared/commit/fe66bd6732dc200ab4df27babc1e0831f6187271))
14
+
1
15
  # [@adobe/spacecat-shared-tokowaka-client-v1.4.4](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.4.3...@adobe/spacecat-shared-tokowaka-client-v1.4.4) (2026-01-05)
2
16
 
3
17
 
package/README.md CHANGED
@@ -146,21 +146,17 @@ The client invalidates CDN cache after uploading configurations. Failures are lo
146
146
 
147
147
  ## Site Configuration
148
148
 
149
- Sites must have the following configuration in their `tokowakaConfig`:
149
+ Sites must have the `tokowakaConfig` field in their site-config to confirm that tokowaka is enabled for that particular site.
150
150
 
151
151
  ```javascript
152
152
  {
153
+ ...
153
154
  "tokowakaConfig": {
154
- "apiKey": "legacy-key-kept-for-backward-compatibility", // Optional, kept for backward compatibility
155
- "forwardedHost": "www.example.com" // Required for preview functionality
155
+ "enabled": true
156
156
  }
157
157
  }
158
158
  ```
159
159
 
160
- **Note:**
161
- - `apiKey` is optional and **not used** for S3 paths or HTTP headers (kept in schema for potential future use)
162
- - `forwardedHost` is **required** for preview functionality to fetch HTML from Tokowaka edge
163
-
164
160
  ## Supported Opportunity Types
165
161
 
166
162
  ### Headings
@@ -215,6 +211,7 @@ Domain-level metaconfig (created once per domain, shared by all URLs):
215
211
  ```json
216
212
  {
217
213
  "siteId": "abc-123",
214
+ "apiKeys": ["tokowaka-api-key-1"],
218
215
  "prerender": true
219
216
  }
220
217
  ```
@@ -308,7 +305,7 @@ TOKOWAKA_CDN_CONFIG='{"cloudfront":{"distributionId":"...","region":"us-east-1"}
308
305
 
309
306
  **Behavior:**
310
307
  - All URLs are batched into a single invalidation request per provider
311
- - All CDN providers are invalidated in parallel using `Promise.all()`
308
+ - All CDN providers are invalidated sequentially
312
309
  - Failures in one CDN don't block others
313
310
  - Each CDN returns its own result in the `cdnInvalidations` array
314
311
  - Results include status, provider name, and provider-specific details
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
@@ -38,7 +38,8 @@
38
38
  "@aws-sdk/client-cloudfront": "3.940.0",
39
39
  "@aws-sdk/client-s3": "3.940.0",
40
40
  "mdast-util-from-markdown": "2.0.2",
41
- "mdast-util-to-hast": "12.3.0"
41
+ "mdast-util-to-hast": "12.3.0",
42
+ "uuid": "11.0.5"
42
43
  },
43
44
  "devDependencies": {
44
45
  "aws-sdk-client-mock": "4.1.0",
package/src/index.d.ts CHANGED
@@ -34,6 +34,7 @@ export const TARGET_USER_AGENTS_CATEGORIES: {
34
34
 
35
35
  export interface TokowakaMetaconfig {
36
36
  siteId: string;
37
+ apiKeys?: string[];
37
38
  prerender?: {
38
39
  allowList?: string[];
39
40
  } | boolean;
package/src/index.js CHANGED
@@ -10,15 +10,17 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import crypto from 'crypto';
13
14
  import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
14
15
  import { hasText, isNonEmptyObject } from '@adobe/spacecat-shared-utils';
16
+ import { v4 as uuidv4 } from 'uuid';
15
17
  import MapperRegistry from './mappers/mapper-registry.js';
16
18
  import CdnClientRegistry from './cdn/cdn-client-registry.js';
17
19
  import { mergePatches } from './utils/patch-utils.js';
18
- import { getTokowakaConfigS3Path, getTokowakaMetaconfigS3Path } from './utils/s3-utils.js';
20
+ import { getTokowakaConfigS3Path, getTokowakaMetaconfigS3Path, getHostName } from './utils/s3-utils.js';
19
21
  import { groupSuggestionsByUrlPath, filterEligibleSuggestions } from './utils/suggestion-utils.js';
20
22
  import { getEffectiveBaseURL } from './utils/site-utils.js';
21
- import { fetchHtmlWithWarmup } from './utils/custom-html-utils.js';
23
+ import { fetchHtmlWithWarmup, calculateForwardedHost } from './utils/custom-html-utils.js';
22
24
 
23
25
  const HTTP_BAD_REQUEST = 400;
24
26
  const HTTP_INTERNAL_SERVER_ERROR = 500;
@@ -219,10 +221,60 @@ class TokowakaClient {
219
221
  }
220
222
  }
221
223
 
224
+ /**
225
+ * Generates an API key for Tokowaka based on domain
226
+ * @param {string} domain - Domain name (e.g., 'example.com')
227
+ * @returns {string} - Base64 URL-encoded API key
228
+ * @private
229
+ */
230
+ /* eslint-disable class-methods-use-this */
231
+ #generateApiKey(normalizedHostName) {
232
+ const uuid = uuidv4();
233
+ return crypto
234
+ .createHash('sha256')
235
+ .update(`${uuid}${normalizedHostName}`)
236
+ .digest('base64url');
237
+ }
238
+
239
+ /**
240
+ * Creates and uploads domain-level metaconfig to S3
241
+ * Generates a new API key and creates the metaconfig structure
242
+ * @param {string} url - Full URL (used to extract domain)
243
+ * @param {string} siteId - Site ID
244
+ * @param {Object} options - Optional configuration
245
+ * @param {boolean} options.tokowakaEnabled - Whether to enable Tokowaka (default: true)
246
+ * @returns {Promise<Object>} - Object with s3Path and metaconfig
247
+ */
248
+ async createMetaconfig(url, siteId, options = {}) {
249
+ if (!hasText(url)) {
250
+ throw this.#createError('URL is required', HTTP_BAD_REQUEST);
251
+ }
252
+
253
+ if (!hasText(siteId)) {
254
+ throw this.#createError('Site ID is required', HTTP_BAD_REQUEST);
255
+ }
256
+
257
+ const normalizedHostName = getHostName(url, this.log);
258
+ const apiKey = this.#generateApiKey(normalizedHostName);
259
+
260
+ const metaconfig = {
261
+ siteId,
262
+ apiKeys: [apiKey],
263
+ tokowakaEnabled: options.tokowakaEnabled ?? true,
264
+ enhancements: false,
265
+ };
266
+
267
+ const s3Path = await this.uploadMetaconfig(url, metaconfig);
268
+
269
+ this.log.info(`Created new Tokowaka metaconfig for ${normalizedHostName} at ${s3Path}`);
270
+
271
+ return metaconfig;
272
+ }
273
+
222
274
  /**
223
275
  * Uploads domain-level metaconfig to S3
224
276
  * @param {string} url - Full URL (used to extract domain)
225
- * @param {Object} metaconfig - Metaconfig object (siteId, prerender)
277
+ * @param {Object} metaconfig - Metaconfig object (siteId, apiKeys, prerender)
226
278
  * @returns {Promise<string>} - S3 key of uploaded metaconfig
227
279
  */
228
280
  async uploadMetaconfig(url, metaconfig) {
@@ -373,7 +425,7 @@ class TokowakaClient {
373
425
 
374
426
  /**
375
427
  * CDN cache invalidation method that supports invalidating URL configs
376
- * or custom S3 paths across provided or default CDN providers
428
+ * or custom S3 paths across provided or default CDN providers.
377
429
  * @param {Object} options - Invalidation options
378
430
  * @param {Array<string>} options.urls - Array of full URLs to invalidate (for URL configs)
379
431
  * @param {Array<string>} options.paths - Custom S3 paths to invalidate directly
@@ -416,37 +468,37 @@ class TokowakaClient {
416
468
  + `via providers: ${providerList.join(', ')}`,
417
469
  );
418
470
 
419
- // Invalidate all providers in parallel
420
- const invalidationPromises = providerList.map(async (provider) => {
471
+ const results = [];
472
+ for (const provider of providerList) {
421
473
  try {
422
474
  const cdnClient = this.cdnClientRegistry.getClient(provider);
423
475
  if (!cdnClient) {
424
476
  this.log.warn(`No CDN client available for provider: ${provider}`);
425
- return {
477
+ results.push({
426
478
  status: 'error',
427
479
  provider,
428
480
  message: `No CDN client available for provider: ${provider}`,
429
- };
481
+ });
482
+ // eslint-disable-next-line no-continue
483
+ continue;
430
484
  }
431
485
 
486
+ // eslint-disable-next-line no-await-in-loop
432
487
  const result = await cdnClient.invalidateCache(pathsToInvalidate);
433
488
  this.log.info(
434
489
  `CDN cache invalidation completed for ${provider}: `
435
490
  + `${pathsToInvalidate.length} path(s)`,
436
491
  );
437
- return result;
492
+ results.push(result);
438
493
  } catch (error) {
439
494
  this.log.warn(`Failed to invalidate ${provider} CDN cache: ${error.message}`, error);
440
- return {
495
+ results.push({
441
496
  status: 'error',
442
497
  provider,
443
498
  message: error.message,
444
- };
499
+ });
445
500
  }
446
- });
447
-
448
- // Wait for all provider invalidations to complete
449
- const results = await Promise.all(invalidationPromises);
501
+ }
450
502
  return results;
451
503
  }
452
504
 
@@ -699,17 +751,6 @@ class TokowakaClient {
699
751
  * @returns {Promise<Object>} - Preview result with config and succeeded/failed suggestions
700
752
  */
701
753
  async previewSuggestions(site, opportunity, suggestions, options = {}) {
702
- // Get site's forwarded host for preview
703
- const { forwardedHost, apiKey } = site.getConfig()?.getTokowakaConfig() || {};
704
-
705
- if (!hasText(forwardedHost) || !hasText(apiKey)) {
706
- throw this.#createError(
707
- 'Site does not have a Tokowaka API key or forwarded host configured. '
708
- + 'Please onboard the site to Tokowaka first.',
709
- HTTP_BAD_REQUEST,
710
- );
711
- }
712
-
713
754
  const opportunityType = opportunity.getType();
714
755
  const mapper = this.mapperRegistry.getMapper(opportunityType);
715
756
  if (!mapper) {
@@ -755,6 +796,29 @@ class TokowakaClient {
755
796
  throw this.#createError('Preview URL not found in suggestion data', HTTP_BAD_REQUEST);
756
797
  }
757
798
 
799
+ // Fetch metaconfig to get API key
800
+ const metaconfig = await this.fetchMetaconfig(previewUrl);
801
+
802
+ if (!metaconfig) {
803
+ throw this.#createError(
804
+ 'No domain-level metaconfig found. '
805
+ + 'A domain-level metaconfig needs to be created first before previewing suggestions.',
806
+ HTTP_INTERNAL_SERVER_ERROR,
807
+ );
808
+ }
809
+
810
+ const { apiKeys } = metaconfig;
811
+ if (!Array.isArray(apiKeys) || apiKeys.length === 0 || !hasText(apiKeys[0])) {
812
+ throw this.#createError(
813
+ 'Metaconfig does not have valid API keys configured. '
814
+ + 'Please ensure the metaconfig has at least one API key.',
815
+ HTTP_INTERNAL_SERVER_ERROR,
816
+ );
817
+ }
818
+
819
+ const apiKey = apiKeys[0];
820
+ const forwardedHost = calculateForwardedHost(previewUrl, this.log);
821
+
758
822
  // Fetch existing deployed configuration for this URL from production S3
759
823
  this.log.debug(`Fetching existing deployed Tokowaka config for URL: ${previewUrl}`);
760
824
  const existingConfig = await this.fetchConfig(previewUrl, false);
@@ -193,3 +193,29 @@ export async function fetchHtmlWithWarmup(
193
193
  throw new Error(errorMsg);
194
194
  }
195
195
  }
196
+
197
+ export function calculateForwardedHost(url, logger = console) {
198
+ try {
199
+ const urlObj = new URL(url);
200
+ const { hostname } = urlObj;
201
+
202
+ // If hostname already starts with www., keep it as is
203
+ if (hostname.startsWith('www.')) {
204
+ logger.debug(`Forwarded host: ${hostname}`);
205
+ return hostname;
206
+ }
207
+
208
+ // Count dots to determine if it's a bare domain or has a subdomain
209
+ const dotCount = (hostname.match(/\./g) || []).length;
210
+
211
+ // If only 1 dot (bare domain like example.com), prepend www.
212
+ // If 2+ dots (subdomain like subdomain.example.com), keep as is
213
+ const forwardedHost = dotCount === 1 ? `www.${hostname}` : hostname;
214
+
215
+ logger.debug(`Forwarded host: ${forwardedHost}`);
216
+ return forwardedHost;
217
+ } catch (error) {
218
+ logger.error(`Error calculating forwarded host from URL ${url}: ${error.message}`);
219
+ throw new Error(`Error calculating forwarded host from URL ${url}: ${error.message}`);
220
+ }
221
+ }
@@ -35,10 +35,17 @@ export function normalizePath(pathname) {
35
35
  * @returns {string} - Normalized hostname
36
36
  * @throws {Error} - If hostname extraction fails
37
37
  */
38
- export function getHostName(url, logger) {
38
+ export function getHostName(url, logger = console) {
39
39
  try {
40
- const finalHostname = url.hostname.replace(/^www\./, '');
41
- return finalHostname;
40
+ let urlObj;
41
+ if (url instanceof URL) {
42
+ urlObj = url;
43
+ } else if (typeof url === 'string') {
44
+ urlObj = new URL(url);
45
+ } else {
46
+ throw new TypeError('Input must be a URL or a string');
47
+ }
48
+ return urlObj.hostname.replace(/^www\./, '');
42
49
  } catch (error) {
43
50
  logger.error(`Error extracting host name: ${error.message}`);
44
51
  throw new Error(`Error extracting host name: ${url.toString()}`);
@@ -456,6 +456,92 @@ describe('TokowakaClient', () => {
456
456
  });
457
457
  });
458
458
 
459
+ describe('createMetaconfig', () => {
460
+ it('should create metaconfig with generated API key and default options', async () => {
461
+ const siteId = 'site-123';
462
+ const url = 'https://www.example.com/page1';
463
+
464
+ const result = await client.createMetaconfig(url, siteId);
465
+
466
+ expect(result).to.have.property('siteId', siteId);
467
+ expect(result).to.have.property('apiKeys');
468
+ expect(result.apiKeys).to.be.an('array').with.lengthOf(1);
469
+ expect(result.apiKeys[0]).to.be.a('string');
470
+ expect(result).to.have.property('tokowakaEnabled', true);
471
+ expect(result).to.have.property('enhancements', false);
472
+
473
+ // Verify uploadMetaconfig was called with correct metaconfig
474
+ expect(s3Client.send).to.have.been.calledOnce;
475
+ const command = s3Client.send.firstCall.args[0];
476
+ expect(command.input.Bucket).to.equal('test-bucket');
477
+ expect(command.input.Key).to.equal('opportunities/example.com/config');
478
+ });
479
+
480
+ it('should create metaconfig with tokowakaEnabled set to false', async () => {
481
+ const siteId = 'site-123';
482
+ const url = 'https://example.com';
483
+
484
+ const result = await client.createMetaconfig(url, siteId, { tokowakaEnabled: false });
485
+
486
+ expect(result).to.have.property('tokowakaEnabled', false);
487
+ expect(result).to.have.property('enhancements', false);
488
+ expect(result.apiKeys).to.have.lengthOf(1);
489
+ });
490
+
491
+ it('should create metaconfig with tokowakaEnabled set to true explicitly', async () => {
492
+ const siteId = 'site-123';
493
+ const url = 'https://example.com';
494
+
495
+ const result = await client.createMetaconfig(url, siteId, { tokowakaEnabled: true });
496
+
497
+ expect(result).to.have.property('tokowakaEnabled', true);
498
+ expect(result).to.have.property('enhancements', false);
499
+ });
500
+
501
+ it('should throw error if URL is missing', async () => {
502
+ try {
503
+ await client.createMetaconfig('', 'site-123');
504
+ expect.fail('Should have thrown error');
505
+ } catch (error) {
506
+ expect(error.message).to.include('URL is required');
507
+ expect(error.status).to.equal(400);
508
+ }
509
+ });
510
+
511
+ it('should throw error if siteId is missing', async () => {
512
+ try {
513
+ await client.createMetaconfig('https://example.com', '');
514
+ expect.fail('Should have thrown error');
515
+ } catch (error) {
516
+ expect(error.message).to.include('Site ID is required');
517
+ expect(error.status).to.equal(400);
518
+ }
519
+ });
520
+
521
+ it('should handle S3 upload failure', async () => {
522
+ const s3Error = new Error('S3 network error');
523
+ s3Client.send.rejects(s3Error);
524
+
525
+ try {
526
+ await client.createMetaconfig('https://example.com', 'site-123');
527
+ expect.fail('Should have thrown error');
528
+ } catch (error) {
529
+ expect(error.message).to.include('S3 upload failed');
530
+ expect(error.status).to.equal(500);
531
+ }
532
+ });
533
+
534
+ it('should strip www. from domain in metaconfig path', async () => {
535
+ const siteId = 'site-123';
536
+ const url = 'https://www.example.com/some/path';
537
+
538
+ await client.createMetaconfig(url, siteId);
539
+
540
+ const command = s3Client.send.firstCall.args[0];
541
+ expect(command.input.Key).to.equal('opportunities/example.com/config');
542
+ });
543
+ });
544
+
459
545
  describe('uploadConfig', () => {
460
546
  it('should upload config to S3', async () => {
461
547
  const config = {
@@ -1698,6 +1784,12 @@ describe('TokowakaClient', () => {
1698
1784
  // Stub fetchConfig to return null by default (no existing config)
1699
1785
  sinon.stub(client, 'fetchConfig').resolves(null);
1700
1786
 
1787
+ // Stub fetchMetaconfig to return metaconfig with apiKeys array
1788
+ sinon.stub(client, 'fetchMetaconfig').resolves({
1789
+ siteId: 'site-123',
1790
+ apiKeys: ['test-api-key-1', 'test-api-key-2'],
1791
+ });
1792
+
1701
1793
  // Add TOKOWAKA_EDGE_URL to env
1702
1794
  client.env.TOKOWAKA_EDGE_URL = 'https://edge-dev.tokowaka.now';
1703
1795
  });
@@ -1804,31 +1896,60 @@ describe('TokowakaClient', () => {
1804
1896
  }
1805
1897
  });
1806
1898
 
1807
- it('should throw error if site does not have forwardedHost', async () => {
1808
- mockSite.getConfig = () => ({
1809
- getTokowakaConfig: () => ({}),
1899
+ it('should throw error if metaconfig does not exist', async () => {
1900
+ client.fetchMetaconfig.resolves(null);
1901
+
1902
+ try {
1903
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1904
+ expect.fail('Should have thrown error');
1905
+ } catch (error) {
1906
+ expect(error.message).to.include('No domain-level metaconfig found');
1907
+ expect(error.status).to.equal(500);
1908
+ }
1909
+ });
1910
+
1911
+ it('should throw error if metaconfig does not have apiKeys', async () => {
1912
+ client.fetchMetaconfig.resolves({
1913
+ siteId: 'site-123',
1914
+ // apiKeys missing
1810
1915
  });
1811
1916
 
1812
1917
  try {
1813
1918
  await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1814
1919
  expect.fail('Should have thrown error');
1815
1920
  } catch (error) {
1816
- expect(error.message).to.include('Site does not have a Tokowaka API key or forwarded host configured');
1817
- expect(error.status).to.equal(400);
1921
+ expect(error.message).to.include('Metaconfig does not have valid API keys configured');
1922
+ expect(error.status).to.equal(500);
1818
1923
  }
1819
1924
  });
1820
1925
 
1821
- it('should throw error if getTokowakaConfig returns null', async () => {
1822
- mockSite.getConfig = () => ({
1823
- getTokowakaConfig: () => null,
1926
+ it('should throw error if metaconfig has empty apiKeys array', async () => {
1927
+ client.fetchMetaconfig.resolves({
1928
+ siteId: 'site-123',
1929
+ apiKeys: [],
1824
1930
  });
1825
1931
 
1826
1932
  try {
1827
1933
  await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1828
1934
  expect.fail('Should have thrown error');
1829
1935
  } catch (error) {
1830
- expect(error.message).to.include('Site does not have a Tokowaka API key or forwarded host configured');
1831
- expect(error.status).to.equal(400);
1936
+ expect(error.message).to.include('Metaconfig does not have valid API keys configured');
1937
+ expect(error.status).to.equal(500);
1938
+ }
1939
+ });
1940
+
1941
+ it('should throw error if metaconfig apiKeys first value is empty', async () => {
1942
+ client.fetchMetaconfig.resolves({
1943
+ siteId: 'site-123',
1944
+ apiKeys: ['', 'test-api-key-2'],
1945
+ });
1946
+
1947
+ try {
1948
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1949
+ expect.fail('Should have thrown error');
1950
+ } catch (error) {
1951
+ expect(error.message).to.include('Metaconfig does not have valid API keys configured');
1952
+ expect(error.status).to.equal(500);
1832
1953
  }
1833
1954
  });
1834
1955
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { expect } from 'chai';
16
16
  import sinon from 'sinon';
17
- import { fetchHtmlWithWarmup } from '../../src/utils/custom-html-utils.js';
17
+ import { fetchHtmlWithWarmup, calculateForwardedHost } from '../../src/utils/custom-html-utils.js';
18
18
 
19
19
  describe('HTML Utils', () => {
20
20
  describe('fetchHtmlWithWarmup', () => {
@@ -429,4 +429,104 @@ describe('HTML Utils', () => {
429
429
  expect(fetchStub.callCount).to.equal(2); // warmup + 1 actual
430
430
  });
431
431
  });
432
+
433
+ describe('calculateForwardedHost', () => {
434
+ let log;
435
+
436
+ beforeEach(() => {
437
+ log = {
438
+ debug: sinon.stub(),
439
+ warn: sinon.stub(),
440
+ error: sinon.stub(),
441
+ info: sinon.stub(),
442
+ };
443
+ });
444
+
445
+ afterEach(() => {
446
+ sinon.restore();
447
+ });
448
+
449
+ it('should add www prefix to bare domain', async () => {
450
+ const result = calculateForwardedHost('https://example.com/page', log);
451
+ expect(result).to.equal('www.example.com');
452
+ });
453
+
454
+ it('should keep www prefix when already present', async () => {
455
+ const result = calculateForwardedHost('https://www.example.com/page', log);
456
+ expect(result).to.equal('www.example.com');
457
+ });
458
+
459
+ it('should keep subdomain without adding www', async () => {
460
+ const result = calculateForwardedHost('https://subdomain.example.com/page', log);
461
+ expect(result).to.equal('subdomain.example.com');
462
+ });
463
+
464
+ it('should keep www.subdomain as is', async () => {
465
+ const result = calculateForwardedHost('https://www.subdomain.example.com/page', log);
466
+ expect(result).to.equal('www.subdomain.example.com');
467
+ });
468
+
469
+ it('should add www to bare domain with port', async () => {
470
+ const result = calculateForwardedHost('https://example.com:8080/page', log);
471
+ expect(result).to.equal('www.example.com');
472
+ });
473
+
474
+ it('should keep www with port', async () => {
475
+ const result = calculateForwardedHost('https://www.example.com:8080/page', log);
476
+ expect(result).to.equal('www.example.com');
477
+ });
478
+
479
+ it('should handle URL with query parameters', async () => {
480
+ const result = calculateForwardedHost('https://example.com/page?param=value', log);
481
+ expect(result).to.equal('www.example.com');
482
+ });
483
+
484
+ it('should handle URL with hash fragment', async () => {
485
+ const result = calculateForwardedHost('https://example.com/page#section', log);
486
+ expect(result).to.equal('www.example.com');
487
+ });
488
+
489
+ it('should handle http protocol', async () => {
490
+ const result = calculateForwardedHost('http://example.com/page', log);
491
+ expect(result).to.equal('www.example.com');
492
+ });
493
+
494
+ it('should handle deep subdomains', async () => {
495
+ const result = calculateForwardedHost('https://level1.level2.example.com/page', log);
496
+ expect(result).to.equal('level1.level2.example.com');
497
+ });
498
+
499
+ it('should throw error for invalid URL', async () => {
500
+ try {
501
+ calculateForwardedHost('not-a-valid-url', log);
502
+ expect.fail('Should have thrown error');
503
+ } catch (error) {
504
+ expect(error.message).to.include('Error calculating forwarded host from URL');
505
+ expect(error.message).to.include('not-a-valid-url');
506
+ expect(log.error).to.have.been.calledOnce;
507
+ }
508
+ });
509
+
510
+ it('should throw error for empty URL', async () => {
511
+ try {
512
+ calculateForwardedHost('', log);
513
+ expect.fail('Should have thrown error');
514
+ } catch (error) {
515
+ expect(error.message).to.include('Error calculating forwarded host from URL');
516
+ expect(log.error).to.have.been.calledOnce;
517
+ }
518
+ });
519
+
520
+ it('should use console as default logger when not provided', async () => {
521
+ const result = calculateForwardedHost('https://example.com/page');
522
+ expect(result).to.equal('www.example.com');
523
+ });
524
+
525
+ it('should handle hostname with no dots (edge case)', async () => {
526
+ // This is an edge case for localhost or single-word hostnames
527
+ const result = calculateForwardedHost('http://localhost/page', log);
528
+ // No dots means dotCount = 0, so it won't match dotCount === 1, keeps as is
529
+ expect(result).to.equal('localhost');
530
+ });
531
+ });
432
532
  });