From b19142637d25326c99aecd7b90fdcd4294d33d24 Mon Sep 17 00:00:00 2001 From: Jules Exel Date: Wed, 20 May 2026 11:11:57 -0400 Subject: [PATCH 1/2] fix: reuse stale target ID when overwriting deleted content (PROD-1320) When --overwrite is set and the local mapping references a target content item that no longer exists on disk, send the payload with the mapped targetContentID rather than -1, so we attempt to update the deleted item rather than silently creating a duplicate. Also extend the conflict branch in change-detection so overwrite resolves to shouldUpdate instead of flagging an unresolvable conflict. Additional housekeeping: - guard the cross-locale fallback so it only fires when no mapping exists at all (was previously overriding existingContentID even when set) - add .claude/settings.local.json to .gitignore - prettier pass on content-batch-processor.ts and change-detection.ts Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 +- .../content-pusher/content-batch-processor.ts | 911 +++++++++--------- .../content-pusher/util/change-detection.ts | 247 +++-- 3 files changed, 591 insertions(+), 570 deletions(-) diff --git a/.gitignore b/.gitignore index a5e9a57..527c0cb 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,5 @@ yarn-error.log* **/*.workspace /**/*.workspace -/src/*.workspace \ No newline at end of file +/src/*.workspace +.claude/settings.local.json diff --git a/src/lib/pushers/content-pusher/content-batch-processor.ts b/src/lib/pushers/content-pusher/content-batch-processor.ts index 2266196..648419c 100644 --- a/src/lib/pushers/content-pusher/content-batch-processor.ts +++ b/src/lib/pushers/content-pusher/content-batch-processor.ts @@ -4,451 +4,478 @@ import ansiColors from "ansi-colors"; import { ModelMapper } from "lib/mappers/model-mapper"; import { ContainerMapper } from "lib/mappers/container-mapper"; import { AssetMapper } from "lib/mappers/asset-mapper"; -import { BatchFailedItem, BatchProcessingResult, BatchProgressCallback, BatchSuccessItem, ContentBatchConfig } from "./util/types"; +import { + BatchFailedItem, + BatchProcessingResult, + BatchProgressCallback, + BatchSuccessItem, + ContentBatchConfig, +} from "./util/types"; import { findContentInOtherLocale } from "./util/find-content-in-other-locale"; import { Logs } from "core/logs"; import { state } from "core/state"; /****** -* USAGE PATTERN: -* 1. Filter content items BEFORE creating the batch processor using filterContentItemsForProcessing() -* 2. Create the batch processor with pre - filtered items -* 3. Call processBatches() with the filtered items -* -* This ensures consistent use of the new versioning logic and eliminates duplicate filtering. -*/ + * USAGE PATTERN: + * 1. Filter content items BEFORE creating the batch processor using filterContentItemsForProcessing() + * 2. Create the batch processor with pre - filtered items + * 3. Call processBatches() with the filtered items + * + * This ensures consistent use of the new versioning logic and eliminates duplicate filtering. + */ export class ContentBatchProcessor { - private config: ContentBatchConfig; - - constructor(config: ContentBatchConfig) { - this.config = { - ...config, - batchSize: config.batchSize || 250, // Default batch size - }; - } - - /** - * Process content items in batches using saveContentItems API - * NOTE: Content items should already be filtered by the caller using filterContentItemsForProcessing() - */ - async processBatches( - contentItems: mgmtApi.ContentItem[], - logger: Logs, - batchType?: string - ): Promise { - const batchSize = this.config.batchSize!; - const contentBatches = this.createContentBatches(contentItems, batchSize); - - console.log( - `Processing ${contentItems.length || 0} content items in ${contentBatches.length} bulk ${batchType || ""} batches` - ); - - let totalSuccessCount = 0; - let totalFailureCount = 0; - let totalSkippedCount = 0; - const allSuccessfulItems: BatchSuccessItem[] = []; - const allFailedItems: BatchFailedItem[] = []; - const startTime = Date.now(); - - for (let i = 0; i < contentBatches.length; i++) { - const contentBatch = contentBatches[i]; - const batchNumber = i + 1; - const processedSoFar = i * batchSize; - - // Calculate ETA for bulk batches - const elapsed = Date.now() - startTime; - const avgTimePerBatch = elapsed / batchNumber; - const remainingBatches = contentBatches.length - batchNumber; - const etaMs = remainingBatches * avgTimePerBatch; - const etaMinutes = Math.round(etaMs / 60000); - - const progress = Math.round((batchNumber / contentBatches.length) * 100); - console.log( - `[${progress}%] Bulk batch ${batchNumber}/${contentBatches.length}: Processing ${contentBatch.length} ${batchType} content items (ETA: ${etaMinutes}m)...` - ); - - // if (onProgress) { - // onProgress(batchNumber, contentBatches.length, processedSoFar, contentItems.length, "processing"); - // } - - try { - // Prepare content payloads for bulk upload - - const { payloads: contentPayloads, skippedCount: batchSkippedCount, includedItems } = await this.prepareContentPayloads( - contentBatch, - this.config.sourceGuid, - this.config.targetGuid - ); - - // Track skipped items from this batch - totalSkippedCount += batchSkippedCount; - - // Execute bulk upload using saveContentItems API with returnBatchID flag - const batchIDResult = await this.config.apiClient.contentMethods.saveContentItems( - contentPayloads, - this.config.targetGuid, - this.config.locale, - true // returnBatchID flag - ); - - // Extract batch ID from array response - const batchID = Array.isArray(batchIDResult) ? batchIDResult[0] : batchIDResult; - // console.log(`πŸ“¦ Batch ${batchNumber} started with ID: ${batchID}`); - - // Poll batch until completion (pass payloads for error matching) - const completedBatch = await pollBatchUntilComplete( - this.config.apiClient, - batchID, - this.config.targetGuid, - contentPayloads, // Pass original payloads for FIFO error matching - 300, // maxAttempts - 2000, // intervalMs - batchType || "Content" // Use provided batch type or default to 'Content' - ); - - // Extract results from completed batch using only items that were actually sent to the API. - // 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); - - // Convert to expected format - // Filter publishableIds to only include items that are Published (state === 2) in source - const publishableSuccessItems = successfulItems.filter((item) => { - const sourceState = item.originalItem?.properties?.state; - return sourceState === 2; // Only published items - }); - - // Log staging items being skipped from auto-publish - const stagingItems = successfulItems.filter((item) => item.originalItem?.properties?.state !== 2); - if (stagingItems.length > 0) { - console.log(ansiColors.gray(` πŸ“‹ Skipping auto-publish for ${stagingItems.length} content item(s) (not published in source)`)); - } - - const batchResult = { - successCount: successfulItems.length, - failureCount: failedItems.length, - skippedCount: 0, // Individual batches don't track skipped items (handled at processBatches level) - successfulItems: successfulItems.map((item) => ({ - originalContent: item.originalItem, - newItem: item.newItem, - newContentId: item.newId, - })), - failedItems: failedItems.map((item) => ({ - originalContent: item.originalItem, - error: item.error, - })), - publishableIds: publishableSuccessItems.map((item) => item.newId), - }; - - totalSuccessCount += batchResult.successCount; - totalFailureCount += batchResult.failureCount; - allSuccessfulItems.push(...batchResult.successfulItems); - allFailedItems.push(...batchResult.failedItems); - - // Update ID mappings for successful uploads - if (batchResult.successfulItems.length > 0) { - this.updateContentIdMappings(batchResult.successfulItems); - } - - console.log("\n"); - // Display individual item results for better visibility - if (batchResult.successfulItems.length > 0) { - batchResult.successfulItems.forEach((item) => { - - // const modelName = item.originalContent.properties.definitionName || "Unknown"; - logger.content.created(item.originalContent, `Type: ${batchType} - created`, this.config.locale, state.targetGuid[0]); - }); - } - - if (batchResult.failedItems.length > 0) { - console.log(`❌ Batch ${batchNumber} failed items:`); - batchResult.failedItems.forEach((item) => { - // const modelName = item.originalContent.properties.definitionName || "Unknown"; - logger.content.error(item.originalContent, item.error, this.config.locale, state.targetGuid[0]); - }); - } - - // Call batch completion callback (for mapping saves, etc.) - if (this.config.onBatchComplete) { - try { - await this.config.onBatchComplete(batchResult, batchNumber); - } catch (callbackError: any) { - console.warn(`⚠️ Batch completion callback failed for batch ${batchNumber}: ${callbackError.message}`); - // Don't fail the entire batch due to callback errors - } - } - - // if (onProgress) { - // onProgress( - // batchNumber, - // contentBatches.length, - // processedSoFar + contentBatch.length, - // contentItems.length, - // "success" - // ); - // } - - // Add small delay between batches to prevent API throttling - if (i < contentBatches.length - 1) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } catch (error: any) { - console.error(`❌ Bulk batch ${batchNumber} failed:`, error.message); - - // Batch pusher only handles batches - mark entire batch as failed - // Individual processing fallbacks should be handled at the sync level - const failedBatchItems: BatchFailedItem[] = contentBatch.map((item) => ({ - originalContent: item, - error: `Batch processing failed: ${error.message}`, - })); - - totalFailureCount += failedBatchItems.length; - allFailedItems.push(...failedBatchItems); - - // if (onProgress) { - // onProgress( - // batchNumber, - // contentBatches.length, - // processedSoFar + contentBatch.length, - // contentItems.length, - // "error" - // ); - // } - } - } - - // console.log(`🎯 Content batch processing complete: ${totalSuccessCount} success, ${totalFailureCount} failed`); - - // Filter final publishableIds to only include items Published (state === 2) in source - const publishableItems = allSuccessfulItems.filter((item) => { - const sourceState = item.originalContent?.properties?.state; - return sourceState === 2; // Only published items - }); - - return { - successCount: totalSuccessCount, - failureCount: totalFailureCount, - skippedCount: totalSkippedCount, - successfulItems: allSuccessfulItems, - failedItems: allFailedItems, - publishableIds: publishableItems.map((item) => item.newContentId), - }; - } - - /** - * Create batches of content items for bulk processing - */ - private createContentBatches(contentItems: mgmtApi.ContentItem[], batchSize: number): mgmtApi.ContentItem[][] { - const batches: mgmtApi.ContentItem[][] = []; - for (let i = 0; i < contentItems.length; i += batchSize) { - batches.push(contentItems.slice(i, i + batchSize)); - } - return batches; - } - - /** - * Prepare content payloads for bulk upload API - * Uses the same payload structure as individual content pusher - */ - private async prepareContentPayloads( - contentBatch: mgmtApi.ContentItem[], - sourceGuid: string, - targetGuid: string - - ): Promise<{ payloads: any[]; skippedCount: number; includedItems: mgmtApi.ContentItem[] }> { - const payloads: any[] = []; - const includedItems: mgmtApi.ContentItem[] = []; - let skippedCount = 0; - - // No imports needed - using reference mapper directly - const modelMapper = new ModelMapper(sourceGuid, targetGuid); - const containerMapper = new ContainerMapper(sourceGuid, targetGuid); - const assetMapper = new AssetMapper(sourceGuid, targetGuid); - - for (const contentItem of contentBatch) { - - - if (contentItem.properties.definitionName.toLowerCase() === "richtextarea" - && contentItem.fields.textblob) { - //if this is a RichText item, we don't need to do the extra processing - just upload it as is - - //see if it's already mapped - const existingMapping = this.config.referenceMapper.getContentItemMappingByContentID(contentItem.contentID, 'source'); - - const payload = { - ...contentItem, // Start with original content item - contentID: existingMapping ? existingMapping.targetContentID : -1, - }; - - payloads.push(payload); - includedItems.push(contentItem); - } else { - //map the content item to the target instance - const modelMapping = modelMapper.getModelMappingByReferenceName(contentItem.properties.definitionName, 'source'); - - try { - // STEP 1: Find source model by content item's definitionName (matching original logic) - - - let sourceModel: mgmtApi.Model | null = null; - if (modelMapping) sourceModel = modelMapper.getMappedEntity(modelMapping, 'source'); - - - if (!sourceModel) { - // Enhanced error reporting for missing content definitions - - const errorDetails = [ - `πŸ“‹ Content Definition Not Found: "${contentItem.properties.definitionName}"`, - `πŸ” Content Item: ${contentItem.properties.referenceName}`, - `πŸ’‘ Common causes:`, - ` β€’ Model was deleted from source instance`, - ` β€’ Model(s) not included in sync elements` - ].join("\n "); - - throw new Error( - `Source model not found for content definition: ${contentItem.properties.definitionName}\n ${errorDetails}` - ); - } - - // STEP 2: Find target model using reference mapper (simplified) - - if (!modelMapping) { - throw new Error(`Target model mapping not found for: ${sourceModel.referenceName} (ID: ${sourceModel.id})`); - } - - // Create model object with target ID and fields from source - const model = { - id: modelMapping.targetID, - referenceName: sourceModel.referenceName, - fields: sourceModel.fields || [] - }; - - // STEP 3: Find container using reference mapper (simplified) - const containerMapping = containerMapper.getContainerMappingByReferenceName(contentItem.properties.referenceName, 'source'); - - if (!containerMapping) { - throw new Error(`Container mapping not found: ${contentItem.properties.referenceName}`); - } - - const targetContainer = containerMapper.getMappedEntity(containerMapping, 'target'); - - // STEP 4: Check if content already exists using reference mapper (since filtering already happened) - const existingMapping = this.config.referenceMapper.getContentItemMappingByContentID(contentItem.contentID, 'source'); - const existingTargetContentItem = this.config.referenceMapper.getMappedEntity(existingMapping, 'target'); - - let existingContentID = existingTargetContentItem ? existingTargetContentItem.contentID : -1; - - if (!existingTargetContentItem) { - //see if this content item has been mapped in another locale - existingContentID = await findContentInOtherLocale({ - sourceGuid, - targetGuid, - sourceContentID: contentItem.contentID, - locale: this.config.locale - }); - } - - // STEP 5: Use proper ContentFieldMapper for field mapping and validation - const { ContentFieldMapper } = await import("../../content/content-field-mapper"); - const fieldMapper = new ContentFieldMapper(); - - const mappingResult = fieldMapper.mapContentFields(contentItem.fields || {}, { - referenceMapper: this.config.referenceMapper, - assetMapper, - apiClient: this.config.apiClient, - targetGuid: this.config.targetGuid, - }); - - // Only log field mapper issues if there are actual errors (not warnings) - if (mappingResult.validationErrors > 0) { - console.warn( - `⚠️ Field mapping errors for ${contentItem.properties.referenceName}: ${mappingResult.validationErrors} errors` - ); - } - - // STEP 6: Normalize field names and add defaults ONLY for truly missing required fields - let validatedFields = { ...mappingResult.mappedFields }; - - // Create field name mapping: source field names (camelCase) to model field names (as-defined) - const fieldNameMap = new Map(); - const camelize = (str: string): string => { - return str - .replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) { - return index === 0 ? word.toLowerCase() : word.toUpperCase(); - }) - .replace(/\s+/g, ""); - }; - - if (model && model.fields) { - model.fields.forEach((fieldDef) => { - const camelCaseFieldName = camelize(fieldDef.name); - fieldNameMap.set(camelCaseFieldName, fieldDef.name); - fieldNameMap.set(fieldDef.name.toLowerCase(), fieldDef.name); - }); - } - - // STEP 7: Define default SEO and Scripts (matching original logic) - const defaultSeo = { - metaDescription: null, - metaKeywords: null, - metaHTML: null, - menuVisible: null, - sitemapVisible: null, - }; - const defaultScripts = { top: null, bottom: null }; - - // STEP 8: Create payload using EXACT original logic - const payload = { - ...contentItem, // Start with original content item - contentID: existingContentID, - fields: validatedFields, // Use validated fields with defaults for required fields - properties: { - ...contentItem.properties, - referenceName: targetContainer?.referenceName || contentItem.properties.referenceName, // Use TARGET container reference name if possible - itemOrder: existingTargetContentItem - ? existingTargetContentItem.properties.itemOrder - : contentItem.properties.itemOrder, - }, - seo: contentItem.seo ?? defaultSeo, - scripts: contentItem.scripts ?? defaultScripts, - }; - - payloads.push(payload); - includedItems.push(contentItem); - } catch (error: any) { - console.error( - ansiColors.yellow( - `βœ— Orphaned content item ${contentItem.contentID}, skipping - ${error.message || 'payload preparation failed'}.` - ) - ); - - // Track skipped item and continue with the rest of the batch - skippedCount++; - continue; - } - } - } - - return { payloads, skippedCount, includedItems }; - } - - /** - * Update content ID mappings in reference mapper - */ - private updateContentIdMappings(successfulItems: BatchSuccessItem[]): void { - successfulItems.forEach((item) => { - const sourceContentItem = item.originalContent; - const targetContentItem = item.newItem as mgmtApi.BatchItem; - - const targetContentItemWithId = { - ...sourceContentItem, - contentID: targetContentItem.itemID, - properties: { - versionID: targetContentItem.processedItemVersionID - } - } as mgmtApi.ContentItem; - - this.config.referenceMapper.addMapping(sourceContentItem, targetContentItemWithId); - }); - } + private config: ContentBatchConfig; + + constructor(config: ContentBatchConfig) { + this.config = { + ...config, + batchSize: config.batchSize || 250, // Default batch size + }; + } + + /** + * Process content items in batches using saveContentItems API + * NOTE: Content items should already be filtered by the caller using filterContentItemsForProcessing() + */ + async processBatches( + contentItems: mgmtApi.ContentItem[], + logger: Logs, + batchType?: string, + ): Promise { + const batchSize = this.config.batchSize!; + const contentBatches = this.createContentBatches(contentItems, batchSize); + + console.log( + `Processing ${contentItems.length || 0} content items in ${contentBatches.length} bulk ${batchType || ""} batches`, + ); + + let totalSuccessCount = 0; + let totalFailureCount = 0; + let totalSkippedCount = 0; + const allSuccessfulItems: BatchSuccessItem[] = []; + const allFailedItems: BatchFailedItem[] = []; + const startTime = Date.now(); + + for (let i = 0; i < contentBatches.length; i++) { + const contentBatch = contentBatches[i]; + const batchNumber = i + 1; + const processedSoFar = i * batchSize; + + // Calculate ETA for bulk batches + const elapsed = Date.now() - startTime; + const avgTimePerBatch = elapsed / batchNumber; + const remainingBatches = contentBatches.length - batchNumber; + const etaMs = remainingBatches * avgTimePerBatch; + const etaMinutes = Math.round(etaMs / 60000); + + const progress = Math.round((batchNumber / contentBatches.length) * 100); + console.log( + `[${progress}%] Bulk batch ${batchNumber}/${contentBatches.length}: Processing ${contentBatch.length} ${batchType} content items (ETA: ${etaMinutes}m)...`, + ); + + // if (onProgress) { + // onProgress(batchNumber, contentBatches.length, processedSoFar, contentItems.length, "processing"); + // } + + try { + // Prepare content payloads for bulk upload + + const { + payloads: contentPayloads, + skippedCount: batchSkippedCount, + includedItems, + } = await this.prepareContentPayloads(contentBatch, this.config.sourceGuid, this.config.targetGuid); + + // Track skipped items from this batch + totalSkippedCount += batchSkippedCount; + + // Execute bulk upload using saveContentItems API with returnBatchID flag + const batchIDResult = await this.config.apiClient.contentMethods.saveContentItems( + contentPayloads, + this.config.targetGuid, + this.config.locale, + true, // returnBatchID flag + ); + + // Extract batch ID from array response + const batchID = Array.isArray(batchIDResult) ? batchIDResult[0] : batchIDResult; + // console.log(`πŸ“¦ Batch ${batchNumber} started with ID: ${batchID}`); + + // Poll batch until completion (pass payloads for error matching) + const completedBatch = await pollBatchUntilComplete( + this.config.apiClient, + batchID, + this.config.targetGuid, + contentPayloads, // Pass original payloads for FIFO error matching + 300, // maxAttempts + 2000, // intervalMs + batchType || "Content", // Use provided batch type or default to 'Content' + ); + + // Extract results from completed batch using only items that were actually sent to the API. + // 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); + + // Convert to expected format + // Filter publishableIds to only include items that are Published (state === 2) in source + const publishableSuccessItems = successfulItems.filter((item) => { + const sourceState = item.originalItem?.properties?.state; + return sourceState === 2; // Only published items + }); + + // Log staging items being skipped from auto-publish + const stagingItems = successfulItems.filter((item) => item.originalItem?.properties?.state !== 2); + if (stagingItems.length > 0) { + console.log( + ansiColors.gray( + ` πŸ“‹ Skipping auto-publish for ${stagingItems.length} content item(s) (not published in source)`, + ), + ); + } + + const batchResult = { + successCount: successfulItems.length, + failureCount: failedItems.length, + skippedCount: 0, // Individual batches don't track skipped items (handled at processBatches level) + successfulItems: successfulItems.map((item) => ({ + originalContent: item.originalItem, + newItem: item.newItem, + newContentId: item.newId, + })), + failedItems: failedItems.map((item) => ({ + originalContent: item.originalItem, + error: item.error, + })), + publishableIds: publishableSuccessItems.map((item) => item.newId), + }; + + totalSuccessCount += batchResult.successCount; + totalFailureCount += batchResult.failureCount; + allSuccessfulItems.push(...batchResult.successfulItems); + allFailedItems.push(...batchResult.failedItems); + + // Update ID mappings for successful uploads + if (batchResult.successfulItems.length > 0) { + this.updateContentIdMappings(batchResult.successfulItems); + } + + console.log("\n"); + // Display individual item results for better visibility + if (batchResult.successfulItems.length > 0) { + batchResult.successfulItems.forEach((item) => { + // const modelName = item.originalContent.properties.definitionName || "Unknown"; + logger.content.created( + item.originalContent, + `Type: ${batchType} - created`, + this.config.locale, + state.targetGuid[0], + ); + }); + } + + if (batchResult.failedItems.length > 0) { + console.log(`❌ Batch ${batchNumber} failed items:`); + batchResult.failedItems.forEach((item) => { + // const modelName = item.originalContent.properties.definitionName || "Unknown"; + logger.content.error(item.originalContent, item.error, this.config.locale, state.targetGuid[0]); + }); + } + + // Call batch completion callback (for mapping saves, etc.) + if (this.config.onBatchComplete) { + try { + await this.config.onBatchComplete(batchResult, batchNumber); + } catch (callbackError: any) { + console.warn(`⚠️ Batch completion callback failed for batch ${batchNumber}: ${callbackError.message}`); + // Don't fail the entire batch due to callback errors + } + } + + // if (onProgress) { + // onProgress( + // batchNumber, + // contentBatches.length, + // processedSoFar + contentBatch.length, + // contentItems.length, + // "success" + // ); + // } + + // Add small delay between batches to prevent API throttling + if (i < contentBatches.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } catch (error: any) { + console.error(`❌ Bulk batch ${batchNumber} failed:`, error.message); + + // Batch pusher only handles batches - mark entire batch as failed + // Individual processing fallbacks should be handled at the sync level + const failedBatchItems: BatchFailedItem[] = contentBatch.map((item) => ({ + originalContent: item, + error: `Batch processing failed: ${error.message}`, + })); + + totalFailureCount += failedBatchItems.length; + allFailedItems.push(...failedBatchItems); + + // if (onProgress) { + // onProgress( + // batchNumber, + // contentBatches.length, + // processedSoFar + contentBatch.length, + // contentItems.length, + // "error" + // ); + // } + } + } + + // console.log(`🎯 Content batch processing complete: ${totalSuccessCount} success, ${totalFailureCount} failed`); + + // Filter final publishableIds to only include items Published (state === 2) in source + const publishableItems = allSuccessfulItems.filter((item) => { + const sourceState = item.originalContent?.properties?.state; + return sourceState === 2; // Only published items + }); + + return { + successCount: totalSuccessCount, + failureCount: totalFailureCount, + skippedCount: totalSkippedCount, + successfulItems: allSuccessfulItems, + failedItems: allFailedItems, + publishableIds: publishableItems.map((item) => item.newContentId), + }; + } + + /** + * Create batches of content items for bulk processing + */ + private createContentBatches(contentItems: mgmtApi.ContentItem[], batchSize: number): mgmtApi.ContentItem[][] { + const batches: mgmtApi.ContentItem[][] = []; + for (let i = 0; i < contentItems.length; i += batchSize) { + batches.push(contentItems.slice(i, i + batchSize)); + } + return batches; + } + + /** + * Prepare content payloads for bulk upload API + * Uses the same payload structure as individual content pusher + */ + private async prepareContentPayloads( + contentBatch: mgmtApi.ContentItem[], + sourceGuid: string, + targetGuid: string, + ): Promise<{ payloads: any[]; skippedCount: number; includedItems: mgmtApi.ContentItem[] }> { + const payloads: any[] = []; + const includedItems: mgmtApi.ContentItem[] = []; + let skippedCount = 0; + + // No imports needed - using reference mapper directly + const modelMapper = new ModelMapper(sourceGuid, targetGuid); + const containerMapper = new ContainerMapper(sourceGuid, targetGuid); + const assetMapper = new AssetMapper(sourceGuid, targetGuid); + + for (const contentItem of contentBatch) { + if (contentItem.properties.definitionName.toLowerCase() === "richtextarea" && contentItem.fields.textblob) { + //if this is a RichText item, we don't need to do the extra processing - just upload it as is + + //see if it's already mapped + const existingMapping = this.config.referenceMapper.getContentItemMappingByContentID( + contentItem.contentID, + "source", + ); + + const payload = { + ...contentItem, // Start with original content item + contentID: existingMapping ? existingMapping.targetContentID : -1, + }; + + payloads.push(payload); + includedItems.push(contentItem); + } else { + //map the content item to the target instance + const modelMapping = modelMapper.getModelMappingByReferenceName( + contentItem.properties.definitionName, + "source", + ); + + try { + // STEP 1: Find source model by content item's definitionName (matching original logic) + + let sourceModel: mgmtApi.Model | null = null; + if (modelMapping) sourceModel = modelMapper.getMappedEntity(modelMapping, "source"); + + if (!sourceModel) { + // Enhanced error reporting for missing content definitions + + const errorDetails = [ + `πŸ“‹ Content Definition Not Found: "${contentItem.properties.definitionName}"`, + `πŸ” Content Item: ${contentItem.properties.referenceName}`, + `πŸ’‘ Common causes:`, + ` β€’ Model was deleted from source instance`, + ` β€’ Model(s) not included in sync elements`, + ].join("\n "); + + throw new Error( + `Source model not found for content definition: ${contentItem.properties.definitionName}\n ${errorDetails}`, + ); + } + + // STEP 2: Find target model using reference mapper (simplified) + + if (!modelMapping) { + throw new Error(`Target model mapping not found for: ${sourceModel.referenceName} (ID: ${sourceModel.id})`); + } + + // Create model object with target ID and fields from source + const model = { + id: modelMapping.targetID, + referenceName: sourceModel.referenceName, + fields: sourceModel.fields || [], + }; + + // STEP 3: Find container using reference mapper (simplified) + const containerMapping = containerMapper.getContainerMappingByReferenceName( + contentItem.properties.referenceName, + "source", + ); + + if (!containerMapping) { + throw new Error(`Container mapping not found: ${contentItem.properties.referenceName}`); + } + + const targetContainer = containerMapper.getMappedEntity(containerMapping, "target"); + + // STEP 4: Check if content already exists using reference mapper (since filtering already happened) + const existingMapping = this.config.referenceMapper.getContentItemMappingByContentID( + contentItem.contentID, + "source", + ); + const existingTargetContentItem = this.config.referenceMapper.getMappedEntity(existingMapping, "target"); + const isTargetContentItemDeleted = existingMapping && existingTargetContentItem == null; + let existingContentID = -1; + + if (isTargetContentItemDeleted && state.overwrite) { + existingContentID = existingMapping ? existingMapping?.targetContentID : -1; + } else { + existingContentID = existingTargetContentItem ? existingTargetContentItem.contentID : -1; + } + + + if (!existingMapping && !existingTargetContentItem) { + //see if this content item has been mapped in another locale + existingContentID = await findContentInOtherLocale({ + sourceGuid, + targetGuid, + sourceContentID: contentItem.contentID, + locale: this.config.locale, + }); + } + + // STEP 5: Use proper ContentFieldMapper for field mapping and validation + const { ContentFieldMapper } = await import("../../content/content-field-mapper"); + const fieldMapper = new ContentFieldMapper(); + + const mappingResult = fieldMapper.mapContentFields(contentItem.fields || {}, { + referenceMapper: this.config.referenceMapper, + assetMapper, + apiClient: this.config.apiClient, + targetGuid: this.config.targetGuid, + }); + + // Only log field mapper issues if there are actual errors (not warnings) + if (mappingResult.validationErrors > 0) { + console.warn( + `⚠️ Field mapping errors for ${contentItem.properties.referenceName}: ${mappingResult.validationErrors} errors`, + ); + } + + // STEP 6: Normalize field names and add defaults ONLY for truly missing required fields + let validatedFields = { ...mappingResult.mappedFields }; + + // Create field name mapping: source field names (camelCase) to model field names (as-defined) + const fieldNameMap = new Map(); + const camelize = (str: string): string => { + return str + .replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) { + return index === 0 ? word.toLowerCase() : word.toUpperCase(); + }) + .replace(/\s+/g, ""); + }; + + if (model && model.fields) { + model.fields.forEach((fieldDef) => { + const camelCaseFieldName = camelize(fieldDef.name); + fieldNameMap.set(camelCaseFieldName, fieldDef.name); + fieldNameMap.set(fieldDef.name.toLowerCase(), fieldDef.name); + }); + } + + // STEP 7: Define default SEO and Scripts (matching original logic) + const defaultSeo = { + metaDescription: null, + metaKeywords: null, + metaHTML: null, + menuVisible: null, + sitemapVisible: null, + }; + const defaultScripts = { top: null, bottom: null }; + + // STEP 8: Create payload using EXACT original logic + const payload = { + ...contentItem, // Start with original content item + contentID: existingContentID, + fields: validatedFields, // Use validated fields with defaults for required fields + properties: { + ...contentItem.properties, + referenceName: targetContainer?.referenceName || contentItem.properties.referenceName, // Use TARGET container reference name if possible + itemOrder: existingTargetContentItem + ? existingTargetContentItem.properties.itemOrder + : contentItem.properties.itemOrder, + }, + seo: contentItem.seo ?? defaultSeo, + scripts: contentItem.scripts ?? defaultScripts, + }; + + payloads.push(payload); + includedItems.push(contentItem); + } catch (error: any) { + console.error( + ansiColors.yellow( + `βœ— Orphaned content item ${contentItem.contentID}, skipping - ${error.message || "payload preparation failed"}.`, + ), + ); + + // Track skipped item and continue with the rest of the batch + skippedCount++; + continue; + } + } + } + + return { payloads, skippedCount, includedItems }; + } + + /** + * Update content ID mappings in reference mapper + */ + private updateContentIdMappings(successfulItems: BatchSuccessItem[]): void { + successfulItems.forEach((item) => { + const sourceContentItem = item.originalContent; + const targetContentItem = item.newItem as mgmtApi.BatchItem; + + const targetContentItemWithId = { + ...sourceContentItem, + contentID: targetContentItem.itemID, + properties: { + versionID: targetContentItem.processedItemVersionID, + }, + } as mgmtApi.ContentItem; + + this.config.referenceMapper.addMapping(sourceContentItem, targetContentItemWithId); + }); + } } diff --git a/src/lib/pushers/content-pusher/util/change-detection.ts b/src/lib/pushers/content-pusher/util/change-detection.ts index c3615b9..7361098 100644 --- a/src/lib/pushers/content-pusher/util/change-detection.ts +++ b/src/lib/pushers/content-pusher/util/change-detection.ts @@ -1,138 +1,131 @@ import { state } from "../../../../core"; import { ContentItemMapping } from "lib/mappers/content-item-mapper"; -import * as mgmtApi from '@agility/management-sdk'; +import * as mgmtApi from "@agility/management-sdk"; /** * Simple change detection for content items */ export interface ChangeDetection { - entity: mgmtApi.ContentItem | null; - shouldUpdate: boolean; - shouldCreate: boolean; - shouldSkip: boolean; - isConflict: boolean; - reason: string; + entity: mgmtApi.ContentItem | null; + shouldUpdate: boolean; + shouldCreate: boolean; + shouldSkip: boolean; + isConflict: boolean; + reason: string; } export function changeDetection( - sourceEntity: mgmtApi.ContentItem, - targetEntity: mgmtApi.ContentItem | null, - mapping: ContentItemMapping, - locale: string + sourceEntity: mgmtApi.ContentItem, + targetEntity: mgmtApi.ContentItem | null, + mapping: ContentItemMapping, + locale: string, ): ChangeDetection { - // Validate source entity structure - if (!sourceEntity || !sourceEntity.properties) { - // console.error(`[ChangeDetection] Invalid source entity structure:`, sourceEntity); - return { - entity: null, - shouldUpdate: false, - shouldCreate: false, - shouldSkip: true, - isConflict: false, - reason: 'Invalid source entity structure' - }; - } - - 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' - }; - } - - // Check if update is needed based on version or modification date - const sourceVersion = sourceEntity.properties?.versionID || 0; - const targetVersion = targetEntity?.properties?.versionID || 0; - - const mappedSourceVersion = (mapping?.sourceVersionID || 0) as number; - const mappedTargetVersion = (mapping?.targetVersionID || 0) as number; - - // Verbose logging for version comparison debugging - // if (state.verbose) { - // console.log(`[ChangeDetection] ${itemName}: sourceV=${sourceVersion} (mapped=${mappedSourceVersion}), targetV=${targetVersion} (mapped=${mappedTargetVersion})`); - // } - - if (sourceVersion > 0 && targetVersion > 0) { - //both the source and the target exist - - if (sourceVersion > mappedSourceVersion && targetVersion > mappedTargetVersion) { - //CONFLICT DETECTION - // Source version is newer than mapped source version - // and target version is newer than mapped target version - - //build the url to the source and target entity - //TODO: if there are multiple guids we need to handle that - - const sourceUrl = `https://app.agilitycms.com/instance/${state.sourceGuid[0]}/${locale}/content/listitem-${sourceEntity.contentID}`; - const targetUrl = `https://app.agilitycms.com/instance/${state.targetGuid[0]}/${locale}/content/listitem-${targetEntity.contentID}`; - - // if (state.verbose) { - // console.log(`[ChangeDetection] ${itemName}: CONFLICT - both versions changed`); - // } - - return { - entity: targetEntity, - shouldUpdate: false, - shouldCreate: false, - shouldSkip: false, - isConflict: true, - reason: `Both source and target versions have been updated. Please resolve manually.\n - source: ${sourceUrl} \n - target: ${targetUrl}.` - }; - } - } - - if (sourceVersion > mappedSourceVersion && targetVersion <= mappedTargetVersion) { - //SOURCE UPDATE ONLY - // Source version is newer the mapped source version - // and target version is NOT newer than mapped target version - // if (state.verbose) { - // console.log(`[ChangeDetection] ${itemName}: UPDATE - source version newer (${sourceVersion} > ${mappedSourceVersion})`); - // } - return { - entity: targetEntity, - shouldUpdate: true, - shouldCreate: false, - shouldSkip: false, - isConflict: false, - reason: 'Source version is newer.' - }; - } - - const { overwrite } = state; - if (overwrite) { - // if (state.verbose) { - // console.log(`[ChangeDetection] ${itemName}: UPDATE - overwrite mode enabled`); - // } - return { - entity: targetEntity, - shouldUpdate: true, - shouldCreate: false, - shouldSkip: false, - isConflict: false, - reason: 'Overwrite mode enabled' - }; - } - - // if (state.verbose) { - // console.log(`[ChangeDetection] ${itemName}: SKIP - up to date`); - // } - return { - entity: targetEntity, - shouldUpdate: false, - shouldCreate: false, - shouldSkip: true, - isConflict: false, - // No update needed, target is up to date - reason: 'Entity exists and is up to date' - }; -} \ No newline at end of file + const { overwrite } = state; + // Validate source entity structure + if (!sourceEntity || !sourceEntity.properties) { + // console.error(`[ChangeDetection] Invalid source entity structure:`, sourceEntity); + return { + entity: null, + shouldUpdate: false, + shouldCreate: false, + shouldSkip: true, + isConflict: false, + reason: "Invalid source entity structure", + }; + } + + 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", + }; + } + + // Check if update is needed based on version or modification date + const sourceVersion = sourceEntity.properties?.versionID || 0; + const targetVersion = targetEntity?.properties?.versionID || 0; + + const mappedSourceVersion = (mapping?.sourceVersionID || 0) as number; + const mappedTargetVersion = (mapping?.targetVersionID || 0) as number; + + if (sourceVersion > 0 && targetVersion > 0) { + //both the source and the target exist + + if (sourceVersion > mappedSourceVersion && targetVersion > mappedTargetVersion) { + //CONFLICT DETECTION + // Source version is newer than mapped source version + // and target version is newer than mapped target version + + //build the url to the source and target entity + //TODO: if there are multiple guids we need to handle that + + const sourceUrl = `https://app.agilitycms.com/instance/${state.sourceGuid[0]}/${locale}/content/listitem-${sourceEntity.contentID}`; + const targetUrl = `https://app.agilitycms.com/instance/${state.targetGuid[0]}/${locale}/content/listitem-${targetEntity.contentID}`; + + if (overwrite) { + return { + entity: targetEntity, + shouldUpdate: true, + shouldCreate: false, + shouldSkip: false, + isConflict: false, + reason: "Overwrite mode enabled", + }; + } else { + return { + entity: targetEntity, + shouldUpdate: false, + shouldCreate: false, + shouldSkip: false, + isConflict: true, + reason: `Both source and target versions have been updated. Please resolve manually.\n - source: ${sourceUrl} \n - target: ${targetUrl}`, + }; + } + } + } + + if (sourceVersion > mappedSourceVersion && targetVersion <= mappedTargetVersion) { + //SOURCE UPDATE ONLY + // Source version is newer the mapped source version + // and target version is NOT newer than mapped target version + return { + entity: targetEntity, + shouldUpdate: true, + shouldCreate: false, + shouldSkip: false, + isConflict: false, + reason: "Source version is newer.", + }; + } + + if (overwrite) { + return { + entity: targetEntity, + shouldUpdate: true, + shouldCreate: false, + shouldSkip: false, + isConflict: false, + reason: "Overwrite mode enabled", + }; + } + + return { + entity: targetEntity, + shouldUpdate: false, + shouldCreate: false, + shouldSkip: true, + isConflict: false, + // No update needed, target is up to date + reason: "Entity exists and is up to date", + }; +} From ee1c10ff9601d8582f5b2db13e734879429af56c Mon Sep 17 00:00:00 2001 From: Jules Exel Date: Wed, 20 May 2026 11:24:16 -0400 Subject: [PATCH 2/2] update unit test to reflect changes --- .../util/tests/change-detection.test.ts | 133 +++++++++--------- 1 file changed, 67 insertions(+), 66 deletions(-) diff --git a/src/lib/pushers/content-pusher/util/tests/change-detection.test.ts b/src/lib/pushers/content-pusher/util/tests/change-detection.test.ts index c32dd41..40940c3 100644 --- a/src/lib/pushers/content-pusher/util/tests/change-detection.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/change-detection.test.ts @@ -1,12 +1,12 @@ -import { resetState, setState } from 'core/state'; -import { changeDetection } from '../change-detection'; -import type { ContentItemMapping } from 'lib/mappers/content-item-mapper'; +import { resetState, setState } from "core/state"; +import { changeDetection } from "../change-detection"; +import type { ContentItemMapping } from "lib/mappers/content-item-mapper"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -18,15 +18,15 @@ afterEach(() => { function makeContent(id: number, versionID = 0, referenceName = `ref-${id}`): any { return { contentID: id, - properties: { referenceName, versionID, definitionName: 'Model' }, + properties: { referenceName, versionID, definitionName: "Model" }, fields: {}, }; } function makeMapping(overrides: Partial = {}): ContentItemMapping { return { - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + sourceGuid: "src-guid", + targetGuid: "tgt-guid", sourceContentID: 1, targetContentID: 100, sourceVersionID: 1, @@ -37,27 +37,27 @@ function makeMapping(overrides: Partial = {}): ContentItemMa // ─── invalid source entity ──────────────────────────────────────────────────── -describe('changeDetection β€” invalid source entity', () => { - it('returns shouldSkip=true when source is null', () => { - const result = changeDetection(null as any, null, null as any, 'en-us'); +describe("changeDetection β€” invalid source entity", () => { + it("returns shouldSkip=true when source is null", () => { + const result = changeDetection(null as any, null, null as any, "en-us"); expect(result.shouldSkip).toBe(true); expect(result.shouldCreate).toBe(false); expect(result.shouldUpdate).toBe(false); expect(result.isConflict).toBe(false); }); - it('returns shouldSkip=true when source has no properties', () => { - const result = changeDetection({} as any, null, null as any, 'en-us'); + it("returns shouldSkip=true when source has no properties", () => { + const result = changeDetection({} as any, null, null as any, "en-us"); expect(result.shouldSkip).toBe(true); }); }); // ─── create path ────────────────────────────────────────────────────────────── -describe('changeDetection β€” create path', () => { - it('returns shouldCreate=true when no mapping and no target', () => { +describe("changeDetection β€” create path", () => { + it("returns shouldCreate=true when no mapping and no target", () => { const source = makeContent(1, 5); - const result = changeDetection(source, null, null as any, 'en-us'); + const result = changeDetection(source, null, null as any, "en-us"); expect(result.shouldCreate).toBe(true); expect(result.shouldUpdate).toBe(false); expect(result.shouldSkip).toBe(false); @@ -68,13 +68,13 @@ describe('changeDetection β€” create path', () => { // ─── conflict path ──────────────────────────────────────────────────────────── -describe('changeDetection β€” conflict path', () => { - it('returns isConflict=true when both source and target versions exceed mapped versions', () => { - setState({ sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); +describe("changeDetection β€” conflict path", () => { + it("returns isConflict=true when both source and target versions exceed mapped versions", () => { + setState({ sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const source = makeContent(1, 10); const target = makeContent(100, 10); const mapping = makeMapping({ sourceVersionID: 5, targetVersionID: 5 }); - const result = changeDetection(source, target, mapping, 'en-us'); + const result = changeDetection(source, target, mapping, "en-us"); expect(result.isConflict).toBe(true); expect(result.shouldUpdate).toBe(false); expect(result.shouldCreate).toBe(false); @@ -82,46 +82,46 @@ describe('changeDetection β€” conflict path', () => { expect(result.entity).toBe(target); }); - it('conflict reason contains source and target URLs', () => { - setState({ sourceGuid: 'src-g', targetGuid: 'tgt-g' }); + it("conflict reason contains source and target URLs", () => { + setState({ sourceGuid: "src-g", targetGuid: "tgt-g" }); const source = makeContent(1, 10); const target = makeContent(100, 10); const mapping = makeMapping({ sourceVersionID: 5, targetVersionID: 5 }); - const result = changeDetection(source, target, mapping, 'en-us'); - expect(result.reason).toContain('src-g'); - expect(result.reason).toContain('tgt-g'); - expect(result.reason).toContain('1'); - expect(result.reason).toContain('100'); + const result = changeDetection(source, target, mapping, "en-us"); + expect(result.reason).toContain("src-g"); + expect(result.reason).toContain("tgt-g"); + expect(result.reason).toContain("1"); + expect(result.reason).toContain("100"); }); - it('does NOT conflict when only source version increased', () => { - setState({ sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); + it("does NOT conflict when only source version increased", () => { + setState({ sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const source = makeContent(1, 10); const target = makeContent(100, 5); const mapping = makeMapping({ sourceVersionID: 5, targetVersionID: 5 }); - const result = changeDetection(source, target, mapping, 'en-us'); + const result = changeDetection(source, target, mapping, "en-us"); expect(result.isConflict).toBe(false); }); - it('does NOT conflict when only target version increased', () => { - setState({ sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); + it("does NOT conflict when only target version increased", () => { + setState({ sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const source = makeContent(1, 5); const target = makeContent(100, 10); const mapping = makeMapping({ sourceVersionID: 5, targetVersionID: 5 }); - const result = changeDetection(source, target, mapping, 'en-us'); + const result = changeDetection(source, target, mapping, "en-us"); expect(result.isConflict).toBe(false); }); }); // ─── update path ────────────────────────────────────────────────────────────── -describe('changeDetection β€” update path', () => { - it('returns shouldUpdate=true when source version > mapped source version and target is unchanged', () => { - setState({ sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); +describe("changeDetection β€” update path", () => { + it("returns shouldUpdate=true when source version > mapped source version and target is unchanged", () => { + setState({ sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const source = makeContent(1, 10); const target = makeContent(100, 5); const mapping = makeMapping({ sourceVersionID: 5, targetVersionID: 5 }); - const result = changeDetection(source, target, mapping, 'en-us'); + const result = changeDetection(source, target, mapping, "en-us"); expect(result.shouldUpdate).toBe(true); expect(result.shouldCreate).toBe(false); expect(result.shouldSkip).toBe(false); @@ -129,50 +129,51 @@ describe('changeDetection β€” update path', () => { expect(result.entity).toBe(target); }); - it('returns shouldUpdate=true when source version > mapped and target version <= mapped', () => { - setState({ sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); + it("returns shouldUpdate=true when source version > mapped and target version <= mapped", () => { + setState({ sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const source = makeContent(1, 20); const target = makeContent(100, 3); const mapping = makeMapping({ sourceVersionID: 10, targetVersionID: 5 }); - const result = changeDetection(source, target, mapping, 'en-us'); + const result = changeDetection(source, target, mapping, "en-us"); expect(result.shouldUpdate).toBe(true); }); }); // ─── overwrite mode ─────────────────────────────────────────────────────────── -describe('changeDetection β€” overwrite mode', () => { - it('returns shouldUpdate=true in overwrite mode even when source is not newer', () => { - setState({ overwrite: true, sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); +describe("changeDetection β€” overwrite mode", () => { + it("returns shouldUpdate=true in overwrite mode even when source is not newer", () => { + setState({ overwrite: true, sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const source = makeContent(1, 5); const target = makeContent(100, 5); const mapping = makeMapping({ sourceVersionID: 5, targetVersionID: 5 }); - const result = changeDetection(source, target, mapping, 'en-us'); + const result = changeDetection(source, target, mapping, "en-us"); expect(result.shouldUpdate).toBe(true); expect(result.shouldSkip).toBe(false); expect(result.reason).toMatch(/overwrite/i); }); - it('uses overwrite fallback only when not a conflict', () => { - setState({ overwrite: true, sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); + it("returns conflict=false in overwrite flag is present when changes to source and target", () => { + setState({ overwrite: true, sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const source = makeContent(1, 10); const target = makeContent(100, 10); const mapping = makeMapping({ sourceVersionID: 5, targetVersionID: 5 }); - const result = changeDetection(source, target, mapping, 'en-us'); - // conflict takes precedence over overwrite - expect(result.isConflict).toBe(true); + const result = changeDetection(source, target, mapping, "en-us"); + // overwrite ignores any conflicts + expect(result.isConflict).toBe(false); + expect(result.shouldUpdate).toBe(true); }); }); // ─── skip path ──────────────────────────────────────────────────────────────── -describe('changeDetection β€” skip path', () => { - it('returns shouldSkip=true when source version equals mapped version and overwrite is false', () => { - setState({ overwrite: false, sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); +describe("changeDetection β€” skip path", () => { + it("returns shouldSkip=true when source version equals mapped version and overwrite is false", () => { + setState({ overwrite: false, sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const source = makeContent(1, 5); const target = makeContent(100, 5); const mapping = makeMapping({ sourceVersionID: 5, targetVersionID: 5 }); - const result = changeDetection(source, target, mapping, 'en-us'); + const result = changeDetection(source, target, mapping, "en-us"); expect(result.shouldSkip).toBe(true); expect(result.shouldUpdate).toBe(false); expect(result.shouldCreate).toBe(false); @@ -180,43 +181,43 @@ describe('changeDetection β€” skip path', () => { expect(result.entity).toBe(target); }); - it('returns shouldSkip=true when source version is less than mapped version', () => { + it("returns shouldSkip=true when source version is less than mapped version", () => { setState({ overwrite: false }); const source = makeContent(1, 3); const target = makeContent(100, 5); const mapping = makeMapping({ sourceVersionID: 5, targetVersionID: 5 }); - const result = changeDetection(source, target, mapping, 'en-us'); + const result = changeDetection(source, target, mapping, "en-us"); expect(result.shouldSkip).toBe(true); }); }); // ─── zero-version edge cases ────────────────────────────────────────────────── -describe('changeDetection β€” zero-version edge cases', () => { - it('does not enter conflict branch when versions are 0', () => { - setState({ sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); +describe("changeDetection β€” zero-version edge cases", () => { + it("does not enter conflict branch when versions are 0", () => { + setState({ sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const source = makeContent(1, 0); const target = makeContent(100, 0); const mapping = makeMapping({ sourceVersionID: 0, targetVersionID: 0 }); - const result = changeDetection(source, target, mapping, 'en-us'); + const result = changeDetection(source, target, mapping, "en-us"); expect(result.isConflict).toBe(false); }); - it('falls through to skip when both source and target version are 0 and overwrite is false', () => { + it("falls through to skip when both source and target version are 0 and overwrite is false", () => { setState({ overwrite: false }); const source = makeContent(1, 0); const target = makeContent(100, 0); const mapping = makeMapping({ sourceVersionID: 0, targetVersionID: 0 }); - const result = changeDetection(source, target, mapping, 'en-us'); + const result = changeDetection(source, target, mapping, "en-us"); expect(result.shouldSkip).toBe(true); }); }); // ─── referenceName fallback ─────────────────────────────────────────────────── -describe('changeDetection β€” referenceName fallback', () => { - it('uses contentID in itemName when referenceName is absent', () => { - setState({ sourceGuid: 'src-guid', targetGuid: 'tgt-guid' }); +describe("changeDetection β€” referenceName fallback", () => { + it("uses contentID in itemName when referenceName is absent", () => { + setState({ sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const source: any = { contentID: 42, properties: { versionID: 10 }, @@ -224,6 +225,6 @@ describe('changeDetection β€” referenceName fallback', () => { }; const target = makeContent(100, 10); const mapping = makeMapping({ sourceVersionID: 5, targetVersionID: 5 }); - expect(() => changeDetection(source, target, mapping, 'en-us')).not.toThrow(); + expect(() => changeDetection(source, target, mapping, "en-us")).not.toThrow(); }); });