From d5d8c49e04f653859fcd311dea4e66ed93fbd7da Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Mon, 7 Sep 2026 07:43:50 +0000 Subject: [PATCH] Gate B #26: shared validation module (V-1, V-2, V-3) - V-1: SKU charset [A-Z0-9-]{3,24}, accepted/rejected with specific error codes - V-2: title/short_title/description UTF-8 within WooCommerce maxLength, enforced at field-exit and submit (200/200/2500 chars, Unicode code-point counting) - V-3: validation module version hash, client caches, submit carries hash, mismatch returns 412 with fresh module (not 409) 25 unit tests green, zero browser/DOM/network dependencies. Re-exports through pipeline/index.ts for shared use. --- frontend/src/__tests__/fixtures/validation.ts | 113 +++++++ frontend/src/__tests__/validation.test.ts | 284 ++++++++++++++++++ frontend/src/lib/pipeline/index.ts | 1 + frontend/src/lib/validation/constants.ts | 16 + frontend/src/lib/validation/content.ts | 115 +++++++ frontend/src/lib/validation/hash.ts | 97 ++++++ frontend/src/lib/validation/index.ts | 4 + frontend/src/lib/validation/sku.ts | 37 +++ 8 files changed, 667 insertions(+) create mode 100644 frontend/src/__tests__/fixtures/validation.ts create mode 100644 frontend/src/__tests__/validation.test.ts create mode 100644 frontend/src/lib/validation/constants.ts create mode 100644 frontend/src/lib/validation/content.ts create mode 100644 frontend/src/lib/validation/hash.ts create mode 100644 frontend/src/lib/validation/index.ts create mode 100644 frontend/src/lib/validation/sku.ts diff --git a/frontend/src/__tests__/fixtures/validation.ts b/frontend/src/__tests__/fixtures/validation.ts new file mode 100644 index 0000000..b8fc018 --- /dev/null +++ b/frontend/src/__tests__/fixtures/validation.ts @@ -0,0 +1,113 @@ +/** Boundary strings for shared validation unit tests (V-1, V-2, V-3). */ + +// --------------------------------------------------------------------------- +// V-1: SKU [A-Z0-9-]{3,24} +// --------------------------------------------------------------------------- + +/** Valid SKUs spanning the charset and length boundaries. */ +export const validSkuStrings = [ + // Minimum length (3 chars) — all letter, all digit, mixed, hyphen edges. + "ABC", + "012", + "A0A", + "A-B", + "0-1", + // Mid-range typical. + "BW-0042", + "SLAB-UAT-001", + "A9Z", + // Maximum length (24 chars). + "AAAAAAAAAAAAAAAAAAAAAAAA", + "A1B2C3D4E5F6G7H8I9J0K-12", + // Edge cases within charset. + "A-A-A-A-A-A", + "1-2-3-4-5-6", + "ABCDEF", +]; + +/** Invalid SKUs: wrong charset, too short, too long. */ +export const invalidSkuStrings = [ + // Too short (1–2 chars). + "A", + "AB", + // Too long (25+ chars). + "AAAAAAAAAAAAAAAAAAAAAAAAA", + // Lowercase letters (outside charset). + "abc", + "bw-0042", + "aB3", + // Special characters outside charset. + "ABC!", + "A B", + "A_B", + "ABC.D", + "ABC#1", + "ABC/1", + "ABC\\1", + // Empty. + "", +]; + +// --------------------------------------------------------------------------- +// V-2: Title / short_title / description maxLength (UTF-8) +// --------------------------------------------------------------------------- + +import { + MAX_TITLE_LENGTH, + MAX_SHORT_TITLE_LENGTH, + MAX_DESCRIPTION_LENGTH, +} from "../../lib/validation/constants.js"; + +const TITLE_LEN = MAX_TITLE_LENGTH; +const SHORT_TITLE_LEN = MAX_SHORT_TITLE_LENGTH; +const DESC_LEN = MAX_DESCRIPTION_LENGTH; + +function repeat(char: string, count: number): string { + return char.repeat(count); +} + +function buildTitle(): { title: string; length: number } { + return { title: repeat("A", TITLE_LEN), length: TITLE_LEN }; +} + +function buildShortTitle(): { shortTitle: string; length: number } { + return { shortTitle: repeat("B", SHORT_TITLE_LEN), length: SHORT_TITLE_LEN }; +} + +function buildDescription(): { description: string; length: number } { + return { description: repeat("C", DESC_LEN), length: DESC_LEN }; +} + +export function validTitleFixture(): { title: string; length: number } { + return buildTitle(); +} + +export function invalidTitleFixture(): { title: string; length: number } { + return { title: repeat("A", TITLE_LEN + 1), length: TITLE_LEN + 1 }; +} + +export function validShortTitleFixture(): { shortTitle: string; length: number } { + return buildShortTitle(); +} + +export function invalidShortTitleFixture(): { shortTitle: string; length: number } { + return { shortTitle: repeat("B", SHORT_TITLE_LEN + 1), length: SHORT_TITLE_LEN + 1 }; +} + +export function validDescriptionFixture(): { description: string; length: number } { + return buildDescription(); +} + +export function invalidDescriptionFixture(): { description: string; length: number } { + return { description: repeat("C", DESC_LEN + 1), length: DESC_LEN + 1 }; +} + +// --------------------------------------------------------------------------- +// V-3: Validation module version hash +// --------------------------------------------------------------------------- + +/** Expected hash for the current module (constants v1). */ +export const expectedHash = "v1"; + +/** A stale hash to test mismatch detection. */ +export const staleHash = "v0"; diff --git a/frontend/src/__tests__/validation.test.ts b/frontend/src/__tests__/validation.test.ts new file mode 100644 index 0000000..9bc3caf --- /dev/null +++ b/frontend/src/__tests__/validation.test.ts @@ -0,0 +1,284 @@ +/** + * Shared validation module — V-1, V-2, V-3. + * + * V-1 SKU charset [A-Z0-9-]{3,24}. + * V-2 Title / short_title / description UTF-8 within WooCommerce maxLength. + * V-3 Validation module version hash — client caches module, submit carries hash, + * mismatch returns 412 with fresh module (not 409). + * + * Pure-unit tests; zero browser, DOM, or network dependencies (FUND-1). + */ + +import { describe, expect, it } from "vitest"; + +import { + VALIDATION_MODULE_VERSION, + MAX_TITLE_LENGTH, + MAX_SHORT_TITLE_LENGTH, + MAX_DESCRIPTION_LENGTH, +} from "../lib/validation/constants.js"; +import { validateSku, isSkuValid } from "../lib/validation/sku.js"; +import { + validateContentField, + isContentValid, + validateTitle, + validateShortTitle, + validateDescription, +} from "../lib/validation/content.js"; +import { + computeModuleHash, + validateModuleHash, +} from "../lib/validation/hash.js"; +import { + validSkuStrings, + invalidSkuStrings, + validTitleFixture, + invalidTitleFixture, + validShortTitleFixture, + invalidShortTitleFixture, + validDescriptionFixture, + invalidDescriptionFixture, + expectedHash, + staleHash, +} from "./fixtures/validation.js"; + +// --------------------------------------------------------------------------- +// V-1: SKU validation — charset [A-Z0-9-]{3,24} +// --------------------------------------------------------------------------- + +describe("V-1 SKU validation", () => { + it("accepts all valid SKU boundary strings", () => { + for (const sku of validSkuStrings) { + const result = validateSku(sku); + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + console.log(`V-1 valid: "${sku}" → length=${sku.length}, valid=${result.valid}`); + } + }); + + it("rejects all invalid SKU boundary strings", () => { + for (const sku of invalidSkuStrings) { + const result = validateSku(sku); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + console.log(`V-1 invalid: "${sku}" → valid=${result.valid}, errors=${result.errors.join(", ")}`); + } + }); + + it("isSkuValid returns true for valid SKUs and false for invalid SKUs", () => { + for (const sku of validSkuStrings) { + expect(isSkuValid(sku)).toBe(true); + } + for (const sku of invalidSkuStrings) { + expect(isSkuValid(sku)).toBe(false); + } + }); + + it("rejects SKU with lowercase letters", () => { + expect(isSkuValid("abc")).toBe(false); + expect(isSkuValid("bw-0042")).toBe(false); + expect(isSkuValid("aB3")).toBe(false); + }); + + it("rejects SKU that is too short or too long", () => { + expect(isSkuValid("")).toBe(false); + expect(isSkuValid("A")).toBe(false); + expect(isSkuValid("AB")).toBe(false); + // 25 chars + expect(isSkuValid("AAAAAAAAAAAAAAAAAAAAAAAAA")).toBe(false); + }); + + it("accepts SKU at exact boundaries (3 and 24 characters)", () => { + expect(isSkuValid("ABC")).toBe(true); + expect(isSkuValid("AAAAAAAAAAAAAAAAAAAAAAAA")).toBe(true); + }); + + it("accepts hyphens anywhere in the SKU including edges", () => { + expect(isSkuValid("A-B")).toBe(true); + expect(isSkuValid("-A-")).toBe(true); + expect(isSkuValid("A-A-A-A-A-A")).toBe(true); + }); + + it("rejects SKU with spaces, underscores, periods, slashes, backslashes, or other special chars", () => { + expect(isSkuValid("A B")).toBe(false); + expect(isSkuValid("A_B")).toBe(false); + expect(isSkuValid("ABC.D")).toBe(false); + expect(isSkuValid("ABC#1")).toBe(false); + expect(isSkuValid("ABC/1")).toBe(false); + expect(isSkuValid("ABC\\1")).toBe(false); + expect(isSkuValid("ABC!")).toBe(false); + }); + + it("returns specific error codes for different failure reasons", () => { + const result = validateSku(""); + expect(result.errors).toContain("too_short"); + + const result2 = validateSku("AAAAAAAAAAAAAAAAAAAAAAAAA"); + expect(result2.errors).toContain("too_long"); + + const result3 = validateSku("abc"); + expect(result3.errors).toContain("invalid_charset"); + }); +}); + +// --------------------------------------------------------------------------- +// V-2: Title / short_title / description maxLength (UTF-8) +// --------------------------------------------------------------------------- + +describe("V-2 content field maxLength validation", () => { + it("accepts title at exactly maxLength", () => { + const { title, length } = validTitleFixture(); + expect(length).toBe(MAX_TITLE_LENGTH); + const result = validateTitle(title); + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + console.log(`V-2 title: length=${length}, valid=${result.valid}`); + }); + + it("rejects title that exceeds maxLength by 1 character", () => { + const { title, length } = invalidTitleFixture(); + expect(length).toBe(MAX_TITLE_LENGTH + 1); + const result = validateTitle(title); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + console.log(`V-2 title invalid: length=${length}, valid=${result.valid}, errors=${result.errors.join(", ")}`); + }); + + it("accepts short_title at exactly maxLength", () => { + const { shortTitle, length } = validShortTitleFixture(); + expect(length).toBe(MAX_SHORT_TITLE_LENGTH); + const result = validateShortTitle(shortTitle); + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + console.log(`V-2 short_title: length=${length}, valid=${result.valid}`); + }); + + it("rejects short_title that exceeds maxLength by 1 character", () => { + const { shortTitle, length } = invalidShortTitleFixture(); + expect(length).toBe(MAX_SHORT_TITLE_LENGTH + 1); + const result = validateShortTitle(shortTitle); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + console.log(`V-2 short_title invalid: length=${length}, valid=${result.valid}, errors=${result.errors.join(", ")}`); + }); + + it("accepts description at exactly maxLength", () => { + const { description, length } = validDescriptionFixture(); + expect(length).toBe(MAX_DESCRIPTION_LENGTH); + const result = validateDescription(description); + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + console.log(`V-2 description: length=${length}, valid=${result.valid}`); + }); + + it("rejects description that exceeds maxLength by 1 character", () => { + const { description, length } = invalidDescriptionFixture(); + expect(length).toBe(MAX_DESCRIPTION_LENGTH + 1); + const result = validateDescription(description); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + console.log(`V-2 description invalid: length=${length}, valid=${result.valid}, errors=${result.errors.join(", ")}`); + }); + + it("accepts empty strings for optional content fields", () => { + expect(isContentValid({ title: "", shortTitle: "", description: "" })).toBe(true); + expect(isContentValid({ title: null, shortTitle: null, description: null })).toBe(true); + }); + + it("accepts multi-byte UTF-8 characters and counts Unicode code points, not UTF-16 units", () => { + const emoji = "🪵"; + // Each emoji is 1 Unicode char but 2 UTF-16 code units; spread counts code points + const longUtf8 = [...emoji.repeat(MAX_TITLE_LENGTH)]; + const longUtf8Str = longUtf8.join(""); + const result = validateTitle(longUtf8Str); + expect(longUtf8.length).toBe(MAX_TITLE_LENGTH); + expect(result.valid).toBe(true); + }); + + it("rejects content exceeding maxLength even with multi-byte UTF-8", () => { + const longUtf8 = "🪵".repeat(MAX_TITLE_LENGTH + 1); + const result = validateTitle(longUtf8); + expect(result.valid).toBe(false); + }); + + it("isContentValid rejects any field that exceeds its maxLength", () => { + const valid = isContentValid({ + title: validTitleFixture().title, + shortTitle: validShortTitleFixture().shortTitle, + description: validDescriptionFixture().description, + }); + expect(valid).toBe(true); + + const invalidTitle = isContentValid({ + title: invalidTitleFixture().title, + shortTitle: validShortTitleFixture().shortTitle, + description: validDescriptionFixture().description, + }); + expect(invalidTitle).toBe(false); + + const invalidShort = isContentValid({ + title: validTitleFixture().title, + shortTitle: invalidShortTitleFixture().shortTitle, + description: validDescriptionFixture().description, + }); + expect(invalidShort).toBe(false); + + const invalidDesc = isContentValid({ + title: validTitleFixture().title, + shortTitle: validShortTitleFixture().shortTitle, + description: invalidDescriptionFixture().description, + }); + expect(invalidDesc).toBe(false); + }); + + it("validateContentField returns field-specific error codes", () => { + const { title } = invalidTitleFixture(); + const result = validateContentField("title", title); + expect(result.errors).toContain("exceeds_max_length"); + }); +}); + +// --------------------------------------------------------------------------- +// V-3: Validation module version hash +// --------------------------------------------------------------------------- + +describe("V-3 validation module version hash", () => { + it("computes a stable hash from current validation rules", () => { + const hash1 = computeModuleHash(); + const hash2 = computeModuleHash(); + expect(hash1).toBe(hash2); + console.log(`V-3 module hash: "${hash1}"`); + expect(hash1).toBe(expectedHash); + }); + + it("validateModuleHash returns true when client hash matches current module", () => { + const currentHash = computeModuleHash(); + const result = validateModuleHash(currentHash); + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + expect(result.freshModule).toBeUndefined(); + console.log(`V-3 match: hash="${currentHash}", valid=${result.valid}`); + }); + + it("validateModuleHash returns 412 with fresh module when client hash mismatches", () => { { + const result = validateModuleHash(staleHash); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors).toContain("validation_module_stale"); + expect(result.freshModule).toBeDefined(); + expect(result.freshModule!.hash).toBe(computeModuleHash()); + expect(result.freshModule!.hash).not.toBe(staleHash); + console.log(`V-3 mismatch: clientHash="${staleHash}", serverHash="${computeModuleHash()}", errors=${result.errors.join(", ")}`); + } + }); + + it("returns the correct validation module version", () => { + expect(VALIDATION_MODULE_VERSION).toBe("v1"); + }); + + it("fresh module includes current version hash and rules", () => { + const result = validateModuleHash(staleHash); + expect(result.freshModule!.version).toBe(VALIDATION_MODULE_VERSION); + expect(result.freshModule!.rules.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/lib/pipeline/index.ts b/frontend/src/lib/pipeline/index.ts index 33a2fbb..1b23fc5 100644 --- a/frontend/src/lib/pipeline/index.ts +++ b/frontend/src/lib/pipeline/index.ts @@ -6,3 +6,4 @@ export * from "./axis.js"; export * from "./crop.js"; export * from "./pricing.js"; export * from "./thicknessBand.js"; +export * from "../validation/index.js"; diff --git a/frontend/src/lib/validation/constants.ts b/frontend/src/lib/validation/constants.ts new file mode 100644 index 0000000..6820923 --- /dev/null +++ b/frontend/src/lib/validation/constants.ts @@ -0,0 +1,16 @@ +/** WooCommerce field max-length constants. */ + +/** WordPress post_title practical limit; WooCommerce product name field. */ +export const MAX_TITLE_LENGTH = 200; + +/** WordPress post_excerpt practical limit; WooCommerce short_description field. */ +export const MAX_SHORT_TITLE_LENGTH = 200; + +/** Reasonable product description limit. WooCommerce description is TEXT (no hard DB limit). */ +export const MAX_DESCRIPTION_LENGTH = 2500; + +/** SKU charset: uppercase letters, digits, hyphen. 3 to 24 characters. */ +export const SKU_REGEX = /^[A-Z0-9-]{3,24}$/; + +/** Validation module version (bump on rule change). */ +export const VALIDATION_MODULE_VERSION = "v1"; diff --git a/frontend/src/lib/validation/content.ts b/frontend/src/lib/validation/content.ts new file mode 100644 index 0000000..2236dd7 --- /dev/null +++ b/frontend/src/lib/validation/content.ts @@ -0,0 +1,115 @@ +import { + MAX_TITLE_LENGTH, + MAX_SHORT_TITLE_LENGTH, + MAX_DESCRIPTION_LENGTH, +} from "./constants.js"; + +/** Content field validation result. */ +export interface ContentValidationResult { + valid: boolean; + errors: string[]; +} + +/** Content fields for a listing. */ +export interface ContentFields { + title: string | null; + shortTitle: string | null; + description: string | null; +} + +/** Title validation result. */ +export interface TitleValidationResult { + valid: boolean; + errors: string[]; +} + +/** + * Count Unicode characters (code points), matching WooCommerce PHP mb_strlen(). + * JavaScript .length counts UTF-16 code units, which double-counts emoji. + */ +function charLen(s: string): number { + return [...s].length; +} + +/** + * Validate a title field against WooCommerce maxLength. + */ +export function validateTitle(title: string | null): TitleValidationResult { + const errors: string[] = []; + + if (title !== null && charLen(title) > MAX_TITLE_LENGTH) { + errors.push("exceeds_max_length"); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Validate a short title field against WooCommerce maxLength. + */ +export function validateShortTitle(shortTitle: string | null): ContentValidationResult { + const errors: string[] = []; + + if (shortTitle !== null && charLen(shortTitle) > MAX_SHORT_TITLE_LENGTH) { + errors.push("exceeds_max_length"); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Validate a description field against WooCommerce maxLength. + */ +export function validateDescription(description: string | null): ContentValidationResult { + const errors: string[] = []; + + if (description !== null && charLen(description) > MAX_DESCRIPTION_LENGTH) { + errors.push("exceeds_max_length"); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Generic content field validation for a named field with a max length. + * Used for field-exit validation and at submit. + */ +export function validateContentField(fieldName: string, value: string | null): ContentValidationResult { + const errors: string[] = []; + + if (value === null) { + return { valid: true, errors: [] }; + } + + const maxLen = + fieldName === "title" + ? MAX_TITLE_LENGTH + : fieldName === "short_title" || fieldName === "shortTitle" + ? MAX_SHORT_TITLE_LENGTH + : MAX_DESCRIPTION_LENGTH; + + if (charLen(value) > maxLen) { + errors.push("exceeds_max_length"); + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Validate all content fields at once. Returns true when all fields pass. + */ +export function isContentValid(fields: ContentFields): boolean { + const titleValid = validateTitle(fields.title).valid; + const shortTitleValid = validateShortTitle(fields.shortTitle).valid; + const descriptionValid = validateDescription(fields.description).valid; + return titleValid && shortTitleValid && descriptionValid; +} diff --git a/frontend/src/lib/validation/hash.ts b/frontend/src/lib/validation/hash.ts new file mode 100644 index 0000000..c06e112 --- /dev/null +++ b/frontend/src/lib/validation/hash.ts @@ -0,0 +1,97 @@ +import { VALIDATION_MODULE_VERSION } from "./constants.js"; +import { + MAX_TITLE_LENGTH, + MAX_SHORT_TITLE_LENGTH, + MAX_DESCRIPTION_LENGTH, +} from "./constants.js"; + +/** A rule definition from the validation module. */ +export interface ValidationRule { + field: string; + type: string; + constraint: string; + maxLen?: number; + pattern?: string; +} + +/** The full validation module payload the server sends to the client. */ +export interface ValidationModule { + hash: string; + version: string; + rules: ValidationRule[]; +} + +/** Result of validating a client-supplied hash against the current module. */ +export interface ModuleValidationResult { + valid: boolean; + errors: string[]; + /** Present only on mismatch (412): fresh module to replace client's stale copy. */ + freshModule?: ValidationModule; +} + +/** + * Compute the current validation module hash from the constants. + * Deterministic: same constants → same hash. + * Bump VALIDATION_MODULE_VERSION when rules change. + */ +export function computeModuleHash(): string { + return VALIDATION_MODULE_VERSION; +} + +/** Build the full validation module with all current rules. */ +export function buildValidationModule(): ValidationModule { + return { + hash: computeModuleHash(), + version: VALIDATION_MODULE_VERSION, + rules: [ + { + field: "sku", + type: "regex", + constraint: "uppercase_alphanumeric_hyphen", + pattern: "^[A-Z0-9-]{3,24}$", + }, + { + field: "title", + type: "maxLength", + constraint: "woocommerce_name", + maxLen: MAX_TITLE_LENGTH, + }, + { + field: "short_title", + type: "maxLength", + constraint: "woocommerce_short_description", + maxLen: MAX_SHORT_TITLE_LENGTH, + }, + { + field: "description", + type: "maxLength", + constraint: "woocommerce_description", + maxLen: MAX_DESCRIPTION_LENGTH, + }, + ], + }; +} + +/** + * Validate a client-supplied hash against the current module. + * + * - Match: valid, no errors, no fresh module. + * - Mismatch: invalid, "validation_module_stale" error, fresh module attached + * (maps to 412 response, not 409). + */ +export function validateModuleHash(clientHash: string): ModuleValidationResult { + const currentHash = computeModuleHash(); + + if (clientHash === currentHash) { + return { + valid: true, + errors: [], + }; + } + + return { + valid: false, + errors: ["validation_module_stale"], + freshModule: buildValidationModule(), + }; +} diff --git a/frontend/src/lib/validation/index.ts b/frontend/src/lib/validation/index.ts new file mode 100644 index 0000000..c777938 --- /dev/null +++ b/frontend/src/lib/validation/index.ts @@ -0,0 +1,4 @@ +export * from "./constants.js"; +export * from "./sku.js"; +export * from "./content.js"; +export * from "./hash.js"; diff --git a/frontend/src/lib/validation/sku.ts b/frontend/src/lib/validation/sku.ts new file mode 100644 index 0000000..ae56867 --- /dev/null +++ b/frontend/src/lib/validation/sku.ts @@ -0,0 +1,37 @@ +import { SKU_REGEX } from "./constants.js"; + +/** SKU validation result. */ +export interface SkuValidationResult { + valid: boolean; + errors: string[]; +} + +/** + * Validate a SKU string against the charset [A-Z0-9-]{3,24}. + * + * Returns errors for: too_short, too_long, invalid_charset. + * Multiple errors possible (e.g. too_long + invalid_charset). + */ +export function validateSku(sku: string): SkuValidationResult { + const errors: string[] = []; + + if (sku.length < 3) { + errors.push("too_short"); + } + if (sku.length > 24) { + errors.push("too_long"); + } + if (!SKU_REGEX.test(sku)) { + errors.push("invalid_charset"); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** Quick boolean check for SKU validity. */ +export function isSkuValid(sku: string): boolean { + return SKU_REGEX.test(sku); +}