Skip to content

Commit 739c10f

Browse files
committed
fix: validation in put product
1 parent 18d0009 commit 739c10f

File tree

3 files changed

+97
-62
lines changed

3 files changed

+97
-62
lines changed

src/catalog/update.js

+62-29
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
/* eslint-disable no-await-in-loop */
14-
13+
import { assertValidProduct } from '../utils/product.js';
1514
import { callAdmin } from '../utils/admin.js';
1615
import { errorResponse, errorWithResponse } from '../utils/http.js';
1716
import { saveProducts } from '../utils/r2.js';
@@ -41,44 +40,78 @@ export async function putProduct(ctx, config, product) {
4140
*/
4241
export async function handleProductSaveRequest(ctx, config, request) {
4342
try {
44-
let requestBody;
45-
46-
try {
47-
requestBody = await request.json();
48-
} catch (jsonError) {
49-
ctx.log.error('Invalid JSON in request body:', jsonError);
50-
return errorResponse(400, 'invalid JSON');
51-
}
43+
let product;
5244

5345
if (config.sku === '*') {
5446
return errorResponse(501, 'not implemented');
47+
} else {
48+
try {
49+
product = await request.json();
50+
} catch (jsonError) {
51+
ctx.log.error('Invalid JSON in request body:', jsonError);
52+
return errorResponse(400, 'invalid JSON');
53+
}
54+
55+
try {
56+
assertValidProduct(product);
57+
} catch (e) {
58+
return errorResponse(400, e.message);
59+
}
5560
}
5661

57-
const product = await putProduct(ctx, config, requestBody);
58-
const products = [product];
62+
const { base: _ = undefined, ...otherPatterns } = config.confEnvMap[config.env] ?? {};
63+
const matchedPathPatterns = Object.entries(otherPatterns)
64+
.reduce((acc, [pattern, matchConf]) => {
65+
// find only configs that match the provided store & view codes
66+
if (config.storeCode === matchConf.storeCode
67+
&& config.storeViewCode === matchConf.storeViewCode) {
68+
acc.push(pattern);
69+
}
70+
return acc;
71+
}, []);
5972

60-
const { base: _ = undefined, ...otherPatterns } = (config.env in config.confEnvMap)
61-
? config.confEnvMap[config.env]
62-
: {};
63-
const matchedPathPatterns = Object.keys(otherPatterns);
73+
if (!matchedPathPatterns.length) {
74+
return errorResponse(404, 'no path patterns found');
75+
}
6476

65-
if (matchedPathPatterns.length !== 0) {
66-
for (const purgeProduct of products) {
67-
for (const pattern of matchedPathPatterns) {
68-
let path = pattern.replace('{{sku}}', purgeProduct.sku);
77+
await putProduct(ctx, config, product);
6978

70-
if (path.includes('{{urlkey}}') && purgeProduct.urlKey) {
71-
path = path.replace('{{urlkey}}', purgeProduct.urlKey);
72-
}
79+
const purgePaths = matchedPathPatterns.map(
80+
(pattern) => pattern
81+
.replace('{{sku}}', product.sku)
82+
.replace('{{urlkey}}', product.urlKey),
83+
);
7384

74-
for (const op of ['preview', 'live']) {
75-
const response = await callAdmin(config, op, path, { method: 'post' });
76-
if (!response.ok) {
77-
return errorResponse(400, `failed to ${op} product`);
78-
}
85+
const errors = [];
86+
await Promise.all(
87+
purgePaths.map(async (path) => {
88+
for (const op of ['preview', 'live']) {
89+
// eslint-disable-next-line no-await-in-loop
90+
const response = await callAdmin(config, op, path, { method: 'POST' });
91+
if (!response.ok) {
92+
errors.push({
93+
op,
94+
path,
95+
status: response.status,
96+
message: response.headers.get('x-error') ?? response.statusText,
97+
});
98+
break; // don't hit live if preview fails
7999
}
80100
}
81-
}
101+
}),
102+
);
103+
104+
if (errors.length) {
105+
// use highest error code as status,
106+
// so 5xx will be forwarded but all 404s will be treated as a 404
107+
const status = errors.reduce((errCode, { status: code }) => Math.max(errCode, code), 0);
108+
return new Response(JSON.stringify({ errors }), {
109+
status,
110+
headers: {
111+
'Content-Type': 'application/json',
112+
'x-error': 'purge errors',
113+
},
114+
});
82115
}
83116

84117
return new Response(undefined, { status: 201 });

src/utils/product.js

+10
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,13 @@ export function findProductImage(product, variants = []) {
5858
}
5959
return variants.find((v) => v.images?.length)?.images?.[0];
6060
}
61+
62+
/**
63+
* @param {any} product
64+
* @returns {asserts product is Product}
65+
*/
66+
export function assertValidProduct(product) {
67+
if (typeof product !== 'object' || !product.sku) {
68+
throw new Error('Invalid product');
69+
}
70+
}

test/catalog/update.test.js

+25-33
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,28 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
// @ts-nocheck
14+
1315
import { strict as assert } from 'assert';
1416
import sinon from 'sinon';
1517
import esmock from 'esmock';
1618

1719
describe('Product Save Tests', () => {
1820
let putProduct;
21+
/** @type {import('../../src/catalog/update.js').handleProductSaveRequest} */
1922
let handleProductSaveRequest;
23+
/** @type {sinon.SinonStub} */
2024
let saveProductsStub;
25+
/** @type {sinon.SinonStub} */
2126
let callAdminStub;
22-
let errorResponseStub;
23-
let errorWithResponseStub;
2427

2528
beforeEach(async () => {
2629
saveProductsStub = sinon.stub();
2730
callAdminStub = sinon.stub();
28-
errorResponseStub = sinon.stub();
29-
errorWithResponseStub = sinon.stub();
3031

3132
const mocks = {
3233
'../../src/utils/r2.js': { saveProducts: saveProductsStub },
3334
'../../src/utils/admin.js': { callAdmin: callAdminStub },
34-
'../../src/utils/http.js': { errorResponse: errorResponseStub, errorWithResponse: errorWithResponseStub },
3535
};
3636

3737
({ putProduct, handleProductSaveRequest } = await esmock('../../src/catalog/update.js', mocks));
@@ -43,16 +43,10 @@ describe('Product Save Tests', () => {
4343

4444
describe('putProduct', () => {
4545
it('should throw error when product SKU is missing', async () => {
46-
const error = new Error('invalid request body: missing sku');
47-
errorWithResponseStub.throws(error);
48-
4946
const product = {};
5047
await assert.rejects(async () => {
5148
await putProduct({}, {}, product);
5249
}, /invalid request body: missing sku/);
53-
54-
assert(errorWithResponseStub.calledOnce);
55-
assert(errorWithResponseStub.calledWith(400, 'invalid request body: missing sku'));
5650
});
5751

5852
it('should save the product when SKU is present', async () => {
@@ -72,13 +66,9 @@ describe('Product Save Tests', () => {
7266
const ctx = { log: { error: sinon.stub() } };
7367
const request = { json: sinon.stub().resolves({ sku: '1234' }) };
7468

75-
const mockResponse = new Response(null, { status: 501, headers: { 'x-error': 'not implemented' } });
76-
errorResponseStub.returns(mockResponse);
77-
7869
const response = await handleProductSaveRequest(ctx, config, request);
7970

8071
assert.equal(response.status, 501);
81-
assert(errorResponseStub.calledWith(501, 'not implemented'));
8272
assert.equal(response.headers.get('x-error'), 'not implemented');
8373
});
8474

@@ -106,7 +96,7 @@ describe('Product Save Tests', () => {
10696
assert.equal(callAdminStub.callCount, 4);
10797
});
10898

109-
it('should skip calling callAdmin when confMap has no matching env', async () => {
99+
it('should return 404 when no matching path patterns found', async () => {
110100
const config = {
111101
sku: '1234',
112102
confEnvMap: {
@@ -124,10 +114,11 @@ describe('Product Save Tests', () => {
124114
const response = await handleProductSaveRequest(ctx, config, request);
125115

126116
assert(callAdminStub.notCalled);
127-
assert.equal(response.status, 201);
117+
assert.equal(response.status, 404);
118+
assert.equal(response.headers.get('x-error'), 'no path patterns found');
128119
});
129120

130-
it('should return error when callAdmin fails', async () => {
121+
it('should return error when purging fails', async () => {
131122
const config = {
132123
sku: '1234',
133124
confEnvMap: {
@@ -137,37 +128,38 @@ describe('Product Save Tests', () => {
137128
},
138129
env: 'test',
139130
};
140-
const ctx = { log: { error: sinon.stub() } };
131+
const ctx = { log: console };
141132
const request = { json: sinon.stub().resolves({ sku: '1234', urlKey: 'product-url-key' }) };
142133

143134
saveProductsStub.resolves();
144-
callAdminStub.onFirstCall().resolves({ ok: false });
145-
146-
const mockResponse = new Response(null, { status: 400, headers: { 'x-error': 'failed to preview product' } });
147-
errorResponseStub.returns(mockResponse);
135+
callAdminStub.onFirstCall().resolves(new Response('', { status: 500, headers: { 'x-error': 'bad thing happen' } }));
148136

149137
const response = await handleProductSaveRequest(ctx, config, request);
150138

151-
assert.equal(response.status, 400);
152-
assert(callAdminStub.calledOnce);
153-
assert(errorResponseStub.calledWith(400, 'failed to preview product'));
154-
assert.equal(response.headers.get('x-error'), 'failed to preview product');
139+
assert.equal(response.status, 500);
140+
assert.ok(callAdminStub.calledOnce);
141+
assert.equal(response.headers.get('x-error'), 'purge errors');
142+
const respBody = await response.json();
143+
assert.deepStrictEqual(respBody, {
144+
errors: [
145+
{
146+
op: 'preview',
147+
path: '/path/to/1234',
148+
status: 500,
149+
message: 'bad thing happen',
150+
},
151+
],
152+
});
155153
});
156154

157155
it('should return 400 if request.json throws a JSON parsing error', async () => {
158156
const config = { sku: '1234', confEnvMap: {} };
159157
const ctx = { log: { error: sinon.stub() } };
160158
const request = { json: sinon.stub().rejects(new Error('Unexpected token < in JSON at position 0')) };
161159

162-
const mockResponse = new Response(null, { status: 400, headers: { 'x-error': 'invalid JSON' } });
163-
errorResponseStub.returns(mockResponse);
164-
165160
const response = await handleProductSaveRequest(ctx, config, request);
166161

167162
assert.equal(response.status, 400);
168-
169-
assert(errorResponseStub.calledWith(400, 'invalid JSON'));
170-
171163
assert.equal(response.headers.get('x-error'), 'invalid JSON');
172164

173165
assert(ctx.log.error.calledOnce);

0 commit comments

Comments
 (0)