diff --git a/package-lock.json b/package-lock.json index 8622975..f96cd77 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 407788a..6319692 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" diff --git a/src/core/push.ts b/src/core/push.ts index 1143765..038b0ce 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 4c20d3d..0305535 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 5f75e3f..69c750c 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 ead4904..548c6ce 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 f3e70a7..7e9536e 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 defe27d..6d64815 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 369f035..a5b9168 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 5eb4773..905fd5b 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 db0f39c..13f7282 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 696d819..db57f1d 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 4cf6a7d..9786616 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 d5a1a8c..000ca33 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 0000000..af4918c --- /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 d4b4edc..0c84ec4 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 1c0d303..246dfbe 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 cfdefe7..981e0f8 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 6f0a2a1..6e887c1 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 4563c56..3c97cd7 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 d0f219b..16adb5f 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 9439fdc..354cf73 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 da026b8..7fde680 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 b57d4c1..bf2ec65 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 76fe093..b9cfbc8 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 75528f8..194b61b 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 0000000..fdb8608 --- /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([]); + }); +});