From 30cb649f530d476b496161ca3df580ef29f1b3d9 Mon Sep 17 00:00:00 2001 From: DerekAgility Date: Wed, 17 Jun 2026 09:10:56 -0400 Subject: [PATCH] CLI Integration tests --- .github/workflows/test.yml | 26 ++- .gitignore | 2 + src/tests/sync/helpers/assertions.ts | 138 ++++++++++++++ src/tests/sync/helpers/dependency-filter.ts | 48 +++++ src/tests/sync/helpers/mock-api-client.ts | 99 ++++++++++ src/tests/sync/helpers/scenario-loader.ts | 145 ++++++++++++++ .../scenarios/01-fresh-sync/scenario.json | 22 +++ .../state/src-1/en-us/item/1000.json | 16 ++ .../02-re-sync-no-changes/scenario.json | 22 +++ .../src-1-tgt-1/en-us/item/mappings.json | 10 + .../state/src-1/en-us/item/1000.json | 16 ++ .../state/tgt-1/en-us/item/5000.json | 16 ++ .../scenario.json | 25 +++ .../src-1-tgt-1/en-us/item/mappings.json | 10 + .../state/src-1/en-us/item/1000.json | 16 ++ .../scenarios/04-source-updated/scenario.json | 22 +++ .../src-1-tgt-1/en-us/item/mappings.json | 10 + .../state/src-1/en-us/item/1000.json | 16 ++ .../state/tgt-1/en-us/item/5000.json | 16 ++ .../sync/scenarios/05-conflict/scenario.json | 22 +++ .../src-1-tgt-1/en-us/item/mappings.json | 10 + .../state/src-1/en-us/item/1000.json | 16 ++ .../state/tgt-1/en-us/item/5000.json | 16 ++ .../scenarios/06-overwrite-mode/scenario.json | 25 +++ .../src-1-tgt-1/en-us/item/mappings.json | 10 + .../state/src-1/en-us/item/1000.json | 16 ++ .../state/tgt-1/en-us/item/5000.json | 16 ++ .../scenario.json | 28 +++ .../src-1-tgt-1/en-us/item/mappings.json | 10 + .../state/src-1/fr-ca/item/1000.json | 16 ++ .../scenarios/08-empty-source/scenario.json | 16 ++ .../09-models-with-deps-simple/scenario.json | 25 +++ .../src-1-tgt-1/containers/mappings.json | 22 +++ .../mappings/src-1-tgt-1/models/mappings.json | 22 +++ .../state/src-1/containers/210.json | 9 + .../state/src-1/en-us/item/1000.json | 16 ++ .../state/src-1/en-us/item/1100.json | 15 ++ .../state/src-1/models/20.json | 12 ++ .../state/tgt-1/containers/610.json | 9 + .../scenario.json | 26 +++ .../state/src-1/en-us/item/1000.json | 16 ++ .../state/src-1/en-us/item/1100.json | 15 ++ .../state/src-1/en-us/item/1200.json | 16 ++ .../11-model-push-fresh/scenario.json | 18 ++ .../state/src-1/models/10.json | 13 ++ .../scenario.json | 18 ++ .../mappings/src-1-tgt-1/models/mappings.json | 12 ++ .../state/src-1/models/10.json | 14 ++ .../state/tgt-1/models/50.json | 13 ++ .../scenario.json | 16 ++ .../state/src-1/models/10.json | 13 ++ .../state/tgt-1/models/50.json | 13 ++ .../scenario.json | 18 ++ .../mappings/src-1-tgt-1/models/mappings.json | 12 ++ .../state/src-1/models/10.json | 13 ++ .../state/tgt-1/models/50.json | 13 ++ .../15-multi-batch-content/scenario.json | 39 ++++ .../state/src-1/en-us/item/1000.json | 7 + .../state/src-1/en-us/item/1001.json | 7 + .../state/src-1/en-us/item/1002.json | 7 + .../state/src-1/en-us/item/1003.json | 7 + .../state/src-1/en-us/item/1004.json | 7 + .../16-container-push-fresh/scenario.json | 18 ++ .../mappings/src-1-tgt-1/models/mappings.json | 12 ++ .../state/src-1/containers/200.json | 9 + .../src-1-tgt-1/containers/mappings.json | 12 ++ .../mappings/src-1-tgt-1/models/mappings.json | 12 ++ .../scenarios/_base/src-1/containers/200.json | 9 + .../sync/scenarios/_base/src-1/models/10.json | 13 ++ .../scenarios/_base/tgt-1/containers/600.json | 9 + src/tests/sync/sync-scenarios.test.ts | 177 ++++++++++++++++++ tsconfig.test.json | 5 + 72 files changed, 1612 insertions(+), 3 deletions(-) create mode 100644 src/tests/sync/helpers/assertions.ts create mode 100644 src/tests/sync/helpers/dependency-filter.ts create mode 100644 src/tests/sync/helpers/mock-api-client.ts create mode 100644 src/tests/sync/helpers/scenario-loader.ts create mode 100644 src/tests/sync/scenarios/01-fresh-sync/scenario.json create mode 100644 src/tests/sync/scenarios/01-fresh-sync/state/src-1/en-us/item/1000.json create mode 100644 src/tests/sync/scenarios/02-re-sync-no-changes/scenario.json create mode 100644 src/tests/sync/scenarios/02-re-sync-no-changes/state/mappings/src-1-tgt-1/en-us/item/mappings.json create mode 100644 src/tests/sync/scenarios/02-re-sync-no-changes/state/src-1/en-us/item/1000.json create mode 100644 src/tests/sync/scenarios/02-re-sync-no-changes/state/tgt-1/en-us/item/5000.json create mode 100644 src/tests/sync/scenarios/03-stale-mapping-target-deleted/scenario.json create mode 100644 src/tests/sync/scenarios/03-stale-mapping-target-deleted/state/mappings/src-1-tgt-1/en-us/item/mappings.json create mode 100644 src/tests/sync/scenarios/03-stale-mapping-target-deleted/state/src-1/en-us/item/1000.json create mode 100644 src/tests/sync/scenarios/04-source-updated/scenario.json create mode 100644 src/tests/sync/scenarios/04-source-updated/state/mappings/src-1-tgt-1/en-us/item/mappings.json create mode 100644 src/tests/sync/scenarios/04-source-updated/state/src-1/en-us/item/1000.json create mode 100644 src/tests/sync/scenarios/04-source-updated/state/tgt-1/en-us/item/5000.json create mode 100644 src/tests/sync/scenarios/05-conflict/scenario.json create mode 100644 src/tests/sync/scenarios/05-conflict/state/mappings/src-1-tgt-1/en-us/item/mappings.json create mode 100644 src/tests/sync/scenarios/05-conflict/state/src-1/en-us/item/1000.json create mode 100644 src/tests/sync/scenarios/05-conflict/state/tgt-1/en-us/item/5000.json create mode 100644 src/tests/sync/scenarios/06-overwrite-mode/scenario.json create mode 100644 src/tests/sync/scenarios/06-overwrite-mode/state/mappings/src-1-tgt-1/en-us/item/mappings.json create mode 100644 src/tests/sync/scenarios/06-overwrite-mode/state/src-1/en-us/item/1000.json create mode 100644 src/tests/sync/scenarios/06-overwrite-mode/state/tgt-1/en-us/item/5000.json create mode 100644 src/tests/sync/scenarios/07-cross-locale-mapping-inheritance/scenario.json create mode 100644 src/tests/sync/scenarios/07-cross-locale-mapping-inheritance/state/mappings/src-1-tgt-1/en-us/item/mappings.json create mode 100644 src/tests/sync/scenarios/07-cross-locale-mapping-inheritance/state/src-1/fr-ca/item/1000.json create mode 100644 src/tests/sync/scenarios/08-empty-source/scenario.json create mode 100644 src/tests/sync/scenarios/09-models-with-deps-simple/scenario.json create mode 100644 src/tests/sync/scenarios/09-models-with-deps-simple/state/mappings/src-1-tgt-1/containers/mappings.json create mode 100644 src/tests/sync/scenarios/09-models-with-deps-simple/state/mappings/src-1-tgt-1/models/mappings.json create mode 100644 src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/containers/210.json create mode 100644 src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/en-us/item/1000.json create mode 100644 src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/en-us/item/1100.json create mode 100644 src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/models/20.json create mode 100644 src/tests/sync/scenarios/09-models-with-deps-simple/state/tgt-1/containers/610.json create mode 100644 src/tests/sync/scenarios/10-multi-item-batch-with-skip/scenario.json create mode 100644 src/tests/sync/scenarios/10-multi-item-batch-with-skip/state/src-1/en-us/item/1000.json create mode 100644 src/tests/sync/scenarios/10-multi-item-batch-with-skip/state/src-1/en-us/item/1100.json create mode 100644 src/tests/sync/scenarios/10-multi-item-batch-with-skip/state/src-1/en-us/item/1200.json create mode 100644 src/tests/sync/scenarios/11-model-push-fresh/scenario.json create mode 100644 src/tests/sync/scenarios/11-model-push-fresh/state/src-1/models/10.json create mode 100644 src/tests/sync/scenarios/12-model-push-source-updated/scenario.json create mode 100644 src/tests/sync/scenarios/12-model-push-source-updated/state/mappings/src-1-tgt-1/models/mappings.json create mode 100644 src/tests/sync/scenarios/12-model-push-source-updated/state/src-1/models/10.json create mode 100644 src/tests/sync/scenarios/12-model-push-source-updated/state/tgt-1/models/50.json create mode 100644 src/tests/sync/scenarios/13-model-exists-in-target-no-mapping/scenario.json create mode 100644 src/tests/sync/scenarios/13-model-exists-in-target-no-mapping/state/src-1/models/10.json create mode 100644 src/tests/sync/scenarios/13-model-exists-in-target-no-mapping/state/tgt-1/models/50.json create mode 100644 src/tests/sync/scenarios/14-model-target-side-change-skipped/scenario.json create mode 100644 src/tests/sync/scenarios/14-model-target-side-change-skipped/state/mappings/src-1-tgt-1/models/mappings.json create mode 100644 src/tests/sync/scenarios/14-model-target-side-change-skipped/state/src-1/models/10.json create mode 100644 src/tests/sync/scenarios/14-model-target-side-change-skipped/state/tgt-1/models/50.json create mode 100644 src/tests/sync/scenarios/15-multi-batch-content/scenario.json create mode 100644 src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1000.json create mode 100644 src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1001.json create mode 100644 src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1002.json create mode 100644 src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1003.json create mode 100644 src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1004.json create mode 100644 src/tests/sync/scenarios/16-container-push-fresh/scenario.json create mode 100644 src/tests/sync/scenarios/16-container-push-fresh/state/mappings/src-1-tgt-1/models/mappings.json create mode 100644 src/tests/sync/scenarios/16-container-push-fresh/state/src-1/containers/200.json create mode 100644 src/tests/sync/scenarios/_base/mappings/src-1-tgt-1/containers/mappings.json create mode 100644 src/tests/sync/scenarios/_base/mappings/src-1-tgt-1/models/mappings.json create mode 100644 src/tests/sync/scenarios/_base/src-1/containers/200.json create mode 100644 src/tests/sync/scenarios/_base/src-1/models/10.json create mode 100644 src/tests/sync/scenarios/_base/tgt-1/containers/600.json create mode 100644 src/tests/sync/sync-scenarios.test.ts create mode 100644 tsconfig.test.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4c75a47..22223fb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: - main jobs: - test: + unit-tests: name: Unit tests runs-on: ubuntu-latest @@ -24,5 +24,25 @@ jobs: - name: Install dependencies run: npm ci - - name: Run tests - run: npm test + - name: Run unit tests + run: npx jest src/core src/lib --no-coverage + + sync-scenarios: + name: Scenario tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run sync scenarios + run: npx jest src/tests/sync/sync-scenarios.test.ts --no-coverage diff --git a/.gitignore b/.gitignore index 527c0cb..97ea110 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ /dist/ /.agility-files/ /agility-files/ +# Catch stray agility-files directories created by tests with unpinned rootPath +**/agility-files/ /temp/ /code.json src/apiCall.ts diff --git a/src/tests/sync/helpers/assertions.ts b/src/tests/sync/helpers/assertions.ts new file mode 100644 index 0000000..c038ad6 --- /dev/null +++ b/src/tests/sync/helpers/assertions.ts @@ -0,0 +1,138 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { MockApiClient } from './mock-api-client'; +import { ScenarioExpectations, ExpectedMapping, ExpectedModelMapping, ExpectedContainerMapping } from './scenario-loader'; + +/** + * Read the resulting content item mappings file from a scenario's temp dir. + * Returns [] if no mappings were ever written. + */ +export function readItemMappings(rootPath: string, sourceGuid: string, targetGuid: string, locale: string): any[] { + const mappingFile = path.join( + rootPath, + 'mappings', + `${sourceGuid}-${targetGuid}`, + locale, + 'item', + 'mappings.json' + ); + if (!fs.existsSync(mappingFile)) return []; + return JSON.parse(fs.readFileSync(mappingFile, 'utf8')); +} + +export function readModelMappings(rootPath: string, sourceGuid: string, targetGuid: string): any[] { + const mappingFile = path.join( + rootPath, + 'mappings', + `${sourceGuid}-${targetGuid}`, + 'models', + 'mappings.json' + ); + if (!fs.existsSync(mappingFile)) return []; + return JSON.parse(fs.readFileSync(mappingFile, 'utf8')); +} + +export function readContainerMappings(rootPath: string, sourceGuid: string, targetGuid: string): any[] { + const mappingFile = path.join( + rootPath, + 'mappings', + `${sourceGuid}-${targetGuid}`, + 'containers', + 'mappings.json' + ); + if (!fs.existsSync(mappingFile)) return []; + return JSON.parse(fs.readFileSync(mappingFile, 'utf8')); +} + +/** + * Apply a scenario's `expect` block against the actual outcome. + * Each block is independently asserted so a single failure flags the right thing. + */ +export function assertScenarioOutcome(opts: { + expectations: ScenarioExpectations; + mockApi: MockApiClient; + itemMappings: any[]; + modelMappings?: any[]; + containerMappings?: any[]; +}): void { + const { expectations, mockApi, itemMappings, modelMappings, containerMappings } = opts; + + if (expectations.apiCalls?.saveContentItems !== undefined) { + expect(mockApi.contentMethods.saveContentItems).toHaveBeenCalledTimes( + expectations.apiCalls.saveContentItems + ); + } + + if (expectations.apiCalls?.saveModel !== undefined) { + expect(mockApi.modelMethods.saveModel).toHaveBeenCalledTimes( + expectations.apiCalls.saveModel + ); + } + + if (expectations.apiCalls?.saveContainer !== undefined) { + expect(mockApi.containerMethods.saveContainer).toHaveBeenCalledTimes( + expectations.apiCalls.saveContainer + ); + } + + if (expectations.noDuplicateMappingsBySourceID) { + const seen = new Set(); + const duplicates: number[] = []; + for (const m of itemMappings) { + if (seen.has(m.sourceContentID)) duplicates.push(m.sourceContentID); + seen.add(m.sourceContentID); + } + expect(duplicates).toEqual([]); + } + + if (expectations.mappings?.item) { + assertMappingsMatch(itemMappings, expectations.mappings.item); + } + + if (expectations.mappings?.model) { + assertModelMappingsMatch(modelMappings ?? [], expectations.mappings.model); + } + + if (expectations.mappings?.container) { + assertContainerMappingsMatch(containerMappings ?? [], expectations.mappings.container); + } +} + +function assertMappingsMatch(actual: any[], expected: ExpectedMapping[]): void { + expect(actual).toHaveLength(expected.length); + for (const want of expected) { + const match = actual.find((m) => m.sourceContentID === want.sourceContentID); + expect(match).toBeDefined(); + if (want.targetContentID !== '__any__') { + expect(match.targetContentID).toBe(want.targetContentID); + } + } +} + +function assertModelMappingsMatch(actual: any[], expected: ExpectedModelMapping[]): void { + expect(actual).toHaveLength(expected.length); + for (const want of expected) { + const match = actual.find((m) => m.sourceID === want.sourceID); + expect(match).toBeDefined(); + if (want.targetID !== '__any__') { + expect(match.targetID).toBe(want.targetID); + } + if (want.sourceReferenceName !== undefined) { + expect(match.sourceReferenceName).toBe(want.sourceReferenceName); + } + } +} + +function assertContainerMappingsMatch(actual: any[], expected: ExpectedContainerMapping[]): void { + expect(actual).toHaveLength(expected.length); + for (const want of expected) { + const match = actual.find((m) => m.sourceContentViewID === want.sourceContentViewID); + expect(match).toBeDefined(); + if (want.targetContentViewID !== '__any__') { + expect(match.targetContentViewID).toBe(want.targetContentViewID); + } + if (want.sourceReferenceName !== undefined) { + expect(match.sourceReferenceName).toBe(want.sourceReferenceName); + } + } +} diff --git a/src/tests/sync/helpers/dependency-filter.ts b/src/tests/sync/helpers/dependency-filter.ts new file mode 100644 index 0000000..e548769 --- /dev/null +++ b/src/tests/sync/helpers/dependency-filter.ts @@ -0,0 +1,48 @@ +import { fileOperations } from 'core'; +import { getModelsFromFileSystem } from 'lib/getters/filesystem/get-models'; +import { getContainersFromFileSystem } from 'lib/getters/filesystem/get-containers'; +import { ModelDependencyTreeBuilder } from 'lib/models/model-dependency-tree-builder'; + +/** + * Mirrors what GuidDataLoader.applyModelFiltering does for --models-with-deps: + * load the supporting entities, build a dependency tree, return only the content + * items the tree includes. + * + * Kept here (and not invoked through GuidDataLoader) so scenarios can run with + * the same filesystem-only inputs the rest of the runner uses, without pulling + * in incremental-change-detection plumbing that GuidDataLoader also does. + */ +export function applyModelsWithDepsFilter(opts: { + sourceGuid: string; + locale: string; + modelsWithDeps: string[]; + contentItems: any[]; +}): any[] { + const guidOps = new fileOperations(opts.sourceGuid); + const models = getModelsFromFileSystem(guidOps); + const containers = getContainersFromFileSystem(guidOps); + + const sourceData: any = { + models, + containers, + content: opts.contentItems, + templates: [], + pages: [], + assets: [], + galleries: [], + lists: [], + }; + + const builder = new ModelDependencyTreeBuilder(sourceData); + + const validation = builder.validateModels(opts.modelsWithDeps, models); + if (validation.invalid.length > 0) { + throw new Error( + `--models-with-deps validation failed; unknown model(s): ${validation.invalid.join(', ')}` + ); + } + + const tree = builder.buildDependencyTree(validation.valid, opts.locale); + + return opts.contentItems.filter((item) => tree.content.has(item.contentID)); +} diff --git a/src/tests/sync/helpers/mock-api-client.ts b/src/tests/sync/helpers/mock-api-client.ts new file mode 100644 index 0000000..60cfbbf --- /dev/null +++ b/src/tests/sync/helpers/mock-api-client.ts @@ -0,0 +1,99 @@ +import * as mgmtApi from '@agility/management-sdk'; + +export interface CapturedSaveCall { + payloads: any[]; + guid: string; + locale: string; +} + +export interface CapturedSaveModelCall { + payload: any; + guid: string; +} + +export interface CapturedSaveContainerCall { + payload: any; + guid: string; +} + +export interface BuiltSuccessItem { + originalItem: any; + newItem: { itemID: number; processedItemVersionID: number }; + newId: number; +} + +/** + * Test double for mgmtApi.ApiClient that captures content-method calls and + * assigns deterministic new content IDs for create operations. + * + * Pair with module mocks of `lib/pushers/batch-polling` so processBatches + * never reaches the real API or polling loop. + */ +export class MockApiClient { + capturedSaveCalls: CapturedSaveCall[] = []; + capturedSaveModelCalls: CapturedSaveModelCall[] = []; + capturedSaveContainerCalls: CapturedSaveContainerCall[] = []; + + private nextNewId: number; + private nextNewModelId: number; + private nextNewContainerId: number; + + constructor(opts: { startingNewId?: number; startingNewModelId?: number; startingNewContainerId?: number } = {}) { + this.nextNewId = opts.startingNewId ?? 9001; + this.nextNewModelId = opts.startingNewModelId ?? 7001; + this.nextNewContainerId = opts.startingNewContainerId ?? 8001; + } + + contentMethods = { + saveContentItems: jest.fn(async (payloads: any[], guid: string, locale: string, _returnBatchID: boolean) => { + this.capturedSaveCalls.push({ payloads, guid, locale }); + return [1000 + this.capturedSaveCalls.length]; + }), + }; + + modelMethods = { + saveModel: jest.fn(async (payload: any, guid: string) => { + this.capturedSaveModelCalls.push({ payload, guid }); + // Create: payload.id is 0 (stub) → assign new ID + // Update: payload.id > 0 → echo back same ID + const id = !payload?.id ? this.nextNewModelId++ : payload.id; + return { ...payload, id, lastModifiedDate: '2025-05-21T00:00:00.000' }; + }), + }; + + containerMethods = { + saveContainer: jest.fn(async (payload: any, guid: string, _includeContentDefinition: boolean) => { + this.capturedSaveContainerCalls.push({ payload, guid }); + // Create: payload.contentViewID === -1 → assign new ID + // Update: payload.contentViewID > 0 → echo back same ID + const contentViewID = !payload?.contentViewID || payload.contentViewID < 0 ? this.nextNewContainerId++ : payload.contentViewID; + return { ...payload, contentViewID, lastModifiedDate: '05/21/2025 12:00AM' }; + }), + }; + + batchMethods = { + getBatch: jest.fn(), + }; + + /** + * Build the `successfulItems` array that `extractBatchResults` would + * normally return. New IDs are assigned to items whose payload had + * contentID === -1; updates echo back the payload's contentID. + */ + buildSuccessfulItems(includedItems: any[], payloads: any[]): BuiltSuccessItem[] { + return includedItems.map((item, idx) => { + const payload = payloads[idx]; + const incomingId = payload?.contentID; + const newId = !incomingId || incomingId <= 0 ? this.nextNewId++ : incomingId; + return { + originalItem: item, + newItem: { itemID: newId, processedItemVersionID: 100 }, + newId, + }; + }); + } + + asApiClient(): mgmtApi.ApiClient { + return this as unknown as mgmtApi.ApiClient; + } +} diff --git a/src/tests/sync/helpers/scenario-loader.ts b/src/tests/sync/helpers/scenario-loader.ts new file mode 100644 index 0000000..cf4f892 --- /dev/null +++ b/src/tests/sync/helpers/scenario-loader.ts @@ -0,0 +1,145 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export interface ScenarioStateOverrides { + availableLocales?: string[]; + overwrite?: boolean; +} + +export interface ScenarioConfig { + name: string; + description?: string; + sourceGuid: string; + targetGuid: string; + locale: string; + /** Per-scenario overrides applied to the global `state` object after resetState/setState. */ + state?: ScenarioStateOverrides; + /** + * Mirrors the --models-with-deps CLI flag. When set, the runner builds a + * ModelDependencyTree from the source filesystem and prunes the content list + * before the change-detection filter runs. + */ + modelsWithDeps?: string[]; + /** + * Which pipeline to run. Defaults to "content" (ContentBatchProcessor). + * "models" runs pushModels, "containers" runs pushContainers. + */ + pushes?: 'content' | 'models' | 'containers'; + /** ContentBatchProcessor batch size. Defaults to 100 — override to exercise multi-batch loops. */ + batchSize?: number; + /** + * Name of a sibling directory under `scenarios/` (e.g., `_base`) whose contents + * should be copied into the test's temp dir BEFORE the scenario's own `state/` + * is applied. Lets common fixtures (Post model, Posts container, default + * mappings) be shared across scenarios. The scenario's `state/` files + * override any same-path files from the base. + */ + base?: string; + expect: ScenarioExpectations; +} + +export interface ScenarioExpectations { + apiCalls?: { + saveContentItems?: number; + saveModel?: number; + saveContainer?: number; + }; + mappings?: { + item?: ExpectedMapping[]; + model?: ExpectedModelMapping[]; + container?: ExpectedContainerMapping[]; + }; + noDuplicateMappingsBySourceID?: boolean; +} + +export interface ExpectedMapping { + sourceContentID: number; + targetContentID: number | '__any__'; +} + +export interface ExpectedModelMapping { + sourceID: number; + targetID: number | '__any__'; + sourceReferenceName?: string; +} + +export interface ExpectedContainerMapping { + sourceContentViewID: number; + targetContentViewID: number | '__any__'; + sourceReferenceName?: string; +} + +export interface LoadedScenario { + config: ScenarioConfig; + dir: string; +} + +const SCENARIOS_DIR = path.join(__dirname, '..', 'scenarios'); + +/** + * Synchronously discover every scenario directory and load its config. + * Runs at module load so describe.each can enumerate scenarios. + */ +export function discoverScenarios(): LoadedScenario[] { + if (!fs.existsSync(SCENARIOS_DIR)) return []; + + return fs + .readdirSync(SCENARIOS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + // Directories starting with `_` are conventionally "not a scenario" — used + // for shared fixtures (e.g., `_base/`) that scenarios reference via the + // `base` field. Skipping them here keeps the discovery loop simple. + .filter((d) => !d.name.startsWith('_')) + .map((d) => { + const dir = path.join(SCENARIOS_DIR, d.name); + const configPath = path.join(dir, 'scenario.json'); + // Skip stray subdirectories that aren't scenarios (e.g., `agility-files` + // accidentally written by another test that didn't pin its rootPath to a + // temp dir). A real scenario always has a scenario.json next to it. + if (!fs.existsSync(configPath)) return null; + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as ScenarioConfig; + return { config, dir }; + }) + .filter((s): s is LoadedScenario => s !== null); +} + +/** + * Copy the scenario's `state/` folder into a fresh temp dir so each test + * gets an isolated, mutable filesystem to push against. + * + * If the scenario declares a `base` (e.g., `_base`), that directory's contents + * are copied first, then the scenario's own `state/` is overlaid on top. Files + * at the same path are overwritten, so scenarios can selectively replace any + * base fixture they need to customize. + */ +export function copyScenarioState(scenario: LoadedScenario): string { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `agility-sync-${scenario.config.name}-`)); + + if (scenario.config.base) { + const baseDir = path.join(SCENARIOS_DIR, scenario.config.base); + if (!fs.existsSync(baseDir)) { + throw new Error(`Scenario "${scenario.config.name}" declares base "${scenario.config.base}" but that directory does not exist.`); + } + copyRecursive(baseDir, tmp); + } + + const stateDir = path.join(scenario.dir, 'state'); + if (fs.existsSync(stateDir)) { + copyRecursive(stateDir, tmp); + } + return tmp; +} + +function copyRecursive(src: string, dst: string): void { + fs.mkdirSync(dst, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, entry.name); + const d = path.join(dst, entry.name); + if (entry.isDirectory()) { + copyRecursive(s, d); + } else { + fs.copyFileSync(s, d); + } + } +} diff --git a/src/tests/sync/scenarios/01-fresh-sync/scenario.json b/src/tests/sync/scenarios/01-fresh-sync/scenario.json new file mode 100644 index 0000000..13ca97a --- /dev/null +++ b/src/tests/sync/scenarios/01-fresh-sync/scenario.json @@ -0,0 +1,22 @@ +{ + "name": "01-fresh-sync", + "description": "Realistic main code path: model + container exist with mappings; one Post content item is created in a blank target and one item mapping is written.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "expect": { + "apiCalls": { + "saveContentItems": 1 + }, + "mappings": { + "item": [ + { + "sourceContentID": 1000, + "targetContentID": "__any__" + } + ] + }, + "noDuplicateMappingsBySourceID": true + }, + "base": "_base" +} diff --git a/src/tests/sync/scenarios/01-fresh-sync/state/src-1/en-us/item/1000.json b/src/tests/sync/scenarios/01-fresh-sync/state/src-1/en-us/item/1000.json new file mode 100644 index 0000000..7ed4bd2 --- /dev/null +++ b/src/tests/sync/scenarios/01-fresh-sync/state/src-1/en-us/item/1000.json @@ -0,0 +1,16 @@ +{ + "contentID": 1000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 1, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "Hello World", + "slug": "hello-world" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/02-re-sync-no-changes/scenario.json b/src/tests/sync/scenarios/02-re-sync-no-changes/scenario.json new file mode 100644 index 0000000..1e8fbdf --- /dev/null +++ b/src/tests/sync/scenarios/02-re-sync-no-changes/scenario.json @@ -0,0 +1,22 @@ +{ + "name": "02-re-sync-no-changes", + "description": "Mapping exists, target item file exists, source version matches mapped version. Filter should skip the item entirely — no API call, mapping unchanged.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "expect": { + "apiCalls": { + "saveContentItems": 0 + }, + "mappings": { + "item": [ + { + "sourceContentID": 1000, + "targetContentID": 5000 + } + ] + }, + "noDuplicateMappingsBySourceID": true + }, + "base": "_base" +} diff --git a/src/tests/sync/scenarios/02-re-sync-no-changes/state/mappings/src-1-tgt-1/en-us/item/mappings.json b/src/tests/sync/scenarios/02-re-sync-no-changes/state/mappings/src-1-tgt-1/en-us/item/mappings.json new file mode 100644 index 0000000..bbcfb8c --- /dev/null +++ b/src/tests/sync/scenarios/02-re-sync-no-changes/state/mappings/src-1-tgt-1/en-us/item/mappings.json @@ -0,0 +1,10 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceContentID": 1000, + "targetContentID": 5000, + "sourceVersionID": 1, + "targetVersionID": 100 + } +] diff --git a/src/tests/sync/scenarios/02-re-sync-no-changes/state/src-1/en-us/item/1000.json b/src/tests/sync/scenarios/02-re-sync-no-changes/state/src-1/en-us/item/1000.json new file mode 100644 index 0000000..7ed4bd2 --- /dev/null +++ b/src/tests/sync/scenarios/02-re-sync-no-changes/state/src-1/en-us/item/1000.json @@ -0,0 +1,16 @@ +{ + "contentID": 1000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 1, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "Hello World", + "slug": "hello-world" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/02-re-sync-no-changes/state/tgt-1/en-us/item/5000.json b/src/tests/sync/scenarios/02-re-sync-no-changes/state/tgt-1/en-us/item/5000.json new file mode 100644 index 0000000..993060b --- /dev/null +++ b/src/tests/sync/scenarios/02-re-sync-no-changes/state/tgt-1/en-us/item/5000.json @@ -0,0 +1,16 @@ +{ + "contentID": 5000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 100, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "Hello World", + "slug": "hello-world" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/03-stale-mapping-target-deleted/scenario.json b/src/tests/sync/scenarios/03-stale-mapping-target-deleted/scenario.json new file mode 100644 index 0000000..e7be058 --- /dev/null +++ b/src/tests/sync/scenarios/03-stale-mapping-target-deleted/scenario.json @@ -0,0 +1,25 @@ +{ + "name": "03-stale-mapping-target-deleted", + "description": "Regression test for PROD-1320 (PR #135). Mapping points to a target item file that no longer exists (deleted out-of-band) and source version has bumped. With state.overwrite=true, prepareContentPayloads detects the stale mapping (existingMapping set, existingTargetContentItem null) and sends the payload with the stale targetContentID (5000) instead of -1. The API treats this as an update, addMapping finds the existing entry by target ID and updates in place — no duplicate. Without the overwrite flag, the underlying bug still produces a duplicate (see FINDINGS.md Finding 1) — that path is left as documented behavior rather than a failing test.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "state": { + "overwrite": true + }, + "expect": { + "apiCalls": { + "saveContentItems": 1 + }, + "mappings": { + "item": [ + { + "sourceContentID": 1000, + "targetContentID": 5000 + } + ] + }, + "noDuplicateMappingsBySourceID": true + }, + "base": "_base" +} diff --git a/src/tests/sync/scenarios/03-stale-mapping-target-deleted/state/mappings/src-1-tgt-1/en-us/item/mappings.json b/src/tests/sync/scenarios/03-stale-mapping-target-deleted/state/mappings/src-1-tgt-1/en-us/item/mappings.json new file mode 100644 index 0000000..bbcfb8c --- /dev/null +++ b/src/tests/sync/scenarios/03-stale-mapping-target-deleted/state/mappings/src-1-tgt-1/en-us/item/mappings.json @@ -0,0 +1,10 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceContentID": 1000, + "targetContentID": 5000, + "sourceVersionID": 1, + "targetVersionID": 100 + } +] diff --git a/src/tests/sync/scenarios/03-stale-mapping-target-deleted/state/src-1/en-us/item/1000.json b/src/tests/sync/scenarios/03-stale-mapping-target-deleted/state/src-1/en-us/item/1000.json new file mode 100644 index 0000000..0704737 --- /dev/null +++ b/src/tests/sync/scenarios/03-stale-mapping-target-deleted/state/src-1/en-us/item/1000.json @@ -0,0 +1,16 @@ +{ + "contentID": 1000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 2, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "Hello World v2", + "slug": "hello-world" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/04-source-updated/scenario.json b/src/tests/sync/scenarios/04-source-updated/scenario.json new file mode 100644 index 0000000..6bc4841 --- /dev/null +++ b/src/tests/sync/scenarios/04-source-updated/scenario.json @@ -0,0 +1,22 @@ +{ + "name": "04-source-updated", + "description": "Mapping + target file both present; source version has bumped while target version matches the mapped version. Filter says shouldUpdate, payload is sent with the existing target contentID, addMapping finds the existing entry by target ID and updates in place — no new mapping entry.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "expect": { + "apiCalls": { + "saveContentItems": 1 + }, + "mappings": { + "item": [ + { + "sourceContentID": 1000, + "targetContentID": 5000 + } + ] + }, + "noDuplicateMappingsBySourceID": true + }, + "base": "_base" +} diff --git a/src/tests/sync/scenarios/04-source-updated/state/mappings/src-1-tgt-1/en-us/item/mappings.json b/src/tests/sync/scenarios/04-source-updated/state/mappings/src-1-tgt-1/en-us/item/mappings.json new file mode 100644 index 0000000..bbcfb8c --- /dev/null +++ b/src/tests/sync/scenarios/04-source-updated/state/mappings/src-1-tgt-1/en-us/item/mappings.json @@ -0,0 +1,10 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceContentID": 1000, + "targetContentID": 5000, + "sourceVersionID": 1, + "targetVersionID": 100 + } +] diff --git a/src/tests/sync/scenarios/04-source-updated/state/src-1/en-us/item/1000.json b/src/tests/sync/scenarios/04-source-updated/state/src-1/en-us/item/1000.json new file mode 100644 index 0000000..0704737 --- /dev/null +++ b/src/tests/sync/scenarios/04-source-updated/state/src-1/en-us/item/1000.json @@ -0,0 +1,16 @@ +{ + "contentID": 1000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 2, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "Hello World v2", + "slug": "hello-world" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/04-source-updated/state/tgt-1/en-us/item/5000.json b/src/tests/sync/scenarios/04-source-updated/state/tgt-1/en-us/item/5000.json new file mode 100644 index 0000000..993060b --- /dev/null +++ b/src/tests/sync/scenarios/04-source-updated/state/tgt-1/en-us/item/5000.json @@ -0,0 +1,16 @@ +{ + "contentID": 5000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 100, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "Hello World", + "slug": "hello-world" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/05-conflict/scenario.json b/src/tests/sync/scenarios/05-conflict/scenario.json new file mode 100644 index 0000000..1514dc5 --- /dev/null +++ b/src/tests/sync/scenarios/05-conflict/scenario.json @@ -0,0 +1,22 @@ +{ + "name": "05-conflict", + "description": "Both source and target versions have bumped past their mapped values. Change detection returns isConflict=true; the filter pushes the item into itemsToSkip and processBatches never runs. Conflict resolution is a manual operation, so the integration test asserts the system declines to act.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "expect": { + "apiCalls": { + "saveContentItems": 0 + }, + "mappings": { + "item": [ + { + "sourceContentID": 1000, + "targetContentID": 5000 + } + ] + }, + "noDuplicateMappingsBySourceID": true + }, + "base": "_base" +} diff --git a/src/tests/sync/scenarios/05-conflict/state/mappings/src-1-tgt-1/en-us/item/mappings.json b/src/tests/sync/scenarios/05-conflict/state/mappings/src-1-tgt-1/en-us/item/mappings.json new file mode 100644 index 0000000..bbcfb8c --- /dev/null +++ b/src/tests/sync/scenarios/05-conflict/state/mappings/src-1-tgt-1/en-us/item/mappings.json @@ -0,0 +1,10 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceContentID": 1000, + "targetContentID": 5000, + "sourceVersionID": 1, + "targetVersionID": 100 + } +] diff --git a/src/tests/sync/scenarios/05-conflict/state/src-1/en-us/item/1000.json b/src/tests/sync/scenarios/05-conflict/state/src-1/en-us/item/1000.json new file mode 100644 index 0000000..b6e5644 --- /dev/null +++ b/src/tests/sync/scenarios/05-conflict/state/src-1/en-us/item/1000.json @@ -0,0 +1,16 @@ +{ + "contentID": 1000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 2, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "Source-side edit", + "slug": "hello-world" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/05-conflict/state/tgt-1/en-us/item/5000.json b/src/tests/sync/scenarios/05-conflict/state/tgt-1/en-us/item/5000.json new file mode 100644 index 0000000..ae64166 --- /dev/null +++ b/src/tests/sync/scenarios/05-conflict/state/tgt-1/en-us/item/5000.json @@ -0,0 +1,16 @@ +{ + "contentID": 5000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 101, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "Target-side edit", + "slug": "hello-world" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/06-overwrite-mode/scenario.json b/src/tests/sync/scenarios/06-overwrite-mode/scenario.json new file mode 100644 index 0000000..f76eb59 --- /dev/null +++ b/src/tests/sync/scenarios/06-overwrite-mode/scenario.json @@ -0,0 +1,25 @@ +{ + "name": "06-overwrite-mode", + "description": "Conflict resolution via overwrite. Source has bumped past mapped (v2 > 1), target has independently bumped past mapped (v101 > 100). Change-detection sees both > mapped and would normally return isConflict — but with state.overwrite=true, the conflict branch's escape hatch returns shouldUpdate, force-applying the source over the target. PR #149 narrowed overwrite to ONLY fire in the conflict branch (it used to force shouldUpdate unconditionally); this scenario now asserts the narrower, more correct semantic.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "state": { + "overwrite": true + }, + "expect": { + "apiCalls": { + "saveContentItems": 1 + }, + "mappings": { + "item": [ + { + "sourceContentID": 1000, + "targetContentID": 5000 + } + ] + }, + "noDuplicateMappingsBySourceID": true + }, + "base": "_base" +} diff --git a/src/tests/sync/scenarios/06-overwrite-mode/state/mappings/src-1-tgt-1/en-us/item/mappings.json b/src/tests/sync/scenarios/06-overwrite-mode/state/mappings/src-1-tgt-1/en-us/item/mappings.json new file mode 100644 index 0000000..bbcfb8c --- /dev/null +++ b/src/tests/sync/scenarios/06-overwrite-mode/state/mappings/src-1-tgt-1/en-us/item/mappings.json @@ -0,0 +1,10 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceContentID": 1000, + "targetContentID": 5000, + "sourceVersionID": 1, + "targetVersionID": 100 + } +] diff --git a/src/tests/sync/scenarios/06-overwrite-mode/state/src-1/en-us/item/1000.json b/src/tests/sync/scenarios/06-overwrite-mode/state/src-1/en-us/item/1000.json new file mode 100644 index 0000000..a7b5457 --- /dev/null +++ b/src/tests/sync/scenarios/06-overwrite-mode/state/src-1/en-us/item/1000.json @@ -0,0 +1,16 @@ +{ + "contentID": 1000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 2, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "Hello World", + "slug": "hello-world" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/06-overwrite-mode/state/tgt-1/en-us/item/5000.json b/src/tests/sync/scenarios/06-overwrite-mode/state/tgt-1/en-us/item/5000.json new file mode 100644 index 0000000..3c48364 --- /dev/null +++ b/src/tests/sync/scenarios/06-overwrite-mode/state/tgt-1/en-us/item/5000.json @@ -0,0 +1,16 @@ +{ + "contentID": 5000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 101, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "Hello World", + "slug": "hello-world" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/07-cross-locale-mapping-inheritance/scenario.json b/src/tests/sync/scenarios/07-cross-locale-mapping-inheritance/scenario.json new file mode 100644 index 0000000..f639dab --- /dev/null +++ b/src/tests/sync/scenarios/07-cross-locale-mapping-inheritance/scenario.json @@ -0,0 +1,28 @@ +{ + "name": "07-cross-locale-mapping-inheritance", + "description": "Pushing fr-ca for source item 1000. fr-ca has no mapping yet, but en-us already has {src:1000, tgt:5000}. findContentInOtherLocale should locate the en-us mapping and reuse target ID 5000 — so fr-ca writes {src:1000, tgt:5000} instead of creating a brand-new item in the target.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "fr-ca", + "state": { + "availableLocales": [ + "en-us", + "fr-ca" + ] + }, + "expect": { + "apiCalls": { + "saveContentItems": 1 + }, + "mappings": { + "item": [ + { + "sourceContentID": 1000, + "targetContentID": 5000 + } + ] + }, + "noDuplicateMappingsBySourceID": true + }, + "base": "_base" +} diff --git a/src/tests/sync/scenarios/07-cross-locale-mapping-inheritance/state/mappings/src-1-tgt-1/en-us/item/mappings.json b/src/tests/sync/scenarios/07-cross-locale-mapping-inheritance/state/mappings/src-1-tgt-1/en-us/item/mappings.json new file mode 100644 index 0000000..bbcfb8c --- /dev/null +++ b/src/tests/sync/scenarios/07-cross-locale-mapping-inheritance/state/mappings/src-1-tgt-1/en-us/item/mappings.json @@ -0,0 +1,10 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceContentID": 1000, + "targetContentID": 5000, + "sourceVersionID": 1, + "targetVersionID": 100 + } +] diff --git a/src/tests/sync/scenarios/07-cross-locale-mapping-inheritance/state/src-1/fr-ca/item/1000.json b/src/tests/sync/scenarios/07-cross-locale-mapping-inheritance/state/src-1/fr-ca/item/1000.json new file mode 100644 index 0000000..fc7fee5 --- /dev/null +++ b/src/tests/sync/scenarios/07-cross-locale-mapping-inheritance/state/src-1/fr-ca/item/1000.json @@ -0,0 +1,16 @@ +{ + "contentID": 1000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 1, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "Bonjour le monde", + "slug": "hello-world" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/08-empty-source/scenario.json b/src/tests/sync/scenarios/08-empty-source/scenario.json new file mode 100644 index 0000000..6e7806b --- /dev/null +++ b/src/tests/sync/scenarios/08-empty-source/scenario.json @@ -0,0 +1,16 @@ +{ + "name": "08-empty-source", + "description": "No source content items on disk at all. The pipeline should short-circuit gracefully: filter sees nothing, processBatches is never called, no mappings file is written. Smoke test for defensive behavior when a locale or container is empty.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "expect": { + "apiCalls": { + "saveContentItems": 0 + }, + "mappings": { + "item": [] + }, + "noDuplicateMappingsBySourceID": true + } +} diff --git a/src/tests/sync/scenarios/09-models-with-deps-simple/scenario.json b/src/tests/sync/scenarios/09-models-with-deps-simple/scenario.json new file mode 100644 index 0000000..ab39c0b --- /dev/null +++ b/src/tests/sync/scenarios/09-models-with-deps-simple/scenario.json @@ -0,0 +1,25 @@ +{ + "name": "09-models-with-deps-simple", + "description": "Source has 2 models (Post, Author) with 1 content item in each. Sync runs with --models-with-deps=[Post]. ModelDependencyTree should include Post + Posts container + the Post content item, and exclude Author and the Author content item entirely. Only the Post item should be pushed and only one item mapping should be written.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "modelsWithDeps": [ + "Post" + ], + "expect": { + "apiCalls": { + "saveContentItems": 1 + }, + "mappings": { + "item": [ + { + "sourceContentID": 1000, + "targetContentID": "__any__" + } + ] + }, + "noDuplicateMappingsBySourceID": true + }, + "base": "_base" +} diff --git a/src/tests/sync/scenarios/09-models-with-deps-simple/state/mappings/src-1-tgt-1/containers/mappings.json b/src/tests/sync/scenarios/09-models-with-deps-simple/state/mappings/src-1-tgt-1/containers/mappings.json new file mode 100644 index 0000000..94e8a10 --- /dev/null +++ b/src/tests/sync/scenarios/09-models-with-deps-simple/state/mappings/src-1-tgt-1/containers/mappings.json @@ -0,0 +1,22 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceContentViewID": 200, + "targetContentViewID": 600, + "sourceReferenceName": "Posts", + "targetReferenceName": "Posts", + "sourceLastModifiedDate": "01/01/2025 12:00AM", + "targetLastModifiedDate": "01/01/2025 12:00AM" + }, + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceContentViewID": 210, + "targetContentViewID": 610, + "sourceReferenceName": "Authors", + "targetReferenceName": "Authors", + "sourceLastModifiedDate": "01/01/2025 12:00AM", + "targetLastModifiedDate": "01/01/2025 12:00AM" + } +] diff --git a/src/tests/sync/scenarios/09-models-with-deps-simple/state/mappings/src-1-tgt-1/models/mappings.json b/src/tests/sync/scenarios/09-models-with-deps-simple/state/mappings/src-1-tgt-1/models/mappings.json new file mode 100644 index 0000000..14da087 --- /dev/null +++ b/src/tests/sync/scenarios/09-models-with-deps-simple/state/mappings/src-1-tgt-1/models/mappings.json @@ -0,0 +1,22 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceID": 10, + "targetID": 50, + "sourceReferenceName": "Post", + "targetReferenceName": "Post", + "sourceLastModifiedDate": "2025-01-01T00:00:00.000", + "targetLastModifiedDate": "2025-01-01T00:00:00.000" + }, + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceID": 20, + "targetID": 60, + "sourceReferenceName": "Author", + "targetReferenceName": "Author", + "sourceLastModifiedDate": "2025-01-01T00:00:00.000", + "targetLastModifiedDate": "2025-01-01T00:00:00.000" + } +] diff --git a/src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/containers/210.json b/src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/containers/210.json new file mode 100644 index 0000000..a21a81e --- /dev/null +++ b/src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/containers/210.json @@ -0,0 +1,9 @@ +{ + "contentViewID": 210, + "referenceName": "Authors", + "title": "Authors", + "contentDefinitionID": 20, + "contentDefinitionName": "Author", + "lastModifiedDate": "01/01/2025 12:00AM", + "isShared": false +} diff --git a/src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/en-us/item/1000.json b/src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/en-us/item/1000.json new file mode 100644 index 0000000..7ed4bd2 --- /dev/null +++ b/src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/en-us/item/1000.json @@ -0,0 +1,16 @@ +{ + "contentID": 1000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 1, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "Hello World", + "slug": "hello-world" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/en-us/item/1100.json b/src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/en-us/item/1100.json new file mode 100644 index 0000000..ba8bc25 --- /dev/null +++ b/src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/en-us/item/1100.json @@ -0,0 +1,15 @@ +{ + "contentID": 1100, + "properties": { + "referenceName": "Authors", + "definitionName": "Author", + "versionID": 1, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "name": "Jane Doe" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/models/20.json b/src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/models/20.json new file mode 100644 index 0000000..44a84a2 --- /dev/null +++ b/src/tests/sync/scenarios/09-models-with-deps-simple/state/src-1/models/20.json @@ -0,0 +1,12 @@ +{ + "id": 20, + "displayName": "Author", + "referenceName": "Author", + "description": "Defines an author.", + "lastModifiedDate": "2025-01-01T00:00:00.000", + "lastModifiedBy": "Test", + "contentDefinitionTypeName": "Content List", + "fields": [ + { "type": "Text", "name": "Name", "label": "Name", "required": true } + ] +} diff --git a/src/tests/sync/scenarios/09-models-with-deps-simple/state/tgt-1/containers/610.json b/src/tests/sync/scenarios/09-models-with-deps-simple/state/tgt-1/containers/610.json new file mode 100644 index 0000000..8b545a2 --- /dev/null +++ b/src/tests/sync/scenarios/09-models-with-deps-simple/state/tgt-1/containers/610.json @@ -0,0 +1,9 @@ +{ + "contentViewID": 610, + "referenceName": "Authors", + "title": "Authors", + "contentDefinitionID": 60, + "contentDefinitionName": "Author", + "lastModifiedDate": "01/01/2025 12:00AM", + "isShared": false +} diff --git a/src/tests/sync/scenarios/10-multi-item-batch-with-skip/scenario.json b/src/tests/sync/scenarios/10-multi-item-batch-with-skip/scenario.json new file mode 100644 index 0000000..33a9eac --- /dev/null +++ b/src/tests/sync/scenarios/10-multi-item-batch-with-skip/scenario.json @@ -0,0 +1,26 @@ +{ + "name": "10-multi-item-batch-with-skip", + "description": "Three source items, one of which has a definitionName with no model mapping. The orphan reaches processBatches, throws inside prepareContentPayloads, and is skipped — leaving only the surviving two items in the payload. PROD-1024 regression: extractBatchResults must align API results against the surviving payloads, not the original batch. If alignment is wrong, the wrong sourceContentID gets the wrong targetContentID.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "expect": { + "apiCalls": { + "saveContentItems": 1 + }, + "mappings": { + "item": [ + { + "sourceContentID": 1000, + "targetContentID": 9001 + }, + { + "sourceContentID": 1200, + "targetContentID": 9002 + } + ] + }, + "noDuplicateMappingsBySourceID": true + }, + "base": "_base" +} diff --git a/src/tests/sync/scenarios/10-multi-item-batch-with-skip/state/src-1/en-us/item/1000.json b/src/tests/sync/scenarios/10-multi-item-batch-with-skip/state/src-1/en-us/item/1000.json new file mode 100644 index 0000000..ce2e5d7 --- /dev/null +++ b/src/tests/sync/scenarios/10-multi-item-batch-with-skip/state/src-1/en-us/item/1000.json @@ -0,0 +1,16 @@ +{ + "contentID": 1000, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 1, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "title": "First Post", + "slug": "first-post" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/10-multi-item-batch-with-skip/state/src-1/en-us/item/1100.json b/src/tests/sync/scenarios/10-multi-item-batch-with-skip/state/src-1/en-us/item/1100.json new file mode 100644 index 0000000..a0edb4f --- /dev/null +++ b/src/tests/sync/scenarios/10-multi-item-batch-with-skip/state/src-1/en-us/item/1100.json @@ -0,0 +1,15 @@ +{ + "contentID": 1100, + "properties": { + "referenceName": "Orphans", + "definitionName": "OrphanedModel", + "versionID": 1, + "state": 2, + "itemOrder": 0 + }, + "fields": { + "value": "I have no model mapping in this target" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/10-multi-item-batch-with-skip/state/src-1/en-us/item/1200.json b/src/tests/sync/scenarios/10-multi-item-batch-with-skip/state/src-1/en-us/item/1200.json new file mode 100644 index 0000000..ba079bf --- /dev/null +++ b/src/tests/sync/scenarios/10-multi-item-batch-with-skip/state/src-1/en-us/item/1200.json @@ -0,0 +1,16 @@ +{ + "contentID": 1200, + "properties": { + "referenceName": "Posts", + "definitionName": "Post", + "versionID": 1, + "state": 2, + "itemOrder": 1 + }, + "fields": { + "title": "Third Post", + "slug": "third-post" + }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/11-model-push-fresh/scenario.json b/src/tests/sync/scenarios/11-model-push-fresh/scenario.json new file mode 100644 index 0000000..cf6ec7d --- /dev/null +++ b/src/tests/sync/scenarios/11-model-push-fresh/scenario.json @@ -0,0 +1,18 @@ +{ + "name": "11-model-push-fresh", + "description": "Brightstar Case 8: source has a Post model that doesn't exist in target yet, no model mappings on disk. pushModels should create a stub in the target (saveModel call #1), then update the stub with fields (saveModel call #2), and write a model mapping linking source ID 10 to the new target ID assigned by the mock (7001).", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "pushes": "models", + "expect": { + "apiCalls": { + "saveModel": 2 + }, + "mappings": { + "model": [ + { "sourceID": 10, "targetID": 7001, "sourceReferenceName": "Post" } + ] + } + } +} diff --git a/src/tests/sync/scenarios/11-model-push-fresh/state/src-1/models/10.json b/src/tests/sync/scenarios/11-model-push-fresh/state/src-1/models/10.json new file mode 100644 index 0000000..58a6b78 --- /dev/null +++ b/src/tests/sync/scenarios/11-model-push-fresh/state/src-1/models/10.json @@ -0,0 +1,13 @@ +{ + "id": 10, + "displayName": "Post", + "referenceName": "Post", + "description": "Defines a blog post.", + "lastModifiedDate": "2025-01-01T00:00:00.000", + "lastModifiedBy": "Test", + "contentDefinitionTypeName": "Content List", + "fields": [ + { "type": "Text", "name": "Title", "label": "Title", "required": true }, + { "type": "Text", "name": "Slug", "label": "URL Slug", "required": true } + ] +} diff --git a/src/tests/sync/scenarios/12-model-push-source-updated/scenario.json b/src/tests/sync/scenarios/12-model-push-source-updated/scenario.json new file mode 100644 index 0000000..74e71d3 --- /dev/null +++ b/src/tests/sync/scenarios/12-model-push-source-updated/scenario.json @@ -0,0 +1,18 @@ +{ + "name": "12-model-push-source-updated", + "description": "Brightstar Case 7: model exists in both source and target with a mapping, but the source's lastModifiedDate is newer than the target's. pushModels should detect hasSourceChanged, take the updateExistingModel path (no stub create), call saveModel exactly once with the existing target ID (50), and leave the model mapping intact — sourceID 10 → targetID 50 — with only the lastModifiedDate fields touched.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "pushes": "models", + "expect": { + "apiCalls": { + "saveModel": 1 + }, + "mappings": { + "model": [ + { "sourceID": 10, "targetID": 50, "sourceReferenceName": "Post" } + ] + } + } +} diff --git a/src/tests/sync/scenarios/12-model-push-source-updated/state/mappings/src-1-tgt-1/models/mappings.json b/src/tests/sync/scenarios/12-model-push-source-updated/state/mappings/src-1-tgt-1/models/mappings.json new file mode 100644 index 0000000..76cb49d --- /dev/null +++ b/src/tests/sync/scenarios/12-model-push-source-updated/state/mappings/src-1-tgt-1/models/mappings.json @@ -0,0 +1,12 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceID": 10, + "targetID": 50, + "sourceReferenceName": "Post", + "targetReferenceName": "Post", + "sourceLastModifiedDate": "2025-01-01T00:00:00.000", + "targetLastModifiedDate": "2025-01-01T00:00:00.000" + } +] diff --git a/src/tests/sync/scenarios/12-model-push-source-updated/state/src-1/models/10.json b/src/tests/sync/scenarios/12-model-push-source-updated/state/src-1/models/10.json new file mode 100644 index 0000000..8ac236a --- /dev/null +++ b/src/tests/sync/scenarios/12-model-push-source-updated/state/src-1/models/10.json @@ -0,0 +1,14 @@ +{ + "id": 10, + "displayName": "Post", + "referenceName": "Post", + "description": "Defines a blog post (source bumped a field).", + "lastModifiedDate": "2025-06-01T00:00:00.000", + "lastModifiedBy": "Test", + "contentDefinitionTypeName": "Content List", + "fields": [ + { "type": "Text", "name": "Title", "label": "Title", "required": true }, + { "type": "Text", "name": "Slug", "label": "URL Slug", "required": true }, + { "type": "Text", "name": "Subtitle", "label": "Subtitle", "required": false } + ] +} diff --git a/src/tests/sync/scenarios/12-model-push-source-updated/state/tgt-1/models/50.json b/src/tests/sync/scenarios/12-model-push-source-updated/state/tgt-1/models/50.json new file mode 100644 index 0000000..f67f7c9 --- /dev/null +++ b/src/tests/sync/scenarios/12-model-push-source-updated/state/tgt-1/models/50.json @@ -0,0 +1,13 @@ +{ + "id": 50, + "displayName": "Post", + "referenceName": "Post", + "description": "Defines a blog post.", + "lastModifiedDate": "2025-01-01T00:00:00.000", + "lastModifiedBy": "Test", + "contentDefinitionTypeName": "Content List", + "fields": [ + { "type": "Text", "name": "Title", "label": "Title", "required": true }, + { "type": "Text", "name": "Slug", "label": "URL Slug", "required": true } + ] +} diff --git a/src/tests/sync/scenarios/13-model-exists-in-target-no-mapping/scenario.json b/src/tests/sync/scenarios/13-model-exists-in-target-no-mapping/scenario.json new file mode 100644 index 0000000..910e769 --- /dev/null +++ b/src/tests/sync/scenarios/13-model-exists-in-target-no-mapping/scenario.json @@ -0,0 +1,16 @@ +{ + "name": "13-model-exists-in-target-no-mapping", + "description": "Behavior changed by PR #147 (Rm reference name lookup, use IDs unless source mapping does not exist). Setup: target has model 'Post' (custom, non-default) with same referenceName as source, but no mapping file exists locally. Previous behavior: pushModels invoked addMapping silently via existsInTargetWithoutMapping → adopted target by name, 1 mapping created. New behavior: only *default* Agility models (richtextarea, formbuilder, agilitycss, etc.) get silently adopted. Custom models like Post fall through every condition in the for-loop without entering shouldCreateStub/shouldUpdateFields/shouldSkip — 0 saveModel calls, 0 mappings, no skip log. The model is silently dropped. NOTE: this is documented in FINDINGS.md Finding 5 as an unintended gap — the PR's stated intent was to *throw* when a name conflict is detected, but the throw only fires when there's an existing conflicting targetMapping; in the no-mapping-at-all case (our scenario), the code falls through silently. Containers downstream that reference this model would now fail to find their target model mapping.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "pushes": "models", + "expect": { + "apiCalls": { + "saveModel": 0 + }, + "mappings": { + "model": [] + } + } +} diff --git a/src/tests/sync/scenarios/13-model-exists-in-target-no-mapping/state/src-1/models/10.json b/src/tests/sync/scenarios/13-model-exists-in-target-no-mapping/state/src-1/models/10.json new file mode 100644 index 0000000..58a6b78 --- /dev/null +++ b/src/tests/sync/scenarios/13-model-exists-in-target-no-mapping/state/src-1/models/10.json @@ -0,0 +1,13 @@ +{ + "id": 10, + "displayName": "Post", + "referenceName": "Post", + "description": "Defines a blog post.", + "lastModifiedDate": "2025-01-01T00:00:00.000", + "lastModifiedBy": "Test", + "contentDefinitionTypeName": "Content List", + "fields": [ + { "type": "Text", "name": "Title", "label": "Title", "required": true }, + { "type": "Text", "name": "Slug", "label": "URL Slug", "required": true } + ] +} diff --git a/src/tests/sync/scenarios/13-model-exists-in-target-no-mapping/state/tgt-1/models/50.json b/src/tests/sync/scenarios/13-model-exists-in-target-no-mapping/state/tgt-1/models/50.json new file mode 100644 index 0000000..cd489de --- /dev/null +++ b/src/tests/sync/scenarios/13-model-exists-in-target-no-mapping/state/tgt-1/models/50.json @@ -0,0 +1,13 @@ +{ + "id": 50, + "displayName": "Post", + "referenceName": "Post", + "description": "Defines a blog post (target — pre-existing).", + "lastModifiedDate": "2025-01-01T00:00:00.000", + "lastModifiedBy": "Test", + "contentDefinitionTypeName": "Content List", + "fields": [ + { "type": "Text", "name": "Title", "label": "Title", "required": true }, + { "type": "Text", "name": "Slug", "label": "URL Slug", "required": true } + ] +} diff --git a/src/tests/sync/scenarios/14-model-target-side-change-skipped/scenario.json b/src/tests/sync/scenarios/14-model-target-side-change-skipped/scenario.json new file mode 100644 index 0000000..5245da1 --- /dev/null +++ b/src/tests/sync/scenarios/14-model-target-side-change-skipped/scenario.json @@ -0,0 +1,18 @@ +{ + "name": "14-model-target-side-change-skipped", + "description": "Target-safety branch of the model pusher. Source model is unchanged since the last sync, but the target model has been edited locally (e.g., a Brightstar customer touched their Client A QA instance between upstream syncs). pushModels detects hasTargetChanged and routes the model to shouldSkip, leaving the target untouched. 0 saveModel calls, mapping unchanged on disk. This is the protection that lets multi-stage chains (Dev → QA → Client) tolerate downstream edits without losing them on the next upstream push.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "pushes": "models", + "expect": { + "apiCalls": { + "saveModel": 0 + }, + "mappings": { + "model": [ + { "sourceID": 10, "targetID": 50, "sourceReferenceName": "Post" } + ] + } + } +} diff --git a/src/tests/sync/scenarios/14-model-target-side-change-skipped/state/mappings/src-1-tgt-1/models/mappings.json b/src/tests/sync/scenarios/14-model-target-side-change-skipped/state/mappings/src-1-tgt-1/models/mappings.json new file mode 100644 index 0000000..76cb49d --- /dev/null +++ b/src/tests/sync/scenarios/14-model-target-side-change-skipped/state/mappings/src-1-tgt-1/models/mappings.json @@ -0,0 +1,12 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceID": 10, + "targetID": 50, + "sourceReferenceName": "Post", + "targetReferenceName": "Post", + "sourceLastModifiedDate": "2025-01-01T00:00:00.000", + "targetLastModifiedDate": "2025-01-01T00:00:00.000" + } +] diff --git a/src/tests/sync/scenarios/14-model-target-side-change-skipped/state/src-1/models/10.json b/src/tests/sync/scenarios/14-model-target-side-change-skipped/state/src-1/models/10.json new file mode 100644 index 0000000..3413af4 --- /dev/null +++ b/src/tests/sync/scenarios/14-model-target-side-change-skipped/state/src-1/models/10.json @@ -0,0 +1,13 @@ +{ + "id": 10, + "displayName": "Post", + "referenceName": "Post", + "description": "Defines a blog post (source — unchanged since last sync).", + "lastModifiedDate": "2025-01-01T00:00:00.000", + "lastModifiedBy": "Test", + "contentDefinitionTypeName": "Content List", + "fields": [ + { "type": "Text", "name": "Title", "label": "Title", "required": true }, + { "type": "Text", "name": "Slug", "label": "URL Slug", "required": true } + ] +} diff --git a/src/tests/sync/scenarios/14-model-target-side-change-skipped/state/tgt-1/models/50.json b/src/tests/sync/scenarios/14-model-target-side-change-skipped/state/tgt-1/models/50.json new file mode 100644 index 0000000..152130c --- /dev/null +++ b/src/tests/sync/scenarios/14-model-target-side-change-skipped/state/tgt-1/models/50.json @@ -0,0 +1,13 @@ +{ + "id": 50, + "displayName": "Post", + "referenceName": "Post", + "description": "Defines a blog post (target — edited locally after last sync).", + "lastModifiedDate": "2025-06-01T00:00:00.000", + "lastModifiedBy": "Test", + "contentDefinitionTypeName": "Content List", + "fields": [ + { "type": "Text", "name": "Title", "label": "Title", "required": true }, + { "type": "Text", "name": "Slug", "label": "URL Slug", "required": true } + ] +} diff --git a/src/tests/sync/scenarios/15-multi-batch-content/scenario.json b/src/tests/sync/scenarios/15-multi-batch-content/scenario.json new file mode 100644 index 0000000..e0ef259 --- /dev/null +++ b/src/tests/sync/scenarios/15-multi-batch-content/scenario.json @@ -0,0 +1,39 @@ +{ + "name": "15-multi-batch-content", + "description": "Five source items with batchSize=2 — exercises the multi-batch loop in ContentBatchProcessor.processBatches. Asserts 3 saveContentItems calls (batches of 2, 2, and 1), 5 distinct mappings, and no duplicate source IDs. Catches regressions in batch splitting, intra-batch ordering, and per-batch result extraction that single-batch scenarios can't reach.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "batchSize": 2, + "expect": { + "apiCalls": { + "saveContentItems": 3 + }, + "mappings": { + "item": [ + { + "sourceContentID": 1000, + "targetContentID": "__any__" + }, + { + "sourceContentID": 1001, + "targetContentID": "__any__" + }, + { + "sourceContentID": 1002, + "targetContentID": "__any__" + }, + { + "sourceContentID": 1003, + "targetContentID": "__any__" + }, + { + "sourceContentID": 1004, + "targetContentID": "__any__" + } + ] + }, + "noDuplicateMappingsBySourceID": true + }, + "base": "_base" +} diff --git a/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1000.json b/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1000.json new file mode 100644 index 0000000..b17aaac --- /dev/null +++ b/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1000.json @@ -0,0 +1,7 @@ +{ + "contentID": 1000, + "properties": { "referenceName": "Posts", "definitionName": "Post", "versionID": 1, "state": 2, "itemOrder": 0 }, + "fields": { "title": "Post One", "slug": "post-one" }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1001.json b/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1001.json new file mode 100644 index 0000000..2946b46 --- /dev/null +++ b/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1001.json @@ -0,0 +1,7 @@ +{ + "contentID": 1001, + "properties": { "referenceName": "Posts", "definitionName": "Post", "versionID": 1, "state": 2, "itemOrder": 1 }, + "fields": { "title": "Post Two", "slug": "post-two" }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1002.json b/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1002.json new file mode 100644 index 0000000..48ac5e2 --- /dev/null +++ b/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1002.json @@ -0,0 +1,7 @@ +{ + "contentID": 1002, + "properties": { "referenceName": "Posts", "definitionName": "Post", "versionID": 1, "state": 2, "itemOrder": 2 }, + "fields": { "title": "Post Three", "slug": "post-three" }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1003.json b/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1003.json new file mode 100644 index 0000000..32692aa --- /dev/null +++ b/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1003.json @@ -0,0 +1,7 @@ +{ + "contentID": 1003, + "properties": { "referenceName": "Posts", "definitionName": "Post", "versionID": 1, "state": 2, "itemOrder": 3 }, + "fields": { "title": "Post Four", "slug": "post-four" }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1004.json b/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1004.json new file mode 100644 index 0000000..f45893d --- /dev/null +++ b/src/tests/sync/scenarios/15-multi-batch-content/state/src-1/en-us/item/1004.json @@ -0,0 +1,7 @@ +{ + "contentID": 1004, + "properties": { "referenceName": "Posts", "definitionName": "Post", "versionID": 1, "state": 2, "itemOrder": 4 }, + "fields": { "title": "Post Five", "slug": "post-five" }, + "seo": null, + "scripts": null +} diff --git a/src/tests/sync/scenarios/16-container-push-fresh/scenario.json b/src/tests/sync/scenarios/16-container-push-fresh/scenario.json new file mode 100644 index 0000000..5543e80 --- /dev/null +++ b/src/tests/sync/scenarios/16-container-push-fresh/scenario.json @@ -0,0 +1,18 @@ +{ + "name": "16-container-push-fresh", + "description": "Source has Posts container, target has no containers, model mapping for Post already exists (since containers reference models via contentDefinitionID). pushContainers should resolve the source's contentDefinitionID through the model mapping to find target model ID 50, send a create payload (contentViewID: -1, contentDefinitionID: 50) to saveContainer, and write a container mapping linking source contentViewID 200 to the new target ID 8001 assigned by the mock.", + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "locale": "en-us", + "pushes": "containers", + "expect": { + "apiCalls": { + "saveContainer": 1 + }, + "mappings": { + "container": [ + { "sourceContentViewID": 200, "targetContentViewID": 8001, "sourceReferenceName": "Posts" } + ] + } + } +} diff --git a/src/tests/sync/scenarios/16-container-push-fresh/state/mappings/src-1-tgt-1/models/mappings.json b/src/tests/sync/scenarios/16-container-push-fresh/state/mappings/src-1-tgt-1/models/mappings.json new file mode 100644 index 0000000..76cb49d --- /dev/null +++ b/src/tests/sync/scenarios/16-container-push-fresh/state/mappings/src-1-tgt-1/models/mappings.json @@ -0,0 +1,12 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceID": 10, + "targetID": 50, + "sourceReferenceName": "Post", + "targetReferenceName": "Post", + "sourceLastModifiedDate": "2025-01-01T00:00:00.000", + "targetLastModifiedDate": "2025-01-01T00:00:00.000" + } +] diff --git a/src/tests/sync/scenarios/16-container-push-fresh/state/src-1/containers/200.json b/src/tests/sync/scenarios/16-container-push-fresh/state/src-1/containers/200.json new file mode 100644 index 0000000..ad195e6 --- /dev/null +++ b/src/tests/sync/scenarios/16-container-push-fresh/state/src-1/containers/200.json @@ -0,0 +1,9 @@ +{ + "contentViewID": 200, + "referenceName": "Posts", + "title": "Posts", + "contentDefinitionID": 10, + "contentDefinitionName": "Post", + "lastModifiedDate": "01/01/2025 12:00AM", + "isShared": false +} diff --git a/src/tests/sync/scenarios/_base/mappings/src-1-tgt-1/containers/mappings.json b/src/tests/sync/scenarios/_base/mappings/src-1-tgt-1/containers/mappings.json new file mode 100644 index 0000000..08e1f84 --- /dev/null +++ b/src/tests/sync/scenarios/_base/mappings/src-1-tgt-1/containers/mappings.json @@ -0,0 +1,12 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceContentViewID": 200, + "targetContentViewID": 600, + "sourceReferenceName": "Posts", + "targetReferenceName": "Posts", + "sourceLastModifiedDate": "01/01/2025 12:00AM", + "targetLastModifiedDate": "01/01/2025 12:00AM" + } +] diff --git a/src/tests/sync/scenarios/_base/mappings/src-1-tgt-1/models/mappings.json b/src/tests/sync/scenarios/_base/mappings/src-1-tgt-1/models/mappings.json new file mode 100644 index 0000000..76cb49d --- /dev/null +++ b/src/tests/sync/scenarios/_base/mappings/src-1-tgt-1/models/mappings.json @@ -0,0 +1,12 @@ +[ + { + "sourceGuid": "src-1", + "targetGuid": "tgt-1", + "sourceID": 10, + "targetID": 50, + "sourceReferenceName": "Post", + "targetReferenceName": "Post", + "sourceLastModifiedDate": "2025-01-01T00:00:00.000", + "targetLastModifiedDate": "2025-01-01T00:00:00.000" + } +] diff --git a/src/tests/sync/scenarios/_base/src-1/containers/200.json b/src/tests/sync/scenarios/_base/src-1/containers/200.json new file mode 100644 index 0000000..ad195e6 --- /dev/null +++ b/src/tests/sync/scenarios/_base/src-1/containers/200.json @@ -0,0 +1,9 @@ +{ + "contentViewID": 200, + "referenceName": "Posts", + "title": "Posts", + "contentDefinitionID": 10, + "contentDefinitionName": "Post", + "lastModifiedDate": "01/01/2025 12:00AM", + "isShared": false +} diff --git a/src/tests/sync/scenarios/_base/src-1/models/10.json b/src/tests/sync/scenarios/_base/src-1/models/10.json new file mode 100644 index 0000000..58a6b78 --- /dev/null +++ b/src/tests/sync/scenarios/_base/src-1/models/10.json @@ -0,0 +1,13 @@ +{ + "id": 10, + "displayName": "Post", + "referenceName": "Post", + "description": "Defines a blog post.", + "lastModifiedDate": "2025-01-01T00:00:00.000", + "lastModifiedBy": "Test", + "contentDefinitionTypeName": "Content List", + "fields": [ + { "type": "Text", "name": "Title", "label": "Title", "required": true }, + { "type": "Text", "name": "Slug", "label": "URL Slug", "required": true } + ] +} diff --git a/src/tests/sync/scenarios/_base/tgt-1/containers/600.json b/src/tests/sync/scenarios/_base/tgt-1/containers/600.json new file mode 100644 index 0000000..ada1299 --- /dev/null +++ b/src/tests/sync/scenarios/_base/tgt-1/containers/600.json @@ -0,0 +1,9 @@ +{ + "contentViewID": 600, + "referenceName": "Posts", + "title": "Posts", + "contentDefinitionID": 50, + "contentDefinitionName": "Post", + "lastModifiedDate": "01/01/2025 12:00AM", + "isShared": false +} diff --git a/src/tests/sync/sync-scenarios.test.ts b/src/tests/sync/sync-scenarios.test.ts new file mode 100644 index 0000000..f0b8a46 --- /dev/null +++ b/src/tests/sync/sync-scenarios.test.ts @@ -0,0 +1,177 @@ +import * as fs from 'fs'; + +import { state, resetState, setState } from 'core/state'; +import { fileOperations } from 'core'; +import { Logs } from 'core/logs'; +import { ContentBatchProcessor } from 'lib/pushers/content-pusher/content-batch-processor'; +import { ContentItemMapper } from 'lib/mappers/content-item-mapper'; +import { getContentItemsFromFileSystem } from 'lib/getters/filesystem/get-content-items'; +import { getModelsFromFileSystem } from 'lib/getters/filesystem/get-models'; +import { getContainersFromFileSystem } from 'lib/getters/filesystem/get-containers'; +import { filterContentItemsForProcessing } from 'lib/pushers/content-pusher/util/filter-content-items-for-processing'; +import { pushModels } from 'lib/pushers/model-pusher'; +import { pushContainers } from 'lib/pushers/container-pusher'; + +import { MockApiClient } from './helpers/mock-api-client'; +import { discoverScenarios, copyScenarioState, LoadedScenario } from './helpers/scenario-loader'; +import { assertScenarioOutcome, readItemMappings, readModelMappings, readContainerMappings } from './helpers/assertions'; +import { applyModelsWithDepsFilter } from './helpers/dependency-filter'; + +// Module-mock the network/polling boundary so processBatches never reaches a real API. +jest.mock('lib/pushers/batch-polling', () => ({ + pollBatchUntilComplete: jest.fn(), + extractContentBatchResults: jest.fn(), +})); + +import { pollBatchUntilComplete, extractContentBatchResults } from 'lib/pushers/batch-polling'; +const mockPoll = pollBatchUntilComplete as jest.Mock; +const mockExtract = extractContentBatchResults as jest.Mock; + +const scenarios = discoverScenarios(); + +function makeLogger(): any { + return { + log: jest.fn(), + setGuid: jest.fn(), + getGuid: jest.fn(), + content: { + created: jest.fn(), + error: jest.fn(), + skipped: jest.fn(), + }, + model: { + downloaded: jest.fn(), + created: jest.fn(), + updated: jest.fn(), + uploaded: jest.fn(), + skipped: jest.fn(), + error: jest.fn(), + }, + container: { + downloaded: jest.fn(), + created: jest.fn(), + updated: jest.fn(), + uploaded: jest.fn(), + skipped: jest.fn(), + error: jest.fn(), + }, + }; +} + +describe.each(scenarios)('sync scenario: $config.name', (scenario: LoadedScenario) => { + let tmpDir: string; + let mockApi: MockApiClient; + + beforeEach(() => { + resetState(); + tmpDir = copyScenarioState(scenario); + setState({ + rootPath: tmpDir, + sourceGuid: scenario.config.sourceGuid, + targetGuid: scenario.config.targetGuid, + locale: scenario.config.locale, + }); + + const overrides = scenario.config.state; + if (overrides?.availableLocales) state.availableLocales = overrides.availableLocales; + if (overrides?.overwrite !== undefined) state.overwrite = overrides.overwrite; + + mockApi = new MockApiClient(); + mockPoll.mockResolvedValue({}); + mockExtract.mockImplementation((_completedBatch: any, includedItems: any[]) => { + const lastSave = mockApi.capturedSaveCalls[mockApi.capturedSaveCalls.length - 1]; + const payloads = lastSave?.payloads ?? []; + return { + successfulItems: mockApi.buildSuccessfulItems(includedItems, payloads), + failedItems: [], + }; + }); + + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('produces the expected mappings and API calls', async () => { + const { sourceGuid, targetGuid, locale } = scenario.config; + const logger = makeLogger() as Logs; + + if (scenario.config.pushes === 'models' || scenario.config.pushes === 'containers') { + // Both pushers read state.cachedApiClient and state.loggerRegistry, so register both. + state.cachedApiClient = mockApi.asApiClient(); + if (!state.loggerRegistry) state.loggerRegistry = new Map(); + state.loggerRegistry.set(sourceGuid, logger); + + if (scenario.config.pushes === 'models') { + const sourceModels = getModelsFromFileSystem(new fileOperations(sourceGuid)); + const targetModels = getModelsFromFileSystem(new fileOperations(targetGuid)); + await pushModels(sourceModels, targetModels); + } else { + const sourceContainers = getContainersFromFileSystem(new fileOperations(sourceGuid)); + const targetContainers = getContainersFromFileSystem(new fileOperations(targetGuid)); + await pushContainers(sourceContainers, targetContainers); + } + + assertScenarioOutcome({ + expectations: scenario.config.expect, + mockApi, + itemMappings: [], + modelMappings: readModelMappings(tmpDir, sourceGuid, targetGuid), + containerMappings: readContainerMappings(tmpDir, sourceGuid, targetGuid), + }); + return; + } + + const sourceFileOps = new fileOperations(sourceGuid, locale); + const sourceItems = getContentItemsFromFileSystem(sourceFileOps); + + // Selective-sync filter: prune by ModelDependencyTree when --models-with-deps is in play. + const dependencyFilteredItems = scenario.config.modelsWithDeps + ? applyModelsWithDepsFilter({ + sourceGuid, + locale, + modelsWithDeps: scenario.config.modelsWithDeps, + contentItems: sourceItems, + }) + : sourceItems; + + const referenceMapper = new ContentItemMapper(sourceGuid, targetGuid, locale); + + // Same orchestration as a real push: filter for change first, then batch-process the survivors. + const { itemsToProcess } = await filterContentItemsForProcessing({ + contentItems: dependencyFilteredItems, + apiClient: mockApi.asApiClient(), + targetGuid, + locale, + referenceMapper, + targetData: [], + logger, + }); + + const processor = new ContentBatchProcessor({ + apiClient: mockApi.asApiClient(), + sourceGuid, + targetGuid, + locale, + referenceMapper, + batchSize: scenario.config.batchSize ?? 100, + }); + + if (itemsToProcess.length > 0) { + await processor.processBatches(itemsToProcess, logger, 'content'); + } + + const itemMappings = readItemMappings(tmpDir, sourceGuid, targetGuid, locale); + + assertScenarioOutcome({ + expectations: scenario.config.expect, + mockApi, + itemMappings, + }); + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..de05744 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": [] +}