diff --git a/cypress/e2e/fse/dry-run/0000-setup-specs-backup.cy.js b/cypress/e2e/fse/dry-run/0000-setup-specs-backup.cy.js new file mode 100644 index 0000000000..736bb83c11 --- /dev/null +++ b/cypress/e2e/fse/dry-run/0000-setup-specs-backup.cy.js @@ -0,0 +1,103 @@ +import { REQUEST_METHOD } from '../../../support/constants'; + +describe('MARC Specifications - Backup and Sync to LOC Defaults', () => { + const BACKUP_FILE_PATH = 'cypress/fixtures/backup/specifications-backup.json'; + const SPECIFICATION_PROFILES = ['bibliographic', 'authority']; + + before('Get admin token', () => { + cy.getAdminToken(); + }); + + it('Backup all MARC specifications and sync to LOC defaults', { tags: ['dryRun'] }, () => { + cy.log('๐Ÿ“ฆ Backing up MARC specifications...'); + + // Step 1: Fetch all specifications with full details (include=all) + cy.okapiRequest({ + method: REQUEST_METHOD.GET, + path: 'specification-storage/specifications', + searchParams: { + include: 'all', + }, + isDefaultSearchParamsRequired: false, + }).then((response) => { + expect(response.status).to.eq(200); + expect(response.body).to.have.property('specifications'); + + const specifications = response.body.specifications; + const specCount = specifications.length; + + cy.log(`โœ“ Fetched ${specCount} specifications from API`); + + // Verify we have the expected profiles + SPECIFICATION_PROFILES.forEach((profile) => { + const spec = specifications.find((s) => s.profile === profile); + if (spec) { + const fieldCount = spec.fields ? spec.fields.length : 0; + cy.log(` โ†’ ${profile}: ${fieldCount} fields`); + } else { + cy.log(` โš  ${profile}: NOT FOUND`); + } + }); + + // Step 2: Save backup to file + cy.writeFile(BACKUP_FILE_PATH, response.body, { log: true }).then(() => { + cy.log(`โœ“ Backup saved to ${BACKUP_FILE_PATH}`); + }); + + // Step 3: Sync each specification to LOC defaults + cy.log('๐Ÿ”„ Syncing specifications to LOC defaults...'); + + const syncResults = []; + + // Process specifications sequentially to avoid overwhelming the API + const syncSpecification = (spec, index) => { + if (index >= specifications.length) { + // All done, show summary + const successCount = syncResults.filter((r) => r.success).length; + const failCount = syncResults.filter((r) => !r.success).length; + + cy.log(''); + cy.log('===================================='); + cy.log('๐Ÿ“Š BACKUP & SYNC SUMMARY'); + cy.log('===================================='); + cy.log(`โœ“ Backed up ${specCount} specifications`); + cy.log(`โœ“ Successfully synced ${successCount} specifications`); + if (failCount > 0) { + cy.log(`โš  Failed to sync ${failCount} specifications`); + } + cy.log(`๐Ÿ’พ Backup stored in: ${BACKUP_FILE_PATH}`); + cy.log('===================================='); + cy.log(''); + cy.log('๐ŸŽฏ All tests will now run against LOC default specifications'); + cy.log('๐Ÿ”™ Original configuration will be restored after all tests complete'); + return; + } + + const currentSpec = specifications[index]; + + cy.okapiRequest({ + method: REQUEST_METHOD.POST, + path: `specification-storage/specifications/${currentSpec.id}/sync`, + isDefaultSearchParamsRequired: false, + failOnStatusCode: false, + }).then((syncResponse) => { + if (syncResponse.status === 202 || syncResponse.status === 200) { + cy.log(`โœ“ Synced ${currentSpec.profile} specification (${currentSpec.id})`); + syncResults.push({ profile: currentSpec.profile, success: true }); + } else { + cy.log( + `โš  Failed to sync ${currentSpec.profile} specification (status: ${syncResponse.status})`, + ); + syncResults.push({ profile: currentSpec.profile, success: false }); + } + + // Process next specification + syncSpecification(spec, index + 1); + }); + }; + + // Start syncing from first specification + syncSpecification(specifications, 0); + }); + }); +}); diff --git a/cypress/e2e/fse/dry-run/zzz/zzz/zzz/zzz/zzzz-restore-specs-backup.cy.js b/cypress/e2e/fse/dry-run/zzz/zzz/zzz/zzz/zzzz-restore-specs-backup.cy.js new file mode 100644 index 0000000000..17f6d9d875 --- /dev/null +++ b/cypress/e2e/fse/dry-run/zzz/zzz/zzz/zzz/zzzz-restore-specs-backup.cy.js @@ -0,0 +1,377 @@ +import { REQUEST_METHOD } from '../../../../../../../support/constants'; +import { + compareSpecifications, + findSpecificationByProfile, + formatDifferencesSummary, +} from '../../../../../../../support/utils/specification-comparator'; + +describe('MARC Specifications - Restore from Backup', () => { + const BACKUP_FILE_PATH = 'cypress/fixtures/backup/specifications-backup.json'; + const SPECIFICATION_PROFILES = ['bibliographic', 'authority', 'holdings']; + + before('Get admin token', () => { + cy.getAdminToken(); + }); + + it('Restore MARC specifications from backup if modified', { tags: ['dryRun'] }, () => { + cy.log('๐Ÿ” Checking for backup file...'); + + // Step 1: Check if backup file exists (use failOnStatusCode: false equivalent) + cy.task('findFiles', BACKUP_FILE_PATH).then((fileExists) => { + if (!fileExists) { + cy.log('โ„น๏ธ No backup file found - skipping restoration'); + cy.log(` Expected location: ${BACKUP_FILE_PATH}`); + cy.log(' This is normal if setup test did not run or was skipped'); + return; + } + + cy.readFile(BACKUP_FILE_PATH, { timeout: 10000 }).then((backupData) => { + cy.log(`โœ“ Backup file found: ${BACKUP_FILE_PATH}`); + expect(backupData).to.have.property('specifications'); + + const originalSpecs = backupData.specifications; + cy.log(`๐Ÿ“ฆ Original backup contains ${originalSpecs.length} specifications`); + + // Step 2: Fetch current state + cy.log('๐Ÿ“ฅ Fetching current specification state...'); + + cy.okapiRequest({ + method: REQUEST_METHOD.GET, + path: 'specification-storage/specifications', + searchParams: { + include: 'all', + }, + isDefaultSearchParamsRequired: false, + }).then((response) => { + expect(response.status).to.eq(200); + + const currentSpecs = response.body.specifications; + cy.log(`โœ“ Fetched current state: ${currentSpecs.length} specifications`); + + // Step 3: Compare and restore each specification + cy.log(''); + cy.log('๐Ÿ”„ Comparing and restoring specifications...'); + cy.log(''); + + const restorationResults = { + totalFields: 0, + totalSubfields: 0, + totalIndicators: 0, + totalIndicatorCodes: 0, + restoredFields: 0, + restoredSubfields: 0, + restoredIndicators: 0, + restoredIndicatorCodes: 0, + errors: [], + }; + + // Collect all changes from all specifications + const allChanges = []; + + SPECIFICATION_PROFILES.forEach((profile) => { + const originalSpec = findSpecificationByProfile(originalSpecs, profile); + const currentSpec = findSpecificationByProfile(currentSpecs, profile); + + if (!originalSpec) { + cy.log(`โš  No backup found for ${profile} specification`); + return; + } + + if (!currentSpec) { + cy.log(`โš  ${profile} specification not found in current state`); + return; + } + + cy.log(`Comparing ${profile} specification...`); + + // Compare specifications + const differences = compareSpecifications(originalSpec, currentSpec); + + restorationResults.totalFields += differences.stats.totalFields; + restorationResults.totalSubfields += differences.stats.totalSubfields; + restorationResults.totalIndicators += differences.stats.totalIndicators; + restorationResults.totalIndicatorCodes += differences.stats.totalIndicatorCodes; + + const summary = formatDifferencesSummary(differences); + cy.log(` ${profile}: ${summary}`); + + // Add changes to the list + differences.fieldsToUpdate.forEach((change) => allChanges.push({ type: 'field', specificationId: currentSpec.id, ...change })); + differences.subfieldsToUpdate.forEach((change) => allChanges.push({ type: 'subfield', ...change })); + differences.indicatorsToUpdate.forEach((change) => allChanges.push({ type: 'indicator', ...change })); + differences.indicatorCodesToUpdate.forEach((change) => allChanges.push({ type: 'indicatorCode', ...change })); + }); + + // Process all changes sequentially + const processChange = (index) => { + if (index >= allChanges.length) { + // All changes processed, show summary + cy.then(() => { + cy.log(''); + cy.log('===================================='); + cy.log('๐Ÿ“Š RESTORATION SUMMARY'); + cy.log('===================================='); + + if ( + restorationResults.restoredFields === 0 && + restorationResults.restoredSubfields === 0 && + restorationResults.restoredIndicators === 0 && + restorationResults.restoredIndicatorCodes === 0 + ) { + cy.log('โœ… No changes detected - specifications unchanged'); + } else { + cy.log('Total resources scanned:'); + cy.log(` โ€ข ${restorationResults.totalFields} fields`); + cy.log(` โ€ข ${restorationResults.totalSubfields} subfields`); + cy.log(` โ€ข ${restorationResults.totalIndicators} indicators`); + cy.log(` โ€ข ${restorationResults.totalIndicatorCodes} indicator codes`); + cy.log(''); + cy.log('Resources restored:'); + if (restorationResults.restoredFields > 0) { + cy.log(` โœ“ ${restorationResults.restoredFields} fields`); + } + if (restorationResults.restoredSubfields > 0) { + cy.log(` โœ“ ${restorationResults.restoredSubfields} subfields`); + } + if (restorationResults.restoredIndicators > 0) { + cy.log(` โœ“ ${restorationResults.restoredIndicators} indicators`); + } + if (restorationResults.restoredIndicatorCodes > 0) { + cy.log(` โœ“ ${restorationResults.restoredIndicatorCodes} indicator codes`); + } + } + + if (restorationResults.errors.length > 0) { + cy.log(''); + cy.log(`โš  Encountered ${restorationResults.errors.length} errors:`); + restorationResults.errors.slice(0, 10).forEach((error) => { + cy.log(` โ€ข ${error}`); + }); + if (restorationResults.errors.length > 10) { + cy.log(` ... and ${restorationResults.errors.length - 10} more`); + } + } + + cy.log('===================================='); + + // Step 4: Clean up backup file + cy.log(''); + cy.log('๐Ÿงน Cleaning up backup file...'); + + cy.task('deleteFile', BACKUP_FILE_PATH, { log: false }).then((result) => { + cy.log(`โœ“ ${result}`); + cy.log(''); + cy.log('๐ŸŽ‰ Restoration complete!'); + }); + }); + return; + } + + const change = allChanges[index]; + + // Process based on change type + if (change.type === 'field' && change.changeType === 'modified') { + cy.updateSpecificationField(change.id, change.original, false).then( + (updateResponse) => { + if (updateResponse.status >= 200 && updateResponse.status < 300) { + restorationResults.restoredFields++; + cy.log( + ` โœ“ Restored field ${change.original.tag} (${change.changedFields.join(', ')})`, + ); + } else { + restorationResults.errors.push( + `Field ${change.original.tag}: status ${updateResponse.status}`, + ); + } + processChange(index + 1); + }, + ); + } else if (change.type === 'field' && change.changeType === 'deleted') { + cy.createSpecificationField(change.specificationId, change.original, false).then( + (fieldResponse) => { + if (fieldResponse.status >= 200 && fieldResponse.status < 300) { + restorationResults.restoredFields++; + cy.log(` โœ“ Restored field ${change.original.tag} (re-created)`); + + change.original.subfields?.forEach((originalSubfield) => { + cy.createSpecificationFieldSubfield( + fieldResponse.body?.id, + originalSubfield, + false, + ).then((subfieldResponse) => { + if (subfieldResponse.status >= 200 && subfieldResponse.status < 300) { + restorationResults.restoredSubfields++; + cy.log(` โœ“ Restored subfield ${originalSubfield.code} (re-created)`); + } else { + restorationResults.errors.push( + `Subfield ${originalSubfield.code}: status ${subfieldResponse.status}`, + ); + } + }); + }); + + change.original.indicators?.forEach((originalIndicator) => { + cy.createSpecificationFieldIndicator( + fieldResponse.body?.id, + originalIndicator, + false, + ).then((indicatorResponse) => { + if (indicatorResponse.status >= 200 && indicatorResponse.status < 300) { + restorationResults.restoredIndicators++; + cy.log( + ` โœ“ Restored indicator ${originalIndicator.order} (re-created)`, + ); + + originalIndicator.codes?.forEach((originalCode) => { + cy.createSpecificationIndicatorCode( + indicatorResponse.body?.id, + originalCode, + false, + ).then((codeResponse) => { + if (codeResponse.status >= 200 && codeResponse.status < 300) { + restorationResults.restoredIndicatorCodes++; + cy.log( + ` โœ“ Restored indicator code '${originalCode.code}' (re-created)`, + ); + } else { + restorationResults.errors.push( + `Indicator code '${originalCode.code}': status ${codeResponse.status}`, + ); + } + }); + }); + } else { + restorationResults.errors.push( + `Indicator ${originalIndicator.order}: status ${indicatorResponse.status}`, + ); + } + }); + }); + } else { + restorationResults.errors.push( + `Field ${change.original.tag}: status ${fieldResponse.status}`, + ); + } + processChange(index + 1); + }, + ); + } else if (change.type === 'subfield' && change.changeType === 'modified') { + cy.updateSpecificationSubfield(change.id, change.original, false).then( + (updateResponse) => { + if (updateResponse.status >= 200 && updateResponse.status < 300) { + restorationResults.restoredSubfields++; + cy.log( + ` โœ“ Restored subfield ${change.original.code} (${change.changedFields.join(', ')})`, + ); + } else { + restorationResults.errors.push( + `Subfield ${change.original.code}: status ${updateResponse.status}`, + ); + } + processChange(index + 1); + }, + ); + } else if (change.type === 'subfield' && change.changeType === 'deleted') { + cy.createSpecificationFieldSubfield(change.fieldId, change.original, false).then( + (createResponse) => { + if (createResponse.status >= 200 && createResponse.status < 300) { + restorationResults.restoredSubfields++; + cy.log(` โœ“ Restored subfield ${change.original.code} (re-created)`); + } else { + restorationResults.errors.push( + `Subfield ${change.original.code}: status ${createResponse.status}`, + ); + } + processChange(index + 1); + }, + ); + } else if (change.type === 'indicator' && change.changeType === 'modified') { + cy.updateSpecificationFieldIndicator(change.id, change.original, false).then( + (updateResponse) => { + if (updateResponse.status >= 200 && updateResponse.status < 300) { + restorationResults.restoredIndicators++; + cy.log( + ` โœ“ Restored indicator ${change.original.order} (${change.changedFields.join(', ')})`, + ); + } else { + restorationResults.errors.push( + `Indicator ${change.original.order}: status ${updateResponse.status}`, + ); + } + processChange(index + 1); + }, + ); + } else if (change.type === 'indicator' && change.changeType === 'deleted') { + cy.createSpecificationFieldIndicator(change.fieldId, change.original, false).then( + (createResponse) => { + if (createResponse.status >= 200 && createResponse.status < 300) { + restorationResults.restoredIndicators++; + cy.log(` โœ“ Restored indicator ${change.original.order} (re-created)`); + } else { + restorationResults.errors.push( + `Indicator ${change.original.order}: status ${createResponse.status}`, + ); + } + processChange(index + 1); + }, + ); + } else if (change.type === 'indicatorCode' && change.changeType === 'modified') { + cy.updateSpecificationIndicatorCode(change.id, change.original, false).then( + (updateResponse) => { + if (updateResponse.status >= 200 && updateResponse.status < 300) { + restorationResults.restoredIndicatorCodes++; + cy.log( + ` โœ“ Restored indicator code '${change.original.code}' (${change.changedFields.join(', ')})`, + ); + } else { + restorationResults.errors.push( + `Indicator code '${change.original.code}': status ${updateResponse.status}`, + ); + } + processChange(index + 1); + }, + ); + } else if (change.type === 'indicatorCode' && change.changeType === 'deleted') { + cy.createSpecificationIndicatorCode(change.indicatorId, change.original, false).then( + (createResponse) => { + if (createResponse.status >= 200 && createResponse.status < 300) { + restorationResults.restoredIndicatorCodes++; + cy.log(` โœ“ Restored indicator code '${change.original.code}' (re-created)`); + } else { + restorationResults.errors.push( + `Indicator code '${change.original.code}': status ${createResponse.status}`, + ); + } + processChange(index + 1); + }, + ); + } + }; + + // Start processing changes + if (allChanges.length > 0) { + processChange(0); + } else { + // No changes, just show summary and cleanup + cy.log(''); + cy.log('===================================='); + cy.log('๐Ÿ“Š RESTORATION SUMMARY'); + cy.log('===================================='); + cy.log('โœ… No changes detected - specifications unchanged'); + cy.log('===================================='); + + // Step 4: Clean up backup file + cy.log(''); + cy.log('๐Ÿงน Cleaning up backup file...'); + + cy.task('deleteFile', BACKUP_FILE_PATH, { log: false }).then((result) => { + cy.log(`โœ“ ${result}`); + cy.log(''); + cy.log('๐ŸŽ‰ Restoration complete!'); + }); + } + }); + }); + }); + }); +}); diff --git a/cypress/fixtures/backup/.gitkeep b/cypress/fixtures/backup/.gitkeep new file mode 100644 index 0000000000..1795470305 --- /dev/null +++ b/cypress/fixtures/backup/.gitkeep @@ -0,0 +1,2 @@ +# This directory is used for temporary backup of MARC specifications during test execution +# Backup files are automatically created by 0000-setup-specs-backup.cy.js and cleaned by 9999-restore-specs-backup.cy.js diff --git a/cypress/support/utils/specification-comparator.js b/cypress/support/utils/specification-comparator.js new file mode 100644 index 0000000000..73fe8187c2 --- /dev/null +++ b/cypress/support/utils/specification-comparator.js @@ -0,0 +1,357 @@ +/** + * Utility for comparing MARC specifications and identifying differences + * Used by the restore process to determine which resources need to be updated + */ + +/** + * Deep comparison of two objects for specific fields + * @param {Object} obj1 - First object + * @param {Object} obj2 - Second object + * @param {Array} fields - Fields to compare + * @returns {boolean} - True if all fields match + */ +function areFieldsEqual(obj1, obj2, fields) { + return fields.every((field) => { + const val1 = obj1[field]; + const val2 = obj2[field]; + + // Handle null/undefined + if (val1 === val2) return true; + if (val1 == null || val2 == null) return false; + + // Deep comparison for objects + if (typeof val1 === 'object' && typeof val2 === 'object') { + return JSON.stringify(val1) === JSON.stringify(val2); + } + + return val1 === val2; + }); +} + +/** + * Get changed fields from a list + * @param {Array} fieldsToCompare - List to compare + * @returns {Object} - Object with changed fields properties + */ +function getChangedFields(originalFields, currentFields) { + const changes = {}; + + originalFields.forEach((origField) => { + const currField = currentFields.find((f) => f.id === origField.id); + + // Skip system-managed fields - they should not be modified by tests + if (origField.scope === 'system') { + return; + } + + if (!currField) { + // Field was deleted - we'll need to recreate it + changes[origField.id] = { type: 'deleted', original: origField }; + return; + } + + // Compare field properties + const fieldPropsToCompare = [ + 'tag', + 'label', + 'repeatable', + 'required', + 'deprecated', + 'scope', + 'url', + ]; + + if (!areFieldsEqual(origField, currField, fieldPropsToCompare)) { + changes[origField.id] = { + type: 'modified', + original: origField, + current: currField, + changedFields: fieldPropsToCompare.filter((prop) => origField[prop] !== currField[prop]), + }; + } + }); + + return changes; +} + +/** + * Get changed subfields from a list + * @param {Array} originalSubfields - Original subfields + * @param {Array} currentSubfields - Current subfields + * @returns {Object} - Object with changed subfield properties + */ +function getChangedSubfields(originalSubfields, currentSubfields) { + const changes = {}; + + originalSubfields.forEach((origSubfield) => { + const currSubfield = currentSubfields.find((s) => s.id === origSubfield.id); + + // Skip system-managed subfields + if (origSubfield.scope === 'system') { + return; + } + + if (!currSubfield) { + changes[origSubfield.id] = { type: 'deleted', original: origSubfield }; + return; + } + + const subfieldPropsToCompare = [ + 'code', + 'label', + 'repeatable', + 'required', + 'deprecated', + 'scope', + ]; + + if (!areFieldsEqual(origSubfield, currSubfield, subfieldPropsToCompare)) { + changes[origSubfield.id] = { + type: 'modified', + original: origSubfield, + current: currSubfield, + changedFields: subfieldPropsToCompare.filter( + (prop) => origSubfield[prop] !== currSubfield[prop], + ), + }; + } + }); + + return changes; +} + +/** + * Get changed indicators from a list + * @param {Array} originalIndicators - Original indicators + * @param {Array} currentIndicators - Current indicators + * @returns {Object} - Object with changed indicator properties + */ +function getChangedIndicators(originalIndicators, currentIndicators) { + const changes = {}; + + originalIndicators.forEach((origIndicator) => { + const currIndicator = currentIndicators.find((i) => i.id === origIndicator.id); + + if (!currIndicator) { + changes[origIndicator.id] = { type: 'deleted', original: origIndicator }; + return; + } + + const indicatorPropsToCompare = ['order', 'label']; + + if (!areFieldsEqual(origIndicator, currIndicator, indicatorPropsToCompare)) { + changes[origIndicator.id] = { + type: 'modified', + original: origIndicator, + current: currIndicator, + changedFields: indicatorPropsToCompare.filter( + (prop) => origIndicator[prop] !== currIndicator[prop], + ), + }; + } + }); + + return changes; +} + +/** + * Get changed indicator codes from a list + * @param {Array} originalCodes - Original indicator codes + * @param {Array} currentCodes - Current indicator codes + * @returns {Object} - Object with changed indicator code properties + */ +function getChangedIndicatorCodes(originalCodes, currentCodes) { + const changes = {}; + + originalCodes.forEach((origCode) => { + const currCode = currentCodes.find((c) => c.id === origCode.id); + + // Skip system-managed codes + if (origCode.scope === 'system') { + return; + } + + if (!currCode) { + changes[origCode.id] = { type: 'deleted', original: origCode }; + return; + } + + const codePropsToCompare = ['code', 'label', 'deprecated', 'scope']; + + if (!areFieldsEqual(origCode, currCode, codePropsToCompare)) { + changes[origCode.id] = { + type: 'modified', + original: origCode, + current: currCode, + changedFields: codePropsToCompare.filter((prop) => origCode[prop] !== currCode[prop]), + }; + } + }); + + return changes; +} + +/** + * Compare two specification states and identify differences + * @param {Object} originalSpec - Original specification with full structure + * @param {Object} currentSpec - Current specification with full structure + * @returns {Object} - Differences organized by resource type + */ +export function compareSpecifications(originalSpec, currentSpec) { + const differences = { + fieldsToUpdate: [], + subfieldsToUpdate: [], + indicatorsToUpdate: [], + indicatorCodesToUpdate: [], + stats: { + totalFields: 0, + totalSubfields: 0, + totalIndicators: 0, + totalIndicatorCodes: 0, + changedFields: 0, + changedSubfields: 0, + changedIndicators: 0, + changedIndicatorCodes: 0, + }, + }; + + // Compare fields + const originalFields = originalSpec.fields || []; + const currentFields = currentSpec.fields || []; + + differences.stats.totalFields = originalFields.length; + + const fieldChanges = getChangedFields(originalFields, currentFields); + + Object.entries(fieldChanges).forEach(([fieldId, change]) => { + if (['modified', 'deleted'].includes(change.type)) { + differences.fieldsToUpdate.push({ + id: fieldId, + fieldId, + original: change.original, + changedFields: change.changedFields, + changeType: change.type, + }); + differences.stats.changedFields++; + } + }); + + // Compare subfields, indicators, and indicator codes for each field + originalFields.forEach((origField) => { + const currField = currentFields.find((f) => f.id === origField.id); + + if (!currField) return; // Field deleted, handled above + + // Subfields + const origSubfields = origField.subfields || []; + const currSubfields = currField.subfields || []; + + differences.stats.totalSubfields += origSubfields.length; + + const subfieldChanges = getChangedSubfields(origSubfields, currSubfields); + + Object.entries(subfieldChanges).forEach(([subfieldId, change]) => { + if (['modified', 'deleted'].includes(change.type)) { + differences.subfieldsToUpdate.push({ + id: subfieldId, + fieldId: origField.id, + original: change.original, + changedFields: change.changedFields, + changeType: change.type, + }); + differences.stats.changedSubfields++; + } + }); + + // Indicators + const origIndicators = origField.indicators || []; + const currIndicators = currField.indicators || []; + + differences.stats.totalIndicators += origIndicators.length; + + const indicatorChanges = getChangedIndicators(origIndicators, currIndicators); + + Object.entries(indicatorChanges).forEach(([indicatorId, change]) => { + if (['modified', 'deleted'].includes(change.type)) { + differences.indicatorsToUpdate.push({ + id: indicatorId, + fieldId: origField.id, + original: change.original, + changedFields: change.changedFields, + changeType: change.type, + }); + differences.stats.changedIndicators++; + } + }); + + // Indicator codes + origIndicators.forEach((origIndicator) => { + const currIndicator = currIndicators.find((i) => i.id === origIndicator.id); + + if (!currIndicator) return; + + const origCodes = origIndicator.codes || []; + const currCodes = currIndicator.codes || []; + + differences.stats.totalIndicatorCodes += origCodes.length; + + const codeChanges = getChangedIndicatorCodes(origCodes, currCodes); + + Object.entries(codeChanges).forEach(([codeId, change]) => { + if (['modified', 'deleted'].includes(change.type)) { + differences.indicatorCodesToUpdate.push({ + id: codeId, + indicatorId: origIndicator.id, + fieldId: origField.id, + original: change.original, + changedFields: change.changedFields, + changeType: change.type, + }); + differences.stats.changedIndicatorCodes++; + } + }); + }); + }); + + return differences; +} + +/** + * Find a specification by profile (bibliographic, authority, holdings) + * @param {Array} specifications - Array of specifications + * @param {string} profile - Profile to find + * @returns {Object|null} - Found specification or null + */ +export function findSpecificationByProfile(specifications, profile) { + return specifications.find((spec) => spec.profile === profile) || null; +} + +/** + * Format differences summary for logging + * @param {Object} differences - Differences object from compareSpecifications + * @returns {string} - Formatted summary + */ +export function formatDifferencesSummary(differences) { + const { stats } = differences; + + const parts = []; + + if (stats.changedFields > 0) { + parts.push(`${stats.changedFields} fields`); + } + if (stats.changedSubfields > 0) { + parts.push(`${stats.changedSubfields} subfields`); + } + if (stats.changedIndicators > 0) { + parts.push(`${stats.changedIndicators} indicators`); + } + if (stats.changedIndicatorCodes > 0) { + parts.push(`${stats.changedIndicatorCodes} indicator codes`); + } + + if (parts.length === 0) { + return 'No changes detected'; + } + + return `Changes detected: ${parts.join(', ')}`; +}