From dcb138cecee4d4c2e74c789c83df1f1d6df7616e Mon Sep 17 00:00:00 2001 From: Jules Exel Date: Tue, 14 Jul 2026 16:33:09 -0400 Subject: [PATCH] PROD-1506: create/update URL redirections on sync/push with ID mappings URL redirections were downloaded on pull (via the Content Sync SDK) but never pushed to the target. Add a full push path using the new Management API batch endpoint POST /api/v1/instance/{guid}/url-redirections (up to 250 per request, one transaction per request; items without a urlRedirectionID are created, items with one are updated). - New "UrlRedirections" element, included in the default --elements list and registered as a guid-level push operation. Runs after Containers and BEFORE Templates: the template pusher deliberately throws on mapping inconsistencies, which aborts the rest of the guid-level loop, and dependency-free redirections must not be collateral damage of that stop. - Pusher diffs pulled source vs target items directly (redirections carry no modifiedOn): mapped+identical -> skip, mapped+different -> update, same origin URL without mapping -> map-on-adopt, otherwise -> create. A mapped redirect deleted on the target is re-created. Deletes do not propagate (consistent with the other pushers). - Creates and updates are sent together in batches of 250; the per-item response indexes are used to write sourceUrlRedirectionID -> targetUrlRedirectionID rows to a new central mapping file (mappings/{source}-{target}/urlredirections/mappings.json). API-side skips (validation/origin collisions) are logged with their reason. A failed batch marks only that batch failed and continues. - Full --preflight support under a "URL Redirections" phase; mapping writes stay behind the existing preflight hard guard. - New urlRedirection log namespace; README and --help updated. - Tests: 47 new tests across getter, mapper, API caller, and pusher. Co-Authored-By: Claude Fable 5 --- README.md | 4 +- src/core/logs.ts | 25 ++ src/core/state.ts | 4 +- src/core/system-args.ts | 4 +- .../filesystem/get-url-redirections.ts | 17 + .../tests/get-url-redirections.test.ts | 76 ++++ .../tests/url-redirection-mapper.test.ts | 159 ++++++++ src/lib/mappers/url-redirection-mapper.ts | 70 ++++ src/lib/pushers/guid-data-loader.ts | 20 +- src/lib/pushers/orchestrate-pushers.ts | 6 +- src/lib/pushers/push-operations-config.ts | 12 +- .../pushers/tests/guid-data-loader.test.ts | 9 + .../tests/push-operations-config.test.ts | 12 +- .../pushers/tests/url-redirection-api.test.ts | 175 ++++++++ .../tests/url-redirection-pusher.test.ts | 376 ++++++++++++++++++ src/lib/pushers/url-redirection-api.ts | 59 +++ src/lib/pushers/url-redirection-pusher.ts | 192 +++++++++ src/types/urlRedirection.ts | 57 +++ 18 files changed, 1265 insertions(+), 12 deletions(-) create mode 100644 src/lib/getters/filesystem/get-url-redirections.ts create mode 100644 src/lib/getters/filesystem/tests/get-url-redirections.test.ts create mode 100644 src/lib/mappers/tests/url-redirection-mapper.test.ts create mode 100644 src/lib/mappers/url-redirection-mapper.ts create mode 100644 src/lib/pushers/tests/url-redirection-api.test.ts create mode 100644 src/lib/pushers/tests/url-redirection-pusher.test.ts create mode 100644 src/lib/pushers/url-redirection-api.ts create mode 100644 src/lib/pushers/url-redirection-pusher.ts create mode 100644 src/types/urlRedirection.ts diff --git a/README.md b/README.md index 3fc787d4..5cf73e39 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ agility pull [options] | Option | Type | Default | Description | | ------------ | ------ | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `--elements` | string | `Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps` | Comma-separated list of elements to process | +| `--elements` | string | `Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps,UrlRedirections` | Comma-separated list of elements to process | | `--models` | string | _(empty)_ | Comma-separated list of model reference names to sync (only syncs the specified models) | **Authentication & Environment Options:** @@ -92,7 +92,7 @@ agility sync [options] | Option | Type | Default | Description | | -------------------- | ------ | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--elements` | string | `Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps` | Comma-separated list of elements to process | +| `--elements` | string | `Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps,UrlRedirections` | Comma-separated list of elements to process | | `--models` | string | _(empty)_ | Comma-separated list of model reference names to sync (only syncs the specified models) | | `--models-with-deps` | string | _(empty)_ | Comma-separated list of model reference names to sync with their full dependency tree — includes dependent content, pages, templates, assets, galleries, and containers | | `--contentIDs` | string | _(empty)_ | Comma-separated list of target content IDs to process directly, bypassing the mappings lookup (e.g. `--contentIDs=121,1221`) | diff --git a/src/core/logs.ts b/src/core/logs.ts index e6704e16..cd4ff13e 100644 --- a/src/core/logs.ts +++ b/src/core/logs.ts @@ -81,6 +81,7 @@ export type EntityType = | "gallery" | "template" | "sitemap" + | "urlRedirection" | "auth" | "system" | "summary"; @@ -984,6 +985,30 @@ export class Logs { }, }; + // URL redirection logging methods + urlRedirection = { + created: (entity: any, details?: string, targetGuid?: string) => { + const itemName = entity?.originUrl || `URL Redirection ${entity?.id || "Unknown"}`; + this.logDataElement("urlRedirection", "created", "success", itemName, targetGuid, details); + }, + + updated: (entity: any, details?: string, targetGuid?: string) => { + const itemName = entity?.originUrl || `URL Redirection ${entity?.id || "Unknown"}`; + this.logDataElement("urlRedirection", "updated", "success", itemName, targetGuid, details); + }, + + skipped: (entity: any, details?: string, targetGuid?: string) => { + const itemName = entity?.originUrl || `URL Redirection`; + this.logDataElement("urlRedirection", "skipped", "skipped", itemName, targetGuid || this.guid, details); + }, + + error: (entity: any, apiError: any, targetGuid?: string) => { + const itemName = entity?.originUrl || `URL Redirection ${entity?.id || "Unknown"}`; + const errorDetails = apiError?.message || apiError || "Unknown error"; + this.logDataElement("urlRedirection", "failed", "failed", itemName, targetGuid || this.guid, errorDetails); + }, + }; + // Sitemap logging methods sitemap = { downloaded: (entity: any, details?: string) => { diff --git a/src/core/state.ts b/src/core/state.ts index 793f2c42..410af6c5 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -107,7 +107,7 @@ export const state: State = { apiKeys: [], channel: "website", preview: true, - elements: "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps", + elements: "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps,UrlRedirections", // File system rootPath: "agility-files", @@ -388,7 +388,7 @@ export function resetState() { state.apiKeys = []; state.channel = "website"; state.preview = true; - state.elements = "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps"; + state.elements = "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps,UrlRedirections"; // File system state.rootPath = "agility-files"; diff --git a/src/core/system-args.ts b/src/core/system-args.ts index dea2ede8..a3bebe1f 100644 --- a/src/core/system-args.ts +++ b/src/core/system-args.ts @@ -53,10 +53,10 @@ export const systemArgs = { }, elements: { describe: - "Comma-separated list of elements to process (Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps)", + "Comma-separated list of elements to process (Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps,UrlRedirections)", demandOption: false, type: "string" as const, - default: "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps", + default: "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps,UrlRedirections", }, // **NEW: Selective Model-Based Sync Parameter (Task 103)** diff --git a/src/lib/getters/filesystem/get-url-redirections.ts b/src/lib/getters/filesystem/get-url-redirections.ts new file mode 100644 index 00000000..cfb26592 --- /dev/null +++ b/src/lib/getters/filesystem/get-url-redirections.ts @@ -0,0 +1,17 @@ +import { fileOperations } from "../../../core"; +import { UrlRedirectionSyncItem, UrlRedirectionSyncFile } from "../../../types/urlRedirection"; + +/** + * Get URL redirections from the filesystem without side effects. + * The Content Sync SDK stores them per-locale at urlredirections/urlredirections.json, + * so the fileOps instance must be locale-level. The set is instance-wide (the same for + * every locale), so reading one locale's file is sufficient. + * Pure function - no filesystem operations, delegates to fileOperations + */ +export function getUrlRedirectionsFromFileSystem(fileOps: fileOperations): UrlRedirectionSyncItem[] { + const data: UrlRedirectionSyncFile | null = fileOps.readJsonFile("urlredirections/urlredirections.json"); + if (!data || !Array.isArray(data.items)) { + return []; + } + return data.items; +} diff --git a/src/lib/getters/filesystem/tests/get-url-redirections.test.ts b/src/lib/getters/filesystem/tests/get-url-redirections.test.ts new file mode 100644 index 00000000..5433e18b --- /dev/null +++ b/src/lib/getters/filesystem/tests/get-url-redirections.test.ts @@ -0,0 +1,76 @@ +import { resetState } from "core/state"; +import { getUrlRedirectionsFromFileSystem } from "../get-url-redirections"; + +beforeEach(() => { + resetState(); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function makeFileOps(readJsonFileImpl: (filePath: string) => any): any { + return { readJsonFile: jest.fn().mockImplementation(readJsonFileImpl) }; +} + +// ─── getUrlRedirectionsFromFileSystem ───────────────────────────────────────── + +describe("getUrlRedirectionsFromFileSystem", () => { + it("returns the items array when data is well-formed", () => { + const items = [ + { id: 1, originUrl: "/old", destinationUrl: "/new", statusCode: 301 }, + { id: 2, originUrl: "/gone", destinationUrl: "/here", statusCode: 302 }, + ]; + const fileOps = makeFileOps(() => ({ items, isUpToDate: true, lastAccessDate: "" })); + + const result = getUrlRedirectionsFromFileSystem(fileOps); + + expect(result).toEqual(items); + expect(fileOps.readJsonFile).toHaveBeenCalledWith("urlredirections/urlredirections.json"); + }); + + it("returns an empty array when readJsonFile returns null", () => { + const fileOps = makeFileOps(() => null); + expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]); + }); + + it("returns an empty array when the data object has no items property", () => { + const fileOps = makeFileOps(() => ({ isUpToDate: true })); + expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]); + }); + + it("returns an empty array when items is not an array (string)", () => { + const fileOps = makeFileOps(() => ({ items: "not-an-array" })); + expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]); + }); + + it("returns an empty array when items is not an array (object)", () => { + const fileOps = makeFileOps(() => ({ items: { 0: { id: 1 } } })); + expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]); + }); + + it("returns an empty array when items is null", () => { + const fileOps = makeFileOps(() => ({ items: null })); + expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]); + }); + + it("returns an empty array when readJsonFile returns undefined", () => { + const fileOps = makeFileOps(() => undefined); + expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]); + }); + + it("returns an empty array when the items array is empty", () => { + const fileOps = makeFileOps(() => ({ items: [] })); + expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]); + }); + + it("always reads the fixed path regardless of arguments", () => { + const fileOps = makeFileOps(() => null); + getUrlRedirectionsFromFileSystem(fileOps); + expect(fileOps.readJsonFile).toHaveBeenCalledTimes(1); + expect(fileOps.readJsonFile).toHaveBeenCalledWith("urlredirections/urlredirections.json"); + }); +}); diff --git a/src/lib/mappers/tests/url-redirection-mapper.test.ts b/src/lib/mappers/tests/url-redirection-mapper.test.ts new file mode 100644 index 00000000..593d9830 --- /dev/null +++ b/src/lib/mappers/tests/url-redirection-mapper.test.ts @@ -0,0 +1,159 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { UrlRedirectionMapper } from "../url-redirection-mapper"; + +let tmpDir: string; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-url-redir-mapper-")); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +let testCounter = 0; +let currentSrc: string; +let currentTgt: string; + +function makeMapper(): UrlRedirectionMapper { + testCounter++; + currentSrc = `src-redir-${testCounter}`; + currentTgt = `tgt-redir-${testCounter}`; + return new UrlRedirectionMapper(currentSrc, currentTgt); +} + +beforeEach(() => { + resetState(); + setState({ rootPath: tmpDir }); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// ─── constructor ────────────────────────────────────────────────────────────── + +describe("UrlRedirectionMapper constructor", () => { + it("constructs without throwing", () => { + expect(() => makeMapper()).not.toThrow(); + }); +}); + +// ─── addMapping — new entry ─────────────────────────────────────────────────── + +describe("UrlRedirectionMapper.addMapping — new entry", () => { + it("adds a new mapping and persists it", () => { + const mapper = makeMapper(); + mapper.addMapping(10, 20, "/old-path"); + + const found = mapper.getMapping(10, "source"); + expect(found).not.toBeNull(); + expect(found!.sourceUrlRedirectionID).toBe(10); + expect(found!.targetUrlRedirectionID).toBe(20); + expect(found!.originUrl).toBe("/old-path"); + expect(found!.sourceGuid).toBe(currentSrc); + expect(found!.targetGuid).toBe(currentTgt); + }); + + it("stores multiple distinct mappings", () => { + const mapper = makeMapper(); + mapper.addMapping(1, 100, "/a"); + mapper.addMapping(2, 200, "/b"); + + expect(mapper.getMapping(1, "source")).not.toBeNull(); + expect(mapper.getMapping(2, "source")).not.toBeNull(); + }); + + it("does not create a duplicate when the same sourceUrlRedirectionID is added twice", () => { + const mapper = makeMapper(); + mapper.addMapping(5, 50, "/url-1"); + mapper.addMapping(5, 99, "/url-1-updated"); + + // There must still be exactly one mapping for source ID 5. + const bySource = mapper.getMapping(5, "source"); + expect(bySource).not.toBeNull(); + expect(bySource!.targetUrlRedirectionID).toBe(99); + + // The old target ID (50) must not appear as a distinct entry. + const byOldTarget = mapper.getMapping(50, "target"); + expect(byOldTarget).toBeNull(); + }); + + it("updates targetUrlRedirectionID in place when sourceUrlRedirectionID already exists", () => { + const mapper = makeMapper(); + mapper.addMapping(7, 70, "/page"); + mapper.addMapping(7, 77, "/page-new"); + + const m = mapper.getMapping(7, "source")!; + expect(m.targetUrlRedirectionID).toBe(77); + expect(m.originUrl).toBe("/page-new"); + }); +}); + +// ─── getMapping — by source ─────────────────────────────────────────────────── + +describe("UrlRedirectionMapper.getMapping — by source", () => { + it("returns null when no mappings exist", () => { + const mapper = makeMapper(); + expect(mapper.getMapping(999, "source")).toBeNull(); + }); + + it("returns the mapping when found by sourceUrlRedirectionID", () => { + const mapper = makeMapper(); + mapper.addMapping(11, 22, "/test"); + const m = mapper.getMapping(11, "source"); + expect(m).not.toBeNull(); + expect(m!.sourceUrlRedirectionID).toBe(11); + }); + + it("returns null for an ID that was not added", () => { + const mapper = makeMapper(); + mapper.addMapping(11, 22, "/test"); + expect(mapper.getMapping(99, "source")).toBeNull(); + }); +}); + +// ─── getMapping — by target ─────────────────────────────────────────────────── + +describe("UrlRedirectionMapper.getMapping — by target", () => { + it("returns null when no mappings exist", () => { + const mapper = makeMapper(); + expect(mapper.getMapping(999, "target")).toBeNull(); + }); + + it("returns the mapping when found by targetUrlRedirectionID", () => { + const mapper = makeMapper(); + mapper.addMapping(33, 44, "/target-test"); + const m = mapper.getMapping(44, "target"); + expect(m).not.toBeNull(); + expect(m!.targetUrlRedirectionID).toBe(44); + }); + + it("returns null for a target ID that was not added", () => { + const mapper = makeMapper(); + mapper.addMapping(33, 44, "/target-test"); + expect(mapper.getMapping(55, "target")).toBeNull(); + }); +}); + +// ─── persistence (saveMappingFile / loadMapping) ────────────────────────────── + +describe("UrlRedirectionMapper — mapping persistence", () => { + it("persists mappings so a new mapper instance can reload them", () => { + const src = `persist-src-${testCounter + 100}`; + const tgt = `persist-tgt-${testCounter + 100}`; + const mapper1 = new UrlRedirectionMapper(src, tgt); + mapper1.addMapping(60, 600, "/persist-me"); + + const mapper2 = new UrlRedirectionMapper(src, tgt); + const found = mapper2.getMapping(60, "source"); + expect(found).not.toBeNull(); + expect(found!.targetUrlRedirectionID).toBe(600); + }); +}); diff --git a/src/lib/mappers/url-redirection-mapper.ts b/src/lib/mappers/url-redirection-mapper.ts new file mode 100644 index 00000000..e9b987d7 --- /dev/null +++ b/src/lib/mappers/url-redirection-mapper.ts @@ -0,0 +1,70 @@ +import { fileOperations } from "../../core"; + +export interface UrlRedirectionMapping { + sourceGuid: string; + targetGuid: string; + sourceUrlRedirectionID: number; + targetUrlRedirectionID: number; + /** The source origin URL at the time of mapping — kept for readability/debugging of the mapping file. */ + originUrl: string; +} + +/** + * Maps source URL redirection IDs to target IDs across syncs. + * + * Unlike galleries/models, redirections carry no modifiedOn timestamp in the pulled + * data, so this mapper only tracks the ID pairing. Change detection is done by the + * pusher via a direct field comparison of the pulled source and target items. + */ +export class UrlRedirectionMapper { + private fileOps: fileOperations; + private sourceGuid: string; + private targetGuid: string; + private mappings: UrlRedirectionMapping[]; + private directory: string; + + constructor(sourceGuid: string, targetGuid: string) { + this.sourceGuid = sourceGuid; + this.targetGuid = targetGuid; + this.directory = "urlredirections"; + this.fileOps = new fileOperations(targetGuid); + this.mappings = this.loadMapping(); + } + + getMapping(urlRedirectionID: number, type: "source" | "target"): UrlRedirectionMapping | null { + const mapping = this.mappings.find((m: UrlRedirectionMapping) => + type === "source" ? m.sourceUrlRedirectionID === urlRedirectionID : m.targetUrlRedirectionID === urlRedirectionID + ); + if (!mapping) return null; + return mapping; + } + + addMapping(sourceUrlRedirectionID: number, targetUrlRedirectionID: number, originUrl: string) { + const existing = this.getMapping(sourceUrlRedirectionID, "source"); + + if (existing) { + existing.sourceGuid = this.sourceGuid; + existing.targetGuid = this.targetGuid; + existing.targetUrlRedirectionID = targetUrlRedirectionID; + existing.originUrl = originUrl; + } else { + this.mappings.push({ + sourceGuid: this.sourceGuid, + targetGuid: this.targetGuid, + sourceUrlRedirectionID, + targetUrlRedirectionID, + originUrl, + }); + } + + this.saveMapping(); + } + + loadMapping(): UrlRedirectionMapping[] { + return this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid); + } + + saveMapping() { + this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid); + } +} diff --git a/src/lib/pushers/guid-data-loader.ts b/src/lib/pushers/guid-data-loader.ts index 86dc958a..3969decc 100644 --- a/src/lib/pushers/guid-data-loader.ts +++ b/src/lib/pushers/guid-data-loader.ts @@ -35,6 +35,7 @@ export interface GuidEntities { content: any[]; assets: any[]; galleries: any[]; + urlRedirections: any[]; } export class GuidDataLoader { @@ -62,7 +63,7 @@ export class GuidDataLoader { // Element filtering happens at the processing level, not the loading level const needsCompleteData = state.isSync || state.modelsWithDeps; const elements = needsCompleteData - ? ["Galleries", "Assets", "Models", "Containers", "Content", "Templates", "Pages", "Sitemaps"] + ? ["Galleries", "Assets", "Models", "Containers", "Content", "Templates", "Pages", "Sitemaps", "UrlRedirections"] : state.elements.split(","); const guidFileOps = new fileOperations(this.guid); @@ -78,6 +79,7 @@ export class GuidDataLoader { content: [], pages: [], templates: [], + urlRedirections: [], }; // Load different entity types using pure getters for consistent architecture @@ -127,6 +129,13 @@ export class GuidDataLoader { guidEntities.pages = Array.isArray(pages) ? pages : []; } + if (elements.includes("UrlRedirections")) { + const { getUrlRedirectionsFromFileSystem } = await import("../getters/filesystem/get-url-redirections"); + // Redirections are instance-wide but stored per-locale by the sync SDK — one locale's file is the full set + const urlRedirections = getUrlRedirectionsFromFileSystem(localeFileOps); + guidEntities.urlRedirections = Array.isArray(urlRedirections) ? urlRedirections : []; + } + // Apply model filtering if requested if (filterOptions) { return await this.applyModelFiltering(guidEntities, filterOptions, locale, state.targetGuid[0], state.sourceGuid[0]); @@ -315,6 +324,8 @@ export class GuidDataLoader { pages: filteredPages, assets: filteredAssets, galleries: filteredGalleries, + // Redirections are never part of a model dependency tree + urlRedirections: [], }; } @@ -333,6 +344,7 @@ export class GuidDataLoader { pages: [], assets: [], galleries: [], + urlRedirections: [], }; } @@ -356,6 +368,7 @@ export class GuidDataLoader { content: guidEntities.content.length, assets: guidEntities.assets.length, galleries: guidEntities.galleries.length, + urlRedirections: guidEntities.urlRedirections.length, }; } @@ -394,6 +407,7 @@ export class GuidDataLoader { content: [], pages: [], templates: [], + urlRedirections: [], }; // Load ALL entity types regardless of state.elements for complete dependency analysis @@ -429,6 +443,10 @@ export class GuidDataLoader { const pages = getPagesFromFileSystem(localeFileOps); guidEntities.pages = Array.isArray(pages) ? pages : []; + const { getUrlRedirectionsFromFileSystem } = await import("../getters/filesystem/get-url-redirections"); + const urlRedirections = getUrlRedirectionsFromFileSystem(localeFileOps); + guidEntities.urlRedirections = Array.isArray(urlRedirections) ? urlRedirections : []; + return guidEntities; } diff --git a/src/lib/pushers/orchestrate-pushers.ts b/src/lib/pushers/orchestrate-pushers.ts index 3462f8d5..3bb6400b 100644 --- a/src/lib/pushers/orchestrate-pushers.ts +++ b/src/lib/pushers/orchestrate-pushers.ts @@ -183,12 +183,16 @@ export class Pushers { // Models depend on nothing that follows, so hoisting them ahead of galleries/assets is // dependency-safe; the remaining relative order is unchanged (Containers→Content→Templates→ // Pages still follow Models, since each depends on Models being pushed first). - // ORDER: Models → Galleries → Assets → Containers → Content → Templates → Pages + // URL redirections depend on nothing, so they run BEFORE templates: the template pusher + // deliberately throws on mapping inconsistencies (rename protection), which aborts the rest + // of the guid-level loop — redirections must not be collateral damage of that stop. + // ORDER: Models → Galleries → Assets → Containers → URL Redirections → Content → Templates → Pages const pusherConfig = [ PUSH_OPERATIONS.models, PUSH_OPERATIONS.galleries, PUSH_OPERATIONS.assets, PUSH_OPERATIONS.containers, + PUSH_OPERATIONS.urlRedirections, PUSH_OPERATIONS.content, PUSH_OPERATIONS.templates, PUSH_OPERATIONS.pages, diff --git a/src/lib/pushers/push-operations-config.ts b/src/lib/pushers/push-operations-config.ts index 37001eed..f745af42 100644 --- a/src/lib/pushers/push-operations-config.ts +++ b/src/lib/pushers/push-operations-config.ts @@ -91,6 +91,16 @@ export const PUSH_OPERATIONS: Record = { dataKey: "pages", dependencies: ["Templates", "Models", "Containers", "Content", "Galleries", "Assets"], // Pages require Templates, Models, and Containers }, + urlRedirections: { + name: "pushUrlRedirections", + description: "Push URL redirections", + handler: async (sourceData, targetData) => { + const { pushUrlRedirections } = await import("./url-redirection-pusher"); + return await pushUrlRedirections(sourceData["urlRedirections"], targetData["urlRedirections"]); + }, + elements: ["UrlRedirections"], + dataKey: "urlRedirections", + }, }; export class PushOperationsRegistry { @@ -101,7 +111,7 @@ export class PushOperationsRegistry { const state = getState(); const elementList = state.elements ? state.elements.split(",") - : ["Galleries", "Assets", "Models", "Containers", "Content", "Templates", "Pages"]; + : ["Galleries", "Assets", "Models", "Containers", "Content", "Templates", "Pages", "UrlRedirections"]; // Resolve dependencies and update state const { resolvedElements, autoIncluded } = this.resolveDependencies(elementList); diff --git a/src/lib/pushers/tests/guid-data-loader.test.ts b/src/lib/pushers/tests/guid-data-loader.test.ts index e3633e3b..053ae2c8 100644 --- a/src/lib/pushers/tests/guid-data-loader.test.ts +++ b/src/lib/pushers/tests/guid-data-loader.test.ts @@ -70,6 +70,7 @@ describe("GuidDataLoader.hasNoContent", () => { content: [], assets: [], galleries: [], + urlRedirections: [], }; expect(loader.hasNoContent(entities)).toBe(true); }); @@ -85,6 +86,7 @@ describe("GuidDataLoader.hasNoContent", () => { content: [], assets: [], galleries: [], + urlRedirections: [], }; expect(loader.hasNoContent(entities)).toBe(false); }); @@ -100,6 +102,7 @@ describe("GuidDataLoader.hasNoContent", () => { content: [], assets: [], galleries: [], + urlRedirections: [], }; expect(loader.hasNoContent(entities)).toBe(false); }); @@ -115,6 +118,7 @@ describe("GuidDataLoader.hasNoContent", () => { content: [], assets: [{ mediaID: 1 }], galleries: [], + urlRedirections: [], }; expect(loader.hasNoContent(entities)).toBe(false); }); @@ -134,6 +138,7 @@ describe("GuidDataLoader.getEntityCounts", () => { content: [1], assets: [1, 2], galleries: [1, 2, 3], + urlRedirections: [1], }; const counts = loader.getEntityCounts(entities as any); @@ -145,6 +150,7 @@ describe("GuidDataLoader.getEntityCounts", () => { expect(counts.content).toBe(1); expect(counts.assets).toBe(2); expect(counts.galleries).toBe(3); + expect(counts.urlRedirections).toBe(1); }); it("returns all zeros for empty entities", () => { @@ -158,6 +164,7 @@ describe("GuidDataLoader.getEntityCounts", () => { content: [], assets: [], galleries: [], + urlRedirections: [], }; const counts = loader.getEntityCounts(entities); @@ -177,6 +184,7 @@ describe("GuidDataLoader.getEntityCounts", () => { content: [], assets: [], galleries: [], + urlRedirections: [], }; const counts = loader.getEntityCounts(entities); @@ -188,6 +196,7 @@ describe("GuidDataLoader.getEntityCounts", () => { expect(counts).toHaveProperty("content"); expect(counts).toHaveProperty("assets"); expect(counts).toHaveProperty("galleries"); + expect(counts).toHaveProperty("urlRedirections"); }); }); diff --git a/src/lib/pushers/tests/push-operations-config.test.ts b/src/lib/pushers/tests/push-operations-config.test.ts index 5798586f..0b59d177 100644 --- a/src/lib/pushers/tests/push-operations-config.test.ts +++ b/src/lib/pushers/tests/push-operations-config.test.ts @@ -24,9 +24,10 @@ describe("PUSH_OPERATIONS registry", () => { expect(keys).toContain("content"); expect(keys).toContain("templates"); expect(keys).toContain("pages"); + expect(keys).toContain("urlRedirections"); }); - it.each(["galleries", "assets", "models", "containers", "content", "templates", "pages"])( + it.each(["galleries", "assets", "models", "containers", "content", "templates", "pages", "urlRedirections"])( "%s operation has required fields", (key) => { const op = PUSH_OPERATIONS[key]; @@ -73,14 +74,19 @@ describe("PUSH_OPERATIONS registry", () => { expect(PUSH_OPERATIONS.pages.elements).toContain("Pages"); expect(PUSH_OPERATIONS.pages.dataKey).toBe("pages"); }); + + it("urlRedirections operation targets UrlRedirections element", () => { + expect(PUSH_OPERATIONS.urlRedirections.elements).toContain("UrlRedirections"); + expect(PUSH_OPERATIONS.urlRedirections.dataKey).toBe("urlRedirections"); + }); }); // ─── PushOperationsRegistry.getAllOperations ────────────────────────────────── describe("PushOperationsRegistry.getAllOperations", () => { - it("returns 7 operations total", () => { + it("returns 8 operations total", () => { const ops = PushOperationsRegistry.getAllOperations(); - expect(ops).toHaveLength(7); + expect(ops).toHaveLength(8); }); it("each operation has a handler function", () => { diff --git a/src/lib/pushers/tests/url-redirection-api.test.ts b/src/lib/pushers/tests/url-redirection-api.test.ts new file mode 100644 index 00000000..b39da30b --- /dev/null +++ b/src/lib/pushers/tests/url-redirection-api.test.ts @@ -0,0 +1,175 @@ +import { resetState, state } from "core/state"; +import { saveUrlRedirections, MAX_URL_REDIRECTION_BATCH_SIZE } from "../url-redirection-api"; + +// Mock core/auth so no keytar / network calls are made. +jest.mock("core/auth", () => { + return { + Auth: jest.fn().mockImplementation(() => ({ + getToken: jest.fn().mockResolvedValue("test-token"), + determineBaseUrl: jest.fn().mockReturnValue("https://mgmt.test"), + })), + }; +}); + +beforeEach(() => { + resetState(); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makePayload(urlRedirectionID = 0, originUrl = "/a", destinationUrl = "/b", httpCode = 301): any { + return { urlRedirectionID, originUrl, destinationUrl, httpCode }; +} + +function makeOkResponse(body: any): Response { + return { + ok: true, + status: 200, + statusText: "OK", + json: jest.fn().mockResolvedValue(body), + text: jest.fn().mockResolvedValue(JSON.stringify(body)), + } as unknown as Response; +} + +function makeErrorResponse(status: number, statusText: string, body = ""): Response { + return { + ok: false, + status, + statusText, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(body), + } as unknown as Response; +} + +// ─── MAX_URL_REDIRECTION_BATCH_SIZE guard ───────────────────────────────────── + +describe("saveUrlRedirections — MAX_URL_REDIRECTION_BATCH_SIZE guard", () => { + it("throws when payload length exceeds 250", async () => { + const payloads = Array.from({ length: 251 }, (_, i) => makePayload(0, `/url-${i}`)); + await expect(saveUrlRedirections("test-guid-u", payloads)).rejects.toThrow(/250/); + }); + + it("does not throw when payload is exactly 250", async () => { + const payloads = Array.from({ length: 250 }, (_, i) => makePayload(0, `/url-${i}`)); + global.fetch = jest.fn().mockResolvedValue( + makeOkResponse({ created: [], updated: [], skipped: [] }) + ) as any; + await expect(saveUrlRedirections("test-guid-u", payloads)).resolves.not.toThrow(); + }); + + it("MAX_URL_REDIRECTION_BATCH_SIZE is 250", () => { + expect(MAX_URL_REDIRECTION_BATCH_SIZE).toBe(250); + }); +}); + +// ─── non-ok response ────────────────────────────────────────────────────────── + +describe("saveUrlRedirections — non-ok HTTP response", () => { + it("throws an error containing status when response is not ok", async () => { + global.fetch = jest.fn().mockResolvedValue(makeErrorResponse(400, "Bad Request", "invalid payload")) as any; + await expect(saveUrlRedirections("test-guid-u", [makePayload()])).rejects.toThrow(/400/); + }); + + it("includes the response body in the error message when available", async () => { + global.fetch = jest + .fn() + .mockResolvedValue(makeErrorResponse(422, "Unprocessable Entity", "origin URL already exists")) as any; + await expect(saveUrlRedirections("test-guid-u", [makePayload()])).rejects.toThrow( + /origin URL already exists/ + ); + }); + + it("still throws when response.text() rejects (best-effort body)", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: jest.fn().mockRejectedValue(new Error("stream error")), + }) as any; + await expect(saveUrlRedirections("test-guid-u", [makePayload()])).rejects.toThrow(/500/); + }); +}); + +// ─── response normalization (missing arrays) ────────────────────────────────── + +describe("saveUrlRedirections — response normalization", () => { + it("defaults missing created/updated/skipped to empty arrays", async () => { + global.fetch = jest.fn().mockResolvedValue(makeOkResponse({})) as any; + const result = await saveUrlRedirections("test-guid-u", [makePayload()]); + expect(result.created).toEqual([]); + expect(result.updated).toEqual([]); + expect(result.skipped).toEqual([]); + }); + + it("defaults only missing fields when partial result is returned", async () => { + global.fetch = jest + .fn() + .mockResolvedValue(makeOkResponse({ created: [{ index: 0, urlRedirectionID: 5 }] })) as any; + const result = await saveUrlRedirections("test-guid-u", [makePayload()]); + expect(result.created).toHaveLength(1); + expect(result.updated).toEqual([]); + expect(result.skipped).toEqual([]); + }); + + it("returns null result normalized to empty arrays", async () => { + global.fetch = jest.fn().mockResolvedValue(makeOkResponse(null)) as any; + const result = await saveUrlRedirections("test-guid-u", [makePayload()]); + expect(result.created).toEqual([]); + expect(result.updated).toEqual([]); + expect(result.skipped).toEqual([]); + }); +}); + +// ─── correct URL, method, headers, and body ─────────────────────────────────── + +describe("saveUrlRedirections — fetch call shape", () => { + it("sends a POST to the correct URL with Authorization and Content-Type headers", async () => { + const mockFetch = jest + .fn() + .mockResolvedValue(makeOkResponse({ created: [], updated: [], skipped: [] })); + global.fetch = mockFetch as any; + + const guid = "test-guid-u"; + await saveUrlRedirections(guid, [makePayload(0, "/src", "/dst")]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe(`https://mgmt.test/api/v1/instance/${guid}/url-redirections`); + expect(init.method).toBe("POST"); + expect(init.headers["Authorization"]).toBe("Bearer test-token"); + expect(init.headers["Content-Type"]).toBe("application/json"); + }); + + it("serialises the payloads array as the request body", async () => { + const mockFetch = jest + .fn() + .mockResolvedValue(makeOkResponse({ created: [], updated: [], skipped: [] })); + global.fetch = mockFetch as any; + + const payloads = [makePayload(0, "/x", "/y", 302), makePayload(3, "/old", "/new", 301)]; + await saveUrlRedirections("test-guid-u", payloads); + + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(init.body)).toEqual(payloads); + }); + + it("uses state.baseUrl instead of determineBaseUrl when set", async () => { + state.baseUrl = "https://custom-base.example.com"; + const mockFetch = jest + .fn() + .mockResolvedValue(makeOkResponse({ created: [], updated: [], skipped: [] })); + global.fetch = mockFetch as any; + + await saveUrlRedirections("test-guid-u", [makePayload()]); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain("https://custom-base.example.com"); + }); +}); diff --git a/src/lib/pushers/tests/url-redirection-pusher.test.ts b/src/lib/pushers/tests/url-redirection-pusher.test.ts new file mode 100644 index 00000000..46dc2296 --- /dev/null +++ b/src/lib/pushers/tests/url-redirection-pusher.test.ts @@ -0,0 +1,376 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState, state, initializeGuidLogger } from "core/state"; +import { preflightReport } from "lib/preflight/preflight-report"; + +// Mock the API module — keep MAX_URL_REDIRECTION_BATCH_SIZE from the real module. +const { MAX_URL_REDIRECTION_BATCH_SIZE: REAL_BATCH_SIZE } = jest.requireActual("../url-redirection-api"); + +const mockSaveUrlRedirections = jest.fn(); + +jest.mock("../url-redirection-api", () => ({ + MAX_URL_REDIRECTION_BATCH_SIZE: REAL_BATCH_SIZE, + saveUrlRedirections: (...args: any[]) => mockSaveUrlRedirections(...args), +})); + +// Mock the UrlRedirectionMapper class so we can capture calls to getMapping / addMapping. +let mockGetMapping: jest.Mock; +let mockAddMapping: jest.Mock; + +jest.mock("lib/mappers/url-redirection-mapper", () => { + return { + UrlRedirectionMapper: jest.fn().mockImplementation(() => ({ + get getMapping() { + return mockGetMapping; + }, + get addMapping() { + return mockAddMapping; + }, + })), + }; +}); + +let tmpDir: string; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-redir-pusher-")); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +beforeEach(() => { + resetState(); + setState({ rootPath: tmpDir, sourceGuid: "src-guid-u", targetGuid: "tgt-guid-u" }); + initializeGuidLogger("src-guid-u", "push", "urlRedirection"); + + preflightReport.reset(); + + mockGetMapping = jest.fn().mockReturnValue(null); + mockAddMapping = jest.fn(); + mockSaveUrlRedirections.mockReset(); + + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function makeSourceItem(overrides: Record = {}): any { + return { + id: 1, + originUrl: "/source-url", + destinationUrl: "/destination", + statusCode: 301, + ...overrides, + }; +} + +function makeTargetItem(overrides: Record = {}): any { + return { + id: 10, + originUrl: "/target-url", + destinationUrl: "/destination", + statusCode: 301, + ...overrides, + }; +} + +// ─── empty sourceData guard ─────────────────────────────────────────────────── + +describe("pushUrlRedirections — empty sourceData guard", () => { + it("returns success with zeros when sourceData is empty", async () => { + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + const result = await pushUrlRedirections([], []); + + expect(result.status).toBe("success"); + expect(result.successful).toBe(0); + expect(result.failed).toBe(0); + expect(result.skipped).toBe(0); + expect(mockSaveUrlRedirections).not.toHaveBeenCalled(); + }); + + it("returns success with zeros when sourceData is null", async () => { + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + const result = await pushUrlRedirections(null as any, []); + + expect(result.status).toBe("success"); + expect(result.successful).toBe(0); + }); +}); + +// ─── map-on-adopt: unmapped source whose originUrl matches target (case-insensitive) ───────── + +describe("pushUrlRedirections — map-on-adopt via originUrl match", () => { + it("calls addMapping with the target id when originUrl matches case-insensitively", async () => { + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + + const source = makeSourceItem({ id: 1, originUrl: "~/A ", destinationUrl: "/same", statusCode: 301 }); + const target = makeTargetItem({ id: 20, originUrl: "~/a", destinationUrl: "/same", statusCode: 301 }); + + // No existing mapping for source id 1. + mockGetMapping.mockReturnValue(null); + // Expect: identical after normalization → SKIP. + mockSaveUrlRedirections.mockResolvedValue({ created: [], updated: [], skipped: [] }); + + const result = await pushUrlRedirections([source], [target]); + + expect(mockAddMapping).toHaveBeenCalledWith(1, 20, source.originUrl); + expect(result.skipped).toBe(1); + expect(result.successful).toBe(0); + expect(mockSaveUrlRedirections).not.toHaveBeenCalled(); + }); + + it("queues an UPDATE when originUrl matches but destinationUrl differs", async () => { + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + + const source = makeSourceItem({ id: 2, originUrl: "/shared", destinationUrl: "/new-dest", statusCode: 301 }); + const target = makeTargetItem({ id: 21, originUrl: "/shared", destinationUrl: "/old-dest", statusCode: 301 }); + + mockGetMapping.mockReturnValue(null); + mockSaveUrlRedirections.mockResolvedValue({ created: [], updated: [{ index: 0, urlRedirectionID: 21 }], skipped: [] }); + + const result = await pushUrlRedirections([source], [target]); + + expect(mockSaveUrlRedirections).toHaveBeenCalledTimes(1); + const [, payloads] = mockSaveUrlRedirections.mock.calls[0]; + expect(payloads[0].urlRedirectionID).toBe(21); + expect(result.successful).toBe(1); + }); + + it("queues an UPDATE when originUrl matches but statusCode differs", async () => { + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + + const source = makeSourceItem({ id: 3, originUrl: "/page", destinationUrl: "/new", statusCode: 302 }); + const target = makeTargetItem({ id: 22, originUrl: "/page", destinationUrl: "/new", statusCode: 301 }); + + mockGetMapping.mockReturnValue(null); + mockSaveUrlRedirections.mockResolvedValue({ created: [], updated: [{ index: 0, urlRedirectionID: 22 }], skipped: [] }); + + const result = await pushUrlRedirections([source], [target]); + + expect(mockSaveUrlRedirections).toHaveBeenCalledTimes(1); + const [, payloads] = mockSaveUrlRedirections.mock.calls[0]; + expect(payloads[0].urlRedirectionID).toBe(22); + expect(result.successful).toBe(1); + }); +}); + +// ─── mapped item — identical → skip ────────────────────────────────────────── + +describe("pushUrlRedirections — mapped item, identical target → skip", () => { + it("skips and makes no API call when source and mapped target are identical", async () => { + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + + const source = makeSourceItem({ id: 5, originUrl: "/mapped", destinationUrl: "/dest", statusCode: 301 }); + const target = makeTargetItem({ id: 50, originUrl: "/mapped", destinationUrl: "/dest", statusCode: 301 }); + + mockGetMapping.mockReturnValue({ + sourceUrlRedirectionID: 5, + targetUrlRedirectionID: 50, + originUrl: "/mapped", + }); + + const result = await pushUrlRedirections([source], [target]); + + expect(result.skipped).toBe(1); + expect(result.successful).toBe(0); + expect(mockSaveUrlRedirections).not.toHaveBeenCalled(); + }); +}); + +// ─── mapped item whose mapped target no longer exists → CREATE ──────────────── + +describe("pushUrlRedirections — mapped target deleted → create", () => { + it("sends a CREATE (urlRedirectionID=0) when the mapping's target no longer exists in targetData", async () => { + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + + const source = makeSourceItem({ id: 6, originUrl: "/orphan", destinationUrl: "/dest", statusCode: 301 }); + + // Mapping exists but target ID 99 is not in targetData. + mockGetMapping.mockReturnValue({ + sourceUrlRedirectionID: 6, + targetUrlRedirectionID: 99, + originUrl: "/orphan", + }); + mockSaveUrlRedirections.mockResolvedValue({ created: [{ index: 0, urlRedirectionID: 100 }], updated: [], skipped: [] }); + + const result = await pushUrlRedirections([source], []); + + expect(mockSaveUrlRedirections).toHaveBeenCalledTimes(1); + const [, payloads] = mockSaveUrlRedirections.mock.calls[0]; + expect(payloads[0].urlRedirectionID).toBe(0); + expect(result.successful).toBe(1); + }); +}); + +// ─── new item (no mapping, no origin match) → CREATE ───────────────────────── + +describe("pushUrlRedirections — new item → create", () => { + it("sends a CREATE for an item with no mapping and no matching target", async () => { + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + + const source = makeSourceItem({ id: 7, originUrl: "/brand-new", destinationUrl: "/dest", statusCode: 302 }); + + mockGetMapping.mockReturnValue(null); + mockSaveUrlRedirections.mockResolvedValue({ created: [{ index: 0, urlRedirectionID: 200 }], updated: [], skipped: [] }); + + const result = await pushUrlRedirections([source], []); + + expect(mockSaveUrlRedirections).toHaveBeenCalledTimes(1); + const [, payloads] = mockSaveUrlRedirections.mock.calls[0]; + expect(payloads[0].urlRedirectionID).toBe(0); + expect(payloads[0].httpCode).toBe(302); + expect(result.successful).toBe(1); + }); + + it("defaults httpCode to 301 when statusCode is undefined", async () => { + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + + const source = makeSourceItem({ id: 8, originUrl: "/no-code", destinationUrl: "/dest", statusCode: undefined }); + + mockGetMapping.mockReturnValue(null); + mockSaveUrlRedirections.mockResolvedValue({ created: [{ index: 0, urlRedirectionID: 201 }], updated: [], skipped: [] }); + + await pushUrlRedirections([source], []); + + const [, payloads] = mockSaveUrlRedirections.mock.calls[0]; + expect(payloads[0].httpCode).toBe(301); + }); +}); + +// ─── response handling: created, updated, skipped ───────────────────────────── + +describe("pushUrlRedirections — response handling", () => { + it("calls addMapping for created and updated items and counts skipped from response", async () => { + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + + const s1 = makeSourceItem({ id: 10, originUrl: "/a" }); + const s2 = makeSourceItem({ id: 11, originUrl: "/b" }); + const s3 = makeSourceItem({ id: 12, originUrl: "/c" }); + + mockGetMapping.mockReturnValue(null); + mockSaveUrlRedirections.mockResolvedValue({ + created: [{ index: 0, urlRedirectionID: 12, originUrl: "/a" }], + updated: [{ index: 1, urlRedirectionID: 5 }], + skipped: [{ index: 2, reason: "collision" }], + }); + + const result = await pushUrlRedirections([s1, s2, s3], []); + + // addMapping called for created (source 10 → target 12) and updated (source 11 → target 5). + expect(mockAddMapping).toHaveBeenCalledWith(10, 12, "/a"); + expect(mockAddMapping).toHaveBeenCalledWith(11, 5, "/b"); + + expect(result.successful).toBe(2); + expect(result.skipped).toBe(1); + expect(result.failed).toBe(0); + expect(result.status).toBe("success"); + }); +}); + +// ─── batching: 251 items → two API calls ───────────────────────────────────── + +describe("pushUrlRedirections — batching", () => { + it("splits 251 pending creates into two API calls (250 + 1)", async () => { + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + + const sources = Array.from({ length: 251 }, (_, i) => + makeSourceItem({ id: i + 1, originUrl: `/url-${i}`, destinationUrl: `/dest-${i}` }) + ); + + mockGetMapping.mockReturnValue(null); + // Each batch call returns empty arrays; successful count will be 0 because no items in created/updated. + mockSaveUrlRedirections.mockResolvedValue({ created: [], updated: [], skipped: [] }); + + await pushUrlRedirections(sources, []); + + expect(mockSaveUrlRedirections).toHaveBeenCalledTimes(2); + const [, firstBatch] = mockSaveUrlRedirections.mock.calls[0]; + const [, secondBatch] = mockSaveUrlRedirections.mock.calls[1]; + expect(firstBatch).toHaveLength(250); + expect(secondBatch).toHaveLength(1); + }); +}); + +// ─── API rejects → batch failed ─────────────────────────────────────────────── + +describe("pushUrlRedirections — API error", () => { + it("counts the whole batch as failed and returns status error when API rejects", async () => { + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + + const sources = [ + makeSourceItem({ id: 20, originUrl: "/fail-1" }), + makeSourceItem({ id: 21, originUrl: "/fail-2" }), + ]; + + mockGetMapping.mockReturnValue(null); + mockSaveUrlRedirections.mockRejectedValue(new Error("API unavailable")); + + const result = await pushUrlRedirections(sources, []); + + expect(result.failed).toBe(2); + expect(result.successful).toBe(0); + expect(result.status).toBe("error"); + }); +}); + +// ─── preflight mode ─────────────────────────────────────────────────────────── + +describe("pushUrlRedirections — preflight mode", () => { + it("records create/update/skip entries in the preflightReport with phase 'URL Redirections'", async () => { + setState({ preflight: true }); + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + + // s1: no mapping, no target match → CREATE + const s1 = makeSourceItem({ id: 30, originUrl: "/new-redir", destinationUrl: "/dest", statusCode: 301 }); + // s2: no mapping, target matches by origin URL exactly, identical → SKIP (recorded before pending loop) + const s2 = makeSourceItem({ id: 31, originUrl: "/existing", destinationUrl: "/same", statusCode: 301 }); + const t2 = makeTargetItem({ id: 31, originUrl: "/existing", destinationUrl: "/same", statusCode: 301 }); + // s3: no mapping, target matches but different → UPDATE (queued in pending, recorded in preflight loop) + const s3 = makeSourceItem({ id: 32, originUrl: "/changed", destinationUrl: "/new-dest", statusCode: 301 }); + const t3 = makeTargetItem({ id: 32, originUrl: "/changed", destinationUrl: "/old-dest", statusCode: 301 }); + + mockGetMapping.mockReturnValue(null); + + const result = await pushUrlRedirections([s1, s2, s3], [t2, t3]); + + // No API calls in preflight mode. + expect(mockSaveUrlRedirections).not.toHaveBeenCalled(); + + const entries = preflightReport.getEntries(); + // One skip recorded directly; two (create + update) recorded in the preflight pending loop. + expect(entries.length).toBe(3); + + const phases = entries.map((e) => e.phase); + expect(phases.every((p) => p === "URL Redirections")).toBe(true); + + const actions = entries.map((e) => e.action); + expect(actions).toContain("create"); + expect(actions).toContain("update"); + expect(actions).toContain("skip"); + + // In preflight the skip counts toward skipped; create/update count toward successful. + expect(result.skipped).toBe(1); + expect(result.successful).toBe(2); // create + update counted as successful in preflight + }); + + it("does not write to the API in preflight mode", async () => { + setState({ preflight: true }); + const { pushUrlRedirections } = await import("../url-redirection-pusher"); + + const source = makeSourceItem({ id: 40, originUrl: "/preflight-only" }); + mockGetMapping.mockReturnValue(null); + + await pushUrlRedirections([source], []); + + expect(mockSaveUrlRedirections).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/pushers/url-redirection-api.ts b/src/lib/pushers/url-redirection-api.ts new file mode 100644 index 00000000..329aa3ed --- /dev/null +++ b/src/lib/pushers/url-redirection-api.ts @@ -0,0 +1,59 @@ +import { Auth } from "../../core/auth"; +import { state } from "../../core/state"; +import { UrlRedirectionSavePayload, UrlRedirectionSaveResult } from "../../types/urlRedirection"; + +/** The endpoint processes at most 250 redirections per request. */ +export const MAX_URL_REDIRECTION_BATCH_SIZE = 250; + +/** + * Create/update a batch of URL redirections via the Management API: + * POST /api/v1/instance/{guid}/url-redirections + * + * The endpoint is not in @agility/management-sdk yet, so this calls it directly using + * the same token and base-URL resolution the SDK client is configured with. + * Items without a urlRedirectionID are created; items with one are updated (full replace). + * Invalid items and origin-URL collisions are reported in `skipped`, not thrown. + */ +export async function saveUrlRedirections( + guid: string, + redirections: UrlRedirectionSavePayload[] +): Promise { + if (redirections.length > MAX_URL_REDIRECTION_BATCH_SIZE) { + throw new Error( + `Cannot save more than ${MAX_URL_REDIRECTION_BATCH_SIZE} redirections at a time (got ${redirections.length}).` + ); + } + + const auth = new Auth(); + const baseUrl = state.baseUrl || auth.determineBaseUrl(guid); + const token = await auth.getToken(); + const url = `${baseUrl}/api/v1/instance/${guid}/url-redirections`; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Cache-Control": "no-cache", + "User-Agent": "agility-cli-fetch/1.0", + }, + body: JSON.stringify(redirections), + }); + + if (!response.ok) { + let body = ""; + try { + body = await response.text(); + } catch { + // response body is best-effort error context + } + throw new Error(`HTTP ${response.status}: ${response.statusText} for ${url}${body ? ` — ${body}` : ""}`); + } + + const result = (await response.json()) as UrlRedirectionSaveResult; + return { + created: result?.created ?? [], + updated: result?.updated ?? [], + skipped: result?.skipped ?? [], + }; +} diff --git a/src/lib/pushers/url-redirection-pusher.ts b/src/lib/pushers/url-redirection-pusher.ts new file mode 100644 index 00000000..7ee49e92 --- /dev/null +++ b/src/lib/pushers/url-redirection-pusher.ts @@ -0,0 +1,192 @@ +import ansiColors from "ansi-colors"; +import { Logs } from "core/logs"; +import { state, getLoggerForGuid } from "core/state"; +import { UrlRedirectionMapper } from "lib/mappers/url-redirection-mapper"; +import { UrlRedirectionSyncItem, UrlRedirectionSavePayload } from "../../types/urlRedirection"; +import { preflightReport } from "../preflight/preflight-report"; +import { saveUrlRedirections, MAX_URL_REDIRECTION_BATCH_SIZE } from "./url-redirection-api"; + +/** A queued create/update: the source item plus the payload the API will receive. */ +interface PendingSave { + source: UrlRedirectionSyncItem; + payload: UrlRedirectionSavePayload; + action: "create" | "update"; +} + +const normalizeOrigin = (url: string | null | undefined): string => (url ?? "").trim().toLowerCase(); + +/** + * Pulled redirections carry no modifiedOn timestamp, so change detection is a direct + * field comparison of the pulled source and target items (there is no + * target-changed/conflict detection like galleries; an update is a full replace). + */ +function areEquivalent(a: UrlRedirectionSyncItem, b: UrlRedirectionSyncItem): boolean { + return ( + normalizeOrigin(a.originUrl) === normalizeOrigin(b.originUrl) && + (a.destinationUrl ?? "").trim() === (b.destinationUrl ?? "").trim() && + (a.statusCode ?? 301) === (b.statusCode ?? 301) && + (a.destinationLocale ?? null) === (b.destinationLocale ?? null) && + JSON.stringify(a.originLocales ?? []) === JSON.stringify(b.originLocales ?? []) && + (a.content ?? null) === (b.content ?? null) + ); +} + +function toSavePayload(item: UrlRedirectionSyncItem, targetUrlRedirectionID: number): UrlRedirectionSavePayload { + return { + urlRedirectionID: targetUrlRedirectionID, // 0 = create, positive = update (full replace) + originUrl: item.originUrl, + destinationUrl: item.destinationUrl, + httpCode: item.statusCode ?? 301, + destinationLocale: item.destinationLocale ?? null, + originLocales: item.originLocales ?? null, + content: item.content ?? null, + }; +} + +/** + * Push URL redirections from source to target using the batch save endpoint. + * Creates and updates are sent together (up to 250 per request); the per-item + * response is used to write source→target ID mappings. + */ +export async function pushUrlRedirections( + sourceData: UrlRedirectionSyncItem[], + targetData: UrlRedirectionSyncItem[] +): Promise<{ status: "success" | "error"; successful: number; failed: number; skipped: number }> { + const redirections: UrlRedirectionSyncItem[] = sourceData || []; + const targetRedirections: UrlRedirectionSyncItem[] = targetData || []; + + const { sourceGuid, targetGuid } = state; + + const logger = getLoggerForGuid(sourceGuid[0]) || new Logs("push", "urlRedirection", sourceGuid[0]); + + if (!redirections || redirections.length === 0) { + console.log("No URL redirections found to process."); + return { status: "success", successful: 0, failed: 0, skipped: 0 }; + } + + const mapper = new UrlRedirectionMapper(sourceGuid[0], targetGuid[0]); + + let successful = 0; + let failed = 0; + let skipped = 0; + let overallStatus: "success" | "error" = "success"; + + // Phase 1: classify every source redirection as create / update / skip. + const pending: PendingSave[] = []; + + for (const source of redirections) { + const mapping = mapper.getMapping(source.id, "source"); + const targetById = mapping + ? targetRedirections.find((t) => t.id === mapping.targetUrlRedirectionID) + : undefined; + const targetByOrigin = targetRedirections.find((t) => normalizeOrigin(t.originUrl) === normalizeOrigin(source.originUrl)); + + if (!mapping && targetByOrigin) { + // Exists in target by origin URL but no mapping — adopt the mapping (map-on-adopt, + // same self-heal behavior as galleries/models), then diff to decide update vs skip. + mapper.addMapping(source.id, targetByOrigin.id, source.originUrl); + if (areEquivalent(source, targetByOrigin)) { + logger.urlRedirection.skipped(source, "already exists in target by origin URL", targetGuid[0]); + preflightReport.record({ + phase: "URL Redirections", + action: "skip", + name: source.originUrl, + detail: "already exists in target by origin URL", + }); + skipped++; + } else { + pending.push({ source, payload: toSavePayload(source, targetByOrigin.id), action: "update" }); + } + } else if (mapping && targetById) { + if (areEquivalent(source, targetById)) { + logger.urlRedirection.skipped(source, "up to date, skipping", targetGuid[0]); + preflightReport.record({ + phase: "URL Redirections", + action: "skip", + name: source.originUrl, + detail: "up to date", + }); + skipped++; + } else { + pending.push({ source, payload: toSavePayload(source, targetById.id), action: "update" }); + } + } else { + // No mapping and no origin match, or the mapped target redirection was deleted — create. + pending.push({ source, payload: toSavePayload(source, 0), action: "create" }); + } + } + + // Preflight (PROD-2203): report what WOULD happen without calling the API. + if (state.preflight) { + for (const item of pending) { + preflightReport.record({ phase: "URL Redirections", action: item.action, name: item.source.originUrl }); + successful++; + } + console.log( + ansiColors.yellow( + `Processed ${successful}/${redirections.length} URL redirections (${failed} failed, ${skipped} skipped)` + ) + ); + return { status: overallStatus, successful, failed, skipped }; + } + + // Phase 2: send creates and updates together in batches of up to 250. + for (let i = 0; i < pending.length; i += MAX_URL_REDIRECTION_BATCH_SIZE) { + const batch = pending.slice(i, i + MAX_URL_REDIRECTION_BATCH_SIZE); + + try { + const result = await saveUrlRedirections( + targetGuid[0], + batch.map((p) => p.payload) + ); + + // `index` is the zero-based position within THIS request's payload. + for (const created of result.created) { + const item = batch[created.index]; + if (item && created.urlRedirectionID) { + mapper.addMapping(item.source.id, created.urlRedirectionID, item.source.originUrl); + logger.urlRedirection.created(item.source, "created", targetGuid[0]); + } + successful++; + } + + for (const updated of result.updated) { + const item = batch[updated.index]; + if (item) { + mapper.addMapping( + item.source.id, + updated.urlRedirectionID ?? item.payload.urlRedirectionID, + item.source.originUrl + ); + logger.urlRedirection.updated(item.source, "updated", targetGuid[0]); + } + successful++; + } + + // Items the API refused individually (validation failure or origin URL collision). + for (const skippedItem of result.skipped) { + const item = batch[skippedItem.index]; + logger.urlRedirection.skipped( + item?.source ?? { originUrl: skippedItem.originUrl }, + `skipped by API: ${skippedItem.reason || "no reason given"}`, + targetGuid[0] + ); + skipped++; + } + } catch (error: any) { + // The endpoint is transactional per request, so the whole batch failed. + failed += batch.length; + overallStatus = "error"; + for (const item of batch) { + logger.urlRedirection.error(item.source, error?.message || error, targetGuid[0]); + } + } + } + + console.log( + ansiColors.yellow( + `Processed ${successful}/${redirections.length} URL redirections (${failed} failed, ${skipped} skipped)` + ) + ); + return { status: overallStatus, successful, failed, skipped }; +} diff --git a/src/types/urlRedirection.ts b/src/types/urlRedirection.ts new file mode 100644 index 00000000..688e4d36 --- /dev/null +++ b/src/types/urlRedirection.ts @@ -0,0 +1,57 @@ +/** + * URL redirection types. + * + * Two shapes are in play: + * - The SYNC shape: what the Content Sync SDK writes to + * agility-files/{guid}/{locale}/urlredirections/urlredirections.json during a pull. + * - The MANAGEMENT shape: what POST /api/v1/instance/{guid}/url-redirections accepts/returns. + * Note the different property names (id → urlRedirectionID, statusCode → httpCode). + */ + +/** A redirection as pulled by the Content Sync SDK. */ +export interface UrlRedirectionSyncItem { + id: number; + originUrl: string; + destinationUrl: string; + statusCode: number; + destinationLocale?: string | null; + originLocales?: string[] | null; + content?: string | null; +} + +/** The file the sync SDK writes: urlredirections/urlredirections.json */ +export interface UrlRedirectionSyncFile { + items: UrlRedirectionSyncItem[]; + isUpToDate: boolean; + lastAccessDate: string; +} + +/** + * One redirection in the Management API batch save payload. + * urlRedirectionID 0 (or absent) = create; a positive ID = update (full replace). + */ +export interface UrlRedirectionSavePayload { + urlRedirectionID: number; + originUrl: string; + destinationUrl: string; + httpCode: number; + destinationLocale?: string | null; + originLocales?: string[] | null; + content?: string | null; +} + +/** Per-item outcome in the batch save response. `index` is the zero-based position in the request payload. */ +export interface UrlRedirectionItemResult { + index: number; + urlRedirectionID?: number; + originUrl?: string; + /** Only set on skipped items (validation failure or origin URL collision). */ + reason?: string; +} + +/** Response of POST /api/v1/instance/{guid}/url-redirections */ +export interface UrlRedirectionSaveResult { + created: UrlRedirectionItemResult[]; + updated: UrlRedirectionItemResult[]; + skipped: UrlRedirectionItemResult[]; +}