diff --git a/jest.config.js b/jest.config.js index 4e7cc44c..cafd060d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,7 +4,6 @@ module.exports = { // Default: unit tests only (exclude integration tests) testMatch: ["**/src/**/tests/**/*.test.ts"], testPathIgnorePatterns: ["/node_modules/", "/dist/", "/src/index.ts", "integration\\.test\\.ts"], - setupFilesAfterEnv: ["/src/tests/setup.ts"], // Map TypeScript path aliases to actual paths moduleNameMapper: { "^core/(.*)$": "/src/core/$1", diff --git a/package.json b/package.json index 02b393e9..ea484ef1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agility/cli", - "version": "1.0.0-beta.13.18", + "version": "1.0.0-beta.13.20", "description": "Agility CLI for working with your content. (Public Beta)", "repository": { "type": "git", diff --git a/src/lib/content/content-field-validation.ts b/src/lib/content/content-field-validation.ts deleted file mode 100644 index 1c287405..00000000 --- a/src/lib/content/content-field-validation.ts +++ /dev/null @@ -1,420 +0,0 @@ -/** - * Content Field Validation Service - * - * Validates and sanitizes content fields before mapping to ensure: - * - Proper reference types and structures - * - Asset URL validity - * - Content ID reference validation - * - Field type compliance with Agility CMS expectations - */ - -import { LinkTypeDetector } from "../shared"; - -export interface FieldValidationResult { - isValid: boolean; - field: any; - warnings: string[]; - errors: string[]; - sanitizedField?: any; -} - -export interface ContentValidationOptions { - sourceAssets?: any[]; - sourceContainers?: any[]; - modelDefinitions?: any[]; - strictMode?: boolean; // If true, invalid references cause errors; if false, warnings -} - -export class ContentFieldValidator { - private linkTypeDetector: LinkTypeDetector; - - constructor() { - this.linkTypeDetector = new LinkTypeDetector(); - } - - /** - * Validate all fields in a content item - */ - public validateContentFields( - fields: any, - options: ContentValidationOptions = {} - ): { - isValid: boolean; - validatedFields: any; - totalWarnings: number; - totalErrors: number; - fieldResults: Map; - } { - if (!fields || typeof fields !== "object") { - return { - isValid: true, - validatedFields: fields, - totalWarnings: 0, - totalErrors: 0, - fieldResults: new Map(), - }; - } - - const fieldResults = new Map(); - const validatedFields: any = {}; - let totalWarnings = 0; - let totalErrors = 0; - let overallValid = true; - - for (const [fieldKey, fieldValue] of Object.entries(fields)) { - const result = this.validateSingleField(fieldKey, fieldValue, options); - fieldResults.set(fieldKey, result); - - validatedFields[fieldKey] = result.sanitizedField ?? result.field; - totalWarnings += result.warnings.length; - totalErrors += result.errors.length; - - if (!result.isValid) { - overallValid = false; - } - } - - return { - isValid: overallValid, - validatedFields, - totalWarnings, - totalErrors, - fieldResults, - }; - } - - /** - * Validate a single field with type-specific rules - */ - private validateSingleField( - fieldKey: string, - fieldValue: any, - options: ContentValidationOptions - ): FieldValidationResult { - const result: FieldValidationResult = { - isValid: true, - field: fieldValue, - warnings: [], - errors: [], - }; - - // Handle null/undefined - always valid - if (fieldValue === null || fieldValue === undefined) { - return result; - } - - // Validate object fields (content references, nested structures) - if (typeof fieldValue === "object" && fieldValue !== null) { - return this.validateObjectField(fieldKey, fieldValue, options); - } - - // Validate string fields (asset URLs, text content) - if (typeof fieldValue === "string") { - return this.validateStringField(fieldKey, fieldValue, options); - } - - // Validate numeric fields - if (typeof fieldValue === "number") { - return this.validateNumericField(fieldKey, fieldValue, options); - } - - // Primitive fields (boolean, etc.) are always valid - return result; - } - - /** - * Validate object fields with content references - */ - private validateObjectField( - fieldKey: string, - fieldValue: any, - options: ContentValidationOptions - ): FieldValidationResult { - const result: FieldValidationResult = { - isValid: true, - field: fieldValue, - warnings: [], - errors: [], - }; - - // Validate contentid/contentID references - if ("contentid" in fieldValue || "contentID" in fieldValue) { - const contentId = fieldValue.contentid || fieldValue.contentID; - if (typeof contentId !== "number" || contentId <= 0) { - result.errors.push(`Invalid content ID: ${contentId} in field ${fieldKey}`); - result.isValid = false; - } - } - - // Validate LinkedContentDropdown pattern - if (fieldValue.referencename && fieldValue.sortids) { - const sortIds = fieldValue.sortids.toString(); - - // Validate sortids format (comma-separated numbers) - const ids = sortIds.split(",").map((id) => id.trim()); - const invalidIds = ids.filter((id) => isNaN(parseInt(id)) || parseInt(id) <= 0); - - if (invalidIds.length > 0) { - result.errors.push(`Invalid sort IDs in field ${fieldKey}: ${invalidIds.join(", ")}`); - result.isValid = false; - } - - // Validate reference name if containers are available - if (options.sourceContainers) { - const containerExists = options.sourceContainers.some((c) => c.referenceName === fieldValue.referencename); - if (!containerExists) { - result.warnings.push(`Container reference ${fieldValue.referencename} not found in field ${fieldKey}`); - } - } - } - - // Validate gallery references - if (fieldValue.mediaGroupingID) { - const galleryId = fieldValue.mediaGroupingID; - if (typeof galleryId !== "number" || galleryId <= 0) { - result.errors.push(`Invalid gallery ID: ${galleryId} in field ${fieldKey}`); - result.isValid = false; - } - } - - // Recursive validation for nested objects/arrays - if (Array.isArray(fieldValue)) { - fieldValue.forEach((item, index) => { - if (typeof item === "object" && item !== null) { - const nestedResult = this.validateObjectField(`${fieldKey}[${index}]`, item, options); - result.warnings.push(...nestedResult.warnings); - result.errors.push(...nestedResult.errors); - if (!nestedResult.isValid) { - result.isValid = false; - } - } - }); - } - - return result; - } - - /** - * Validate string fields - */ - private validateStringField( - fieldKey: string, - fieldValue: string, - options: ContentValidationOptions - ): FieldValidationResult { - const result: FieldValidationResult = { - isValid: true, - field: fieldValue, - warnings: [], - errors: [], - }; - - // Validate asset URLs - if (this.isAssetUrl(fieldValue, options)) { - if (!this.isValidAssetUrl(fieldValue)) { - result.errors.push(`Invalid asset URL format in field ${fieldKey}: ${fieldValue}`); - result.isValid = false; - } else if (options.sourceAssets) { - // Check if asset exists in source data - const assetExists = options.sourceAssets.some( - (asset) => asset.originUrl === fieldValue || asset.url === fieldValue || asset.edgeUrl === fieldValue - ); - if (!assetExists) { - result.warnings.push(`Asset URL not found in source data for field ${fieldKey}: ${fieldValue}`); - } - } - } - - // Validate content ID strings (CategoryID, ValueField patterns) - if (this.isContentIdField(fieldKey, fieldValue)) { - const contentIds = fieldValue.includes(",") ? fieldValue.split(",").map((id) => id.trim()) : [fieldValue.trim()]; - - const invalidIds = contentIds.filter((id) => isNaN(parseInt(id)) || parseInt(id) <= 0); - if (invalidIds.length > 0) { - result.errors.push(`Invalid content IDs in field ${fieldKey}: ${invalidIds.join(", ")}`); - result.isValid = false; - } - } - - // Validate against maximum field length - if (fieldValue.length > 10000) { - // Agility CMS typical max field length - result.warnings.push(`Field ${fieldKey} exceeds recommended length (${fieldValue.length} chars)`); - } - - return result; - } - - /** - * Validate numeric fields - */ - private validateNumericField( - fieldKey: string, - fieldValue: number, - options: ContentValidationOptions - ): FieldValidationResult { - const result: FieldValidationResult = { - isValid: true, - field: fieldValue, - warnings: [], - errors: [], - }; - - // Validate range for ID fields - if (fieldKey.toLowerCase().includes("id") || fieldKey.toLowerCase().includes("contentid")) { - if (fieldValue <= 0) { - result.errors.push(`Invalid ID value in field ${fieldKey}: ${fieldValue}`); - result.isValid = false; - } - } - - return result; - } - - /** - * Check if string field contains content ID references - */ - private isContentIdField(fieldKey: string, fieldValue: string): boolean { - const lowercaseKey = fieldKey.toLowerCase(); - return ( - (lowercaseKey.includes("categoryid") || - lowercaseKey.includes("valuefield") || - lowercaseKey.includes("tags") || - lowercaseKey.includes("links")) && - /^\d+(,\d+)*$/.test(fieldValue.trim()) - ); - } - - /** - * Domain-agnostic check for asset URL strings. Matches: - * - any Agility-managed CDN subdomain (cdn.aglty.io, cdn-usa2.aglty.io, *.agilitycms.com, etc.) - * - any URL that exactly matches one of the source assets passed in options - * (so customer-supplied custom CDN domains are recognized when source data is available) - */ - private isAssetUrl(value: string, options: ContentValidationOptions): boolean { - if (value.includes(".aglty.io") || value.includes(".agilitycms.com")) return true; - if (options.sourceAssets) { - return options.sourceAssets.some( - (asset) => asset?.originUrl === value || asset?.url === value || asset?.edgeUrl === value - ); - } - return false; - } - - /** - * Structural validity check for an asset URL — caller is responsible for - * deciding the string is asset-like in the first place (see looksLikeAssetUrl). - */ - private isValidAssetUrl(url: string): boolean { - try { - const urlObj = new URL(url); - return urlObj.pathname.length > 1; - } catch { - return false; - } - } - - /** - * Sanitize field value to ensure compatibility - */ - public sanitizeField(fieldKey: string, fieldValue: any): any { - if (fieldValue === null || fieldValue === undefined) { - return fieldValue; - } - - // Sanitize string fields - if (typeof fieldValue === "string") { - // Trim whitespace - let sanitized = fieldValue.trim(); - - // Remove null characters - sanitized = sanitized.replace(/\0/g, ""); - - // Ensure proper encoding for special characters - try { - sanitized = decodeURIComponent(encodeURIComponent(sanitized)); - } catch { - // If encoding fails, return original - return fieldValue; - } - - return sanitized; - } - - // Sanitize numeric fields - if (typeof fieldValue === "number") { - // Ensure finite numbers - if (!Number.isFinite(fieldValue)) { - return 0; - } - return fieldValue; - } - - // Sanitize object fields recursively - if (typeof fieldValue === "object" && fieldValue !== null) { - if (Array.isArray(fieldValue)) { - return fieldValue.map((item, index) => this.sanitizeField(`${fieldKey}[${index}]`, item)); - } else { - const sanitized: any = {}; - for (const [key, value] of Object.entries(fieldValue)) { - sanitized[key] = this.sanitizeField(`${fieldKey}.${key}`, value); - } - return sanitized; - } - } - - return fieldValue; - } - /** - * Get validation summary for reporting - */ - public getValidationSummary(fieldResults: Map): { - totalFields: number; - validFields: number; - fieldsWithWarnings: number; - fieldsWithErrors: number; - criticalFields: string[]; - } { - const summary = { - totalFields: fieldResults.size, - validFields: 0, - fieldsWithWarnings: 0, - fieldsWithErrors: 0, - criticalFields: [] as string[], - }; - - fieldResults.forEach((result, fieldKey) => { - if (result.isValid) { - summary.validFields++; - } - if (result.warnings.length > 0) { - summary.fieldsWithWarnings++; - } - if (result.errors.length > 0) { - summary.fieldsWithErrors++; - summary.criticalFields.push(fieldKey); - } - }); - - return summary; - } -} - -/** - * Factory function for easy usage - */ -export function createContentFieldValidator(): ContentFieldValidator { - return new ContentFieldValidator(); -} - -/** - * Quick validation function for single fields - */ -export function validateField( - fieldKey: string, - fieldValue: any, - options: ContentValidationOptions = {} -): FieldValidationResult { - const validator = new ContentFieldValidator(); - return validator["validateSingleField"](fieldKey, fieldValue, options); -} diff --git a/src/lib/content/index.ts b/src/lib/content/index.ts index bcd93f82..3fe17ff3 100644 --- a/src/lib/content/index.ts +++ b/src/lib/content/index.ts @@ -1,3 +1,2 @@ export * from "./content-classifier"; export * from "./content-field-mapper"; -export * from "./content-field-validation"; diff --git a/src/lib/content/tests/content-field-validation.test.ts b/src/lib/content/tests/content-field-validation.test.ts deleted file mode 100644 index a52c2a91..00000000 --- a/src/lib/content/tests/content-field-validation.test.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { resetState } from "core/state"; -import { - ContentFieldValidator, - createContentFieldValidator, - validateField, - FieldValidationResult, -} from "lib/content/content-field-validation"; - -beforeEach(() => { - resetState(); - jest.spyOn(console, "log").mockImplementation(() => {}); - jest.spyOn(console, "warn").mockImplementation(() => {}); - jest.spyOn(console, "error").mockImplementation(() => {}); -}); - -afterEach(() => { - jest.restoreAllMocks(); -}); - -// ─── createContentFieldValidator / validateField factories ──────────────────── - -describe("createContentFieldValidator", () => { - it("returns a ContentFieldValidator instance", () => { - expect(createContentFieldValidator()).toBeInstanceOf(ContentFieldValidator); - }); -}); - -describe("validateField (standalone helper)", () => { - it("returns isValid:true for a plain string", () => { - const result = validateField("title", "Hello"); - expect(result.isValid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - it("returns isValid:false for a non-positive contentid", () => { - const result = validateField("ref", { contentid: -1 }); - expect(result.isValid).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); -}); - -// ─── validateContentFields ──────────────────────────────────────────────────── - -describe("ContentFieldValidator.validateContentFields", () => { - let validator: ContentFieldValidator; - - beforeEach(() => { - validator = new ContentFieldValidator(); - }); - - describe("null / non-object inputs", () => { - it.each([ - ["null", null], - ["undefined", undefined], - ["a number", 42], - ["a string", "text"], - ])("returns isValid:true with zero counts for %s", (_label, input) => { - const result = validator.validateContentFields(input as any); - expect(result.isValid).toBe(true); - expect(result.totalWarnings).toBe(0); - expect(result.totalErrors).toBe(0); - expect(result.fieldResults.size).toBe(0); - }); - }); - - describe("primitive fields", () => { - it("validates a plain string field as valid", () => { - const result = validator.validateContentFields({ title: "My Title" }); - expect(result.isValid).toBe(true); - expect(result.totalErrors).toBe(0); - expect(result.validatedFields.title).toBe("My Title"); - }); - - it("validates a number field without an id-like name as valid", () => { - const result = validator.validateContentFields({ count: 5 }); - expect(result.isValid).toBe(true); - }); - - it("validates a boolean field as valid", () => { - const result = validator.validateContentFields({ active: true }); - expect(result.isValid).toBe(true); - expect(result.totalErrors).toBe(0); - }); - - it("validates null and undefined field values as valid", () => { - const result = validator.validateContentFields({ a: null, b: undefined }); - expect(result.isValid).toBe(true); - }); - }); - - describe("numeric id fields", () => { - it("returns an error for a non-positive ID field", () => { - const result = validator.validateContentFields({ categoryId: -1 }); - expect(result.isValid).toBe(false); - expect(result.totalErrors).toBeGreaterThan(0); - }); - - it("returns an error for a zero ID field", () => { - const result = validator.validateContentFields({ contentid: 0 }); - expect(result.isValid).toBe(false); - }); - - it("passes a positive numeric id field", () => { - const result = validator.validateContentFields({ categoryId: 10 }); - expect(result.isValid).toBe(true); - expect(result.totalErrors).toBe(0); - }); - }); - - describe("asset URL string fields", () => { - it("validates a well-formed cdn.aglty.io URL", () => { - const result = validator.validateContentFields({ - image: "https://cdn.aglty.io/guid/assets/photo.jpg", - }); - expect(result.totalErrors).toBe(0); - }); - - it('returns an error for a malformed cdn.aglty.io "URL"', () => { - const result = validator.validateContentFields({ - image: "not-a-url-cdn.aglty.io", - }); - expect(result.isValid).toBe(false); - expect(result.totalErrors).toBeGreaterThan(0); - }); - - it("returns a warning when sourceAssets is provided but the URL is not found", () => { - const result = validator.validateContentFields( - { image: "https://cdn.aglty.io/guid/assets/missing.jpg" }, - { sourceAssets: [] } - ); - expect(result.totalWarnings).toBeGreaterThan(0); - }); - - it("does not warn when the URL is present in sourceAssets via url property", () => { - const url = "https://cdn.aglty.io/guid/assets/photo.jpg"; - const result = validator.validateContentFields({ image: url }, { sourceAssets: [{ url }] }); - expect(result.totalWarnings).toBe(0); - }); - - it("does not warn when the URL is present in sourceAssets via originUrl", () => { - const url = "https://cdn.aglty.io/guid/assets/photo.jpg"; - const result = validator.validateContentFields({ image: url }, { sourceAssets: [{ originUrl: url }] }); - expect(result.totalWarnings).toBe(0); - }); - - it("warns for a field that exceeds the recommended length", () => { - const longString = "a".repeat(10001); - const result = validator.validateContentFields({ body: longString }); - expect(result.totalWarnings).toBeGreaterThan(0); - }); - - it("validates a regional .aglty.io subdomain (cdn-usa2.aglty.io) as an asset URL", () => { - // Regression check: the old `includes("cdn.aglty.io")` check missed cdn-usa2.aglty.io. - const result = validator.validateContentFields({ - config: "https://cdn-usa2.aglty.io/brightstar-qa/mobile/config.json", - }); - expect(result.totalErrors).toBe(0); - }); - - it("validates a *.agilitycms.com subdomain as an asset URL", () => { - const result = validator.validateContentFields({ - hero: "https://cdndev.agilitycms.com/fcukahpf/posts/photo.jpg", - }); - expect(result.totalErrors).toBe(0); - }); - - it("recognizes a custom CDN URL by container-prefix match against sourceAssets", () => { - // Customer custom CDN host. The validator should derive a container prefix - // (host + first path segment) from the assets and accept URLs that share it. - const result = validator.validateContentFields( - { config: "https://cdn.ilotteryservices.com/8f5ad099/mobile/config.json" }, - { - sourceAssets: [{ url: "https://cdn.ilotteryservices.com/8f5ad099/mobile/other.json" }], - } - ); - // Both URLs share the prefix → no warning about asset-not-found. - expect(result.totalWarnings).toBe(0); - expect(result.totalErrors).toBe(0); - }); - - it("does not treat a non-asset URL as an asset URL", () => { - // String is a URL but no Agility domain and no sourceAsset prefix match. - const result = validator.validateContentFields( - { link: "https://www.example.com/article/123" }, - { sourceAssets: [{ url: "https://cdn.ilotteryservices.com/8f5ad099/file.json" }] } - ); - expect(result.totalErrors).toBe(0); - expect(result.totalWarnings).toBe(0); - }); - }); - - describe("content ID string fields", () => { - it("validates a valid comma-separated categoryid string", () => { - const result = validator.validateContentFields({ categoryid: "1,2,3" }); - expect(result.isValid).toBe(true); - expect(result.totalErrors).toBe(0); - }); - - it("does not error for a categoryid string containing non-numeric parts because isContentIdField guards on the pattern", () => { - // isContentIdField only triggers when the value already matches /^\d+(,\d+)*$/ - // so '1,abc,3' is treated as a plain string and passes through without error - const result = validator.validateContentFields({ categoryid: "1,abc,3" }); - expect(result.isValid).toBe(true); - expect(result.totalErrors).toBe(0); - }); - - it("returns an error for a zero ID in a categoryid string (zero fails parseInt(id) > 0 check)", () => { - const result = validator.validateContentFields({ categoryid: "0,2" }); - expect(result.isValid).toBe(false); - expect(result.totalErrors).toBeGreaterThan(0); - }); - }); - - describe("content reference object fields (contentid / contentID)", () => { - it("returns an error for a non-positive contentid in an object field", () => { - const result = validator.validateContentFields({ ref: { contentid: -5 } }); - expect(result.isValid).toBe(false); - expect(result.totalErrors).toBeGreaterThan(0); - }); - - it("returns an error for a non-positive contentID in an object field", () => { - const result = validator.validateContentFields({ ref: { contentID: 0 } }); - expect(result.isValid).toBe(false); - }); - - it("passes a positive contentid in an object field", () => { - const result = validator.validateContentFields({ ref: { contentid: 42 } }); - expect(result.totalErrors).toBe(0); - }); - - it("passes a positive contentID in an object field", () => { - const result = validator.validateContentFields({ ref: { contentID: 42 } }); - expect(result.totalErrors).toBe(0); - }); - - it("returns an error for a string contentid in an object field", () => { - const result = validator.validateContentFields({ ref: { contentid: "bad" } }); - expect(result.isValid).toBe(false); - }); - }); - - describe("referencename + sortids pattern", () => { - it("passes valid sortids with a referencename", () => { - const result = validator.validateContentFields({ - items: { referencename: "my-list", sortids: "1,2,3" }, - }); - expect(result.totalErrors).toBe(0); - }); - - it("returns an error for invalid sortids", () => { - const result = validator.validateContentFields({ - items: { referencename: "my-list", sortids: "1,abc" }, - }); - expect(result.isValid).toBe(false); - expect(result.totalErrors).toBeGreaterThan(0); - }); - - it("returns an error for zero sortids", () => { - const result = validator.validateContentFields({ - items: { referencename: "my-list", sortids: "0,2" }, - }); - expect(result.isValid).toBe(false); - }); - - it("warns when the container reference is not found in sourceContainers", () => { - const result = validator.validateContentFields( - { items: { referencename: "ghost-list", sortids: "1" } }, - { sourceContainers: [{ referenceName: "other-list" }] } - ); - expect(result.totalWarnings).toBeGreaterThan(0); - }); - - it("does not warn when the container reference IS found in sourceContainers", () => { - const result = validator.validateContentFields( - { items: { referencename: "known-list", sortids: "1" } }, - { sourceContainers: [{ referenceName: "known-list" }] } - ); - expect(result.totalWarnings).toBe(0); - }); - }); - - describe("gallery reference fields", () => { - it("returns an error for a non-positive mediaGroupingID", () => { - const result = validator.validateContentFields({ gallery: { mediaGroupingID: -1 } }); - expect(result.isValid).toBe(false); - expect(result.totalErrors).toBeGreaterThan(0); - }); - - it("passes a positive mediaGroupingID", () => { - const result = validator.validateContentFields({ gallery: { mediaGroupingID: 10 } }); - expect(result.totalErrors).toBe(0); - }); - }); - - describe("array field validation", () => { - it("validates each item in an array field recursively", () => { - const result = validator.validateContentFields({ - items: [{ contentid: 1 }, { contentid: -5 }], - }); - expect(result.isValid).toBe(false); - expect(result.totalErrors).toBeGreaterThan(0); - }); - - it("passes when all array items are valid", () => { - const result = validator.validateContentFields({ - items: [{ contentid: 1 }, { contentid: 2 }], - }); - expect(result.totalErrors).toBe(0); - }); - }); - - describe("fieldResults map", () => { - it("contains an entry per validated field", () => { - const result = validator.validateContentFields({ a: "x", b: "y" }); - expect(result.fieldResults.size).toBe(2); - expect(result.fieldResults.has("a")).toBe(true); - expect(result.fieldResults.has("b")).toBe(true); - }); - }); -}); - -// ─── sanitizeField ──────────────────────────────────────────────────────────── - -describe("ContentFieldValidator.sanitizeField", () => { - let validator: ContentFieldValidator; - - beforeEach(() => { - validator = new ContentFieldValidator(); - }); - - it("returns null unchanged", () => { - expect(validator.sanitizeField("f", null)).toBeNull(); - }); - - it("returns undefined unchanged", () => { - expect(validator.sanitizeField("f", undefined)).toBeUndefined(); - }); - - it("trims whitespace from string fields", () => { - expect(validator.sanitizeField("title", " hello ")).toBe("hello"); - }); - - it("removes null characters from string fields", () => { - expect(validator.sanitizeField("body", "hello\0world")).toBe("helloworld"); - }); - - it("returns 0 for non-finite numbers", () => { - expect(validator.sanitizeField("val", Infinity)).toBe(0); - expect(validator.sanitizeField("val", NaN)).toBe(0); - }); - - it("preserves finite numbers unchanged", () => { - expect(validator.sanitizeField("count", 42)).toBe(42); - expect(validator.sanitizeField("price", -3.14)).toBe(-3.14); - }); - - it("sanitizes string values inside nested objects recursively", () => { - const result = validator.sanitizeField("obj", { text: " padded " }); - expect(result.text).toBe("padded"); - }); - - it("sanitizes string values inside arrays recursively", () => { - const result = validator.sanitizeField("arr", [" a ", " b "]); - expect(result).toEqual(["a", "b"]); - }); - - it("passes through boolean values unchanged", () => { - expect(validator.sanitizeField("flag", true)).toBe(true); - expect(validator.sanitizeField("flag", false)).toBe(false); - }); -}); - -// ─── getValidationSummary ───────────────────────────────────────────────────── - -describe("ContentFieldValidator.getValidationSummary", () => { - let validator: ContentFieldValidator; - - beforeEach(() => { - validator = new ContentFieldValidator(); - }); - - it("returns zero counts for an empty map", () => { - const summary = validator.getValidationSummary(new Map()); - expect(summary.totalFields).toBe(0); - expect(summary.validFields).toBe(0); - expect(summary.fieldsWithWarnings).toBe(0); - expect(summary.fieldsWithErrors).toBe(0); - expect(summary.criticalFields).toHaveLength(0); - }); - - it("counts valid, warned, and errored fields correctly", () => { - const fieldResults = new Map([ - ["ok", { isValid: true, field: "x", warnings: [], errors: [] }], - ["warned", { isValid: true, field: "y", warnings: ["w1"], errors: [] }], - ["errored", { isValid: false, field: "z", warnings: [], errors: ["e1"] }], - ]); - const summary = validator.getValidationSummary(fieldResults); - expect(summary.totalFields).toBe(3); - expect(summary.validFields).toBe(2); - expect(summary.fieldsWithWarnings).toBe(1); - expect(summary.fieldsWithErrors).toBe(1); - expect(summary.criticalFields).toContain("errored"); - expect(summary.criticalFields).not.toContain("ok"); - }); - - it("includes a field in criticalFields when it has errors", () => { - const fieldResults = new Map([ - ["badField", { isValid: false, field: null, warnings: [], errors: ["bad content ID"] }], - ]); - const { criticalFields } = validator.getValidationSummary(fieldResults); - expect(criticalFields).toContain("badField"); - }); - - it("derives summary from validateContentFields output", () => { - const { fieldResults } = validator.validateContentFields({ - title: "Good", - ref: { contentid: -1 }, - }); - const summary = validator.getValidationSummary(fieldResults); - expect(summary.totalFields).toBe(2); - expect(summary.fieldsWithErrors).toBe(1); - expect(summary.criticalFields).toContain("ref"); - }); -}); diff --git a/src/lib/mappers/asset-mapper.ts b/src/lib/mappers/asset-mapper.ts index 2007a279..499e0e3a 100644 --- a/src/lib/mappers/asset-mapper.ts +++ b/src/lib/mappers/asset-mapper.ts @@ -102,6 +102,10 @@ export class AssetMapper { * Returns true if the given URL starts with any container edge/origin URL * known to the mapper (source or target). Lets callers identify asset URLs * without hardcoding CDN domains — supports custom CDN hosts. + * + * Falls back to the URL origin (protocol + host) of the per-asset + * sourceUrl/targetUrl for legacy mapping files written before container URLs + * were tracked, so custom-CDN assets are still recognized without a re-map. */ isKnownAssetUrl(url: string): boolean { if (!url || typeof url !== "string") return false; @@ -114,10 +118,35 @@ export class AssetMapper { mapping.targetContainerOriginUrl, ].filter((containerUrl): containerUrl is string => typeof containerUrl === "string"); - return knownContainerUrls.some((containerUrl) => url.startsWith(containerUrl)); + if (knownContainerUrls.length > 0) { + // New-format mapping: match strictly on the container prefix so assets + // from a different account on the same CDN host are not falsely matched. + return knownContainerUrls.some((containerUrl) => url.startsWith(containerUrl)); + } + + // Legacy fallback: this mapping predates container-URL tracking and only + // stored the full asset URLs (sourceUrl/targetUrl). Match on the URL + // origin so any asset on the same CDN host is recognized without a re-map. + const legacyOrigins = [mapping.sourceUrl, mapping.targetUrl] + .map((assetUrl) => this.getUrlOrigin(assetUrl)) + .filter((origin): origin is string => origin !== null); + + return legacyOrigins.some((origin) => url.startsWith(origin)); }); } + /** + * Extract the origin (protocol + host) from a URL, or null if it can't be parsed. + */ + private getUrlOrigin(url: string | undefined): string | null { + if (!url || typeof url !== "string") return null; + try { + return new URL(url).origin; + } catch { + return null; + } + } + getMappedEntity(mapping: AssetMapping, type: "source" | "target"): mgmtApi.Media | null { const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; const mediaID = type === "source" ? mapping.sourceMediaID : mapping.targetMediaID; diff --git a/src/lib/mappers/tests/asset-mapper.test.ts b/src/lib/mappers/tests/asset-mapper.test.ts index d67a8a0f..ea548715 100644 --- a/src/lib/mappers/tests/asset-mapper.test.ts +++ b/src/lib/mappers/tests/asset-mapper.test.ts @@ -312,21 +312,91 @@ describe("AssetMapper.isKnownAssetUrl", () => { expect(mapper.isKnownAssetUrl("https://unrelated.example.com/file.jpg")).toBe(false); }); - it("ignores mappings whose container URLs are undefined", () => { + it("returns false when a mapping has no container URLs and no asset URLs", () => { const mapper = makeMapper(); const src = makeAsset({ mediaID: 1, + edgeUrl: undefined as any, containerEdgeUrl: undefined as any, containerOriginUrl: undefined as any, }); const tgt = makeAsset({ mediaID: 2, + edgeUrl: undefined as any, containerEdgeUrl: undefined as any, containerOriginUrl: undefined as any, }); mapper.addMapping(src, tgt); expect(mapper.isKnownAssetUrl("https://cdn.aglty.io/anything.jpg")).toBe(false); }); + + // Legacy fallback: mapping files written before container URLs were tracked + // only stored the full per-asset sourceUrl/targetUrl. Detection must still + // work off the URL origin so custom-CDN assets are recognized without a re-map. + describe("legacy mapping files (no container URLs)", () => { + it("recognizes a custom-CDN URL via the source asset URL origin", () => { + const mapper = makeMapper(); + // Simulate a legacy entry: container URLs absent, only edge/origin asset URLs. + const src = makeAsset({ + mediaID: 1, + edgeUrl: "https://cdn.ilotteryservices.com/brightstar-tns-cat/acl/en-us1.json", + containerEdgeUrl: undefined as any, + containerOriginUrl: undefined as any, + }); + const tgt = makeAsset({ + mediaID: 2, + edgeUrl: "https://cdn-usa2.aglty.io/de5185c3/acl/en-us1.json", + containerEdgeUrl: undefined as any, + containerOriginUrl: undefined as any, + }); + mapper.addMapping(src, tgt); + + // A different file on the same custom CDN host is recognized. + expect( + mapper.isKnownAssetUrl( + "https://cdn.ilotteryservices.com/brightstar-tns-cat/mobile/configuration/draw-games/quickPickConfig.json" + ) + ).toBe(true); + }); + + it("recognizes a URL via the target asset URL origin", () => { + const mapper = makeMapper(); + const src = makeAsset({ + mediaID: 1, + edgeUrl: "https://cdn.ilotteryservices.com/brightstar-tns-cat/acl/en-us1.json", + containerEdgeUrl: undefined as any, + containerOriginUrl: undefined as any, + }); + const tgt = makeAsset({ + mediaID: 2, + edgeUrl: "https://cdn-usa2.aglty.io/de5185c3/acl/en-us1.json", + containerEdgeUrl: undefined as any, + containerOriginUrl: undefined as any, + }); + mapper.addMapping(src, tgt); + + expect(mapper.isKnownAssetUrl("https://cdn-usa2.aglty.io/de5185c3/mobile/other.json")).toBe(true); + }); + + it("does not match a URL on an unrelated host", () => { + const mapper = makeMapper(); + const src = makeAsset({ + mediaID: 1, + edgeUrl: "https://cdn.ilotteryservices.com/brightstar-tns-cat/acl/en-us1.json", + containerEdgeUrl: undefined as any, + containerOriginUrl: undefined as any, + }); + const tgt = makeAsset({ + mediaID: 2, + edgeUrl: "https://cdn-usa2.aglty.io/de5185c3/acl/en-us1.json", + containerEdgeUrl: undefined as any, + containerOriginUrl: undefined as any, + }); + mapper.addMapping(src, tgt); + + expect(mapper.isKnownAssetUrl("https://cdn.competitor.com/brightstar-tns-cat/file.json")).toBe(false); + }); + }); }); // ─── addMapping / updateMapping ─────────────────────────────────────────────── diff --git a/src/lib/models/model-dependency-tree-builder.ts b/src/lib/models/model-dependency-tree-builder.ts index 6c1911f1..be739578 100644 --- a/src/lib/models/model-dependency-tree-builder.ts +++ b/src/lib/models/model-dependency-tree-builder.ts @@ -12,6 +12,7 @@ import { SourceData } from "../../types/sourceData"; import ansiColors from "ansi-colors"; import { SitemapHierarchy } from "../pushers/page-pusher/sitemap-hierarchy"; import { AssetReferenceExtractor } from "../assets/asset-reference-extractor"; +import { AssetMapper } from "lib/mappers/asset-mapper"; /** * Expand a list of model reference names to include every model they reference through @@ -64,9 +65,15 @@ export interface ModelDependencyTree { export class ModelDependencyTreeBuilder { private static hasLoggedBreakdown = false; private assetExtractor: AssetReferenceExtractor; + private assetMapper: AssetMapper; - constructor(private sourceData: SourceData) { + constructor( + private sourceData: SourceData, + targetGuid: string, + sourceGuid: string + ) { this.assetExtractor = new AssetReferenceExtractor(); + this.assetMapper = new AssetMapper(sourceGuid, targetGuid); } /** @@ -614,7 +621,7 @@ export class ModelDependencyTreeBuilder { const urls: string[] = []; if (contentItem.fields) { - const assetReferences = this.assetExtractor.extractAssetReferences(contentItem.fields); + const assetReferences = this.assetExtractor.extractAssetReferences(contentItem.fields, this.assetMapper); assetReferences.forEach((ref) => { if (ref.url) { urls.push(ref.url); @@ -634,7 +641,7 @@ export class ModelDependencyTreeBuilder { // Scan page zones for asset references if (page.zones) { - const zoneReferences = this.assetExtractor.extractAssetReferences(page.zones); + const zoneReferences = this.assetExtractor.extractAssetReferences(page.zones, this.assetMapper); zoneReferences.forEach((ref) => { if (ref.url) { urls.push(ref.url); @@ -644,7 +651,7 @@ export class ModelDependencyTreeBuilder { // Scan page content if it exists if (page.content) { - const contentReferences = this.assetExtractor.extractAssetReferences(page.content); + const contentReferences = this.assetExtractor.extractAssetReferences(page.content, this.assetMapper); contentReferences.forEach((ref) => { if (ref.url) { urls.push(ref.url); diff --git a/src/lib/models/tests/model-dependency-tree-builder.test.ts b/src/lib/models/tests/model-dependency-tree-builder.test.ts index 1a8cd069..747ca418 100644 --- a/src/lib/models/tests/model-dependency-tree-builder.test.ts +++ b/src/lib/models/tests/model-dependency-tree-builder.test.ts @@ -94,6 +94,13 @@ function makeSourceData(overrides: Partial = {}): any { }; } +const TEST_TARGET_GUID = "target-guid"; +const TEST_SOURCE_GUID = "source-guid"; + +function makeBuilder(sourceData: any): ModelDependencyTreeBuilder { + return new ModelDependencyTreeBuilder(sourceData, TEST_TARGET_GUID, TEST_SOURCE_GUID); +} + // ─── model→model references via linked-content fields (PROD-2187) ───────────── function makeModelWithRefs(id: number, referenceName: string, refs: string[] = []): any { @@ -106,7 +113,7 @@ function makeModelWithRefs(id: number, referenceName: string, refs: string[] = [ describe("ModelDependencyTreeBuilder — model→model references", () => { it("includes a model referenced via a linked-content field (FooterLinks → FooterLinksLists)", () => { - const builder = new ModelDependencyTreeBuilder( + const builder = makeBuilder( makeSourceData({ models: [makeModelWithRefs(1, "FooterLinks", ["FooterLinksLists"]), makeModelWithRefs(2, "FooterLinksLists")], }) @@ -117,7 +124,7 @@ describe("ModelDependencyTreeBuilder — model→model references", () => { }); it("resolves references transitively (A → B → C)", () => { - const builder = new ModelDependencyTreeBuilder( + const builder = makeBuilder( makeSourceData({ models: [makeModelWithRefs(1, "A", ["B"]), makeModelWithRefs(2, "B", ["C"]), makeModelWithRefs(3, "C")], }) @@ -128,7 +135,7 @@ describe("ModelDependencyTreeBuilder — model→model references", () => { }); it("does not pull in unrelated models", () => { - const builder = new ModelDependencyTreeBuilder( + const builder = makeBuilder( makeSourceData({ models: [ makeModelWithRefs(1, "FooterLinks", ["FooterLinksLists"]), @@ -142,7 +149,7 @@ describe("ModelDependencyTreeBuilder — model→model references", () => { }); it("terminates on a reference cycle (A → B → A)", () => { - const builder = new ModelDependencyTreeBuilder( + const builder = makeBuilder( makeSourceData({ models: [makeModelWithRefs(1, "A", ["B"]), makeModelWithRefs(2, "B", ["A"])] }) ); const tree = builder.buildDependencyTree(["A"], "website"); @@ -159,7 +166,7 @@ describe("ModelDependencyTreeBuilder.resetLoggingFlags", () => { }); it("allows the breakdown log to fire again on a fresh builder", () => { - const builder = new ModelDependencyTreeBuilder( + const builder = makeBuilder( makeSourceData({ models: [makeModel(1, "Post")], content: [makeContent(10, "Post")] }) ); const logSpy = jest.spyOn(console, "log"); @@ -182,7 +189,7 @@ describe("ModelDependencyTreeBuilder.resetLoggingFlags", () => { describe("ModelDependencyTreeBuilder constructor", () => { it("does not throw with a valid sourceData object", () => { - expect(() => new ModelDependencyTreeBuilder(makeSourceData())).not.toThrow(); + expect(() => makeBuilder(makeSourceData())).not.toThrow(); }); }); @@ -192,7 +199,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — guard clauses", () let builder: ModelDependencyTreeBuilder; beforeEach(() => { - builder = new ModelDependencyTreeBuilder(makeSourceData()); + builder = makeBuilder(makeSourceData()); }); it("throws when modelNames is null", () => { @@ -212,7 +219,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — guard clauses", () describe("ModelDependencyTreeBuilder.buildDependencyTree — empty source data", () => { it("returns tree with only the requested model names when source data is empty", () => { - const builder = new ModelDependencyTreeBuilder(makeSourceData()); + const builder = makeBuilder(makeSourceData()); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.models).toEqual(new Set(["Post"])); @@ -225,13 +232,13 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — empty source data", }); it("seeds the models set with all supplied model names", () => { - const builder = new ModelDependencyTreeBuilder(makeSourceData()); + const builder = makeBuilder(makeSourceData()); const tree = builder.buildDependencyTree(["Alpha", "Beta", "Gamma"], "website"); expect(tree.models).toEqual(new Set(["Alpha", "Beta", "Gamma"])); }); it("returns a tree with all required keys", () => { - const builder = new ModelDependencyTreeBuilder(makeSourceData()); + const builder = makeBuilder(makeSourceData()); const tree = builder.buildDependencyTree(["M"], "website"); const keys: Array = [ "models", @@ -255,7 +262,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — container discovery models: [makeModel(1, "Post")], containers: [makeContainer(100, 1)], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.containers.has(100)).toBe(true); @@ -266,7 +273,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — container discovery models: [makeModel(1, "Post"), makeModel(2, "Author")], containers: [makeContainer(100, 1), makeContainer(200, 2)], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.containers.has(100)).toBe(true); @@ -278,7 +285,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — container discovery models: [makeModel(1, "Post")], containers: [makeContainer(100, 1), makeContainer(101, 1)], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.containers.has(100)).toBe(true); @@ -287,13 +294,13 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — container discovery it("handles missing containers array gracefully", () => { const sourceData = makeSourceData({ models: [makeModel(1, "Post")], containers: undefined }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); it("handles missing models array gracefully", () => { const sourceData = makeSourceData({ models: undefined, containers: [makeContainer(100, 1)] }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); @@ -302,7 +309,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — container discovery models: [makeModel(1, "Post")], containers: [makeContainer(100, 1)], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["NonExistent"], "website"); expect(tree.containers.size).toBe(0); }); @@ -315,7 +322,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — content discovery", const sourceData = makeSourceData({ content: [makeContent(10, "Post"), makeContent(11, "Post"), makeContent(12, "Author")], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.content.has(10)).toBe(true); @@ -325,7 +332,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — content discovery", it("handles missing content array gracefully", () => { const sourceData = makeSourceData({ content: undefined }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); @@ -333,7 +340,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — content discovery", const sourceData = makeSourceData({ content: [makeContent(10, "Post"), makeContent(20, "Author")], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post", "Author"], "website"); expect(tree.content.has(10)).toBe(true); @@ -350,7 +357,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — template discovery containers: [makeContainer(100, 1)], templates: [makeTemplate(500, [{ contentViewID: 100 }])], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.templates.has(500)).toBe(true); @@ -362,7 +369,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — template discovery containers: [makeContainer(100, 1)], templates: [makeTemplate(501, [{ itemContainerID: 100 }])], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.templates.has(501)).toBe(true); @@ -374,7 +381,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — template discovery containers: [makeContainer(100, 1)], templates: [makeTemplate(500, [{ contentViewID: 999 }])], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.templates.has(500)).toBe(false); @@ -386,7 +393,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — template discovery containers: [makeContainer(100, 1)], templates: undefined, }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); @@ -399,7 +406,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — template discovery ], pages: [{ pageID: 300, name: "blog", templateName: "MainLayout" }], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.templates.has(600)).toBe(true); @@ -416,7 +423,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — page discovery", () templates: [makeTemplate(500, [{ contentViewID: 100 }])], pages: [makePage(300, { pageTemplateID: 500 })], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.pages.has(300)).toBe(true); @@ -428,7 +435,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — page discovery", () content: [makeContent(10, "Post")], pages: [makePage(300, { zones })], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.pages.has(300)).toBe(true); @@ -440,7 +447,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — page discovery", () content: [makeContent(20, "Widget")], pages: [makePage(400, { zones })], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Widget"], "website"); expect(tree.pages.has(400)).toBe(true); @@ -453,7 +460,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — page discovery", () templates: [makeTemplate(500, [{ contentViewID: 100 }])], pages: [makePage(300, { pageTemplateID: 500 }), makePage(999, { pageTemplateID: 888 })], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.pages.has(300)).toBe(true); @@ -462,7 +469,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — page discovery", () it("handles missing pages array gracefully", () => { const sourceData = makeSourceData({ pages: undefined }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); }); @@ -476,7 +483,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — content pulled from content: [makeContent(10, "Post"), makeContent(99, "Promo")], pages: [makePage(300, { zones })], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.content.has(99)).toBe(true); @@ -488,7 +495,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — content pulled from content: [makeContent(10, "Post")], pages: [makePage(300, { zones })], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); // Should not throw expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); @@ -504,7 +511,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — model back-discover models: [makeModel(1, "Post"), makeModel(2, "Promo")], pages: [makePage(300, { zones })], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.models.has("Promo")).toBe(true); @@ -522,7 +529,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — container discovery ], content: [makeContent(10, "Post", "news1_postlist")], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.containers.has(100)).toBe(true); @@ -537,7 +544,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — container discovery containers: [makeContainer(200, 99, "unrelated_container")], content: [makeContent(10, "Post", "news1_postlist")], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.containers.has(200)).toBe(false); @@ -551,7 +558,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — asset discovery", ( const sourceData = makeSourceData({ content: [makeContent(10, "Post", "ref-10", { image: "https://cdn.aglty.io/my-img.jpg" })], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.assets.has("https://cdn.aglty.io/my-img.jpg")).toBe(true); @@ -568,7 +575,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — asset discovery", ( ), ], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.assets.has("https://origin.aglty.io/my-img.jpg")).toBe(true); @@ -582,7 +589,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — asset discovery", ( makeContent(20, "Other", "ref-20", { image: "https://cdn.aglty.io/excluded.jpg" }), ], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.assets.has("https://cdn.aglty.io/included.jpg")).toBe(true); @@ -591,7 +598,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — asset discovery", ( it("handles missing content array gracefully for asset discovery", () => { const sourceData = makeSourceData({ content: undefined }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); @@ -599,7 +606,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — asset discovery", ( const sourceData = makeSourceData({ content: [makeContent(10, "Post", "ref-10", { banner: "https://static.agilitycms.com/banner.png" })], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.assets.has("https://static.agilitycms.com/banner.png")).toBe(true); @@ -614,7 +621,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — gallery discovery", content: [makeContent(10, "Post", "ref-10", { gallery: { mediaGroupingID: 55 } })], galleries: [makeGallery(55)], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.galleries.has(55)).toBe(true); @@ -625,7 +632,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — gallery discovery", content: [makeContent(10, "Post", "ref-10", { pics: { galleryID: 77 } })], galleries: [makeGallery(77)], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.galleries.has(77)).toBe(true); @@ -639,7 +646,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — gallery discovery", ], galleries: [makeGallery(99)], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.galleries.has(99)).toBe(false); @@ -650,7 +657,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — gallery discovery", content: [makeContent(10, "Post", "ref-10", { g: { mediaGroupingID: 55 } })], galleries: undefined, }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); @@ -659,7 +666,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — gallery discovery", content: [makeContent(10, "Post", "ref-10", { items: [{ mediaGroupingID: 42 }] })], galleries: [makeGallery(42)], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.galleries.has(42)).toBe(true); @@ -681,7 +688,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — ancestor page disco content: [makeContent(10, "Post")], pages: [makePage(300, { zones, name: "child-page" }), makePage(200, { name: "parent-page" })], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.pages.has(300)).toBe(true); @@ -698,7 +705,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — ancestor page disco content: [makeContent(10, "Post")], pages: [makePage(300, { zones })], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.pages.size).toBe(1); @@ -720,7 +727,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — ancestor page disco makePage(100, { name: "grandparent" }), ], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.pages.has(100)).toBe(true); @@ -734,7 +741,7 @@ describe("ModelDependencyTreeBuilder.buildDependencyTree — ancestor page disco describe("ModelDependencyTreeBuilder.buildDependencyTree — breakdown log deduplication", () => { it("only logs the breakdown once across multiple calls on the same builder", () => { const sourceData = makeSourceData({ content: [makeContent(10, "Post")] }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const logSpy = jest.spyOn(console, "log"); builder.buildDependencyTree(["Post"], "website"); @@ -751,7 +758,7 @@ describe("ModelDependencyTreeBuilder.validateModels", () => { let builder: ModelDependencyTreeBuilder; beforeEach(() => { - builder = new ModelDependencyTreeBuilder(makeSourceData()); + builder = makeBuilder(makeSourceData()); }); it("returns all names as invalid when models list is empty", () => { @@ -816,7 +823,7 @@ describe("ModelDependencyTreeBuilder — full pipeline integration", () => { assets: [makeAsset("https://cdn.aglty.io/hero.jpg")], }); - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = makeBuilder(sourceData); const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.models.has("Post")).toBe(true); diff --git a/src/lib/pushers/guid-data-loader.ts b/src/lib/pushers/guid-data-loader.ts index dae17881..86dc958a 100644 --- a/src/lib/pushers/guid-data-loader.ts +++ b/src/lib/pushers/guid-data-loader.ts @@ -129,7 +129,7 @@ export class GuidDataLoader { // Apply model filtering if requested if (filterOptions) { - return await this.applyModelFiltering(guidEntities, filterOptions, locale); + return await this.applyModelFiltering(guidEntities, filterOptions, locale, state.targetGuid[0], state.sourceGuid[0]); } return guidEntities; @@ -141,7 +141,9 @@ export class GuidDataLoader { private async applyModelFiltering( guidEntities: GuidEntities, filterOptions: ModelFilterOptions, - locale: string + locale: string, + targetGuid: string, + sourceGuid: string ): Promise { // Determine which filtering mode to use let modelNames: string[] = []; @@ -180,7 +182,7 @@ export class GuidDataLoader { // Import and use ModelDependencyTreeBuilder with complete data const { ModelDependencyTreeBuilder } = await import("../models/model-dependency-tree-builder"); - const treeBuilder = new ModelDependencyTreeBuilder(useFullDependencyTree ? completeEntities! : guidEntities); + const treeBuilder = new ModelDependencyTreeBuilder(useFullDependencyTree ? completeEntities! : guidEntities, targetGuid, sourceGuid); // Validate that specified models exist const validation = treeBuilder.validateModels(modelNames, (completeEntities ?? guidEntities).models); diff --git a/src/lib/pushers/tests/guid-data-loader.test.ts b/src/lib/pushers/tests/guid-data-loader.test.ts index 7721ce26..e3633e3b 100644 --- a/src/lib/pushers/tests/guid-data-loader.test.ts +++ b/src/lib/pushers/tests/guid-data-loader.test.ts @@ -17,6 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); + // Model filtering builds a ModelDependencyTreeBuilder → AssetMapper, which needs guids. + state.sourceGuid = ["source-guid-u"]; + state.targetGuid = ["target-guid-u"]; jest.spyOn(console, "log").mockImplementation(() => {}); jest.spyOn(console, "warn").mockImplementation(() => {}); jest.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/tests/setup.ts b/src/tests/setup.ts deleted file mode 100644 index fc6f4903..00000000 --- a/src/tests/setup.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Jest setup file - loads environment variables for testing - * - * Test env file location: src/tests/.env.test - * Copy .env.test.example to .env.test and fill in your test credentials - */ -import dotenv from "dotenv"; -import path from "path"; - -// Load .env.test from the tests folder (not project root) -const envPath = path.resolve(__dirname, ".env"); -const result = dotenv.config({ path: envPath }); - -if (result.error) { - console.warn(` -⚠️ Test environment file not found: ${envPath} - Copy src/tests/.env.test.example to src/tests/.env - and fill in your test credentials. -`); -} diff --git a/src/tests/sync/helpers/dependency-filter.ts b/src/tests/sync/helpers/dependency-filter.ts index e548769a..c0ce6b2c 100644 --- a/src/tests/sync/helpers/dependency-filter.ts +++ b/src/tests/sync/helpers/dependency-filter.ts @@ -33,7 +33,7 @@ export function applyModelsWithDepsFilter(opts: { lists: [], }; - const builder = new ModelDependencyTreeBuilder(sourceData); + const builder = new ModelDependencyTreeBuilder(sourceData, opts.sourceGuid, opts.sourceGuid); const validation = builder.validateModels(opts.modelsWithDeps, models); if (validation.invalid.length > 0) {