Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/lib/pushers/container-pusher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,45 @@ export async function pushContainers(
// if no mapping found, we should be creating the container
shouldCreate = existingMapping === null;

// PROD-2307: before creating, adopt an existing target container that matches by
// referenceName but has no mapping row (e.g. a fresh machine / wiped mapping cache,
// or a create that succeeded server-side but was never mapped). Container reference
// names are unique per instance, so a name match is a safe identity. This mirrors the
// model-pusher PROD-2211 map-on-adopt path: without it, a mapping-less run re-creates
// containers and drops the mapping row that downstream content/pages rely on.
if (shouldCreate) {
const targetByRef =
targetData?.find(
(tc: mgmtApi.Container) =>
tc.referenceName?.toLowerCase() === sourceContainer.referenceName?.toLowerCase()
) || null;

if (targetByRef) {
if (targetModelID >= 1 && targetByRef.contentDefinitionID !== targetModelID) {
// Pathological: same reference name, different model. Adopt on the (unique) name,
// but surface the mismatch so it can be investigated.
logger.log(
"WARN",
`Adopting existing target container "${sourceContainer.referenceName}" by referenceName, ` +
`but its model (${targetByRef.contentDefinitionID}) differs from the mapped source model (${targetModelID}).`
);
}

if (!state.preflight) {
containerMapper.addMapping(sourceContainer, targetByRef);
}
logger.container.skipped(sourceContainer, "already exists on target; mapping row created", targetGuid[0]);
preflightReport.record({
phase: "Containers",
action: "skip",
name: sourceContainer.referenceName,
detail: "adopted existing target container (mapping row created)",
});
skipped++;
continue; // finally{} still runs (processedCount++); no create/update this run
}
}

if (!shouldCreate) {
// get the target container, check if the source and targets need updates
const targetContainer: mgmtApi.Container =
Expand Down
79 changes: 79 additions & 0 deletions src/lib/pushers/tests/container-pusher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,85 @@ describe("pushContainers — RichTextArea special case", () => {
});
});

// ─── pushContainers — PROD-2307 adopt-by-referenceName ────────────────────────

describe("pushContainers — adopt existing target container by referenceName (PROD-2307)", () => {
it("adopts an unmapped target container that matches by referenceName instead of creating", async () => {
const saveContainer = jest.fn();
state.cachedApiClient = { containerMethods: { saveContainer } } as any;

const { ContainerMapper } = await import("lib/mappers/container-mapper");
const addMappingSpy = jest.spyOn(ContainerMapper.prototype, "addMapping");

const { pushContainers } = await import("../container-pusher");

// Source has no mapping row (fresh cache); target already has a same-named container.
const source = makeContainer({ referenceName: "AdoptMe", contentDefinitionID: 500 });
const target = makeContainer({ referenceName: "AdoptMe", contentDefinitionID: 500 });

const result = await pushContainers([source], [target]);

// Adopted, not created.
expect(saveContainer).not.toHaveBeenCalled();
expect(addMappingSpy).toHaveBeenCalledTimes(1);
expect(result.skipped).toBe(1);
expect(result.successful).toBe(0);
expect(result.failed).toBe(0);
});

it("matches referenceName case-insensitively", async () => {
const saveContainer = jest.fn();
state.cachedApiClient = { containerMethods: { saveContainer } } as any;

const { pushContainers } = await import("../container-pusher");

const source = makeContainer({ referenceName: "MixedCase", contentDefinitionID: 500 });
const target = makeContainer({ referenceName: "mixedcase", contentDefinitionID: 500 });

const result = await pushContainers([source], [target]);

expect(saveContainer).not.toHaveBeenCalled();
expect(result.skipped).toBe(1);
});

it("still creates when no target container matches by referenceName", async () => {
const created = makeContainer({ contentViewID: 777, contentDefinitionID: 1 });
const saveContainer = jest.fn().mockResolvedValue(created);
state.cachedApiClient = { containerMethods: { saveContainer } } as any;

const { pushContainers } = await import("../container-pusher");

// contentDefinitionID=1 → RichTextArea special case makes targetModelID valid, so create proceeds.
const source = makeContainer({ referenceName: "BrandNew", contentDefinitionID: 1 });
const nonMatch = makeContainer({ referenceName: "SomethingElse" });

const result = await pushContainers([source], [nonMatch]);

expect(saveContainer).toHaveBeenCalledTimes(1);
expect(result.successful).toBe(1);
});

it("does not write a mapping during preflight (dry run), but still records the adopt as a skip", async () => {
state.preflight = true;
const saveContainer = jest.fn();
state.cachedApiClient = { containerMethods: { saveContainer } } as any;

const { ContainerMapper } = await import("lib/mappers/container-mapper");
const addMappingSpy = jest.spyOn(ContainerMapper.prototype, "addMapping");

const { pushContainers } = await import("../container-pusher");

const source = makeContainer({ referenceName: "PreflightAdopt", contentDefinitionID: 500 });
const target = makeContainer({ referenceName: "PreflightAdopt", contentDefinitionID: 500 });

const result = await pushContainers([source], [target]);

expect(saveContainer).not.toHaveBeenCalled();
expect(addMappingSpy).not.toHaveBeenCalled();
expect(result.skipped).toBe(1);
});
});

// ─── pushContainers — result shape ────────────────────────────────────────────

describe("pushContainers — result shape", () => {
Expand Down
Loading