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.
This commit is contained in:
@@ -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";
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,3 +6,4 @@ export * from "./axis.js";
|
|||||||
export * from "./crop.js";
|
export * from "./crop.js";
|
||||||
export * from "./pricing.js";
|
export * from "./pricing.js";
|
||||||
export * from "./thicknessBand.js";
|
export * from "./thicknessBand.js";
|
||||||
|
export * from "../validation/index.js";
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./constants.js";
|
||||||
|
export * from "./sku.js";
|
||||||
|
export * from "./content.js";
|
||||||
|
export * from "./hash.js";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user