From 3a49a421e0aabef82a9385c7634b7c1db48379a0 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 20 May 2026 14:47:18 -0400 Subject: [PATCH 1/4] - strongly type the batch item for ease of use - split extractBatchResults into typed extractContentBatchResults and extractPageBatchResults - update mappers to throw an error if the mappings are invalid or will be invalid - formatting, add a check for container mapping validity - remove dead code. failedItems field never got populated anyway. - add logic in the content mapper to guard against incorrect mappings. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/mappers/asset-mapper.ts | 41 +++-- src/lib/mappers/container-mapper.ts | 36 ++-- src/lib/mappers/content-item-mapper.ts | 61 +++++-- src/lib/mappers/gallery-mapper.ts | 29 ++-- src/lib/mappers/model-mapper.ts | 33 ++-- src/lib/mappers/page-mapper.ts | 33 ++-- src/lib/mappers/template-mapper.ts | 29 ++-- src/lib/pushers/batch-polling.ts | 156 +++++++----------- src/lib/pushers/container-pusher.ts | 59 ++++--- .../content-pusher/content-batch-processor.ts | 4 +- .../tests/content-batch-processor.test.ts | 6 +- .../content-pusher/util/change-detection.ts | 28 ++-- src/lib/pushers/page-pusher/process-page.ts | 11 +- .../page-pusher/tests/process-page.test.ts | 6 +- src/lib/pushers/tests/batch-polling.test.ts | 76 +++++---- src/lib/shared/index.ts | 2 +- 16 files changed, 316 insertions(+), 294 deletions(-) diff --git a/src/lib/mappers/asset-mapper.ts b/src/lib/mappers/asset-mapper.ts index f8db4de..5f4f820 100644 --- a/src/lib/mappers/asset-mapper.ts +++ b/src/lib/mappers/asset-mapper.ts @@ -103,10 +103,15 @@ export class AssetMapper { } addMapping(sourceAsset: mgmtApi.Media, targetAsset: mgmtApi.Media) { - const mapping = this.getAssetMapping(targetAsset, 'target'); + const targetMapping = this.getAssetMapping(targetAsset, 'target'); + const sourceMapping = this.getAssetMapping(sourceAsset, 'source'); + + if (targetMapping !== sourceMapping) { + throw new Error(`Invalid Mappings detected! Source mediaID: ${sourceAsset.mediaID}, Target mediaID: ${targetAsset.mediaID}`); + } - if (mapping) { - this.updateMapping(sourceAsset, targetAsset); + if (targetMapping) { + this.updateMapping(sourceAsset, targetAsset, targetMapping); } else { const newMapping: AssetMapping = { @@ -130,22 +135,22 @@ export class AssetMapper { this.saveMapping(); } - updateMapping(sourceAsset: mgmtApi.Media, targetAsset: mgmtApi.Media) { - const mapping = this.getAssetMapping(targetAsset, 'target'); - if (mapping) { - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourceDateModified = sourceAsset.dateModified; - mapping.targetDateModified = targetAsset.dateModified; - mapping.sourceMediaID = sourceAsset.mediaID; - mapping.targetMediaID = targetAsset.mediaID; - mapping.sourceUrl = sourceAsset.edgeUrl; - mapping.targetUrl = targetAsset.edgeUrl; - mapping.sourceContainerEdgeUrl = sourceAsset.containerEdgeUrl; - mapping.targetContainerEdgeUrl = targetAsset.containerEdgeUrl; - mapping.sourceContainerOriginUrl = sourceAsset.containerOriginUrl; - mapping.targetContainerOriginUrl = targetAsset.containerOriginUrl; + updateMapping(sourceAsset: mgmtApi.Media, targetAsset: mgmtApi.Media, mapping: AssetMapping) { + if (sourceAsset.mediaID !== mapping.sourceMediaID || targetAsset.mediaID !== mapping.targetMediaID) { + throw new Error(`Invalid items trying to be mapped! Source mediaID: ${sourceAsset.mediaID}, Target mediaID: ${targetAsset.mediaID}`); } + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourceDateModified = sourceAsset.dateModified; + mapping.targetDateModified = targetAsset.dateModified; + mapping.sourceMediaID = sourceAsset.mediaID; + mapping.targetMediaID = targetAsset.mediaID; + mapping.sourceUrl = sourceAsset.edgeUrl; + mapping.targetUrl = targetAsset.edgeUrl; + mapping.sourceContainerEdgeUrl = sourceAsset.containerEdgeUrl; + mapping.targetContainerEdgeUrl = targetAsset.containerEdgeUrl; + mapping.sourceContainerOriginUrl = sourceAsset.containerOriginUrl; + mapping.targetContainerOriginUrl = targetAsset.containerOriginUrl; this.saveMapping(); } diff --git a/src/lib/mappers/container-mapper.ts b/src/lib/mappers/container-mapper.ts index b043881..cb537bf 100644 --- a/src/lib/mappers/container-mapper.ts +++ b/src/lib/mappers/container-mapper.ts @@ -98,10 +98,15 @@ export class ContainerMapper { } addMapping(sourceContainer: mgmtApi.Container, targetContainer: mgmtApi.Container) { - const mapping = this.getContainerMapping(targetContainer, 'target'); + const targetMapping = this.getContainerMapping(targetContainer, 'target'); + const sourceMapping = this.getContainerMapping(sourceContainer, 'source'); - if (mapping) { - this.updateMapping(sourceContainer, targetContainer); + if (targetMapping !== sourceMapping) { + throw new Error(`Invalid Mappings detected! Source contentViewID: ${sourceContainer.contentViewID}, Target contentViewID: ${targetContainer.contentViewID}`); + } + + if (targetMapping) { + this.updateMapping(sourceContainer, targetContainer, targetMapping); } else { const newMapping: ContainerMapping = { @@ -122,20 +127,19 @@ export class ContainerMapper { this.saveMapping(); } - updateMapping(sourceContainer: mgmtApi.Container, targetContainer: mgmtApi.Container) { - const mapping = this.getContainerMapping(targetContainer, 'target'); - if (mapping) { - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourceContentViewID = sourceContainer.contentViewID; - mapping.targetContentViewID = targetContainer.contentViewID; - mapping.sourceLastModifiedDate = sourceContainer.lastModifiedDate; - mapping.targetLastModifiedDate = targetContainer.lastModifiedDate; - mapping.sourceReferenceName = sourceContainer.referenceName; - mapping.targetReferenceName = targetContainer.referenceName; - this.saveMapping(); + updateMapping(sourceContainer: mgmtApi.Container, targetContainer: mgmtApi.Container, mapping: ContainerMapping) { + if (sourceContainer.contentViewID !== mapping.sourceContentViewID || targetContainer.contentViewID !== mapping.targetContentViewID) { + throw new Error(`Invalid items trying to be mapped! Source contentViewID: ${sourceContainer.contentViewID}, Target contentViewID: ${targetContainer.contentViewID}`); } - + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourceContentViewID = sourceContainer.contentViewID; + mapping.targetContentViewID = targetContainer.contentViewID; + mapping.sourceLastModifiedDate = sourceContainer.lastModifiedDate; + mapping.targetLastModifiedDate = targetContainer.lastModifiedDate; + mapping.sourceReferenceName = sourceContainer.referenceName; + mapping.targetReferenceName = targetContainer.referenceName; + this.saveMapping(); } loadMapping() { diff --git a/src/lib/mappers/content-item-mapper.ts b/src/lib/mappers/content-item-mapper.ts index a949d0d..1e8321a 100644 --- a/src/lib/mappers/content-item-mapper.ts +++ b/src/lib/mappers/content-item-mapper.ts @@ -1,5 +1,6 @@ import { fileOperations } from "../../core"; import * as mgmtApi from "@agility/management-sdk"; +import { ContainerMapper } from "./container-mapper"; export interface ContentItemMapping { sourceGuid: string; @@ -17,6 +18,7 @@ export class ContentItemMapper { private targetGuid: string; private mappings: ContentItemMapping[]; private directory: string; + private containerMapper: ContainerMapper; public locale: string; constructor(sourceGuid: string, targetGuid: string, locale: string) { @@ -24,6 +26,7 @@ export class ContentItemMapper { this.targetGuid = targetGuid; this.directory = 'item'; this.locale = locale; + this.containerMapper = new ContainerMapper(sourceGuid, targetGuid); // this will provide access to the /agility-files/{GUID}/{locale} folder this.fileOps = new fileOperations(targetGuid, locale); this.mappings = this.loadMapping(); @@ -80,13 +83,38 @@ export class ContentItemMapper { } } + /* + * Function to check if the items are mappable. If the definitions are the same, the contentviews are mapped, then we are safe + */ + checkItemIsMappable(sourceContentItem: mgmtApi.ContentItem, targetContentItem: mgmtApi.ContentItem): void { + + // check if the models are the same + if(sourceContentItem.properties.definitionName !== targetContentItem.properties.definitionName){ + throw new Error(`Items cannot be mapped. They are not congruent, the content models are different. source contentID: ${sourceContentItem.contentID}, target contentID: ${targetContentItem.contentID}`); + } + + // use the reference name to check if the containers are truly mapped together + const sourceContainerMapping = this.containerMapper.getContainerMappingByReferenceName(sourceContentItem.properties.referenceName, "source"); + const targetContainerMapping = this.containerMapper.getContainerMappingByReferenceName(targetContentItem.properties.referenceName, "target"); + + if(sourceContainerMapping !== targetContainerMapping){ + throw new Error(`Items cannot be mapped. The containers are not mapped to each other. source contentID: ${sourceContentItem.contentID}, target contentID: ${targetContentItem.contentID}`); + } + } + addMapping(sourceContentItem: mgmtApi.ContentItem, targetContentItem: mgmtApi.ContentItem) { - const mapping = this.getContentItemMapping(targetContentItem, 'target'); + const targetMapping = this.getContentItemMapping(targetContentItem, 'target'); + const sourceMapping = this.getContentItemMapping(sourceContentItem, 'source') - if (mapping) { - this.updateMapping(sourceContentItem, targetContentItem); - } else { + if(targetMapping !== sourceMapping){ + throw new Error(`Invalid Mappings detected! The two items have different mappings, Source contentID: ${sourceContentItem.contentID}, Target contentID: ${targetContentItem.contentID}`); + } + // At this point target and source mappings should be the same + if (targetMapping) { + this.updateMapping(sourceContentItem, targetContentItem, targetMapping); + } else { + this.checkItemIsMappable(sourceContentItem, targetContentItem); const newMapping: ContentItemMapping = { sourceGuid: this.sourceGuid, targetGuid: this.targetGuid, @@ -94,25 +122,24 @@ export class ContentItemMapper { targetContentID: targetContentItem.contentID, sourceVersionID: sourceContentItem.properties.versionID, targetVersionID: targetContentItem.properties.versionID, - } - this.mappings.push(newMapping); } - this.saveMapping(); } - updateMapping(sourceContentItem: mgmtApi.ContentItem, targetContentItem: mgmtApi.ContentItem) { - const mapping = this.getContentItemMapping(targetContentItem, 'target'); - if (mapping) { - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourceContentID = sourceContentItem.contentID; - mapping.targetContentID = targetContentItem.contentID; - mapping.sourceVersionID = sourceContentItem.properties.versionID; - mapping.targetVersionID = targetContentItem.properties.versionID; + updateMapping(sourceContentItem: mgmtApi.ContentItem, targetContentItem: mgmtApi.ContentItem, mapping: ContentItemMapping) { + + if(sourceContentItem.contentID !== mapping.sourceContentID || targetContentItem.contentID !== mapping.targetContentID){ + throw new Error(`Invalid items trying to be mapped! Source contentID: ${sourceContentItem.contentID}, Target contentID: ${targetContentItem.contentID}`); } + + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourceContentID = sourceContentItem.contentID; + mapping.targetContentID = targetContentItem.contentID; + mapping.sourceVersionID = sourceContentItem.properties.versionID; + mapping.targetVersionID = targetContentItem.properties.versionID; this.saveMapping(); } @@ -167,4 +194,4 @@ export class ContentItemMapper { }; } -} \ No newline at end of file +} diff --git a/src/lib/mappers/gallery-mapper.ts b/src/lib/mappers/gallery-mapper.ts index 227a5a3..a22f329 100644 --- a/src/lib/mappers/gallery-mapper.ts +++ b/src/lib/mappers/gallery-mapper.ts @@ -63,10 +63,15 @@ export class GalleryMapper { } addMapping(sourceGallery: mgmtApi.assetMediaGrouping, targetGallery: mgmtApi.assetMediaGrouping) { - const mapping = this.getGalleryMapping(targetGallery, 'target'); + const targetMapping = this.getGalleryMapping(targetGallery, 'target'); + const sourceMapping = this.getGalleryMapping(sourceGallery, 'source'); + + if (targetMapping !== sourceMapping) { + throw new Error(`Invalid Mappings detected! Source mediaGroupingID: ${sourceGallery.mediaGroupingID}, Target mediaGroupingID: ${targetGallery.mediaGroupingID}`); + } - if (mapping) { - this.updateMapping(sourceGallery, targetGallery); + if (targetMapping) { + this.updateMapping(sourceGallery, targetGallery, targetMapping); } else { const newMapping: GalleryMapping = { @@ -85,16 +90,16 @@ export class GalleryMapper { this.saveMapping(); } - updateMapping(sourceGallery: mgmtApi.assetMediaGrouping, targetGallery: mgmtApi.assetMediaGrouping) { - const mapping = this.getGalleryMapping(targetGallery, 'target'); - if (mapping) { - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourceMediaGroupingID = sourceGallery.mediaGroupingID; - mapping.targetMediaGroupingID = targetGallery.mediaGroupingID; - mapping.sourceModifiedOn = sourceGallery.modifiedOn; - mapping.targetModifiedOn = targetGallery.modifiedOn; + updateMapping(sourceGallery: mgmtApi.assetMediaGrouping, targetGallery: mgmtApi.assetMediaGrouping, mapping: GalleryMapping) { + if (sourceGallery.mediaGroupingID !== mapping.sourceMediaGroupingID || targetGallery.mediaGroupingID !== mapping.targetMediaGroupingID) { + throw new Error(`Invalid items trying to be mapped! Source mediaGroupingID: ${sourceGallery.mediaGroupingID}, Target mediaGroupingID: ${targetGallery.mediaGroupingID}`); } + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourceMediaGroupingID = sourceGallery.mediaGroupingID; + mapping.targetMediaGroupingID = targetGallery.mediaGroupingID; + mapping.sourceModifiedOn = sourceGallery.modifiedOn; + mapping.targetModifiedOn = targetGallery.modifiedOn; this.saveMapping(); } diff --git a/src/lib/mappers/model-mapper.ts b/src/lib/mappers/model-mapper.ts index 056ec1d..3d88424 100644 --- a/src/lib/mappers/model-mapper.ts +++ b/src/lib/mappers/model-mapper.ts @@ -72,10 +72,15 @@ export class ModelMapper { } addMapping(sourceModel: mgmtApi.Model, targetModel: mgmtApi.Model) { - const mapping = this.getModelMapping(targetModel, 'target'); + const targetMapping = this.getModelMapping(targetModel, 'target'); + const sourceMapping = this.getModelMapping(sourceModel, 'source'); + + if (targetMapping !== sourceMapping) { + throw new Error(`Invalid Mappings detected! Source model ID: ${sourceModel.id}, Target model ID: ${targetModel.id}`); + } - if (mapping) { - this.updateMapping(sourceModel, targetModel); + if (targetMapping) { + this.updateMapping(sourceModel, targetModel, targetMapping); } else { const newMapping: ModelMapping = { @@ -96,18 +101,18 @@ export class ModelMapper { this.saveMapping(); } - updateMapping(sourceModel: mgmtApi.Model, targetModel: mgmtApi.Model) { - const mapping = this.getModelMapping(targetModel, 'target'); - if (mapping) { - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourceID = sourceModel.id; - mapping.targetID = targetModel.id; - mapping.sourceReferenceName = sourceModel.referenceName; - mapping.targetReferenceName = targetModel.referenceName; - mapping.sourceLastModifiedDate = sourceModel.lastModifiedDate; - mapping.targetLastModifiedDate = targetModel.lastModifiedDate; + updateMapping(sourceModel: mgmtApi.Model, targetModel: mgmtApi.Model, mapping: ModelMapping) { + if (sourceModel.id !== mapping.sourceID || targetModel.id !== mapping.targetID) { + throw new Error(`Invalid items trying to be mapped! Source model ID: ${sourceModel.id}, Target model ID: ${targetModel.id}`); } + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourceID = sourceModel.id; + mapping.targetID = targetModel.id; + mapping.sourceReferenceName = sourceModel.referenceName; + mapping.targetReferenceName = targetModel.referenceName; + mapping.sourceLastModifiedDate = sourceModel.lastModifiedDate; + mapping.targetLastModifiedDate = targetModel.lastModifiedDate; this.saveMapping(); } diff --git a/src/lib/mappers/page-mapper.ts b/src/lib/mappers/page-mapper.ts index 3710faf..6f613da 100644 --- a/src/lib/mappers/page-mapper.ts +++ b/src/lib/mappers/page-mapper.ts @@ -67,10 +67,15 @@ export class PageMapper { } addMapping(sourcePage: mgmtApi.PageItem, targetPage: mgmtApi.PageItem) { - const mapping = this.getPageMapping(targetPage, 'target'); + const targetMapping = this.getPageMapping(targetPage, 'target'); + const sourceMapping = this.getPageMapping(sourcePage, 'source'); - if (mapping) { - this.updateMapping(sourcePage, targetPage); + if (targetMapping !== sourceMapping) { + throw new Error(`Invalid Mappings detected! Source pageID: ${sourcePage.pageID}, Target pageID: ${targetPage.pageID}`); + } + + if (targetMapping) { + this.updateMapping(sourcePage, targetPage, targetMapping); } else { const newMapping: PageMapping = { @@ -90,18 +95,18 @@ export class PageMapper { this.saveMapping(); } - updateMapping(sourcePage: mgmtApi.PageItem, targetPage: mgmtApi.PageItem) { - const mapping = this.getPageMapping(targetPage, 'target'); - if (mapping) { - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourcePageID = sourcePage.pageID; - mapping.targetPageID = targetPage.pageID; - mapping.sourceVersionID = sourcePage.properties.versionID; - mapping.targetVersionID = targetPage.properties.versionID; - mapping.sourcePageTemplateName = sourcePage.templateName; - mapping.targetPageTemplateName = targetPage.templateName; + updateMapping(sourcePage: mgmtApi.PageItem, targetPage: mgmtApi.PageItem, mapping: PageMapping) { + if (sourcePage.pageID !== mapping.sourcePageID || targetPage.pageID !== mapping.targetPageID) { + throw new Error(`Invalid items trying to be mapped! Source pageID: ${sourcePage.pageID}, Target pageID: ${targetPage.pageID}`); } + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourcePageID = sourcePage.pageID; + mapping.targetPageID = targetPage.pageID; + mapping.sourceVersionID = sourcePage.properties.versionID; + mapping.targetVersionID = targetPage.properties.versionID; + mapping.sourcePageTemplateName = sourcePage.templateName; + mapping.targetPageTemplateName = targetPage.templateName; this.saveMapping(); } diff --git a/src/lib/mappers/template-mapper.ts b/src/lib/mappers/template-mapper.ts index 888a574..4827ae2 100644 --- a/src/lib/mappers/template-mapper.ts +++ b/src/lib/mappers/template-mapper.ts @@ -66,10 +66,15 @@ export class TemplateMapper { } addMapping(sourceTemplate: mgmtApi.PageModel, targetTemplate: mgmtApi.PageModel) { - const mapping = this.getTemplateMapping(targetTemplate, 'target'); + const targetMapping = this.getTemplateMapping(targetTemplate, 'target'); + const sourceMapping = this.getTemplateMapping(sourceTemplate, 'source'); - if (mapping) { - this.updateMapping(sourceTemplate, targetTemplate); + if (targetMapping !== sourceMapping) { + throw new Error(`Invalid Mappings detected! Source pageTemplateID: ${sourceTemplate.pageTemplateID}, Target pageTemplateID: ${targetTemplate.pageTemplateID}`); + } + + if (targetMapping) { + this.updateMapping(sourceTemplate, targetTemplate, targetMapping); } else { const newMapping: TemplateMapping = { @@ -87,16 +92,16 @@ export class TemplateMapper { this.saveMapping(); } - updateMapping(sourceTemplate: mgmtApi.PageModel, targetTemplate: mgmtApi.PageModel) { - const mapping = this.getTemplateMapping(targetTemplate, 'target'); - if (mapping) { - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourcePageTemplateID = sourceTemplate.pageTemplateID; - mapping.targetPageTemplateID = targetTemplate.pageTemplateID; - mapping.sourcePageTemplateName = sourceTemplate.pageTemplateName; - mapping.targetPageTemplateName = targetTemplate.pageTemplateName; + updateMapping(sourceTemplate: mgmtApi.PageModel, targetTemplate: mgmtApi.PageModel, mapping: TemplateMapping) { + if (sourceTemplate.pageTemplateID !== mapping.sourcePageTemplateID || targetTemplate.pageTemplateID !== mapping.targetPageTemplateID) { + throw new Error(`Invalid items trying to be mapped! Source pageTemplateID: ${sourceTemplate.pageTemplateID}, Target pageTemplateID: ${targetTemplate.pageTemplateID}`); } + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourcePageTemplateID = sourceTemplate.pageTemplateID; + mapping.targetPageTemplateID = targetTemplate.pageTemplateID; + mapping.sourcePageTemplateName = sourceTemplate.pageTemplateName; + mapping.targetPageTemplateName = targetTemplate.pageTemplateName; this.saveMapping(); } diff --git a/src/lib/pushers/batch-polling.ts b/src/lib/pushers/batch-polling.ts index 89b4fd4..8818ce7 100644 --- a/src/lib/pushers/batch-polling.ts +++ b/src/lib/pushers/batch-polling.ts @@ -1,6 +1,13 @@ import * as mgmtApi from '@agility/management-sdk'; import ansiColors from 'ansi-colors'; +export type CompletedBatch = mgmtApi.Batch & { + totalItems?: number; + successCount?: number; + failureCount?: number; + durationMs?: number; +}; + /** * Extract the error message from a JSON error response or plain text * Handles both JSON format {"message":"..."} and plain text errors @@ -40,33 +47,7 @@ function createProgressBar(percent: number, width: number = 20): string { return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`; } -/** - * Log batch errors using structured failedItems array (preferred) or legacy items array - */ function logBatchErrors(batchStatus: any, originalPayloads?: any[]): void { - // Prefer structured failedItems array from new API - if (Array.isArray(batchStatus.failedItems) && batchStatus.failedItems.length > 0) { - batchStatus.failedItems.forEach((failed: any) => { - const batchItemId = failed.batchItemId ?? failed.batchItemID ?? '?'; - const errorType = failed.errorType || 'Error'; - const errorMessage = failed.errorMessage || 'Unknown error'; - const itemType = failed.itemType || 'Item'; - - // Try to find the original payload by batchItemId to get referenceName - let referenceName = 'unknown'; - if (originalPayloads) { - // batchItemId typically corresponds to index in the batch - const payload = originalPayloads.find((p, idx) => p?.batchItemId === batchItemId) - || originalPayloads[batchStatus.failedItems.indexOf(failed)]; - referenceName = payload?.properties?.referenceName || payload?.referenceName || 'unknown'; - } - - console.error(ansiColors.red(` ✗ ${itemType} ${batchItemId} (${referenceName}): ${errorMessage}`)); - }); - return; - } - - // Fallback to legacy items array with errorMessage field if (Array.isArray(batchStatus.items)) { batchStatus.items.forEach((item: any, index: number) => { if (item.errorMessage) { @@ -86,7 +67,7 @@ const BATCH_STATE_PROCESSED = 3; /** * Normalize batch response so we handle both camelCase (SDK) and PascalCase (.NET API). */ -function normalizeBatchStatus(batchStatus: any): any { +function normalizeBatchStatus(batchStatus: any): CompletedBatch { if (!batchStatus) return batchStatus; return { ...batchStatus, @@ -129,7 +110,7 @@ export async function pollBatchUntilComplete( intervalMs: number = 2000, // 2 seconds batchType?: string, // Type of batch for better logging totalItems?: number // Total items in batch for progress display -): Promise { +): Promise { let attempts = 0; let consecutiveErrors = 0; const startTime = Date.now(); @@ -229,19 +210,40 @@ export async function pollBatchUntilComplete( throw new Error(`Batch ${batchID} polling timed out after ${maxAttempts} attempts (~${Math.round(maxAttempts * intervalMs / 60000)} minutes)`); } -/** - * Extract results from completed batch - * Uses structured failedItems from new API if available, falls back to legacy items array - */ -export function extractBatchResults(batch: any, originalItems: any[]): { - successfulItems: any[], - failedItems: any[], - summary?: { totalItems: number, successCount: number, failureCount: number, durationMs: number } -} { - const successfulItems: any[] = []; - const failedItems: any[] = []; - - // Extract summary info if available from new API +interface BatchSummary { + totalItems: number; + successCount: number; + failureCount: number; + durationMs: number; +} + +interface BatchSuccessItem { + originalItem: T; + newId: number; + newItem: mgmtApi.BatchItem; + index: number; +} + +interface BatchFailureItem { + originalItem: T | null; + newItem: null; + error: string; + errorType?: string; + itemType?: string; + batchItemId?: number; + index: number; +} + +interface BatchExtractResult { + successfulItems: BatchSuccessItem[]; + failedItems: BatchFailureItem[]; + summary?: BatchSummary; +} + +function extractBatchResultsImpl(batch: CompletedBatch, originalItems: T[]): BatchExtractResult { + const successfulItems: BatchSuccessItem[] = []; + const failedItems: BatchFailureItem[] = []; + const summary = batch?.totalItems !== undefined ? { totalItems: batch.totalItems, successCount: batch.successCount ?? 0, @@ -249,52 +251,12 @@ export function extractBatchResults(batch: any, originalItems: any[]): { durationMs: batch.durationMs ?? 0 } : undefined; - // Use structured failedItems from new API if available - if (Array.isArray(batch?.failedItems) && batch.failedItems.length > 0) { - // Track which indices failed - const failedBatchItemIds = new Set(batch.failedItems.map((f: any) => f.batchItemId ?? f.batchItemID)); - - // Process failed items from structured array - batch.failedItems.forEach((failed: any, idx: number) => { - const batchItemId = failed.batchItemId ?? failed.batchItemID; - // Try to match to original item - batchItemId might be 1-indexed or match some property - const originalItem = originalItems[idx] || originalItems.find((item, i) => i === batchItemId - 1); - - failedItems.push({ - originalItem: originalItem || null, - newItem: null, - error: failed.errorMessage || 'Unknown error', - errorType: failed.errorType, - itemType: failed.itemType, - batchItemId: batchItemId, - index: idx - }); - }); - - // Remaining items are successful (from items array or inferred) - if (Array.isArray(batch?.items)) { - batch.items.forEach((item: any, index: number) => { - const batchItemId = item.batchItemID || item.batchItemId; - if (!failedBatchItemIds.has(batchItemId) && item.itemID > 0) { - successfulItems.push({ - originalItem: originalItems[index], - newId: item.itemID, - newItem: item, - index - }); - } - }); - } - - return { successfulItems, failedItems, summary }; - } - - // Fallback to legacy items array processing if (!batch?.items || !Array.isArray(batch.items)) { return { successfulItems: [], failedItems: originalItems.map((item, index) => ({ originalItem: item, + newItem: null, error: 'No batch items returned', index })), @@ -302,17 +264,11 @@ export function extractBatchResults(batch: any, originalItems: any[]): { }; } - // Process each batch item (legacy) batch.items.forEach((item: any, index: number) => { const originalItem = originalItems[index]; - + if (item.itemID > 0 && !item.itemNull) { - successfulItems.push({ - originalItem, - newId: item.itemID, - newItem: item, - index - }); + successfulItems.push({ originalItem, newId: item.itemID, newItem: item, index }); } else { let errorMsg = 'Failed to create item'; if (item.errorMessage) { @@ -320,18 +276,20 @@ export function extractBatchResults(batch: any, originalItems: any[]): { } else if (!item.itemNull) { errorMsg = `Invalid ID: ${item.itemID}`; } - - failedItems.push({ - originalItem, - newItem: null, - error: errorMsg, - index - }); + failedItems.push({ originalItem, newItem: null, error: errorMsg, index }); } }); return { successfulItems, failedItems, summary }; -} +} + +export function extractContentBatchResults(batch: CompletedBatch, originalItems: mgmtApi.ContentItem[]): BatchExtractResult { + return extractBatchResultsImpl(batch, originalItems); +} + +export function extractPageBatchResults(batch: CompletedBatch, originalItems: mgmtApi.PageItem[]): BatchExtractResult { + return extractBatchResultsImpl(batch, originalItems); +} export function prettyException(error: string) { @@ -386,4 +344,4 @@ export function logBatchError( console.log(ansiColors.gray.italic('Full Payload:')); console.log(ansiColors.gray.italic(JSON.stringify(originalPayload, null, 2))); } -} \ No newline at end of file +} diff --git a/src/lib/pushers/container-pusher.ts b/src/lib/pushers/container-pusher.ts index 703823c..a8bb558 100644 --- a/src/lib/pushers/container-pusher.ts +++ b/src/lib/pushers/container-pusher.ts @@ -33,13 +33,13 @@ export async function pushContainers( const modelMapper = new ModelMapper(sourceGuid[0], targetGuid[0]); for (const sourceContainer of sourceContainers) { - //SPECIAL CASE for fixed Agility containers - if (sourceContainer.referenceName === "AgilityCSSFiles" - || sourceContainer.referenceName === "AgilityJavascriptFiles" - || sourceContainer.referenceName === "AgilityGlobalCodeTemplates" - || sourceContainer.referenceName === "AgilityModuleCodeTemplates" - || sourceContainer.referenceName === "AgilityPageCodeTemplates" + if ( + sourceContainer.referenceName === "AgilityCSSFiles" || + sourceContainer.referenceName === "AgilityJavascriptFiles" || + sourceContainer.referenceName === "AgilityGlobalCodeTemplates" || + sourceContainer.referenceName === "AgilityModuleCodeTemplates" || + sourceContainer.referenceName === "AgilityPageCodeTemplates" ) { //ignore these containers continue; @@ -67,15 +67,17 @@ export async function pushContainers( const hasTargetChanges = existingMapping !== null && containerMapper.hasTargetChanged(targetContainer); const hasSourceChanges = existingMapping !== null && containerMapper.hasSourceChanged(sourceContainer); let shouldUpdate = existingMapping !== null && !hasTargetChanges && hasSourceChanges; - let shouldSkip = existingMapping !== null && hasTargetChanges && !hasSourceChanges || existingMapping !== null && !hasSourceChanges && !hasTargetChanges; + let shouldSkip = + (existingMapping !== null && hasTargetChanges && !hasSourceChanges) || + (existingMapping !== null && !hasSourceChanges && !hasTargetChanges); if (overwrite) { shouldUpdate = true; shouldSkip = false; } - const modelMapping = modelMapper.getModelMappingByID(sourceContainer.contentDefinitionID, 'source') - let targetModelID = -1 + const modelMapping = modelMapper.getModelMappingByID(sourceContainer.contentDefinitionID, "source"); + let targetModelID = -1; // Check if target container mapping exists before attempting to create if (sourceContainer.contentDefinitionID === 1) { @@ -90,7 +92,7 @@ export async function pushContainers( if (shouldCreate) { // Container doesn't exist - create new one if (targetModelID < 1) { - logger.container.skipped(sourceContainer, "Target model mapping not found", targetGuid[0]) + logger.container.skipped(sourceContainer, "Target model mapping not found", targetGuid[0]); skipped++; } else { // Container doesn't exist - create new one @@ -103,11 +105,11 @@ export async function pushContainers( ); if (createResult) { - logger.container.created(sourceContainer, "created", targetGuid[0]) - containerMapper.addMapping(sourceContainer, createResult) + logger.container.created(sourceContainer, "created", targetGuid[0]); + containerMapper.addMapping(sourceContainer, createResult); successful++; } else { - logger.container.error(sourceContainer, "Failed to create container", targetGuid[0]) + logger.container.error(sourceContainer, "Failed to create container", targetGuid[0]); failed++; currentStatus = "error"; overallStatus = "error"; @@ -119,7 +121,7 @@ export async function pushContainers( // Container exists but needs updating if (targetModelID < 1) { - logger.container.skipped(sourceContainer, "Target model mapping not found", targetGuid[0]) + logger.container.skipped(sourceContainer, "Target model mapping not found", targetGuid[0]); skipped++; } else { @@ -133,11 +135,20 @@ export async function pushContainers( ); if (updateResult) { - logger.container.updated(sourceContainer, "updated", targetGuid[0]) - containerMapper.updateMapping(sourceContainer, updateResult); + logger.container.updated(sourceContainer, "updated", targetGuid[0]); + const sourceMapping = containerMapper.getContainerMapping(sourceContainer, "source"); + const targetMapping = containerMapper.getContainerMapping(targetContainer, "target"); + + if (sourceMapping !== targetMapping) { + throw new Error( + `Invalid Mappings detected! Source containerID: ${sourceContainer.contentViewID}, Target containerID: ${targetContainer.contentViewID}`, + ); + } + + containerMapper.updateMapping(sourceContainer, updateResult, sourceMapping); successful++; } else { - logger.container.error(sourceContainer, "Failed to update container", targetGuid[0]) + logger.container.error(sourceContainer, "Failed to update container", targetGuid[0]); failed++; currentStatus = "error"; overallStatus = "error"; @@ -147,11 +158,11 @@ export async function pushContainers( } } else if (shouldSkip) { // Container exists and is up to date - skip - logger.container.skipped(sourceContainer, "up to date, skipping", targetGuid[0]) + logger.container.skipped(sourceContainer, "up to date, skipping", targetGuid[0]); skipped++; } } catch (error: any) { - logger.container.error(sourceContainer, error, targetGuid[0]) + logger.container.error(sourceContainer, error, targetGuid[0]); failed++; currentStatus = "error"; overallStatus = "error"; @@ -172,9 +183,8 @@ async function updateExistingContainer( apiClient: ApiClient, targetGuid: string, targetModelId: number, - logger: Logs + logger: Logs, ): Promise { - // Prepare update payload const updatePayload = { ...sourceContainer, @@ -184,7 +194,7 @@ async function updateExistingContainer( // Update the container const updatedContainer = await apiClient.containerMethods.saveContainer(updatePayload, targetGuid, true); - logger.container.updated(sourceContainer, "updated", targetGuid) + logger.container.updated(sourceContainer, "updated", targetGuid); return updatedContainer; } @@ -196,9 +206,8 @@ async function createNewContainer( apiClient: ApiClient, targetGuid: string, targetModelId: number, - logger: Logs + logger: Logs, ): Promise { - // Prepare creation payload const createPayload = { ...sourceContainer, @@ -211,7 +220,7 @@ async function createNewContainer( const newContainer = await apiClient.containerMethods.saveContainer(createPayload, targetGuid, true); return newContainer; } catch (error: any) { - logger.container.error(createPayload, error, targetGuid) + logger.container.error(createPayload, error, targetGuid); throw error; } } diff --git a/src/lib/pushers/content-pusher/content-batch-processor.ts b/src/lib/pushers/content-pusher/content-batch-processor.ts index 648419c..ebb103a 100644 --- a/src/lib/pushers/content-pusher/content-batch-processor.ts +++ b/src/lib/pushers/content-pusher/content-batch-processor.ts @@ -1,5 +1,5 @@ import * as mgmtApi from "@agility/management-sdk"; -import { pollBatchUntilComplete, extractBatchResults } from "../batch-polling"; +import { pollBatchUntilComplete, extractContentBatchResults } from "../batch-polling"; import ansiColors from "ansi-colors"; import { ModelMapper } from "lib/mappers/model-mapper"; import { ContainerMapper } from "lib/mappers/container-mapper"; @@ -115,7 +115,7 @@ export class ContentBatchProcessor { // contentBatch may include skipped items (orphaned content, missing model, etc.) that were // never added to contentPayloads; using it here would shift every result after a skip onto // the wrong source item, corrupting the source→target ID mappings. - const { successfulItems, failedItems } = extractBatchResults(completedBatch, includedItems); + const { successfulItems, failedItems } = extractContentBatchResults(completedBatch, includedItems); // Convert to expected format // Filter publishableIds to only include items that are Published (state === 2) in source diff --git a/src/lib/pushers/content-pusher/tests/content-batch-processor.test.ts b/src/lib/pushers/content-pusher/tests/content-batch-processor.test.ts index bf3cf2c..f2be76e 100644 --- a/src/lib/pushers/content-pusher/tests/content-batch-processor.test.ts +++ b/src/lib/pushers/content-pusher/tests/content-batch-processor.test.ts @@ -8,17 +8,17 @@ import { ContentItemMapper } from 'lib/mappers/content-item-mapper'; // Hoist mocks for modules that make real network calls or file I/O inside processBatches jest.mock('lib/pushers/batch-polling', () => ({ pollBatchUntilComplete: jest.fn(), - extractBatchResults: jest.fn(), + extractContentBatchResults: jest.fn(), })); jest.mock('../util/find-content-in-other-locale', () => ({ findContentInOtherLocale: jest.fn().mockResolvedValue(-1), })); -import { pollBatchUntilComplete, extractBatchResults } from 'lib/pushers/batch-polling'; +import { pollBatchUntilComplete, extractContentBatchResults } from 'lib/pushers/batch-polling'; const mockPoll = pollBatchUntilComplete as jest.Mock; -const mockExtract = extractBatchResults as jest.Mock; +const mockExtract = extractContentBatchResults as jest.Mock; let tmpDir: string; diff --git a/src/lib/pushers/content-pusher/util/change-detection.ts b/src/lib/pushers/content-pusher/util/change-detection.ts index 7361098..c216845 100644 --- a/src/lib/pushers/content-pusher/util/change-detection.ts +++ b/src/lib/pushers/content-pusher/util/change-detection.ts @@ -36,20 +36,20 @@ export function changeDetection( const itemName = sourceEntity.properties?.referenceName || `ID:${sourceEntity.contentID}`; - if (!mapping && !targetEntity) { - //if we have no target content and no mapping - // if (state.verbose) { - // console.log(`[ChangeDetection] ${itemName}: No mapping and no target entity → CREATE`); - // } - return { - entity: null, - shouldUpdate: false, - shouldCreate: true, - shouldSkip: false, - isConflict: false, - reason: "Entity does not exist in target", - }; - } + if (!mapping && !targetEntity) { + //if we have no target content and no mapping + // if (state.verbose) { + // console.log(`[ChangeDetection] ${itemName}: No mapping and no target entity → CREATE`); + // } + return { + entity: null, + shouldUpdate: false, + shouldCreate: true, + shouldSkip: false, + isConflict: false, + reason: "Mapping and Target Content Item doesn't exist" + }; + } // Check if update is needed based on version or modification date const sourceVersion = sourceEntity.properties?.versionID || 0; diff --git a/src/lib/pushers/page-pusher/process-page.ts b/src/lib/pushers/page-pusher/process-page.ts index db15be1..d99f14e 100644 --- a/src/lib/pushers/page-pusher/process-page.ts +++ b/src/lib/pushers/page-pusher/process-page.ts @@ -424,7 +424,7 @@ export async function processPage({ // Page batch processing started (silent) // Poll batch until completion using consistent utility (pass payload for error matching) - const { pollBatchUntilComplete, extractBatchResults } = await import("../batch-polling"); + const { pollBatchUntilComplete, extractPageBatchResults } = await import("../batch-polling"); const completedBatch = await pollBatchUntilComplete( apiClient, batchID, @@ -436,7 +436,7 @@ export async function processPage({ ); // Extract result from completed batch - const { successfulItems: batchSuccessItems, failedItems: batchFailedItems } = extractBatchResults( + const { successfulItems: batchSuccessItems, failedItems: batchFailedItems } = extractPageBatchResults( completedBatch, [page] ); @@ -480,13 +480,8 @@ export async function processPage({ } return { status: "success" }; // Success } else { - // Extract error message - prefer structured failedItems from new API let errorMsg: string; - if (Array.isArray(completedBatch.failedItems) && completedBatch.failedItems.length > 0) { - // Use structured error from new API - errorMsg = completedBatch.failedItems[0].errorMessage || 'Unknown batch error'; - } else if (batchFailedItems.length > 0 && batchFailedItems[0].error) { - // Use error from extractBatchResults + if (batchFailedItems.length > 0 && batchFailedItems[0].error) { errorMsg = batchFailedItems[0].error; } else if (completedBatch.errorData && typeof completedBatch.errorData === 'string' && !completedBatch.errorData.startsWith('{')) { // Use errorData only if it's a simple string (not JSON) diff --git a/src/lib/pushers/page-pusher/tests/process-page.test.ts b/src/lib/pushers/page-pusher/tests/process-page.test.ts index a7e6b59..321b3f6 100644 --- a/src/lib/pushers/page-pusher/tests/process-page.test.ts +++ b/src/lib/pushers/page-pusher/tests/process-page.test.ts @@ -11,15 +11,15 @@ jest.mock('../find-page-in-other-locale', () => ({ jest.mock('lib/pushers/batch-polling', () => ({ pollBatchUntilComplete: jest.fn(), - extractBatchResults: jest.fn(), + extractPageBatchResults: jest.fn(), })); import { findPageInOtherLocale } from '../find-page-in-other-locale'; -import { pollBatchUntilComplete, extractBatchResults } from 'lib/pushers/batch-polling'; +import { pollBatchUntilComplete, extractPageBatchResults } from 'lib/pushers/batch-polling'; const mockFindInOtherLocale = findPageInOtherLocale as jest.Mock; const mockPoll = pollBatchUntilComplete as jest.Mock; -const mockExtract = extractBatchResults as jest.Mock; +const mockExtract = extractPageBatchResults as jest.Mock; let tmpDir: string; diff --git a/src/lib/pushers/tests/batch-polling.test.ts b/src/lib/pushers/tests/batch-polling.test.ts index d2ac05e..2602514 100644 --- a/src/lib/pushers/tests/batch-polling.test.ts +++ b/src/lib/pushers/tests/batch-polling.test.ts @@ -1,5 +1,9 @@ -import { resetState, setState } from 'core/state'; -import { extractBatchResults, logBatchError } from '../batch-polling'; +import { resetState } from 'core/state'; +import { extractContentBatchResults, logBatchError, CompletedBatch } from '../batch-polling'; +import * as mgmtApi from '@agility/management-sdk'; + +const asBatch = (obj: Record): CompletedBatch => obj as CompletedBatch; +const asContent = (obj: Record): mgmtApi.ContentItem => obj as mgmtApi.ContentItem; beforeEach(() => { resetState(); @@ -12,12 +16,12 @@ afterEach(() => { jest.restoreAllMocks(); }); -// ─── extractBatchResults — no batch items returned ──────────────────────────── +// ─── extractContentBatchResults — no batch items returned ──────────────────────────── -describe('extractBatchResults — no items in batch', () => { +describe('extractContentBatchResults — no items in batch', () => { it('marks all originalItems as failed when batch has no items array', () => { - const originals = [{ contentID: 1 }, { contentID: 2 }]; - const result = extractBatchResults({}, originals); + const originals = [{ contentID: 1 }, { contentID: 2 }] as mgmtApi.ContentItem[]; + const result = extractContentBatchResults(asBatch({}), originals); expect(result.failedItems).toHaveLength(2); expect(result.successfulItems).toHaveLength(0); @@ -27,22 +31,22 @@ describe('extractBatchResults — no items in batch', () => { }); it('marks all originalItems as failed when batch.items is null', () => { - const originals = [{ contentID: 1 }]; - const result = extractBatchResults({ items: null }, originals); + const originals = [asContent({ contentID: 1 })] as mgmtApi.ContentItem[]; + const result = extractContentBatchResults(asBatch({ items: null }), originals); expect(result.failedItems).toHaveLength(1); expect(result.successfulItems).toHaveLength(0); }); it('returns empty summary when batch has no totalItems field', () => { - const result = extractBatchResults({}, []); + const result = extractContentBatchResults(asBatch({}), []); expect(result.summary).toBeUndefined(); }); }); -// ─── extractBatchResults — legacy items array (happy path) ──────────────────── +// ─── extractContentBatchResults — legacy items array (happy path) ──────────────────── -describe('extractBatchResults — legacy items array', () => { +describe('extractContentBatchResults — legacy items array', () => { it('classifies items with itemID > 0 as successful', () => { const batch = { items: [ @@ -50,8 +54,8 @@ describe('extractBatchResults — legacy items array', () => { { itemID: 102, processedItemVersionID: 1 }, ], }; - const originals = [{ contentID: 1 }, { contentID: 2 }]; - const result = extractBatchResults(batch, originals); + const originals = [{ contentID: 1 }, { contentID: 2 }] as mgmtApi.ContentItem[]; + const result = extractContentBatchResults(asBatch(batch), originals); expect(result.successfulItems).toHaveLength(2); expect(result.failedItems).toHaveLength(0); @@ -60,17 +64,17 @@ describe('extractBatchResults — legacy items array', () => { }); it('preserves originalItem reference in successful items', () => { - const original = { contentID: 99 }; + const original = { contentID: 99 } as mgmtApi.ContentItem; const batch = { items: [{ itemID: 200, processedItemVersionID: 1 }] }; - const result = extractBatchResults(batch, [original]); + const result = extractContentBatchResults(asBatch(batch), [original]); expect(result.successfulItems[0].originalItem).toBe(original); }); it('classifies items with itemID <= 0 as failed', () => { const batch = { items: [{ itemID: 0 }] }; - const originals = [{ contentID: 1 }]; - const result = extractBatchResults(batch, originals); + const originals = [asContent({ contentID: 1 })] as mgmtApi.ContentItem[]; + const result = extractContentBatchResults(asBatch(batch), originals); expect(result.failedItems).toHaveLength(1); expect(result.successfulItems).toHaveLength(0); @@ -80,30 +84,30 @@ describe('extractBatchResults — legacy items array', () => { const batch = { items: [{ itemID: 0, errorMessage: '{"message":"field too long"}' }], }; - const result = extractBatchResults(batch, [{ contentID: 1 }]); + const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 1 })]); expect(result.failedItems[0].error).toBe('field too long'); }); it('uses fallback error message when errorMessage is absent', () => { const batch = { items: [{ itemID: -1 }] }; - const result = extractBatchResults(batch, [{ contentID: 1 }]); + const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 1 })]); expect(result.failedItems[0].error).toContain('Invalid ID'); }); it('marks item as failed when itemNull is set even if itemID > 0', () => { const batch = { items: [{ itemID: 5, itemNull: true }] }; - const result = extractBatchResults(batch, [{ contentID: 1 }]); + const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 1 })]); expect(result.failedItems).toHaveLength(1); expect(result.successfulItems).toHaveLength(0); }); }); -// ─── extractBatchResults — structured failedItems (new API) ─────────────────── +// ─── extractContentBatchResults — structured failedItems (new API) ─────────────────── -describe('extractBatchResults — structured failedItems array', () => { +describe('extractContentBatchResults — structured failedItems array', () => { it('uses failedItems array from new API when present', () => { const batch = { failedItems: [ @@ -113,8 +117,8 @@ describe('extractBatchResults — structured failedItems array', () => { { itemID: 0, batchItemID: 1 }, ], }; - const originals = [{ contentID: 10 }]; - const result = extractBatchResults(batch, originals); + const originals = [{ contentID: 10 }] as mgmtApi.ContentItem[]; + const result = extractContentBatchResults(asBatch(batch), originals); expect(result.failedItems).toHaveLength(1); expect(result.failedItems[0].error).toBe('Validation error'); @@ -132,8 +136,8 @@ describe('extractBatchResults — structured failedItems array', () => { { itemID: 200, batchItemID: 2 }, ], }; - const originals = [{ contentID: 1 }, { contentID: 2 }]; - const result = extractBatchResults(batch, originals); + const originals = [{ contentID: 1 }, { contentID: 2 }] as mgmtApi.ContentItem[]; + const result = extractContentBatchResults(asBatch(batch), originals); expect(result.successfulItems).toHaveLength(1); expect(result.successfulItems[0].newId).toBe(200); @@ -146,15 +150,15 @@ describe('extractBatchResults — structured failedItems array', () => { { batchItemID: 1, errorMessage: 'bad field', errorType: 'Error', itemType: 'Content' }, ], }; - const result = extractBatchResults(batch, [{ contentID: 1 }]); + const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 1 })]); expect(result.failedItems[0].batchItemId).toBe(1); }); }); -// ─── extractBatchResults — summary field ────────────────────────────────────── +// ─── extractContentBatchResults — summary field ────────────────────────────────────── -describe('extractBatchResults — summary', () => { +describe('extractContentBatchResults — summary', () => { it('includes summary when batch has totalItems', () => { const batch = { totalItems: 3, @@ -167,8 +171,8 @@ describe('extractBatchResults — summary', () => { { itemID: 0 }, ], }; - const originals = [{ contentID: 1 }, { contentID: 2 }, { contentID: 3 }]; - const result = extractBatchResults(batch, originals); + const originals = [{ contentID: 1 }, { contentID: 2 }, { contentID: 3 }] as mgmtApi.ContentItem[]; + const result = extractContentBatchResults(asBatch(batch), originals); expect(result.summary).toBeDefined(); expect(result.summary!.totalItems).toBe(3); @@ -179,23 +183,23 @@ describe('extractBatchResults — summary', () => { it('defaults successCount and failureCount to 0 when missing from batch', () => { const batch = { totalItems: 1, items: [{ itemID: 50, processedItemVersionID: 1 }] }; - const result = extractBatchResults(batch, [{ contentID: 1 }]); + const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 1 })]); expect(result.summary!.successCount).toBe(0); expect(result.summary!.failureCount).toBe(0); }); }); -// ─── extractBatchResults — empty originalItems edge cases ───────────────────── +// ─── extractContentBatchResults — empty originalItems edge cases ───────────────────── -describe('extractBatchResults — edge cases', () => { +describe('extractContentBatchResults — edge cases', () => { it('handles empty originals array without throwing', () => { const batch = { items: [] }; - expect(() => extractBatchResults(batch, [])).not.toThrow(); + expect(() => extractContentBatchResults(asBatch(batch), [])).not.toThrow(); }); it('returns empty results for empty batch and empty originals', () => { - const result = extractBatchResults({ items: [] }, []); + const result = extractContentBatchResults(asBatch({ items: [] }), []); expect(result.successfulItems).toHaveLength(0); expect(result.failedItems).toHaveLength(0); }); diff --git a/src/lib/shared/index.ts b/src/lib/shared/index.ts index 30a1a8a..d7c024f 100644 --- a/src/lib/shared/index.ts +++ b/src/lib/shared/index.ts @@ -9,7 +9,7 @@ export * from "./link-type-detector"; export { GuidDataLoader, GuidEntities, SourceEntities } from "../pushers/guid-data-loader"; export function prettyException(error: any): string { return error.message || error.toString(); } export function logBatchError(error: any, context: string): void { console.error("Batch Error:", error); } -export { pollBatchUntilComplete, extractBatchResults } from "../pushers/batch-polling"; +export { pollBatchUntilComplete, extractContentBatchResults, extractPageBatchResults } from "../pushers/batch-polling"; // Source publish status checker - checks source instance publish status export { From eff1de19edc347d0f8b22b351d205b2803dc743c Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 21 May 2026 16:04:49 -0400 Subject: [PATCH 2/4] add additional checks in mappers and update tests --- src/lib/mappers/asset-mapper.ts | 4 +- src/lib/mappers/container-mapper.ts | 4 +- src/lib/mappers/content-item-mapper.ts | 5 +- src/lib/mappers/gallery-mapper.ts | 4 +- src/lib/mappers/model-mapper.ts | 4 +- src/lib/mappers/page-mapper.ts | 4 +- src/lib/mappers/template-mapper.ts | 4 +- .../mappers/tests/content-item-mapper.test.ts | 14 ++- .../tests/mapping-version-updater.test.ts | 5 +- src/lib/pushers/tests/batch-polling.test.ts | 118 ++++++++++++++---- 10 files changed, 127 insertions(+), 39 deletions(-) diff --git a/src/lib/mappers/asset-mapper.ts b/src/lib/mappers/asset-mapper.ts index 5f4f820..8f72872 100644 --- a/src/lib/mappers/asset-mapper.ts +++ b/src/lib/mappers/asset-mapper.ts @@ -106,7 +106,7 @@ export class AssetMapper { const targetMapping = this.getAssetMapping(targetAsset, 'target'); const sourceMapping = this.getAssetMapping(sourceAsset, 'source'); - if (targetMapping !== sourceMapping) { + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { throw new Error(`Invalid Mappings detected! Source mediaID: ${sourceAsset.mediaID}, Target mediaID: ${targetAsset.mediaID}`); } @@ -136,7 +136,7 @@ export class AssetMapper { } updateMapping(sourceAsset: mgmtApi.Media, targetAsset: mgmtApi.Media, mapping: AssetMapping) { - if (sourceAsset.mediaID !== mapping.sourceMediaID || targetAsset.mediaID !== mapping.targetMediaID) { + if (targetAsset.mediaID !== mapping.targetMediaID) { throw new Error(`Invalid items trying to be mapped! Source mediaID: ${sourceAsset.mediaID}, Target mediaID: ${targetAsset.mediaID}`); } mapping.sourceGuid = this.sourceGuid; diff --git a/src/lib/mappers/container-mapper.ts b/src/lib/mappers/container-mapper.ts index cb537bf..ef96ef3 100644 --- a/src/lib/mappers/container-mapper.ts +++ b/src/lib/mappers/container-mapper.ts @@ -101,7 +101,7 @@ export class ContainerMapper { const targetMapping = this.getContainerMapping(targetContainer, 'target'); const sourceMapping = this.getContainerMapping(sourceContainer, 'source'); - if (targetMapping !== sourceMapping) { + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { throw new Error(`Invalid Mappings detected! Source contentViewID: ${sourceContainer.contentViewID}, Target contentViewID: ${targetContainer.contentViewID}`); } @@ -128,7 +128,7 @@ export class ContainerMapper { } updateMapping(sourceContainer: mgmtApi.Container, targetContainer: mgmtApi.Container, mapping: ContainerMapping) { - if (sourceContainer.contentViewID !== mapping.sourceContentViewID || targetContainer.contentViewID !== mapping.targetContentViewID) { + if (targetContainer.contentViewID !== mapping.targetContentViewID) { throw new Error(`Invalid items trying to be mapped! Source contentViewID: ${sourceContainer.contentViewID}, Target contentViewID: ${targetContainer.contentViewID}`); } mapping.sourceGuid = this.sourceGuid; diff --git a/src/lib/mappers/content-item-mapper.ts b/src/lib/mappers/content-item-mapper.ts index 1e8321a..4f97f30 100644 --- a/src/lib/mappers/content-item-mapper.ts +++ b/src/lib/mappers/content-item-mapper.ts @@ -106,7 +106,7 @@ export class ContentItemMapper { const targetMapping = this.getContentItemMapping(targetContentItem, 'target'); const sourceMapping = this.getContentItemMapping(sourceContentItem, 'source') - if(targetMapping !== sourceMapping){ + if(targetMapping && sourceMapping && targetMapping !== sourceMapping){ throw new Error(`Invalid Mappings detected! The two items have different mappings, Source contentID: ${sourceContentItem.contentID}, Target contentID: ${targetContentItem.contentID}`); } @@ -114,7 +114,6 @@ export class ContentItemMapper { if (targetMapping) { this.updateMapping(sourceContentItem, targetContentItem, targetMapping); } else { - this.checkItemIsMappable(sourceContentItem, targetContentItem); const newMapping: ContentItemMapping = { sourceGuid: this.sourceGuid, targetGuid: this.targetGuid, @@ -130,7 +129,7 @@ export class ContentItemMapper { updateMapping(sourceContentItem: mgmtApi.ContentItem, targetContentItem: mgmtApi.ContentItem, mapping: ContentItemMapping) { - if(sourceContentItem.contentID !== mapping.sourceContentID || targetContentItem.contentID !== mapping.targetContentID){ + if(targetContentItem.contentID !== mapping.targetContentID){ throw new Error(`Invalid items trying to be mapped! Source contentID: ${sourceContentItem.contentID}, Target contentID: ${targetContentItem.contentID}`); } diff --git a/src/lib/mappers/gallery-mapper.ts b/src/lib/mappers/gallery-mapper.ts index a22f329..d17ab8c 100644 --- a/src/lib/mappers/gallery-mapper.ts +++ b/src/lib/mappers/gallery-mapper.ts @@ -66,7 +66,7 @@ export class GalleryMapper { const targetMapping = this.getGalleryMapping(targetGallery, 'target'); const sourceMapping = this.getGalleryMapping(sourceGallery, 'source'); - if (targetMapping !== sourceMapping) { + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { throw new Error(`Invalid Mappings detected! Source mediaGroupingID: ${sourceGallery.mediaGroupingID}, Target mediaGroupingID: ${targetGallery.mediaGroupingID}`); } @@ -91,7 +91,7 @@ export class GalleryMapper { } updateMapping(sourceGallery: mgmtApi.assetMediaGrouping, targetGallery: mgmtApi.assetMediaGrouping, mapping: GalleryMapping) { - if (sourceGallery.mediaGroupingID !== mapping.sourceMediaGroupingID || targetGallery.mediaGroupingID !== mapping.targetMediaGroupingID) { + if (targetGallery.mediaGroupingID !== mapping.targetMediaGroupingID) { throw new Error(`Invalid items trying to be mapped! Source mediaGroupingID: ${sourceGallery.mediaGroupingID}, Target mediaGroupingID: ${targetGallery.mediaGroupingID}`); } mapping.sourceGuid = this.sourceGuid; diff --git a/src/lib/mappers/model-mapper.ts b/src/lib/mappers/model-mapper.ts index 3d88424..4462ab9 100644 --- a/src/lib/mappers/model-mapper.ts +++ b/src/lib/mappers/model-mapper.ts @@ -75,7 +75,7 @@ export class ModelMapper { const targetMapping = this.getModelMapping(targetModel, 'target'); const sourceMapping = this.getModelMapping(sourceModel, 'source'); - if (targetMapping !== sourceMapping) { + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { throw new Error(`Invalid Mappings detected! Source model ID: ${sourceModel.id}, Target model ID: ${targetModel.id}`); } @@ -102,7 +102,7 @@ export class ModelMapper { } updateMapping(sourceModel: mgmtApi.Model, targetModel: mgmtApi.Model, mapping: ModelMapping) { - if (sourceModel.id !== mapping.sourceID || targetModel.id !== mapping.targetID) { + if (targetModel.id !== mapping.targetID) { throw new Error(`Invalid items trying to be mapped! Source model ID: ${sourceModel.id}, Target model ID: ${targetModel.id}`); } mapping.sourceGuid = this.sourceGuid; diff --git a/src/lib/mappers/page-mapper.ts b/src/lib/mappers/page-mapper.ts index 6f613da..b3c2075 100644 --- a/src/lib/mappers/page-mapper.ts +++ b/src/lib/mappers/page-mapper.ts @@ -70,7 +70,7 @@ export class PageMapper { const targetMapping = this.getPageMapping(targetPage, 'target'); const sourceMapping = this.getPageMapping(sourcePage, 'source'); - if (targetMapping !== sourceMapping) { + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { throw new Error(`Invalid Mappings detected! Source pageID: ${sourcePage.pageID}, Target pageID: ${targetPage.pageID}`); } @@ -96,7 +96,7 @@ export class PageMapper { } updateMapping(sourcePage: mgmtApi.PageItem, targetPage: mgmtApi.PageItem, mapping: PageMapping) { - if (sourcePage.pageID !== mapping.sourcePageID || targetPage.pageID !== mapping.targetPageID) { + if (targetPage.pageID !== mapping.targetPageID) { throw new Error(`Invalid items trying to be mapped! Source pageID: ${sourcePage.pageID}, Target pageID: ${targetPage.pageID}`); } mapping.sourceGuid = this.sourceGuid; diff --git a/src/lib/mappers/template-mapper.ts b/src/lib/mappers/template-mapper.ts index 4827ae2..6bb8a91 100644 --- a/src/lib/mappers/template-mapper.ts +++ b/src/lib/mappers/template-mapper.ts @@ -69,7 +69,7 @@ export class TemplateMapper { const targetMapping = this.getTemplateMapping(targetTemplate, 'target'); const sourceMapping = this.getTemplateMapping(sourceTemplate, 'source'); - if (targetMapping !== sourceMapping) { + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { throw new Error(`Invalid Mappings detected! Source pageTemplateID: ${sourceTemplate.pageTemplateID}, Target pageTemplateID: ${targetTemplate.pageTemplateID}`); } @@ -93,7 +93,7 @@ export class TemplateMapper { } updateMapping(sourceTemplate: mgmtApi.PageModel, targetTemplate: mgmtApi.PageModel, mapping: TemplateMapping) { - if (sourceTemplate.pageTemplateID !== mapping.sourcePageTemplateID || targetTemplate.pageTemplateID !== mapping.targetPageTemplateID) { + if (targetTemplate.pageTemplateID !== mapping.targetPageTemplateID) { throw new Error(`Invalid items trying to be mapped! Source pageTemplateID: ${sourceTemplate.pageTemplateID}, Target pageTemplateID: ${targetTemplate.pageTemplateID}`); } mapping.sourceGuid = this.sourceGuid; diff --git a/src/lib/mappers/tests/content-item-mapper.test.ts b/src/lib/mappers/tests/content-item-mapper.test.ts index a0e6cb6..2cdd43e 100644 --- a/src/lib/mappers/tests/content-item-mapper.test.ts +++ b/src/lib/mappers/tests/content-item-mapper.test.ts @@ -39,6 +39,7 @@ function makeMapper(): ContentItemMapper { } function makeItem(overrides: Record = {}): any { + const { properties: propOverride, ...rest } = overrides; return { contentID: 100, properties: { @@ -46,9 +47,10 @@ function makeItem(overrides: Record = {}): any { referenceName: 'my-ref', definitionName: 'MyModel', state: 2, + ...propOverride, }, fields: { title: 'Test Item' }, - ...overrides, + ...rest, }; } @@ -167,6 +169,16 @@ describe('ContentItemMapper.addMapping', () => { expect(found.sourceContentID).toBe(11); expect(found.sourceVersionID).toBe(2); }); + + it('throws when source is already mapped to a target and a different target is also already mapped', () => { + const mapper = makeMapper(); + mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 20 })); + mapper.addMapping(makeItem({ contentID: 11 }), makeItem({ contentID: 21 })); + // source 10 maps to 20; target 21 maps to 11 — genuinely conflicting cross-mapping + expect(() => + mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 21 })) + ).toThrow('Invalid Mappings detected'); + }); }); // ─── hasSourceChanged ───────────────────────────────────────────────────────── diff --git a/src/lib/mappers/tests/mapping-version-updater.test.ts b/src/lib/mappers/tests/mapping-version-updater.test.ts index d91d92e..b61dba1 100644 --- a/src/lib/mappers/tests/mapping-version-updater.test.ts +++ b/src/lib/mappers/tests/mapping-version-updater.test.ts @@ -59,11 +59,12 @@ let SRC: string; let TGT: string; function makeContentItem(overrides: Record = {}): any { + const { properties: propOverride, ...rest } = overrides; return { contentID: 100, - properties: { versionID: 1, referenceName: 'ref', definitionName: 'Model', state: 2 }, + properties: { versionID: 1, referenceName: 'ref', definitionName: 'Model', state: 2, ...propOverride }, fields: { title: 'Test' }, - ...overrides, + ...rest, }; } diff --git a/src/lib/pushers/tests/batch-polling.test.ts b/src/lib/pushers/tests/batch-polling.test.ts index 2602514..f829d4a 100644 --- a/src/lib/pushers/tests/batch-polling.test.ts +++ b/src/lib/pushers/tests/batch-polling.test.ts @@ -1,5 +1,5 @@ import { resetState } from 'core/state'; -import { extractContentBatchResults, logBatchError, CompletedBatch } from '../batch-polling'; +import { extractContentBatchResults, extractPageBatchResults, logBatchError, CompletedBatch } from '../batch-polling'; import * as mgmtApi from '@agility/management-sdk'; const asBatch = (obj: Record): CompletedBatch => obj as CompletedBatch; @@ -105,10 +105,10 @@ describe('extractContentBatchResults — legacy items array', () => { }); }); -// ─── extractContentBatchResults — structured failedItems (new API) ─────────────────── +// ─── extractContentBatchResults — batch with extra failedItems field ───────────────── -describe('extractContentBatchResults — structured failedItems array', () => { - it('uses failedItems array from new API when present', () => { +describe('extractContentBatchResults — batch with failedItems field', () => { + it('classifies items by itemID even when batch has a failedItems field', () => { const batch = { failedItems: [ { batchItemId: 1, errorMessage: 'Validation error', errorType: 'ValidationException', itemType: 'Content' }, @@ -117,16 +117,13 @@ describe('extractContentBatchResults — structured failedItems array', () => { { itemID: 0, batchItemID: 1 }, ], }; - const originals = [{ contentID: 10 }] as mgmtApi.ContentItem[]; - const result = extractContentBatchResults(asBatch(batch), originals); + const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 10 })]); expect(result.failedItems).toHaveLength(1); - expect(result.failedItems[0].error).toBe('Validation error'); - expect(result.failedItems[0].errorType).toBe('ValidationException'); - expect(result.failedItems[0].itemType).toBe('Content'); + expect(result.failedItems[0].error).toContain('Invalid ID'); }); - it('marks remaining items as successful when failedItems array is present and items exist', () => { + it('marks items with itemID > 0 as successful even when a failedItems field is present', () => { const batch = { failedItems: [ { batchItemId: 1, errorMessage: 'error', errorType: 'Error', itemType: 'Content' }, @@ -143,17 +140,6 @@ describe('extractContentBatchResults — structured failedItems array', () => { expect(result.successfulItems[0].newId).toBe(200); expect(result.failedItems).toHaveLength(1); }); - - it('supports PascalCase batchItemID in failedItems', () => { - const batch = { - failedItems: [ - { batchItemID: 1, errorMessage: 'bad field', errorType: 'Error', itemType: 'Content' }, - ], - }; - const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 1 })]); - - expect(result.failedItems[0].batchItemId).toBe(1); - }); }); // ─── extractContentBatchResults — summary field ────────────────────────────────────── @@ -205,6 +191,96 @@ describe('extractContentBatchResults — edge cases', () => { }); }); +// ─── extractPageBatchResults ────────────────────────────────────────────────── + +describe('extractPageBatchResults', () => { + it('classifies page items with itemID > 0 as successful', () => { + const batch = { items: [{ itemID: 10 }] }; + const originals = [{ pageID: 1 }] as mgmtApi.PageItem[]; + const result = extractPageBatchResults(asBatch(batch), originals); + + expect(result.successfulItems).toHaveLength(1); + expect(result.successfulItems[0].newId).toBe(10); + }); + + it('preserves the PageItem reference in originalItem', () => { + const original = { pageID: 99, title: 'Home' } as mgmtApi.PageItem; + const batch = { items: [{ itemID: 10 }] }; + const result = extractPageBatchResults(asBatch(batch), [original]); + + expect(result.successfulItems[0].originalItem).toBe(original); + }); + + it('classifies page items with itemID <= 0 as failed', () => { + const batch = { items: [{ itemID: 0 }] }; + const result = extractPageBatchResults(asBatch(batch), [{ pageID: 1 }] as mgmtApi.PageItem[]); + + expect(result.failedItems).toHaveLength(1); + expect(result.successfulItems).toHaveLength(0); + }); + + it('marks all pages as failed when batch has no items array', () => { + const originals = [{ pageID: 1 }, { pageID: 2 }] as mgmtApi.PageItem[]; + const result = extractPageBatchResults(asBatch({}), originals); + + expect(result.failedItems).toHaveLength(2); + expect(result.failedItems[0].error).toBe('No batch items returned'); + expect(result.successfulItems).toHaveLength(0); + }); + + it('uses errorMessage from item when available', () => { + const batch = { items: [{ itemID: 0, errorMessage: '{"message":"page save failed"}' }] }; + const result = extractPageBatchResults(asBatch(batch), [{ pageID: 1 }] as mgmtApi.PageItem[]); + + expect(result.failedItems[0].error).toBe('page save failed'); + }); + + it('classifies pages by itemID even when batch has a failedItems field', () => { + const batch = { + failedItems: [ + { batchItemId: 1, errorMessage: 'Page validation error', errorType: 'ValidationException', itemType: 'Page' }, + ], + items: [{ itemID: 0, batchItemID: 1 }], + }; + const result = extractPageBatchResults(asBatch(batch), [{ pageID: 1 }] as mgmtApi.PageItem[]); + + expect(result.failedItems[0].error).toContain('Invalid ID'); + }); + + it('marks pages with itemID > 0 as successful even when a failedItems field is present', () => { + const batch = { + failedItems: [{ batchItemId: 1, errorMessage: 'error', errorType: 'Error', itemType: 'Page' }], + items: [ + { itemID: 0, batchItemID: 1 }, + { itemID: 50, batchItemID: 2 }, + ], + }; + const originals = [{ pageID: 1 }, { pageID: 2 }] as mgmtApi.PageItem[]; + const result = extractPageBatchResults(asBatch(batch), originals); + + expect(result.successfulItems).toHaveLength(1); + expect(result.successfulItems[0].newId).toBe(50); + expect(result.failedItems).toHaveLength(1); + }); + + it('includes summary when batch has totalItems', () => { + const batch = { + totalItems: 2, + successCount: 1, + failureCount: 1, + durationMs: 300, + items: [{ itemID: 5 }, { itemID: 0 }], + }; + const originals = [{ pageID: 1 }, { pageID: 2 }] as mgmtApi.PageItem[]; + const result = extractPageBatchResults(asBatch(batch), originals); + + expect(result.summary).toBeDefined(); + expect(result.summary!.totalItems).toBe(2); + expect(result.summary!.successCount).toBe(1); + expect(result.summary!.failureCount).toBe(1); + }); +}); + // ─── logBatchError ───────────────────────────────────────────────────────────── describe('logBatchError', () => { From 0ed73d645d5479be63983740f6c29b39382bd3cb Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 21 May 2026 16:05:04 -0400 Subject: [PATCH 3/4] remove weird code and use cleaner logic to get error message --- src/lib/pushers/batch-polling.ts | 45 ++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/lib/pushers/batch-polling.ts b/src/lib/pushers/batch-polling.ts index 8818ce7..f392094 100644 --- a/src/lib/pushers/batch-polling.ts +++ b/src/lib/pushers/batch-polling.ts @@ -2,12 +2,22 @@ import * as mgmtApi from '@agility/management-sdk'; import ansiColors from 'ansi-colors'; export type CompletedBatch = mgmtApi.Batch & { + failedItems: FailedBatchItemFromApi[]; totalItems?: number; successCount?: number; failureCount?: number; durationMs?: number; }; +export type FailedBatchItemFromApi = { + batchItemId: number; + itemId: number; + itemType: string; + errorType: string; + errorMessage: string; +} + + /** * Extract the error message from a JSON error response or plain text * Handles both JSON format {"message":"..."} and plain text errors @@ -47,7 +57,30 @@ function createProgressBar(percent: number, width: number = 20): string { return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`; } -function logBatchErrors(batchStatus: any, originalPayloads?: any[]): void { +function logBatchErrors(batchStatus: CompletedBatch, originalPayloads?: any[]): void { + // Prefer structured failedItems array from new API + if (Array.isArray(batchStatus.failedItems) && batchStatus.failedItems.length > 0) { + batchStatus.failedItems.forEach((failed: any) => { + const batchItemId = failed.batchItemId ?? failed.batchItemID ?? '?'; + const errorType = failed.errorType || 'Error'; + const errorMessage = failed.errorMessage || 'Unknown error'; + const itemType = failed.itemType || 'Item'; + + // Try to find the original payload by batchItemId to get referenceName + let referenceName = 'unknown'; + if (originalPayloads) { + // batchItemId typically corresponds to index in the batch + const payload = originalPayloads.find((p, idx) => p?.batchItemId === batchItemId) + || originalPayloads[batchStatus.failedItems.indexOf(failed)]; + referenceName = payload?.properties?.referenceName || payload?.referenceName || 'unknown'; + } + + console.error(ansiColors.red(` ✗ ${itemType} ${batchItemId} (${referenceName}): ${errorMessage}`)); + }); + return; + } + + // Fallback to legacy items array with errorMessage field if (Array.isArray(batchStatus.items)) { batchStatus.items.forEach((item: any, index: number) => { if (item.errorMessage) { @@ -251,6 +284,7 @@ function extractBatchResultsImpl(batch: CompletedBatch, originalItems: T[]): durationMs: batch.durationMs ?? 0 } : undefined; + // Fallback to legacy items array processing if (!batch?.items || !Array.isArray(batch.items)) { return { successfulItems: [], @@ -264,7 +298,7 @@ function extractBatchResultsImpl(batch: CompletedBatch, originalItems: T[]): }; } - batch.items.forEach((item: any, index: number) => { + batch.items.forEach((item, index: number) => { const originalItem = originalItems[index]; if (item.itemID > 0 && !item.itemNull) { @@ -276,6 +310,13 @@ function extractBatchResultsImpl(batch: CompletedBatch, originalItems: T[]): } else if (!item.itemNull) { errorMsg = `Invalid ID: ${item.itemID}`; } + + // replace with new api error message if we can + const newApiFailedItem = batch.failedItems && batch.failedItems.length > 0 && batch.failedItems.find(fi => fi.batchItemId == item.batchItemID); + if(newApiFailedItem){ + errorMsg = newApiFailedItem.errorMessage; + } + failedItems.push({ originalItem, newItem: null, error: errorMsg, index }); } }); From f0c673990c79a92bf6ba2e9e4c95e2d014418718 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 21 May 2026 16:35:09 -0400 Subject: [PATCH 4/4] fix failing tests by updating expected error substrings to match actual error messages Co-Authored-By: Claude Sonnet 4.6 --- src/lib/pushers/tests/batch-polling.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/pushers/tests/batch-polling.test.ts b/src/lib/pushers/tests/batch-polling.test.ts index f829d4a..80bad09 100644 --- a/src/lib/pushers/tests/batch-polling.test.ts +++ b/src/lib/pushers/tests/batch-polling.test.ts @@ -120,7 +120,7 @@ describe('extractContentBatchResults — batch with failedItems field', () => { const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 10 })]); expect(result.failedItems).toHaveLength(1); - expect(result.failedItems[0].error).toContain('Invalid ID'); + expect(result.failedItems[0].error).toContain('Validation error'); }); it('marks items with itemID > 0 as successful even when a failedItems field is present', () => { @@ -244,7 +244,7 @@ describe('extractPageBatchResults', () => { }; const result = extractPageBatchResults(asBatch(batch), [{ pageID: 1 }] as mgmtApi.PageItem[]); - expect(result.failedItems[0].error).toContain('Invalid ID'); + expect(result.failedItems[0].error).toContain('Page validation error'); }); it('marks pages with itemID > 0 as successful even when a failedItems field is present', () => {