@adobe/spacecat-shared-tokowaka-client 1.4.6 → 1.5.0

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.5.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.4.7...@adobe/spacecat-shared-tokowaka-client-v1.5.0) (2026-01-15)
2
+
3
+
4
+ ### Features
5
+
6
+ * add TokowakaKVClient for fetching stale suggestions from edge o… ([#1262](https://github.com/adobe/spacecat-shared/issues/1262)) ([e8d440b](https://github.com/adobe/spacecat-shared/commit/e8d440bcc462419b4c82d3415f3a83dfde5195f1))
7
+
8
+ # [@adobe/spacecat-shared-tokowaka-client-v1.4.7](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.4.6...@adobe/spacecat-shared-tokowaka-client-v1.4.7) (2026-01-14)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * update tokowaka s3 config with patches ([#1263](https://github.com/adobe/spacecat-shared/issues/1263)) ([59605e9](https://github.com/adobe/spacecat-shared/commit/59605e9b75b7289029bd14e1832e594ea565296d))
14
+
1
15
  # [@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
16
 
3
17
 
package/README.md CHANGED
@@ -64,10 +64,12 @@ Creates a client instance from a context object.
64
64
 
65
65
  #### `deploySuggestions(site, opportunity, suggestions)`
66
66
 
67
- Generates configuration and uploads to S3 **per URL**. **Automatically fetches existing configuration for each URL and merges** new suggestions with it. Invalidates CDN cache after upload.
67
+ Generates configuration and uploads to S3 **per URL**. **Automatically fetches existing configuration for each URL and merges** new suggestions with it. Invalidates CDN cache after upload. **Updates the metaconfig's `patches` field** to track deployed endpoints.
68
68
 
69
69
  **Architecture Change:** Creates one S3 file per URL instead of a single file with all URLs. This prevents files from growing too large over time.
70
70
 
71
+ **Metaconfig Tracking:** After successful deployment, the method updates the domain-level metaconfig's `patches` object with the normalized paths of all deployed endpoints (e.g., `{ "/products/item": true }`). This provides a centralized registry of all deployed endpoints for the domain.
72
+
71
73
  **Returns:** `Promise<DeploymentResult>` with:
72
74
  - `s3Paths` - Array of S3 keys where configs were uploaded (one per URL)
73
75
  - `cdnInvalidations` - Array of CDN invalidation results (one per URL per provider)
@@ -212,7 +214,17 @@ Domain-level metaconfig (created once per domain, shared by all URLs):
212
214
  {
213
215
  "siteId": "abc-123",
214
216
  "apiKeys": ["tokowaka-api-key-1"],
215
- "prerender": true
217
+ "tokowakaEnabled": true,
218
+ "enhancements": true,
219
+ "prerender": {
220
+ "allowList": [],
221
+ "denyList": ["/*"]
222
+ },
223
+ "patches": {
224
+ "/products/item": true,
225
+ "/about": true,
226
+ "/contact": true
227
+ }
216
228
  }
217
229
  ```
218
230
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.4.6",
3
+ "version": "1.5.0",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,197 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import {
14
+ tracingFetch as fetch,
15
+ hasText,
16
+ isNonEmptyArray,
17
+ } from '@adobe/spacecat-shared-utils';
18
+
19
+ const FASTLY_KV_API_BASE = 'https://api.fastly.com/resources/stores/kv';
20
+ const DEFAULT_PAGE_SIZE = 100;
21
+ const DEFAULT_TIMEOUT = 30000;
22
+
23
+ /**
24
+ * Client for interacting with Fastly KV Store used by Tokowaka.
25
+ * Used to fetch stale suggestion IDs for cleanup.
26
+ *
27
+ * Key format: `${suggestionId}`
28
+ * Value format: { url: string, status: 'stale' | 'live' }
29
+ */
30
+ export class FastlyKVClient {
31
+ /**
32
+ * Creates a new FastlyKVClient instance.
33
+ * @param {object} env - Environment variables containing FASTLY_KV_STORE_ID and FASTLY_API_TOKEN
34
+ * @param {object} log - Logger instance
35
+ */
36
+ constructor(env, log) {
37
+ if (!hasText(env?.FASTLY_KV_STORE_ID)) {
38
+ throw new Error('FASTLY_KV_STORE_ID environment variable is required');
39
+ }
40
+ if (!hasText(env?.FASTLY_API_TOKEN)) {
41
+ throw new Error('FASTLY_API_TOKEN environment variable is required');
42
+ }
43
+
44
+ this.storeId = env.FASTLY_KV_STORE_ID;
45
+ this.apiToken = env.FASTLY_API_TOKEN;
46
+ this.log = log;
47
+ this.timeout = env.FASTLY_KV_TIMEOUT || DEFAULT_TIMEOUT;
48
+ }
49
+
50
+ /**
51
+ * Creates the authorization headers for Fastly API requests.
52
+ * @returns {object} Headers object
53
+ */
54
+ #getHeaders() {
55
+ return {
56
+ 'Fastly-Key': this.apiToken,
57
+ Accept: 'application/json',
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Retrieves the value for a specific key from the KV store.
63
+ *
64
+ * @param {string} key - The key to retrieve
65
+ * @returns {Promise<{url: string, status: string}|null>} The parsed value or null if not found
66
+ */
67
+ async #getValue(key) {
68
+ const url = `${FASTLY_KV_API_BASE}/${this.storeId}/keys/${encodeURIComponent(key)}`;
69
+
70
+ const response = await fetch(url, {
71
+ method: 'GET',
72
+ headers: this.#getHeaders(),
73
+ timeout: this.timeout,
74
+ });
75
+
76
+ if (response.status === 404) {
77
+ return null;
78
+ }
79
+
80
+ if (!response.ok) {
81
+ const errorText = await response.text();
82
+ throw new Error(`Failed to get value from KV Store: ${response.status} - ${errorText}`);
83
+ }
84
+
85
+ const text = await response.text();
86
+
87
+ try {
88
+ return JSON.parse(text);
89
+ } catch {
90
+ this.log.warn(`Failed to parse value for key ${key} as JSON`);
91
+ return { url: text, status: 'unknown' };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Lists stale keys from a single page of the KV store.
97
+ *
98
+ * @param {object} [options] - Options for listing keys
99
+ * @param {number} [options.pageSize=100] - Number of keys to fetch per page
100
+ * @param {string} [options.cursor] - Cursor for pagination
101
+ * @returns {Promise<{keys: Array<object>, cursor: string|null}>}
102
+ */
103
+ async #listStaleKeysPage(options = {}) {
104
+ const { pageSize = DEFAULT_PAGE_SIZE, cursor } = options;
105
+ const staleEntries = [];
106
+
107
+ const url = new URL(`${FASTLY_KV_API_BASE}/${this.storeId}/keys`);
108
+ url.searchParams.set('limit', pageSize.toString());
109
+ if (cursor) {
110
+ url.searchParams.set('cursor', cursor);
111
+ }
112
+
113
+ const response = await fetch(url.toString(), {
114
+ method: 'GET',
115
+ headers: this.#getHeaders(),
116
+ timeout: this.timeout,
117
+ });
118
+
119
+ if (!response.ok) {
120
+ const errorText = await response.text();
121
+ throw new Error(`Failed to list keys from KV Store: ${response.status} - ${errorText}`);
122
+ }
123
+
124
+ const result = await response.json();
125
+ const { data: keys = [], meta = {} } = result;
126
+
127
+ for (const keyName of keys) {
128
+ try {
129
+ // eslint-disable-next-line no-await-in-loop
130
+ const value = await this.#getValue(keyName);
131
+
132
+ const normalizedStatus = typeof value?.status === 'string'
133
+ ? value.status.trim().toLowerCase()
134
+ : '';
135
+
136
+ if (normalizedStatus === 'stale' && hasText(keyName)) {
137
+ staleEntries.push({
138
+ key: keyName,
139
+ suggestionId: keyName,
140
+ url: value.url,
141
+ });
142
+ }
143
+ } catch (error) {
144
+ this.log.warn(`Failed to fetch value for key ${keyName}: ${error.message}`);
145
+ }
146
+ }
147
+
148
+ return {
149
+ keys: staleEntries,
150
+ cursor: meta.next_cursor || null,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Lists all stale suggestion IDs from the KV store, handling pagination automatically.
156
+ *
157
+ * @param {object} [options] - Options for listing keys
158
+ * @param {number} [options.pageSize=100] - Number of keys to fetch per page
159
+ * @param {number} [options.maxPages=100] - Maximum number of pages to fetch (safety limit)
160
+ * @returns {Promise<Array<{key: string, suggestionId: string, url: string}>>}
161
+ */
162
+ async listAllStaleKeys(options = {}) {
163
+ const { pageSize = DEFAULT_PAGE_SIZE, maxPages = 100 } = options;
164
+ const allStaleKeys = [];
165
+ let cursor = null;
166
+ let pageCount = 0;
167
+
168
+ this.log.info('Starting to fetch stale keys from Fastly KV Store');
169
+
170
+ do {
171
+ // eslint-disable-next-line no-await-in-loop
172
+ const result = await this.#listStaleKeysPage({ pageSize, cursor });
173
+
174
+ if (isNonEmptyArray(result.keys)) {
175
+ allStaleKeys.push(...result.keys);
176
+ }
177
+
178
+ cursor = result.cursor;
179
+ pageCount += 1;
180
+
181
+ this.log.debug(
182
+ `Fetched page ${pageCount}, found ${result.keys.length} stale keys, total: ${allStaleKeys.length}`,
183
+ );
184
+
185
+ if (pageCount >= maxPages) {
186
+ this.log.warn(`Reached maximum page limit (${maxPages}), stopping pagination`);
187
+ break;
188
+ }
189
+ } while (cursor);
190
+
191
+ this.log.info(`Completed fetching stale keys: ${allStaleKeys.length} total from ${pageCount} pages`);
192
+
193
+ return allStaleKeys;
194
+ }
195
+ }
196
+
197
+ export default FastlyKVClient;
package/src/index.d.ts CHANGED
@@ -128,6 +128,25 @@ export interface Suggestion {
128
128
  getUpdatedAt(): string;
129
129
  }
130
130
 
131
+ export interface FastlyKVEntry {
132
+ key: string;
133
+ suggestionId: string;
134
+ url: string;
135
+ }
136
+
137
+ export class FastlyKVClient {
138
+ constructor(env: {
139
+ FASTLY_KV_STORE_ID: string;
140
+ FASTLY_API_TOKEN: string;
141
+ FASTLY_KV_TIMEOUT?: number;
142
+ }, log: any);
143
+
144
+ listAllStaleKeys(options?: {
145
+ pageSize?: number;
146
+ maxPages?: number;
147
+ }): Promise<FastlyKVEntry[]>;
148
+ }
149
+
131
150
  /**
132
151
  * Base class for opportunity mappers
133
152
  * Extend this class to create custom mappers for new opportunity types
package/src/index.js CHANGED
@@ -17,11 +17,18 @@ import { v4 as uuidv4 } from 'uuid';
17
17
  import MapperRegistry from './mappers/mapper-registry.js';
18
18
  import CdnClientRegistry from './cdn/cdn-client-registry.js';
19
19
  import { mergePatches } from './utils/patch-utils.js';
20
- import { getTokowakaConfigS3Path, getTokowakaMetaconfigS3Path, getHostName } from './utils/s3-utils.js';
20
+ import {
21
+ getTokowakaConfigS3Path,
22
+ getTokowakaMetaconfigS3Path,
23
+ getHostName,
24
+ normalizePath,
25
+ } from './utils/s3-utils.js';
21
26
  import { groupSuggestionsByUrlPath, filterEligibleSuggestions } from './utils/suggestion-utils.js';
22
27
  import { getEffectiveBaseURL } from './utils/site-utils.js';
23
28
  import { fetchHtmlWithWarmup, calculateForwardedHost } from './utils/custom-html-utils.js';
24
29
 
30
+ export { FastlyKVClient } from './fastly-kv-client.js';
31
+
25
32
  const HTTP_BAD_REQUEST = 400;
26
33
  const HTTP_INTERNAL_SERVER_ERROR = 500;
27
34
  const HTTP_NOT_IMPLEMENTED = 501;
@@ -94,6 +101,44 @@ class TokowakaClient {
94
101
  return error;
95
102
  }
96
103
 
104
+ /**
105
+ * Updates the metaconfig with deployed endpoint paths
106
+ * @param {Object} metaconfig - Existing metaconfig object
107
+ * @param {Array<string>} deployedUrls - Array of successfully deployed URLs
108
+ * @param {string} baseUrl - Base URL for uploading metaconfig
109
+ * @returns {Promise<void>}
110
+ * @private
111
+ */
112
+ async #updateMetaconfigWithDeployedPaths(metaconfig, deployedUrls, baseUrl) {
113
+ if (!Array.isArray(deployedUrls) || deployedUrls.length === 0) {
114
+ return;
115
+ }
116
+
117
+ try {
118
+ // Initialize patches field if it doesn't exist
119
+ const updatedMetaconfig = {
120
+ ...metaconfig,
121
+ patches: { ...(metaconfig.patches || {}) },
122
+ };
123
+
124
+ // Extract normalized paths from deployed URLs and add to patches object
125
+ deployedUrls.forEach((url) => {
126
+ const urlObj = new URL(url);
127
+ const normalizedPath = normalizePath(urlObj.pathname);
128
+ updatedMetaconfig.patches[normalizedPath] = true;
129
+ });
130
+
131
+ await this.uploadMetaconfig(baseUrl, updatedMetaconfig);
132
+ this.log.info(`Updated metaconfig with ${deployedUrls.length} deployed endpoint(s)`);
133
+ } catch (error) {
134
+ this.log.error(`Failed to update metaconfig with deployed paths: ${error.message}`, error);
135
+ throw this.#createError(
136
+ `Failed to update metaconfig with deployed paths: ${error.message}`,
137
+ HTTP_INTERNAL_SERVER_ERROR,
138
+ );
139
+ }
140
+ }
141
+
97
142
  /**
98
143
  * Gets the list of CDN providers from environment configuration
99
144
  * Supports both single provider (string) and multiple providers (comma-separated string or array)
@@ -597,6 +642,9 @@ class TokowakaClient {
597
642
 
598
643
  this.log.info(`Uploaded Tokowaka configs for ${s3Paths.length} URLs`);
599
644
 
645
+ // Update metaconfig with deployed paths
646
+ await this.#updateMetaconfigWithDeployedPaths(metaconfig, deployedUrls, baseURL);
647
+
600
648
  // Invalidate CDN cache for all deployed URLs at once
601
649
  const cdnInvalidations = await this.invalidateCdnCache({ urls: deployedUrls });
602
650
 
@@ -0,0 +1,254 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /* eslint-env mocha */
14
+
15
+ import { expect } from 'chai';
16
+ import sinon from 'sinon';
17
+ import nock from 'nock';
18
+ import { FastlyKVClient } from '../src/fastly-kv-client.js';
19
+
20
+ describe('FastlyKVClient', () => {
21
+ let sandbox;
22
+ let log;
23
+ let env;
24
+
25
+ const FASTLY_KV_API_BASE = 'https://api.fastly.com/resources/stores/kv';
26
+ const TEST_STORE_ID = 'test-store-id';
27
+ const TEST_API_TOKEN = 'test-api-token';
28
+
29
+ beforeEach(() => {
30
+ sandbox = sinon.createSandbox();
31
+ log = {
32
+ debug: sandbox.spy(),
33
+ info: sandbox.spy(),
34
+ warn: sandbox.spy(),
35
+ error: sandbox.spy(),
36
+ };
37
+ env = {
38
+ FASTLY_KV_STORE_ID: TEST_STORE_ID,
39
+ FASTLY_API_TOKEN: TEST_API_TOKEN,
40
+ };
41
+ });
42
+
43
+ afterEach(() => {
44
+ sandbox.restore();
45
+ nock.cleanAll();
46
+ });
47
+
48
+ describe('constructor', () => {
49
+ it('should create a client with valid configuration', () => {
50
+ const client = new FastlyKVClient(env, log);
51
+ expect(client.storeId).to.equal(TEST_STORE_ID);
52
+ expect(client.apiToken).to.equal(TEST_API_TOKEN);
53
+ });
54
+
55
+ it('should throw error if FASTLY_KV_STORE_ID is missing', () => {
56
+ delete env.FASTLY_KV_STORE_ID;
57
+ expect(() => new FastlyKVClient(env, log))
58
+ .to.throw('FASTLY_KV_STORE_ID environment variable is required');
59
+ });
60
+
61
+ it('should throw error if FASTLY_API_TOKEN is missing', () => {
62
+ delete env.FASTLY_API_TOKEN;
63
+ expect(() => new FastlyKVClient(env, log))
64
+ .to.throw('FASTLY_API_TOKEN environment variable is required');
65
+ });
66
+
67
+ it('should throw error if env is null', () => {
68
+ expect(() => new FastlyKVClient(null, log))
69
+ .to.throw('FASTLY_KV_STORE_ID environment variable is required');
70
+ });
71
+ });
72
+
73
+ describe('listAllStaleKeys', () => {
74
+ it('should fetch all pages of stale keys', async () => {
75
+ const client = new FastlyKVClient(env, log);
76
+ const keysPage1 = ['sugg-1'];
77
+ const keysPage2 = ['sugg-2'];
78
+ const staleValue = { url: 'https://example.com', status: 'stale' };
79
+
80
+ // Page 1
81
+ nock(FASTLY_KV_API_BASE)
82
+ .get(`/${TEST_STORE_ID}/keys`)
83
+ .query({ limit: '100' })
84
+ .reply(200, { data: keysPage1, meta: { next_cursor: 'cursor-1' } });
85
+
86
+ nock(FASTLY_KV_API_BASE)
87
+ .get(`/${TEST_STORE_ID}/keys/${encodeURIComponent(keysPage1[0])}`)
88
+ .reply(200, JSON.stringify(staleValue));
89
+
90
+ // Page 2
91
+ nock(FASTLY_KV_API_BASE)
92
+ .get(`/${TEST_STORE_ID}/keys`)
93
+ .query({ limit: '100', cursor: 'cursor-1' })
94
+ .reply(200, { data: keysPage2, meta: {} });
95
+
96
+ nock(FASTLY_KV_API_BASE)
97
+ .get(`/${TEST_STORE_ID}/keys/${encodeURIComponent(keysPage2[0])}`)
98
+ .reply(200, JSON.stringify(staleValue));
99
+
100
+ const result = await client.listAllStaleKeys();
101
+
102
+ expect(result).to.have.lengthOf(2);
103
+ expect(result[0].suggestionId).to.equal('sugg-1');
104
+ expect(result[1].suggestionId).to.equal('sugg-2');
105
+ });
106
+
107
+ it('should filter out non-stale keys', async () => {
108
+ const client = new FastlyKVClient(env, log);
109
+ const keys = ['sugg-stale', 'sugg-live'];
110
+
111
+ nock(FASTLY_KV_API_BASE)
112
+ .get(`/${TEST_STORE_ID}/keys`)
113
+ .query({ limit: '100' })
114
+ .reply(200, { data: keys, meta: {} });
115
+
116
+ nock(FASTLY_KV_API_BASE)
117
+ .get(`/${TEST_STORE_ID}/keys/${encodeURIComponent(keys[0])}`)
118
+ .reply(200, JSON.stringify({ url: 'https://example.com/stale', status: 'stale' }));
119
+
120
+ nock(FASTLY_KV_API_BASE)
121
+ .get(`/${TEST_STORE_ID}/keys/${encodeURIComponent(keys[1])}`)
122
+ .reply(200, JSON.stringify({ url: 'https://example.com/live', status: 'live' }));
123
+
124
+ const result = await client.listAllStaleKeys();
125
+
126
+ expect(result).to.have.lengthOf(1);
127
+ expect(result[0].suggestionId).to.equal('sugg-stale');
128
+ expect(result[0].url).to.equal('https://example.com/stale');
129
+ });
130
+
131
+ it('should respect maxPages limit', async () => {
132
+ const client = new FastlyKVClient(env, log);
133
+ const staleValue = { url: 'https://example.com', status: 'stale' };
134
+
135
+ // Always return a next cursor to simulate infinite pages
136
+ nock(FASTLY_KV_API_BASE)
137
+ .get(`/${TEST_STORE_ID}/keys`)
138
+ .query(true)
139
+ .times(3)
140
+ .reply(200, { data: ['sugg-1'], meta: { next_cursor: 'next' } });
141
+
142
+ nock(FASTLY_KV_API_BASE)
143
+ .get(`/${TEST_STORE_ID}/keys/${encodeURIComponent('sugg-1')}`)
144
+ .times(3)
145
+ .reply(200, JSON.stringify(staleValue));
146
+
147
+ const result = await client.listAllStaleKeys({ maxPages: 3 });
148
+
149
+ expect(result).to.have.lengthOf(3);
150
+ expect(log.warn.calledWith(sinon.match(/Reached maximum page limit/))).to.be.true;
151
+ });
152
+
153
+ it('should return empty array if no stale keys found', async () => {
154
+ const client = new FastlyKVClient(env, log);
155
+
156
+ nock(FASTLY_KV_API_BASE)
157
+ .get(`/${TEST_STORE_ID}/keys`)
158
+ .query({ limit: '100' })
159
+ .reply(200, { data: [], meta: {} });
160
+
161
+ const result = await client.listAllStaleKeys();
162
+
163
+ expect(result).to.have.lengthOf(0);
164
+ });
165
+
166
+ it('should continue processing if individual key fetch fails', async () => {
167
+ const client = new FastlyKVClient(env, log);
168
+ const keys = ['sugg-fail', 'sugg-success'];
169
+
170
+ nock(FASTLY_KV_API_BASE)
171
+ .get(`/${TEST_STORE_ID}/keys`)
172
+ .query({ limit: '100' })
173
+ .reply(200, { data: keys, meta: {} });
174
+
175
+ // First key fails
176
+ nock(FASTLY_KV_API_BASE)
177
+ .get(`/${TEST_STORE_ID}/keys/${encodeURIComponent(keys[0])}`)
178
+ .reply(500, 'Error');
179
+
180
+ // Second key succeeds
181
+ nock(FASTLY_KV_API_BASE)
182
+ .get(`/${TEST_STORE_ID}/keys/${encodeURIComponent(keys[1])}`)
183
+ .reply(200, JSON.stringify({ url: 'https://example.com', status: 'stale' }));
184
+
185
+ const result = await client.listAllStaleKeys();
186
+
187
+ expect(result).to.have.lengthOf(1);
188
+ expect(result[0].suggestionId).to.equal('sugg-success');
189
+ expect(log.warn.called).to.be.true;
190
+ });
191
+
192
+ it('should handle non-JSON response gracefully', async () => {
193
+ const client = new FastlyKVClient(env, log);
194
+ const keys = ['sugg-1'];
195
+
196
+ nock(FASTLY_KV_API_BASE)
197
+ .get(`/${TEST_STORE_ID}/keys`)
198
+ .query({ limit: '100' })
199
+ .reply(200, { data: keys, meta: {} });
200
+
201
+ nock(FASTLY_KV_API_BASE)
202
+ .get(`/${TEST_STORE_ID}/keys/${encodeURIComponent(keys[0])}`)
203
+ .reply(200, 'plain-text-value');
204
+
205
+ const result = await client.listAllStaleKeys();
206
+
207
+ // Non-JSON response gets status 'unknown', which is not 'stale', so filtered out
208
+ expect(result).to.have.lengthOf(0);
209
+ expect(log.warn.called).to.be.true;
210
+ });
211
+
212
+ it('should skip keys that return 404', async () => {
213
+ const client = new FastlyKVClient(env, log);
214
+ const keys = ['sugg-deleted', 'sugg-exists'];
215
+
216
+ nock(FASTLY_KV_API_BASE)
217
+ .get(`/${TEST_STORE_ID}/keys`)
218
+ .query({ limit: '100' })
219
+ .reply(200, { data: keys, meta: {} });
220
+
221
+ // First key returns 404
222
+ nock(FASTLY_KV_API_BASE)
223
+ .get(`/${TEST_STORE_ID}/keys/${encodeURIComponent(keys[0])}`)
224
+ .reply(404, 'Not Found');
225
+
226
+ // Second key exists and is stale
227
+ nock(FASTLY_KV_API_BASE)
228
+ .get(`/${TEST_STORE_ID}/keys/${encodeURIComponent(keys[1])}`)
229
+ .reply(200, JSON.stringify({ url: 'https://example.com', status: 'stale' }));
230
+
231
+ const result = await client.listAllStaleKeys();
232
+
233
+ // Only the existing stale key should be returned
234
+ expect(result).to.have.lengthOf(1);
235
+ expect(result[0].suggestionId).to.equal('sugg-exists');
236
+ });
237
+
238
+ it('should throw error when listing keys fails', async () => {
239
+ const client = new FastlyKVClient(env, log);
240
+
241
+ nock(FASTLY_KV_API_BASE)
242
+ .get(`/${TEST_STORE_ID}/keys`)
243
+ .query({ limit: '100' })
244
+ .reply(500, 'Internal Server Error');
245
+
246
+ try {
247
+ await client.listAllStaleKeys();
248
+ expect.fail('Should have thrown an error');
249
+ } catch (error) {
250
+ expect(error.message).to.include('Failed to list keys from KV Store');
251
+ }
252
+ });
253
+ });
254
+ });
@@ -1030,6 +1030,161 @@ describe('TokowakaClient', () => {
1030
1030
  expect(result.failedSuggestions[0].suggestion.getId()).to.equal('sugg-2');
1031
1031
  });
1032
1032
 
1033
+ it('should update metaconfig patches field with deployed endpoints', async () => {
1034
+ mockSuggestions = [
1035
+ {
1036
+ getId: () => 'sugg-1',
1037
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1038
+ getData: () => ({
1039
+ url: 'https://example.com/page1',
1040
+ recommendedAction: 'Page 1 Heading',
1041
+ checkType: 'heading-empty',
1042
+ transformRules: {
1043
+ action: 'replace',
1044
+ selector: 'h1',
1045
+ },
1046
+ }),
1047
+ },
1048
+ {
1049
+ getId: () => 'sugg-2',
1050
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1051
+ getData: () => ({
1052
+ url: 'https://example.com/page2',
1053
+ recommendedAction: 'Page 2 Heading',
1054
+ checkType: 'heading-empty',
1055
+ transformRules: {
1056
+ action: 'replace',
1057
+ selector: 'h1',
1058
+ },
1059
+ }),
1060
+ },
1061
+ ];
1062
+
1063
+ const result = await client.deploySuggestions(
1064
+ mockSite,
1065
+ mockOpportunity,
1066
+ mockSuggestions,
1067
+ );
1068
+
1069
+ expect(result.succeededSuggestions).to.have.length(2);
1070
+ // Verify uploadMetaconfig was called to update the metaconfig
1071
+ expect(client.uploadMetaconfig).to.have.been.called;
1072
+ // Check that the last call included the patches field
1073
+ const { lastCall } = client.uploadMetaconfig;
1074
+ expect(lastCall.args[1]).to.have.property('patches');
1075
+ expect(lastCall.args[1].patches).to.deep.equal({
1076
+ '/page1': true,
1077
+ '/page2': true,
1078
+ });
1079
+ });
1080
+
1081
+ it('should add to existing patches in metaconfig when deploying new endpoints', async () => {
1082
+ // Set up metaconfig with existing patches
1083
+ // Reset the stub to provide consistent behavior
1084
+ client.fetchMetaconfig.reset();
1085
+ client.fetchMetaconfig.resolves({
1086
+ siteId: 'site-123',
1087
+ prerender: true,
1088
+ patches: {
1089
+ '/existing-page': true,
1090
+ },
1091
+ });
1092
+
1093
+ mockSuggestions = [
1094
+ {
1095
+ getId: () => 'sugg-1',
1096
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1097
+ getData: () => ({
1098
+ url: 'https://example.com/new-page',
1099
+ recommendedAction: 'New Heading',
1100
+ checkType: 'heading-empty',
1101
+ transformRules: {
1102
+ action: 'replace',
1103
+ selector: 'h1',
1104
+ },
1105
+ }),
1106
+ },
1107
+ ];
1108
+
1109
+ await client.deploySuggestions(
1110
+ mockSite,
1111
+ mockOpportunity,
1112
+ mockSuggestions,
1113
+ );
1114
+
1115
+ // Verify the updated metaconfig includes both existing and new patches
1116
+ const { lastCall } = client.uploadMetaconfig;
1117
+ expect(lastCall.args[1].patches).to.deep.equal({
1118
+ '/existing-page': true,
1119
+ '/new-page': true,
1120
+ });
1121
+ });
1122
+
1123
+ it('should throw error when metaconfig update fails', async () => {
1124
+ // Make uploadMetaconfig fail during the update
1125
+ client.uploadMetaconfig.rejects(new Error('S3 upload error'));
1126
+
1127
+ try {
1128
+ await client.deploySuggestions(
1129
+ mockSite,
1130
+ mockOpportunity,
1131
+ mockSuggestions,
1132
+ );
1133
+ expect.fail('Should have thrown error');
1134
+ } catch (error) {
1135
+ expect(error.message).to.include('Failed to update metaconfig with deployed paths');
1136
+ expect(error.status).to.equal(500);
1137
+ }
1138
+ });
1139
+
1140
+ it('should return early when no eligible suggestions to deploy', async () => {
1141
+ // All suggestions are ineligible
1142
+ mockSuggestions = [
1143
+ {
1144
+ getId: () => 'sugg-1',
1145
+ getData: () => ({
1146
+ url: 'https://example.com/page1',
1147
+ recommendedAction: 'New Heading',
1148
+ checkType: 'heading-missing', // Not eligible
1149
+ }),
1150
+ },
1151
+ ];
1152
+
1153
+ const result = await client.deploySuggestions(
1154
+ mockSite,
1155
+ mockOpportunity,
1156
+ mockSuggestions,
1157
+ );
1158
+
1159
+ // No suggestions deployed - returns early before metaconfig check
1160
+ expect(result.succeededSuggestions).to.have.length(0);
1161
+ expect(result.failedSuggestions).to.have.length(1);
1162
+ // fetchMetaconfig should not be called at all (returns before that point)
1163
+ expect(client.fetchMetaconfig).to.not.have.been.called;
1164
+ // uploadMetaconfig should not be called at all
1165
+ expect(client.uploadMetaconfig).to.not.have.been.called;
1166
+ });
1167
+
1168
+ it('should not update metaconfig when all URLs fail to generate configs', async () => {
1169
+ // Stub generateConfig to return null (no config generated)
1170
+ sinon.stub(client, 'generateConfig').returns(null);
1171
+
1172
+ const result = await client.deploySuggestions(
1173
+ mockSite,
1174
+ mockOpportunity,
1175
+ mockSuggestions,
1176
+ );
1177
+
1178
+ // Suggestions are marked as succeeded (eligible) but no configs uploaded
1179
+ expect(result.succeededSuggestions).to.have.length(2);
1180
+ expect(result.s3Paths).to.have.length(0); // No configs uploaded
1181
+ // fetchMetaconfig called once for initial check, but not for update
1182
+ // since no URLs were actually deployed (deployedUrls is empty)
1183
+ expect(client.fetchMetaconfig).to.have.been.calledOnce;
1184
+ // uploadMetaconfig should not be called at all
1185
+ expect(client.uploadMetaconfig).to.not.have.been.called;
1186
+ });
1187
+
1033
1188
  it('should skip URL when generateConfig returns no patches', async () => {
1034
1189
  // Stub mapper to return empty patches for the first call, normal for subsequent calls
1035
1190
  const mapper = client.mapperRegistry.getMapper('headings');