diff --git a/src/lib/content/content-field-mapper.ts b/src/lib/content/content-field-mapper.ts index a9f821a..9ffea9c 100644 --- a/src/lib/content/content-field-mapper.ts +++ b/src/lib/content/content-field-mapper.ts @@ -100,7 +100,7 @@ export class ContentFieldMapper { errors += contentMappingResult.errors; } // Handle URL fields with potential asset references - else if (typeof fieldValue === "string" && fieldValue.includes("cdn.aglty.io")) { + else if (typeof fieldValue === "string" && this.isAssetUrl(fieldValue, context)) { const urlMappingResult = this.mapAssetUrlString(fieldValue, context); mappedValue = urlMappingResult.mappedValue; warnings += urlMappingResult.warnings; @@ -129,6 +129,19 @@ export class ContentFieldMapper { return { mappedValue, warnings, errors }; } + /** + * Domain check for asset URL strings. Matches: + * - any Agility-managed CDN subdomain (cdn.aglty.io, cdn-usa2.aglty.io, *.agilitycms.com, etc.) + * - any URL whose prefix matches a container URL loaded into the asset mapper + */ + private isAssetUrl(value: string, context?: ContentFieldMappingContext): boolean { + return ( + value.includes(".aglty.io") || + value.includes(".agilitycms.com") || + context?.assetMapper?.isKnownAssetUrl(value) === true + ); + } + private isAssetAttachmentField(fieldValue: any): boolean { if (!fieldValue || typeof fieldValue !== "object") return false; diff --git a/src/lib/content/content-field-validation.ts b/src/lib/content/content-field-validation.ts index 80ba84e..1c28740 100644 --- a/src/lib/content/content-field-validation.ts +++ b/src/lib/content/content-field-validation.ts @@ -210,7 +210,7 @@ export class ContentFieldValidator { }; // Validate asset URLs - if (fieldValue.includes("cdn.aglty.io")) { + if (this.isAssetUrl(fieldValue, options)) { if (!this.isValidAssetUrl(fieldValue)) { result.errors.push(`Invalid asset URL format in field ${fieldKey}: ${fieldValue}`); result.isValid = false; @@ -286,12 +286,29 @@ export class ContentFieldValidator { } /** - * Validate asset URL format + * 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.hostname.includes("cdn.aglty.io") && urlObj.pathname.length > 1; + return urlObj.pathname.length > 1; } catch { return false; } diff --git a/src/lib/content/tests/content-field-mapper.test.ts b/src/lib/content/tests/content-field-mapper.test.ts index 4851013..9dc7b6b 100644 --- a/src/lib/content/tests/content-field-mapper.test.ts +++ b/src/lib/content/tests/content-field-mapper.test.ts @@ -48,6 +48,7 @@ function makeAssetMapper(overrides: any = {}): any { return { getAssetMappingByMediaUrl: jest.fn().mockReturnValue(null), remapUrlByContainer: jest.fn().mockReturnValue(null), + isKnownAssetUrl: jest.fn().mockReturnValue(false), ...overrides, }; } @@ -254,6 +255,83 @@ describe("ContentFieldMapper.mapContentFields", () => { const result = mapper.mapContentFields(fields, context); expect(result.mappedFields.heroUrl).toBe("https://cdn.aglty.io/tgt/assets/img.jpg"); }); + + it("detects 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 assetMapper = makeAssetMapper({ + getAssetMappingByMediaUrl: jest.fn().mockReturnValue({ sourceUrl: "different", targetUrl: "x" }), + remapUrlByContainer: jest.fn().mockReturnValue("https://cdn-usa2.aglty.io/tgt/file.json"), + }); + const context = { referenceMapper: makeReferenceMapper(), assetMapper }; + const fields = { config: "https://cdn-usa2.aglty.io/src/mobile/config.json" }; + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields.config).toBe("https://cdn-usa2.aglty.io/tgt/file.json"); + }); + + it("detects a *.agilitycms.com subdomain as an asset URL", () => { + const assetMapper = makeAssetMapper({ + getAssetMappingByMediaUrl: jest.fn().mockReturnValue({ sourceUrl: "different", targetUrl: "x" }), + remapUrlByContainer: jest.fn().mockReturnValue("https://cdndev.agilitycms.com/tgt/file.jpg"), + }); + const context = { referenceMapper: makeReferenceMapper(), assetMapper }; + const fields = { hero: "https://cdndev.agilitycms.com/src/folder/photo.jpg" }; + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields.hero).toBe("https://cdndev.agilitycms.com/tgt/file.jpg"); + }); + + it("detects a custom CDN URL via assetMapper.isKnownAssetUrl()", () => { + // Customer using a custom CDN host (cdn.ilotteryservices.com). The string + // does NOT include .aglty.io/.agilitycms.com, so detection falls through + // to the asset mapper, which recognizes it from its loaded container URLs. + const assetMapper = makeAssetMapper({ + isKnownAssetUrl: jest.fn().mockReturnValue(true), + getAssetMappingByMediaUrl: jest.fn().mockReturnValue({ sourceUrl: "different", targetUrl: "x" }), + remapUrlByContainer: jest.fn().mockReturnValue("https://cdn.ilotteryservices.com/0e9b1234/mobile/file.json"), + }); + const context = { referenceMapper: makeReferenceMapper(), assetMapper }; + const fields = { config: "https://cdn.ilotteryservices.com/8f5ad099/mobile/file.json" }; + const result = mapper.mapContentFields(fields, context); + expect(assetMapper.isKnownAssetUrl).toHaveBeenCalledWith(fields.config); + expect(result.mappedFields.config).toBe("https://cdn.ilotteryservices.com/0e9b1234/mobile/file.json"); + }); + + it("leaves an unrecognized string field unchanged", () => { + const assetMapper = makeAssetMapper(); // isKnownAssetUrl returns false by default + const context = { referenceMapper: makeReferenceMapper(), assetMapper }; + const fields = { description: "Just a regular text value, not a URL" }; + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields.description).toBe("Just a regular text value, not a URL"); + // Non-asset string should not trigger the asset-URL mapping path + expect(assetMapper.getAssetMappingByMediaUrl).not.toHaveBeenCalled(); + }); + }); + + describe("URL link object fields (href/target/text)", () => { + it("remaps the href of an Agility URL field that points at a source asset URL", () => { + // Agility's "URL" field type produces an object of shape { href, target, text }. + // It does NOT have a `.url` key, so it is not caught by isAssetAttachmentField. + // The href value pointing at a source CDN should be remapped to the target CDN. + const assetMapper = makeAssetMapper({ + getAssetMappingByMediaUrl: jest + .fn() + .mockReturnValue({ + sourceUrl: "https://cdn-eu.aglty.io/jules-eu-a/pikachu.png", + targetUrl: "https://cdn-aus.aglty.io/jules-aus-a/pikachu.png", + }), + }); + const context = { referenceMapper: makeReferenceMapper(), assetMapper }; + const fields = { + url: { + href: "https://cdn-eu.aglty.io/jules-eu-a/pikachu.png", + target: "", + text: "", + }, + }; + const result = mapper.mapContentFields(fields, context); + expect(result.mappedFields.url.href).toBe("https://cdn-aus.aglty.io/jules-aus-a/pikachu.png"); + expect(result.mappedFields.url.target).toBe(""); + expect(result.mappedFields.url.text).toBe(""); + }); }); describe("nested object fields", () => { diff --git a/src/lib/content/tests/content-field-validation.test.ts b/src/lib/content/tests/content-field-validation.test.ts index 0253774..a52c2a9 100644 --- a/src/lib/content/tests/content-field-validation.test.ts +++ b/src/lib/content/tests/content-field-validation.test.ts @@ -148,6 +148,45 @@ describe("ContentFieldValidator.validateContentFields", () => { 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", () => { diff --git a/src/lib/mappers/asset-mapper.ts b/src/lib/mappers/asset-mapper.ts index 3aca31e..2007a27 100644 --- a/src/lib/mappers/asset-mapper.ts +++ b/src/lib/mappers/asset-mapper.ts @@ -98,6 +98,26 @@ 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. + */ + isKnownAssetUrl(url: string): boolean { + if (!url || typeof url !== "string") return false; + + return this.mappings.some((mapping) => { + const knownContainerUrls = [ + mapping.sourceContainerEdgeUrl, + mapping.sourceContainerOriginUrl, + mapping.targetContainerEdgeUrl, + mapping.targetContainerOriginUrl, + ].filter((containerUrl): containerUrl is string => typeof containerUrl === "string"); + + return knownContainerUrls.some((containerUrl) => url.startsWith(containerUrl)); + }); + } + 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 6a044e5..d67a8a0 100644 --- a/src/lib/mappers/tests/asset-mapper.test.ts +++ b/src/lib/mappers/tests/asset-mapper.test.ts @@ -208,6 +208,125 @@ describe("AssetMapper.remapUrlByContainer", () => { const result = mapper.remapUrlByContainer("https://completely-different.io/file.jpg", "source"); expect(result).toBeNull(); }); + + it("handles a customer using a custom CDN domain (cdn.ilotteryservices.com) end-to-end", () => { + // custom configured domain cdn host + const mapper = makeMapper(); + const src = makeAsset({ + mediaID: 1, + edgeUrl: "https://cdn.ilotteryservices.com/8f5ad099/mobile/configuration/wizard.json", + containerEdgeUrl: "https://cdn.ilotteryservices.com/8f5ad099", + containerOriginUrl: "https://origin.ilotteryservices.com/8f5ad099", + }); + const tgt = makeAsset({ + mediaID: 2, + edgeUrl: "https://cdn.ilotteryservices.com/0e9b1234/mobile/configuration/wizard.json", + containerEdgeUrl: "https://cdn.ilotteryservices.com/0e9b1234", + containerOriginUrl: "https://origin.ilotteryservices.com/0e9b1234", + }); + mapper.addMapping(src, tgt); + + // Edge URL swap + expect( + mapper.remapUrlByContainer("https://cdn.ilotteryservices.com/8f5ad099/draw-games/picks.json", "source") + ).toBe("https://cdn.ilotteryservices.com/0e9b1234/draw-games/picks.json"); + + // Origin URL swap + expect(mapper.remapUrlByContainer("https://origin.ilotteryservices.com/8f5ad099/folder/asset.png", "source")).toBe( + "https://origin.ilotteryservices.com/0e9b1234/folder/asset.png" + ); + + // Detection: source-side URL is recognized + expect(mapper.isKnownAssetUrl("https://cdn.ilotteryservices.com/8f5ad099/anything/at/all.json")).toBe(true); + expect(mapper.isKnownAssetUrl("https://cdn.ilotteryservices.com/0e9b1234/anything/at/all.json")).toBe(true); + + expect(mapper.isKnownAssetUrl("https://cdn.competitor.com/8f5ad099/file.jpg")).toBe(false); + expect(mapper.isKnownAssetUrl("https://cdn.ilotteryservices.com/zzzzzzzz/file.jpg")).toBe(false); + }); +}); + +// ─── isKnownAssetUrl ────────────────────────────────────────────────────────── + +describe("AssetMapper.isKnownAssetUrl", () => { + it("returns false for empty or non-string input", () => { + const mapper = makeMapper(); + expect(mapper.isKnownAssetUrl("")).toBe(false); + expect(mapper.isKnownAssetUrl(null as any)).toBe(false); + expect(mapper.isKnownAssetUrl(undefined as any)).toBe(false); + expect(mapper.isKnownAssetUrl(123 as any)).toBe(false); + }); + + it("returns false when no mappings exist", () => { + const mapper = makeMapper(); + expect(mapper.isKnownAssetUrl("https://cdn.aglty.io/anywhere/file.jpg")).toBe(false); + }); + + it("returns true when URL starts with a source container edge URL", () => { + const mapper = makeMapper(); + const src = makeAsset({ mediaID: 1, containerEdgeUrl: "https://cdn-usa2.aglty.io/brightstar-qa" }); + const tgt = makeAsset({ mediaID: 2, containerEdgeUrl: "https://cdn-usa2.aglty.io/brightstar-prod" }); + mapper.addMapping(src, tgt); + expect(mapper.isKnownAssetUrl("https://cdn-usa2.aglty.io/brightstar-qa/mobile/file.json")).toBe(true); + }); + + it("returns true when URL starts with a source container origin URL", () => { + const mapper = makeMapper(); + const src = makeAsset({ mediaID: 1, containerOriginUrl: "https://mediadev.agilitycms.com/aaaaaaaa" }); + const tgt = makeAsset({ mediaID: 2, containerOriginUrl: "https://mediadev.agilitycms.com/bbbbbbbb" }); + mapper.addMapping(src, tgt); + expect(mapper.isKnownAssetUrl("https://mediadev.agilitycms.com/aaaaaaaa/folder/asset.png")).toBe(true); + }); + + it("returns true when URL starts with a target container URL", () => { + const mapper = makeMapper(); + const src = makeAsset({ mediaID: 1, containerEdgeUrl: "https://cdn.aglty.io/src" }); + const tgt = makeAsset({ mediaID: 2, containerEdgeUrl: "https://cdn.aglty.io/tgt" }); + mapper.addMapping(src, tgt); + expect(mapper.isKnownAssetUrl("https://cdn.aglty.io/tgt/some/path.jpg")).toBe(true); + }); + + it("recognizes URLs on a custom CDN host (PROD-1505 scenario)", () => { + // Customer using a custom CDN domain — the container URL the CMS returns + // points to their host, not aglty.io. isKnownAssetUrl should still match. + const mapper = makeMapper(); + const src = makeAsset({ + mediaID: 1, + containerEdgeUrl: "https://cdn.ilotteryservices.com/8f5ad099", + containerOriginUrl: "https://origin.ilotteryservices.com/8f5ad099", + }); + const tgt = makeAsset({ + mediaID: 2, + containerEdgeUrl: "https://cdn.ilotteryservices.com/0e9b1234", + containerOriginUrl: "https://origin.ilotteryservices.com/0e9b1234", + }); + mapper.addMapping(src, tgt); + expect(mapper.isKnownAssetUrl("https://cdn.ilotteryservices.com/8f5ad099/mobile/config.json")).toBe(true); + expect(mapper.isKnownAssetUrl("https://origin.ilotteryservices.com/8f5ad099/folder/file.png")).toBe(true); + }); + + it("returns false for a URL that does not match any container URL", () => { + const mapper = makeMapper(); + const src = makeAsset({ mediaID: 1, containerEdgeUrl: "https://cdn.aglty.io/src" }); + const tgt = makeAsset({ mediaID: 2, containerEdgeUrl: "https://cdn.aglty.io/tgt" }); + mapper.addMapping(src, tgt); + expect(mapper.isKnownAssetUrl("https://unrelated.example.com/file.jpg")).toBe(false); + }); + + it("ignores mappings whose container URLs are undefined", () => { + const mapper = makeMapper(); + const src = makeAsset({ + mediaID: 1, + containerEdgeUrl: undefined as any, + containerOriginUrl: undefined as any, + }); + const tgt = makeAsset({ + mediaID: 2, + containerEdgeUrl: undefined as any, + containerOriginUrl: undefined as any, + }); + mapper.addMapping(src, tgt); + expect(mapper.isKnownAssetUrl("https://cdn.aglty.io/anything.jpg")).toBe(false); + }); }); // ─── addMapping / updateMapping ───────────────────────────────────────────────