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:
grokbot_ty
2026-09-07 07:43:50 +00:00
parent 34dbfa5d7d
commit d5d8c49e04
8 changed files with 667 additions and 0 deletions
@@ -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";
+284
View File
@@ -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);
});
});
+1
View File
@@ -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";
+16
View File
@@ -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";
+115
View File
@@ -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;
}
+97
View File
@@ -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(),
};
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./constants.js";
export * from "./sku.js";
export * from "./content.js";
export * from "./hash.js";
+37
View File
@@ -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);
}