diff --git a/src/types/BreadcrumbList.js b/src/types/BreadcrumbList.js index 259029d..8cb2fc8 100644 --- a/src/types/BreadcrumbList.js +++ b/src/types/BreadcrumbList.js @@ -32,6 +32,7 @@ export default class BreadcrumbListValidator extends BaseValidator { issueMessage: 'At least two ListItems are required', severity: 'WARNING', path: this.path, + fieldNames: ['itemListElement'], }; } return null; @@ -137,6 +138,7 @@ export default class BreadcrumbListValidator extends BaseValidator { issueMessage: e, severity: 'WARNING', path: newPath, + fieldNames: [urlPath || 'item'], }); } } diff --git a/src/types/DefinedRegion.js b/src/types/DefinedRegion.js index 8fe64f9..abdf079 100644 --- a/src/types/DefinedRegion.js +++ b/src/types/DefinedRegion.js @@ -30,6 +30,7 @@ export default class DefinedRegionValidator extends BaseValidator { issueMessage: 'Only one of addressRegion or postalCode can be used', severity: 'WARNING', path: this.path, + fieldNames: ['addressRegion', 'postalCode'], }; } } diff --git a/src/types/MerchantReturnPolicy.js b/src/types/MerchantReturnPolicy.js index fcd4541..e8df9cf 100644 --- a/src/types/MerchantReturnPolicy.js +++ b/src/types/MerchantReturnPolicy.js @@ -84,6 +84,11 @@ export default class MerchantReturnPolicyValidator extends BaseValidator { 'Either applicableCountry and returnPolicyCategory or merchantReturnLink must be present', severity: 'ERROR', path: this.path, + fieldNames: [ + 'applicableCountry', + 'returnPolicyCategory', + 'merchantReturnLink', + ], }; } } diff --git a/src/types/Product.js b/src/types/Product.js index 5b6d990..e21a434 100644 --- a/src/types/Product.js +++ b/src/types/Product.js @@ -47,6 +47,7 @@ export default class ProductValidator extends BaseValidator { 'At least 2 notes, either positive or negative, are required', severity: 'WARNING', path: this.path, + fieldNames: ['review.positiveNotes', 'review.negativeNotes'], }); } @@ -63,6 +64,7 @@ export default class ProductValidator extends BaseValidator { 'One of the following attributes is required: "aggregateRating", "offers" or "review"', severity: 'ERROR', path: this.path, + fieldNames: ['aggregateRating', 'offers', 'review'], }); } diff --git a/src/types/ProductMerchant.js b/src/types/ProductMerchant.js index abfd632..412ff21 100644 --- a/src/types/ProductMerchant.js +++ b/src/types/ProductMerchant.js @@ -81,6 +81,7 @@ export default class ProductMerchantValidator extends BaseValidator { issueMessage: `Missing one of field ${gtinFields.map((a) => `"${a}"`).join(', ')} on either product or all offers`, severity: 'WARNING', path: this.path, + fieldNames: gtinFields, }; } } diff --git a/src/types/Rating.js b/src/types/Rating.js index b88bfc0..1e78892 100644 --- a/src/types/Rating.js +++ b/src/types/Rating.js @@ -46,6 +46,7 @@ export default class RatingValidator extends BaseValidator { issueMessage: `Rating is outside the specified or default range`, severity: 'ERROR', path: this.path, + fieldNames: ['ratingValue'], }; } } diff --git a/src/types/__tests__/BreadcrumbList.test.js b/src/types/__tests__/BreadcrumbList.test.js index 71282bc..aac1dbd 100644 --- a/src/types/__tests__/BreadcrumbList.test.js +++ b/src/types/__tests__/BreadcrumbList.test.js @@ -51,6 +51,7 @@ describe('BreadcrumbListValidator', () => { issueMessage: 'At least two ListItems are required', severity: 'WARNING', path: [{ type: 'BreadcrumbList', index: 0 }], + fieldNames: ['itemListElement'], }); }); @@ -77,6 +78,7 @@ describe('BreadcrumbListValidator', () => { type: 'ListItem', }, ], + fieldNames: ['item'], }); }); @@ -215,6 +217,7 @@ describe('BreadcrumbListValidator', () => { type: 'ListItem', }, ], + fieldNames: ['item'], }); }); }); diff --git a/src/types/__tests__/DefinedRegion.test.js b/src/types/__tests__/DefinedRegion.test.js index e19e8ad..7d002de 100644 --- a/src/types/__tests__/DefinedRegion.test.js +++ b/src/types/__tests__/DefinedRegion.test.js @@ -51,6 +51,7 @@ describe('DefinedRegionValidator', () => { index: 0, }, ], + fieldNames: ['addressRegion', 'postalCode'], }); }); }); diff --git a/src/types/__tests__/MerchantReturnPolicy.test.js b/src/types/__tests__/MerchantReturnPolicy.test.js index a44a9c8..2ce8a31 100644 --- a/src/types/__tests__/MerchantReturnPolicy.test.js +++ b/src/types/__tests__/MerchantReturnPolicy.test.js @@ -74,6 +74,11 @@ describe('MerchantReturnPolicyValidator', () => { index: 0, }, ], + fieldNames: [ + 'applicableCountry', + 'returnPolicyCategory', + 'merchantReturnLink', + ], }); }); }); diff --git a/src/types/__tests__/Product.test.js b/src/types/__tests__/Product.test.js index 62b86e7..4abeaf0 100644 --- a/src/types/__tests__/Product.test.js +++ b/src/types/__tests__/Product.test.js @@ -92,6 +92,7 @@ describe('ProductValidator', () => { 'One of the following attributes is required: "aggregateRating", "offers" or "review"', location: '35,350', severity: 'ERROR', + fieldNames: ['aggregateRating', 'offers', 'review'], }); }); @@ -107,6 +108,7 @@ describe('ProductValidator', () => { 'At least 2 notes, either positive or negative, are required', location: '35,1353', severity: 'WARNING', + fieldNames: ['review.positiveNotes', 'review.negativeNotes'], }); }); diff --git a/src/types/__tests__/ProductMerchant.test.js b/src/types/__tests__/ProductMerchant.test.js index 74e29ba..045121d 100644 --- a/src/types/__tests__/ProductMerchant.test.js +++ b/src/types/__tests__/ProductMerchant.test.js @@ -84,6 +84,7 @@ describe('ProductMerchantListValidator', () => { location: '35,1236', severity: 'WARNING', path: [{ type: 'Product', index: 0 }], + fieldNames: ['gtin', 'gtin8', 'gtin12', 'gtin13', 'gtin14', 'isbn'], }); }); }); diff --git a/src/types/__tests__/Rating.test.js b/src/types/__tests__/Rating.test.js index 4089a1f..b8ce05d 100644 --- a/src/types/__tests__/Rating.test.js +++ b/src/types/__tests__/Rating.test.js @@ -28,5 +28,28 @@ describe('RatingValidator', () => { const issues = await validator.validate(data); expect(issues).to.deep.equal([]); }); + + it('should return error with fieldNames when rating is outside range', async () => { + const data = { + jsonld: { + Rating: [ + { + '@type': 'Rating', + '@location': '1,100', + ratingValue: 10, + bestRating: 5, + worstRating: 0, + }, + ], + }, + }; + const issues = await validator.validate(data); + expect(issues).to.have.lengthOf(1); + expect(issues[0]).to.deep.include({ + issueMessage: 'Rating is outside the specified or default range', + severity: 'ERROR', + fieldNames: ['ratingValue'], + }); + }); }); }); diff --git a/src/types/__tests__/base.test.js b/src/types/__tests__/base.test.js new file mode 100644 index 0000000..74f1f9f --- /dev/null +++ b/src/types/__tests__/base.test.js @@ -0,0 +1,139 @@ +/** + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { expect } from "chai"; +import BaseValidator from "../base.js"; + +describe("BaseValidator", () => { + describe("fieldNames property", () => { + let validator; + const testPath = [{ type: "TestType" }]; + + beforeEach(() => { + validator = new BaseValidator({ + dataFormat: "jsonld", + path: testPath, + }); + }); + + describe("required()", () => { + it("should include fieldNames when required attribute is missing", () => { + const condition = validator.required("price"); + const result = condition({}); + + expect(result).to.deep.include({ + severity: "ERROR", + issueMessage: 'Required attribute "price" is missing', + }); + expect(result.fieldNames).to.deep.equal(["price"]); + expect(result.path).to.deep.equal(testPath); + }); + + it("should include fieldNames when required attribute has invalid type", () => { + const condition = validator.required("price", "number"); + const result = condition({ price: "not-a-number" }); + + expect(result).to.deep.include({ + severity: "ERROR", + issueMessage: 'Invalid type for attribute "price"', + }); + expect(result.fieldNames).to.deep.equal(["price"]); + }); + + it("should return null when required attribute is valid", () => { + const condition = validator.required("name"); + const result = condition({ name: "Test Product" }); + + expect(result).to.be.null; + }); + }); + + describe("recommended()", () => { + it("should include fieldNames when recommended attribute is missing", () => { + const condition = validator.recommended("description"); + const result = condition({}); + + expect(result).to.deep.include({ + severity: "WARNING", + issueMessage: 'Missing field "description" (optional)', + }); + expect(result.fieldNames).to.deep.equal(["description"]); + expect(result.path).to.deep.equal(testPath); + }); + + it("should include fieldNames when recommended attribute has invalid type", () => { + const condition = validator.recommended("image", "url"); + const result = condition({ image: "data:invalid" }); + + expect(result).to.deep.include({ + severity: "WARNING", + issueMessage: 'Invalid type for attribute "image"', + }); + expect(result.fieldNames).to.deep.equal(["image"]); + }); + + it("should return null when recommended attribute is valid", () => { + const condition = validator.recommended("description"); + const result = condition({ description: "A great product" }); + + expect(result).to.be.null; + }); + }); + + describe("or()", () => { + it("should include fieldNames when or conditions fail", () => { + const condition = validator.or( + validator.required("price"), + validator.required("priceSpecification.price") + ); + const result = condition({}); + + expect(result.severity).to.equal("ERROR"); + expect(result.fieldNames).to.deep.equal([ + "price", + "priceSpecification.price", + ]); + expect(result.path).to.deep.equal(testPath); + }); + + it("should return null when at least one or condition passes", () => { + const condition = validator.or( + validator.required("price"), + validator.required("priceSpecification.price") + ); + const result = condition({ price: "19.99" }); + + expect(result).to.be.null; + }); + + it("should handle single failing condition in or()", () => { + const condition = validator.or( + validator.required("availability") + ); + const result = condition({}); + + expect(result.fieldNames).to.deep.equal(["availability"]); + }); + }); + + describe("nested path fieldNames", () => { + it("should include fieldNames for nested attributes", () => { + const condition = validator.required("offers.price"); + const result = condition({ offers: {} }); + + expect(result).to.deep.include({ + severity: "ERROR", + }); + expect(result.fieldNames).to.deep.equal(["offers.price"]); + }); + }); + }); +}); diff --git a/src/types/__tests__/schemaOrg.test.js b/src/types/__tests__/schemaOrg.test.js index 211a9be..2f52f71 100644 --- a/src/types/__tests__/schemaOrg.test.js +++ b/src/types/__tests__/schemaOrg.test.js @@ -126,6 +126,7 @@ describe('Schema.org Validator', () => { severity: 'WARNING', path: [{ type: 'Product', index: 0 }], errorType: 'schemaOrg', + fieldNames: ['my-custom-attribute'], }); }); @@ -154,6 +155,7 @@ describe('Schema.org Validator', () => { }, ], errorType: 'schemaOrg', + fieldNames: ['my-custom-attribute'], }); }); }); diff --git a/src/types/base.js b/src/types/base.js index 4804a25..847d413 100644 --- a/src/types/base.js +++ b/src/types/base.js @@ -58,6 +58,7 @@ export default class BaseValidator { issueMessage: `Required attribute "${name}" is missing`, severity: 'ERROR', path: this.path, + fieldNames: [name], }; } if (type && !this.checkType(value, type, ...opts)) { @@ -65,6 +66,7 @@ export default class BaseValidator { issueMessage: `Invalid type for attribute "${name}"`, severity: 'ERROR', path: this.path, + fieldNames: [name], }; } return null; @@ -89,6 +91,12 @@ export default class BaseValidator { return max; }, 'WARNING'); + // Collect all field names from the conditions + const fieldNames = issues + .flat() + .filter((i) => i && i.fieldNames) + .flatMap((i) => i.fieldNames); + return { issueMessage: `One of the following conditions needs to be met: ${issues .flat() @@ -96,6 +104,7 @@ export default class BaseValidator { .join(' or ')}`, severity, path: this.path, + fieldNames: fieldNames.length > 0 ? fieldNames : [], }; }; } @@ -108,6 +117,7 @@ export default class BaseValidator { issueMessage: `Missing field "${name}" (optional)`, severity: 'WARNING', path: this.path, + fieldNames: [name], }; } if (type && !this.checkType(value, type, ...opts)) { @@ -115,6 +125,7 @@ export default class BaseValidator { issueMessage: `Invalid type for attribute "${name}"`, severity: 'WARNING', path: this.path, + fieldNames: [name], }; } return null; diff --git a/src/types/schemaOrg.js b/src/types/schemaOrg.js index 88579b7..e9c160b 100644 --- a/src/types/schemaOrg.js +++ b/src/types/schemaOrg.js @@ -215,6 +215,7 @@ export default class SchemaOrgValidator { severity: 'WARNING', path: this.path, errorType: 'schemaOrg', + fieldNames: [propertyId], }); } }),