From 4aab901f021453cd04d6ef7f0ba7877f7cb6ae13 Mon Sep 17 00:00:00 2001 From: Vadym Yeromichev Date: Tue, 24 Feb 2026 17:23:32 +0200 Subject: [PATCH 1/7] Add backup and restore functionality for MARC specifications with detailed logging --- .../fse/dry-run/0000-setup-specs-backup.cy.js | 107 ++++++ .../dry-run/9999-restore-specs-backup.cy.js | 266 +++++++++++++ cypress/fixtures/backup/.gitkeep | 2 + .../support/utils/specification-comparator.js | 353 ++++++++++++++++++ 4 files changed, 728 insertions(+) create mode 100644 cypress/e2e/fse/dry-run/0000-setup-specs-backup.cy.js create mode 100644 cypress/e2e/fse/dry-run/9999-restore-specs-backup.cy.js create mode 100644 cypress/fixtures/backup/.gitkeep create mode 100644 cypress/support/utils/specification-comparator.js 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..e1a008e78a --- /dev/null +++ b/cypress/e2e/fse/dry-run/0000-setup-specs-backup.cy.js @@ -0,0 +1,107 @@ +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( + 'C999901 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/9999-restore-specs-backup.cy.js b/cypress/e2e/fse/dry-run/9999-restore-specs-backup.cy.js new file mode 100644 index 0000000000..12b4e389b0 --- /dev/null +++ b/cypress/e2e/fse/dry-run/9999-restore-specs-backup.cy.js @@ -0,0 +1,266 @@ +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('C999902 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', ...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') { + 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 === 'subfield') { + 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 === 'indicator') { + 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 === 'indicatorCode') { + 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); + }, + ); + } + }; + + // 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..a8dff637a4 --- /dev/null +++ b/cypress/support/utils/specification-comparator.js @@ -0,0 +1,353 @@ +/** + * 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); + + if (!currField) { + // Field was deleted - we'll need to recreate it + changes[origField.id] = { type: 'deleted', original: origField }; + return; + } + + // Skip system-managed fields - they should not be modified by tests + if (origField.scope === 'system') { + 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); + + if (!currSubfield) { + changes[origSubfield.id] = { type: 'deleted', original: origSubfield }; + return; + } + + // Skip system-managed subfields + if (origSubfield.scope === 'system') { + 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); + + if (!currCode) { + changes[origCode.id] = { type: 'deleted', original: origCode }; + return; + } + + // Skip system-managed codes + if (origCode.scope === 'system') { + 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 (change.type === 'modified') { + differences.fieldsToUpdate.push({ + id: fieldId, + fieldId, + original: change.original, + changedFields: change.changedFields, + }); + 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 (change.type === 'modified') { + differences.subfieldsToUpdate.push({ + id: subfieldId, + fieldId: origField.id, + original: change.original, + changedFields: change.changedFields, + }); + 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 (change.type === 'modified') { + differences.indicatorsToUpdate.push({ + id: indicatorId, + fieldId: origField.id, + original: change.original, + changedFields: change.changedFields, + }); + 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 (change.type === 'modified') { + differences.indicatorCodesToUpdate.push({ + id: codeId, + indicatorId: origIndicator.id, + fieldId: origField.id, + original: change.original, + changedFields: change.changedFields, + }); + 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(', ')}`; +} From bf2e24e0275df951d9c729e96e211be90cfbf1c2 Mon Sep 17 00:00:00 2001 From: Vadym Yeromichev Date: Tue, 24 Feb 2026 17:40:31 +0200 Subject: [PATCH 2/7] Add functionality to restore MARC specifications from backup with detailed comparison and logging --- ...restore-specs-backup.cy.js => zzzz-restore-specs-backup.cy.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename cypress/e2e/fse/dry-run/{9999-restore-specs-backup.cy.js => zzzz-restore-specs-backup.cy.js} (100%) diff --git a/cypress/e2e/fse/dry-run/9999-restore-specs-backup.cy.js b/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js similarity index 100% rename from cypress/e2e/fse/dry-run/9999-restore-specs-backup.cy.js rename to cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js From bf4e8d0d87350a89af5031cf68763176966509bf Mon Sep 17 00:00:00 2001 From: Vadym Yeromichev Date: Tue, 24 Feb 2026 17:43:03 +0200 Subject: [PATCH 3/7] removed test ids --- .../fse/dry-run/0000-setup-specs-backup.cy.js | 178 +++++++++--------- .../dry-run/zzzz-restore-specs-backup.cy.js | 2 +- 2 files changed, 88 insertions(+), 92 deletions(-) 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 index e1a008e78a..736bb83c11 100644 --- a/cypress/e2e/fse/dry-run/0000-setup-specs-backup.cy.js +++ b/cypress/e2e/fse/dry-run/0000-setup-specs-backup.cy.js @@ -8,100 +8,96 @@ describe('MARC Specifications - Backup and Sync to LOC Defaults', () => { cy.getAdminToken(); }); - it( - 'C999901 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`); + 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(` โš  ${profile}: NOT FOUND`); + cy.log( + `โš  Failed to sync ${currentSpec.profile} specification (status: ${syncResponse.status})`, + ); + syncResults.push({ profile: currentSpec.profile, success: false }); } - }); - // Step 2: Save backup to file - cy.writeFile(BACKUP_FILE_PATH, response.body, { log: true }).then(() => { - cy.log(`โœ“ Backup saved to ${BACKUP_FILE_PATH}`); + // Process next specification + syncSpecification(spec, index + 1); }); + }; - // 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); - }); - }, - ); + // Start syncing from first specification + syncSpecification(specifications, 0); + }); + }); }); diff --git a/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js b/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js index 12b4e389b0..8228a572d1 100644 --- a/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js +++ b/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js @@ -13,7 +13,7 @@ describe('MARC Specifications - Restore from Backup', () => { cy.getAdminToken(); }); - it('C999902 Restore MARC specifications from backup if modified', { tags: ['dryRun'] }, () => { + 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) From 8a24133d9f37886764e956b88a07cab1026bb3dc Mon Sep 17 00:00:00 2001 From: Yauhen Viazau Date: Wed, 25 Feb 2026 16:36:52 +0500 Subject: [PATCH 4/7] Spec script: added ability to re-create deleted fields --- .../dry-run/zzzz-restore-specs-backup.cy.js | 18 ++++++++++++++++-- .../support/utils/specification-comparator.js | 3 ++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js b/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js index 8228a572d1..1e78806b0d 100644 --- a/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js +++ b/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js @@ -96,7 +96,7 @@ describe('MARC Specifications - Restore from Backup', () => { cy.log(` ${profile}: ${summary}`); // Add changes to the list - differences.fieldsToUpdate.forEach((change) => allChanges.push({ type: 'field', ...change })); + 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 })); @@ -170,7 +170,7 @@ describe('MARC Specifications - Restore from Backup', () => { const change = allChanges[index]; // Process based on change type - if (change.type === 'field') { + if (change.type === 'field' && change.changeType === 'modified') { cy.updateSpecificationField(change.id, change.original, false).then( (updateResponse) => { if (updateResponse.status >= 200 && updateResponse.status < 300) { @@ -186,6 +186,20 @@ describe('MARC Specifications - Restore from Backup', () => { processChange(index + 1); }, ); + } else if (change.type === 'field' && change.changeType === 'deleted') { + cy.createSpecificationField(change.specificationId, change.original, false).then( + (updateResponse) => { + if (updateResponse.status >= 200 && updateResponse.status < 300) { + restorationResults.restoredFields++; + cy.log(` โœ“ Restored field ${change.original.tag} (re-created)`); + } else { + restorationResults.errors.push( + `Field ${change.original.tag}: status ${updateResponse.status}`, + ); + } + processChange(index + 1); + }, + ); } else if (change.type === 'subfield') { cy.updateSpecificationSubfield(change.id, change.original, false).then( (updateResponse) => { diff --git a/cypress/support/utils/specification-comparator.js b/cypress/support/utils/specification-comparator.js index a8dff637a4..cecd38ecea 100644 --- a/cypress/support/utils/specification-comparator.js +++ b/cypress/support/utils/specification-comparator.js @@ -224,12 +224,13 @@ export function compareSpecifications(originalSpec, currentSpec) { const fieldChanges = getChangedFields(originalFields, currentFields); Object.entries(fieldChanges).forEach(([fieldId, change]) => { - if (change.type === 'modified') { + if (['modified', 'deleted'].includes(change.type)) { differences.fieldsToUpdate.push({ id: fieldId, fieldId, original: change.original, changedFields: change.changedFields, + changeType: change.type, }); differences.stats.changedFields++; } From 1f09c85ed3156560d0901444f25dfa46f2fe699d Mon Sep 17 00:00:00 2001 From: Yauhen Viazau Date: Thu, 26 Feb 2026 12:48:57 +0500 Subject: [PATCH 5/7] Spec script: added ability to re-create subfields, indicators, codes for deleted spec fields --- .../dry-run/zzzz-restore-specs-backup.cy.js | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js b/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js index 1e78806b0d..dee7ee85d6 100644 --- a/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js +++ b/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js @@ -188,13 +188,68 @@ describe('MARC Specifications - Restore from Backup', () => { ); } else if (change.type === 'field' && change.changeType === 'deleted') { cy.createSpecificationField(change.specificationId, change.original, false).then( - (updateResponse) => { - if (updateResponse.status >= 200 && updateResponse.status < 300) { + (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 ${updateResponse.status}`, + `Field ${change.original.tag}: status ${fieldResponse.status}`, ); } processChange(index + 1); From a9a15c2a1655d1bee3f7624bcd59c068d3adb38b Mon Sep 17 00:00:00 2001 From: Yauhen Viazau Date: Thu, 26 Feb 2026 14:10:31 +0500 Subject: [PATCH 6/7] Spec script: added ability to re-create deleted subfields, indicators, codes for updated existing spec fields --- .../dry-run/zzzz-restore-specs-backup.cy.js | 48 +++++++++++++++++-- .../support/utils/specification-comparator.js | 35 +++++++------- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js b/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js index dee7ee85d6..bb067cee4e 100644 --- a/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js +++ b/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js @@ -255,7 +255,7 @@ describe('MARC Specifications - Restore from Backup', () => { processChange(index + 1); }, ); - } else if (change.type === 'subfield') { + } else if (change.type === 'subfield' && change.changeType === 'modified') { cy.updateSpecificationSubfield(change.id, change.original, false).then( (updateResponse) => { if (updateResponse.status >= 200 && updateResponse.status < 300) { @@ -271,7 +271,21 @@ describe('MARC Specifications - Restore from Backup', () => { processChange(index + 1); }, ); - } else if (change.type === 'indicator') { + } 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) { @@ -287,7 +301,21 @@ describe('MARC Specifications - Restore from Backup', () => { processChange(index + 1); }, ); - } else if (change.type === 'indicatorCode') { + } 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) { @@ -303,6 +331,20 @@ describe('MARC Specifications - Restore from Backup', () => { 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); + }, + ); } }; diff --git a/cypress/support/utils/specification-comparator.js b/cypress/support/utils/specification-comparator.js index cecd38ecea..73fe8187c2 100644 --- a/cypress/support/utils/specification-comparator.js +++ b/cypress/support/utils/specification-comparator.js @@ -39,14 +39,14 @@ function getChangedFields(originalFields, currentFields) { originalFields.forEach((origField) => { const currField = currentFields.find((f) => f.id === origField.id); - if (!currField) { - // Field was deleted - we'll need to recreate it - changes[origField.id] = { type: 'deleted', original: origField }; + // Skip system-managed fields - they should not be modified by tests + if (origField.scope === 'system') { return; } - // Skip system-managed fields - they should not be modified by tests - if (origField.scope === 'system') { + if (!currField) { + // Field was deleted - we'll need to recreate it + changes[origField.id] = { type: 'deleted', original: origField }; return; } @@ -86,13 +86,13 @@ function getChangedSubfields(originalSubfields, currentSubfields) { originalSubfields.forEach((origSubfield) => { const currSubfield = currentSubfields.find((s) => s.id === origSubfield.id); - if (!currSubfield) { - changes[origSubfield.id] = { type: 'deleted', original: origSubfield }; + // Skip system-managed subfields + if (origSubfield.scope === 'system') { return; } - // Skip system-managed subfields - if (origSubfield.scope === 'system') { + if (!currSubfield) { + changes[origSubfield.id] = { type: 'deleted', original: origSubfield }; return; } @@ -166,13 +166,13 @@ function getChangedIndicatorCodes(originalCodes, currentCodes) { originalCodes.forEach((origCode) => { const currCode = currentCodes.find((c) => c.id === origCode.id); - if (!currCode) { - changes[origCode.id] = { type: 'deleted', original: origCode }; + // Skip system-managed codes + if (origCode.scope === 'system') { return; } - // Skip system-managed codes - if (origCode.scope === 'system') { + if (!currCode) { + changes[origCode.id] = { type: 'deleted', original: origCode }; return; } @@ -251,12 +251,13 @@ export function compareSpecifications(originalSpec, currentSpec) { const subfieldChanges = getChangedSubfields(origSubfields, currSubfields); Object.entries(subfieldChanges).forEach(([subfieldId, change]) => { - if (change.type === 'modified') { + 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++; } @@ -271,12 +272,13 @@ export function compareSpecifications(originalSpec, currentSpec) { const indicatorChanges = getChangedIndicators(origIndicators, currIndicators); Object.entries(indicatorChanges).forEach(([indicatorId, change]) => { - if (change.type === 'modified') { + 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++; } @@ -296,13 +298,14 @@ export function compareSpecifications(originalSpec, currentSpec) { const codeChanges = getChangedIndicatorCodes(origCodes, currCodes); Object.entries(codeChanges).forEach(([codeId, change]) => { - if (change.type === 'modified') { + 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++; } From 337b16a4ec4a2e80b35447d33d1b4ca6fbad7f24 Mon Sep 17 00:00:00 2001 From: Ostap_Voitsekhovskyi Date: Tue, 3 Mar 2026 14:49:05 +0200 Subject: [PATCH 7/7] moved to another location --- .../{ => zzz/zzz/zzz/zzz}/zzzz-restore-specs-backup.cy.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename cypress/e2e/fse/dry-run/{ => zzz/zzz/zzz/zzz}/zzzz-restore-specs-backup.cy.js (99%) diff --git a/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js b/cypress/e2e/fse/dry-run/zzz/zzz/zzz/zzz/zzzz-restore-specs-backup.cy.js similarity index 99% rename from cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js rename to cypress/e2e/fse/dry-run/zzz/zzz/zzz/zzz/zzzz-restore-specs-backup.cy.js index bb067cee4e..17f6d9d875 100644 --- a/cypress/e2e/fse/dry-run/zzzz-restore-specs-backup.cy.js +++ b/cypress/e2e/fse/dry-run/zzz/zzz/zzz/zzz/zzzz-restore-specs-backup.cy.js @@ -1,9 +1,9 @@ -import { REQUEST_METHOD } from '../../../support/constants'; +import { REQUEST_METHOD } from '../../../../../../../support/constants'; import { compareSpecifications, findSpecificationByProfile, formatDifferencesSummary, -} from '../../../support/utils/specification-comparator'; +} from '../../../../../../../support/utils/specification-comparator'; describe('MARC Specifications - Restore from Backup', () => { const BACKUP_FILE_PATH = 'cypress/fixtures/backup/specifications-backup.json';