diff --git a/README.md b/README.md index 05a4ecb..ab351a0 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,8 @@ The `--autoPublish` flag lets you automatically publish synced content and/or pa 4. Publishing is done per-locale to match the workflow API requirements 5. After publishing, reference mappings are updated to reflect the new published versions +A page's components are shared by all of its locales, so a save that adds, removes or reorders components in one locale also puts the page in staging in its other locales. When the page was published in another locale of the run before the save, and is published in the source, `pages` and `both` republish it in that locale too. With `content` or no `--autoPublish`, the sync lists those pages under "Pages Left in Staging by Another Locale's Save" (and in `--jsonSummary` under `autoPublish.warnings`, type `staging`). Locales not included in the run are not checked. + #### Examples ```bash diff --git a/src/core/json-summary.ts b/src/core/json-summary.ts index 801b95b..9e62f3f 100644 --- a/src/core/json-summary.ts +++ b/src/core/json-summary.ts @@ -301,9 +301,12 @@ export function buildJsonSummary(input: BuildJsonSummaryInput): JsonSummary { ); // Same split the console summary uses (PROD-2311): "publish"/"fatal" block the run, - // "mapping"/"refresh" are post-publish bookkeeping and deliberately do not. + // "mapping"/"refresh" are post-publish bookkeeping and deliberately do not, nor does "staging" + // (PROD-2782: a page another locale's save left in staging, not republished). const autoPublishErrors = input.autoPublishErrors.filter((e) => e.type === "publish" || e.type === "fatal"); - const autoPublishWarnings = input.autoPublishErrors.filter((e) => e.type === "mapping" || e.type === "refresh"); + const autoPublishWarnings = input.autoPublishErrors.filter( + (e) => e.type === "mapping" || e.type === "refresh" || e.type === "staging" + ); return { schemaVersion: JSON_SUMMARY_SCHEMA_VERSION, diff --git a/src/core/push.ts b/src/core/push.ts index 1b00072..1143765 100644 --- a/src/core/push.ts +++ b/src/core/push.ts @@ -6,6 +6,8 @@ import { clearFailedContentRegistry, clearHeldBackTemplateRegistry, clearEffectiveTargetModels, + clearOtherLocaleStagingRegistry, + getOtherLocaleStaging, getPageCmsLink, getContentCmsLink, } from "./state"; @@ -21,6 +23,48 @@ import { buildJsonSummary, writeJsonSummary, JsonSummaryDetail, JsonSummaryPhase import type { FailureDetail } from "../types/sourceData"; import { foreignCdnReport } from "../lib/content/foreign-cdn-report"; import { resetForeignCdnDetectorCache } from "../lib/pushers/content-pusher/util/report-foreign-cdn-references"; +import type { OtherLocaleStagingEntry } from "./state"; + +/** + * PROD-2782: whether an --autoPublish mode publishes pages ("pages" or "both"). + */ +export function autoPublishIncludesPages(autoPublishMode: string | undefined): boolean { + return autoPublishMode === "pages" || autoPublishMode === "both"; +} + +/** + * PROD-2782: add the pages a save in one locale put in staging in another locale to that locale's + * page publish list. Duplicates are left in; executeAutoPublish de-duplicates per locale. + */ +export function mergeOtherLocaleStaging( + pageIdsByLocale: Map, + stagingByLocale: Map +): void { + stagingByLocale.forEach((entries, locale) => { + const existing = pageIdsByLocale.get(locale) || []; + pageIdsByLocale.set(locale, [...existing, ...entries.map((e) => e.targetPageID)]); + }); +} + +/** + * PROD-2782: non-blocking "staging" notices for pages a save in one locale put in staging in + * another locale, used when auto-publish does not publish pages and so cannot republish them. + */ +export function otherLocaleStagingWarnings( + stagingByLocale: Map +): Array<{ locale: string; type: string; error: string }> { + const warnings: Array<{ locale: string; type: string; error: string }> = []; + stagingByLocale.forEach((entries, locale) => { + for (const { targetPageID, pageName, savedLocale } of entries) { + warnings.push({ + locale, + type: "staging", + error: `page "${pageName}" (target page ${targetPageID}) was put in staging by the ${savedLocale} save and was not republished (auto-publish does not include pages)`, + }); + } + }); + return warnings; +} /** * PROD-2310: Decide whether a set of auto-publish errors should fail the sync exit code. @@ -61,6 +105,7 @@ export class Push { clearHeldBackTemplateRegistry(); // PROD-2542: models saved by a previous run must not stand in for this run's target models clearEffectiveTargetModels(); + clearOtherLocaleStagingRegistry(); // Preflight (PROD-2203): start each run with a clean report of planned actions preflightReport.reset(); @@ -210,6 +255,12 @@ export class Push { autoPublishErrors = await this.executeAutoPublish(results, autoPublish); } + // PROD-2782: pages a save in one locale put in staging in another locale are republished by + // auto-publish when it covers pages. Otherwise, say which ones were left in staging. + if (!autoPublishIncludesPages(autoPublish) || !isSync || state.preflight) { + autoPublishErrors.push(...otherLocaleStagingWarnings(getOtherLocaleStaging())); + } + // Split auto-publish outcomes: genuine publish failures (blocking) vs // post-publish mapping/refresh bookkeeping notices (non-blocking). PROD-2311 const genuinePublishErrors = autoPublishErrors.filter( @@ -218,6 +269,7 @@ export class Push { const mappingWarnings = autoPublishErrors.filter( ({ type }) => type === "mapping" || type === "refresh" ); + const stagingWarnings = autoPublishErrors.filter(({ type }) => type === "staging"); // PROD-2310: Fold auto-publish failures into the returned success signal so the // entry point can exit non-zero. "mapping"/"refresh" entries are post-publish @@ -322,6 +374,16 @@ export class Push { }); } + // PROD-2782: pages left in staging in a locale by another locale's save (no auto-publish of + // pages). Non-blocking: nothing was lost, the page needs publishing in that locale. + if (stagingWarnings.length > 0) { + console.log(ansiColors.yellow(`\n Pages Left in Staging by Another Locale's Save (non-blocking):`)); + stagingWarnings.forEach(({ locale, error }) => { + const localeDisplay = locale ? `[${locale}]` : ""; + console.log(ansiColors.yellow(` • ${localeDisplay} ${error}`)); + }); + } + // Show log file paths at the very end if (logFilePaths.length > 0) { console.log(ansiColors.cyan("\nšŸ“„ Log Files:")); @@ -494,7 +556,14 @@ export class Push { // Determine what to publish based on mode const publishContent = autoPublishMode === "content" || autoPublishMode === "both"; - const publishPages = autoPublishMode === "pages" || autoPublishMode === "both"; + const publishPages = autoPublishIncludesPages(autoPublishMode); + + // PROD-2782: a page saved in one locale may have been put in staging in another locale that + // this run did not save it in. Republish it there too. + const otherLocaleStaging = getOtherLocaleStaging(); + if (publishPages && otherLocaleStaging.size > 0) { + mergeOtherLocaleStaging(pageIdsByLocale, otherLocaleStaging); + } // Get all locales that have items to publish const allLocales = new Set(); @@ -527,6 +596,11 @@ export class Push { console.log(ansiColors.gray(`Locales to publish: ${Array.from(allLocales).join(", ")}`)); console.log(ansiColors.gray(`Total content items: ${totalContent}`)); console.log(ansiColors.gray(`Total pages: ${totalPages}`)); + if (publishPages && otherLocaleStaging.size > 0) { + let count = 0; + otherLocaleStaging.forEach((entries) => (count += entries.length)); + console.log(ansiColors.gray(` incl. ${count} page(s) put in staging by a save in another locale`)); + } // Import workflow dependencies const { processBatches } = await import("../lib/workflows/process-batches"); diff --git a/src/core/state.ts b/src/core/state.ts index 6367c63..4c20d3d 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -120,6 +120,20 @@ export interface State { // 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. heldBackTemplateRegistry: Map; + + // Other-locale staging registry (PROD-2782) - pages that a save in one locale put in staging in + // another locale, keyed by that other locale, then by target page ID. Auto-publish republishes + // them; without auto-publish they are reported as warnings. + otherLocaleStagingRegistry: Map>; + // "locale:targetPageID" for pages whose own save in that locale failed this run - never republished + otherLocaleStagingBlocked: Set; +} + +export interface OtherLocaleStagingEntry { + targetPageID: number; + pageName: string; + /** The locale whose save put this page in staging. */ + savedLocale: string; } // Global state - populated from argv and referenced throughout the app @@ -199,6 +213,10 @@ export const state: State = { // Held-back template registry - templates whose mapped target is gone and was not recreated heldBackTemplateRegistry: new Map(), + + // Other-locale staging registry - pages a save in one locale put in staging in another (PROD-2782) + otherLocaleStagingRegistry: new Map(), + otherLocaleStagingBlocked: new Set(), }; /** @@ -848,6 +866,49 @@ export function clearHeldBackTemplateRegistry(): void { state.heldBackTemplateRegistry.clear(); } +/** + * Record a page that a save in another locale put in staging in `locale` (PROD-2782) + * @param locale - The locale the page went to staging in + * @param entry - The target page and the locale whose save caused it + */ +export function registerOtherLocaleStaging(locale: string, entry: OtherLocaleStagingEntry): void { + if (state.otherLocaleStagingBlocked.has(`${locale}:${entry.targetPageID}`)) return; + let pages = state.otherLocaleStagingRegistry.get(locale); + if (!pages) { + pages = new Map(); + state.otherLocaleStagingRegistry.set(locale, pages); + } + pages.set(entry.targetPageID, entry); +} + +/** + * Never republish this page in `locale` this run - its own save in that locale failed. Drops it if + * it was already recorded, and keeps a later save in another locale from recording it again. + */ +export function blockOtherLocaleStaging(locale: string, targetPageID: number): void { + state.otherLocaleStagingRegistry.get(locale)?.delete(targetPageID); + state.otherLocaleStagingBlocked.add(`${locale}:${targetPageID}`); +} + +/** + * The pages recorded for each locale, as arrays + */ +export function getOtherLocaleStaging(): Map { + const result = new Map(); + state.otherLocaleStagingRegistry.forEach((pages, locale) => { + if (pages.size > 0) result.set(locale, Array.from(pages.values())); + }); + return result; +} + +/** + * Clear the other-locale staging registry (should be called at start of each sync) + */ +export function clearOtherLocaleStagingRegistry(): void { + state.otherLocaleStagingRegistry.clear(); + state.otherLocaleStagingBlocked.clear(); +} + /** * Get the CMS app URL based on environment * - dev/local/preprod: app-qa.publishwithagility.com diff --git a/src/core/tests/json-summary.test.ts b/src/core/tests/json-summary.test.ts index 9034769..c43f391 100644 --- a/src/core/tests/json-summary.test.ts +++ b/src/core/tests/json-summary.test.ts @@ -125,12 +125,13 @@ describe("buildJsonSummary — auto-publish classification", () => { { type: "fatal", error: "crashed" }, { type: "mapping", error: "mapping refresh skipped" }, { type: "refresh", error: "target refresh failed" }, + { type: "staging", error: "left in staging by another locale's save", locale: "pt-br" }, ], }) ); expect(summary.autoPublish.errors.map((e) => e.type)).toEqual(["publish", "fatal"]); - expect(summary.autoPublish.warnings.map((e) => e.type)).toEqual(["mapping", "refresh"]); + expect(summary.autoPublish.warnings.map((e) => e.type)).toEqual(["mapping", "refresh", "staging"]); }); it("yields empty groups when auto-publish reported nothing", () => { diff --git a/src/core/tests/push-auto-publish.test.ts b/src/core/tests/push-auto-publish.test.ts new file mode 100644 index 0000000..8f6e0fb --- /dev/null +++ b/src/core/tests/push-auto-publish.test.ts @@ -0,0 +1,89 @@ +/** + * PROD-2782: executeAutoPublish republishes pages that a save in one locale put in staging in + * another locale, alongside the pages the run saved. + */ +import { Push } from "../push"; +import { resetState, setState, registerOtherLocaleStaging, clearOtherLocaleStagingRegistry } from "../state"; + +jest.mock("../../lib/workflows/process-batches", () => ({ + processBatches: jest.fn(async (ids: number[]) => ({ processedIds: ids, failed: 0 })), +})); +jest.mock("../../lib/mappers/mapping-version-updater", () => ({ + updateMappingsAfterPublish: jest.fn(async () => ({ result: { errors: [] } })), +})); +jest.mock("../../lib/shared/get-fetch-api-status", () => ({ + waitForFetchApiSync: jest.fn(async () => undefined), +})); +jest.mock("../pull", () => ({ + Pull: jest.fn().mockImplementation(() => ({ pullInstances: jest.fn(async () => ({ success: true })) })), +})); + +import { processBatches } from "../../lib/workflows/process-batches"; +import { updateMappingsAfterPublish } from "../../lib/mappers/mapping-version-updater"; + +const mockProcessBatches = processBatches as jest.Mock; +const mockUpdateMappings = updateMappingsAfterPublish as jest.Mock; + +beforeEach(() => { + resetState(); + clearOtherLocaleStagingRegistry(); + setState({ sourceGuid: "src", targetGuid: "tgt" }); + mockProcessBatches.mockClear(); + mockUpdateMappings.mockClear(); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +/** The run saved page 98 in pt-br only; the pt-br save put page 98 in staging in en-us. */ +function results(): any[] { + return [ + { + publishableContentIdsByLocale: new Map(), + publishablePageIdsByLocale: new Map([["pt-br", [98, 40]]]), + }, + ]; +} + +function pageCalls(): Array<[number[], string]> { + return mockProcessBatches.mock.calls + .filter((c: any[]) => c[1] === "pages") + .map((c: any[]) => [c[0], c[2]] as [number[], string]); +} + +describe("executeAutoPublish — pages another locale's save put in staging (PROD-2782)", () => { + it("republishes the page in the locale it went to staging in, and updates its mapping", async () => { + registerOtherLocaleStaging("en-us", { targetPageID: 98, pageName: "how-it-works", savedLocale: "pt-br" }); + + const errors = await (new Push() as any).executeAutoPublish(results(), "both"); + + expect(errors).toEqual([]); + expect(pageCalls()).toEqual( + expect.arrayContaining([ + [[98, 40], "pt-br"], + [[98], "en-us"], + ]) + ); + expect(mockUpdateMappings).toHaveBeenCalledWith([], [98], "src", "tgt", "en-us"); + }); + + it("publishes a page once when the run also saved it in that locale", async () => { + registerOtherLocaleStaging("pt-br", { targetPageID: 40, pageName: "promotions", savedLocale: "en-us" }); + + await (new Push() as any).executeAutoPublish(results(), "pages"); + + expect(pageCalls()).toEqual([[[98, 40], "pt-br"]]); + }); + + it("does not republish pages when auto-publish is content-only", async () => { + registerOtherLocaleStaging("en-us", { targetPageID: 98, pageName: "how-it-works", savedLocale: "pt-br" }); + + await (new Push() as any).executeAutoPublish(results(), "content"); + + expect(pageCalls()).toEqual([]); + }); +}); diff --git a/src/core/tests/push.test.ts b/src/core/tests/push.test.ts index 8a61459..a4526a6 100644 --- a/src/core/tests/push.test.ts +++ b/src/core/tests/push.test.ts @@ -1,4 +1,11 @@ -import { Push, hasBlockingAutoPublishErrors, formatFailureName } from "../push"; +import { + Push, + hasBlockingAutoPublishErrors, + formatFailureName, + autoPublishIncludesPages, + mergeOtherLocaleStaging, + otherLocaleStagingWarnings, +} from "../push"; import { resetState, setState } from "../state"; import { Pull } from "../pull"; import { Pushers } from "../../lib/pushers/orchestrate-pushers"; @@ -78,6 +85,57 @@ describe("hasBlockingAutoPublishErrors", () => { ]) ).toBe(true); }); + + it("does not treat a page left in staging by another locale's save as blocking (PROD-2782)", () => { + expect(hasBlockingAutoPublishErrors([{ locale: "en-us", type: "staging", error: "left in staging" }])).toBe(false); + }); +}); + +// ─── PROD-2782: pages another locale's save put in staging ────────────────── + +describe("autoPublishIncludesPages (PROD-2782)", () => { + it("is true for pages and both, false otherwise", () => { + expect(autoPublishIncludesPages("pages")).toBe(true); + expect(autoPublishIncludesPages("both")).toBe(true); + expect(autoPublishIncludesPages("content")).toBe(false); + expect(autoPublishIncludesPages("")).toBe(false); + expect(autoPublishIncludesPages(undefined)).toBe(false); + }); +}); + +describe("mergeOtherLocaleStaging (PROD-2782)", () => { + it("adds each locale's pages to that locale's publish list, creating it if needed", () => { + const pageIdsByLocale = new Map([["en-us", [10, 98]]]); + mergeOtherLocaleStaging( + pageIdsByLocale, + new Map([ + ["en-us", [{ targetPageID: 98, pageName: "a", savedLocale: "pt-br" }]], + ["pt-br", [{ targetPageID: 40, pageName: "promotions", savedLocale: "en-us" }]], + ]) + ); + expect(pageIdsByLocale.get("en-us")).toEqual([10, 98, 98]); + expect(pageIdsByLocale.get("pt-br")).toEqual([40]); + }); +}); + +describe("otherLocaleStagingWarnings (PROD-2782)", () => { + it("emits one non-blocking staging notice per page, in the page's locale", () => { + const warnings = otherLocaleStagingWarnings( + new Map([["pt-br", [{ targetPageID: 40, pageName: "promotions", savedLocale: "en-us" }]]]) + ); + expect(warnings).toEqual([ + { + locale: "pt-br", + type: "staging", + error: + 'page "promotions" (target page 40) was put in staging by the en-us save and was not republished (auto-publish does not include pages)', + }, + ]); + }); + + it("is empty when nothing was recorded", () => { + expect(otherLocaleStagingWarnings(new Map())).toEqual([]); + }); }); // ─── --jsonSummary: the run is written as a machine-readable artifact ───────── diff --git a/src/lib/pushers/page-pusher/process-sitemap.ts b/src/lib/pushers/page-pusher/process-sitemap.ts index 10b7ce2..f478bf1 100644 --- a/src/lib/pushers/page-pusher/process-sitemap.ts +++ b/src/lib/pushers/page-pusher/process-sitemap.ts @@ -1,6 +1,6 @@ import * as mgmtApi from "@agility/management-sdk"; import ansiColors from "ansi-colors"; -import { state, getApiClient } from "../../../core/state"; +import { state, getApiClient, blockOtherLocaleStaging } from "../../../core/state"; import { FailureDetail, PusherResult } from "../../../types/sourceData"; import { SitemapHierarchy } from "./sitemap-hierarchy"; import { PageMapper } from "../../mappers/page-mapper"; @@ -201,6 +201,12 @@ export async function processSitemap({ } else { // pageRes.status is "failure" returnData.failed++; + // PROD-2782: a save in another locale may queue this page for republishing in this locale + // (before or after this point in the run). Its own save here failed, so leave it in staging. + const failedMapping = pageMapper.getPageMappingByPageID(sourcePage.pageID, "source"); + if (failedMapping) { + blockOtherLocaleStaging(locale, failedMapping.targetPageID); + } returnData.failureDetails.push({ name: sourcePage.name || `Page ${sourcePage.pageID}`, error: pageRes.error || "Unknown error", diff --git a/src/lib/pushers/page-pusher/refresh-other-locale-versions.ts b/src/lib/pushers/page-pusher/refresh-other-locale-versions.ts index dc3db58..5e97d0a 100644 --- a/src/lib/pushers/page-pusher/refresh-other-locale-versions.ts +++ b/src/lib/pushers/page-pusher/refresh-other-locale-versions.ts @@ -1,5 +1,9 @@ import * as mgmtApi from "@agility/management-sdk"; import { PageMapper } from "../../mappers/page-mapper"; +import { registerOtherLocaleStaging } from "core/state"; + +const STAGING_STATE = 1; +const PUBLISHED_STATE = 2; interface Props { targetPageID: number; @@ -25,6 +29,13 @@ interface Props { * Mapping files are oriented through PageMapper/fileOperations, so under reverse-sync this moves * the original source's recorded version, which is the side reverse-sync wrote to. * + * PROD-2782: a save that changes the page's components (add, remove, reorder) also puts the page in + * staging in every other locale, because the zones are shared and the server cuts a staging + * version for each language. Auto-publish only republishes pages saved in that locale, so the same + * re-read records the page for republishing when it was Published on the target before the run, + * is Staging now, and is Published on the source. A page left in staging on the source stays in + * staging on the target. + * * Returns the locales whose mapping was refreshed. Never throws — a failed re-read only leaves * the old behaviour (a spurious conflict on the next run) for that locale. */ @@ -57,6 +68,19 @@ export async function refreshOtherLocaleVersions({ const currentVersion = currentPage?.properties?.versionID; if (!currentVersion || currentVersion <= record.targetVersionID) continue; + const sourcePage = otherMapper.getMappedEntity(record, "source"); + if ( + pulledTargetPage.properties?.state === PUBLISHED_STATE && + currentPage.properties?.state === STAGING_STATE && + sourcePage?.properties?.state === PUBLISHED_STATE + ) { + registerOtherLocaleStaging(otherLocale, { + targetPageID, + pageName: currentPage.name || pulledTargetPage.name || `Page ${targetPageID}`, + savedLocale, + }); + } + const result = otherMapper.updateTargetVersionID(targetPageID, currentVersion); if (result.success) refreshed.push(otherLocale); } catch { diff --git a/src/lib/pushers/page-pusher/tests/process-sitemap.test.ts b/src/lib/pushers/page-pusher/tests/process-sitemap.test.ts index 674859b..9a9734b 100644 --- a/src/lib/pushers/page-pusher/tests/process-sitemap.test.ts +++ b/src/lib/pushers/page-pusher/tests/process-sitemap.test.ts @@ -1,7 +1,13 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { resetState, setState } from "core/state"; +import { + resetState, + setState, + registerOtherLocaleStaging, + getOtherLocaleStaging, + clearOtherLocaleStagingRegistry, +} from "core/state"; import { processSitemap, resetProcessedPageIDs } from "../process-sitemap"; import { SitemapNode } from "types/syncAnalysis"; @@ -26,6 +32,7 @@ afterAll(() => { beforeEach(() => { resetState(); + clearOtherLocaleStagingRegistry(); setState({ rootPath: tmpDir, sourceGuid: "src", targetGuid: "tgt" }); resetProcessedPageIDs(); mockProcessPage.mockClear(); @@ -250,6 +257,28 @@ describe("processSitemap — failed page processing", () => { const result = await processSitemap(makeProps({ sitemapNodes: nodes, sourcePages: pages })); expect(result.failureDetails[0].name).toContain("Page 5"); }); + + it("does not republish a page whose own save failed, even if another locale's save queued it (PROD-2782)", async () => { + // the pt-br save earlier in the run put target page 999 in staging in en-us + registerOtherLocaleStaging("en-us", { targetPageID: 999, pageName: "Page 5", savedLocale: "pt-br" }); + mockProcessPage.mockResolvedValue({ status: "failure", error: "boom" }); + + await processSitemap(makeProps({ sitemapNodes: [makeNode(5)], sourcePages: [makePage(5)] })); + + expect(getOtherLocaleStaging().size).toBe(0); + // and a later save in another locale cannot queue it again + registerOtherLocaleStaging("en-us", { targetPageID: 999, pageName: "Page 5", savedLocale: "fr-ca" }); + expect(getOtherLocaleStaging().size).toBe(0); + }); + + it("keeps a queued page when its own locale skipped it as up to date (PROD-2782)", async () => { + registerOtherLocaleStaging("en-us", { targetPageID: 999, pageName: "Page 5", savedLocale: "pt-br" }); + mockProcessPage.mockResolvedValue({ status: "skip" }); + + await processSitemap(makeProps({ sitemapNodes: [makeNode(5)], sourcePages: [makePage(5)] })); + + expect(getOtherLocaleStaging().get("en-us")).toHaveLength(1); + }); }); // ─── duplicate pageID prevention ────────────────────────────────────────────── diff --git a/src/lib/pushers/page-pusher/tests/refresh-other-locale-versions.test.ts b/src/lib/pushers/page-pusher/tests/refresh-other-locale-versions.test.ts index 110f5db..b9ef93f 100644 --- a/src/lib/pushers/page-pusher/tests/refresh-other-locale-versions.test.ts +++ b/src/lib/pushers/page-pusher/tests/refresh-other-locale-versions.test.ts @@ -1,7 +1,13 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { resetState, setState } from "core/state"; +import { + resetState, + setState, + getOtherLocaleStaging, + clearOtherLocaleStagingRegistry, + blockOtherLocaleStaging, +} from "core/state"; import { PageMapper } from "lib/mappers/page-mapper"; import { refreshOtherLocaleVersions } from "../refresh-other-locale-versions"; @@ -17,6 +23,7 @@ afterAll(() => { beforeEach(() => { resetState(); + clearOtherLocaleStagingRegistry(); setState({ rootPath: tmpDir }); jest.spyOn(console, "log").mockImplementation(() => {}); jest.spyOn(console, "warn").mockImplementation(() => {}); @@ -39,15 +46,25 @@ function nextPair() { TGT = `tgt-rl-${testCounter}`; } -function makePage(pageID: number, versionID: number): any { - return { pageID, name: "how-it-works", templateName: "Main", properties: { versionID } }; +const STAGING = 1; +const PUBLISHED = 2; + +function makePage(pageID: number, versionID: number, state?: number): any { + return { pageID, name: "how-it-works", templateName: "Main", properties: { versionID, state } }; } /** Write the pulled target page file for a locale (what the run read at start). */ -function writePulledTargetPage(locale: string, pageID: number, versionID: number) { +function writePulledTargetPage(locale: string, pageID: number, versionID: number, state?: number) { const dir = path.join(tmpDir, TGT, locale, "page"); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, `${pageID}.json`), JSON.stringify(makePage(pageID, versionID))); + fs.writeFileSync(path.join(dir, `${pageID}.json`), JSON.stringify(makePage(pageID, versionID, state))); +} + +/** Write the pulled source page file for a locale. */ +function writePulledSourcePage(locale: string, pageID: number, versionID: number, state?: number) { + const dir = path.join(tmpDir, SRC, locale, "page"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${pageID}.json`), JSON.stringify(makePage(pageID, versionID, state))); } /** Seed a locale's mapping record: source page 176 v{sourceVersion} -> target page 98 v{targetVersion}. */ @@ -59,10 +76,12 @@ function recordFor(locale: string) { return new PageMapper(SRC, TGT, locale).getPageMappingByPageID(98, "target")!; } -function makeApiClient(versionsByLocale: Record): any { +function makeApiClient(versionsByLocale: Record, statesByLocale: Record = {}): any { return { pageMethods: { - getPage: jest.fn(async (pageID: number, _guid: string, locale: string) => makePage(pageID, versionsByLocale[locale])), + getPage: jest.fn(async (pageID: number, _guid: string, locale: string) => + makePage(pageID, versionsByLocale[locale], statesByLocale[locale]) + ), }, }; } @@ -207,3 +226,82 @@ describe("refreshOtherLocaleVersions (PROD-2628)", () => { expect(recordFor("en-us").targetVersionID).toBe(1349); }); }); + +// ─── PROD-2782: other locale left in staging ────────────────────────────────── + +describe("refreshOtherLocaleVersions records pages put in staging in another locale (PROD-2782)", () => { + /** + * en-us was in sync and Published (target page 98 v1349, source page 176 Published). The pt-br + * save just cut en-us version 1362 in state {currentState}. + */ + async function runAfterSave(opts: { + pulledTargetState?: number; + currentState?: number; + sourceState?: number; + pulledTargetVersion?: number; + }) { + nextPair(); + seedMapping("en-us", 4071, 1349); + writePulledTargetPage("en-us", 98, opts.pulledTargetVersion ?? 1349, opts.pulledTargetState ?? PUBLISHED); + writePulledSourcePage("en-us", 176, 4071, opts.sourceState ?? PUBLISHED); + seedMapping("pt-br", 5000, 1362); + const apiClient = makeApiClient({ "en-us": 1362 }, { "en-us": opts.currentState ?? STAGING }); + + const refreshed = await refreshOtherLocaleVersions({ + targetPageID: 98, + savedLocale: "pt-br", + locales: ["en-us", "pt-br"], + sourceGuid: SRC, + targetGuid: TGT, + apiClient, + }); + return { refreshed, apiClient }; + } + + it("records the page when it was Published on the target, is Staging now, and is Published on the source", async () => { + const { refreshed } = await runAfterSave({}); + + expect(refreshed).toEqual(["en-us"]); + expect(getOtherLocaleStaging()).toEqual( + new Map([["en-us", [{ targetPageID: 98, pageName: "how-it-works", savedLocale: "pt-br" }]]]) + ); + // the saved locale itself is never recorded + expect(getOtherLocaleStaging().has("pt-br")).toBe(false); + }); + + it("does not record a page that was already in staging on the target before the run", async () => { + await runAfterSave({ pulledTargetState: STAGING }); + expect(getOtherLocaleStaging().size).toBe(0); + }); + + it("does not record a page the save left Published (a field-only save)", async () => { + await runAfterSave({ currentState: PUBLISHED }); + expect(getOtherLocaleStaging().size).toBe(0); + }); + + it("does not record a page that is in staging on the source - the target mirrors it", async () => { + await runAfterSave({ sourceState: STAGING }); + expect(getOtherLocaleStaging().size).toBe(0); + }); + + it("does not record a page with an independent target edit (it still conflicts)", async () => { + // pulled en-us page is ahead of the mapping's 1349 + const { apiClient } = await runAfterSave({ pulledTargetVersion: 1355 }); + expect(apiClient.pageMethods.getPage).not.toHaveBeenCalled(); + expect(getOtherLocaleStaging().size).toBe(0); + }); + + it("does not record a page whose own save in that locale failed earlier in the run", async () => { + blockOtherLocaleStaging("en-us", 98); + await runAfterSave({}); + expect(getOtherLocaleStaging().size).toBe(0); + }); + + it("drops a recorded page when its own save in that locale fails later in the run", async () => { + await runAfterSave({}); + expect(getOtherLocaleStaging().get("en-us")).toHaveLength(1); + + blockOtherLocaleStaging("en-us", 98); + expect(getOtherLocaleStaging().size).toBe(0); + }); +});