From e74eff22b4a34541a85e2cf0b91aef2a56b61798 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 19 Oct 2026 08:34:47 -0400 Subject: [PATCH 1/2] PROD-2807: read entities saved earlier in the run, not the pulled target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every mapper's getMappedEntity(…, "target") read the target from the local cache, which holds the target as pulled at the start of the run. Anything a pusher saved was stale for the rest of the run. A template that gained a zone in the template phase was missing it when the page phase translated zone names, so that zone's modules overflowed into the template's first zone. This was patched per entity before: PROD-2346 rewrote the container cache file after a save, and PROD-2542 kept a model-only registry for content validation. Templates, galleries and assets were never covered. - core/state: one run-scoped cache (recordSavedTarget, getSavedTarget, getSavedTargetBySourceReferenceName, clearSavedTargets), by kind then source ID, for the kinds whose save returns the complete entity: model, container, template, gallery, asset. Cleared at the start of each push and by resetState. - The five mappers check it before the disk cache. - The pushers record the server's response beside each addMapping. This replaces PROD-2346's container cache write and generalizes PROD-2542's registry; preflight still records planned models. - Content items and pages are left out on purpose: their pushers hold only a partial target, and the pulled copy is what conflict detection compares against. - In memory rather than on disk so it also works under --preflight and leaves pulled files untouched. Also: - GalleryMapper.getMappedEntity listed "galleries" relative to the working directory, so it never found a pulled gallery and created an empty galleries/ folder there. Drop its stray console.log. - ContainerMapper.getContainerByReferenceName dereferenced the null mapping in its no-mapping fallback. Co-Authored-By: Claude Opus 5.5 --- src/core/push.ts | 6 +- src/core/state.ts | 82 +++++++++--- src/core/tests/state.test.ts | 44 +++++-- src/lib/mappers/asset-mapper.ts | 7 + src/lib/mappers/container-mapper.ts | 8 +- src/lib/mappers/content-item-mapper.ts | 2 + src/lib/mappers/gallery-mapper.ts | 13 +- src/lib/mappers/model-mapper.ts | 6 + src/lib/mappers/page-mapper.ts | 2 + src/lib/mappers/template-mapper.ts | 6 + .../mappers/tests/container-mapper.test.ts | 12 ++ src/lib/mappers/tests/gallery-mapper.test.ts | 21 +++ src/lib/mappers/tests/saved-target.test.ts | 122 ++++++++++++++++++ src/lib/pushers/asset-pusher.ts | 5 +- src/lib/pushers/container-pusher.ts | 19 +-- .../util/resolve-effective-target-model.ts | 9 +- ...ilter-content-items-for-processing.test.ts | 6 +- .../resolve-effective-target-model.test.ts | 6 +- src/lib/pushers/gallery-pusher.ts | 4 +- src/lib/pushers/model-pusher.ts | 20 +-- src/lib/pushers/template-pusher.ts | 4 +- .../pushers/tests/container-pusher.test.ts | 29 ++--- src/lib/pushers/tests/model-pusher.test.ts | 8 +- src/lib/pushers/tests/template-pusher.test.ts | 49 +++++++ src/tests/sync/same-run-saved-targets.test.ts | 84 ++++++++++++ 25 files changed, 476 insertions(+), 98 deletions(-) create mode 100644 src/lib/mappers/tests/saved-target.test.ts create mode 100644 src/tests/sync/same-run-saved-targets.test.ts diff --git a/src/core/push.ts b/src/core/push.ts index 11437651..038b0cea 100644 --- a/src/core/push.ts +++ b/src/core/push.ts @@ -5,7 +5,7 @@ import { state, clearFailedContentRegistry, clearHeldBackTemplateRegistry, - clearEffectiveTargetModels, + clearSavedTargets, clearOtherLocaleStagingRegistry, getOtherLocaleStaging, getPageCmsLink, @@ -103,8 +103,8 @@ export class Push { // Clear failed content and held-back template registries from any previous sync clearFailedContentRegistry(); clearHeldBackTemplateRegistry(); - // PROD-2542: models saved by a previous run must not stand in for this run's target models - clearEffectiveTargetModels(); + // Entities saved by a previous run must not stand in for this run's pulled targets + clearSavedTargets(); clearOtherLocaleStagingRegistry(); // Preflight (PROD-2203): start each run with a clean report of planned actions diff --git a/src/core/state.ts b/src/core/state.ts index 4c20d3d6..03055355 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -111,11 +111,12 @@ export interface State { // Keyed by `${locale}:${contentID}` (PROD-2542) so a failure in one locale never labels another. failedContentRegistry: Map; - // Effective target models (PROD-2542) - the model definition content will actually be validated - // against on the target, keyed by lower-cased SOURCE model referenceName. Filled by the model - // pusher on a saved create/update (and with the source model in preflight), because the target - // models/.json cache is not rewritten after saveModel and is stale for the rest of the run. - effectiveTargetModels: Map; + // Saved targets - the target entity each pusher saved this run (or, for models in preflight, the + // definition it would save), by kind, then by SOURCE ID. The mappers' getMappedEntity(…, "target") + // read this before the target cache on disk, which holds the target as pulled at the start of the + // run and is stale for anything saved since. Generalizes PROD-2542's model registry and replaces + // PROD-2346's container cache write; preflight cannot write files, but can record a plan here. + savedTargets: Map>; // Held-back template registry - source template names whose mapped target template is gone and // was not recreated (no --overwrite). Used by page pusher to skip pages that depend on them. @@ -208,8 +209,8 @@ export const state: State = { // Failed content registry - tracks content items that failed during sync failedContentRegistry: new Map(), - // Effective target models - models saved (or, in preflight, planned) during this run - effectiveTargetModels: new Map(), + // Saved targets - target entities saved (or, for models in preflight, planned) during this run + savedTargets: new Map(), // Held-back template registry - templates whose mapped target is gone and was not recreated heldBackTemplateRegistry: new Map(), @@ -519,8 +520,8 @@ export function resetState() { state.reverseSync = false; state.mappingPair = undefined; - // Run-scoped model registry (PROD-2542) - state.effectiveTargetModels = new Map(); + // Run-scoped saved target entities + state.savedTargets = new Map(); } /** @@ -818,27 +819,66 @@ export function clearFailedContentRegistry(): void { } // ============================================================================ -// Effective target model registry (PROD-2542) +// Saved target entities // ============================================================================ /** - * Record the model definition content of `sourceReferenceName` will be validated against on the - * target for the rest of this run. + * Entity kinds whose save returns the complete target entity, so the saved copy can stand in for the + * pulled one. Content items and pages are deliberately not here: their pushers only hold a partial + * target (the payload plus the new ID and version), and their pulled target copy is what conflict + * detection compares against (hasTargetChanged, PROD-2628). */ -export function registerEffectiveTargetModel(sourceReferenceName: string, model: any): void { - if (!sourceReferenceName || !model) return; - state.effectiveTargetModels.set(sourceReferenceName.toLowerCase(), model); +export type SavedTargetKind = "model" | "container" | "template" | "gallery" | "asset"; + +export interface SavedTarget { + entity: any; + sourceReferenceName?: string; +} + +/** + * Record the target entity saved this run for the source entity `sourceID`. A later save of the same + * source entity replaces it. + */ +export function recordSavedTarget( + kind: SavedTargetKind, + sourceID: number, + entity: any, + sourceReferenceName?: string +): void { + if (sourceID == null || !entity) return; + let saved = state.savedTargets.get(kind); + if (!saved) { + saved = new Map(); + state.savedTargets.set(kind, saved); + } + saved.set(sourceID, { entity, sourceReferenceName }); +} + +/** The target entity saved this run for the source entity `sourceID`, if any. */ +export function getSavedTarget(kind: SavedTargetKind, sourceID: number): any | undefined { + if (sourceID == null) return undefined; + return state.savedTargets.get(kind)?.get(sourceID)?.entity; } -/** Look up a model registered this run by its source referenceName (case-insensitive). */ -export function getEffectiveTargetModel(sourceReferenceName: string): any | undefined { +/** + * The target entity saved this run for the source entity with this referenceName (case-insensitive), + * if any. For callers that know a source entity only by name, e.g. content validation, which has the + * item's definitionName and, in preflight, a model that has no mapping yet. + */ +export function getSavedTargetBySourceReferenceName( + kind: SavedTargetKind, + sourceReferenceName: string +): any | undefined { if (!sourceReferenceName) return undefined; - return state.effectiveTargetModels.get(sourceReferenceName.toLowerCase()); + const name = sourceReferenceName.toLowerCase(); + const saved = state.savedTargets.get(kind); + if (!saved) return undefined; + return Array.from(saved.values()).find((s) => s.sourceReferenceName?.toLowerCase() === name)?.entity; } -/** Clear the effective target model registry (called at start of each sync). */ -export function clearEffectiveTargetModels(): void { - state.effectiveTargetModels.clear(); +/** Clear the saved target entities (called at start of each sync). */ +export function clearSavedTargets(): void { + state.savedTargets.clear(); } /** diff --git a/src/core/tests/state.test.ts b/src/core/tests/state.test.ts index 5f75e3fe..69c750c5 100644 --- a/src/core/tests/state.test.ts +++ b/src/core/tests/state.test.ts @@ -14,9 +14,10 @@ import { registerFailedContent, getFailedContent, clearFailedContentRegistry, - registerEffectiveTargetModel, - getEffectiveTargetModel, - clearEffectiveTargetModels, + recordSavedTarget, + getSavedTarget, + getSavedTargetBySourceReferenceName, + clearSavedTargets, initializeLogger, initializeGuidLogger, getLoggerForGuid, @@ -337,20 +338,37 @@ describe("failed content registry", () => { }); }); -describe("effective target model registry (PROD-2542)", () => { - it("registers and looks up by source referenceName, case-insensitively", () => { +describe("saved target entities", () => { + it("looks up by kind and source ID", () => { + const template = { pageTemplateID: 70, contentSectionDefinitions: [] }; + recordSavedTarget("template", 7, template, "Home"); + expect(getSavedTarget("template", 7)).toBe(template); + expect(getSavedTarget("template", 8)).toBeUndefined(); + expect(getSavedTarget("container", 7)).toBeUndefined(); + }); + + it("replaces an earlier save of the same source entity", () => { + const first = { id: 10, fields: [] }; + const second = { id: 10, fields: [{ name: "Title" }] }; + recordSavedTarget("model", 1, first, "Post"); + recordSavedTarget("model", 1, second, "Post"); + expect(getSavedTarget("model", 1)).toBe(second); + }); + + it("looks up by source referenceName, case-insensitively (PROD-2542)", () => { const model = { referenceName: "PromoBanner", fields: [] }; - registerEffectiveTargetModel("PromoBanner", model); - expect(getEffectiveTargetModel("promobanner")).toBe(model); + recordSavedTarget("model", 3, model, "PromoBanner"); + expect(getSavedTargetBySourceReferenceName("model", "promobanner")).toBe(model); + expect(getSavedTargetBySourceReferenceName("container", "promobanner")).toBeUndefined(); }); - it("is cleared by clearEffectiveTargetModels and resetState", () => { - registerEffectiveTargetModel("A", { fields: [] }); - clearEffectiveTargetModels(); - expect(getEffectiveTargetModel("A")).toBeUndefined(); - registerEffectiveTargetModel("B", { fields: [] }); + it("is cleared by clearSavedTargets and resetState", () => { + recordSavedTarget("model", 1, { fields: [] }, "A"); + clearSavedTargets(); + expect(getSavedTarget("model", 1)).toBeUndefined(); + recordSavedTarget("model", 2, { fields: [] }, "B"); resetState(); - expect(getEffectiveTargetModel("B")).toBeUndefined(); + expect(getSavedTargetBySourceReferenceName("model", "B")).toBeUndefined(); }); }); diff --git a/src/lib/mappers/asset-mapper.ts b/src/lib/mappers/asset-mapper.ts index ead49042..548c6ce6 100644 --- a/src/lib/mappers/asset-mapper.ts +++ b/src/lib/mappers/asset-mapper.ts @@ -1,4 +1,5 @@ import { fileOperations } from "../../core"; +import { getSavedTarget } from "../../core/state"; import * as mgmtApi from "@agility/management-sdk"; interface AssetMapping { @@ -153,6 +154,12 @@ export class AssetMapper { } getMappedEntity(mapping: AssetMapping, type: "source" | "target"): mgmtApi.Media | null { + if (!mapping) return null; + // Something saved to the target earlier in this run is newer than the pulled copy on disk. + if (type === "target") { + const saved = getSavedTarget("asset", mapping.sourceMediaID); + if (saved) return saved; + } const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; const mediaID = type === "source" ? mapping.sourceMediaID : mapping.targetMediaID; const fileOps = new fileOperations(guid); diff --git a/src/lib/mappers/container-mapper.ts b/src/lib/mappers/container-mapper.ts index f3e70a79..7e9536e0 100644 --- a/src/lib/mappers/container-mapper.ts +++ b/src/lib/mappers/container-mapper.ts @@ -1,5 +1,6 @@ import { parse } from "date-fns"; import { fileOperations } from "../../core"; +import { getSavedTarget } from "../../core/state"; import * as mgmtApi from "@agility/management-sdk"; //TODO: Change to use lastModifiedOn instead of lastModifiedDate when the fix to that is deployed! @@ -62,6 +63,11 @@ export class ContainerMapper { getMappedEntity(mapping: ContainerMapping, type: "source" | "target"): mgmtApi.Container | null { if (!mapping) return null; + // Something saved to the target earlier in this run is newer than the pulled copy on disk. + if (type === "target") { + const saved = getSavedTarget("container", mapping.sourceContentViewID); + if (saved) return saved; + } //fetch the container from the file system based on source or target GUID const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; const containerID = type === "source" ? mapping.sourceContentViewID : mapping.targetContentViewID; @@ -78,7 +84,7 @@ export class ContainerMapper { return this.getMappedEntity(mapping, type); } else { //if there's no mappping, we have to loop through ALL the containers to find it - const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; + const guid = type === "source" ? this.sourceGuid : this.targetGuid; const fileOps = new fileOperations(guid); const containerFiles = fileOps.listFilesInFolder(`containers`); diff --git a/src/lib/mappers/content-item-mapper.ts b/src/lib/mappers/content-item-mapper.ts index defe27d3..6d64815e 100644 --- a/src/lib/mappers/content-item-mapper.ts +++ b/src/lib/mappers/content-item-mapper.ts @@ -48,6 +48,8 @@ export class ContentItemMapper { } getMappedEntity(mapping: ContentItemMapping, type: "source" | "target"): mgmtApi.ContentItem | null { + // Deliberately reads only the pulled copy, never a saved target (see SavedTargetKind): the + // pusher holds a partial target, and this pulled copy is what conflict detection compares to. //fetch the content item from the file system based on source or target GUID if (!mapping) { return null; diff --git a/src/lib/mappers/gallery-mapper.ts b/src/lib/mappers/gallery-mapper.ts index 369f035a..a5b9168b 100644 --- a/src/lib/mappers/gallery-mapper.ts +++ b/src/lib/mappers/gallery-mapper.ts @@ -1,6 +1,7 @@ import { parse } from "date-fns"; import * as mgmtApi from "@agility/management-sdk"; import { fileOperations } from "../../core"; +import { getSavedTarget } from "../../core/state"; interface GalleryMapping { sourceGuid: string; targetGuid: string; @@ -46,15 +47,21 @@ export class GalleryMapper { getMappedEntity(mapping: GalleryMapping | null, type: "source" | "target"): mgmtApi.assetMediaGrouping | null { if (!mapping) return null; + // Something saved to the target earlier in this run is newer than the pulled copy on disk. + if (type === "target") { + const saved = getSavedTarget("gallery", mapping.sourceMediaGroupingID); + if (saved) return saved; + } const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; const mediaGroupingID = type === "source" ? mapping.sourceMediaGroupingID : mapping.targetMediaGroupingID; const fileOps = new fileOperations(guid); - const galleriesFiles = fileOps.getFolderContents("galleries"); + // getFolderContents takes a full path; a bare "galleries" resolved against the working + // directory, so the pulled galleries were never found (and an empty folder was created there). + const galleriesFiles = fileOps.getFolderContents(fileOps.getDataFolderPath("galleries")); - console.log("galleriesFiles", galleriesFiles); for (const galleryFile of galleriesFiles) { const galleryData = fileOps.readJsonFile(`galleries/${galleryFile}`); - if (galleryData.mediaGroupingID === mediaGroupingID) { + if (galleryData?.mediaGroupingID === mediaGroupingID) { return galleryData; } } diff --git a/src/lib/mappers/model-mapper.ts b/src/lib/mappers/model-mapper.ts index 5eb47732..905fd5b1 100644 --- a/src/lib/mappers/model-mapper.ts +++ b/src/lib/mappers/model-mapper.ts @@ -1,4 +1,5 @@ import { fileOperations } from "../../core"; +import { getSavedTarget } from "../../core/state"; import * as mgmtApi from "@agility/management-sdk"; interface ModelMapping { @@ -99,6 +100,11 @@ export class ModelMapper { getMappedEntity(mapping: ModelMapping, type: "source" | "target"): mgmtApi.Model | null { if (!mapping) return null; + // Something saved to the target earlier in this run is newer than the pulled copy on disk. + if (type === "target") { + const saved = getSavedTarget("model", mapping.sourceID); + if (saved) return saved; + } //fetch the model from the file system based on source or target GUID const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; const modelID = type === "source" ? mapping.sourceID : mapping.targetID; diff --git a/src/lib/mappers/page-mapper.ts b/src/lib/mappers/page-mapper.ts index db0f39c5..13f72821 100644 --- a/src/lib/mappers/page-mapper.ts +++ b/src/lib/mappers/page-mapper.ts @@ -55,6 +55,8 @@ export class PageMapper { } getMappedEntity(mapping: PageMapping, type: "source" | "target"): mgmtApi.PageItem | null { + // Deliberately reads only the pulled copy, never a saved target (see SavedTargetKind): the + // pusher holds a partial target, and this pulled copy is what conflict detection compares to. if (!mapping) return null; const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; const pageID = type === "source" ? mapping.sourcePageID : mapping.targetPageID; diff --git a/src/lib/mappers/template-mapper.ts b/src/lib/mappers/template-mapper.ts index 696d8199..db57f1d7 100644 --- a/src/lib/mappers/template-mapper.ts +++ b/src/lib/mappers/template-mapper.ts @@ -1,4 +1,5 @@ import { fileOperations } from "../../core"; +import { getSavedTarget } from "../../core/state"; import * as mgmtApi from "@agility/management-sdk"; import { SectionMapper } from "./section-mapper"; interface TemplateMapping { @@ -55,6 +56,11 @@ export class TemplateMapper { getMappedEntity(mapping: TemplateMapping, type: "source" | "target"): mgmtApi.PageModel | null { if (!mapping) return null; + // Something saved to the target earlier in this run is newer than the pulled copy on disk. + if (type === "target") { + const saved = getSavedTarget("template", mapping.sourcePageTemplateID); + if (saved) return saved; + } const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; const pageTemplateID = type === "source" ? mapping.sourcePageTemplateID : mapping.targetPageTemplateID; const fileOps = new fileOperations(guid); diff --git a/src/lib/mappers/tests/container-mapper.test.ts b/src/lib/mappers/tests/container-mapper.test.ts index 4cf6a7d3..9786616e 100644 --- a/src/lib/mappers/tests/container-mapper.test.ts +++ b/src/lib/mappers/tests/container-mapper.test.ts @@ -213,3 +213,15 @@ describe("ContainerMapper.hasTargetChanged", () => { ); }); }); + +describe("ContainerMapper — getContainerByReferenceName without a mapping", () => { + it("scans the instance's containers instead of throwing", () => { + const mapper = new ContainerMapper("src-cbr", "tgt-cbr"); + const containerDir = path.join(tmpDir, "tgt-cbr", "containers"); + fs.mkdirSync(containerDir, { recursive: true }); + fs.writeFileSync(path.join(containerDir, "77.json"), JSON.stringify({ contentViewID: 77, referenceName: "Posts" })); + + expect(mapper.getContainerByReferenceName("posts", "target")).toMatchObject({ contentViewID: 77 }); + expect(mapper.getContainerByReferenceName("missing", "target")).toBeNull(); + }); +}); diff --git a/src/lib/mappers/tests/gallery-mapper.test.ts b/src/lib/mappers/tests/gallery-mapper.test.ts index d5a1a8c8..000ca331 100644 --- a/src/lib/mappers/tests/gallery-mapper.test.ts +++ b/src/lib/mappers/tests/gallery-mapper.test.ts @@ -166,3 +166,24 @@ describe("GalleryMapper.hasTargetChanged", () => { expect(mapper.hasTargetChanged(makeGallery({ mediaGroupingID: 20, modifiedOn: "12/31/2025 11:59PM" }))).toBe(true); }); }); + +describe("GalleryMapper — getMappedEntity", () => { + it("finds the pulled gallery in the instance's galleries folder, not the working directory", () => { + const mapper = new GalleryMapper("src-gme", "tgt-gme"); + const galleryDir = path.join(tmpDir, "tgt-gme", "galleries"); + fs.mkdirSync(galleryDir, { recursive: true }); + fs.writeFileSync(path.join(galleryDir, "40.json"), JSON.stringify({ mediaGroupingID: 40, name: "Hero" })); + + const mapping = { + sourceGuid: "src-gme", + targetGuid: "tgt-gme", + sourceMediaGroupingID: 4, + targetMediaGroupingID: 40, + sourceModifiedOn: "", + targetModifiedOn: "", + }; + + expect(mapper.getMappedEntity(mapping, "target")).toMatchObject({ mediaGroupingID: 40, name: "Hero" }); + expect(fs.existsSync(path.join(process.cwd(), "galleries"))).toBe(false); + }); +}); diff --git a/src/lib/mappers/tests/saved-target.test.ts b/src/lib/mappers/tests/saved-target.test.ts new file mode 100644 index 00000000..af4918cc --- /dev/null +++ b/src/lib/mappers/tests/saved-target.test.ts @@ -0,0 +1,122 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { recordSavedTarget, resetState, setState } from "core/state"; +import { ModelMapper } from "lib/mappers/model-mapper"; +import { ContainerMapper } from "lib/mappers/container-mapper"; +import { TemplateMapper } from "lib/mappers/template-mapper"; +import { GalleryMapper } from "lib/mappers/gallery-mapper"; +import { AssetMapper } from "lib/mappers/asset-mapper"; + +// getMappedEntity(…, "target") must return what was saved earlier in the run, not the target +// as pulled, and fall back to the pulled copy for anything not saved. + +let tmpDir: string; + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-saved-target-")); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +beforeEach(() => { + resetState(); + setState({ rootPath: tmpDir }); + jest.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function writeTarget(relativePath: string, data: any) { + const full = path.join(tmpDir, "tgt-st", relativePath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, JSON.stringify(data)); +} + +const pair = { sourceGuid: "src-st", targetGuid: "tgt-st" }; + +describe("getMappedEntity reads entities saved this run before the pulled target", () => { + it("models", () => { + const mapping = { ...pair, sourceID: 1, targetID: 10, sourceLastModifiedDate: "", targetLastModifiedDate: "" }; + writeTarget("models/10.json", { id: 10, fields: [] }); + const mapper = new ModelMapper("src-st", "tgt-st"); + expect(mapper.getMappedEntity(mapping, "target")).toEqual({ id: 10, fields: [] }); + + const saved = { id: 10, fields: [{ name: "Title" }] }; + recordSavedTarget("model", 1, saved, "Post"); + expect(mapper.getMappedEntity(mapping, "target")).toBe(saved); + }); + + it("containers", () => { + const mapping = { + ...pair, + sourceContentViewID: 2, + targetContentViewID: 20, + sourceLastModifiedDate: "", + targetLastModifiedDate: "", + }; + writeTarget("containers/20.json", { contentViewID: 20, lastModifiedDate: "pulled" }); + const mapper = new ContainerMapper("src-st", "tgt-st"); + expect(mapper.getMappedEntity(mapping, "target")).toMatchObject({ lastModifiedDate: "pulled" }); + + const saved = { contentViewID: 20, lastModifiedDate: "saved" }; + recordSavedTarget("container", 2, saved, "Posts"); + expect(mapper.getMappedEntity(mapping, "target")).toBe(saved); + }); + + it("templates", () => { + const mapping = { + ...pair, + sourcePageTemplateID: 3, + targetPageTemplateID: 30, + sourcePageTemplateName: "Home", + targetPageTemplateName: "Home", + }; + writeTarget("templates/30.json", { + pageTemplateID: 30, + contentSectionDefinitions: [{ pageItemTemplateReferenceName: "Main" }], + }); + const mapper = new TemplateMapper("src-st", "tgt-st"); + expect(mapper.getMappedEntity(mapping, "target")!.contentSectionDefinitions).toHaveLength(1); + + const saved = { + pageTemplateID: 30, + contentSectionDefinitions: [ + { pageItemTemplateReferenceName: "Banner" }, + { pageItemTemplateReferenceName: "Main" }, + ], + }; + recordSavedTarget("template", 3, saved, "Home"); + expect(mapper.getMappedEntity(mapping, "target")).toBe(saved); + }); + + it("galleries", () => { + const mapping = { + ...pair, + sourceMediaGroupingID: 4, + targetMediaGroupingID: 40, + sourceModifiedOn: "", + targetModifiedOn: "", + }; + const saved = { mediaGroupingID: 40, name: "Hero" }; + recordSavedTarget("gallery", 4, saved, "Hero"); + expect(new GalleryMapper("src-st", "tgt-st").getMappedEntity(mapping, "target")).toBe(saved); + }); + + it("assets", () => { + const mapping = { ...pair, sourceMediaID: 5, targetMediaID: 50, sourceDateModified: "", targetDateModified: "" }; + const saved = { mediaID: 50, fileName: "hero.jpg" }; + recordSavedTarget("asset", 5, saved, "images/hero.jpg"); + expect(new AssetMapper("src-st", "tgt-st").getMappedEntity(mapping, "target")).toBe(saved); + }); + + it("never substitutes a saved target for the source side", () => { + const mapping = { ...pair, sourceID: 6, targetID: 60, sourceLastModifiedDate: "", targetLastModifiedDate: "" }; + recordSavedTarget("model", 6, { id: 60, fields: [] }, "Post"); + expect(new ModelMapper("src-st", "tgt-st").getMappedEntity(mapping, "source")).toBeNull(); + }); +}); diff --git a/src/lib/pushers/asset-pusher.ts b/src/lib/pushers/asset-pusher.ts index d4b4edc9..0c84ec46 100644 --- a/src/lib/pushers/asset-pusher.ts +++ b/src/lib/pushers/asset-pusher.ts @@ -1,7 +1,7 @@ import ansiColors from "ansi-colors"; import * as mgmtApi from "@agility/management-sdk"; import { getAssetFilePath } from "../shared"; -import { state, getApiClient, getLoggerForGuid } from "../../core/state"; +import { state, getApiClient, getLoggerForGuid, recordSavedTarget } from "../../core/state"; import { AssetMapper } from "../mappers/asset-mapper"; import { Logs } from "../../core/logs"; const FormData = require("form-data"); @@ -250,6 +250,7 @@ export async function pushAssets( logger ); referenceMapper.addMapping(media, updatedAsset); + recordSavedTarget("asset", media.mediaID, updatedAsset, media.originKey); logger.asset.uploaded(media, moveDetail, targetGuid); } successful++; @@ -293,6 +294,7 @@ export async function pushAssets( logger ); referenceMapper.addMapping(media, createdAsset); + recordSavedTarget("asset", media.mediaID, createdAsset, media.originKey); } successful++; } else if (shouldUpdate) { @@ -311,6 +313,7 @@ export async function pushAssets( logger ); referenceMapper.addMapping(media, updatedAsset); + recordSavedTarget("asset", media.mediaID, updatedAsset, media.originKey); } successful++; } else if (shouldSkip) { diff --git a/src/lib/pushers/container-pusher.ts b/src/lib/pushers/container-pusher.ts index 1c0d3039..246dfbeb 100644 --- a/src/lib/pushers/container-pusher.ts +++ b/src/lib/pushers/container-pusher.ts @@ -1,23 +1,12 @@ import * as mgmtApi from "@agility/management-sdk"; import { ApiClient } from "@agility/management-sdk"; -import { getLoggerForGuid, state } from "core/state"; -import { fileOperations } from "core"; +import { getLoggerForGuid, state, recordSavedTarget } from "core/state"; import { ContainerMapper } from "lib/mappers/container-mapper"; import { ModelMapper } from "lib/mappers/model-mapper"; import { Logs } from "core/logs"; import { FailureDetail, PusherResult } from "types/sourceData"; import { preflightReport } from "../preflight/preflight-report"; -/** - * Persist the target container to the local disk cache (agility-files/{targetGuid}/containers/{id}.json), - * mirroring what a download-containers.ts pull would produce. Without this, ContainerMapper.getMappedEntity - * (which only reads from disk) can't distinguish a container that was just created/updated in this same run - * from one that was genuinely deleted since the last sync (PROD-2346). - */ -function cacheTargetContainer(targetGuid: string, container: mgmtApi.Container) { - new fileOperations(targetGuid).exportFiles("containers", container.contentViewID.toString(), container); -} - /** * Container pusher with enhanced version-based comparison * Uses lastModifiedDate for intelligent update decisions @@ -115,7 +104,7 @@ export async function pushContainers( if (!state.preflight) { containerMapper.addMapping(sourceContainer, targetByRef); - cacheTargetContainer(targetGuid, targetByRef); + recordSavedTarget("container", sourceContainer.contentViewID, targetByRef, sourceContainer.referenceName); } logger.container.skipped(sourceContainer, "already exists on target; mapping row created", targetGuid); preflightReport.record({ @@ -220,7 +209,7 @@ export async function pushContainers( } containerMapper.updateMapping(sourceContainer, updateResult, sourceMapping); - cacheTargetContainer(targetGuid, updateResult); + recordSavedTarget("container", sourceContainer.contentViewID, updateResult, sourceContainer.referenceName); successful++; } else { logger.container.error(sourceContainer, "Failed to update container", targetGuid); @@ -265,7 +254,7 @@ export async function pushContainers( if (createResult) { logger.container.created(sourceContainer, "created", targetGuid); containerMapper.addMapping(sourceContainer, createResult); - cacheTargetContainer(targetGuid, createResult); + recordSavedTarget("container", sourceContainer.contentViewID, createResult, sourceContainer.referenceName); successful++; } else { logger.container.error(sourceContainer, "Failed to create container", targetGuid); diff --git a/src/lib/pushers/content-pusher/util/resolve-effective-target-model.ts b/src/lib/pushers/content-pusher/util/resolve-effective-target-model.ts index cfdefe7b..981e0f89 100644 --- a/src/lib/pushers/content-pusher/util/resolve-effective-target-model.ts +++ b/src/lib/pushers/content-pusher/util/resolve-effective-target-model.ts @@ -1,11 +1,12 @@ -import { getEffectiveTargetModel } from "core/state"; +import { getSavedTargetBySourceReferenceName } from "core/state"; import { ModelMapper } from "lib/mappers/model-mapper"; /** * PROD-2542: resolve the model a content item will be validated against on the target, by its * source definitionName. Lookup order: - * 1. the run-scoped registry (a model saved — or, in preflight, planned — earlier in this run; - * the target models/.json cache is NOT rewritten after saveModel, so it would be stale), + * 1. the model saved — or, in preflight, planned — earlier in this run (recordSavedTarget); the + * target models/.json cache holds the model as pulled and is stale once it is saved. Looked + * up by name because a model planned in preflight has no mapping yet, * 2. the target model cache via the model mapping, * 3. the source model via the model mapping, * 4. none — the caller skips validation. @@ -35,7 +36,7 @@ export function createEffectiveTargetModelResolver( const key = definitionName.toLowerCase(); if (cache.has(key)) return cache.get(key); - let model: any | null = getEffectiveTargetModel(definitionName) || null; + let model: any | null = getSavedTargetBySourceReferenceName("model", definitionName) || null; if (!model) { try { const mapper = getModelMapper(); diff --git a/src/lib/pushers/content-pusher/util/tests/filter-content-items-for-processing.test.ts b/src/lib/pushers/content-pusher/util/tests/filter-content-items-for-processing.test.ts index 6f0a2a18..6e887c1a 100644 --- a/src/lib/pushers/content-pusher/util/tests/filter-content-items-for-processing.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/filter-content-items-for-processing.test.ts @@ -1,4 +1,4 @@ -import { resetState, setState, registerEffectiveTargetModel } from "core/state"; +import { resetState, setState, recordSavedTarget } from "core/state"; import { preflightReport } from "lib/preflight/preflight-report"; // Mock findContentInTargetInstance so we can control its return value @@ -326,7 +326,7 @@ describe("filterContentItemsForProcessing — required field validation (PROD-25 beforeEach(() => { preflightReport.reset(); - registerEffectiveTargetModel("PromoBanner", promoBanner); + recordSavedTarget("model", 1, promoBanner, "PromoBanner"); }); it("routes an item missing a required scalar to itemsToFail, never itemsToProcess", async () => { @@ -395,7 +395,7 @@ describe("filterContentItemsForProcessing — required field validation (PROD-25 it("records an invalid (not create) preflight entry with the reason", async () => { setState({ preflight: true }); - registerEffectiveTargetModel("PromoBanner", promoBanner); + recordSavedTarget("model", 1, promoBanner, "PromoBanner"); mockFind.mockReturnValue(wouldCreate); await filterContentItemsForProcessing( makeBaseProps([bannerItem(1, { enabled: "true" }), bannerItem(2, { promoTitle: "a", promoSubtitle: "b" })]) diff --git a/src/lib/pushers/content-pusher/util/tests/resolve-effective-target-model.test.ts b/src/lib/pushers/content-pusher/util/tests/resolve-effective-target-model.test.ts index 4563c56c..3c97cd79 100644 --- a/src/lib/pushers/content-pusher/util/tests/resolve-effective-target-model.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/resolve-effective-target-model.test.ts @@ -1,7 +1,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { resetState, setState, registerEffectiveTargetModel } from "core/state"; +import { resetState, setState, recordSavedTarget } from "core/state"; import { createEffectiveTargetModelResolver } from "../resolve-effective-target-model"; let tmpDir: string; @@ -45,10 +45,10 @@ function seed(sourceGuid: string, targetGuid: string, opts: { targetFile: boolea } describe("createEffectiveTargetModelResolver (PROD-2542)", () => { - it("prefers the run-scoped registry over the (stale) target cache", () => { + it("prefers the model saved this run over the (stale) target cache", () => { seed("etm-src-1", "etm-tgt-1", { targetFile: true }); const saved = { referenceName: "Post", fields: [{ name: "saved" }] }; - registerEffectiveTargetModel("Post", saved); + recordSavedTarget("model", 1, saved, "Post"); const resolve = createEffectiveTargetModelResolver("etm-src-1", "etm-tgt-1"); expect(resolve("post")).toBe(saved); }); diff --git a/src/lib/pushers/gallery-pusher.ts b/src/lib/pushers/gallery-pusher.ts index d0f219bc..16adb5f4 100644 --- a/src/lib/pushers/gallery-pusher.ts +++ b/src/lib/pushers/gallery-pusher.ts @@ -1,7 +1,7 @@ import * as mgmtApi from "@agility/management-sdk"; import ansiColors from "ansi-colors"; import { Logs } from "core/logs"; -import { state, getState, getApiClient, getLoggerForGuid } from "core/state"; +import { state, getState, getApiClient, getLoggerForGuid, recordSavedTarget } from "core/state"; import { GalleryMapper } from "lib/mappers/gallery-mapper"; import { preflightReport } from "../preflight/preflight-report"; import { FailureDetail, PusherResult } from "types/sourceData"; @@ -197,6 +197,7 @@ async function createGallery( // Let errors propagate to caller for proper failure tracking const savedGallery = await apiClient.assetMethods.saveGallery(targetGuid, payload); referenceMapper.addMapping(mediaGrouping, savedGallery); + recordSavedTarget("gallery", mediaGrouping.mediaGroupingID, savedGallery, mediaGrouping.name); logger.gallery.created(mediaGrouping, "created", targetGuid); } @@ -229,5 +230,6 @@ async function updateGallery( }; const savedGallery = await apiClient.assetMethods.saveGallery(targetGuid, payload); referenceMapper.addMapping(sourceGallery, savedGallery); + recordSavedTarget("gallery", sourceGallery.mediaGroupingID, savedGallery, sourceGallery.name); logger.gallery.updated(sourceGallery, "updated", targetGuid); } diff --git a/src/lib/pushers/model-pusher.ts b/src/lib/pushers/model-pusher.ts index 9439fdce..354cf73e 100644 --- a/src/lib/pushers/model-pusher.ts +++ b/src/lib/pushers/model-pusher.ts @@ -1,5 +1,5 @@ import * as mgmtApi from "@agility/management-sdk"; -import { getApiClient, state, getLoggerForGuid, registerEffectiveTargetModel } from "../../core/state"; +import { getApiClient, state, getLoggerForGuid, recordSavedTarget } from "../../core/state"; import { PusherResult, FailureDetail } from "../../types/sourceData"; import { ModelMapper } from "lib/mappers/model-mapper"; import { Logs } from "core/logs"; @@ -408,11 +408,11 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp // PROD-2542: a real run would save the source definition, so content is validated against it. for (const model of shouldCreateStub) { preflightReport.record({ phase: "Models", action: "create", name: model.referenceName }); - registerEffectiveTargetModel(model.referenceName, model); + recordSavedTarget("model", model.id, model, model.referenceName); } for (const model of shouldUpdateFields) { preflightReport.record({ phase: "Models", action: "update", name: model.referenceName }); - registerEffectiveTargetModel(model.referenceName, model); + recordSavedTarget("model", model.id, model, model.referenceName); } for (const { model, reason } of shouldSkip) { const isConflict = /target model has changed|no longer exists on the target/i.test(reason); @@ -571,11 +571,13 @@ async function updateExistingModel( const updatedModel = await apiClient.modelMethods.saveModel(updatePayload, targetGuid); logger.model.updated(sourceModel, "updated", targetGuid); referenceMapper.addMapping(sourceModel, updatedModel); - // PROD-2542: the target models/.json cache isn't rewritten here, so record what was saved - // for content validation later in this run (the payload, if the response omits the fields). - registerEffectiveTargetModel( - sourceModel.referenceName, - Array.isArray(updatedModel?.fields) ? updatedModel : updatePayload + // PROD-2542: content later in this run is validated against what was saved, not the pulled + // target (the payload stands in if the response omits the fields). + recordSavedTarget( + "model", + sourceModel.id, + Array.isArray(updatedModel?.fields) ? updatedModel : updatePayload, + sourceModel.referenceName ); return "updated"; } catch (error: any) { @@ -587,7 +589,7 @@ async function updateExistingModel( if (recovered && (recovered.fields?.length ?? 0) === (sourceModel.fields?.length ?? 0)) { logger.model.updated(sourceModel, "updated", targetGuid); referenceMapper.addMapping(sourceModel, recovered); - registerEffectiveTargetModel(sourceModel.referenceName, recovered); + recordSavedTarget("model", sourceModel.id, recovered, sourceModel.referenceName); return "updated"; } logger.model.error(sourceModel, error, targetGuid); diff --git a/src/lib/pushers/template-pusher.ts b/src/lib/pushers/template-pusher.ts index da026b8d..7fde6802 100644 --- a/src/lib/pushers/template-pusher.ts +++ b/src/lib/pushers/template-pusher.ts @@ -1,5 +1,5 @@ import * as mgmtApi from "@agility/management-sdk"; -import { state, getLoggerForGuid, registerHeldBackTemplate } from "../../core/state"; +import { state, getLoggerForGuid, registerHeldBackTemplate, recordSavedTarget } from "../../core/state"; import { TemplateMapper } from "lib/mappers/template-mapper"; import { ModelMapper } from "lib/mappers/model-mapper"; import { ContainerMapper } from "lib/mappers/container-mapper"; @@ -211,6 +211,8 @@ export async function pushTemplates( try { const savedTemplate = await apiClient.pageMethods.savePageTemplate(targetGuid, locale, payload); templateMapper.addMapping(sourceTemplate, savedTemplate); + // The page pusher translates zone names against this template; the pulled copy lacks any zone added here. + recordSavedTarget("template", sourceTemplate.pageTemplateID, savedTemplate, sourceTemplate.pageTemplateName); // Refresh section-level ID mappings from the confirmed response — this both seeds the // mapping the first time a section is seen and keeps it correct going forward, regardless diff --git a/src/lib/pushers/tests/container-pusher.test.ts b/src/lib/pushers/tests/container-pusher.test.ts index b57d4c17..bf2ec65d 100644 --- a/src/lib/pushers/tests/container-pusher.test.ts +++ b/src/lib/pushers/tests/container-pusher.test.ts @@ -233,14 +233,17 @@ describe("pushContainers — adopt existing target container by referenceName (P }); }); -// ─── pushContainers — PROD-2346 local cache write ───────────────────────────── +// ─── pushContainers — PROD-2346 saved container visible later in the run ───── -describe("pushContainers — writes local container cache file (PROD-2346)", () => { - function cachedContainerPath(guid: string, contentViewID: number): string { - return path.join(tmpDir, guid, "containers", `${contentViewID}.json`); +describe("pushContainers — saved container is what the mapper returns for the rest of the run (PROD-2346)", () => { + // What later phases (the PROD-2309 content guard) read: the mapped target container. + async function mappedTargetContainer(sourceContentViewID: number) { + const { ContainerMapper } = await import("lib/mappers/container-mapper"); + const mapper = new ContainerMapper("src-cont-u", "tgt-cont-u"); + return mapper.getMappedEntity(mapper.getContainerMappingByContentViewID(sourceContentViewID, "source")!, "target"); } - it("writes the target container to disk after a successful create", async () => { + it("returns the created container after a successful create", async () => { const created = makeContainer({ contentViewID: 501, contentDefinitionID: 1, @@ -256,16 +259,14 @@ describe("pushContainers — writes local container cache file (PROD-2346)", () const result = await pushContainers([sourceContainer], []); expect(result.successful).toBe(1); - const cachePath = cachedContainerPath("tgt-cont-u", 501); - expect(fs.existsSync(cachePath)).toBe(true); - expect(JSON.parse(fs.readFileSync(cachePath, "utf8"))).toMatchObject({ + expect(await mappedTargetContainer(sourceContainer.contentViewID)).toMatchObject({ contentViewID: 501, referenceName: created.referenceName, lastModifiedDate: "01/01/2024 10:00AM", }); }); - it("writes the target container to disk after a successful update", async () => { + it("returns the updated container after a successful update, not the pulled one", async () => { const referenceName = "UpdateCacheTest"; // First run: create the container, establishing the mapping and initial cache file. @@ -303,16 +304,14 @@ describe("pushContainers — writes local container cache file (PROD-2346)", () const result = await pushContainers([sourceV2], [targetUnchanged]); expect(result.successful).toBe(1); - const cachePath = cachedContainerPath("tgt-cont-u", 502); - expect(fs.existsSync(cachePath)).toBe(true); - expect(JSON.parse(fs.readFileSync(cachePath, "utf8"))).toMatchObject({ + expect(await mappedTargetContainer(900)).toMatchObject({ contentViewID: 502, referenceName, lastModifiedDate: "06/01/2025 10:00AM", }); }); - it("writes the target container to disk after adopting by referenceName (PROD-2307)", async () => { + it("returns the adopted container after adopting by referenceName (PROD-2307)", async () => { const saveContainer = jest.fn(); state.cachedApiClient = { containerMethods: { saveContainer } } as any; @@ -330,9 +329,7 @@ describe("pushContainers — writes local container cache file (PROD-2346)", () expect(result.skipped).toBe(1); expect(saveContainer).not.toHaveBeenCalled(); - const cachePath = cachedContainerPath("tgt-cont-u", 503); - expect(fs.existsSync(cachePath)).toBe(true); - expect(JSON.parse(fs.readFileSync(cachePath, "utf8"))).toMatchObject({ + expect(await mappedTargetContainer(source.contentViewID)).toMatchObject({ contentViewID: 503, referenceName: "AdoptCacheTest", }); diff --git a/src/lib/pushers/tests/model-pusher.test.ts b/src/lib/pushers/tests/model-pusher.test.ts index 76fe0933..b9cfbc84 100644 --- a/src/lib/pushers/tests/model-pusher.test.ts +++ b/src/lib/pushers/tests/model-pusher.test.ts @@ -945,7 +945,7 @@ describe("pushModels — effective target model registry (PROD-2542)", () => { const { pushModels } = await import("../model-pusher"); await pushModels([makeModel({ referenceName: "PromoBanner", fields: requiredTitle })], []); - expect(stateModule.getEffectiveTargetModel("promobanner")).toBe(saved); + expect(stateModule.getSavedTargetBySourceReferenceName("model", "promobanner")).toBe(saved); }); it("registers the sent payload when the save response carries no fields", async () => { @@ -955,7 +955,7 @@ describe("pushModels — effective target model registry (PROD-2542)", () => { const { pushModels } = await import("../model-pusher"); await pushModels([makeModel({ referenceName: "NoFields", fields: requiredTitle })], []); - const registered = stateModule.getEffectiveTargetModel("NoFields"); + const registered = stateModule.getSavedTargetBySourceReferenceName("model", "NoFields"); expect(registered.fields).toEqual(requiredTitle); expect(registered.id).toBe(89); }); @@ -967,7 +967,7 @@ describe("pushModels — effective target model registry (PROD-2542)", () => { const { pushModels } = await import("../model-pusher"); await pushModels([makeModel({ referenceName: "Broken", fields: requiredTitle })], []); - expect(stateModule.getEffectiveTargetModel("Broken")).toBeUndefined(); + expect(stateModule.getSavedTargetBySourceReferenceName("model", "Broken")).toBeUndefined(); }); it("registers the source model for a planned create under --preflight (no saveModel)", async () => { @@ -980,6 +980,6 @@ describe("pushModels — effective target model registry (PROD-2542)", () => { await pushModels([source], []); expect(saveModel).not.toHaveBeenCalled(); - expect(stateModule.getEffectiveTargetModel("Planned")).toBe(source); + expect(stateModule.getSavedTargetBySourceReferenceName("model", "Planned")).toBe(source); }); }); diff --git a/src/lib/pushers/tests/template-pusher.test.ts b/src/lib/pushers/tests/template-pusher.test.ts index 75528f8b..194b61b1 100644 --- a/src/lib/pushers/tests/template-pusher.test.ts +++ b/src/lib/pushers/tests/template-pusher.test.ts @@ -538,3 +538,52 @@ describe("pushTemplates — stale mapping to a deleted target template (PROD-260 expect(second.skipped).toBe(1); }); }); + +// ─── pushTemplates — saved template visible later in the run ───────────────── + +describe("pushTemplates — saved template is what the mapper returns for the rest of the run", () => { + it("returns the saved template, so the page pusher sees zones added in the same run", async () => { + const sourceTpl = makeTemplate({ + pageTemplateID: 7001, + pageTemplateName: "ZoneAddedTemplate", + contentSectionDefinitions: [ + { pageItemTemplateID: 1, pageItemTemplateReferenceName: "banner-zone", itemOrder: 1, contentViewID: -1 }, + { pageItemTemplateID: 2, pageItemTemplateReferenceName: "main-content-zone", itemOrder: 2, contentViewID: -1 }, + ], + }); + const targetTplBefore = makeTemplate({ + pageTemplateID: 7002, + pageTemplateName: "ZoneAddedTemplate", + contentSectionDefinitions: [ + { pageItemTemplateID: 20, pageItemTemplateReferenceName: "main-content-zone", itemOrder: 1, contentViewID: -1 }, + ], + }); + + // The target template as pulled at the start of the run, before the source's new zone reached it. + new TemplateMapper("src-tpl-u", "tgt-tpl-u").addMapping(sourceTpl, targetTplBefore); + const { fileOperations } = await import("core"); + new fileOperations("tgt-tpl-u").exportFiles("templates", 7002, targetTplBefore); + + const savedTpl = { + ...targetTplBefore, + contentSectionDefinitions: [ + { pageItemTemplateID: 21, pageItemTemplateReferenceName: "banner-zone", itemOrder: 1, contentViewID: -1 }, + { pageItemTemplateID: 20, pageItemTemplateReferenceName: "main-content-zone", itemOrder: 2, contentViewID: -1 }, + ], + }; + const savePageTemplate = jest.fn().mockResolvedValue(savedTpl); + state.cachedApiClient = { pageMethods: { savePageTemplate } } as any; + + const { pushTemplates } = await import("../template-pusher"); + const result = await pushTemplates([sourceTpl], [targetTplBefore], "en-us"); + expect(result.successful).toBe(1); + + // What the page pusher reads to translate zone names. + const mapper = new TemplateMapper("src-tpl-u", "tgt-tpl-u"); + const cached = mapper.getMappedEntity(mapper.getTemplateMappingByPageTemplateName("ZoneAddedTemplate", "source")!, "target"); + expect(cached!.contentSectionDefinitions.map((s: any) => s.pageItemTemplateReferenceName)).toEqual([ + "banner-zone", + "main-content-zone", + ]); + }); +}); diff --git a/src/tests/sync/same-run-saved-targets.test.ts b/src/tests/sync/same-run-saved-targets.test.ts new file mode 100644 index 00000000..fdb8608c --- /dev/null +++ b/src/tests/sync/same-run-saved-targets.test.ts @@ -0,0 +1,84 @@ +/** + * Entities saved earlier in a run must be what later phases read, not the target as pulled. + * + * The mappers read the target off disk, and the disk holds the target as it was pulled at the + * start of the run. A template that gains a zone in the template phase is therefore missing that + * zone when the page phase translates zone names against it, and the page's modules for the new + * zone overflow into the template's first zone (seen on a real instance: a home page banner + * landed in the main content zone). + */ +import { SyncHarness } from "./helpers/run-sync"; +import { installNetworkStubs, InstalledNetworkStubs } from "./helpers/network-stubs"; +import { + ALL_ELEMENTS, + FIXTURE_LOCALE, + PAGE_HOME, + TEMPLATE_STANDARD, + fullInstanceFixture, +} from "./helpers/full-instance-fixture"; + +jest.mock("lib/pushers/batch-polling", () => ({ + pollBatchUntilComplete: jest.fn(), + extractContentBatchResults: jest.fn(), + extractPageBatchResults: jest.fn(), +})); + +jest.mock("axios", () => ({ post: jest.fn(), get: jest.fn(), default: { post: jest.fn() } })); + +jest.mock("lib/pushers/url-redirection-api", () => ({ + MAX_URL_REDIRECTION_BATCH_SIZE: 250, + saveUrlRedirections: jest.fn(), +})); + +describe("same-run saved targets", () => { + let harness: SyncHarness; + let net: InstalledNetworkStubs; + + beforeEach(() => { + net = installNetworkStubs(); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + harness?.cleanup(); + net.restore(); + jest.restoreAllMocks(); + }); + + it("places a module in a zone the template gained earlier in the same run", async () => { + const postModule = { module: "PostModule", item: { contentid: 500 } }; + const fixture = fullInstanceFixture(); + fixture.source[`${FIXTURE_LOCALE}/page/800.json`] = { ...PAGE_HOME, zones: { Main: [postModule] } }; + + harness = new SyncHarness({ elements: ALL_ELEMENTS, fixture, overwrite: true }); + await harness.run(); + + // The source template gains a Banner zone ahead of Main, and the page moves its module there. + harness.editSourceFile("templates/700.json", { + ...TEMPLATE_STANDARD, + contentSectionDefinitions: [ + { pageItemTemplateID: 72, pageItemTemplateReferenceName: "Banner", itemOrder: 0, contentViewID: -1 }, + { pageItemTemplateID: 71, pageItemTemplateReferenceName: "Main", itemOrder: 1, contentViewID: -1 }, + ], + }); + harness.editSourceFile(`${FIXTURE_LOCALE}/page/800.json`, { + ...PAGE_HOME, + zones: { Banner: [postModule], Main: [] }, + properties: { ...PAGE_HOME.properties, versionID: 951, modified: "2026-02-02T00:00:00.000" }, + }); + + await harness.resync(); + + const targetContentID = harness.readMappings("item", FIXTURE_LOCALE)![0].targetContentID; + const pageSaves = harness.fake.callsTo("pageMethods.savePage"); + expect(pageSaves).toHaveLength(1); + const zones = pageSaves[0].payload.zones; + + expect(zones.Banner).toEqual([ + expect.objectContaining({ item: expect.objectContaining({ contentid: targetContentID }) }), + ]); + expect(zones.Main).toEqual([]); + }); +}); From 3c66e862154dc69e34d71667b4b61f3b1a440129 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 19 Oct 2026 11:17:01 -0400 Subject: [PATCH 2/2] Chore: bump version to 1.2.1 Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 86229756..f96cd770 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agility/cli", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agility/cli", - "version": "1.2.0", + "version": "1.2.1", "license": "ISC", "dependencies": { "@agility/content-sync": "^1.2.0", diff --git a/package.json b/package.json index 407788ab..63196926 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agility/cli", - "version": "1.2.0", + "version": "1.2.1", "repository": { "type": "git", "url": "https://github.com/agility/agility-cli.git"