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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
- main

jobs:
test:
unit-tests:
name: Unit tests
runs-on: ubuntu-latest

Expand All @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions src/tests/sync/helpers/assertions.ts
Original file line number Diff line number Diff line change
@@ -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<number>();
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);
}
}
}
48 changes: 48 additions & 0 deletions src/tests/sync/helpers/dependency-filter.ts
Original file line number Diff line number Diff line change
@@ -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));
}
99 changes: 99 additions & 0 deletions src/tests/sync/helpers/mock-api-client.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading