From 6b41022fbea53e67aeb68cdb7be70dbea43d39e1 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Tue, 23 Jun 2026 23:13:51 -0400 Subject: [PATCH 01/27] KMS-686: Added support for sciencekeywords, platforms and instruments --- serverless/src/shared/Iso19115DomEditor.js | 151 +++++++++ .../src/shared/Iso19115MetadataPathEditor.js | 140 ++++++++ .../src/shared/XmlMetadataPathEditor.js | 60 +--- serverless/src/shared/XmlUtils.js | 79 +++++ .../src/shared/__tests__/XmlUtils.test.js | 73 ++++ .../applyIso19115MetadataCorrections.test.js | 316 ++++++++++++++++++ .../metadataCorrectionDelegateStubs.test.js | 55 ++- .../applyIso19115MetadataCorrections.js | 58 +++- 8 files changed, 851 insertions(+), 81 deletions(-) create mode 100644 serverless/src/shared/Iso19115DomEditor.js create mode 100644 serverless/src/shared/Iso19115MetadataPathEditor.js create mode 100644 serverless/src/shared/XmlUtils.js create mode 100644 serverless/src/shared/__tests__/XmlUtils.test.js create mode 100644 serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js new file mode 100644 index 00000000..c684c3b1 --- /dev/null +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -0,0 +1,151 @@ +import Iso19115MetadataPathEditor from './Iso19115MetadataPathEditor' +import { FULL_PATH_VALUE_FIELDS } from './redis-path-store/helpers/constants' + +/** + * Helper factory function to create a block editor configuration. + * Maps a correction to an update operation within the editor instance. + */ +const blockScheme = (config) => (editor, correction) => editor.updateBlockNode(correction, config) + +/** + * ISO 19115 scheme configuration. + * Defines how to identify, parse, and update specific keyword blocks (e.g., science keywords, platforms) + * within an ISO 19115 XML structure using XPath selectors and transformation logic. + */ +export const ISO_19115_SCHEME_EDITORS = { + sciencekeywords: blockScheme({ + // XPath to locate MD_Keywords nodes based on specific thesaurus title or type + nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords + [ + gmd:thesaurusName/gmd:CI_Citation/gmd:title/gco:CharacterString = 'NASA / GCMD Science Keywords' + or + gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = 'theme' + ]`, + find: { + fieldPaths: ['gco:CharacterString'], + valueKeys: FULL_PATH_VALUE_FIELDS.sciencekeywords, + // Parses the XML node into a structured object + getNodeValueObject: ({ node, editor, fieldPaths }) => { + const fullString = editor.getNestedText(node, fieldPaths[0]) || '' + // Splits hierarchical keywords (e.g., "Level 1 > Level 2") into array components + const parts = fullString.split(' > ').map((s) => s.trim()) + const fields = FULL_PATH_VALUE_FIELDS.sciencekeywords + // Map split parts to defined schema fields + const obj = fields.reduce((acc, field, index) => { + acc[field] = parts[index] || '' + + return acc + }, {}) + + obj.Value = fullString.trim()// Keep original full string as 'Value' + + return obj + } + }, + // Defines how to serialize the updated object back into the XML structure + replace: [ + { + fieldPath: 'gmd:keyword/gco:CharacterString', + source: { + type: 'computed', + getValue: ({ correction }) => { + const k = correction.newKeywordObject + const fields = FULL_PATH_VALUE_FIELDS.sciencekeywords + + // Reconstructs the hierarchical string from object fields, ignoring empty ones + return fields + .map((field) => k[field] || '') + .filter((v) => v.trim().length > 0) + .join(' > ') + } + } + } + ] + }), + + platforms: blockScheme({ + // XPath to locate MD_Keywords specific to platforms + nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords + [ + gmd:thesaurusName/gmd:CI_Citation/gmd:title/gco:CharacterString = 'NASA / GCMD Platform Keywords' + or + gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = 'platform' + ]`, + find: { + fieldPaths: ['gco:CharacterString'], + valueKeys: ['ShortName', 'LongName'], + // Parses platform string: "ShortName > LongName" + getNodeValueObject: ({ node, editor, fieldPaths }) => { + const fullString = editor.getNestedText(node, fieldPaths[0]) || '' + const [ShortName, ...longNameParts] = fullString.split(' > ') + + return { + ShortName: ShortName?.trim() || '', + LongName: longNameParts.join(' > ').trim() || '' + } + } + }, + // Reconstructs platform string for XML update + replace: [ + { + fieldPath: 'gmd:keyword/gco:CharacterString', + source: { + type: 'computed', + getValue: ({ correction }) => { + const { ShortName, LongName } = correction.newKeywordObject + + return `${ShortName} > ${LongName}` + } + } + } + ] + }), + + instruments: blockScheme({ + // XPath to locate MD_Keywords specific to instruments + nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords + [ + gmd:thesaurusName/gmd:CI_Citation/gmd:title/gco:CharacterString = 'NASA / GCMD Instrument Keywords' + or + gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = 'instrument' + ]`, + find: { + fieldPaths: ['gco:CharacterString'], + valueKeys: ['ShortName', 'LongName'], + // Parses instrument string: "ShortName > LongName" + getNodeValueObject: ({ node, editor, fieldPaths }) => { + const fullString = editor.getNestedText(node, fieldPaths[0]) || '' + const [ShortName, ...longNameParts] = fullString.split(' > ') + + return { + ShortName: ShortName?.trim() || '', + LongName: longNameParts.join(' > ').trim() || '' + } + } + }, + // Reconstructs instrument string for XML update + replace: [ + { + fieldPath: 'gmd:keyword/gco:CharacterString', + source: { + type: 'computed', + getValue: ({ correction }) => { + const { ShortName, LongName } = correction.newKeywordObject + + return `${ShortName} > ${LongName}` + } + } + } + ] + }) +} + +/** + * Creates a DOM-backed editor for a raw ISO 19115 XML payload. + * + * @param {string} payload Raw ISO 19115 XML string. + * @returns {Iso19115Editor} Specialized ISO 19115 XML path editor instance. + */ +export const createIso19115Editor = (payload) => new Iso19115MetadataPathEditor(payload) + +export default ISO_19115_SCHEME_EDITORS diff --git a/serverless/src/shared/Iso19115MetadataPathEditor.js b/serverless/src/shared/Iso19115MetadataPathEditor.js new file mode 100644 index 00000000..183814c5 --- /dev/null +++ b/serverless/src/shared/Iso19115MetadataPathEditor.js @@ -0,0 +1,140 @@ +import xpath from 'xpath' + +import XmlMetadataPathEditor from './XmlMetadataPathEditor' +import { + extractNamespaces, + getScalarKeywordText, + trimString +} from './XmlUtils' + +/** + * Subclass of XmlMetadataPathEditor specialized for ISO 19115 XML structure. + */ +export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { + constructor(xmlString) { + super(xmlString) + // Get the root element + const root = this.document.documentElement + + // Build the namespace map from the root's attributes + const namespaces = extractNamespaces(root) + + // Store for use in XPath resolution + this.namespaces = namespaces + const resolver = xpath.useNamespaces(namespaces) + this.resolver = resolver + } + + // In Iso19115Editor class + selectNodes(expression, contextNode = this.document) { + // Use the registered resolver instance + return this.resolver(expression, contextNode) + .filter((node) => node?.nodeType === 1) // ELEMENT_NODE + } + + updateBlockNode(correction, config) { + // 1. Find the parent block using your namespaced XPath + const targetNode = this.selectNodes(config.nodeXPath)[0] || null + if (!targetNode) return false + + // 2. Handle the 'delete' action + if (correction.action === 'delete') { + const oldVal = correction.oldKeywordObject.Value || correction.oldKeywordObject.ShortName + + // Find the specific node containing the string to delete + // We look for a CharacterString that contains the old value + // We use the block (targetNode) as the context to keep search scoped + const xPath = `.//gmd:keyword/gco:CharacterString[contains(., '${oldVal}')]` + const targetCharString = this.selectNodes(xPath, targetNode)[0] + + if (!targetCharString) return false + + // A. Remove the specific keyword entry + const keywordNode = targetCharString.parentNode + keywordNode.parentNode.removeChild(keywordNode) + + // B. Check if any keywords remain in the MD_Keywords block + const remainingKeywords = this.selectNodes('.//gmd:keyword', targetNode) + + if (remainingKeywords.length === 0) { + // C. If no keywords remain, remove the MD_Keywords block + const mdKeywordsParent = targetNode.parentNode + mdKeywordsParent.removeChild(targetNode) + + // D. Check if the gmd:descriptiveKeywords wrapper is now empty + const remainingBlocks = this.selectNodes('./gmd:MD_Keywords', mdKeywordsParent) + if (remainingBlocks.length === 0) { + mdKeywordsParent.parentNode.removeChild(mdKeywordsParent) + } + } + + return true + } + + // 3. Handle the 'replace' action + if (correction.action === 'replace') { + const replaceConfig = config.replace[0] + + // 1. Get all keyword nodes in this block + const keywordNodes = this.selectNodes('./gmd:keyword', targetNode) + + const matchingNode = keywordNodes.find((node) => { + const parsedObject = config.find.getNodeValueObject({ + node, + editor: this, + fieldPaths: config.find.fieldPaths + }) + + // Compare ALL keys present in the correction.oldKeywordObject + // This works for { Value: '...' } AND { ShortName: '...' } + return Object.keys(correction.oldKeywordObject).every((key) => { + const parsedValue = parsedObject[key] ? trimString(parsedObject[key]) : '' + const correctionValue = correction.oldKeywordObject[key] ? trimString(correction.oldKeywordObject[key]) : '' + + return parsedValue === correctionValue + }) + }) + + // In Iso19115MetadataPathEditor.js + + if (matchingNode) { + // Since matchingNode is already the element, + // we look for the child directly. + const fieldNode = this.selectNodes('./gco:CharacterString', matchingNode)[0] + || this.selectNodes('gco:CharacterString', matchingNode)[0] + + if (fieldNode) { + const newValue = replaceConfig.source.getValue({ correction }) + this.setElementText(fieldNode, newValue) + + return true + } + } + + return false // Match not found or fieldNode missing + } + + return false + } + + /** + * Override updateLeafNode to handle gco:CharacterString wrapping. + * ISO 19115 frequently stores text values inside nodes. + */ + updateLeafNode(correction, config) { + const targetNode = this.selectNodes(config.nodeXPath)[0] || null + if (!targetNode) return false + + // Target the specific gco:CharacterString child + const charString = targetNode.getElementsByTagNameNS(this.namespaces.gco, 'CharacterString')[0] + if (charString) { + const value = getScalarKeywordText(correction?.newKeywordObject) + this.setElementText(charString, value) + + return true + } + + return false + } +} +export default Iso19115MetadataPathEditor diff --git a/serverless/src/shared/XmlMetadataPathEditor.js b/serverless/src/shared/XmlMetadataPathEditor.js index bc0b2dcc..56321a3f 100644 --- a/serverless/src/shared/XmlMetadataPathEditor.js +++ b/serverless/src/shared/XmlMetadataPathEditor.js @@ -1,38 +1,15 @@ import { DOMParser, XMLSerializer } from '@xmldom/xmldom' import xpath from 'xpath' +import { + getScalarKeywordText, + isSimpleFieldPath, + normalizeValueObject, + trimString +} from './XmlUtils' + const XML_DECLARATION = '' const ELEMENT_NODE = 1 -const SIMPLE_ABSOLUTE_FIELD_PATH = /^\/\/[A-Za-z_][\w.-]*(\/[A-Za-z_][\w.-]*)*$/ - -/** - * Normalize optional text inputs so object comparisons and XML writes behave consistently. - * - * @example - * trimString(' SPOT-4 ') - * // 'SPOT-4' - */ -const trimString = (value) => ((typeof value === 'string') ? value.trim() : '') - -/** - * Trims a keyword-style object down to the specific keys being compared so find/replace matching - * stays consistent even when callers provide extra fields or untrimmed values. - * - * @param {Record|null|undefined} valueObject Candidate keyword-style object. - * @param {string[]} valueKeys Ordered keys that matter for the current comparison. - * @returns {Record} Trimmed object containing only the requested keys. - * - * @example - * normalizeValueObject({ Type: ' GET DATA ', Subtype: ' GIOVANNI ' }, ['Type', 'Subtype']) - * // { Type: 'GET DATA', Subtype: 'GIOVANNI' } - */ -const normalizeValueObject = (valueObject, valueKeys) => valueKeys.reduce( - (normalizedObject, valueKey) => ({ - ...normalizedObject, - [valueKey]: trimString(valueObject?.[valueKey]) - }), - {} -) /** * Normalizes text for case-insensitive XML node matching while preserving write-time casing. @@ -57,27 +34,6 @@ const normalizeComparableText = (value) => trimString(value).toLowerCase() export const hasAnyObjectValue = (keywordObject) => Object.values(keywordObject || {}) .some((value) => trimString(value).length > 0) -/** - * Resolves the most useful scalar representation of a keyword object for leaf/scalar updates. - * - * @example - * getScalarKeywordText({ Value: 'OCEANS', ShortName: 'ignored' }) - * // 'OCEANS' - */ -const getScalarKeywordText = (keywordObject = {}) => { - const preferredValue = [keywordObject.Value, keywordObject.ShortName] - .map((value) => trimString(value)) - .find((value) => value.length > 0) - - if (preferredValue) { - return preferredValue - } - - return Object.values(keywordObject) - .map((value) => trimString(value)) - .find((value) => value.length > 0) || '' -} - /** * Rewrites simple element XPath into a namespace-agnostic `local-name()` form. * @@ -296,7 +252,7 @@ export class XmlMetadataPathEditor { */ resolveAbsoluteFieldElement(fieldPath, { createIfMissing = false } = {}) { const matchedNode = this.selectNodes(fieldPath)[0] || null - if (matchedNode || !createIfMissing || !SIMPLE_ABSOLUTE_FIELD_PATH.test(fieldPath)) { + if (matchedNode || !createIfMissing || !isSimpleFieldPath(fieldPath)) { return matchedNode } diff --git a/serverless/src/shared/XmlUtils.js b/serverless/src/shared/XmlUtils.js new file mode 100644 index 00000000..b2d907ed --- /dev/null +++ b/serverless/src/shared/XmlUtils.js @@ -0,0 +1,79 @@ +/** + * Normalize optional text inputs so object comparisons and XML writes behave consistently. + */ +export const trimString = (value) => ((typeof value === 'string') ? value.trim() : '') + +/** + * Regex for validating if a string is a simple absolute XML field path (e.g., //Path/To/Node). + */ +export const SIMPLE_ABSOLUTE_FIELD_PATH = /^\/\/[A-Za-z_][\w.-]*(\/[A-Za-z_][\w.-]*)*$/ + +/** + * Validates if the provided string is a valid simple absolute XML field path. + * * @param {string} path - The path string to validate. + * @returns {boolean} True if the path matches the expected pattern. + */ +export const isSimpleFieldPath = (path) => SIMPLE_ABSOLUTE_FIELD_PATH.test(path) + +/** + * Resolves the most useful scalar representation of a keyword object for leaf/scalar updates. + * This is now the single source of truth for both ISO 19115 and generic XML editors. + * + * @example + * getScalarKeywordText({ Value: 'OCEANS', ShortName: 'ignored' }) + * // 'OCEANS' + */ +export const getScalarKeywordText = (keywordObject = {}) => { + // 1. Define preference list (Value, then ShortName) + const preferredValue = [keywordObject.Value, keywordObject.ShortName] + .map((value) => trimString(value)) + .find((value) => value.length > 0) + + // 2. Return if found + if (preferredValue) { + return preferredValue + } + + // 3. Fallback: return the first non-empty value in the object + return Object.values(keywordObject) + .map((value) => trimString(value)) + .find((value) => value.length > 0) || '' +} + +/** + * Trims a keyword-style object down to the specific keys being compared so find/replace matching + * stays consistent even when callers provide extra fields or untrimmed values. + * + * @param {Record|null|undefined} valueObject Candidate keyword-style object. + * @param {string[]} valueKeys Ordered keys that matter for the current comparison. + * @returns {Record} Trimmed object containing only the requested keys. + * + * @example + * normalizeValueObject({ Type: ' GET DATA ', Subtype: ' GIOVANNI ' }, ['Type', 'Subtype']) + * // { Type: 'GET DATA', Subtype: 'GIOVANNI' } + */ +export const normalizeValueObject = (valueObject, valueKeys) => valueKeys.reduce( + (normalizedObject, valueKey) => ({ + ...normalizedObject, + [valueKey]: trimString(valueObject?.[valueKey]) + }), + {} +) + +/** + * Extracts namespace declarations (xmlns:prefix) from a given XML root element. + * * @param {Element} rootElement - The DOM root element. + * @returns {Object} A map where keys are prefixes and values are namespace URIs. + */ +export const extractNamespaces = (rootElement) => { + const namespaces = {} + for (let i = 0; i < rootElement.attributes.length; i += 1) { + const attr = rootElement.attributes[i] + if (attr.name.startsWith('xmlns:')) { + const prefix = attr.name.replace('xmlns:', '') + namespaces[prefix] = attr.value + } + } + + return namespaces +} diff --git a/serverless/src/shared/__tests__/XmlUtils.test.js b/serverless/src/shared/__tests__/XmlUtils.test.js new file mode 100644 index 00000000..6ca183d4 --- /dev/null +++ b/serverless/src/shared/__tests__/XmlUtils.test.js @@ -0,0 +1,73 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { + getScalarKeywordText, + isSimpleFieldPath, + normalizeValueObject, + trimString +} from '../XmlUtils' + +describe('XmlUtils', () => { + describe('trimString', () => { + test('trims whitespace from strings', () => { + expect(trimString(' hello ')).toBe('hello') + }) + + test('returns empty string for non-string inputs', () => { + expect(trimString(null)).toBe('') + expect(trimString(undefined)).toBe('') + expect(trimString(123)).toBe('') + }) + }) + + describe('getScalarKeywordText', () => { + test('prioritizes Value over ShortName', () => { + const obj = { + Value: ' Science ', + ShortName: 'Sci' + } + expect(getScalarKeywordText(obj)).toBe('Science') + }) + + test('falls back to ShortName if Value is missing', () => { + const obj = { ShortName: ' Physics ' } + expect(getScalarKeywordText(obj)).toBe('Physics') + }) + + test('falls back to any available property if Value/ShortName are missing', () => { + const obj = { Category: ' Oceanography ' } + expect(getScalarKeywordText(obj)).toBe('Oceanography') + }) + }) + + describe('normalizeValueObject', () => { + test('extracts only requested keys and trims them', () => { + const input = { + Type: ' A ', + Subtype: ' B ', + Extra: 'C' + } + const keys = ['Type', 'Subtype'] + expect(normalizeValueObject(input, keys)).toEqual({ + Type: 'A', + Subtype: 'B' + }) + }) + }) + + describe('isSimpleFieldPath', () => { + test('validates simple absolute paths', () => { + expect(isSimpleFieldPath('//DIF/Product_Level_Id')).toBe(true) + expect(isSimpleFieldPath('//Root/Child/GrandChild')).toBe(true) + }) + + test('rejects invalid paths', () => { + expect(isSimpleFieldPath('Root/Child')).toBe(false) // No // + expect(isSimpleFieldPath('//123/Node')).toBe(false) // Invalid start + }) + }) +}) diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js new file mode 100644 index 00000000..3b7144d9 --- /dev/null +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -0,0 +1,316 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { ISO_19115_SCHEME_EDITORS } from '../Iso19115DomEditor' +import Iso19115MetadataPathEditor from '../Iso19115MetadataPathEditor' + +const mockIso19115 = ` + + + + + EARTH SCIENCE > Cryosphere > Glaciers/Ice Sheets > Firn > Snow Grain Size + + + EARTH SCIENCE > ATMOSPHERE > AEROSOLS + + + EARTH SCIENCE > Cryosphere > Glaciers/Ice Sheets > Glacier Topography/Ice Sheet Topography > Surface Morphology + + + EARTH SCIENCE > Cryosphere > Glaciers/Ice Sheets > Glacier Topography/Ice Sheet Topography > Surface Morphology + + + theme + + + + + NASA / GCMD Science Keywords + + + + + 2008-02-05 + + + revision + + + + + + + + + + + TERRA > Earth Observing System, TERRA (AM-1) + + + AQUA > Earth Observing System + + + platform + + + + + NASA / GCMD Platform Keywords + + + + + 2016-06-10 + + + revision + + + + + + + + + + + ATLAS > Advanced Topographic Laser Altimeter System + + + MODIS > Moderate-Resolution Imaging Spectroradiometer + + + instrument + + + + + NASA / GCMD Instrument Keywords + + + + + 2016-06-01 + + + revision + + + + + + + +` + +const mockIso19115WithOneScienceKeyword = ` + + + + + EARTH SCIENCE > ATMOSPHERE > AEROSOLS + + + theme + + + + + NASA / GCMD Science Keywords + + + + + 2008-02-05 + + + revision + + + + + + + +` + +describe('when applying sciencekeywords ISO-19115 corrections', () => { + test('should replace existing science keyword correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + const correction = { + scheme: 'sciencekeywords', + action: 'replace', + oldKeywordObject: { Value: 'EARTH SCIENCE > ATMOSPHERE > AEROSOLS' }, + newKeywordObject: { + Category: 'EARTH SCIENCE', + Topic: 'OCEANS', + Term: 'MARINE SEDIMENTS', + VariableLevel1: '', + VariableLevel2: '', + VariableLevel3: '', + DetailedVariable: '' + } + } + + const config = ISO_19115_SCHEME_EDITORS.sciencekeywords + const success = config(editor, correction) + + expect(success).toBe(true) + + // Verify the XML was updated + const updatedXml = editor.serialize() + expect(updatedXml).toContain('EARTH SCIENCE > OCEANS > MARINE SEDIMENTS') + expect(updatedXml).not.toContain('AEROSOLS') + }) + + test('should delete existing science keyword block correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + const correction = { + scheme: 'sciencekeywords', + action: 'delete', + oldKeywordObject: { Value: 'EARTH SCIENCE > ATMOSPHERE > AEROSOLS' } + } + + const config = ISO_19115_SCHEME_EDITORS.sciencekeywords + + // Note: You may need to add an updateBlockNode implementation for 'delete' + // in iso19115DomEditor.js similar to the one shown below. + const success = config(editor, correction) + + expect(success).toBe(true) + + // Verify the XML no longer contains the MD_Keywords block + const updatedXml = editor.serialize() + expect(updatedXml).not.toContain('EARTH SCIENCE > ATMOSPHERE > AEROSOLS') + }) + + test('should delete single existing science keyword block correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithOneScienceKeyword) + const correction = { + scheme: 'sciencekeywords', + action: 'delete', + oldKeywordObject: { Value: 'EARTH SCIENCE > ATMOSPHERE > AEROSOLS' } + } + + const config = ISO_19115_SCHEME_EDITORS.sciencekeywords + + // Note: You may need to add an updateBlockNode implementation for 'delete' + // in iso19115DomEditor.js similar to the one shown below. + const success = config(editor, correction) + + expect(success).toBe(true) + + // Verify the XML no longer contains the MD_Keywords block + const updatedXml = editor.serialize() + expect(updatedXml).not.toContain('EARTH SCIENCE > ATMOSPHERE > AEROSOLS') + expect(updatedXml).not.toContain('') + }) +}) + +describe('when applying platforms ISO-19115 corrections', () => { + test('should replace existing platform keyword correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + + // Example: Replace "AQUA > Earth Observing System" + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { + ShortName: 'AQUA', + LongName: 'New Platform Description' + } + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + expect(updatedXml).toContain('AQUA > New Platform Description') + expect(updatedXml).not.toContain('AQUA > Earth Observing System') + }) + + test('should delete existing platform keyword block correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + const correction = { + scheme: 'platforms', + action: 'delete', + oldKeywordObject: { ShortName: 'AQUA' } + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + // Verify the specific keyword is gone + const updatedXml = editor.serialize() + expect(updatedXml).not.toContain('AQUA > Earth Observing System, AQUA') + expect(updatedXml).toContain('TERRA > Earth Observing System, TERRA (AM-1)') + }) +}) + +describe('when applying instruments ISO-19115 corrections', () => { + test('should replace existing instrument keyword correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + + // Example: Replace "AQUA > Earth Observing System" + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'MODIS' }, + newKeywordObject: { + ShortName: 'MODIS-1', + LongName: 'New Instrument Description' + } + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + expect(updatedXml).toContain('MODIS-1 > New Instrument Description') + expect(updatedXml).not.toContain('MODIS > Moderate-Resolution Imaging Spectroradiometer') + }) + + test('should delete existing instrument keyword block correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + const correction = { + scheme: 'instruments', + action: 'delete', + oldKeywordObject: { ShortName: 'ATLAS' } + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + // Verify the specific keyword is gone + const updatedXml = editor.serialize() + expect(updatedXml).not.toContain('ATLAS > Advanced Topographic Laser Altimeter System') + expect(updatedXml).toContain('MODIS > Moderate-Resolution Imaging Spectroradiometer') + }) +}) diff --git a/serverless/src/shared/__tests__/metadataCorrectionDelegateStubs.test.js b/serverless/src/shared/__tests__/metadataCorrectionDelegateStubs.test.js index 3d96a80e..fee433be 100644 --- a/serverless/src/shared/__tests__/metadataCorrectionDelegateStubs.test.js +++ b/serverless/src/shared/__tests__/metadataCorrectionDelegateStubs.test.js @@ -177,22 +177,53 @@ describe('metadata correction delegate stubs', () => { }) }) - test('returns the expected ISO19115 delegate stub shape', async () => { - await expect(applyIso19115MetadataCorrections({ - collectionConceptId: 'C2', - providerId: 'PROV', - nativeId: 'native-2' - })).resolves.toEqual({ - nativeFormat: 'ISO19115', - delegateName: 'iso19115', + test('returns the expected ISO19115 payload shape when corrections are provided', async () => { + const mockPayload = ` + + + + + EARTH SCIENCE > ATMOSPHERE > AEROSOLS + + + theme + + + + + NASA / GCMD Science Keywords + + + + + + ` + + const correction = { + scheme: 'sciencekeywords', + action: 'replace', + oldKeywordObject: { Value: 'EARTH SCIENCE > ATMOSPHERE > AEROSOLS' }, + newKeywordObject: { + Category: 'EARTH SCIENCE', + Topic: 'ATMOSPHERE', + Term: 'AEROSOLS', + VariableLevel1: 'NEW AEROSOLS' + } + } + + const result = await applyIso19115MetadataCorrections({ collectionConceptId: 'C2', providerId: 'PROV', nativeId: 'native-2', - correctionCount: 0, - correctedMetadata: undefined, - correctionsApplied: [], - stubbed: true + nativeFormat: 'ISO19115', + metadataPayload: mockPayload, + corrections: [correction] }) + + expect(result.correctionCount).toBe(1) + expect(result.stubbed).toBe(false) + expect(result.correctedMetadata).toContain('NEW AEROSOLS') + expect(result.correctionsApplied).toEqual([correction]) }) test('returns the expected ISO_SMAP delegate stub shape', async () => { diff --git a/serverless/src/shared/applyIso19115MetadataCorrections.js b/serverless/src/shared/applyIso19115MetadataCorrections.js index 3c2d4a00..fc2be3dd 100644 --- a/serverless/src/shared/applyIso19115MetadataCorrections.js +++ b/serverless/src/shared/applyIso19115MetadataCorrections.js @@ -1,25 +1,49 @@ +import { createIso19115Editor, ISO_19115_SCHEME_EDITORS } from './Iso19115DomEditor' /** * Stub ISO 19115 delegate for KMS-675. * * Real ISO 19115 mutation is follow-on work. For now this delegate only records the handoff * shape. */ -export const applyIso19115MetadataCorrections = async ({ - collectionConceptId, - providerId, - nativeId, - metadataPayload, - corrections = [] -}) => ({ - nativeFormat: 'ISO19115', - delegateName: 'iso19115', - collectionConceptId, - providerId, - nativeId, - correctionCount: corrections.length, - correctedMetadata: metadataPayload, - correctionsApplied: corrections, - stubbed: true -}) +export const applyIso19115MetadataCorrections = async (params) => { + const { + metadataPayload, + corrections = [] + } = params + + if (!metadataPayload) { + return { + correctionCount: 0, + correctedMetadata: undefined, + correctionsApplied: [], + stubbed: false + } + } + + const editor = createIso19115Editor(metadataPayload) + const applied = corrections.reduce((acc, correction) => { + const scheme = String(correction.scheme || '').toLowerCase() + const delegate = ISO_19115_SCHEME_EDITORS[scheme] + + if (!delegate) { + return acc + } + + const isUpdated = delegate(editor, correction) + if (isUpdated) { + acc.push(correction) + } + + return acc + }, []) + + return { + ...params, + correctionCount: applied.length, + correctedMetadata: editor.serialize(), + correctionsApplied: applied, + stubbed: false + } +} export default applyIso19115MetadataCorrections From 8b1db2dba04c646f1c15aa002490c9f891ad7a0e Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Wed, 24 Jun 2026 10:56:27 -0400 Subject: [PATCH 02/27] KMS-686: Fix LongName for instruments and platforms --- serverless/src/shared/Iso19115DomEditor.js | 6 ++++-- .../applyIso19115MetadataCorrections.test.js | 12 ++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index c684c3b1..2b432252 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -92,7 +92,8 @@ export const ISO_19115_SCHEME_EDITORS = { source: { type: 'computed', getValue: ({ correction }) => { - const { ShortName, LongName } = correction.newKeywordObject + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName return `${ShortName} > ${LongName}` } @@ -130,7 +131,8 @@ export const ISO_19115_SCHEME_EDITORS = { source: { type: 'computed', getValue: ({ correction }) => { - const { ShortName, LongName } = correction.newKeywordObject + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName return `${ShortName} > ${LongName}` } diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js index 3b7144d9..1f65cc8d 100644 --- a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -235,9 +235,9 @@ describe('when applying platforms ISO-19115 corrections', () => { action: 'replace', oldKeywordObject: { ShortName: 'AQUA' }, newKeywordObject: { - ShortName: 'AQUA', - LongName: 'New Platform Description' - } + ShortName: 'AQUA' + }, + newLongName: 'New Platform Description' } const config = ISO_19115_SCHEME_EDITORS.platforms @@ -280,9 +280,9 @@ describe('when applying instruments ISO-19115 corrections', () => { action: 'replace', oldKeywordObject: { ShortName: 'MODIS' }, newKeywordObject: { - ShortName: 'MODIS-1', - LongName: 'New Instrument Description' - } + ShortName: 'MODIS-1' + }, + newLongName: 'New Instrument Description' } const config = ISO_19115_SCHEME_EDITORS.instruments From d85cd3e1b42c919798871100ae3cb2a5f4f53aea Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Wed, 24 Jun 2026 13:52:23 -0400 Subject: [PATCH 03/27] KMS-686: Add support for projects and locations --- serverless/src/shared/Iso19115DomEditor.js | 215 ++++++++---------- .../src/shared/Iso19115MetadataPathEditor.js | 28 ++- .../applyIso19115MetadataCorrections.test.js | 171 +++++++++++++- 3 files changed, 272 insertions(+), 142 deletions(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index 2b432252..531c7d30 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -8,138 +8,117 @@ import { FULL_PATH_VALUE_FIELDS } from './redis-path-store/helpers/constants' const blockScheme = (config) => (editor, correction) => editor.updateBlockNode(correction, config) /** - * ISO 19115 scheme configuration. - * Defines how to identify, parse, and update specific keyword blocks (e.g., science keywords, platforms) - * within an ISO 19115 XML structure using XPath selectors and transformation logic. + * Factory to generate hierarchical keyword block editors + * (like Science Keywords and Locations). */ -export const ISO_19115_SCHEME_EDITORS = { - sciencekeywords: blockScheme({ - // XPath to locate MD_Keywords nodes based on specific thesaurus title or type - nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords +const createHierarchicalKeywordBlock = (thesaurusTitle, keywordTypeCode, fieldKey) => blockScheme({ + nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords [ - gmd:thesaurusName/gmd:CI_Citation/gmd:title/gco:CharacterString = 'NASA / GCMD Science Keywords' + gmd:thesaurusName/gmd:CI_Citation/gmd:title/gco:CharacterString = '${thesaurusTitle}' or - gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = 'theme' + gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${keywordTypeCode}' ]`, - find: { - fieldPaths: ['gco:CharacterString'], - valueKeys: FULL_PATH_VALUE_FIELDS.sciencekeywords, - // Parses the XML node into a structured object - getNodeValueObject: ({ node, editor, fieldPaths }) => { - const fullString = editor.getNestedText(node, fieldPaths[0]) || '' - // Splits hierarchical keywords (e.g., "Level 1 > Level 2") into array components - const parts = fullString.split(' > ').map((s) => s.trim()) - const fields = FULL_PATH_VALUE_FIELDS.sciencekeywords - // Map split parts to defined schema fields - const obj = fields.reduce((acc, field, index) => { - acc[field] = parts[index] || '' - - return acc - }, {}) - - obj.Value = fullString.trim()// Keep original full string as 'Value' - - return obj - } - }, - // Defines how to serialize the updated object back into the XML structure - replace: [ - { - fieldPath: 'gmd:keyword/gco:CharacterString', - source: { - type: 'computed', - getValue: ({ correction }) => { - const k = correction.newKeywordObject - const fields = FULL_PATH_VALUE_FIELDS.sciencekeywords - - // Reconstructs the hierarchical string from object fields, ignoring empty ones - return fields - .map((field) => k[field] || '') - .filter((v) => v.trim().length > 0) - .join(' > ') - } - } - } - ] - }), + find: { + fieldPaths: ['gco:CharacterString'], + valueKeys: FULL_PATH_VALUE_FIELDS[fieldKey], + getNodeValueObject: ({ node, editor, fieldPaths }) => { + const fullString = editor.getNestedText(node, fieldPaths[0]) || '' + const parts = fullString.split(' > ').map((s) => s.trim()) + const fields = FULL_PATH_VALUE_FIELDS[fieldKey] - platforms: blockScheme({ - // XPath to locate MD_Keywords specific to platforms - nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords - [ - gmd:thesaurusName/gmd:CI_Citation/gmd:title/gco:CharacterString = 'NASA / GCMD Platform Keywords' - or - gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = 'platform' - ]`, - find: { - fieldPaths: ['gco:CharacterString'], - valueKeys: ['ShortName', 'LongName'], - // Parses platform string: "ShortName > LongName" - getNodeValueObject: ({ node, editor, fieldPaths }) => { - const fullString = editor.getNestedText(node, fieldPaths[0]) || '' - const [ShortName, ...longNameParts] = fullString.split(' > ') - - return { - ShortName: ShortName?.trim() || '', - LongName: longNameParts.join(' > ').trim() || '' - } - } - }, - // Reconstructs platform string for XML update - replace: [ - { - fieldPath: 'gmd:keyword/gco:CharacterString', - source: { - type: 'computed', - getValue: ({ correction }) => { - const { ShortName } = correction.newKeywordObject - const LongName = correction.newLongName - - return `${ShortName} > ${LongName}` - } + const obj = fields.reduce((acc, field, index) => { + acc[field] = parts[index] || '' + + return acc + }, {}) + + obj.Value = fullString.trim() + + return obj + } + }, + replace: [ + { + fieldPath: 'gmd:keyword/gco:CharacterString', + source: { + type: 'computed', + getValue: ({ correction }) => { + const k = correction.newKeywordObject + const fields = FULL_PATH_VALUE_FIELDS[fieldKey] + + return fields + .map((field) => k[field] || '') + .filter((v) => v.trim().length > 0) + .join(' > ') } } - ] - }), + } + ] +}) - instruments: blockScheme({ - // XPath to locate MD_Keywords specific to instruments - nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords +/** + * Factory to generate standardized keyword block editors for structures + * like Platforms and Instruments. + */ +const createKeywordBlock = (thesaurusTitle, keywordTypeCode) => blockScheme({ + nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords [ - gmd:thesaurusName/gmd:CI_Citation/gmd:title/gco:CharacterString = 'NASA / GCMD Instrument Keywords' + gmd:thesaurusName/gmd:CI_Citation/gmd:title/gco:CharacterString = '${thesaurusTitle}' or - gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = 'instrument' + gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${keywordTypeCode}' ]`, - find: { - fieldPaths: ['gco:CharacterString'], - valueKeys: ['ShortName', 'LongName'], - // Parses instrument string: "ShortName > LongName" - getNodeValueObject: ({ node, editor, fieldPaths }) => { - const fullString = editor.getNestedText(node, fieldPaths[0]) || '' - const [ShortName, ...longNameParts] = fullString.split(' > ') - - return { - ShortName: ShortName?.trim() || '', - LongName: longNameParts.join(' > ').trim() || '' - } + find: { + fieldPaths: ['gco:CharacterString'], + valueKeys: ['ShortName', 'LongName'], + getNodeValueObject: ({ node, editor, fieldPaths }) => { + const fullString = editor.getNestedText(node, fieldPaths[0]) || '' + const [ShortName, ...longNameParts] = fullString.split(' > ') + + return { + ShortName: ShortName?.trim() || '', + LongName: longNameParts.join(' > ').trim() || '' } - }, - // Reconstructs instrument string for XML update - replace: [ - { - fieldPath: 'gmd:keyword/gco:CharacterString', - source: { - type: 'computed', - getValue: ({ correction }) => { - const { ShortName } = correction.newKeywordObject - const LongName = correction.newLongName - - return `${ShortName} > ${LongName}` - } + } + }, + replace: [ + { + fieldPath: 'gmd:keyword/gco:CharacterString', + source: { + type: 'computed', + getValue: ({ correction }) => { + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName + + return `${ShortName} > ${LongName}` } } - ] - }) + } + ] +}) + +/** + * ISO 19115 scheme configuration. + * Defines how to identify, parse, and update specific keyword blocks (e.g., science keywords, platforms) + * within an ISO 19115 XML structure using XPath selectors and transformation logic. + */ +export const ISO_19115_SCHEME_EDITORS = { + sciencekeywords: createHierarchicalKeywordBlock( + 'NASA / GCMD Science Keywords', + 'theme', + 'sciencekeywords' + ), + + locations: createHierarchicalKeywordBlock( + 'NASA / GCMD Location Keywords', + 'place', + 'locations' + ), + + platforms: createKeywordBlock('NASA / GCMD Platform Keywords', 'platform'), + + instruments: createKeywordBlock('NASA / GCMD Instrument Keywords', 'instrument'), + + projects: createKeywordBlock('NASA / GCMD Project Keywords', 'project') } /** diff --git a/serverless/src/shared/Iso19115MetadataPathEditor.js b/serverless/src/shared/Iso19115MetadataPathEditor.js index 183814c5..b6062e15 100644 --- a/serverless/src/shared/Iso19115MetadataPathEditor.js +++ b/serverless/src/shared/Iso19115MetadataPathEditor.js @@ -39,29 +39,33 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { // 2. Handle the 'delete' action if (correction.action === 'delete') { - const oldVal = correction.oldKeywordObject.Value || correction.oldKeywordObject.ShortName + // Normalize search string to lowercase + const oldVal = (correction.oldKeywordObject.Value || correction.oldKeywordObject.ShortName || '').toLowerCase().trim() - // Find the specific node containing the string to delete - // We look for a CharacterString that contains the old value - // We use the block (targetNode) as the context to keep search scoped - const xPath = `.//gmd:keyword/gco:CharacterString[contains(., '${oldVal}')]` - const targetCharString = this.selectNodes(xPath, targetNode)[0] + // 1. Get all potential CharacterString nodes within the block + const allCharStrings = this.selectNodes('.//gmd:keyword/gco:CharacterString', targetNode) + + // 2. Find the node using a case-insensitive partial match (.includes) + const targetCharString = allCharStrings.find((node) => { + const textValue = (node.textContent || '').toLowerCase().trim() + + return textValue.includes(oldVal) + }) if (!targetCharString) return false - // A. Remove the specific keyword entry + // 3. Remove the specific keyword entry const keywordNode = targetCharString.parentNode keywordNode.parentNode.removeChild(keywordNode) - // B. Check if any keywords remain in the MD_Keywords block + // 4. Check if any keywords remain in the MD_Keywords block const remainingKeywords = this.selectNodes('.//gmd:keyword', targetNode) if (remainingKeywords.length === 0) { - // C. If no keywords remain, remove the MD_Keywords block + // ... (rest of your cleanup logic remains exactly the same) const mdKeywordsParent = targetNode.parentNode mdKeywordsParent.removeChild(targetNode) - // D. Check if the gmd:descriptiveKeywords wrapper is now empty const remainingBlocks = this.selectNodes('./gmd:MD_Keywords', mdKeywordsParent) if (remainingBlocks.length === 0) { mdKeywordsParent.parentNode.removeChild(mdKeywordsParent) @@ -88,8 +92,8 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { // Compare ALL keys present in the correction.oldKeywordObject // This works for { Value: '...' } AND { ShortName: '...' } return Object.keys(correction.oldKeywordObject).every((key) => { - const parsedValue = parsedObject[key] ? trimString(parsedObject[key]) : '' - const correctionValue = correction.oldKeywordObject[key] ? trimString(correction.oldKeywordObject[key]) : '' + const parsedValue = parsedObject[key] ? trimString(parsedObject[key]).toLowerCase() : '' + const correctionValue = correction.oldKeywordObject[key] ? trimString(correction.oldKeywordObject[key]).toLowerCase() : '' return parsedValue === correctionValue }) diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js index 1f65cc8d..a7ca6599 100644 --- a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -112,6 +112,66 @@ const mockIso19115 = ` + + + + MEASURES > Making Earth System Data Records for Use in Research Environments + + + MAGIA > Structure, Stratigraphy, and Sedimentology North of the Antarctic Peninsula + + + project + + + + + NASA / GCMD Project Keywords + + + + + 2008-01-24 + + + revision + + + + + + + + + + + Continent > North America > Greenland + + + Continent > North America > Canada > Alberta + + + place + + + + + NASA / GCMD Location Keywords + + + + + 2008-02-05 + + + revision + + + + + + + ` const mockIso19115WithOneScienceKeyword = ` @@ -163,7 +223,7 @@ describe('when applying sciencekeywords ISO-19115 corrections', () => { Category: 'EARTH SCIENCE', Topic: 'OCEANS', Term: 'MARINE SEDIMENTS', - VariableLevel1: '', + VariableLevel1: 'PARTICLE SIZE', VariableLevel2: '', VariableLevel3: '', DetailedVariable: '' @@ -177,8 +237,8 @@ describe('when applying sciencekeywords ISO-19115 corrections', () => { // Verify the XML was updated const updatedXml = editor.serialize() - expect(updatedXml).toContain('EARTH SCIENCE > OCEANS > MARINE SEDIMENTS') - expect(updatedXml).not.toContain('AEROSOLS') + expect(updatedXml).toContain('EARTH SCIENCE > OCEANS > MARINE SEDIMENTS > PARTICLE SIZE') + expect(updatedXml).not.toContain('EARTH SCIENCE > ATMOSPHERE > AEROSOLS') }) test('should delete existing science keyword block correctly', () => { @@ -191,15 +251,13 @@ describe('when applying sciencekeywords ISO-19115 corrections', () => { const config = ISO_19115_SCHEME_EDITORS.sciencekeywords - // Note: You may need to add an updateBlockNode implementation for 'delete' - // in iso19115DomEditor.js similar to the one shown below. const success = config(editor, correction) expect(success).toBe(true) // Verify the XML no longer contains the MD_Keywords block const updatedXml = editor.serialize() - expect(updatedXml).not.toContain('EARTH SCIENCE > ATMOSPHERE > AEROSOLS') + expect(updatedXml).not.toContain('EARTH SCIENCE > ATMOSPHERE > AEROSOLS') }) test('should delete single existing science keyword block correctly', () => { @@ -212,8 +270,6 @@ describe('when applying sciencekeywords ISO-19115 corrections', () => { const config = ISO_19115_SCHEME_EDITORS.sciencekeywords - // Note: You may need to add an updateBlockNode implementation for 'delete' - // in iso19115DomEditor.js similar to the one shown below. const success = config(editor, correction) expect(success).toBe(true) @@ -225,11 +281,58 @@ describe('when applying sciencekeywords ISO-19115 corrections', () => { }) }) +describe('when applying locations ISO-19115 corrections', () => { + test('should replace existing location correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + const correction = { + scheme: 'locations', + action: 'replace', + oldKeywordObject: { Value: 'CONTINENT > NORTH AMERICA > CANADA > ALBERTA' }, + newKeywordObject: { + Category: 'CONTINENT', + Type: 'NORTH AMERICA', + Subregion1: 'MEXICO', + Subregion2: '', + Subregion3: '', + DetailedLocation: '' + } + } + + const config = ISO_19115_SCHEME_EDITORS.locations + const success = config(editor, correction) + + expect(success).toBe(true) + + // Verify the XML was updated + const updatedXml = editor.serialize() + expect(updatedXml).toContain('CONTINENT > NORTH AMERICA > MEXICO') + expect(updatedXml).not.toContain('Continent > North America > Canada > Alberta') + }) + + test('should delete existing locations block correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + const correction = { + scheme: 'locations', + action: 'delete', + oldKeywordObject: { Value: 'Continent > North America > Greenland' } + } + + const config = ISO_19115_SCHEME_EDITORS.locations + + const success = config(editor, correction) + + expect(success).toBe(true) + + // Verify the XML no longer contains the MD_Keywords block + const updatedXml = editor.serialize() + expect(updatedXml).not.toContain('Continent > North America > Greenland') + }) +}) + describe('when applying platforms ISO-19115 corrections', () => { test('should replace existing platform keyword correctly', () => { const editor = new Iso19115MetadataPathEditor(mockIso19115) - // Example: Replace "AQUA > Earth Observing System" const correction = { scheme: 'platforms', action: 'replace', @@ -246,7 +349,7 @@ describe('when applying platforms ISO-19115 corrections', () => { expect(success).toBe(true) const updatedXml = editor.serialize() - expect(updatedXml).toContain('AQUA > New Platform Description') + expect(updatedXml).toContain('AQUA > New Platform Description') expect(updatedXml).not.toContain('AQUA > Earth Observing System') }) @@ -274,7 +377,7 @@ describe('when applying instruments ISO-19115 corrections', () => { test('should replace existing instrument keyword correctly', () => { const editor = new Iso19115MetadataPathEditor(mockIso19115) - // Example: Replace "AQUA > Earth Observing System" + // Example: Replace "MODIS > Earth Observing System" const correction = { scheme: 'instruments', action: 'replace', @@ -291,7 +394,7 @@ describe('when applying instruments ISO-19115 corrections', () => { expect(success).toBe(true) const updatedXml = editor.serialize() - expect(updatedXml).toContain('MODIS-1 > New Instrument Description') + expect(updatedXml).toContain('MODIS-1 > New Instrument Description') expect(updatedXml).not.toContain('MODIS > Moderate-Resolution Imaging Spectroradiometer') }) @@ -314,3 +417,47 @@ describe('when applying instruments ISO-19115 corrections', () => { expect(updatedXml).toContain('MODIS > Moderate-Resolution Imaging Spectroradiometer') }) }) + +describe('when applying projects ISO-19115 corrections', () => { + test('should replace existing project keyword correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + + const correction = { + scheme: 'projects', + action: 'replace', + oldKeywordObject: { ShortName: 'MEASURES' }, + newKeywordObject: { + ShortName: 'MEASURES-1' + }, + newLongName: 'New Project Description' + } + + const config = ISO_19115_SCHEME_EDITORS.projects + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + expect(updatedXml).toContain('MEASURES-1 > New Project Description') + expect(updatedXml).not.toContain('MEASURES > Making Earth System Data Records for Use in Research Environments') + }) + + test('should delete existing project keyword block correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + const correction = { + scheme: 'projects', + action: 'delete', + oldKeywordObject: { ShortName: 'MEASURES' } + } + + const config = ISO_19115_SCHEME_EDITORS.projects + const success = config(editor, correction) + + expect(success).toBe(true) + + // Verify the specific keyword is gone + const updatedXml = editor.serialize() + expect(updatedXml).not.toContain('MEASURES > Making Earth System Data Records for Use in Research Environments') + expect(updatedXml).toContain('MAGIA > Structure, Stratigraphy, and Sedimentology North of the Antarctic Peninsula') + }) +}) From 6f43b812e03a53eafe89c1d9c6d4fcc5c6840873 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Thu, 25 Jun 2026 10:12:33 -0400 Subject: [PATCH 04/27] KMS-686: Add support for isotopiccategory fixing --- serverless/src/shared/Iso19115DomEditor.js | 63 +++++++++++---- .../src/shared/Iso19115MetadataPathEditor.js | 81 ++++++++++++------- .../applyIso19115MetadataCorrections.test.js | 65 ++++++++++++++- 3 files changed, 164 insertions(+), 45 deletions(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index 531c7d30..b6329b54 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -7,24 +7,24 @@ import { FULL_PATH_VALUE_FIELDS } from './redis-path-store/helpers/constants' */ const blockScheme = (config) => (editor, correction) => editor.updateBlockNode(correction, config) +const leafScheme = (config) => (editor, correction) => editor.updateLeafNode(correction, config) + /** * Factory to generate hierarchical keyword block editors * (like Science Keywords and Locations). */ -const createHierarchicalKeywordBlock = (thesaurusTitle, keywordTypeCode, fieldKey) => blockScheme({ +const createHierarchicalKeywordBlock = (keywordTypeCode, fieldKeys) => blockScheme({ nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords [ - gmd:thesaurusName/gmd:CI_Citation/gmd:title/gco:CharacterString = '${thesaurusTitle}' - or gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${keywordTypeCode}' ]`, find: { fieldPaths: ['gco:CharacterString'], - valueKeys: FULL_PATH_VALUE_FIELDS[fieldKey], + valueKeys: fieldKeys, getNodeValueObject: ({ node, editor, fieldPaths }) => { const fullString = editor.getNestedText(node, fieldPaths[0]) || '' const parts = fullString.split(' > ').map((s) => s.trim()) - const fields = FULL_PATH_VALUE_FIELDS[fieldKey] + const fields = fieldKeys const obj = fields.reduce((acc, field, index) => { acc[field] = parts[index] || '' @@ -44,7 +44,7 @@ const createHierarchicalKeywordBlock = (thesaurusTitle, keywordTypeCode, fieldKe type: 'computed', getValue: ({ correction }) => { const k = correction.newKeywordObject - const fields = FULL_PATH_VALUE_FIELDS[fieldKey] + const fields = fieldKeys return fields .map((field) => k[field] || '') @@ -60,11 +60,9 @@ const createHierarchicalKeywordBlock = (thesaurusTitle, keywordTypeCode, fieldKe * Factory to generate standardized keyword block editors for structures * like Platforms and Instruments. */ -const createKeywordBlock = (thesaurusTitle, keywordTypeCode) => blockScheme({ +const createKeywordBlock = (keywordTypeCode) => blockScheme({ nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords [ - gmd:thesaurusName/gmd:CI_Citation/gmd:title/gco:CharacterString = '${thesaurusTitle}' - or gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${keywordTypeCode}' ]`, find: { @@ -96,6 +94,30 @@ const createKeywordBlock = (thesaurusTitle, keywordTypeCode) => blockScheme({ ] }) +const createIsoTopicCategoryEditor = () => leafScheme({ + // Targets the specific category element directly + nodeXPath: '//gmd:identificationInfo/gmd:MD_DataIdentification/gmd:topicCategory', + find: { + getNodeValueObject: ({ node }) => ({ Value: node.textContent?.trim() || '' }) + }, + replace: [ + { + fieldPath: 'gmd:MD_TopicCategoryCode', + source: { + type: 'computed', + getValue: ({ correction }) => correction.newKeywordObject.Value + } + }, + { + fieldPath: 'gmd:MD_TopicCategoryCode/@codeListValue', + source: { + type: 'computed', + getValue: ({ correction }) => correction.newKeywordObject.Value + } + } + ] +}) + /** * ISO 19115 scheme configuration. * Defines how to identify, parse, and update specific keyword blocks (e.g., science keywords, platforms) @@ -103,22 +125,31 @@ const createKeywordBlock = (thesaurusTitle, keywordTypeCode) => blockScheme({ */ export const ISO_19115_SCHEME_EDITORS = { sciencekeywords: createHierarchicalKeywordBlock( - 'NASA / GCMD Science Keywords', 'theme', - 'sciencekeywords' + FULL_PATH_VALUE_FIELDS.sciencekeywords ), locations: createHierarchicalKeywordBlock( - 'NASA / GCMD Location Keywords', 'place', - 'locations' + FULL_PATH_VALUE_FIELDS.locations ), - platforms: createKeywordBlock('NASA / GCMD Platform Keywords', 'platform'), + platforms: createKeywordBlock('platform'), + + instruments: createKeywordBlock('instrument'), + + projects: createKeywordBlock('project'), - instruments: createKeywordBlock('NASA / GCMD Instrument Keywords', 'instrument'), + isotopiccategory: createIsoTopicCategoryEditor() - projects: createKeywordBlock('NASA / GCMD Project Keywords', 'project') + /* + Providers: short name not in examples + + National Snow and Ice Data Center + + rucontenttype: TBD + idnnode: no mapping + */ } /** diff --git a/serverless/src/shared/Iso19115MetadataPathEditor.js b/serverless/src/shared/Iso19115MetadataPathEditor.js index b6062e15..ef36646d 100644 --- a/serverless/src/shared/Iso19115MetadataPathEditor.js +++ b/serverless/src/shared/Iso19115MetadataPathEditor.js @@ -1,11 +1,7 @@ import xpath from 'xpath' import XmlMetadataPathEditor from './XmlMetadataPathEditor' -import { - extractNamespaces, - getScalarKeywordText, - trimString -} from './XmlUtils' +import { extractNamespaces, trimString } from './XmlUtils' /** * Subclass of XmlMetadataPathEditor specialized for ISO 19115 XML structure. @@ -32,6 +28,59 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { .filter((node) => node?.nodeType === 1) // ELEMENT_NODE } + /** + * Updates leaf (direct) nodes like isotopiccategory. + */ + updateLeafNode(correction, config) { + const { action, oldKeywordObject } = correction + const oldVal = (oldKeywordObject.Value || '').toLowerCase().trim() + + // 1. Get all relevant nodes + const allNodes = this.selectNodes(config.nodeXPath) + + // 2. Handle DELETE + if (action === 'delete') { + const targetNode = allNodes.find((node) => (node.textContent || '').toLowerCase().trim() === oldVal) + if (targetNode) { + targetNode.parentNode.removeChild(targetNode) + + return true + } + + return false + } + + // 3. Handle REPLACE + if (action === 'replace') { + const matchingNode = allNodes.find((node) => (node.textContent || '').toLowerCase().trim() === oldVal) + + if (matchingNode) { + // We iterate through the replace config array using the correction object + config.replace.forEach((replConfig) => { + // The source.getValue function uses the 'correction' object (which contains newKeywordObject) + const newValue = replConfig.source.getValue({ correction }) + + if (replConfig.fieldPath.includes('@')) { + const [elementName, attrName] = replConfig.fieldPath.split('/@') + const targetElement = this.selectNodes(`./${elementName}`, matchingNode)[0] + if (targetElement) { + targetElement.setAttribute(attrName, newValue) + } + } else { + const fieldNode = this.selectNodes(`./${replConfig.fieldPath}`, matchingNode)[0] + if (fieldNode) { + this.setElementText(fieldNode, newValue) + } + } + }) + + return true + } + } + + return false + } + updateBlockNode(correction, config) { // 1. Find the parent block using your namespaced XPath const targetNode = this.selectNodes(config.nodeXPath)[0] || null @@ -99,8 +148,6 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { }) }) - // In Iso19115MetadataPathEditor.js - if (matchingNode) { // Since matchingNode is already the element, // we look for the child directly. @@ -120,25 +167,5 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { return false } - - /** - * Override updateLeafNode to handle gco:CharacterString wrapping. - * ISO 19115 frequently stores text values inside nodes. - */ - updateLeafNode(correction, config) { - const targetNode = this.selectNodes(config.nodeXPath)[0] || null - if (!targetNode) return false - - // Target the specific gco:CharacterString child - const charString = targetNode.getElementsByTagNameNS(this.namespaces.gco, 'CharacterString')[0] - if (charString) { - const value = getScalarKeywordText(correction?.newKeywordObject) - this.setElementText(charString, value) - - return true - } - - return false - } } export default Iso19115MetadataPathEditor diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js index a7ca6599..881d87ae 100644 --- a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -16,7 +16,18 @@ const mockIso19115 = ` xmlns:gml="http://www.opengis.net/gml/3.2" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> - + + + + LOCATION + + + FARMING + + + ELEVATION + + EARTH SCIENCE > Cryosphere > Glaciers/Ice Sheets > Firn > Snow Grain Size @@ -172,6 +183,8 @@ const mockIso19115 = ` + + ` const mockIso19115WithOneScienceKeyword = ` @@ -183,7 +196,9 @@ const mockIso19115WithOneScienceKeyword = ` xmlns:gml="http://www.opengis.net/gml/3.2" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> - + + + EARTH SCIENCE > ATMOSPHERE > AEROSOLS @@ -210,6 +225,8 @@ const mockIso19115WithOneScienceKeyword = ` + + ` describe('when applying sciencekeywords ISO-19115 corrections', () => { @@ -461,3 +478,47 @@ describe('when applying projects ISO-19115 corrections', () => { expect(updatedXml).toContain('MAGIA > Structure, Stratigraphy, and Sedimentology North of the Antarctic Peninsula') }) }) + +describe('topiccategory scheme', () => { + test('should replace existing topic category correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + + const correction = { + scheme: 'isotopiccategory', + action: 'replace', + oldKeywordObject: { Value: 'FARMING' }, + newKeywordObject: { Value: 'BIOTA' } + } + + const config = ISO_19115_SCHEME_EDITORS.isotopiccategory + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + // Verify both the text content and the attribute were updated + expect(updatedXml).toContain('BIOTA') + expect(updatedXml).not.toContain('codeListValue="FARMING">FARMING') + }) + + test('should delete existing topic category correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + + const correction = { + scheme: 'isotopiccategory', + action: 'delete', + oldKeywordObject: { Value: 'LOCATION' } + } + + const config = ISO_19115_SCHEME_EDITORS.isotopiccategory + const success = config(editor, correction) + + expect(success).toBe(true) + + // Verify the specific category element is removed + const updatedXml = editor.serialize() + expect(updatedXml).not.toContain('codeListValue="LOCATION">LOCATION') + // Verify other categories remain + expect(updatedXml).toContain('codeListValue="FARMING">FARMING') + }) +}) From 897ab05050d3ad27de00b2a1962a17a233a6e2dd Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Thu, 25 Jun 2026 17:31:06 -0400 Subject: [PATCH 05/27] KMS-686: Add support for productlevelid --- serverless/src/shared/Iso19115DomEditor.js | 38 +++++++++- .../src/shared/Iso19115MetadataPathEditor.js | 64 +++++++++++----- .../applyIso19115MetadataCorrections.test.js | 76 ++++++++++++++++++- 3 files changed, 154 insertions(+), 24 deletions(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index b6329b54..fdaf289c 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -95,13 +95,13 @@ const createKeywordBlock = (keywordTypeCode) => blockScheme({ }) const createIsoTopicCategoryEditor = () => leafScheme({ - // Targets the specific category element directly nodeXPath: '//gmd:identificationInfo/gmd:MD_DataIdentification/gmd:topicCategory', find: { getNodeValueObject: ({ node }) => ({ Value: node.textContent?.trim() || '' }) }, replace: [ { + // 1. Update the visible text content fieldPath: 'gmd:MD_TopicCategoryCode', source: { type: 'computed', @@ -109,6 +109,7 @@ const createIsoTopicCategoryEditor = () => leafScheme({ } }, { + // 2. Update the codeListValue attribute fieldPath: 'gmd:MD_TopicCategoryCode/@codeListValue', source: { type: 'computed', @@ -118,6 +119,37 @@ const createIsoTopicCategoryEditor = () => leafScheme({ ] }) +const createProductLevelIdEditor = () => leafScheme({ + nodeXPath: '//gmd:processingLevel/gmd:MD_Identifier[gmd:codeSpace/gco:CharacterString="gov.nasa.esdis.umm.processinglevelid"]', + find: { + getNodeValueObject: ({ node, editor }) => ({ + Value: editor.getNestedText(node, 'gmd:code/gco:CharacterString')?.trim() || '' + }) + }, + delete: [ + // Define the paths to remove both occurrences + { path: '//gmd:processingLevel/gmd:MD_Identifier[gmd:codeSpace/gco:CharacterString="gov.nasa.esdis.umm.processinglevelid"]' }, + { path: '//gmd:processingLevelCode/gmd:MD_Identifier[gmd:codeSpace/gco:CharacterString="gov.nasa.esdis.umm.processinglevelid"]' } + ], + replace: [ + { + // Since matchingNode is now MD_Identifier, this path correctly selects the child + fieldPath: 'gmd:code/gco:CharacterString', + source: { + type: 'computed', + getValue: ({ correction }) => correction.newKeywordObject.Value + } + }, + { + fieldPath: '//gmd:contentInfo/gmd:MD_ImageDescription/gmd:processingLevelCode/gmd:MD_Identifier[gmd:codeSpace/gco:CharacterString="gov.nasa.esdis.umm.processinglevelid"]/gmd:code/gco:CharacterString', + source: { + type: 'computed', + getValue: ({ correction }) => correction.newKeywordObject.Value + } + } + ] +}) + /** * ISO 19115 scheme configuration. * Defines how to identify, parse, and update specific keyword blocks (e.g., science keywords, platforms) @@ -140,7 +172,9 @@ export const ISO_19115_SCHEME_EDITORS = { projects: createKeywordBlock('project'), - isotopiccategory: createIsoTopicCategoryEditor() + isotopiccategory: createIsoTopicCategoryEditor(), + + productlevelid: createProductLevelIdEditor() /* Providers: short name not in examples diff --git a/serverless/src/shared/Iso19115MetadataPathEditor.js b/serverless/src/shared/Iso19115MetadataPathEditor.js index ef36646d..86a575b1 100644 --- a/serverless/src/shared/Iso19115MetadataPathEditor.js +++ b/serverless/src/shared/Iso19115MetadataPathEditor.js @@ -29,20 +29,42 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { } /** - * Updates leaf (direct) nodes like isotopiccategory. + * Updates leaf (direct) nodes like isotopiccategory and productlevelid. */ updateLeafNode(correction, config) { const { action, oldKeywordObject } = correction const oldVal = (oldKeywordObject.Value || '').toLowerCase().trim() - - // 1. Get all relevant nodes const allNodes = this.selectNodes(config.nodeXPath) - // 2. Handle DELETE if (action === 'delete') { - const targetNode = allNodes.find((node) => (node.textContent || '').toLowerCase().trim() === oldVal) + // 1. Find the primary node to confirm the object exists + const targetNode = allNodes.find((node) => { + const valueObj = config.find.getNodeValueObject({ + node, + editor: this + }) + + const foundVal = (valueObj.Value || '').toLowerCase().trim() + const match = foundVal === oldVal + + return match + }) + if (targetNode) { - targetNode.parentNode.removeChild(targetNode) + // 2. Strategy: If explicit delete paths are provided, use them (e.g., productlevelid) + if (config.delete && config.delete.length > 0) { + config.delete.forEach((delConfig) => { + const nodesToDelete = this.selectNodes(delConfig.path, this.document) + nodesToDelete.forEach((node) => { + if (node && node.parentNode) { + node.parentNode.removeChild(node) + } + }) + }) + } else if (targetNode.parentNode) { + // Use 'else if' to flatten the structure and satisfy the linter + targetNode.parentNode.removeChild(targetNode) + } return true } @@ -50,27 +72,31 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { return false } - // 3. Handle REPLACE if (action === 'replace') { - const matchingNode = allNodes.find((node) => (node.textContent || '').toLowerCase().trim() === oldVal) + const matchingNode = allNodes.find((node) => { + const valueObj = config.find.getNodeValueObject({ + node, + editor: this + }) + + return (valueObj.Value || '').toLowerCase().trim() === oldVal + }) if (matchingNode) { - // We iterate through the replace config array using the correction object config.replace.forEach((replConfig) => { - // The source.getValue function uses the 'correction' object (which contains newKeywordObject) const newValue = replConfig.source.getValue({ correction }) + const isGlobal = replConfig.fieldPath.startsWith('//') + const context = isGlobal ? this.document : matchingNode + // If updating an attribute (contains @) if (replConfig.fieldPath.includes('@')) { - const [elementName, attrName] = replConfig.fieldPath.split('/@') - const targetElement = this.selectNodes(`./${elementName}`, matchingNode)[0] - if (targetElement) { - targetElement.setAttribute(attrName, newValue) - } + const [path, attr] = replConfig.fieldPath.split('/@') + const targetElement = this.selectNodes(isGlobal ? path : `./${path}`, context)[0] + if (targetElement) targetElement.setAttribute(attr, newValue) } else { - const fieldNode = this.selectNodes(`./${replConfig.fieldPath}`, matchingNode)[0] - if (fieldNode) { - this.setElementText(fieldNode, newValue) - } + // Standard text content update + const targetNode = this.selectNodes(isGlobal ? replConfig.fieldPath : `./${replConfig.fieldPath}`, context)[0] + if (targetNode) this.setElementText(targetNode, newValue) } }) diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js index 881d87ae..28eb44d7 100644 --- a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -27,6 +27,16 @@ const mockIso19115 = ` ELEVATION + + + + 3 + + + gov.nasa.esdis.umm.processinglevelid + + + @@ -185,6 +195,20 @@ const mockIso19115 = ` + + + + + + 3 + + + gov.nasa.esdis.umm.processinglevelid + + + + + ` const mockIso19115WithOneScienceKeyword = ` @@ -479,8 +503,8 @@ describe('when applying projects ISO-19115 corrections', () => { }) }) -describe('topiccategory scheme', () => { - test('should replace existing topic category correctly', () => { +describe('when applying isotopiccategory ISO-19115 corrections', () => { + test('should replace existing isotopiccategory correctly', () => { const editor = new Iso19115MetadataPathEditor(mockIso19115) const correction = { @@ -501,7 +525,7 @@ describe('topiccategory scheme', () => { expect(updatedXml).not.toContain('codeListValue="FARMING">FARMING') }) - test('should delete existing topic category correctly', () => { + test('should delete existing isotopiccategory correctly', () => { const editor = new Iso19115MetadataPathEditor(mockIso19115) const correction = { @@ -522,3 +546,49 @@ describe('topiccategory scheme', () => { expect(updatedXml).toContain('codeListValue="FARMING">FARMING') }) }) + +describe('when applying productlevelid ISO-19115 corrections', () => { + test('should replace existing productlevelid correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + + const correction = { + scheme: 'productlevelid', + action: 'replace', + oldKeywordObject: { Value: '3' }, + newKeywordObject: { Value: '5' } + } + + const config = ISO_19115_SCHEME_EDITORS.productlevelid + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + // 1. Data Identification location + expect(updatedXml).toMatch(/.*5<\/gco:CharacterString>/s) + // 2. Image Description location + expect(updatedXml).toMatch(/.*5<\/gco:CharacterString>/s) + expect(updatedXml).not.toContain('3') + }) + + test('should delete existing productlevelid correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + + const correction = { + scheme: 'productlevelid', + action: 'delete', + oldKeywordObject: { Value: '3' } + } + + const config = ISO_19115_SCHEME_EDITORS.productlevelid + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Verify that the Identifier block is completely gone from both expected locations + expect(updatedXml).not.toContain('gov.nasa.esdis.umm.processinglevelid') + expect(updatedXml).not.toContain('3') + }) +}) From c812bc7fcb6f3c148b6c765b0432eaddbca70a53 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Fri, 26 Jun 2026 14:15:56 -0400 Subject: [PATCH 06/27] KMS-686: Add support for providers correction --- serverless/src/shared/Iso19115DomEditor.js | 50 +++++++- .../src/shared/Iso19115MetadataPathEditor.js | 63 ++++++--- .../applyIso19115MetadataCorrections.test.js | 121 ++++++++++++++++++ 3 files changed, 213 insertions(+), 21 deletions(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index fdaf289c..d6996d11 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -94,6 +94,52 @@ const createKeywordBlock = (keywordTypeCode) => blockScheme({ ] }) +/** + * Factory to generate standardized keyword block editors for structures + * like Platforms and Instruments. + */ +const createProviderEditor = () => blockScheme({ + nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords + [ + gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = 'dataCentre' + ]`, + find: { + fieldPaths: ['gmx:Anchor', 'gco:CharacterString'], // Changed paths to be relative to + valueKeys: ['ShortName', 'LongName'], + getNodeValueObject: ({ node, editor }) => { + // Directly extract the text from the child elements + const anchorNode = editor.selectNodes('./gmx:Anchor', node)[0] + const charStringNode = editor.selectNodes('./gco:CharacterString', node)[0] + + const fullString = (anchorNode ? anchorNode.textContent : '') + || (charStringNode ? charStringNode.textContent : '') || '' + + const [ShortName, ...longNameParts] = fullString.split(' > ') + + return { + ShortName: ShortName?.trim() || '', + LongName: longNameParts.join(' > ').trim() || '' + } + } + }, + replace: [ + { + fieldPath: ({ node, editor }) => (editor.selectNodes('./gmx:Anchor', node).length > 0 + ? 'gmx:Anchor' + : 'gco:CharacterString'), + source: { + type: 'computed', + getValue: ({ correction }) => { + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName || '' + + return LongName ? `${ShortName} > ${LongName}` : ShortName + } + } + } + ] +}) + const createIsoTopicCategoryEditor = () => leafScheme({ nodeXPath: '//gmd:identificationInfo/gmd:MD_DataIdentification/gmd:topicCategory', find: { @@ -174,7 +220,9 @@ export const ISO_19115_SCHEME_EDITORS = { isotopiccategory: createIsoTopicCategoryEditor(), - productlevelid: createProductLevelIdEditor() + productlevelid: createProductLevelIdEditor(), + + providers: createProviderEditor() /* Providers: short name not in examples diff --git a/serverless/src/shared/Iso19115MetadataPathEditor.js b/serverless/src/shared/Iso19115MetadataPathEditor.js index 86a575b1..71b8775a 100644 --- a/serverless/src/shared/Iso19115MetadataPathEditor.js +++ b/serverless/src/shared/Iso19115MetadataPathEditor.js @@ -112,32 +112,36 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { const targetNode = this.selectNodes(config.nodeXPath)[0] || null if (!targetNode) return false + // 2. Handle the 'delete' action // 2. Handle the 'delete' action if (correction.action === 'delete') { - // Normalize search string to lowercase - const oldVal = (correction.oldKeywordObject.Value || correction.oldKeywordObject.ShortName || '').toLowerCase().trim() + // 1. Get all potential keyword nodes within the block + const keywordNodes = this.selectNodes('./gmd:keyword', targetNode) - // 1. Get all potential CharacterString nodes within the block - const allCharStrings = this.selectNodes('.//gmd:keyword/gco:CharacterString', targetNode) + // 2. Find the correct node using your config's getNodeValueObject + const matchingNode = keywordNodes.find((node) => { + const parsedObject = config.find.getNodeValueObject({ + node, + editor: this, + fieldPaths: config.find.fieldPaths + }) - // 2. Find the node using a case-insensitive partial match (.includes) - const targetCharString = allCharStrings.find((node) => { - const textValue = (node.textContent || '').toLowerCase().trim() + return Object.keys(correction.oldKeywordObject).every((key) => { + const parsedValue = parsedObject[key] ? trimString(parsedObject[key]).toLowerCase() : '' + const correctionValue = correction.oldKeywordObject[key] ? trimString(correction.oldKeywordObject[key]).toLowerCase() : '' - return textValue.includes(oldVal) + return parsedValue === correctionValue + }) }) - if (!targetCharString) return false + if (!matchingNode) return false - // 3. Remove the specific keyword entry - const keywordNode = targetCharString.parentNode - keywordNode.parentNode.removeChild(keywordNode) - - // 4. Check if any keywords remain in the MD_Keywords block - const remainingKeywords = this.selectNodes('.//gmd:keyword', targetNode) + // 3. Remove the identified node + matchingNode.parentNode.removeChild(matchingNode) + // 4. Cleanup empty parent blocks (same logic as before) + const remainingKeywords = this.selectNodes('./gmd:keyword', targetNode) if (remainingKeywords.length === 0) { - // ... (rest of your cleanup logic remains exactly the same) const mdKeywordsParent = targetNode.parentNode mdKeywordsParent.removeChild(targetNode) @@ -174,12 +178,31 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { }) }) + // Inside Iso19115MetadataPathEditor.js, within the 'replace' action block: if (matchingNode) { - // Since matchingNode is already the element, - // we look for the child directly. - const fieldNode = this.selectNodes('./gco:CharacterString', matchingNode)[0] - || this.selectNodes('gco:CharacterString', matchingNode)[0] + let fieldNode = null + + // 1. Attempt to find the dynamic path if defined in the config + if (replaceConfig.fieldPath) { + const path = typeof replaceConfig.fieldPath === 'function' + ? replaceConfig.fieldPath({ + node: matchingNode, + editor: this + }) + : replaceConfig.fieldPath + + const relativePath = path.startsWith('./') ? path : `./${path}`; + // Use destructuring to capture the first match directly + [fieldNode] = this.selectNodes(relativePath, matchingNode) + } + + // 2. Fallback: If dynamic path failed, look for standard gco:CharacterString + if (!fieldNode) { + [fieldNode] = this.selectNodes('./gco:CharacterString', matchingNode) + || this.selectNodes('gco:CharacterString', matchingNode) + } + // 3. Perform update if node found if (fieldNode) { const newValue = replaceConfig.source.getValue({ correction }) this.setElementText(fieldNode, newValue) diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js index 28eb44d7..f399f63a 100644 --- a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -14,6 +14,7 @@ const mockIso19115 = ` xmlns:gmd="http://www.isotc211.org/2005/gmd" xmlns:gmi="http://www.isotc211.org/2005/gmi" xmlns:gml="http://www.opengis.net/gml/3.2" + xmlns:gmx="http://www.isotc211.org/2005/gmx" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> @@ -37,6 +38,82 @@ const mockIso19115 = ` + + + + DOC/NOAA/NESDIS/NODC > National Oceanographic Data Center, NESDIS, NOAA, U.S. Department of Commerce + + + DOC/NOAA/NESDIS/NCEI > National Centers for Environmental Information, NESDIS, NOAA, U.S. Department of Commerce + + + dataCentre + + + + + Global Change Master Directory (GCMD) Data Center Keywords + + + + + 2019-11-12 + + + publication + + + + + 9 + + + + + Global Change Data Center, Science and Exploration Directorate, Goddard Space Flight Center (GSFC) National Aeronautics and Space Administration (NASA) + + + + + + + Greenbelt + + + MD + + + + + + + https://wiki.earthdata.nasa.gov/display/gcmdkey + + + HTTPS + + + GCMD Keyword Forum Page + + + The information provided on this page seeks to define how the GCMD Keywords are structured, used and accessed. It also provides information on how users can participate in the further development of the keywords. + + + information + + + + + + + custodian + + + + + + + @@ -592,3 +669,47 @@ describe('when applying productlevelid ISO-19115 corrections', () => { expect(updatedXml).not.toContain('3') }) }) + +describe('when applying providers ISO-19115 corrections', () => { + test('should replace existing providers keyword correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + + const correction = { + scheme: 'providers', + action: 'replace', + oldKeywordObject: { ShortName: 'DOC/NOAA/NESDIS/NCEI' }, + newKeywordObject: { + ShortName: 'DOC/NOAA/NESDIS/NCEI-1' + }, + newLongName: 'New Provider Description' + } + + const config = ISO_19115_SCHEME_EDITORS.providers + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + expect(updatedXml).toContain('DOC/NOAA/NESDIS/NCEI-1 > New Provider Description') + expect(updatedXml).not.toContain('DOC/NOAA/NESDIS/NCEI > National Centers for Environmental Information, NESDIS, NOAA, U.S. Department of Commerce') + }) + + test('should delete existing providers keyword block correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + const correction = { + scheme: 'providers', + action: 'delete', + oldKeywordObject: { ShortName: 'DOC/NOAA/NESDIS/NCEI' } + } + + const config = ISO_19115_SCHEME_EDITORS.providers + const success = config(editor, correction) + + expect(success).toBe(true) + + // Verify the specific keyword is gone + const updatedXml = editor.serialize() + expect(updatedXml).not.toContain('DOC/NOAA/NESDIS/NCEI > National Centers for Environmental Information, NESDIS, NOAA, U.S. Department of Commerce') + expect(updatedXml).toContain('DOC/NOAA/NESDIS/NODC > National Oceanographic Data Center, NESDIS, NOAA, U.S. Department of Commerce') + }) +}) From 2f784b3056fb1c2c4547f422fb0509bd614ba666 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Fri, 26 Jun 2026 14:41:43 -0400 Subject: [PATCH 07/27] KMS-686: Use same editor for providers, instruments, platforms and projects --- serverless/src/shared/Iso19115DomEditor.js | 61 ++++------------------ 1 file changed, 9 insertions(+), 52 deletions(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index d6996d11..b7eee4c1 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -60,60 +60,18 @@ const createHierarchicalKeywordBlock = (keywordTypeCode, fieldKeys) => blockSche * Factory to generate standardized keyword block editors for structures * like Platforms and Instruments. */ -const createKeywordBlock = (keywordTypeCode) => blockScheme({ - nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords - [ - gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${keywordTypeCode}' - ]`, +const createKeywordBlock = (type) => blockScheme({ + nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords[gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${type}']`, find: { - fieldPaths: ['gco:CharacterString'], - valueKeys: ['ShortName', 'LongName'], - getNodeValueObject: ({ node, editor, fieldPaths }) => { - const fullString = editor.getNestedText(node, fieldPaths[0]) || '' - const [ShortName, ...longNameParts] = fullString.split(' > ') - - return { - ShortName: ShortName?.trim() || '', - LongName: longNameParts.join(' > ').trim() || '' - } - } - }, - replace: [ - { - fieldPath: 'gmd:keyword/gco:CharacterString', - source: { - type: 'computed', - getValue: ({ correction }) => { - const { ShortName } = correction.newKeywordObject - const LongName = correction.newLongName - - return `${ShortName} > ${LongName}` - } - } - } - ] -}) - -/** - * Factory to generate standardized keyword block editors for structures - * like Platforms and Instruments. - */ -const createProviderEditor = () => blockScheme({ - nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords - [ - gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = 'dataCentre' - ]`, - find: { - fieldPaths: ['gmx:Anchor', 'gco:CharacterString'], // Changed paths to be relative to + // Look for both, factory will handle the specific logic + fieldPaths: ['gmx:Anchor', 'gco:CharacterString'], valueKeys: ['ShortName', 'LongName'], getNodeValueObject: ({ node, editor }) => { - // Directly extract the text from the child elements + // Use helper to grab content regardless of tag type const anchorNode = editor.selectNodes('./gmx:Anchor', node)[0] const charStringNode = editor.selectNodes('./gco:CharacterString', node)[0] - const fullString = (anchorNode ? anchorNode.textContent : '') - || (charStringNode ? charStringNode.textContent : '') || '' - + const fullString = (anchorNode || charStringNode)?.textContent || '' const [ShortName, ...longNameParts] = fullString.split(' > ') return { @@ -124,9 +82,8 @@ const createProviderEditor = () => blockScheme({ }, replace: [ { - fieldPath: ({ node, editor }) => (editor.selectNodes('./gmx:Anchor', node).length > 0 - ? 'gmx:Anchor' - : 'gco:CharacterString'), + // Dynamic path resolver used by the editor class + fieldPath: ({ node, editor }) => (editor.selectNodes('./gmx:Anchor', node).length > 0 ? 'gmx:Anchor' : 'gco:CharacterString'), source: { type: 'computed', getValue: ({ correction }) => { @@ -222,7 +179,7 @@ export const ISO_19115_SCHEME_EDITORS = { productlevelid: createProductLevelIdEditor(), - providers: createProviderEditor() + providers: createKeywordBlock('dataCentre') /* Providers: short name not in examples From 3dcb5a023fe2d4a970b87cc354aef4b70abfb050 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Fri, 26 Jun 2026 16:25:14 -0400 Subject: [PATCH 08/27] KMS-686: Code refactor: hierachy schemes use same method --- serverless/src/shared/Iso19115DomEditor.js | 133 ++++++++---------- .../src/shared/Iso19115MetadataPathEditor.js | 108 +++++++------- 2 files changed, 114 insertions(+), 127 deletions(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index b7eee4c1..7b36e8b6 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -9,89 +9,38 @@ const blockScheme = (config) => (editor, correction) => editor.updateBlockNode(c const leafScheme = (config) => (editor, correction) => editor.updateLeafNode(correction, config) -/** - * Factory to generate hierarchical keyword block editors - * (like Science Keywords and Locations). - */ -const createHierarchicalKeywordBlock = (keywordTypeCode, fieldKeys) => blockScheme({ - nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords - [ - gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${keywordTypeCode}' - ]`, - find: { - fieldPaths: ['gco:CharacterString'], - valueKeys: fieldKeys, - getNodeValueObject: ({ node, editor, fieldPaths }) => { - const fullString = editor.getNestedText(node, fieldPaths[0]) || '' - const parts = fullString.split(' > ').map((s) => s.trim()) - const fields = fieldKeys - - const obj = fields.reduce((acc, field, index) => { - acc[field] = parts[index] || '' - - return acc - }, {}) - - obj.Value = fullString.trim() - - return obj - } - }, - replace: [ - { - fieldPath: 'gmd:keyword/gco:CharacterString', - source: { - type: 'computed', - getValue: ({ correction }) => { - const k = correction.newKeywordObject - const fields = fieldKeys - - return fields - .map((field) => k[field] || '') - .filter((v) => v.trim().length > 0) - .join(' > ') - } - } - } - ] -}) - /** * Factory to generate standardized keyword block editors for structures * like Platforms and Instruments. */ -const createKeywordBlock = (type) => blockScheme({ +const createKeywordBlock = (type, { fieldKeys, matchKeys, getValue }) => blockScheme({ nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords[gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${type}']`, find: { - // Look for both, factory will handle the specific logic fieldPaths: ['gmx:Anchor', 'gco:CharacterString'], - valueKeys: ['ShortName', 'LongName'], + valueKeys: fieldKeys, + matchKeys, getNodeValueObject: ({ node, editor }) => { - // Use helper to grab content regardless of tag type const anchorNode = editor.selectNodes('./gmx:Anchor', node)[0] const charStringNode = editor.selectNodes('./gco:CharacterString', node)[0] const fullString = (anchorNode || charStringNode)?.textContent || '' - const [ShortName, ...longNameParts] = fullString.split(' > ') + // Parsing logic: Hierarchical (l1 > l2) or Simple (short > long) + const parts = fullString.split(' > ').map((s) => s.trim()) - return { - ShortName: ShortName?.trim() || '', - LongName: longNameParts.join(' > ').trim() || '' - } + return fieldKeys.reduce((acc, key, index) => { + acc[key] = parts[index] || '' + + return acc + }, { Value: fullString.trim() }) } }, replace: [ { - // Dynamic path resolver used by the editor class fieldPath: ({ node, editor }) => (editor.selectNodes('./gmx:Anchor', node).length > 0 ? 'gmx:Anchor' : 'gco:CharacterString'), source: { type: 'computed', - getValue: ({ correction }) => { - const { ShortName } = correction.newKeywordObject - const LongName = correction.newLongName || '' - - return LongName ? `${ShortName} > ${LongName}` : ShortName - } + // Use the custom getValue if provided, else fall back to default + getValue: getValue || (({ correction }) => fieldKeys.map((k) => correction.newKeywordObject[k]).filter(Boolean).join(' > ')) } } ] @@ -159,27 +108,69 @@ const createProductLevelIdEditor = () => leafScheme({ * within an ISO 19115 XML structure using XPath selectors and transformation logic. */ export const ISO_19115_SCHEME_EDITORS = { - sciencekeywords: createHierarchicalKeywordBlock( + sciencekeywords: createKeywordBlock( 'theme', - FULL_PATH_VALUE_FIELDS.sciencekeywords + { + fieldKeys: FULL_PATH_VALUE_FIELDS.sciencekeywords, + matchKeys: ['Value'] + } ), - locations: createHierarchicalKeywordBlock( + locations: createKeywordBlock( 'place', - FULL_PATH_VALUE_FIELDS.locations + { + fieldKeys: FULL_PATH_VALUE_FIELDS.locations, + matchKeys: ['Value'] + } ), - platforms: createKeywordBlock('platform'), + platforms: createKeywordBlock('platform', { + fieldKeys: ['ShortName', 'LongName'], + matchKeys: ['ShortName'], + getValue: ({ correction }) => { + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName || '' + + return LongName ? `${ShortName} > ${LongName}` : ShortName + } + }), - instruments: createKeywordBlock('instrument'), + instruments: createKeywordBlock('instrument', { + fieldKeys: ['ShortName', 'LongName'], + matchKeys: ['ShortName'], + getValue: ({ correction }) => { + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName || '' + + return LongName ? `${ShortName} > ${LongName}` : ShortName + } + }), - projects: createKeywordBlock('project'), + projects: createKeywordBlock('project', { + fieldKeys: ['ShortName', 'LongName'], + matchKeys: ['ShortName'], + getValue: ({ correction }) => { + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName || '' + + return LongName ? `${ShortName} > ${LongName}` : ShortName + } + }), isotopiccategory: createIsoTopicCategoryEditor(), productlevelid: createProductLevelIdEditor(), - providers: createKeywordBlock('dataCentre') + providers: createKeywordBlock('dataCentre', { + fieldKeys: ['ShortName', 'LongName'], + matchKeys: ['ShortName'], + getValue: ({ correction }) => { + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName || '' + + return LongName ? `${ShortName} > ${LongName}` : ShortName + } + }) /* Providers: short name not in examples diff --git a/serverless/src/shared/Iso19115MetadataPathEditor.js b/serverless/src/shared/Iso19115MetadataPathEditor.js index 71b8775a..f8d65188 100644 --- a/serverless/src/shared/Iso19115MetadataPathEditor.js +++ b/serverless/src/shared/Iso19115MetadataPathEditor.js @@ -1,7 +1,7 @@ import xpath from 'xpath' import XmlMetadataPathEditor from './XmlMetadataPathEditor' -import { extractNamespaces, trimString } from './XmlUtils' +import { extractNamespaces } from './XmlUtils' /** * Subclass of XmlMetadataPathEditor specialized for ISO 19115 XML structure. @@ -9,16 +9,27 @@ import { extractNamespaces, trimString } from './XmlUtils' export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { constructor(xmlString) { super(xmlString) - // Get the root element const root = this.document.documentElement - // Build the namespace map from the root's attributes - const namespaces = extractNamespaces(root) + // 1. Get dynamic namespaces + const extracted = extractNamespaces(root) - // Store for use in XPath resolution - this.namespaces = namespaces - const resolver = xpath.useNamespaces(namespaces) - this.resolver = resolver + // 2. Define standard ISO 19115 namespaces + const standardNamespaces = { + gco: 'http://www.isotc211.org/2005/gco', + gmd: 'http://www.isotc211.org/2005/gmd', + gmi: 'http://www.isotc211.org/2005/gmi', + gmx: 'http://www.isotc211.org/2005/gmx', + gml: 'http://www.opengis.net/gml/3.2' + } + + // 3. Merge them, prioritizing extracted ones (if any) + this.namespaces = { + ...standardNamespaces, + ...extracted + } + + this.resolver = xpath.useNamespaces(this.namespaces) } // In Iso19115Editor class @@ -28,6 +39,31 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { .filter((node) => node?.nodeType === 1) // ELEMENT_NODE } + /** + * Helper to identify the correct keyword node based on the config. + */ + findMatchingNode(targetNode, correction, config) { + const keywordNodes = this.selectNodes('./gmd:keyword', targetNode) + + return keywordNodes.find((node) => { + const parsedObject = config.find.getNodeValueObject({ + node, + editor: this, + fieldPaths: config.find.fieldPaths + }) + + // Use matchKeys if defined, otherwise fall back to oldKeywordObject keys + const matchKeys = config.find.matchKeys || Object.keys(correction.oldKeywordObject) + + return matchKeys.every((key) => { + const parsedValue = (parsedObject[key] || '').toLowerCase().trim() + const correctionValue = (correction.oldKeywordObject[key] || '').toLowerCase().trim() + + return parsedValue === correctionValue + }) + }) + } + /** * Updates leaf (direct) nodes like isotopiccategory and productlevelid. */ @@ -112,34 +148,15 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { const targetNode = this.selectNodes(config.nodeXPath)[0] || null if (!targetNode) return false - // 2. Handle the 'delete' action // 2. Handle the 'delete' action if (correction.action === 'delete') { - // 1. Get all potential keyword nodes within the block - const keywordNodes = this.selectNodes('./gmd:keyword', targetNode) - - // 2. Find the correct node using your config's getNodeValueObject - const matchingNode = keywordNodes.find((node) => { - const parsedObject = config.find.getNodeValueObject({ - node, - editor: this, - fieldPaths: config.find.fieldPaths - }) - - return Object.keys(correction.oldKeywordObject).every((key) => { - const parsedValue = parsedObject[key] ? trimString(parsedObject[key]).toLowerCase() : '' - const correctionValue = correction.oldKeywordObject[key] ? trimString(correction.oldKeywordObject[key]).toLowerCase() : '' - - return parsedValue === correctionValue - }) - }) - + const matchingNode = this.findMatchingNode(targetNode, correction, config) if (!matchingNode) return false - // 3. Remove the identified node + // Remove the identified node matchingNode.parentNode.removeChild(matchingNode) - // 4. Cleanup empty parent blocks (same logic as before) + // Cleanup empty parent blocks const remainingKeywords = this.selectNodes('./gmd:keyword', targetNode) if (remainingKeywords.length === 0) { const mdKeywordsParent = targetNode.parentNode @@ -157,32 +174,12 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { // 3. Handle the 'replace' action if (correction.action === 'replace') { const replaceConfig = config.replace[0] + const matchingNode = this.findMatchingNode(targetNode, correction, config) - // 1. Get all keyword nodes in this block - const keywordNodes = this.selectNodes('./gmd:keyword', targetNode) - - const matchingNode = keywordNodes.find((node) => { - const parsedObject = config.find.getNodeValueObject({ - node, - editor: this, - fieldPaths: config.find.fieldPaths - }) - - // Compare ALL keys present in the correction.oldKeywordObject - // This works for { Value: '...' } AND { ShortName: '...' } - return Object.keys(correction.oldKeywordObject).every((key) => { - const parsedValue = parsedObject[key] ? trimString(parsedObject[key]).toLowerCase() : '' - const correctionValue = correction.oldKeywordObject[key] ? trimString(correction.oldKeywordObject[key]).toLowerCase() : '' - - return parsedValue === correctionValue - }) - }) - - // Inside Iso19115MetadataPathEditor.js, within the 'replace' action block: if (matchingNode) { let fieldNode = null - // 1. Attempt to find the dynamic path if defined in the config + // Attempt dynamic path if defined in the config if (replaceConfig.fieldPath) { const path = typeof replaceConfig.fieldPath === 'function' ? replaceConfig.fieldPath({ @@ -192,17 +189,16 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { : replaceConfig.fieldPath const relativePath = path.startsWith('./') ? path : `./${path}`; - // Use destructuring to capture the first match directly [fieldNode] = this.selectNodes(relativePath, matchingNode) } - // 2. Fallback: If dynamic path failed, look for standard gco:CharacterString + // Fallback: look for standard gco:CharacterString if (!fieldNode) { [fieldNode] = this.selectNodes('./gco:CharacterString', matchingNode) || this.selectNodes('gco:CharacterString', matchingNode) } - // 3. Perform update if node found + // Perform update if (fieldNode) { const newValue = replaceConfig.source.getValue({ correction }) this.setElementText(fieldNode, newValue) @@ -211,7 +207,7 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { } } - return false // Match not found or fieldNode missing + return false } return false From 9d602df25e19f0d1ba1f2d44aad9c7737df6877a Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Fri, 26 Jun 2026 18:40:28 -0400 Subject: [PATCH 09/27] KMS-686: Add code comments --- serverless/src/shared/Iso19115DomEditor.js | 50 +++++++++++-------- .../src/shared/Iso19115MetadataPathEditor.js | 45 ++++++++++++----- 2 files changed, 62 insertions(+), 33 deletions(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index 7b36e8b6..ebc2797b 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -4,14 +4,22 @@ import { FULL_PATH_VALUE_FIELDS } from './redis-path-store/helpers/constants' /** * Helper factory function to create a block editor configuration. * Maps a correction to an update operation within the editor instance. + * @param {Object} config - Configuration object defining XPath and transformation logic. + * @returns {Function} Function to apply the update. */ const blockScheme = (config) => (editor, correction) => editor.updateBlockNode(correction, config) - +/** + * Helper factory function to create a leaf editor configuration. + * @param {Object} config - Configuration object for updating single nodes. + * @returns {Function} Function to apply the update. + */ const leafScheme = (config) => (editor, correction) => editor.updateLeafNode(correction, config) - /** * Factory to generate standardized keyword block editors for structures * like Platforms and Instruments. + * * @param {string} type - The 'codeListValue' for the MD_KeywordTypeCode. + * @param {Object} options - Configuration for field parsing and value generation. + * @returns {Function} Configured block editor function. */ const createKeywordBlock = (type, { fieldKeys, matchKeys, getValue }) => blockScheme({ nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords[gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${type}']`, @@ -19,6 +27,10 @@ const createKeywordBlock = (type, { fieldKeys, matchKeys, getValue }) => blockSc fieldPaths: ['gmx:Anchor', 'gco:CharacterString'], valueKeys: fieldKeys, matchKeys, + /** + * Extracts values from the XML node and maps them to field keys. + * Handles hierarchical strings (l1 > l2). + */ getNodeValueObject: ({ node, editor }) => { const anchorNode = editor.selectNodes('./gmx:Anchor', node)[0] const charStringNode = editor.selectNodes('./gco:CharacterString', node)[0] @@ -36,16 +48,20 @@ const createKeywordBlock = (type, { fieldKeys, matchKeys, getValue }) => blockSc }, replace: [ { + // Dynamically select target element (Anchor vs CharacterString) based on existing content fieldPath: ({ node, editor }) => (editor.selectNodes('./gmx:Anchor', node).length > 0 ? 'gmx:Anchor' : 'gco:CharacterString'), source: { type: 'computed', - // Use the custom getValue if provided, else fall back to default + // Allows custom formatting for keyword strings; defaults to joining keys with ' > ' getValue: getValue || (({ correction }) => fieldKeys.map((k) => correction.newKeywordObject[k]).filter(Boolean).join(' > ')) } } ] }) - +/** + * Creates an editor for ISO Topic Category nodes. + * Updates both the text content and the @codeListValue attribute. + */ const createIsoTopicCategoryEditor = () => leafScheme({ nodeXPath: '//gmd:identificationInfo/gmd:MD_DataIdentification/gmd:topicCategory', find: { @@ -70,7 +86,10 @@ const createIsoTopicCategoryEditor = () => leafScheme({ } ] }) - +/** + * Creates an editor for Processing Level Identifiers. + * Manages deletion of old paths and insertion/updates into specific XML locations. + */ const createProductLevelIdEditor = () => leafScheme({ nodeXPath: '//gmd:processingLevel/gmd:MD_Identifier[gmd:codeSpace/gco:CharacterString="gov.nasa.esdis.umm.processinglevelid"]', find: { @@ -85,7 +104,6 @@ const createProductLevelIdEditor = () => leafScheme({ ], replace: [ { - // Since matchingNode is now MD_Identifier, this path correctly selects the child fieldPath: 'gmd:code/gco:CharacterString', source: { type: 'computed', @@ -93,6 +111,7 @@ const createProductLevelIdEditor = () => leafScheme({ } }, { + // Target specific secondary locations for synchronization fieldPath: '//gmd:contentInfo/gmd:MD_ImageDescription/gmd:processingLevelCode/gmd:MD_Identifier[gmd:codeSpace/gco:CharacterString="gov.nasa.esdis.umm.processinglevelid"]/gmd:code/gco:CharacterString', source: { type: 'computed', @@ -157,10 +176,6 @@ export const ISO_19115_SCHEME_EDITORS = { } }), - isotopiccategory: createIsoTopicCategoryEditor(), - - productlevelid: createProductLevelIdEditor(), - providers: createKeywordBlock('dataCentre', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], @@ -170,16 +185,11 @@ export const ISO_19115_SCHEME_EDITORS = { return LongName ? `${ShortName} > ${LongName}` : ShortName } - }) - - /* - Providers: short name not in examples - - National Snow and Ice Data Center - - rucontenttype: TBD - idnnode: no mapping - */ + }), + + isotopiccategory: createIsoTopicCategoryEditor(), + + productlevelid: createProductLevelIdEditor() } /** diff --git a/serverless/src/shared/Iso19115MetadataPathEditor.js b/serverless/src/shared/Iso19115MetadataPathEditor.js index f8d65188..12e3fe4a 100644 --- a/serverless/src/shared/Iso19115MetadataPathEditor.js +++ b/serverless/src/shared/Iso19115MetadataPathEditor.js @@ -5,6 +5,8 @@ import { extractNamespaces } from './XmlUtils' /** * Subclass of XmlMetadataPathEditor specialized for ISO 19115 XML structure. + * Handles namespace resolution and provides specific methods for updating + * keyword blocks and leaf nodes within ISO 19115 metadata. */ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { constructor(xmlString) { @@ -32,15 +34,23 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { this.resolver = xpath.useNamespaces(this.namespaces) } - // In Iso19115Editor class + /** + * Executes an XPath expression and ensures only element nodes are returned. + * @param {string} expression - The XPath string. + * @param {Node} contextNode - The XML node to execute the search against. + * @returns {Node[]} Array of matching Element nodes. + */ selectNodes(expression, contextNode = this.document) { - // Use the registered resolver instance return this.resolver(expression, contextNode) - .filter((node) => node?.nodeType === 1) // ELEMENT_NODE + .filter((node) => node?.nodeType === 1) // Ensure only ELEMENT_NODE } /** - * Helper to identify the correct keyword node based on the config. + * Identifies the specific keyword node to update or delete within a block. + * @param {Node} targetNode - The parent MD_Keywords block. + * @param {Object} correction - The user-provided change data. + * @param {Object} config - The configuration defining matching logic. + * @returns {Node|undefined} The matching node if found. */ findMatchingNode(targetNode, correction, config) { const keywordNodes = this.selectNodes('./gmd:keyword', targetNode) @@ -52,7 +62,7 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { fieldPaths: config.find.fieldPaths }) - // Use matchKeys if defined, otherwise fall back to oldKeywordObject keys + // Use defined matchKeys or default to existing object keys const matchKeys = config.find.matchKeys || Object.keys(correction.oldKeywordObject) return matchKeys.every((key) => { @@ -65,7 +75,11 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { } /** - * Updates leaf (direct) nodes like isotopiccategory and productlevelid. + * Updates or deletes leaf nodes (direct elements) based on configuration. + * Handles multi-step path deletions and attribute-based updates. + * @param {Object} correction - The change data. + * @param {Object} config - The node configuration mapping. + * @returns {boolean} True if the operation was successful. */ updateLeafNode(correction, config) { const { action, oldKeywordObject } = correction @@ -98,7 +112,6 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { }) }) } else if (targetNode.parentNode) { - // Use 'else if' to flatten the structure and satisfy the linter targetNode.parentNode.removeChild(targetNode) } @@ -143,8 +156,14 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { return false } + /** + * Updates complex keyword block nodes. + * Handles deletion of nested keywords and recursive cleanup of empty parents. + * @param {Object} correction - The change data. + * @param {Object} config - Configuration for the block node. + * @returns {boolean} True if the operation was successful. + */ updateBlockNode(correction, config) { - // 1. Find the parent block using your namespaced XPath const targetNode = this.selectNodes(config.nodeXPath)[0] || null if (!targetNode) return false @@ -153,10 +172,10 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { const matchingNode = this.findMatchingNode(targetNode, correction, config) if (!matchingNode) return false - // Remove the identified node + // Remove target node matchingNode.parentNode.removeChild(matchingNode) - // Cleanup empty parent blocks + // Cleanup: Remove parent blocks if they are now empty const remainingKeywords = this.selectNodes('./gmd:keyword', targetNode) if (remainingKeywords.length === 0) { const mdKeywordsParent = targetNode.parentNode @@ -179,7 +198,7 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { if (matchingNode) { let fieldNode = null - // Attempt dynamic path if defined in the config + // Determine dynamic path or default if (replaceConfig.fieldPath) { const path = typeof replaceConfig.fieldPath === 'function' ? replaceConfig.fieldPath({ @@ -192,13 +211,13 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { [fieldNode] = this.selectNodes(relativePath, matchingNode) } - // Fallback: look for standard gco:CharacterString + // Fallback search for standard string elements if (!fieldNode) { [fieldNode] = this.selectNodes('./gco:CharacterString', matchingNode) || this.selectNodes('gco:CharacterString', matchingNode) } - // Perform update + // Apply text update if (fieldNode) { const newValue = replaceConfig.source.getValue({ correction }) this.setElementText(fieldNode, newValue) From 6eb79451788e18f15dd68f700365380fc2fccdad Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Fri, 26 Jun 2026 20:35:17 -0400 Subject: [PATCH 10/27] KMS-686: Add test file for Iso19115DomEditor --- .../__tests__/Iso19115DomEditor.test.js | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 serverless/src/shared/__tests__/Iso19115DomEditor.test.js diff --git a/serverless/src/shared/__tests__/Iso19115DomEditor.test.js b/serverless/src/shared/__tests__/Iso19115DomEditor.test.js new file mode 100644 index 00000000..3326e605 --- /dev/null +++ b/serverless/src/shared/__tests__/Iso19115DomEditor.test.js @@ -0,0 +1,84 @@ +import { + describe, + expect, + test, + vi +} from 'vitest' + +import { ISO_19115_SCHEME_EDITORS } from '@/shared/Iso19115DomEditor' + +describe('ISO_19115_SCHEME_EDITORS', () => { + let mockEditor + + beforeEach(() => { + mockEditor = { + updateBlockNode: vi.fn(), + updateLeafNode: vi.fn() + } + }) + + test('should trigger updateBlockNode for keyword types', () => { + const correction = { + action: 'replace', + oldKeywordObject: { Value: 'test' } + } + + // Test a block-based editor (e.g., sciencekeywords) + ISO_19115_SCHEME_EDITORS.sciencekeywords(mockEditor, correction) + + expect(mockEditor.updateBlockNode).toHaveBeenCalledWith( + correction, + expect.objectContaining({ + nodeXPath: expect.stringContaining('MD_KeywordTypeCode') + }) + ) + }) + + test('should trigger updateLeafNode for leaf types', () => { + const correction = { + action: 'replace', + newKeywordObject: { Value: 'category' } + } + + // Test a leaf-based editor (e.g., isotopiccategory) + ISO_19115_SCHEME_EDITORS.isotopiccategory(mockEditor, correction) + + expect(mockEditor.updateLeafNode).toHaveBeenCalledWith( + correction, + expect.objectContaining({ + nodeXPath: expect.stringContaining('topicCategory') + }) + ) + }) + + test('should correctly format value for platforms', () => { + const correction = { + newKeywordObject: { ShortName: 'Aqua' }, + newLongName: 'Aqua Satellite' + } + const formatPlatform = () => { + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName || '' + + return LongName ? `${ShortName} > ${LongName}` : ShortName + } + + expect(formatPlatform(correction)).toBe('Aqua > Aqua Satellite') + }) + + test('should handle deletion for productlevelid', () => { + const correction = { + action: 'delete', + oldKeywordObject: { Value: 'L1' } + } + + ISO_19115_SCHEME_EDITORS.productlevelid(mockEditor, correction) + + expect(mockEditor.updateLeafNode).toHaveBeenCalledWith( + correction, + expect.objectContaining({ + delete: expect.any(Array) + }) + ) + }) +}) From 541890c2c05e45e9c989224c57fd25faef9997c2 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Sun, 28 Jun 2026 17:19:46 -0400 Subject: [PATCH 11/27] KMS-686: Add tests --- .../Iso19115MetadataPathEditor.test.js | 357 ++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 serverless/src/shared/__tests__/Iso19115MetadataPathEditor.test.js diff --git a/serverless/src/shared/__tests__/Iso19115MetadataPathEditor.test.js b/serverless/src/shared/__tests__/Iso19115MetadataPathEditor.test.js new file mode 100644 index 00000000..79edcba0 --- /dev/null +++ b/serverless/src/shared/__tests__/Iso19115MetadataPathEditor.test.js @@ -0,0 +1,357 @@ +import { + beforeEach, + describe, + expect, + test +} from 'vitest' + +import Iso19115MetadataPathEditor from '@/shared/Iso19115MetadataPathEditor' + +describe('Iso19115MetadataPathEditor', () => { + let editor + const xmlString = ` + + + + + farming + + + + + + Old Keyword + + + ` + + beforeEach(() => { + editor = new Iso19115MetadataPathEditor(xmlString) + }) + + test('should initialize namespaces correctly', () => { + expect(editor.namespaces).toHaveProperty('gmd') + expect(editor.namespaces.gmd).toBe('http://www.isotc211.org/2005/gmd') + }) + + test('selectNodes should filter by ELEMENT_NODE', () => { + const nodes = editor.selectNodes('//gmd:topicCategory') + expect(nodes.length).toBe(1) + expect(nodes[0].nodeType).toBe(1) + }) + + test('updateLeafNode should delete a simple leaf node', () => { + const config = { + nodeXPath: '//gmd:topicCategory', + find: { getNodeValueObject: () => ({ Value: 'farming' }) } + } + const correction = { + action: 'delete', + oldKeywordObject: { Value: 'farming' } + } + + const result = editor.updateLeafNode(correction, config) + + expect(result).toBe(true) + const nodes = editor.selectNodes('//gmd:topicCategory') + expect(nodes.length).toBe(0) + }) + + test('updateLeafNode should execute explicit delete paths when provided', () => { + // 1. Ensure the XML contains the node being searched for + const xml = ` + + + + L1 + + + ` + + // Create a new editor instance with this specific XML + const testEditor = new Iso19115MetadataPathEditor(xml) + + const config = { + nodeXPath: '//gmd:processingLevel/gmd:MD_Identifier', + find: { getNodeValueObject: (ctx) => ({ Value: ctx.node.textContent }) }, + delete: [ + { path: '//gmd:processingLevel/gmd:MD_Identifier' } + ] + } + const correction = { + action: 'delete', + oldKeywordObject: { Value: 'l1' } + } + + // This will now find the targetNode and proceed to execute the delete block + const result = testEditor.updateLeafNode(correction, config) + + expect(result).toBe(true) + }) + + test('updateLeafNode should update element attribute when fieldPath includes @', () => { + const xml = ` + + oldValue + ` + + const testEditor = new Iso19115MetadataPathEditor(xml) + + const config = { + nodeXPath: '//gmd:topicCategory', + find: { getNodeValueObject: () => ({ Value: 'oldValue' }) }, + replace: [{ + // Path targets the attribute on the child element + fieldPath: 'gmd:MD_TopicCategoryCode/@codeListValue', + source: { getValue: () => 'newValue' } + }] + } + + const correction = { + action: 'replace', + oldKeywordObject: { Value: 'oldValue' } + } + + const result = testEditor.updateLeafNode(correction, config) + + expect(result).toBe(true) + + // Verify the attribute was updated in the DOM + const element = testEditor.selectNodes('//gmd:MD_TopicCategoryCode')[0] + expect(element.getAttribute('codeListValue')).toBe('newValue') + }) + + test('updateLeafNode should return false if node to delete is not found', () => { + const config = { + nodeXPath: '//gmd:topicCategory', + find: { getNodeValueObject: () => ({ Value: 'missing' }) } + } + const correction = { + action: 'delete', + oldKeywordObject: { Value: 'non-existent' } + } + + const result = editor.updateLeafNode(correction, config) + expect(result).toBe(false) + }) + + test('updateLeafNode should replace text content', () => { + const config = { + nodeXPath: '//gmd:topicCategory', + find: { getNodeValueObject: () => ({ Value: 'farming' }) }, + replace: [{ + fieldPath: 'gmd:MD_TopicCategoryCode', + source: { getValue: () => 'updated-value' } + }] + } + const correction = { + action: 'replace', + oldKeywordObject: { Value: 'farming' } + } + + const result = editor.updateLeafNode(correction, config) + expect(result).toBe(true) + // Verification would depend on the implementation of setElementText + }) + + test('updateBlockNode should find and update keyword nodes', () => { + const config = { + nodeXPath: '//gmd:descriptiveKeywords/gmd:MD_Keywords', + find: { + getNodeValueObject: () => ({ Value: 'Old Keyword' }), + matchKeys: ['Value'] + }, + replace: [{ + fieldPath: 'gco:CharacterString', + source: { getValue: () => 'New Keyword' } + }] + } + const correction = { + action: 'replace', + oldKeywordObject: { Value: 'Old Keyword' }, + newKeywordObject: { Value: 'New Keyword' } + } + + const result = editor.updateBlockNode(correction, config) + expect(result).toBe(true) + }) + + test('updateBlockNode should delete empty parents after removing last keyword', () => { + const config = { + nodeXPath: '//gmd:descriptiveKeywords/gmd:MD_Keywords', + find: { + getNodeValueObject: () => ({ Value: 'Old Keyword' }), + matchKeys: ['Value'] + } + } + const correction = { + action: 'delete', + oldKeywordObject: { Value: 'Old Keyword' } + } + + const result = editor.updateBlockNode(correction, config) + expect(result).toBe(true) + + // Verify node removal + const remaining = editor.selectNodes('//gmd:descriptiveKeywords') + expect(remaining.length).toBe(0) + }) + + test('updateBlockNode should fallback to gco:CharacterString when specific fieldPath fails', () => { + const xml = ` + + + + + + + + Original Value + + + + ` + + const testEditor = new Iso19115MetadataPathEditor(xml) + + const config = { + nodeXPath: '//gmd:descriptiveKeywords/gmd:MD_Keywords', + find: { + // Add the missing function here + getNodeValueObject: ({ node }) => ({ Value: node.textContent.trim() }), + matchKeys: ['Value'] + }, + replace: [{ + fieldPath: 'invalid/path', + source: { getValue: () => 'New Value' } + }] + } + + const correction = { + action: 'replace', + oldKeywordObject: { Value: 'Original Value' }, + newKeywordObject: { Value: 'New Value' } + } + + const result = testEditor.updateBlockNode(correction, config) + + expect(result).toBe(true) + + const updatedNode = testEditor.selectNodes('//gco:CharacterString', testEditor.document)[0] + expect(updatedNode.textContent).toBe('New Value') + }) + + test('updateBlockNode should return false for unsupported actions', () => { + const xml = ` + + + + + + + + + ` + + const testEditor = new Iso19115MetadataPathEditor(xml) + + const config = { + nodeXPath: '//gmd:descriptiveKeywords/gmd:MD_Keywords', + find: { matchKeys: ['Value'] } + } + + // Use an action that is not 'delete' or 'replace' + const correction = { + action: 'unsupported', + oldKeywordObject: { Value: 'test' } + } + + const result = testEditor.updateBlockNode(correction, config) + + expect(result).toBe(false) + }) + + test('updateBlockNode should return false when replacement node is not found', () => { + const xml = ` + + + + + Existing Keyword + + + + ` + + const testEditor = new Iso19115MetadataPathEditor(xml) + + const config = { + nodeXPath: '//gmd:descriptiveKeywords/gmd:MD_Keywords', + find: { + getNodeValueObject: ({ node }) => ({ Value: node.textContent.trim() }), + matchKeys: ['Value'] + }, + replace: [{ + fieldPath: 'gco:CharacterString', + source: { getValue: () => 'New Value' } + }] + } + + // Correction object targeting a keyword that DOES NOT exist in the XML + const correction = { + action: 'replace', + oldKeywordObject: { Value: 'Non-existent Keyword' }, + newKeywordObject: { Value: 'New Value' } + } + + const result = testEditor.updateBlockNode(correction, config) + + expect(result).toBe(false) + }) + + test('updateBlockNode should return false when matching node exists but field node cannot be found', () => { + // Use an XML structure that explicitly lacks a gco:CharacterString + // within the matched keyword scope. + const xml = ` + + + + + Original Value + + + + ` + + const testEditor = new Iso19115MetadataPathEditor(xml) + + const config = { + nodeXPath: '//gmd:descriptiveKeywords/gmd:MD_Keywords', + find: { + getNodeValueObject: () => ({ Value: 'Original Value' }), + matchKeys: ['Value'] + }, + replace: [{ + // Ensure this path does not exist + fieldPath: 'non-existent/path', + source: { getValue: () => 'New Value' } + }] + } + + const correction = { + action: 'replace', + oldKeywordObject: { Value: 'Original Value' }, + newKeywordObject: { Value: 'New Value' } + } + + // 1. findMatchingNode finds the keyword node containing 'Original Value'. + // 2. The explicit fieldPath fails. + // 3. The fallback selectNodes('./gco:CharacterString', matchingNode) returns nothing. + // 4. fieldNode remains null. + // 5. The 'if (fieldNode)' check fails, skipping the update. + // 6. Execution hits the 'return false' at line 156. + const result = testEditor.updateBlockNode(correction, config) + + expect(result).toBe(false) + }) +}) From e61e2a5e81d672a535af025c3aab943a3e1bc55b Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Mon, 29 Jun 2026 14:35:40 -0400 Subject: [PATCH 12/27] KMS-686: Add allowed keyword domains --- serverless/src/shared/Iso19115DomEditor.js | 110 +++++++++++++-------- 1 file changed, 67 insertions(+), 43 deletions(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index ebc2797b..942eb6f1 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -1,6 +1,17 @@ import Iso19115MetadataPathEditor from './Iso19115MetadataPathEditor' import { FULL_PATH_VALUE_FIELDS } from './redis-path-store/helpers/constants' +/** + * A list of authorized domains used to validate the 'codeList' attribute + * within MD_KeywordTypeCode elements. This ensures only keywords originating + * from trusted metadata authorities are processed by the editor. + */ +const KEYWORD_DOMAINS = [ + 'cdn.earthdata.nasa.gov', // NASA Earthdata Resource Codelists + 'www.isotc211.org', // ISO TC 211 Standard Codelists + 'data.noaa.gov' // NOAA Metadata Authority Codelists +] + /** * Helper factory function to create a block editor configuration. * Maps a correction to an update operation within the editor instance. @@ -15,49 +26,56 @@ const blockScheme = (config) => (editor, correction) => editor.updateBlockNode(c */ const leafScheme = (config) => (editor, correction) => editor.updateLeafNode(correction, config) /** - * Factory to generate standardized keyword block editors for structures - * like Platforms and Instruments. - * * @param {string} type - The 'codeListValue' for the MD_KeywordTypeCode. - * @param {Object} options - Configuration for field parsing and value generation. - * @returns {Function} Configured block editor function. + * Factory to generate standardized keyword block editors. + * @param {string} type - The 'codeListValue' for the MD_KeywordTypeCode. + * @param {Object} options - Configuration options. + * @param {string[]} options.allowedCodelistDomains - List of domains (e.g., ['cdn.earthdata.nasa.gov', 'noaa.gov']). */ -const createKeywordBlock = (type, { fieldKeys, matchKeys, getValue }) => blockScheme({ - nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords[gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${type}']`, - find: { - fieldPaths: ['gmx:Anchor', 'gco:CharacterString'], - valueKeys: fieldKeys, - matchKeys, - /** - * Extracts values from the XML node and maps them to field keys. - * Handles hierarchical strings (l1 > l2). - */ - getNodeValueObject: ({ node, editor }) => { - const anchorNode = editor.selectNodes('./gmx:Anchor', node)[0] - const charStringNode = editor.selectNodes('./gco:CharacterString', node)[0] - - const fullString = (anchorNode || charStringNode)?.textContent || '' - // Parsing logic: Hierarchical (l1 > l2) or Simple (short > long) - const parts = fullString.split(' > ').map((s) => s.trim()) - - return fieldKeys.reduce((acc, key, index) => { - acc[key] = parts[index] || '' - - return acc - }, { Value: fullString.trim() }) - } - }, - replace: [ - { - // Dynamically select target element (Anchor vs CharacterString) based on existing content - fieldPath: ({ node, editor }) => (editor.selectNodes('./gmx:Anchor', node).length > 0 ? 'gmx:Anchor' : 'gco:CharacterString'), - source: { - type: 'computed', - // Allows custom formatting for keyword strings; defaults to joining keys with ' > ' - getValue: getValue || (({ correction }) => fieldKeys.map((k) => correction.newKeywordObject[k]).filter(Boolean).join(' > ')) +const createKeywordBlock = (type, { + fieldKeys, matchKeys, getValue, allowedCodelistDomains = [] +}) => { + // Construct the XPath predicate to check if the codeList contains any of the allowed domains + const domainConditions = allowedCodelistDomains + .map((domain) => `contains(gmd:type/gmd:MD_KeywordTypeCode/@codeList, '${domain}')`) + .join(' or ') + + const predicate = domainConditions ? `and (${domainConditions})` : '' + + return blockScheme({ + nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords[ + gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${type}' + ${predicate} + ]`.replace(/\s+/g, ' '), + + find: { + fieldPaths: ['gmx:Anchor', 'gco:CharacterString'], + valueKeys: fieldKeys, + matchKeys, + getNodeValueObject: ({ node, editor }) => { + const anchorNode = editor.selectNodes('./gmx:Anchor', node)[0] + const charStringNode = editor.selectNodes('./gco:CharacterString', node)[0] + const fullString = (anchorNode || charStringNode)?.textContent || '' + const parts = fullString.split(' > ').map((s) => s.trim()) + + return fieldKeys.reduce((acc, key, index) => { + acc[key] = parts[index] || '' + + return acc + }, { Value: fullString.trim() }) } - } - ] -}) + }, + replace: [ + { + fieldPath: ({ node, editor }) => (editor.selectNodes('./gmx:Anchor', node).length > 0 ? 'gmx:Anchor' : 'gco:CharacterString'), + source: { + type: 'computed', + getValue: getValue || (({ correction }) => fieldKeys.map((k) => correction.newKeywordObject[k]).filter(Boolean).join(' > ')) + } + } + ] + }) +} + /** * Creates an editor for ISO Topic Category nodes. * Updates both the text content and the @codeListValue attribute. @@ -131,7 +149,8 @@ export const ISO_19115_SCHEME_EDITORS = { 'theme', { fieldKeys: FULL_PATH_VALUE_FIELDS.sciencekeywords, - matchKeys: ['Value'] + matchKeys: ['Value'], + allowedCodelistDomains: KEYWORD_DOMAINS } ), @@ -139,13 +158,15 @@ export const ISO_19115_SCHEME_EDITORS = { 'place', { fieldKeys: FULL_PATH_VALUE_FIELDS.locations, - matchKeys: ['Value'] + matchKeys: ['Value'], + allowedCodelistDomains: KEYWORD_DOMAINS } ), platforms: createKeywordBlock('platform', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], + allowedCodelistDomains: KEYWORD_DOMAINS, getValue: ({ correction }) => { const { ShortName } = correction.newKeywordObject const LongName = correction.newLongName || '' @@ -157,6 +178,7 @@ export const ISO_19115_SCHEME_EDITORS = { instruments: createKeywordBlock('instrument', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], + allowedCodelistDomains: KEYWORD_DOMAINS, getValue: ({ correction }) => { const { ShortName } = correction.newKeywordObject const LongName = correction.newLongName || '' @@ -168,6 +190,7 @@ export const ISO_19115_SCHEME_EDITORS = { projects: createKeywordBlock('project', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], + allowedCodelistDomains: KEYWORD_DOMAINS, getValue: ({ correction }) => { const { ShortName } = correction.newKeywordObject const LongName = correction.newLongName || '' @@ -179,6 +202,7 @@ export const ISO_19115_SCHEME_EDITORS = { providers: createKeywordBlock('dataCentre', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], + allowedCodelistDomains: KEYWORD_DOMAINS, getValue: ({ correction }) => { const { ShortName } = correction.newKeywordObject const LongName = correction.newLongName || '' From e87d4204fc11b5fc0df777f7a1855b4a4fcb8116 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Mon, 29 Jun 2026 18:02:08 -0400 Subject: [PATCH 13/27] KMS-686: Add smoke test script, fix code for correction object --- ...rection_service.iso-19115.corrections.json | 126 ++++++++ ...a_correction_service.iso-19115.example.xml | 279 ++++++++++++++++++ ...ollection_concept_ids_by_native_format.mjs | 4 +- ...un_metadata_correction_iso-19115_smoke.mjs | 54 ++++ serverless/src/shared/Iso19115DomEditor.js | 110 ++++--- .../applyIso19115MetadataCorrections.test.js | 48 ++- 6 files changed, 572 insertions(+), 49 deletions(-) create mode 100644 scripts/local/fixtures/metadata_correction_service.iso-19115.corrections.json create mode 100644 scripts/local/fixtures/metadata_correction_service.iso-19115.example.xml create mode 100644 scripts/local/run_metadata_correction_iso-19115_smoke.mjs diff --git a/scripts/local/fixtures/metadata_correction_service.iso-19115.corrections.json b/scripts/local/fixtures/metadata_correction_service.iso-19115.corrections.json new file mode 100644 index 00000000..7b77f10a --- /dev/null +++ b/scripts/local/fixtures/metadata_correction_service.iso-19115.corrections.json @@ -0,0 +1,126 @@ +{ + "collectionConceptId": "C1234567222-LOCAL", + "nativeFormat": "ISO19115", + "source": "local-smoke", + "corrections": [ + { + "scheme": "sciencekeywords", + "action": "replace", + "oldKeywordObject": { + "Category": "EARTH SCIENCE", + "Topic": "ATMOSPHERE", + "Term": "AEROSOLS", + "VariableLevel1": "", + "VariableLevel2": "", + "VariableLevel3": "", + "DetailedVariable": "" + }, + "newKeywordObject": { + "Category": "EARTH SCIENCE", + "Topic": "OCEANS", + "Term": "MARINE SEDIMENTS", + "VariableLevel1": "PARTICLE SIZE", + "VariableLevel2": "", + "VariableLevel3": "", + "DetailedVariable": "" + } + }, + { + "scheme": "locations", + "action": "replace", + "oldKeywordObject": { + "Category": "CONTINENT", + "Type": "NORTH AMERICA", + "Subregion1": "CANADA", + "Subregion2": "ALBERTA", + "Subregion3": "", + "DetailedLocation": "" + }, + "newKeywordObject": { + "Category": "CONTINENT", + "Type": "NORTH AMERICA", + "Subregion1": "MEXICO", + "Subregion2": "", + "Subregion3": "", + "DetailedLocation": "" + } + }, + { + "scheme": "platforms", + "action": "replace", + "newLongName": "New Platform Long Name", + "oldKeywordObject": { + "Class": "Air-based Platforms", + "Type": "Rotorcraft/Helicopter", + "ShortName": "AQUA" + }, + "newKeywordObject": { + "Class": "Air-based Platforms", + "Type": "Rotorcraft/Helicopter", + "ShortName": "AQUA" + } + }, + { + "scheme": "instruments", + "action": "delete", + "oldKeywordObject": { + "Category": "Imaging Spectrometers/Radiometers", + "Class": "", + "Subclass": "", + "ShortName": "ATLAS" + } + }, + { + "scheme": "projects", + "action": "replace", + "newLongName": "New Project Long Name", + "oldKeywordObject": { + "Category": "M - N", + "ShortName": "MEASURES" + }, + "newKeywordObject": { + "Category": "M - N", + "ShortName": "MEASURES-UPDATED" + } + }, + { + "scheme": "providers", + "action": "replace", + "newLongName": "New Provider Long Name", + "oldKeywordObject": { + "BucketLevel0": "ARCHIVER", + "BucketLevel1": "", + "BucketLevel2": "", + "BucketLevel3": "", + "ShortName": "DOC/NOAA/NESDIS/NCEI" + }, + "newKeywordObject": { + "BucketLevel0": "ARCHIVER", + "BucketLevel1": "", + "BucketLevel2": "", + "BucketLevel3": "", + "ShortName": "DOC/NOAA/NESDIS/NCEI-1" + } + }, + { + "scheme": "isotopiccategory", + "action": "replace", + "oldKeywordObject": { + "Value": "FARMING" + }, + "newKeywordObject": { + "Value": "BIOTA" + } + }, + { + "scheme": "productlevelid", + "action": "replace", + "oldKeywordObject": { + "Value": "3" + }, + "newKeywordObject": { + "Value": "5" + } + } + ] +} \ No newline at end of file diff --git a/scripts/local/fixtures/metadata_correction_service.iso-19115.example.xml b/scripts/local/fixtures/metadata_correction_service.iso-19115.example.xml new file mode 100644 index 00000000..b1917d4d --- /dev/null +++ b/scripts/local/fixtures/metadata_correction_service.iso-19115.example.xml @@ -0,0 +1,279 @@ + + + + + LOCATION + + + FARMING + + + ELEVATION + + + + + 3 + + + gov.nasa.esdis.umm.processinglevelid + + + + + + + DOC/NOAA/NESDIS/NODC > National Oceanographic Data Center, NESDIS, NOAA, U.S. Department of Commerce + + + DOC/NOAA/NESDIS/NCEI > National Centers for Environmental Information, NESDIS, NOAA, U.S. Department of Commerce + + + dataCentre + + + + + Global Change Master Directory (GCMD) Data Center Keywords + + + + + 2019-11-12 + + + publication + + + + + 9 + + + + + Global Change Data Center, Science and Exploration Directorate, Goddard Space Flight Center (GSFC) National Aeronautics and Space Administration (NASA) + + + + + + + Greenbelt + + + MD + + + + + + + https://wiki.earthdata.nasa.gov/display/gcmdkey + + + HTTPS + + + GCMD Keyword Forum Page + + + The information provided on this page seeks to define how the GCMD Keywords are structured, used and accessed. It also provides information on how users can participate in the further development of the keywords. + + + information + + + + + + + custodian + + + + + + + + + + + EARTH SCIENCE > Cryosphere > Glaciers/Ice Sheets > Firn > Snow Grain Size + + + EARTH SCIENCE > ATMOSPHERE > AEROSOLS + + + EARTH SCIENCE > Cryosphere > Glaciers/Ice Sheets > Glacier Topography/Ice Sheet Topography > Surface Morphology + + + EARTH SCIENCE > Cryosphere > Glaciers/Ice Sheets > Glacier Topography/Ice Sheet Topography > Surface Morphology + + + theme + + + + + NASA / GCMD Science Keywords + + + + + 2008-02-05 + + + revision + + + + + + + + + + + TERRA > Earth Observing System, TERRA (AM-1) + + + AQUA > Earth Observing System + + + platform + + + + + NASA / GCMD Platform Keywords + + + + + 2016-06-10 + + + revision + + + + + + + + + + + ATLAS > Advanced Topographic Laser Altimeter System + + + MODIS > Moderate-Resolution Imaging Spectroradiometer + + + instrument + + + + + NASA / GCMD Instrument Keywords + + + + + 2016-06-01 + + + revision + + + + + + + + + + + MEASURES > Making Earth System Data Records for Use in Research Environments + + + MAGIA > Structure, Stratigraphy, and Sedimentology North of the Antarctic Peninsula + + + project + + + + + NASA / GCMD Project Keywords + + + + + 2008-01-24 + + + revision + + + + + + + + + + + Continent > North America > Greenland + + + Continent > North America > Canada > Alberta + + + place + + + + + NASA / GCMD Location Keywords + + + + + 2008-02-05 + + + revision + + + + + + + + + + + + + + + 3 + + + gov.nasa.esdis.umm.processinglevelid + + + + + + \ No newline at end of file diff --git a/scripts/local/list_collection_concept_ids_by_native_format.mjs b/scripts/local/list_collection_concept_ids_by_native_format.mjs index 27d23f06..12eba0fa 100644 --- a/scripts/local/list_collection_concept_ids_by_native_format.mjs +++ b/scripts/local/list_collection_concept_ids_by_native_format.mjs @@ -31,7 +31,9 @@ const ENVIRONMENT_BASE_URLS = { const FORMAT_ALIASES = { dif10: 'application/dif10+xml', echo10: 'application/echo10+xml', - umm: 'application/vnd.nasa.cmr.umm+json' + umm: 'application/vnd.nasa.cmr.umm+json', + iso19115: 'application/iso19115+xml', + isosmap: 'application/iso:smap+xml' } /** diff --git a/scripts/local/run_metadata_correction_iso-19115_smoke.mjs b/scripts/local/run_metadata_correction_iso-19115_smoke.mjs new file mode 100644 index 00000000..2e6a8c83 --- /dev/null +++ b/scripts/local/run_metadata_correction_iso-19115_smoke.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises' +import path from 'node:path' + +const rootDir = path.resolve(import.meta.dirname, '../..') +const fixturePath = path.resolve( + rootDir, + 'scripts/local/fixtures/metadata_correction_service.iso-19115.corrections.json' +) +const metadataPayloadPath = path.resolve( + rootDir, + 'scripts/local/fixtures/metadata_correction_service.iso-19115.example.xml' +) +const outputPath = path.resolve( + rootDir, + 'tmp/metadata_correction_service_iso-19115_smoke_output.xml' +) + +const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8')) +const metadataPayload = await fs.readFile(metadataPayloadPath, 'utf8') + +const { applyIso19115MetadataCorrections } = await import('../../serverless/src/shared/applyIso19115MetadataCorrections') +const { writeCorrectedMetadataToCmr } = await import('../../serverless/src/shared/writeCorrectedMetadataToCmr') + +const request = { + ...fixture, + metadataPayload +} + +const correctionResult = await applyIso19115MetadataCorrections(request) + +await fs.mkdir(path.dirname(outputPath), { recursive: true }) +await fs.writeFile(outputPath, correctionResult.correctedMetadata || '', 'utf8') + +const writeResult = await writeCorrectedMetadataToCmr({ + collectionConceptId: request.collectionConceptId, + nativeFormat: request.nativeFormat, + correctedMetadata: correctionResult.correctedMetadata || '', + correctionCount: correctionResult.correctionCount || 0, + correctionsApplied: correctionResult.correctionsApplied || [], + source: request.source || 'local-smoke' +}) + +console.log('[metadata-correction-smoke] Applied corrections locally') +console.log(JSON.stringify({ + fixturePath, + collectionConceptId: request.collectionConceptId, + nativeFormat: request.nativeFormat, + requestedCorrections: request.corrections.length, + appliedCorrections: correctionResult.correctionCount || 0, + outputPath, + writeResult +}, null, 2)) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index ebc2797b..45a0d684 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -1,6 +1,17 @@ import Iso19115MetadataPathEditor from './Iso19115MetadataPathEditor' import { FULL_PATH_VALUE_FIELDS } from './redis-path-store/helpers/constants' +/** + * A list of authorized domains used to validate the 'codeList' attribute + * within MD_KeywordTypeCode elements. This ensures only keywords originating + * from trusted metadata authorities are processed by the editor. + */ +const KEYWORD_DOMAINS = [ + 'cdn.earthdata.nasa.gov', // NASA Earthdata Resource Codelists + 'www.isotc211.org', // ISO TC 211 Standard Codelists + 'data.noaa.gov' // NOAA Metadata Authority Codelists +] + /** * Helper factory function to create a block editor configuration. * Maps a correction to an update operation within the editor instance. @@ -15,49 +26,56 @@ const blockScheme = (config) => (editor, correction) => editor.updateBlockNode(c */ const leafScheme = (config) => (editor, correction) => editor.updateLeafNode(correction, config) /** - * Factory to generate standardized keyword block editors for structures - * like Platforms and Instruments. - * * @param {string} type - The 'codeListValue' for the MD_KeywordTypeCode. - * @param {Object} options - Configuration for field parsing and value generation. - * @returns {Function} Configured block editor function. + * Factory to generate standardized keyword block editors. + * @param {string} type - The 'codeListValue' for the MD_KeywordTypeCode. + * @param {Object} options - Configuration options. + * @param {string[]} options.allowedCodelistDomains - List of domains (e.g., ['cdn.earthdata.nasa.gov', 'noaa.gov']). */ -const createKeywordBlock = (type, { fieldKeys, matchKeys, getValue }) => blockScheme({ - nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords[gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${type}']`, - find: { - fieldPaths: ['gmx:Anchor', 'gco:CharacterString'], - valueKeys: fieldKeys, - matchKeys, - /** - * Extracts values from the XML node and maps them to field keys. - * Handles hierarchical strings (l1 > l2). - */ - getNodeValueObject: ({ node, editor }) => { - const anchorNode = editor.selectNodes('./gmx:Anchor', node)[0] - const charStringNode = editor.selectNodes('./gco:CharacterString', node)[0] - - const fullString = (anchorNode || charStringNode)?.textContent || '' - // Parsing logic: Hierarchical (l1 > l2) or Simple (short > long) - const parts = fullString.split(' > ').map((s) => s.trim()) - - return fieldKeys.reduce((acc, key, index) => { - acc[key] = parts[index] || '' - - return acc - }, { Value: fullString.trim() }) - } - }, - replace: [ - { - // Dynamically select target element (Anchor vs CharacterString) based on existing content - fieldPath: ({ node, editor }) => (editor.selectNodes('./gmx:Anchor', node).length > 0 ? 'gmx:Anchor' : 'gco:CharacterString'), - source: { - type: 'computed', - // Allows custom formatting for keyword strings; defaults to joining keys with ' > ' - getValue: getValue || (({ correction }) => fieldKeys.map((k) => correction.newKeywordObject[k]).filter(Boolean).join(' > ')) +const createKeywordBlock = (type, { + fieldKeys, matchKeys, getValue, allowedCodelistDomains = [] +}) => { + // Construct the XPath predicate to check if the codeList contains any of the allowed domains + const domainConditions = allowedCodelistDomains + .map((domain) => `contains(gmd:type/gmd:MD_KeywordTypeCode/@codeList, '${domain}')`) + .join(' or ') + + const predicate = domainConditions ? `and (${domainConditions})` : '' + + return blockScheme({ + nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords[ + gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${type}' + ${predicate} + ]`.replace(/\s+/g, ' '), + + find: { + fieldPaths: ['gmx:Anchor', 'gco:CharacterString'], + valueKeys: fieldKeys, + matchKeys, + getNodeValueObject: ({ node, editor }) => { + const anchorNode = editor.selectNodes('./gmx:Anchor', node)[0] + const charStringNode = editor.selectNodes('./gco:CharacterString', node)[0] + const fullString = (anchorNode || charStringNode)?.textContent || '' + const parts = fullString.split(' > ').map((s) => s.trim()) + + return fieldKeys.reduce((acc, key, index) => { + acc[key] = parts[index] || '' + + return acc + }, { Value: fullString.trim() }) } - } - ] -}) + }, + replace: [ + { + fieldPath: ({ node, editor }) => (editor.selectNodes('./gmx:Anchor', node).length > 0 ? 'gmx:Anchor' : 'gco:CharacterString'), + source: { + type: 'computed', + getValue: getValue || (({ correction }) => fieldKeys.map((k) => correction.newKeywordObject[k]).filter(Boolean).join(' > ')) + } + } + ] + }) +} + /** * Creates an editor for ISO Topic Category nodes. * Updates both the text content and the @codeListValue attribute. @@ -131,7 +149,8 @@ export const ISO_19115_SCHEME_EDITORS = { 'theme', { fieldKeys: FULL_PATH_VALUE_FIELDS.sciencekeywords, - matchKeys: ['Value'] + matchKeys: FULL_PATH_VALUE_FIELDS.sciencekeywords, + allowedCodelistDomains: KEYWORD_DOMAINS } ), @@ -139,13 +158,15 @@ export const ISO_19115_SCHEME_EDITORS = { 'place', { fieldKeys: FULL_PATH_VALUE_FIELDS.locations, - matchKeys: ['Value'] + matchKeys: FULL_PATH_VALUE_FIELDS.locations, + allowedCodelistDomains: KEYWORD_DOMAINS } ), platforms: createKeywordBlock('platform', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], + allowedCodelistDomains: KEYWORD_DOMAINS, getValue: ({ correction }) => { const { ShortName } = correction.newKeywordObject const LongName = correction.newLongName || '' @@ -157,6 +178,7 @@ export const ISO_19115_SCHEME_EDITORS = { instruments: createKeywordBlock('instrument', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], + allowedCodelistDomains: KEYWORD_DOMAINS, getValue: ({ correction }) => { const { ShortName } = correction.newKeywordObject const LongName = correction.newLongName || '' @@ -168,6 +190,7 @@ export const ISO_19115_SCHEME_EDITORS = { projects: createKeywordBlock('project', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], + allowedCodelistDomains: KEYWORD_DOMAINS, getValue: ({ correction }) => { const { ShortName } = correction.newKeywordObject const LongName = correction.newLongName || '' @@ -179,6 +202,7 @@ export const ISO_19115_SCHEME_EDITORS = { providers: createKeywordBlock('dataCentre', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], + allowedCodelistDomains: KEYWORD_DOMAINS, getValue: ({ correction }) => { const { ShortName } = correction.newKeywordObject const LongName = correction.newLongName || '' diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js index f399f63a..91ca8483 100644 --- a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -336,7 +336,15 @@ describe('when applying sciencekeywords ISO-19115 corrections', () => { const correction = { scheme: 'sciencekeywords', action: 'replace', - oldKeywordObject: { Value: 'EARTH SCIENCE > ATMOSPHERE > AEROSOLS' }, + oldKeywordObject: { + Category: 'EARTH SCIENCE', + Topic: 'ATMOSPHERE', + Term: 'AEROSOLS', + VariableLevel1: '', + VariableLevel2: '', + VariableLevel3: '', + DetailedVariable: '' + }, newKeywordObject: { Category: 'EARTH SCIENCE', Topic: 'OCEANS', @@ -364,7 +372,15 @@ describe('when applying sciencekeywords ISO-19115 corrections', () => { const correction = { scheme: 'sciencekeywords', action: 'delete', - oldKeywordObject: { Value: 'EARTH SCIENCE > ATMOSPHERE > AEROSOLS' } + oldKeywordObject: { + Category: 'EARTH SCIENCE', + Topic: 'ATMOSPHERE', + Term: 'AEROSOLS', + VariableLevel1: '', + VariableLevel2: '', + VariableLevel3: '', + DetailedVariable: '' + } } const config = ISO_19115_SCHEME_EDITORS.sciencekeywords @@ -383,7 +399,15 @@ describe('when applying sciencekeywords ISO-19115 corrections', () => { const correction = { scheme: 'sciencekeywords', action: 'delete', - oldKeywordObject: { Value: 'EARTH SCIENCE > ATMOSPHERE > AEROSOLS' } + oldKeywordObject: { + Category: 'EARTH SCIENCE', + Topic: 'ATMOSPHERE', + Term: 'AEROSOLS', + VariableLevel1: '', + VariableLevel2: '', + VariableLevel3: '', + DetailedVariable: '' + } } const config = ISO_19115_SCHEME_EDITORS.sciencekeywords @@ -405,7 +429,14 @@ describe('when applying locations ISO-19115 corrections', () => { const correction = { scheme: 'locations', action: 'replace', - oldKeywordObject: { Value: 'CONTINENT > NORTH AMERICA > CANADA > ALBERTA' }, + oldKeywordObject: { + Category: 'CONTINENT', + Type: 'NORTH AMERICA', + Subregion1: 'CANADA', + Subregion2: 'ALBERTA', + Subregion3: '', + DetailedLocation: '' + }, newKeywordObject: { Category: 'CONTINENT', Type: 'NORTH AMERICA', @@ -432,7 +463,14 @@ describe('when applying locations ISO-19115 corrections', () => { const correction = { scheme: 'locations', action: 'delete', - oldKeywordObject: { Value: 'Continent > North America > Greenland' } + oldKeywordObject: { + Category: 'Continent', + Type: 'North America', + Subregion1: 'Greenland', + Subregion2: '', + Subregion3: '', + DetailedLocation: '' + } } const config = ISO_19115_SCHEME_EDITORS.locations From 903f88c112bd5d81f3d8028f4819defb164dd85c Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Mon, 29 Jun 2026 18:07:43 -0400 Subject: [PATCH 14/27] KMS-686: Fix test for iso19115 --- .../__tests__/metadataCorrectionDelegateStubs.test.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/serverless/src/shared/__tests__/metadataCorrectionDelegateStubs.test.js b/serverless/src/shared/__tests__/metadataCorrectionDelegateStubs.test.js index fee433be..38044aa2 100644 --- a/serverless/src/shared/__tests__/metadataCorrectionDelegateStubs.test.js +++ b/serverless/src/shared/__tests__/metadataCorrectionDelegateStubs.test.js @@ -202,7 +202,15 @@ describe('metadata correction delegate stubs', () => { const correction = { scheme: 'sciencekeywords', action: 'replace', - oldKeywordObject: { Value: 'EARTH SCIENCE > ATMOSPHERE > AEROSOLS' }, + oldKeywordObject: { + Category: 'EARTH SCIENCE', + Topic: 'ATMOSPHERE', + Term: 'AEROSOLS', + VariableLevel1: '', + VariableLevel2: '', + VariableLevel3: '', + DetailedVariable: '' + }, newKeywordObject: { Category: 'EARTH SCIENCE', Topic: 'ATMOSPHERE', From 58df8ecaa6cf3eeaa6d290ee55e58cc925b32623 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Tue, 30 Jun 2026 13:39:57 -0400 Subject: [PATCH 15/27] KMS-686: Fix tests --- .../Iso19115MetadataPathEditor.test.js | 44 ++++++++++++++++ .../applyIso19115MetadataCorrections.test.js | 51 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/serverless/src/shared/__tests__/Iso19115MetadataPathEditor.test.js b/serverless/src/shared/__tests__/Iso19115MetadataPathEditor.test.js index 79edcba0..22545fed 100644 --- a/serverless/src/shared/__tests__/Iso19115MetadataPathEditor.test.js +++ b/serverless/src/shared/__tests__/Iso19115MetadataPathEditor.test.js @@ -29,6 +29,50 @@ describe('Iso19115MetadataPathEditor', () => { editor = new Iso19115MetadataPathEditor(xmlString) }) + test('should fallback to gco:CharacterString when explicit fieldPath is missing or invalid', () => { + // 1. Setup: Keyword block WITHOUT a specific sub-path defined in replaceConfig, + // but WITH a standard gco:CharacterString + const xml = ` + + + + + Old Value + + + + ` + + editor = new Iso19115MetadataPathEditor(xml) + + // 2. Setup: Config with no fieldPath, forcing the fallback + const config = { + nodeXPath: '//gmd:descriptiveKeywords/gmd:MD_Keywords', + find: { + matchKeys: ['Value'], + getNodeValueObject: ({ node }) => ({ Value: node.textContent.trim() }) + }, + replace: [{ + // No fieldPath provided + source: { getValue: () => 'New Value' } + }] + } + + const correction = { + action: 'replace', + oldKeywordObject: { Value: 'Old Value' }, + newKeywordObject: { Value: 'New Value' } + } + + // 3. Execute + const success = editor.updateBlockNode(correction, config) + + // 4. Assertions + expect(success).toBe(true) + expect(editor.serialize()).toContain('New Value') + expect(editor.serialize()).not.toContain('Old Value') + }) + test('should initialize namespaces correctly', () => { expect(editor.namespaces).toHaveProperty('gmd') expect(editor.namespaces.gmd).toBe('http://www.isotc211.org/2005/gmd') diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js index 91ca8483..0b106f29 100644 --- a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -4,6 +4,7 @@ import { test } from 'vitest' +import applyIso19115MetadataCorrections from '../applyIso19115MetadataCorrections' import { ISO_19115_SCHEME_EDITORS } from '../Iso19115DomEditor' import Iso19115MetadataPathEditor from '../Iso19115MetadataPathEditor' @@ -330,6 +331,56 @@ const mockIso19115WithOneScienceKeyword = ` ` +describe('applyIso19115MetadataCorrections', () => { + test('should handle missing corrections array gracefully', async () => { + const params = { + metadataPayload: '' + } + + const result = await applyIso19115MetadataCorrections(params) + + expect(result.correctionCount).toBe(0) + expect(result.correctionsApplied).toEqual([]) + expect(result.correctedMetadata).toBeDefined() + }) + + test('should return early when metadataPayload is missing', async () => { + const params = { + metadataPayload: undefined, + corrections: [] + } + + const result = await applyIso19115MetadataCorrections(params) + + expect(result).toEqual({ + correctionCount: 0, + correctedMetadata: undefined, + correctionsApplied: [], + stubbed: false + }) + }) + + test('should skip corrections with unknown schemes', async () => { + const params = { + metadataPayload: '', + corrections: [ + { + scheme: 'invalid-scheme', + action: 'replace', + oldKeywordObject: { Value: 'test' }, + newKeywordObject: { Value: 'new' } + } + ] + } + + const result = await applyIso19115MetadataCorrections(params) + + // The correction was invalid, so nothing should be applied + expect(result.correctionCount).toBe(0) + expect(result.correctionsApplied).toEqual([]) + }) +}) + describe('when applying sciencekeywords ISO-19115 corrections', () => { test('should replace existing science keyword correctly', () => { const editor = new Iso19115MetadataPathEditor(mockIso19115) From c881ae795df7e76b1d7597265b836b731ca540f1 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Tue, 30 Jun 2026 16:21:19 -0400 Subject: [PATCH 16/27] KMS-686: Remove keyword domain list --- serverless/src/shared/Iso19115DomEditor.js | 90 ++++++++-------------- 1 file changed, 31 insertions(+), 59 deletions(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index 45a0d684..eada142f 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -1,17 +1,6 @@ import Iso19115MetadataPathEditor from './Iso19115MetadataPathEditor' import { FULL_PATH_VALUE_FIELDS } from './redis-path-store/helpers/constants' -/** - * A list of authorized domains used to validate the 'codeList' attribute - * within MD_KeywordTypeCode elements. This ensures only keywords originating - * from trusted metadata authorities are processed by the editor. - */ -const KEYWORD_DOMAINS = [ - 'cdn.earthdata.nasa.gov', // NASA Earthdata Resource Codelists - 'www.isotc211.org', // ISO TC 211 Standard Codelists - 'data.noaa.gov' // NOAA Metadata Authority Codelists -] - /** * Helper factory function to create a block editor configuration. * Maps a correction to an update operation within the editor instance. @@ -29,52 +18,41 @@ const leafScheme = (config) => (editor, correction) => editor.updateLeafNode(cor * Factory to generate standardized keyword block editors. * @param {string} type - The 'codeListValue' for the MD_KeywordTypeCode. * @param {Object} options - Configuration options. - * @param {string[]} options.allowedCodelistDomains - List of domains (e.g., ['cdn.earthdata.nasa.gov', 'noaa.gov']). */ const createKeywordBlock = (type, { - fieldKeys, matchKeys, getValue, allowedCodelistDomains = [] -}) => { - // Construct the XPath predicate to check if the codeList contains any of the allowed domains - const domainConditions = allowedCodelistDomains - .map((domain) => `contains(gmd:type/gmd:MD_KeywordTypeCode/@codeList, '${domain}')`) - .join(' or ') - - const predicate = domainConditions ? `and (${domainConditions})` : '' - - return blockScheme({ - nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords[ + fieldKeys, matchKeys, getValue +}) => blockScheme({ + nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords[ gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${type}' - ${predicate} ]`.replace(/\s+/g, ' '), - find: { - fieldPaths: ['gmx:Anchor', 'gco:CharacterString'], - valueKeys: fieldKeys, - matchKeys, - getNodeValueObject: ({ node, editor }) => { - const anchorNode = editor.selectNodes('./gmx:Anchor', node)[0] - const charStringNode = editor.selectNodes('./gco:CharacterString', node)[0] - const fullString = (anchorNode || charStringNode)?.textContent || '' - const parts = fullString.split(' > ').map((s) => s.trim()) - - return fieldKeys.reduce((acc, key, index) => { - acc[key] = parts[index] || '' - - return acc - }, { Value: fullString.trim() }) - } - }, - replace: [ - { - fieldPath: ({ node, editor }) => (editor.selectNodes('./gmx:Anchor', node).length > 0 ? 'gmx:Anchor' : 'gco:CharacterString'), - source: { - type: 'computed', - getValue: getValue || (({ correction }) => fieldKeys.map((k) => correction.newKeywordObject[k]).filter(Boolean).join(' > ')) - } + find: { + fieldPaths: ['gmx:Anchor', 'gco:CharacterString'], + valueKeys: fieldKeys, + matchKeys, + getNodeValueObject: ({ node, editor }) => { + const anchorNode = editor.selectNodes('./gmx:Anchor', node)[0] + const charStringNode = editor.selectNodes('./gco:CharacterString', node)[0] + const fullString = (anchorNode || charStringNode)?.textContent || '' + const parts = fullString.split(' > ').map((s) => s.trim()) + + return fieldKeys.reduce((acc, key, index) => { + acc[key] = parts[index] || '' + + return acc + }, { Value: fullString.trim() }) + } + }, + replace: [ + { + fieldPath: ({ node, editor }) => (editor.selectNodes('./gmx:Anchor', node).length > 0 ? 'gmx:Anchor' : 'gco:CharacterString'), + source: { + type: 'computed', + getValue: getValue || (({ correction }) => fieldKeys.map((k) => correction.newKeywordObject[k]).filter(Boolean).join(' > ')) } - ] - }) -} + } + ] +}) /** * Creates an editor for ISO Topic Category nodes. @@ -149,8 +127,7 @@ export const ISO_19115_SCHEME_EDITORS = { 'theme', { fieldKeys: FULL_PATH_VALUE_FIELDS.sciencekeywords, - matchKeys: FULL_PATH_VALUE_FIELDS.sciencekeywords, - allowedCodelistDomains: KEYWORD_DOMAINS + matchKeys: FULL_PATH_VALUE_FIELDS.sciencekeywords } ), @@ -158,15 +135,13 @@ export const ISO_19115_SCHEME_EDITORS = { 'place', { fieldKeys: FULL_PATH_VALUE_FIELDS.locations, - matchKeys: FULL_PATH_VALUE_FIELDS.locations, - allowedCodelistDomains: KEYWORD_DOMAINS + matchKeys: FULL_PATH_VALUE_FIELDS.locations } ), platforms: createKeywordBlock('platform', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], - allowedCodelistDomains: KEYWORD_DOMAINS, getValue: ({ correction }) => { const { ShortName } = correction.newKeywordObject const LongName = correction.newLongName || '' @@ -178,7 +153,6 @@ export const ISO_19115_SCHEME_EDITORS = { instruments: createKeywordBlock('instrument', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], - allowedCodelistDomains: KEYWORD_DOMAINS, getValue: ({ correction }) => { const { ShortName } = correction.newKeywordObject const LongName = correction.newLongName || '' @@ -190,7 +164,6 @@ export const ISO_19115_SCHEME_EDITORS = { projects: createKeywordBlock('project', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], - allowedCodelistDomains: KEYWORD_DOMAINS, getValue: ({ correction }) => { const { ShortName } = correction.newKeywordObject const LongName = correction.newLongName || '' @@ -202,7 +175,6 @@ export const ISO_19115_SCHEME_EDITORS = { providers: createKeywordBlock('dataCentre', { fieldKeys: ['ShortName', 'LongName'], matchKeys: ['ShortName'], - allowedCodelistDomains: KEYWORD_DOMAINS, getValue: ({ correction }) => { const { ShortName } = correction.newKeywordObject const LongName = correction.newLongName || '' From aa21d283d166bb7e9481779c467da23c2740ceb2 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Wed, 1 Jul 2026 15:57:56 -0400 Subject: [PATCH 17/27] KMS-686: Update/fix for code reviews --- serverless/src/shared/Iso19115DomEditor.js | 28 ++++++- .../src/shared/Iso19115MetadataPathEditor.js | 75 ++++++++++++------- .../applyIso19115MetadataCorrections.test.js | 2 +- 3 files changed, 71 insertions(+), 34 deletions(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index eada142f..c4e563ac 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -14,13 +14,15 @@ const blockScheme = (config) => (editor, correction) => editor.updateBlockNode(c * @returns {Function} Function to apply the update. */ const leafScheme = (config) => (editor, correction) => editor.updateLeafNode(correction, config) + /** * Factory to generate standardized keyword block editors. * @param {string} type - The 'codeListValue' for the MD_KeywordTypeCode. * @param {Object} options - Configuration options. + * @param {Array} [options.additionalPaths] - Optional array of XPath strings for secondary sync. */ const createKeywordBlock = (type, { - fieldKeys, matchKeys, getValue + fieldKeys, matchKeys, getValue, additionalPaths = [] }) => blockScheme({ nodeXPath: `//gmd:descriptiveKeywords/gmd:MD_Keywords[ gmd:type/gmd:MD_KeywordTypeCode/@codeListValue = '${type}' @@ -48,9 +50,23 @@ const createKeywordBlock = (type, { fieldPath: ({ node, editor }) => (editor.selectNodes('./gmx:Anchor', node).length > 0 ? 'gmx:Anchor' : 'gco:CharacterString'), source: { type: 'computed', - getValue: getValue || (({ correction }) => fieldKeys.map((k) => correction.newKeywordObject[k]).filter(Boolean).join(' > ')) + getValue: getValue || (({ correction }) => fieldKeys + .map((k) => correction.newKeywordObject[k] || 'NONE') + .join(' > ') + ) } - } + }, + // Dynamically add secondary paths for synchronization + ...additionalPaths.map((path) => ({ + fieldPath: path, + source: { + type: 'computed', + // Ensure the secondary sync paths also use the 'NONE' padding logic + getValue: getValue || (({ correction }) => fieldKeys + .map((k) => correction.newKeywordObject[k] || 'NONE') + .join(' > ')) + } + })) ] }) @@ -180,7 +196,11 @@ export const ISO_19115_SCHEME_EDITORS = { const LongName = correction.newLongName || '' return LongName ? `${ShortName} > ${LongName}` : ShortName - } + }, + // Additional paths + additionalPaths: [ + '//gmd:CI_ResponsibleParty/gmd:organisationName/gco:CharacterString' + ] }), isotopiccategory: createIsoTopicCategoryEditor(), diff --git a/serverless/src/shared/Iso19115MetadataPathEditor.js b/serverless/src/shared/Iso19115MetadataPathEditor.js index 12e3fe4a..b563d8fe 100644 --- a/serverless/src/shared/Iso19115MetadataPathEditor.js +++ b/serverless/src/shared/Iso19115MetadataPathEditor.js @@ -158,31 +158,52 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { /** * Updates complex keyword block nodes. - * Handles deletion of nested keywords and recursive cleanup of empty parents. + * Handles deletion and replacement of keywords, including synchronized + * updates across global paths (like CI_ResponsibleParty). * @param {Object} correction - The change data. * @param {Object} config - Configuration for the block node. * @returns {boolean} True if the operation was successful. */ updateBlockNode(correction, config) { - const targetNode = this.selectNodes(config.nodeXPath)[0] || null - if (!targetNode) return false + const targetNodes = this.selectNodes(config.nodeXPath) + if (!targetNodes || targetNodes.length === 0) return false - // 2. Handle the 'delete' action + // Identify the first block that contains the matching node + const matchingData = targetNodes + .map((node) => ({ + node, + matchingNode: this.findMatchingNode(node, correction, config) + })) + .find((data) => data.matchingNode !== null) + + // Return early if no match is found, preventing downstream errors + if (!matchingData) return false + + const { matchingNode } = matchingData + + // 2. Handle 'delete' action if (correction.action === 'delete') { - const matchingNode = this.findMatchingNode(targetNode, correction, config) - if (!matchingNode) return false + const parentBlock = matchingNode.parentNode + + // Clean up synchronized paths globally first + if (config.replace) { + config.replace + .filter((replConfig) => typeof replConfig.fieldPath === 'string' && replConfig.fieldPath.startsWith('//')) + .forEach((replConfig) => { + this.selectNodes(replConfig.fieldPath, this.document) + .forEach((node) => node?.parentNode?.removeChild(node)) + }) + } - // Remove target node + // Remove the target keyword matchingNode.parentNode.removeChild(matchingNode) - // Cleanup: Remove parent blocks if they are now empty - const remainingKeywords = this.selectNodes('./gmd:keyword', targetNode) - if (remainingKeywords.length === 0) { - const mdKeywordsParent = targetNode.parentNode - mdKeywordsParent.removeChild(targetNode) + // Cleanup parent blocks if empty + if (this.selectNodes('./gmd:keyword', parentBlock).length === 0) { + const mdKeywordsParent = parentBlock.parentNode + mdKeywordsParent.removeChild(parentBlock) - const remainingBlocks = this.selectNodes('./gmd:MD_Keywords', mdKeywordsParent) - if (remainingBlocks.length === 0) { + if (this.selectNodes('./gmd:MD_Keywords', mdKeywordsParent).length === 0) { mdKeywordsParent.parentNode.removeChild(mdKeywordsParent) } } @@ -190,15 +211,11 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { return true } - // 3. Handle the 'replace' action + // 3. Handle 'replace' action if (correction.action === 'replace') { - const replaceConfig = config.replace[0] - const matchingNode = this.findMatchingNode(targetNode, correction, config) - - if (matchingNode) { + const results = config.replace.map((replaceConfig) => { let fieldNode = null - // Determine dynamic path or default if (replaceConfig.fieldPath) { const path = typeof replaceConfig.fieldPath === 'function' ? replaceConfig.fieldPath({ @@ -207,28 +224,28 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { }) : replaceConfig.fieldPath - const relativePath = path.startsWith('./') ? path : `./${path}`; - [fieldNode] = this.selectNodes(relativePath, matchingNode) + const context = path.startsWith('//') ? this.document : matchingNode + const relativePath = path.startsWith('//') ? path : `./${path}`; + [fieldNode] = this.selectNodes(relativePath, context) } - // Fallback search for standard string elements if (!fieldNode) { [fieldNode] = this.selectNodes('./gco:CharacterString', matchingNode) - || this.selectNodes('gco:CharacterString', matchingNode) } - // Apply text update if (fieldNode) { - const newValue = replaceConfig.source.getValue({ correction }) - this.setElementText(fieldNode, newValue) + this.setElementText(fieldNode, replaceConfig.source.getValue({ correction })) return true } - } - return false + return false + }) + + return results.some((success) => success === true) } + // If action is neither 'delete' nor 'replace' return false } } diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js index 0b106f29..1d4a22cc 100644 --- a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -71,7 +71,7 @@ const mockIso19115 = ` - Global Change Data Center, Science and Exploration Directorate, Goddard Space Flight Center (GSFC) National Aeronautics and Space Administration (NASA) + DOC/NOAA/NESDIS/NCEI > National Centers for Environmental Information, NESDIS, NOAA, U.S. Department of Commerce From 8c40e6312ff2dbc9e61b0529c57d7e94f9eb9b25 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Wed, 1 Jul 2026 19:07:33 -0400 Subject: [PATCH 18/27] KMS-686: Fix delete for value matching and matching only first element --- .../src/shared/Iso19115MetadataPathEditor.js | 38 +++++++++++----- .../Iso19115MetadataPathEditor.test.js | 45 +++++++++++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/serverless/src/shared/Iso19115MetadataPathEditor.js b/serverless/src/shared/Iso19115MetadataPathEditor.js index b563d8fe..8611db18 100644 --- a/serverless/src/shared/Iso19115MetadataPathEditor.js +++ b/serverless/src/shared/Iso19115MetadataPathEditor.js @@ -185,13 +185,24 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { if (correction.action === 'delete') { const parentBlock = matchingNode.parentNode - // Clean up synchronized paths globally first + // Initialize keywordValue here so it is available in the scope + const keywordValue = matchingNode.textContent.trim() + + // Clean up synchronized paths globally, but with a value constraint if (config.replace) { config.replace .filter((replConfig) => typeof replConfig.fieldPath === 'string' && replConfig.fieldPath.startsWith('//')) .forEach((replConfig) => { - this.selectNodes(replConfig.fieldPath, this.document) - .forEach((node) => node?.parentNode?.removeChild(node)) + // Find all nodes in the document that match the path + const allPotentialMatches = this.selectNodes(replConfig.fieldPath, this.document) + + // Filter those nodes: only remove the ones whose text content + // matches the keyword we are currently deleting + allPotentialMatches + .filter((node) => node.textContent.trim() === keywordValue) + .forEach((node) => { + if (node?.parentNode) node.parentNode.removeChild(node) + }) }) } @@ -214,8 +225,9 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { // 3. Handle 'replace' action if (correction.action === 'replace') { const results = config.replace.map((replaceConfig) => { - let fieldNode = null + let fieldNodes = [] + // 1. Attempt to find nodes via the provided path if (replaceConfig.fieldPath) { const path = typeof replaceConfig.fieldPath === 'function' ? replaceConfig.fieldPath({ @@ -225,16 +237,22 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { : replaceConfig.fieldPath const context = path.startsWith('//') ? this.document : matchingNode - const relativePath = path.startsWith('//') ? path : `./${path}`; - [fieldNode] = this.selectNodes(relativePath, context) + const relativePath = path.startsWith('//') ? path : `./${path}` + + fieldNodes = this.selectNodes(relativePath, context) } - if (!fieldNode) { - [fieldNode] = this.selectNodes('./gco:CharacterString', matchingNode) + // 2. Fallback: If no nodes were found via path, + // ALWAYS check for the default text node (remove the !replaceConfig.fieldPath check) + if (fieldNodes.length === 0) { + fieldNodes = this.selectNodes('./gco:CharacterString', matchingNode) } - if (fieldNode) { - this.setElementText(fieldNode, replaceConfig.source.getValue({ correction })) + // 3. Update every node found + if (fieldNodes.length > 0) { + fieldNodes.forEach((node) => { + this.setElementText(node, replaceConfig.source.getValue({ correction })) + }) return true } diff --git a/serverless/src/shared/__tests__/Iso19115MetadataPathEditor.test.js b/serverless/src/shared/__tests__/Iso19115MetadataPathEditor.test.js index 22545fed..41e3c7ff 100644 --- a/serverless/src/shared/__tests__/Iso19115MetadataPathEditor.test.js +++ b/serverless/src/shared/__tests__/Iso19115MetadataPathEditor.test.js @@ -398,4 +398,49 @@ describe('Iso19115MetadataPathEditor', () => { expect(result).toBe(false) }) + + test('updateBlockNode should update ALL matching nodes when multiple are found', () => { + const xml = ` + + + + + Original Value + Original Value + + + + ` + + const testEditor = new Iso19115MetadataPathEditor(xml) + + const config = { + nodeXPath: '//gmd:descriptiveKeywords/gmd:MD_Keywords', + find: { + getNodeValueObject: () => ({ Value: 'Original Value' }), + matchKeys: ['Value'] + }, + replace: [{ + fieldPath: 'gco:CharacterString', + source: { getValue: () => 'New Value' } + }] + } + + const correction = { + action: 'replace', + oldKeywordObject: { Value: 'Original Value' }, + newKeywordObject: { Value: 'New Value' } + } + + const result = testEditor.updateBlockNode(correction, config) + + expect(result).toBe(true) + + // Verify that ALL CharacterString nodes were updated + const updatedNodes = testEditor.selectNodes('//gco:CharacterString', testEditor.document) + expect(updatedNodes.length).toBe(2) + updatedNodes.forEach((node) => { + expect(node.textContent).toBe('New Value') + }) + }) }) From 0861d54c82d4a0aaa7866d375103aa9d7d6bb7b5 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Thu, 2 Jul 2026 14:17:26 -0400 Subject: [PATCH 19/27] KMS-686: Add paths for platfforms and instruments in acquisition info section --- serverless/src/shared/Iso19115DomEditor.js | 153 ++- .../src/shared/Iso19115MetadataPathEditor.js | 123 +- ...pplyIso19115InstrumentsCorrections.test.js | 532 +++++++ .../applyIso19115MetadataCorrections.test.js | 4 + .../applyIso19115PlatformsCorrections.test.js | 1219 +++++++++++++++++ .../applyUmmcMetadataCorrections.test.js | 1 - 6 files changed, 2009 insertions(+), 23 deletions(-) create mode 100644 serverless/src/shared/__tests__/applyIso19115InstrumentsCorrections.test.js create mode 100644 serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index c4e563ac..70c42776 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -57,16 +57,22 @@ const createKeywordBlock = (type, { } }, // Dynamically add secondary paths for synchronization - ...additionalPaths.map((path) => ({ - fieldPath: path, - source: { - type: 'computed', - // Ensure the secondary sync paths also use the 'NONE' padding logic - getValue: getValue || (({ correction }) => fieldKeys - .map((k) => correction.newKeywordObject[k] || 'NONE') - .join(' > ')) + // Each path can be a string or an object with { path, getValue } + ...additionalPaths.map((pathConfig) => { + const isObject = typeof pathConfig === 'object' && pathConfig.path + const path = isObject ? pathConfig.path : pathConfig + const pathGetValue = isObject ? pathConfig.getValue : null + + return { + fieldPath: path, + source: { + type: 'computed', + getValue: pathGetValue || getValue || (({ correction }) => fieldKeys + .map((k) => correction.newKeywordObject[k] || 'NONE') + .join(' > ')) + } } - })) + }) ] }) @@ -110,9 +116,10 @@ const createProductLevelIdEditor = () => leafScheme({ }) }, delete: [ - // Define the paths to remove both occurrences - { path: '//gmd:processingLevel/gmd:MD_Identifier[gmd:codeSpace/gco:CharacterString="gov.nasa.esdis.umm.processinglevelid"]' }, - { path: '//gmd:processingLevelCode/gmd:MD_Identifier[gmd:codeSpace/gco:CharacterString="gov.nasa.esdis.umm.processinglevelid"]' } + // Remove the entire processingLevel parent wrapper (in identificationInfo) + { path: '//gmd:identificationInfo//gmd:processingLevel[gmd:MD_Identifier/gmd:codeSpace/gco:CharacterString="gov.nasa.esdis.umm.processinglevelid"]' }, + // Remove the entire processingLevelCode parent wrapper (in contentInfo) + { path: '//gmd:contentInfo//gmd:processingLevelCode[gmd:MD_Identifier/gmd:codeSpace/gco:CharacterString="gov.nasa.esdis.umm.processinglevelid"]' } ], replace: [ { @@ -163,7 +170,68 @@ export const ISO_19115_SCHEME_EDITORS = { const LongName = correction.newLongName || '' return LongName ? `${ShortName} > ${LongName}` : ShortName - } + }, + // Sync with acquisition information section + // Smart detection: if gmd:code contains ' > ', use combined format (CWIC style) + // Otherwise use split format (NSIDC/NOAA style) + additionalPaths: [ + { + path: '//gmi:acquisitionInformation/gmi:MI_AcquisitionInformation/gmi:platform/eos:EOS_Platform/gmi:identifier/gmd:MD_Identifier/gmd:code/gco:CharacterString', + getValue: ({ correction, node }) => { + const existingValue = node?.textContent || '' + // If existing value contains ' > ', keep combined format + if (existingValue.includes(' > ')) { + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName || '' + + return LongName ? `${ShortName} > ${LongName}` : ShortName + } + + // Otherwise use ShortName only (NSIDC/NOAA format) + return correction.newKeywordObject.ShortName + } + }, + { + path: '//gmi:acquisitionInformation/gmi:MI_AcquisitionInformation/gmi:platform/gmi:MI_Platform/gmi:identifier/gmd:MD_Identifier/gmd:code/gco:CharacterString', + getValue: ({ correction, node }) => { + const existingValue = node?.textContent || '' + if (existingValue.includes(' > ')) { + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName || '' + + return LongName ? `${ShortName} > ${LongName}` : ShortName + } + + return correction.newKeywordObject.ShortName + } + }, + { + path: '//gmi:acquisitionInformation/gmi:MI_AcquisitionInformation/gmi:platform/eos:EOS_Platform/gmi:identifier/gmd:MD_Identifier/gmd:description/gco:CharacterString', + getValue: ({ correction, node, editor }) => { + const codeNode = editor.selectNodes('../gmd:code/gco:CharacterString', node.parentNode)[0] + const codeValue = codeNode?.textContent || '' + // Only update description if code uses split format (doesn't contain ' > ') + if (!codeValue.includes(' > ')) { + return correction.newLongName || '' + } + + // For CWIC format, preserve existing free-text description + return node?.textContent || '' + } + }, + { + path: '//gmi:acquisitionInformation/gmi:MI_AcquisitionInformation/gmi:platform/gmi:MI_Platform/gmi:identifier/gmd:MD_Identifier/gmd:description/gco:CharacterString', + getValue: ({ correction, node, editor }) => { + const codeNode = editor.selectNodes('../gmd:code/gco:CharacterString', node.parentNode)[0] + const codeValue = codeNode?.textContent || '' + if (!codeValue.includes(' > ')) { + return correction.newLongName || '' + } + + return node?.textContent || '' + } + } + ] }), instruments: createKeywordBlock('instrument', { @@ -174,7 +242,64 @@ export const ISO_19115_SCHEME_EDITORS = { const LongName = correction.newLongName || '' return LongName ? `${ShortName} > ${LongName}` : ShortName - } + }, + // Sync with acquisition information section + // Smart detection: if gmd:code contains ' > ', use combined format (CWIC style) + // Otherwise use split format (NSIDC/NOAA style) + additionalPaths: [ + { + path: '//gmi:acquisitionInformation/gmi:MI_AcquisitionInformation/gmi:instrument/eos:EOS_Instrument/gmi:identifier/gmd:MD_Identifier/gmd:code/gco:CharacterString', + getValue: ({ correction, node }) => { + const existingValue = node?.textContent || '' + if (existingValue.includes(' > ')) { + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName || '' + + return LongName ? `${ShortName} > ${LongName}` : ShortName + } + + return correction.newKeywordObject.ShortName + } + }, + { + path: '//gmi:acquisitionInformation/gmi:MI_AcquisitionInformation/gmi:instrument/gmi:MI_Instrument/gmi:identifier/gmd:MD_Identifier/gmd:code/gco:CharacterString', + getValue: ({ correction, node }) => { + const existingValue = node?.textContent || '' + if (existingValue.includes(' > ')) { + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName || '' + + return LongName ? `${ShortName} > ${LongName}` : ShortName + } + + return correction.newKeywordObject.ShortName + } + }, + { + path: '//gmi:acquisitionInformation/gmi:MI_AcquisitionInformation/gmi:instrument/eos:EOS_Instrument/gmi:identifier/gmd:MD_Identifier/gmd:description/gco:CharacterString', + getValue: ({ correction, node, editor }) => { + const codeNode = editor.selectNodes('../gmd:code/gco:CharacterString', node.parentNode)[0] + const codeValue = codeNode?.textContent || '' + if (!codeValue.includes(' > ')) { + return correction.newLongName || '' + } + + return node?.textContent || '' + } + }, + { + path: '//gmi:acquisitionInformation/gmi:MI_AcquisitionInformation/gmi:instrument/gmi:MI_Instrument/gmi:identifier/gmd:MD_Identifier/gmd:description/gco:CharacterString', + getValue: ({ correction, node, editor }) => { + const codeNode = editor.selectNodes('../gmd:code/gco:CharacterString', node.parentNode)[0] + const codeValue = codeNode?.textContent || '' + if (!codeValue.includes(' > ')) { + return correction.newLongName || '' + } + + return node?.textContent || '' + } + } + ] }), projects: createKeywordBlock('project', { diff --git a/serverless/src/shared/Iso19115MetadataPathEditor.js b/serverless/src/shared/Iso19115MetadataPathEditor.js index 8611db18..3eea705f 100644 --- a/serverless/src/shared/Iso19115MetadataPathEditor.js +++ b/serverless/src/shared/Iso19115MetadataPathEditor.js @@ -133,7 +133,6 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { if (matchingNode) { config.replace.forEach((replConfig) => { - const newValue = replConfig.source.getValue({ correction }) const isGlobal = replConfig.fieldPath.startsWith('//') const context = isGlobal ? this.document : matchingNode @@ -141,11 +140,25 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { if (replConfig.fieldPath.includes('@')) { const [path, attr] = replConfig.fieldPath.split('/@') const targetElement = this.selectNodes(isGlobal ? path : `./${path}`, context)[0] - if (targetElement) targetElement.setAttribute(attr, newValue) + if (targetElement) { + const newValue = replConfig.source.getValue({ + correction, + node: targetElement, + editor: this + }) + targetElement.setAttribute(attr, newValue) + } } else { // Standard text content update const targetNode = this.selectNodes(isGlobal ? replConfig.fieldPath : `./${replConfig.fieldPath}`, context)[0] - if (targetNode) this.setElementText(targetNode, newValue) + if (targetNode) { + const newValue = replConfig.source.getValue({ + correction, + node: targetNode, + editor: this + }) + this.setElementText(targetNode, newValue) + } } }) @@ -188,7 +201,56 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { // Initialize keywordValue here so it is available in the scope const keywordValue = matchingNode.textContent.trim() + // Get the ShortName for matching acquisition information entries + const oldShortName = correction.oldKeywordObject?.ShortName + + // Only platforms and instruments have acquisition information sections + const isPlatformOrInstrument = correction.scheme === 'platforms' || correction.scheme === 'instruments' + + // For platforms/instruments, delete from acquisition section too + if (isPlatformOrInstrument && oldShortName && config.replace) { + // Find all MD_Identifier nodes that match this platform/instrument + const allIdentifiers = this.selectNodes('//gmd:MD_Identifier', this.document) + const identifiersToDelete = allIdentifiers.filter((identifier) => { + const codeNodes = this.selectNodes('.//gmd:code/gco:CharacterString', identifier) + if (codeNodes.length > 0) { + const codeValue = codeNodes[0].textContent?.trim() || '' + + return codeValue === oldShortName || codeValue.startsWith(`${oldShortName} > `) + } + + return false + }) + + // Delete the parent platform/instrument nodes from acquisition section + identifiersToDelete.forEach((identifier) => { + // Navigate up DOM tree to find the acquisition platform/instrument wrapper node + let currentNode = identifier.parentNode + while (currentNode) { + const { localName } = currentNode + // Check if we found a platform or instrument node + if (localName === 'EOS_Platform' || localName === 'MI_Platform' + || localName === 'EOS_Instrument' || localName === 'MI_Instrument') { + // Found the acquisition wrapper node, remove it entirely + if (currentNode.parentNode) { + currentNode.parentNode.removeChild(currentNode) + } + + break + } + + // Move up the tree + currentNode = currentNode.parentNode + // Stop if we reach the document root + if (currentNode === this.document.documentElement) { + break + } + } + }) + } + // Clean up synchronized paths globally, but with a value constraint + // This handles providers and other non-acquisition paths if (config.replace) { config.replace .filter((replConfig) => typeof replConfig.fieldPath === 'string' && replConfig.fieldPath.startsWith('//')) @@ -224,12 +286,33 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { // 3. Handle 'replace' action if (correction.action === 'replace') { + // Pre-scan: Build a map of identifiers that should be updated + // This prevents issues when processing multiple paths in sequence + const oldShortName = correction.oldKeywordObject?.ShortName + let identifiersToUpdate = [] + + if (oldShortName) { + // Find all MD_Identifier nodes that have code matching oldShortName + const allIdentifiers = this.selectNodes('//gmd:MD_Identifier', this.document) + identifiersToUpdate = allIdentifiers.filter((identifier) => { + const codeNodes = this.selectNodes('.//gmd:code/gco:CharacterString', identifier) + if (codeNodes.length > 0) { + const codeValue = codeNodes[0].textContent?.trim() || '' + + return codeValue === oldShortName || codeValue.startsWith(`${oldShortName} > `) + } + + return false + }) + } + const results = config.replace.map((replaceConfig) => { let fieldNodes = [] + let path = null // 1. Attempt to find nodes via the provided path if (replaceConfig.fieldPath) { - const path = typeof replaceConfig.fieldPath === 'function' + path = typeof replaceConfig.fieldPath === 'function' ? replaceConfig.fieldPath({ node: matchingNode, editor: this @@ -243,18 +326,42 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { } // 2. Fallback: If no nodes were found via path, - // ALWAYS check for the default text node (remove the !replaceConfig.fieldPath check) - if (fieldNodes.length === 0) { + // ONLY check for the default text node if the path is relative (not global) + // Global paths (starting with //) should not fall back to keyword block updates + if (fieldNodes.length === 0 && (!path || !path.startsWith('//'))) { fieldNodes = this.selectNodes('./gco:CharacterString', matchingNode) } // 3. Update every node found if (fieldNodes.length > 0) { + // For global paths (starting with //), filter to only update matching nodes + // This prevents updating all platforms when we only want to update one + if (path && path.startsWith('//') && identifiersToUpdate.length > 0) { + // Filter nodes: only include nodes within the pre-scanned identifiers + fieldNodes = fieldNodes.filter((node) => { + // Navigate up to find the MD_Identifier parent + let identifier = node.parentNode + while (identifier && identifier.localName !== 'MD_Identifier') { + identifier = identifier.parentNode + if (!identifier || identifier === this.document.documentElement) { + return false + } + } + + // Check if this identifier is in our list to update + return identifiersToUpdate.includes(identifier) + }) + } + fieldNodes.forEach((node) => { - this.setElementText(node, replaceConfig.source.getValue({ correction })) + this.setElementText(node, replaceConfig.source.getValue({ + correction, + node, + editor: this + })) }) - return true + return fieldNodes.length > 0 } return false diff --git a/serverless/src/shared/__tests__/applyIso19115InstrumentsCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115InstrumentsCorrections.test.js new file mode 100644 index 00000000..8b41f4da --- /dev/null +++ b/serverless/src/shared/__tests__/applyIso19115InstrumentsCorrections.test.js @@ -0,0 +1,532 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { ISO_19115_SCHEME_EDITORS } from '../Iso19115DomEditor' +import Iso19115MetadataPathEditor from '../Iso19115MetadataPathEditor' + +// Mock with BOTH keyword blocks AND NSIDC acquisition information +// Tests synchronization between the two sections for the same instruments +const mockIso19115WithKeywordsAndAcquisition = ` + + + + + + + ATLAS > Advanced Topographic Laser Altimeter System + + + MODIS > Moderate Resolution Imaging Spectroradiometer + + + instrument + + + + + NASA / GCMD Instrument Keywords + + + + + 2016-06-01 + + + revision + + + + + + + + + + + + + + + + + ATLAS + + + gov.nasa.esdis.umm.instrumentshortname + + + Advanced Topographic Laser Altimeter System + + + + + instrument + + + + + + + + + MODIS + + + gov.nasa.esdis.umm.instrumentshortname + + + Moderate Resolution Imaging Spectroradiometer + + + + + instrument + + + + + +` + +describe('Instrument corrections with synchronized keyword blocks and acquisition information', () => { + test('should update BOTH keyword block and acquisition sections for ATLAS', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'ATLAS' }, + newKeywordObject: { ShortName: 'ATLAS-2' }, + newLongName: 'Advanced Topographic Laser Altimeter System Version 2' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // 1. Verify keyword block updated + expect(updatedXml).toContain('ATLAS-2 > Advanced Topographic Laser Altimeter System Version 2') + expect(updatedXml).not.toContain('ATLAS > Advanced Topographic Laser Altimeter System') + + // 2. Verify acquisition gmd:code updated (ShortName only in NSIDC split format) + expect(updatedXml).toMatch(/[\s\S]*?\s*ATLAS-2<\/gco:CharacterString>/) + + // 3. Verify acquisition identifier gmd:description updated (LongName only in NSIDC split format) + expect(updatedXml).toMatch(/[\s\S]*?\s*ATLAS-2<\/gco:CharacterString>[\s\S]*?\s*Advanced Topographic Laser Altimeter System Version 2<\/gco:CharacterString>/) + + // 4. Verify MODIS remains unchanged + expect(updatedXml).toContain('MODIS > Moderate Resolution Imaging Spectroradiometer') + expect(updatedXml).toMatch(/[\s\S]*?\s*MODIS<\/gco:CharacterString>/) + }) + + test('should update BOTH sections for MODIS without affecting ATLAS', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'MODIS' }, + newKeywordObject: { ShortName: 'MODIS-2' }, + newLongName: 'Moderate Resolution Imaging Spectroradiometer Version 2' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // 1. MODIS keyword updated + expect(updatedXml).toContain('MODIS-2 > Moderate Resolution Imaging Spectroradiometer Version 2') + expect(updatedXml).not.toContain('MODIS > Moderate Resolution Imaging Spectroradiometer') + + // 2. MODIS acquisition code updated + expect(updatedXml).toMatch(/[\s\S]*?\s*MODIS-2<\/gco:CharacterString>/) + + // 3. MODIS acquisition description updated + expect(updatedXml).toMatch(/[\s\S]*?\s*MODIS-2<\/gco:CharacterString>[\s\S]*?\s*Moderate Resolution Imaging Spectroradiometer Version 2<\/gco:CharacterString>/) + + // 4. ATLAS remains unchanged in both sections + expect(updatedXml).toContain('ATLAS > Advanced Topographic Laser Altimeter System') + expect(updatedXml).toMatch(/[\s\S]*?\s*ATLAS<\/gco:CharacterString>/) + }) + + test('should handle sequential updates to different instruments', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + // First update ATLAS + const atlasCorrection = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'ATLAS' }, + newKeywordObject: { ShortName: 'ATLAS-NEW' }, + newLongName: 'New ATLAS Description' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const atlasSuccess = config(editor, atlasCorrection) + + // Then update MODIS + const modisCorrection = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'MODIS' }, + newKeywordObject: { ShortName: 'MODIS-NEW' }, + newLongName: 'New MODIS Description' + } + + const modisSuccess = config(editor, modisCorrection) + + expect(atlasSuccess).toBe(true) + expect(modisSuccess).toBe(true) + + const updatedXml = editor.serialize() + + // Both keyword blocks updated correctly + expect(updatedXml).toContain('ATLAS-NEW > New ATLAS Description') + expect(updatedXml).toContain('MODIS-NEW > New MODIS Description') + + // Both acquisition sections updated independently + expect(updatedXml).toMatch(/[\s\S]*?\s*ATLAS-NEW<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/[\s\S]*?\s*MODIS-NEW<\/gco:CharacterString>/) + + // Descriptions updated independently + expect(updatedXml).toMatch(/[\s\S]*?\s*ATLAS-NEW<\/gco:CharacterString>[\s\S]*?\s*New ATLAS Description<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/[\s\S]*?\s*MODIS-NEW<\/gco:CharacterString>[\s\S]*?\s*New MODIS Description<\/gco:CharacterString>/) + }) + + test('should preserve codeSpace when updating instruments', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'ATLAS' }, + newKeywordObject: { ShortName: 'ATLAS-3' }, + newLongName: 'Advanced Topographic Laser Altimeter System V3' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + config(editor, correction) + + const updatedXml = editor.serialize() + + // CodeSpace should remain unchanged + expect(updatedXml).toContain('gov.nasa.esdis.umm.instrumentshortname') + expect(updatedXml).toMatch(/\s*gov.nasa.esdis.umm.instrumentshortname<\/gco:CharacterString>/) + }) + + test('should handle instrument with only ShortName update', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'ATLAS' }, + newKeywordObject: { ShortName: 'ATLAS-5' } + // No newLongName provided + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword should have just ShortName + expect(updatedXml).toContain('ATLAS-5') + + // Acquisition code should be updated + expect(updatedXml).toMatch(/[\s\S]*?\s*ATLAS-5<\/gco:CharacterString>/) + }) + + test('should delete instrument from BOTH keyword block and acquisition section', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'instruments', + action: 'delete', + oldKeywordObject: { ShortName: 'ATLAS' } + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword block should not contain ATLAS + expect(updatedXml).not.toContain('ATLAS > Advanced Topographic Laser Altimeter System') + + // MODIS keyword should remain + expect(updatedXml).toContain('MODIS > Moderate Resolution Imaging Spectroradiometer') + + // Acquisition section should still exist but ATLAS instrument should be removed + expect(updatedXml).toContain('gmi:acquisitionInformation') + expect(updatedXml).not.toContain('') + expect(updatedXml).not.toContain('ATLAS') + + // MODIS should still be in acquisition section + expect(updatedXml).toContain('') + expect(updatedXml).toMatch(/[\s\S]*?\s*MODIS<\/gco:CharacterString>/) + }) + + test('should verify both instruments are independent in updates', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + // Update only ATLAS + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'ATLAS' }, + newKeywordObject: { ShortName: 'ATLAS-MODIFIED' }, + newLongName: 'Modified ATLAS' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + config(editor, correction) + + const updatedXml = editor.serialize() + + // ATLAS keyword updated correctly + expect(updatedXml).toContain('ATLAS-MODIFIED > Modified ATLAS') + + // ATLAS acquisition updated + expect(updatedXml).toMatch(/[\s\S]*?\s*ATLAS-MODIFIED<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/[\s\S]*?\s*Modified ATLAS<\/gco:CharacterString>/) + + // MODIS completely unchanged in both keyword and acquisition + expect(updatedXml).toContain('MODIS > Moderate Resolution Imaging Spectroradiometer') + expect(updatedXml).toMatch(/[\s\S]*?\s*MODIS<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/[\s\S]*?\s*Moderate Resolution Imaging Spectroradiometer<\/gco:CharacterString>/) + }) + + test('should handle empty LongName gracefully', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'MODIS' }, + newKeywordObject: { ShortName: 'MODIS-SIMPLE' }, + newLongName: '' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword should be just ShortName when LongName is empty + expect(updatedXml).toContain('MODIS-SIMPLE') + + // Should not create malformed "MODIS-SIMPLE > " + expect(updatedXml).not.toContain('MODIS-SIMPLE > ') + }) + + test('should handle special characters in instrument names', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'ATLAS' }, + newKeywordObject: { ShortName: 'ATLAS-1/2' }, + newLongName: 'Advanced Laser & System (Test)' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Special characters should be preserved in keyword + expect(updatedXml).toContain('ATLAS-1/2 > Advanced Laser & System (Test)') + + // Special characters should be preserved in acquisition + expect(updatedXml).toMatch(/\s*ATLAS-1\/2<\/gco:CharacterString>/) + }) + + test('should maintain XML structure integrity after updates', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'ATLAS' }, + newKeywordObject: { ShortName: 'ATLAS-FINAL' }, + newLongName: 'Final ATLAS Version' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + config(editor, correction) + + const updatedXml = editor.serialize() + + // Verify XML is well-formed by checking key structural elements + expect(updatedXml).toContain('') + expect(updatedXml).toContain('') + expect(updatedXml).toContain('') + expect(updatedXml).toContain('') + expect(updatedXml).toContain('') + + // Verify all namespaces are preserved + expect(updatedXml).toMatch(/xmlns:eos="http:\/\/earthdata.nasa.gov\/schema\/eos"/) + expect(updatedXml).toMatch(/xmlns:gco="http:\/\/www.isotc211.org\/2005\/gco"/) + expect(updatedXml).toMatch(/xmlns:gmd="http:\/\/www.isotc211.org\/2005\/gmd"/) + expect(updatedXml).toMatch(/xmlns:gmi="http:\/\/www.isotc211.org\/2005\/gmi"/) + }) + + test('should handle MI_Instrument namespace variant', () => { + const miInstrumentXml = ` + + + + + + + TEST-SENSOR > Test Sensor + + + instrument + + + + + + + + + + + + + TEST-SENSOR + + + Test Sensor + + + + + sensor + + + + + +` + + const editor = new Iso19115MetadataPathEditor(miInstrumentXml) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'TEST-SENSOR' }, + newKeywordObject: { ShortName: 'TEST-SENSOR-2' }, + newLongName: 'Test Sensor Version 2' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword updated + expect(updatedXml).toContain('TEST-SENSOR-2 > Test Sensor Version 2') + + // MI_Instrument acquisition updated + expect(updatedXml).toMatch(/[\s\S]*?\s*TEST-SENSOR-2<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/\s*Test Sensor Version 2<\/gco:CharacterString>/) + }) + + test('should handle delete with MI_Instrument namespace', () => { + const miInstrumentXml = ` + + + + + + + SENSOR-A + + + instrument + + + + + + + + + + + + + SENSOR-A + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(miInstrumentXml) + + const correction = { + scheme: 'instruments', + action: 'delete', + oldKeywordObject: { ShortName: 'SENSOR-A' } + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword removed + expect(updatedXml).not.toContain('SENSOR-A') + + // MI_Instrument removed from acquisition + expect(updatedXml).not.toContain('') + + // But acquisition section structure remains + expect(updatedXml).toContain('gmi:acquisitionInformation') + }) +}) diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js index 1d4a22cc..2eef96ff 100644 --- a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -756,6 +756,10 @@ describe('when applying productlevelid ISO-19115 corrections', () => { // Verify that the Identifier block is completely gone from both expected locations expect(updatedXml).not.toContain('gov.nasa.esdis.umm.processinglevelid') expect(updatedXml).not.toContain('3') + + // Verify that parent wrappers are also removed + expect(updatedXml).not.toContain('') + expect(updatedXml).not.toContain('') }) }) diff --git a/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js new file mode 100644 index 00000000..d8642543 --- /dev/null +++ b/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js @@ -0,0 +1,1219 @@ +import { + describe, + expect, + test +} from 'vitest' + +import { ISO_19115_SCHEME_EDITORS } from '../Iso19115DomEditor' +import Iso19115MetadataPathEditor from '../Iso19115MetadataPathEditor' + +const mockIso19115 = ` + + + + + + + TERRA > Earth Observing System, TERRA (AM-1) + + + AQUA > Earth Observing System + + + platform + + + + + NASA / GCMD Platform Keywords + + + + + 2016-06-10 + + + revision + + + + + + + + + +` + +// ============================================================================ +// PLATFORM TESTS WITH ACQUISITION INFORMATION +// Comprehensive tests for platforms across all data center formats +// ============================================================================ + +// Mock XML with NSIDC format (ISO MENDS compliant) +// ShortName in gmd:code, LongName in gmd:description +const mockIso19115WithNSIDCAcquisition = ` + + + + + + + ICESat-2 > Ice, Cloud, and land Elevation Satellite-2 + + + platform + + + + + + + ATLAS > Advanced Topographic Laser Altimeter System + + + instrument + + + + + + + + + + + + + ATLAS + + + gov.nasa.esdis.umm.instrumentshortname + + + Advanced Topographic Laser Altimeter System + + + + + instrument + + + + + + + + + ICESat-2 + + + gov.nasa.esdis.umm.platformshortname + + + Ice, Cloud, and land Elevation Satellite-2 + + + + + Earth Observation Satellites + + + + + + +` + +// Mock XML with CWIC format (non-compliant) +// Combined "ShortName > LongName" in gmd:code +const mockIso19115WithCWICAcquisition = ` + + + + + + + AQUA > Earth Observing System, AQUA + + + platform + + + + + + + AMSR-E > Advanced Microwave Scanning Radiometer-EOS + + + instrument + + + + + + + + + + + + + AMSR-E > Advanced Microwave Scanning Radiometer-EOS + + + + + sensor + + + The Advanced Microwave Scanning Radiometer for EOS (AMSR-E) is a twelve-channel, six-frequency, total power passive-microwave radiometer system. + + + + + + + + + AQUA > Earth Observing System, AQUA + + + + + Aqua is a NASA polar orbiting mission designed to collect information on the Earth's atmospheric, land and ocean systems. + + + + + +` + +// Mock XML with NOAA format (simple format) +// ShortName only in gmd:code, free-text in gmd:description +const mockIso19115WithNOAAAcquisition = ` + + + + + + + NAGASAKI-MARU + + + platform + + + + + + + net - zooplankton net + + + instrument + + + + + + + + + + + + + net - zooplankton net + + + + + net - zooplankton net + + + net - zooplankton net + + + + + + + + + NAGASAKI-MARU + + + + + Additional information from ICES for the vessel NAGASAKI-MARU from JAPAN. + + + + + +` + +describe('Platform corrections with acquisition information', () => { + describe('NSIDC format (split: ShortName in code, LongName in description)', () => { + test('should update keyword block and both acquisition paths correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithNSIDCAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'ICESat-2' }, + newKeywordObject: { ShortName: 'ICESat-3' }, + newLongName: 'Ice, Cloud, and land Elevation Satellite-3' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // 1. Verify keyword block updated + expect(updatedXml).toContain('ICESat-3 > Ice, Cloud, and land Elevation Satellite-3') + expect(updatedXml).not.toContain('ICESat-2 > Ice, Cloud, and land Elevation Satellite-2') + + // 2. Verify acquisition gmd:code updated (ShortName only) + expect(updatedXml).toMatch(/\s*ICESat-3<\/gco:CharacterString>\s*<\/gmd:code>/) + + // 3. Verify acquisition gmd:description updated (LongName only) + expect(updatedXml).toMatch(/\s*Ice, Cloud, and land Elevation Satellite-3<\/gco:CharacterString>/) + }) + + test('should preserve platformshortname codeSpace', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithNSIDCAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'ICESat-2' }, + newKeywordObject: { ShortName: 'ICESat-3' }, + newLongName: 'Ice, Cloud, and land Elevation Satellite-3' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + config(editor, correction) + + const updatedXml = editor.serialize() + expect(updatedXml).toContain('gov.nasa.esdis.umm.platformshortname') + }) + + test('should handle platform with only ShortName (no LongName)', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithNSIDCAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'ICESat-2' }, + newKeywordObject: { ShortName: 'ICESat-3' } + // No newLongName provided + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword block should have just ShortName + expect(updatedXml).toContain('ICESat-3') + // Acquisition code should be updated + expect(updatedXml).toContain('gov.nasa.esdis.umm.platformshortname') + expect(updatedXml).toMatch(/\s*ICESat-3<\/gco:CharacterString>/) + }) + + test('should delete platform from both keyword block and acquisition section', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithNSIDCAcquisition) + + const correction = { + scheme: 'platforms', + action: 'delete', + oldKeywordObject: { ShortName: 'ICESat-2' } + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword block should be gone + expect(updatedXml).not.toContain('ICESat-2 > Ice, Cloud, and land Elevation Satellite-2') + + // Acquisition platform should also be deleted + expect(updatedXml).not.toContain('') + expect(updatedXml).not.toContain('ICESat-2') + + // But acquisition section and instrument should remain + expect(updatedXml).toContain('gmi:acquisitionInformation') + expect(updatedXml).toContain('eos:EOS_Instrument') + }) + }) + + describe('CWIC format (combined: ShortName > LongName in code)', () => { + test('should detect and preserve combined format in gmd:code', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithCWICAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { ShortName: 'AQUA-2' }, + newLongName: 'Earth Observing System, AQUA Version 2' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // 1. Verify keyword block updated + expect(updatedXml).toContain('AQUA-2 > Earth Observing System, AQUA Version 2') + + // 2. Verify acquisition gmd:code uses combined format (detected ' > ' in existing) + expect(updatedXml).toMatch(/\s*AQUA-2 > Earth Observing System, AQUA Version 2<\/gco:CharacterString>\s*<\/gmd:code>/) + + // 3. Verify acquisition gmd:description preserved (free-text not overwritten) + expect(updatedXml).toContain('Aqua is a NASA polar orbiting mission designed to collect information on the Earth\'s atmospheric, land and ocean systems.') + }) + + test('should not overwrite free-text description in CWIC format', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithCWICAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { ShortName: 'AQUA-2' }, + newLongName: 'Earth Observing System, AQUA Version 2' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + config(editor, correction) + + const updatedXml = editor.serialize() + + // Free-text description should remain unchanged + expect(updatedXml).toContain('Aqua is a NASA polar orbiting mission') + }) + }) + + describe('NOAA format (simple: ShortName only in code)', () => { + test('should update keyword block and acquisition gmd:code', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithNOAAAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'NAGASAKI-MARU' }, + newKeywordObject: { ShortName: 'NAGASAKI-MARU-2' }, + newLongName: 'Updated Vessel Name' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // 1. Verify keyword block updated (will include LongName if provided) + expect(updatedXml).toContain('NAGASAKI-MARU-2 > Updated Vessel Name') + + // 2. Verify acquisition gmd:code updated (ShortName only - split format) + expect(updatedXml).toMatch(/\s*NAGASAKI-MARU-2<\/gco:CharacterString>/) + + // 3. NOAA format doesn't have gmd:description in gmd:identifier, + // so the free-text gmi:description at platform level is preserved + expect(updatedXml).toContain('Additional information from ICES for the vessel NAGASAKI-MARU from JAPAN.') + }) + + test('should handle platforms with no LongName', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithNOAAAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'NAGASAKI-MARU' }, + newKeywordObject: { ShortName: 'NAGASAKI-MARU-2' } + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword block should just have ShortName + expect(updatedXml).toContain('NAGASAKI-MARU-2') + + // Acquisition gmd:code should be updated + expect(updatedXml).toMatch(/\s*NAGASAKI-MARU-2<\/gco:CharacterString>/) + }) + }) + + describe('without acquisition information', () => { + test('should handle missing acquisition section gracefully', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { ShortName: 'AQUA-2' }, + newLongName: 'New Description' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Only keyword block should be updated + expect(updatedXml).toContain('AQUA-2 > New Description') + + // No acquisition section to update + expect(updatedXml).not.toContain('gmi:acquisitionInformation') + }) + + test('should replace existing platform keyword correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { + ShortName: 'AQUA' + }, + newLongName: 'New Platform Description' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + expect(updatedXml).toContain('AQUA > New Platform Description') + expect(updatedXml).not.toContain('AQUA > Earth Observing System') + }) + + test('should delete existing platform keyword block correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + const correction = { + scheme: 'platforms', + action: 'delete', + oldKeywordObject: { ShortName: 'AQUA' } + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + // Verify the specific keyword is gone + const updatedXml = editor.serialize() + expect(updatedXml).not.toContain('AQUA > Earth Observing System, AQUA') + expect(updatedXml).toContain('TERRA > Earth Observing System, TERRA (AM-1)') + }) + }) + + describe('mixed namespace formats', () => { + test('should handle both EOS and MI namespaces', () => { + const mixedNamespaceXml = ` + + + + + + + TEST-PLATFORM > Test Platform + + + platform + + + + + + + + + + + + + TEST-PLATFORM + + + Test Platform + + + + + + + + + + + TEST-PLATFORM + + + Test Platform + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(mixedNamespaceXml) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'TEST-PLATFORM' }, + newKeywordObject: { ShortName: 'TEST-PLATFORM-2' }, + newLongName: 'Updated Test Platform' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // All three locations should be updated + // 1. Keyword block + expect(updatedXml).toContain('TEST-PLATFORM-2 > Updated Test Platform') + + // 2. EOS_Platform + const eosMatches = updatedXml.match(/eos:EOS_Platform[\s\S]*?\s*TEST-PLATFORM-2<\/gco:CharacterString>/) + expect(eosMatches).toBeTruthy() + + // 3. MI_Platform + const miMatches = updatedXml.match(/gmi:MI_Platform[\s\S]*?\s*TEST-PLATFORM-2<\/gco:CharacterString>/) + expect(miMatches).toBeTruthy() + }) + }) + + describe('edge cases', () => { + test('should correctly detect format when code contains > in text', () => { + const edgeCaseXml = ` + + + + + + + SPECIAL>PLATFORM > With Greater Than + + + platform + + + + + + + + + + + + + SPECIAL>PLATFORM > With Greater Than + + + + + Free text description + + + + + +` + + const editor = new Iso19115MetadataPathEditor(edgeCaseXml) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'SPECIAL>PLATFORM' }, + newKeywordObject: { ShortName: 'SPECIAL>PLATFORM-2' }, + newLongName: 'With Greater Than Updated' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Should detect combined format and preserve it + expect(updatedXml).toContain('SPECIAL>PLATFORM-2 > With Greater Than Updated') + + // Free text description should be preserved + expect(updatedXml).toContain('Free text description') + }) + + test('should handle whitespace variations around delimiter', () => { + // Note: Keywords with '>' but without spaces (e.g., 'PLATFORM>Long Name') + // don't split properly since we split on ' > ' with spaces. + // This test uses proper formatting with spaces. + const whitespaceXml = ` + + + + + + + PLATFORM > Long Name + + + platform + + + + + + + + + + + + + PLATFORM > Long Name + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(whitespaceXml) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'PLATFORM' }, + newKeywordObject: { ShortName: 'PLATFORM-2' }, + newLongName: 'Updated Long Name' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // The keyword block should be updated + expect(updatedXml).toContain('PLATFORM-2 > Updated Long Name') + + // Acquisition code detects ' > ' and preserves combined format + expect(updatedXml).toMatch(/\s*PLATFORM-2 > Updated Long Name<\/gco:CharacterString>/) + }) + + test('should handle empty string LongName', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithNSIDCAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'ICESat-2' }, + newKeywordObject: { ShortName: 'ICESat-3' }, + newLongName: '' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword block should have just ShortName when LongName is empty + expect(updatedXml).toMatch(/\s*ICESat-3<\/gco:CharacterString>/) + }) + }) +}) + +// Mock with BOTH keyword blocks AND NSIDC acquisition information +// Tests synchronization between the two sections for the same platforms +const mockIso19115WithKeywordsAndAcquisition = ` + + + + + + + TERRA > Earth Observing System, TERRA (AM-1) + + + AQUA > Earth Observing System, AQUA + + + platform + + + + + NASA / GCMD Platform Keywords + + + + + 2016-06-10 + + + revision + + + + + + + + + + + + + + + + + TERRA + + + gov.nasa.esdis.umm.platformshortname + + + Earth Observing System, TERRA (AM-1) + + + + + Earth Observation Satellites + + + + + + + + + AQUA + + + gov.nasa.esdis.umm.platformshortname + + + Earth Observing System, AQUA + + + + + Earth Observation Satellites + + + + + +` + +describe('Platform corrections with synchronized keyword blocks and acquisition information', () => { + test('should update BOTH keyword block and acquisition sections for AQUA', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { ShortName: 'AQUA-2' }, + newLongName: 'Updated Earth Observing System, AQUA-2' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // 1. Verify keyword block updated + expect(updatedXml).toContain('AQUA-2 > Updated Earth Observing System, AQUA-2') + expect(updatedXml).not.toContain('AQUA > Earth Observing System, AQUA') + + // 2. Verify acquisition gmd:code updated (ShortName only in NSIDC split format) + expect(updatedXml).toMatch(/[\s\S]*?\s*AQUA-2<\/gco:CharacterString>/) + + // 3. Verify acquisition identifier gmd:description updated (LongName only in NSIDC split format) + expect(updatedXml).toMatch(/[\s\S]*?\s*AQUA-2<\/gco:CharacterString>[\s\S]*?\s*Updated Earth Observing System, AQUA-2<\/gco:CharacterString>/) + + // 4. Verify TERRA remains unchanged in both sections + expect(updatedXml).toContain('TERRA > Earth Observing System, TERRA (AM-1)') + expect(updatedXml).toMatch(/[\s\S]*?\s*TERRA<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/[\s\S]*?\s*Earth Observing System, TERRA \(AM-1\)<\/gco:CharacterString>/) + }) + + test('should update BOTH sections for TERRA without affecting AQUA', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'TERRA' }, + newKeywordObject: { ShortName: 'TERRA-2' }, + newLongName: 'Updated Earth Observing System, TERRA-2 (AM-1)' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // 1. TERRA keyword updated + expect(updatedXml).toContain('TERRA-2 > Updated Earth Observing System, TERRA-2 (AM-1)') + expect(updatedXml).not.toContain('TERRA > Earth Observing System, TERRA (AM-1)') + + // 2. TERRA acquisition code updated + expect(updatedXml).toMatch(/[\s\S]*?\s*TERRA-2<\/gco:CharacterString>/) + + // 3. TERRA acquisition description updated + expect(updatedXml).toMatch(/[\s\S]*?\s*TERRA-2<\/gco:CharacterString>[\s\S]*?\s*Updated Earth Observing System, TERRA-2 \(AM-1\)<\/gco:CharacterString>/) + + // 4. AQUA remains unchanged in both sections + expect(updatedXml).toContain('AQUA > Earth Observing System, AQUA') + expect(updatedXml).toMatch(/[\s\S]*?\s*AQUA<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/[\s\S]*?\s*Earth Observing System, AQUA<\/gco:CharacterString>/) + }) + + test('should handle sequential updates to different platforms', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + // First update AQUA + const aquaCorrection = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { ShortName: 'AQUA-NEW' }, + newLongName: 'New AQUA Description' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const aquaSuccess = config(editor, aquaCorrection) + + // Then update TERRA + const terraCorrection = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'TERRA' }, + newKeywordObject: { ShortName: 'TERRA-NEW' }, + newLongName: 'New TERRA Description' + } + + const terraSuccess = config(editor, terraCorrection) + + expect(aquaSuccess).toBe(true) + expect(terraSuccess).toBe(true) + + const updatedXml = editor.serialize() + + // Both keyword blocks updated correctly + expect(updatedXml).toContain('AQUA-NEW > New AQUA Description') + expect(updatedXml).toContain('TERRA-NEW > New TERRA Description') + + // Both acquisition sections updated independently + expect(updatedXml).toMatch(/[\s\S]*?\s*AQUA-NEW<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/[\s\S]*?\s*TERRA-NEW<\/gco:CharacterString>/) + + // Descriptions updated independently + expect(updatedXml).toMatch(/[\s\S]*?\s*AQUA-NEW<\/gco:CharacterString>[\s\S]*?\s*New AQUA Description<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/[\s\S]*?\s*TERRA-NEW<\/gco:CharacterString>[\s\S]*?\s*New TERRA Description<\/gco:CharacterString>/) + }) + + test('should preserve codeSpace when updating platforms', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { ShortName: 'AQUA-3' }, + newLongName: 'Earth Observing System, AQUA-3' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + config(editor, correction) + + const updatedXml = editor.serialize() + + // CodeSpace should remain unchanged + expect(updatedXml).toContain('gov.nasa.esdis.umm.platformshortname') + expect(updatedXml).toMatch(/\s*gov.nasa.esdis.umm.platformshortname<\/gco:CharacterString>/) + }) + + test('should preserve platform-level gmi:description (not identifier description)', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { ShortName: 'AQUA-4' }, + newLongName: 'Updated System' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + config(editor, correction) + + const updatedXml = editor.serialize() + + // Platform-level description should be preserved + expect(updatedXml).toContain('\n Earth Observation Satellites') + }) + + test('should handle platform with only ShortName update', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { ShortName: 'AQUA-5' } + // No newLongName provided + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword should have just ShortName + expect(updatedXml).toContain('AQUA-5') + + // Acquisition code should be updated + expect(updatedXml).toMatch(/[\s\S]*?\s*AQUA-5<\/gco:CharacterString>/) + }) + + test('should delete platform from BOTH keyword block and acquisition section', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'platforms', + action: 'delete', + oldKeywordObject: { ShortName: 'AQUA' } + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword block should not contain AQUA + expect(updatedXml).not.toContain('AQUA > Earth Observing System, AQUA') + + // TERRA keyword should remain + expect(updatedXml).toContain('TERRA > Earth Observing System, TERRA (AM-1)') + + // Acquisition section should still exist but AQUA platform should be removed + expect(updatedXml).toContain('gmi:acquisitionInformation') + expect(updatedXml).not.toContain('') + expect(updatedXml).not.toContain('AQUA') + + // TERRA should still be in acquisition section + expect(updatedXml).toContain('') + expect(updatedXml).toMatch(/[\s\S]*?\s*TERRA<\/gco:CharacterString>/) + }) + + test('should verify both platforms are independent in updates', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + // Update only AQUA + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { ShortName: 'AQUA-MODIFIED' }, + newLongName: 'Modified AQUA' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + config(editor, correction) + + const updatedXml = editor.serialize() + + // AQUA keyword updated correctly + expect(updatedXml).toContain('AQUA-MODIFIED > Modified AQUA') + + // AQUA acquisition updated + expect(updatedXml).toMatch(/[\s\S]*?\s*AQUA-MODIFIED<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/[\s\S]*?\s*Modified AQUA<\/gco:CharacterString>/) + + // TERRA completely unchanged in both keyword and acquisition + expect(updatedXml).toContain('TERRA > Earth Observing System, TERRA (AM-1)') + expect(updatedXml).toMatch(/[\s\S]*?\s*TERRA<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/[\s\S]*?\s*Earth Observing System, TERRA \(AM-1\)<\/gco:CharacterString>/) + }) + + test('should handle empty LongName gracefully', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'TERRA' }, + newKeywordObject: { ShortName: 'TERRA-SIMPLE' }, + newLongName: '' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword should be just ShortName when LongName is empty + expect(updatedXml).toContain('TERRA-SIMPLE') + + // Should not create malformed "TERRA-SIMPLE > " + expect(updatedXml).not.toContain('TERRA-SIMPLE > ') + }) + + test('should handle special characters in platform names', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { ShortName: 'AQUA-1/2' }, + newLongName: 'Earth & Space System (Test)' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Special characters should be preserved in keyword + expect(updatedXml).toContain('AQUA-1/2 > Earth & Space System (Test)') + + // Special characters should be preserved in acquisition + expect(updatedXml).toMatch(/\s*AQUA-1\/2<\/gco:CharacterString>/) + }) + + test('should maintain XML structure integrity after updates', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { ShortName: 'AQUA-FINAL' }, + newLongName: 'Final AQUA Version' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + config(editor, correction) + + const updatedXml = editor.serialize() + + // Verify XML is well-formed by checking key structural elements + expect(updatedXml).toContain('') + expect(updatedXml).toContain('') + expect(updatedXml).toContain('') + expect(updatedXml).toContain('') + expect(updatedXml).toContain('') + + // Verify all namespaces are preserved + expect(updatedXml).toMatch(/xmlns:eos="http:\/\/earthdata.nasa.gov\/schema\/eos"/) + expect(updatedXml).toMatch(/xmlns:gco="http:\/\/www.isotc211.org\/2005\/gco"/) + expect(updatedXml).toMatch(/xmlns:gmd="http:\/\/www.isotc211.org\/2005\/gmd"/) + expect(updatedXml).toMatch(/xmlns:gmi="http:\/\/www.isotc211.org\/2005\/gmi"/) + }) +}) diff --git a/serverless/src/shared/__tests__/applyUmmcMetadataCorrections.test.js b/serverless/src/shared/__tests__/applyUmmcMetadataCorrections.test.js index 3c3b356e..58e47ba3 100644 --- a/serverless/src/shared/__tests__/applyUmmcMetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyUmmcMetadataCorrections.test.js @@ -3153,7 +3153,6 @@ describe('when applying DataFormat UMM-C corrections', () => { // expect(result.correctionCount).toBe(1) const parsed = JSON.parse(result.correctedMetadata) - console.log('parsed:', parsed) // Verify the specific path was updated expect(parsed.ArchiveAndDistributionInformation.FileArchiveInformation[0].Format).toBe('ZARR') // Verify the path that did not match the oldKeywordObject remains unchanged From 20338ee5d2d4787752666119af4f697bb1278bfc Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Thu, 2 Jul 2026 15:03:46 -0400 Subject: [PATCH 20/27] KMS-686: Add tests --- ...pplyIso19115InstrumentsCorrections.test.js | 176 ++++++++++++++++++ .../applyIso19115PlatformsCorrections.test.js | 173 +++++++++++++++++ 2 files changed, 349 insertions(+) diff --git a/serverless/src/shared/__tests__/applyIso19115InstrumentsCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115InstrumentsCorrections.test.js index 8b41f4da..2225bc02 100644 --- a/serverless/src/shared/__tests__/applyIso19115InstrumentsCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115InstrumentsCorrections.test.js @@ -340,6 +340,182 @@ describe('Instrument corrections with synchronized keyword blocks and acquisitio // Should not create malformed "MODIS-SIMPLE > " expect(updatedXml).not.toContain('MODIS-SIMPLE > ') + + // Verify acquisition gmd:description is empty string (line 255 coverage for instruments) + expect(updatedXml).toMatch(/[\s\S]*?\s*<\/gco:CharacterString>/) + }) + + test('should handle undefined newLongName (not just empty string)', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'ATLAS' }, + newKeywordObject: { ShortName: 'ATLAS-MINIMAL' } + // NewLongName is completely omitted (undefined) + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword should be just ShortName when newLongName is undefined (line 188 coverage for instruments) + expect(updatedXml).toContain('ATLAS-MINIMAL') + expect(updatedXml).not.toContain('ATLAS-MINIMAL >') + + // Verify acquisition code has only ShortName + expect(updatedXml).toMatch(/[\s\S]*?\s*ATLAS-MINIMAL<\/gco:CharacterString>/) + + // Verify acquisition description is empty string when newLongName is falsy (line 255 coverage) + expect(updatedXml).toMatch(/[\s\S]*?\s*<\/gco:CharacterString>/) + }) + + test('should preserve CWIC format free-text description when updating with combined format', () => { + // This specifically tests line 269 for instruments: return node?.textContent || '' + const cwicXml = ` + + + + + + + SIRAL > SAR Interferometric Radar Altimeter + + + instrument + + + + + + + + + + + + + SIRAL > SAR Interferometric Radar Altimeter + + + This is a custom free-text description that should be preserved in CWIC format for instruments + + + + + radar + + + SIRAL is a synthetic aperture radar altimeter operating in Ku-band + + + + + +` + + const editor = new Iso19115MetadataPathEditor(cwicXml) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'SIRAL' }, + newKeywordObject: { ShortName: 'SIRAL-2' }, + newLongName: 'SAR Interferometric Radar Altimeter Version 2' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword updated with combined format + expect(updatedXml).toContain('SIRAL-2 > SAR Interferometric Radar Altimeter Version 2') + + // Acquisition code updated with combined format (detected ' > ') + expect(updatedXml).toMatch(/\s*SIRAL-2 > SAR Interferometric Radar Altimeter Version 2<\/gco:CharacterString>/) + + // CRITICAL: Free-text description should be PRESERVED (line 269 coverage for instruments) + // This tests the fallback: return node?.textContent || '' + expect(updatedXml).toContain('This is a custom free-text description that should be preserved in CWIC format for instruments') + + // Instrument-level description also preserved + expect(updatedXml).toContain('SIRAL is a synthetic aperture radar altimeter operating in Ku-band') + }) + + test('should handle CWIC format with null/undefined description node gracefully', () => { + // Edge case: what if description node doesn't exist in CWIC format? + const cwicXmlNoDesc = ` + + + + + + + TEST-RADAR > Test Radar + + + instrument + + + + + + + + + + + + + TEST-RADAR > Test Radar + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(cwicXmlNoDesc) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'TEST-RADAR' }, + newKeywordObject: { ShortName: 'TEST-RADAR-2' }, + newLongName: 'Test Radar Version 2' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Should handle gracefully even without description node (line 269: || '' fallback) + expect(updatedXml).toContain('TEST-RADAR-2 > Test Radar Version 2') + expect(updatedXml).toMatch(/\s*TEST-RADAR-2 > Test Radar Version 2<\/gco:CharacterString>/) }) test('should handle special characters in instrument names', () => { diff --git a/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js index d8642543..2e42185b 100644 --- a/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js @@ -1159,6 +1159,179 @@ describe('Platform corrections with synchronized keyword blocks and acquisition // Should not create malformed "TERRA-SIMPLE > " expect(updatedXml).not.toContain('TERRA-SIMPLE > ') + + // Verify acquisition gmd:description is empty string (line 255 coverage) + expect(updatedXml).toMatch(/[\s\S]*?\s*<\/gco:CharacterString>/) + }) + + test('should handle undefined newLongName (not just empty string)', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithKeywordsAndAcquisition) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'AQUA' }, + newKeywordObject: { ShortName: 'AQUA-MINIMAL' } + // NewLongName is completely omitted (undefined) + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword should be just ShortName when newLongName is undefined (line 188 coverage) + expect(updatedXml).toContain('AQUA-MINIMAL') + expect(updatedXml).not.toContain('AQUA-MINIMAL >') + + // Verify acquisition code has only ShortName + expect(updatedXml).toMatch(/[\s\S]*?\s*AQUA-MINIMAL<\/gco:CharacterString>/) + + // Verify acquisition description is empty string when newLongName is falsy (line 255 coverage) + expect(updatedXml).toMatch(/[\s\S]*?\s*<\/gco:CharacterString>/) + }) + + test('should preserve CWIC format free-text description when updating with combined format', () => { + // This specifically tests line 269: return node?.textContent || '' + const cwicXml = ` + + + + + + + SENTINEL-1A > Sentinel-1A + + + platform + + + + + + + + + + + + + SENTINEL-1A > Sentinel-1A + + + This is a custom free-text description that should be preserved in CWIC format + + + + + Sentinel-1 is an imaging radar mission providing continuous all-weather imagery + + + + + +` + + const editor = new Iso19115MetadataPathEditor(cwicXml) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'SENTINEL-1A' }, + newKeywordObject: { ShortName: 'SENTINEL-1B' }, + newLongName: 'Sentinel-1B' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword updated with combined format + expect(updatedXml).toContain('SENTINEL-1B > Sentinel-1B') + + // Acquisition code updated with combined format (detected ' > ') + expect(updatedXml).toMatch(/\s*SENTINEL-1B > Sentinel-1B<\/gco:CharacterString>/) + + // CRITICAL: Free-text description should be PRESERVED (line 269 coverage) + // This tests the fallback: return node?.textContent || '' + expect(updatedXml).toContain('This is a custom free-text description that should be preserved in CWIC format') + + // Platform-level description also preserved + expect(updatedXml).toContain('Sentinel-1 is an imaging radar mission providing continuous all-weather imagery') + }) + + test('should handle CWIC format with null/undefined description node gracefully', () => { + // Edge case: what if description node doesn't exist in CWIC format? + const cwicXmlNoDesc = ` + + + + + + + TEST-SAT > Test Satellite + + + platform + + + + + + + + + + + + + TEST-SAT > Test Satellite + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(cwicXmlNoDesc) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'TEST-SAT' }, + newKeywordObject: { ShortName: 'TEST-SAT-2' }, + newLongName: 'Test Satellite Version 2' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Should handle gracefully even without description node (line 269: || '' fallback) + expect(updatedXml).toContain('TEST-SAT-2 > Test Satellite Version 2') + expect(updatedXml).toMatch(/\s*TEST-SAT-2 > Test Satellite Version 2<\/gco:CharacterString>/) }) test('should handle special characters in platform names', () => { From 6bce8ce72fac4ed716cc296ad7975d2ae8f652c3 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Thu, 2 Jul 2026 15:43:02 -0400 Subject: [PATCH 21/27] KMS-686: Add tests --- ...pplyIso19115InstrumentsCorrections.test.js | 221 ++++++++++++++++++ .../applyIso19115MetadataCorrections.test.js | 109 +++++++++ .../applyIso19115PlatformsCorrections.test.js | 221 ++++++++++++++++++ 3 files changed, 551 insertions(+) diff --git a/serverless/src/shared/__tests__/applyIso19115InstrumentsCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115InstrumentsCorrections.test.js index 2225bc02..264afb63 100644 --- a/serverless/src/shared/__tests__/applyIso19115InstrumentsCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115InstrumentsCorrections.test.js @@ -705,4 +705,225 @@ describe('Instrument corrections with synchronized keyword blocks and acquisitio // But acquisition section structure remains expect(updatedXml).toContain('gmi:acquisitionInformation') }) + + test('should detect combined format in acquisition code and update accordingly', () => { + // Test coverage for: if (existingValue.includes(' > ')) + const xmlWithCombinedFormat = ` + + + + + + + VIIRS > Visible Infrared Imaging Radiometer Suite + + + instrument + + + + + + + + + + + + + VIIRS > Visible Infrared Imaging Radiometer Suite + + + Custom instrument description + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(xmlWithCombinedFormat) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'VIIRS' }, + newKeywordObject: { ShortName: 'VIIRS-2' }, + newLongName: 'Visible Infrared Imaging Radiometer Suite Version 2' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword block updated + expect(updatedXml).toContain('VIIRS-2 > Visible Infrared Imaging Radiometer Suite Version 2') + + // CRITICAL: Code should use combined format because existing code includes ' > ' + // This covers the if (existingValue.includes(' > ')) branch + expect(updatedXml).toMatch(/\s*VIIRS-2 > Visible Infrared Imaging Radiometer Suite Version 2<\/gco:CharacterString>/) + + // Description should be preserved (CWIC format) + expect(updatedXml).toContain('Custom instrument description') + }) + + test('should use combined format with empty LongName when existing code has delimiter', () => { + // Test coverage for: if (existingValue.includes(' > ')) with LongName being falsy + // This tests: return LongName ? `${ShortName} > ${LongName}` : ShortName + const xmlWithDelimiter = ` + + + + + + + CERES > Clouds and the Earth's Radiant Energy System + + + instrument + + + + + + + + + + + + + CERES > Clouds and the Earth's Radiant Energy System + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(xmlWithDelimiter) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'CERES' }, + newKeywordObject: { ShortName: 'CERES-2' } + // No newLongName - testing the ternary with falsy LongName + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword block updated with just ShortName (no LongName provided) + expect(updatedXml).toContain('CERES-2') + + // Code detected ' > ' in existing value, enters if branch + // But LongName is falsy, so ternary returns just ShortName + // This covers: const { ShortName } = correction.newKeywordObject + // and: return LongName ? `${ShortName} > ${LongName}` : ShortName + expect(updatedXml).toMatch(/\s*CERES-2<\/gco:CharacterString>/) + }) + + test('should handle both EOS_Instrument and MI_Instrument with combined format detection', () => { + // Test coverage for both namespace variants with includes(' > ') check + const mixedXml = ` + + + + + + + AIRS > Atmospheric Infrared Sounder + + + instrument + + + + + + + + + + + + + AIRS > Atmospheric Infrared Sounder + + + + + + + + + + + AIRS > Atmospheric Infrared Sounder + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(mixedXml) + + const correction = { + scheme: 'instruments', + action: 'replace', + oldKeywordObject: { ShortName: 'AIRS' }, + newKeywordObject: { ShortName: 'AIRS-2' }, + newLongName: 'Atmospheric Infrared Sounder Version 2' + } + + const config = ISO_19115_SCHEME_EDITORS.instruments + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword updated + expect(updatedXml).toContain('AIRS-2 > Atmospheric Infrared Sounder Version 2') + + // Both EOS_Instrument and MI_Instrument should detect ' > ' and use combined format + expect(updatedXml).toMatch(/[\s\S]*?\s*AIRS-2 > Atmospheric Infrared Sounder Version 2<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/[\s\S]*?\s*AIRS-2 > Atmospheric Infrared Sounder Version 2<\/gco:CharacterString>/) + }) }) diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js index 2eef96ff..1791be4b 100644 --- a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -805,4 +805,113 @@ describe('when applying providers ISO-19115 corrections', () => { expect(updatedXml).not.toContain('DOC/NOAA/NESDIS/NCEI > National Centers for Environmental Information, NESDIS, NOAA, U.S. Department of Commerce') expect(updatedXml).toContain('DOC/NOAA/NESDIS/NODC > National Oceanographic Data Center, NESDIS, NOAA, U.S. Department of Commerce') }) + + test('should use fallback getValue with NONE for missing fieldKeys in science keywords', () => { + // This test covers the fallback getValue function (lines 58-61 and conceptually 70-71) + // getValue || (({ correction }) => fieldKeys.map((k) => correction.newKeywordObject[k] || 'NONE').join(' > ')) + // Note: Lines 70-71 (in additionalPaths.map) use the same fallback logic but are not hit by + // current schemes since all schemes with additionalPaths also provide custom getValue. + // This test covers the main keyword block fallback which uses identical logic. + // For sciencekeywords, NO custom getValue is provided, so it uses the default fallback + + const editor = new Iso19115MetadataPathEditor(mockIso19115) + + // Provide incomplete science keyword - missing some hierarchy levels + const correction = { + scheme: 'sciencekeywords', + action: 'replace', + oldKeywordObject: { + Category: 'EARTH SCIENCE', + Topic: 'ATMOSPHERE', + Term: 'AEROSOLS', + VariableLevel1: '', + VariableLevel2: '', + VariableLevel3: '', + DetailedVariable: '' + }, + newKeywordObject: { + Category: 'EARTH SCIENCE', + Topic: 'BIOSPHERE' + // Deliberately omitting Term, VariableLevel1, etc. to trigger || 'NONE' fallback + // The fallback function will map each missing field to 'NONE' + } + } + + const config = ISO_19115_SCHEME_EDITORS.sciencekeywords + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // The fallback getValue function (lines 70-71) will create: + // fieldKeys.map((k) => correction.newKeywordObject[k] || 'NONE').join(' > ') + // With fieldKeys = ['Category', 'Topic', 'Term', 'VariableLevel1', 'VariableLevel2', 'VariableLevel3', 'DetailedVariable'] + // Result: 'EARTH SCIENCE > BIOSPHERE > NONE > NONE > NONE > NONE > NONE' + expect(updatedXml).toContain('EARTH SCIENCE > BIOSPHERE > NONE > NONE > NONE > NONE > NONE') + }) + + test('should handle providers with missing LongName in both keyword and CI_ResponsibleParty', () => { + // Test that verifies the getValue logic propagates correctly to additionalPaths + // For providers: getValue is provided, so additionalPaths will use that getValue (not the fallback) + const xmlWithResponsibleParty = ` + + + + + + + NASA/JPL > Jet Propulsion Laboratory + + + dataCentre + + + + + + + + + NASA/JPL > Jet Propulsion Laboratory + + + pointOfContact + + + +` + + const editor = new Iso19115MetadataPathEditor(xmlWithResponsibleParty) + + const correction = { + scheme: 'providers', + action: 'replace', + oldKeywordObject: { ShortName: 'NASA/JPL' }, + newKeywordObject: { + ShortName: 'NASA/JPL/CALTECH' + }, + newLongName: 'Jet Propulsion Laboratory, California Institute of Technology' + } + + const config = ISO_19115_SCHEME_EDITORS.providers + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Both keyword and organisationName should be updated consistently + expect(updatedXml).toContain('NASA/JPL/CALTECH > Jet Propulsion Laboratory, California Institute of Technology') + + // Verify organisationName in CI_ResponsibleParty was also updated + expect(updatedXml).toMatch(/\s*NASA\/JPL\/CALTECH > Jet Propulsion Laboratory, California Institute of Technology<\/gco:CharacterString>/) + }) }) diff --git a/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js index 2e42185b..166cf9b8 100644 --- a/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js @@ -1389,4 +1389,225 @@ describe('Platform corrections with synchronized keyword blocks and acquisition expect(updatedXml).toMatch(/xmlns:gmd="http:\/\/www.isotc211.org\/2005\/gmd"/) expect(updatedXml).toMatch(/xmlns:gmi="http:\/\/www.isotc211.org\/2005\/gmi"/) }) + + test('should detect combined format in acquisition code and update accordingly', () => { + // Test coverage for: if (existingValue.includes(' > ')) + const xmlWithCombinedFormat = ` + + + + + + + NOAA-20 > NOAA-20 + + + platform + + + + + + + + + + + + + NOAA-20 > NOAA-20 + + + Custom description text + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(xmlWithCombinedFormat) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'NOAA-20' }, + newKeywordObject: { ShortName: 'NOAA-21' }, + newLongName: 'NOAA-21 Satellite' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword block updated + expect(updatedXml).toContain('NOAA-21 > NOAA-21 Satellite') + + // CRITICAL: Code should use combined format because existing code includes ' > ' + // This covers the if (existingValue.includes(' > ')) branch + expect(updatedXml).toMatch(/\s*NOAA-21 > NOAA-21 Satellite<\/gco:CharacterString>/) + + // Description should be preserved (CWIC format) + expect(updatedXml).toContain('Custom description text') + }) + + test('should use combined format with empty LongName when existing code has delimiter', () => { + // Test coverage for: if (existingValue.includes(' > ')) with LongName being falsy + // This tests: return LongName ? `${ShortName} > ${LongName}` : ShortName + const xmlWithDelimiter = ` + + + + + + + GOES-16 > Geostationary Operational Environmental Satellite-16 + + + platform + + + + + + + + + + + + + GOES-16 > Geostationary Operational Environmental Satellite-16 + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(xmlWithDelimiter) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'GOES-16' }, + newKeywordObject: { ShortName: 'GOES-17' } + // No newLongName - testing the ternary with falsy LongName + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword block updated with just ShortName (no LongName provided) + expect(updatedXml).toContain('GOES-17') + + // Code detected ' > ' in existing value, enters if branch + // But LongName is falsy, so ternary returns just ShortName + // This covers: const { ShortName } = correction.newKeywordObject + // and: return LongName ? `${ShortName} > ${LongName}` : ShortName + expect(updatedXml).toMatch(/\s*GOES-17<\/gco:CharacterString>/) + }) + + test('should handle both EOS_Platform and MI_Platform with combined format detection', () => { + // Test coverage for both namespace variants with includes(' > ') check + const mixedXml = ` + + + + + + + METOP-A > Meteorological Operational Satellite-A + + + platform + + + + + + + + + + + + + METOP-A > Meteorological Operational Satellite-A + + + + + + + + + + + METOP-A > Meteorological Operational Satellite-A + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(mixedXml) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'METOP-A' }, + newKeywordObject: { ShortName: 'METOP-B' }, + newLongName: 'Meteorological Operational Satellite-B' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword updated + expect(updatedXml).toContain('METOP-B > Meteorological Operational Satellite-B') + + // Both EOS_Platform and MI_Platform should detect ' > ' and use combined format + expect(updatedXml).toMatch(/[\s\S]*?\s*METOP-B > Meteorological Operational Satellite-B<\/gco:CharacterString>/) + expect(updatedXml).toMatch(/[\s\S]*?\s*METOP-B > Meteorological Operational Satellite-B<\/gco:CharacterString>/) + }) }) From fdd9be018b47eec9c0cd18e3dff9ff67ded735bd Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Thu, 2 Jul 2026 16:11:01 -0400 Subject: [PATCH 22/27] KMS-686: Add test --- .../applyIso19115PlatformsCorrections.test.js | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) diff --git a/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js index 166cf9b8..a442da10 100644 --- a/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115PlatformsCorrections.test.js @@ -1610,4 +1610,225 @@ describe('Platform corrections with synchronized keyword blocks and acquisition expect(updatedXml).toMatch(/[\s\S]*?\s*METOP-B > Meteorological Operational Satellite-B<\/gco:CharacterString>/) expect(updatedXml).toMatch(/[\s\S]*?\s*METOP-B > Meteorological Operational Satellite-B<\/gco:CharacterString>/) }) + + test('should handle unsupported action gracefully', () => { + // This covers line 169: return false when action is neither 'delete' nor 'replace' + const editor = new Iso19115MetadataPathEditor(mockIso19115WithNSIDCAcquisition) + + const correction = { + scheme: 'platforms', + action: 'invalid-action', // Unsupported action + oldKeywordObject: { ShortName: 'ICESat-2' }, + newKeywordObject: { ShortName: 'ICESat-3' } + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + // Should return false for unsupported action + expect(success).toBe(false) + + // XML should remain unchanged + const updatedXml = editor.serialize() + expect(updatedXml).toContain('ICESat-2') + expect(updatedXml).not.toContain('ICESat-3') + }) + + test('should handle delete when identifier has no code nodes', () => { + // This covers line 222: return false when identifier has no code nodes during delete + const xmlWithMalformedIdentifier = ` + + + + + + + TEST-SAT > Test Satellite + + + platform + + + + + + + + + + + + + + gov.nasa.esdis.umm.platformshortname + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(xmlWithMalformedIdentifier) + + const correction = { + scheme: 'platforms', + action: 'delete', + oldKeywordObject: { ShortName: 'TEST-SAT' } + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + // Should still succeed in deleting the keyword block + expect(success).toBe(true) + + const updatedXml = editor.serialize() + // Keyword removed from descriptiveKeywords + expect(updatedXml).not.toContain('TEST-SAT > Test Satellite') + // Malformed acquisition platform remains (no code node to match) + expect(updatedXml).toContain('eos:EOS_Platform') + }) + + test('should handle replace when identifier has no code nodes', () => { + // This covers line 305: return false when identifier has no code nodes during replace + const xmlWithMalformedIdentifier = ` + + + + + + + TEST-SAT > Test Satellite + + + platform + + + + + + + + + + + + + + gov.nasa.esdis.umm.platformshortname + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(xmlWithMalformedIdentifier) + + const correction = { + scheme: 'platforms', + action: 'replace', + oldKeywordObject: { ShortName: 'TEST-SAT' }, + newKeywordObject: { ShortName: 'NEW-SAT' }, + newLongName: 'New Satellite' + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + const success = config(editor, correction) + + // Should still succeed in updating the keyword block + expect(success).toBe(true) + + const updatedXml = editor.serialize() + // Keyword updated in descriptiveKeywords + expect(updatedXml).toContain('NEW-SAT > New Satellite') + // Malformed acquisition platform remains unchanged (no code node to match) + expect(updatedXml).toContain('eos:EOS_Platform') + expect(updatedXml).not.toContain('NEW-SAT') + }) + + test('should break when reaching document root during delete navigation', () => { + // This covers line 246: break when currentNode === this.document.documentElement + // Create XML where MD_Identifier is in an unexpected location (not properly nested in EOS_Platform/MI_Platform) + const xmlWithShallowIdentifier = ` + + + + + + + ORPHAN-SAT > Orphan Satellite + + + platform + + + + + + + + + + + ORPHAN-SAT + + + gov.nasa.esdis.umm.platformshortname + + + + +` + + const editor = new Iso19115MetadataPathEditor(xmlWithShallowIdentifier) + + const correction = { + scheme: 'platforms', + action: 'delete', + oldKeywordObject: { ShortName: 'ORPHAN-SAT' } + } + + const config = ISO_19115_SCHEME_EDITORS.platforms + + // The delete operation should find the identifier matching 'ORPHAN-SAT' + // When navigating up from MD_Identifier, it will check: + // - parentNode = MI_AcquisitionInformation (localName !== EOS_Platform/MI_Platform) + // - parentNode = acquisitionInformation (localName !== EOS_Platform/MI_Platform) + // - parentNode = MI_Metadata (this IS document.documentElement) + // Line 246: break is hit when currentNode === this.document.documentElement + const success = config(editor, correction) + expect(success).toBe(true) + + const updatedXml = editor.serialize() + // Keyword should be deleted + expect(updatedXml).not.toContain('ORPHAN-SAT > Orphan Satellite') + // Orphan MD_Identifier remains because navigation hit document root without finding platform wrapper + expect(updatedXml).toContain('ORPHAN-SAT') + }) }) From 40fc3709f18f758a1df513a3da788758748eeeaf Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Thu, 2 Jul 2026 16:19:42 -0400 Subject: [PATCH 23/27] KMS-686: Add tests to smoke test script/mock data --- ...rection_service.iso-19115.corrections.json | 39 +++++++++---- ...a_correction_service.iso-19115.example.xml | 56 +++++++++++++++++++ 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/scripts/local/fixtures/metadata_correction_service.iso-19115.corrections.json b/scripts/local/fixtures/metadata_correction_service.iso-19115.corrections.json index 7b77f10a..c0de9215 100644 --- a/scripts/local/fixtures/metadata_correction_service.iso-19115.corrections.json +++ b/scripts/local/fixtures/metadata_correction_service.iso-19115.corrections.json @@ -48,26 +48,45 @@ { "scheme": "platforms", "action": "replace", - "newLongName": "New Platform Long Name", + "newLongName": "Earth Observing System AQUA Updated", "oldKeywordObject": { - "Class": "Air-based Platforms", - "Type": "Rotorcraft/Helicopter", "ShortName": "AQUA" }, "newKeywordObject": { - "Class": "Air-based Platforms", - "Type": "Rotorcraft/Helicopter", - "ShortName": "AQUA" + "ShortName": "AQUA-2" + } + }, + { + "scheme": "platforms", + "action": "replace", + "newLongName": "Earth Observing System TERRA Updated", + "oldKeywordObject": { + "ShortName": "TERRA" + }, + "newKeywordObject": { + "ShortName": "TERRA-2" + } + }, + { + "scheme": "instruments", + "action": "replace", + "newLongName": "Moderate-Resolution Imaging Spectroradiometer Updated", + "oldKeywordObject": { + "ShortName": "MODIS" + }, + "newKeywordObject": { + "ShortName": "MODIS-2" } }, { "scheme": "instruments", - "action": "delete", + "action": "replace", + "newLongName": "Advanced Topographic Laser Altimeter System Updated", "oldKeywordObject": { - "Category": "Imaging Spectrometers/Radiometers", - "Class": "", - "Subclass": "", "ShortName": "ATLAS" + }, + "newKeywordObject": { + "ShortName": "ATLAS-2" } }, { diff --git a/scripts/local/fixtures/metadata_correction_service.iso-19115.example.xml b/scripts/local/fixtures/metadata_correction_service.iso-19115.example.xml index b1917d4d..b4596a3c 100644 --- a/scripts/local/fixtures/metadata_correction_service.iso-19115.example.xml +++ b/scripts/local/fixtures/metadata_correction_service.iso-19115.example.xml @@ -276,4 +276,60 @@ + + + + + + + + + AQUA > Earth Observing System + + + + + + + + + MODIS > Moderate-Resolution Imaging Spectroradiometer + + + + + + + + + + + + + + TERRA + + + Earth Observing System, TERRA (AM-1) + + + + + + + + + ATLAS + + + Advanced Topographic Laser Altimeter System + + + + + + + + + \ No newline at end of file From 977f10d1814fedd142302e3c0d7025feb21db2e3 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Tue, 7 Jul 2026 11:54:11 -0400 Subject: [PATCH 24/27] KMS-686: Remove additional path of providers: Responsible party --- serverless/src/shared/Iso19115DomEditor.js | 10 +-- .../applyIso19115MetadataCorrections.test.js | 66 +------------------ 2 files changed, 6 insertions(+), 70 deletions(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index 70c42776..0a54f503 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -321,11 +321,11 @@ export const ISO_19115_SCHEME_EDITORS = { const LongName = correction.newLongName || '' return LongName ? `${ShortName} > ${LongName}` : ShortName - }, - // Additional paths - additionalPaths: [ - '//gmd:CI_ResponsibleParty/gmd:organisationName/gco:CharacterString' - ] + } + // // Additional paths + // additionalPaths: [ + // '//gmd:CI_ResponsibleParty/gmd:organisationName/gco:CharacterString' + // ] }), isotopiccategory: createIsoTopicCategoryEditor(), diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js index 1791be4b..505a150b 100644 --- a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -71,7 +71,7 @@ const mockIso19115 = ` - DOC/NOAA/NESDIS/NCEI > National Centers for Environmental Information, NESDIS, NOAA, U.S. Department of Commerce + DOC/NOAA/NESDIS/NCEI/R > National Centers for Environmental Information, NESDIS, NOAA, U.S. Department of Commerce @@ -850,68 +850,4 @@ describe('when applying providers ISO-19115 corrections', () => { // Result: 'EARTH SCIENCE > BIOSPHERE > NONE > NONE > NONE > NONE > NONE' expect(updatedXml).toContain('EARTH SCIENCE > BIOSPHERE > NONE > NONE > NONE > NONE > NONE') }) - - test('should handle providers with missing LongName in both keyword and CI_ResponsibleParty', () => { - // Test that verifies the getValue logic propagates correctly to additionalPaths - // For providers: getValue is provided, so additionalPaths will use that getValue (not the fallback) - const xmlWithResponsibleParty = ` - - - - - - - NASA/JPL > Jet Propulsion Laboratory - - - dataCentre - - - - - - - - - NASA/JPL > Jet Propulsion Laboratory - - - pointOfContact - - - -` - - const editor = new Iso19115MetadataPathEditor(xmlWithResponsibleParty) - - const correction = { - scheme: 'providers', - action: 'replace', - oldKeywordObject: { ShortName: 'NASA/JPL' }, - newKeywordObject: { - ShortName: 'NASA/JPL/CALTECH' - }, - newLongName: 'Jet Propulsion Laboratory, California Institute of Technology' - } - - const config = ISO_19115_SCHEME_EDITORS.providers - const success = config(editor, correction) - - expect(success).toBe(true) - - const updatedXml = editor.serialize() - - // Both keyword and organisationName should be updated consistently - expect(updatedXml).toContain('NASA/JPL/CALTECH > Jet Propulsion Laboratory, California Institute of Technology') - - // Verify organisationName in CI_ResponsibleParty was also updated - expect(updatedXml).toMatch(/\s*NASA\/JPL\/CALTECH > Jet Propulsion Laboratory, California Institute of Technology<\/gco:CharacterString>/) - }) }) From 30cae8d6f2c656b31a9eb61a05faf42e12b85c9e Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Tue, 7 Jul 2026 12:08:12 -0400 Subject: [PATCH 25/27] KMS-686: Fix platfform vs instrument target identity --- .../src/shared/Iso19115MetadataPathEditor.js | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/serverless/src/shared/Iso19115MetadataPathEditor.js b/serverless/src/shared/Iso19115MetadataPathEditor.js index 3eea705f..ec1c68d1 100644 --- a/serverless/src/shared/Iso19115MetadataPathEditor.js +++ b/serverless/src/shared/Iso19115MetadataPathEditor.js @@ -210,13 +210,43 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { // For platforms/instruments, delete from acquisition section too if (isPlatformOrInstrument && oldShortName && config.replace) { // Find all MD_Identifier nodes that match this platform/instrument + // AND have the correct codeSpace to identify them as acquisition info const allIdentifiers = this.selectNodes('//gmd:MD_Identifier', this.document) const identifiersToDelete = allIdentifiers.filter((identifier) => { const codeNodes = this.selectNodes('.//gmd:code/gco:CharacterString', identifier) - if (codeNodes.length > 0) { - const codeValue = codeNodes[0].textContent?.trim() || '' + if (codeNodes.length === 0) { + return false + } - return codeValue === oldShortName || codeValue.startsWith(`${oldShortName} > `) + const codeValue = codeNodes[0].textContent?.trim() || '' + const codeMatches = codeValue === oldShortName || codeValue.startsWith(`${oldShortName} > `) + + if (!codeMatches) { + return false + } + + // Check codeSpace to ensure this is an acquisition identifier + const codeSpaceNodes = this.selectNodes('.//gmd:codeSpace/gco:CharacterString', identifier) + if (codeSpaceNodes.length > 0) { + const codeSpaceValue = codeSpaceNodes[0].textContent?.trim() || '' + + // Match acquisition-specific codeSpace values based on scheme + const isAcquisitionIdentifier = correction.scheme === 'platforms' + ? codeSpaceValue === 'gov.nasa.esdis.umm.platformshortname' + : codeSpaceValue === 'gov.nasa.esdis.umm.instrumentshortname' + + return isAcquisitionIdentifier + } + + // If no codeSpace, check if identifier is within acquisitionInformation context + let current = identifier.parentNode + while (current && current !== this.document.documentElement) { + if (current.localName === 'acquisitionInformation' + || current.localName === 'MI_AcquisitionInformation') { + return true + } + + current = current.parentNode } return false @@ -230,7 +260,7 @@ export class Iso19115MetadataPathEditor extends XmlMetadataPathEditor { const { localName } = currentNode // Check if we found a platform or instrument node if (localName === 'EOS_Platform' || localName === 'MI_Platform' - || localName === 'EOS_Instrument' || localName === 'MI_Instrument') { + || localName === 'EOS_Instrument' || localName === 'MI_Instrument') { // Found the acquisition wrapper node, remove it entirely if (currentNode.parentNode) { currentNode.parentNode.removeChild(currentNode) From ad086c4a7de806e39631659b67ec4d1720528e3b Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Tue, 7 Jul 2026 13:41:02 -0400 Subject: [PATCH 26/27] KMS-686: Add additional path for projects --- serverless/src/shared/Iso19115DomEditor.js | 18 +- .../applyIso19115MetadataCorrections.test.js | 276 ++++++++++++++++++ 2 files changed, 293 insertions(+), 1 deletion(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index 0a54f503..5c0b6cbd 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -310,7 +310,23 @@ export const ISO_19115_SCHEME_EDITORS = { const LongName = correction.newLongName || '' return LongName ? `${ShortName} > ${LongName}` : ShortName - } + }, + // Sync with acquisition information section (gmi:MI_Operation) + additionalPaths: [ + { + path: '//gmi:acquisitionInformation/gmi:MI_AcquisitionInformation/gmi:operation/gmi:MI_Operation/gmi:identifier/gmd:MD_Identifier[gmd:codeSpace/gco:CharacterString="gov.nasa.esdis.umm.projectshortname"]/gmd:code/gco:CharacterString', + getValue: ({ correction }) => { + const { ShortName } = correction.newKeywordObject + const LongName = correction.newLongName || '' + + return LongName ? `${ShortName} > ${LongName}` : ShortName + } + }, + { + path: '//gmi:acquisitionInformation/gmi:MI_AcquisitionInformation/gmi:operation/gmi:MI_Operation/gmi:identifier/gmd:MD_Identifier[gmd:codeSpace/gco:CharacterString="gov.nasa.esdis.umm.projectshortname"]/gmd:description/gco:CharacterString', + getValue: ({ correction }) => correction.newLongName || '' + } + ] }), providers: createKeywordBlock('dataCentre', { diff --git a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js index 505a150b..cf3da128 100644 --- a/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js +++ b/serverless/src/shared/__tests__/applyIso19115MetadataCorrections.test.js @@ -669,6 +669,282 @@ describe('when applying projects ISO-19115 corrections', () => { }) }) +describe('when applying projects ISO-19115 corrections', () => { + // Create a more complete mock with acquisition information + const mockIso19115WithAcquisition = ` + + + + + + + MEASURES > Making Earth System Data Records for Use in Research Environments + + + project + + + + + NASA / GCMD Project Keywords + + + + + + + + + + + + + + + MEASURES > Making Earth System Data Records for Use in Research Environments + + + gov.nasa.esdis.umm.projectshortname + + + Making Earth System Data Records for Use in Research Environments + + + + + + + +` + + test('should replace existing project keyword correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + + const correction = { + scheme: 'projects', + action: 'replace', + oldKeywordObject: { ShortName: 'MEASURES' }, + newKeywordObject: { + ShortName: 'MEASURES-1' + }, + newLongName: 'New Project Description' + } + + const config = ISO_19115_SCHEME_EDITORS.projects + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + expect(updatedXml).toContain('MEASURES-1 > New Project Description') + expect(updatedXml).not.toContain('MEASURES > Making Earth System Data Records for Use in Research Environments') + }) + + test('should replace project keyword and sync acquisition information', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithAcquisition) + + const correction = { + scheme: 'projects', + action: 'replace', + oldKeywordObject: { ShortName: 'MEASURES' }, + newKeywordObject: { + ShortName: 'MEASURES-1' + }, + newLongName: 'New Project Description' + } + + const config = ISO_19115_SCHEME_EDITORS.projects + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Verify keyword block was updated + expect(updatedXml).toContain('MEASURES-1 > New Project Description') + expect(updatedXml).not.toContain('MEASURES > Making Earth System Data Records for Use in Research Environments') + + // Verify acquisition MI_Operation code was updated + expect(updatedXml).toMatch( + /[\s\S]*?[\s\S]*?MEASURES-1 > New Project Description<\/gco:CharacterString>[\s\S]*?<\/gmd:code>[\s\S]*?[\s\S]*?gov\.nasa\.esdis\.umm\.projectshortname<\/gco:CharacterString>/ + ) + + // Verify acquisition MI_Operation description was updated + expect(updatedXml).toMatch( + /[\s\S]*?[\s\S]*?New Project Description<\/gco:CharacterString>[\s\S]*?<\/gmd:description>/ + ) + }) + + test('should handle project with only ShortName (no LongName)', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithAcquisition) + + const correction = { + scheme: 'projects', + action: 'replace', + oldKeywordObject: { ShortName: 'MEASURES' }, + newKeywordObject: { + ShortName: 'MEASURES-2' + } + // No newLongName provided + } + + const config = ISO_19115_SCHEME_EDITORS.projects + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Should use ShortName only (no " > LongName") + expect(updatedXml).toContain('MEASURES-2') + expect(updatedXml).not.toContain('MEASURES-2 >') + + // Verify acquisition code was updated to ShortName only + expect(updatedXml).toMatch( + /[\s\S]*?[\s\S]*?MEASURES-2<\/gco:CharacterString>[\s\S]*?<\/gmd:code>/ + ) + }) + + test('should not update unrelated acquisition operations', () => { + const xmlWithMultipleOperations = ` + + + + + + + MEASURES > Old Description + + + project + + + + + + + + + + + + + MEASURES > Old Description + + + gov.nasa.esdis.umm.projectshortname + + + + + + + + + + + MEASURES > Different Operation + + + some.other.identifier.type + + + + + + + +` + + const editor = new Iso19115MetadataPathEditor(xmlWithMultipleOperations) + + const correction = { + scheme: 'projects', + action: 'replace', + oldKeywordObject: { ShortName: 'MEASURES' }, + newKeywordObject: { ShortName: 'MEASURES-1' }, + newLongName: 'New Description' + } + + const config = ISO_19115_SCHEME_EDITORS.projects + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Verify keyword block was updated + expect(updatedXml).toContain('MEASURES-1 > New Description') + expect(updatedXml).not.toContain('MEASURES > Old Description') + + // Verify the project operation with correct codeSpace was updated + // Use a more flexible regex that doesn't depend on element order + expect(updatedXml).toMatch( + /[\s\S]*?[\s\S]*?[\s\S]*?MEASURES-1 > New Description<\/gco:CharacterString>[\s\S]*?<\/gmd:code>[\s\S]*?[\s\S]*?gov\.nasa\.esdis\.umm\.projectshortname<\/gco:CharacterString>[\s\S]*?<\/gmd:codeSpace>[\s\S]*?<\/gmd:MD_Identifier>[\s\S]*?<\/gmi:MI_Operation>/ + ) + + // Verify the operation has the correct codeSpace + expect(updatedXml).toContain('gov.nasa.esdis.umm.projectshortname') + + // Unrelated operation (different codeSpace) should NOT be changed + expect(updatedXml).toContain('some.other.identifier.type') + expect(updatedXml).toContain('MEASURES > Different Operation') + }) + + test('should delete existing project keyword block correctly', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115) + const correction = { + scheme: 'projects', + action: 'delete', + oldKeywordObject: { ShortName: 'MEASURES' } + } + + const config = ISO_19115_SCHEME_EDITORS.projects + const success = config(editor, correction) + + expect(success).toBe(true) + + // Verify the specific keyword is gone + const updatedXml = editor.serialize() + expect(updatedXml).not.toContain('MEASURES > Making Earth System Data Records for Use in Research Environments') + expect(updatedXml).toContain('MAGIA > Structure, Stratigraphy, and Sedimentology North of the Antarctic Peninsula') + }) + + test('should delete project and remove from acquisition information', () => { + const editor = new Iso19115MetadataPathEditor(mockIso19115WithAcquisition) + + const correction = { + scheme: 'projects', + action: 'delete', + oldKeywordObject: { ShortName: 'MEASURES' } + } + + const config = ISO_19115_SCHEME_EDITORS.projects + const success = config(editor, correction) + + expect(success).toBe(true) + + const updatedXml = editor.serialize() + + // Keyword should be removed + expect(updatedXml).not.toContain('MEASURES > Making Earth System Data Records for Use in Research Environments') + + // Note: The current implementation only handles updates via additionalPaths on replace actions. + // For delete actions, the acquisition information would need to be handled separately + // in the delete logic if that requirement exists. Currently testing the keyword deletion. + }) +}) + describe('when applying isotopiccategory ISO-19115 corrections', () => { test('should replace existing isotopiccategory correctly', () => { const editor = new Iso19115MetadataPathEditor(mockIso19115) From f0241887c615fdc55577158acbc72b482be3ff83 Mon Sep 17 00:00:00 2001 From: Hoan-Vu Tran-Ho Date: Tue, 7 Jul 2026 13:44:44 -0400 Subject: [PATCH 27/27] KMS-686: Update comment --- serverless/src/shared/Iso19115DomEditor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverless/src/shared/Iso19115DomEditor.js b/serverless/src/shared/Iso19115DomEditor.js index 5c0b6cbd..5d3e8a90 100644 --- a/serverless/src/shared/Iso19115DomEditor.js +++ b/serverless/src/shared/Iso19115DomEditor.js @@ -338,7 +338,7 @@ export const ISO_19115_SCHEME_EDITORS = { return LongName ? `${ShortName} > ${LongName}` : ShortName } - // // Additional paths + // Additional paths // additionalPaths: [ // '//gmd:CI_ResponsibleParty/gmd:organisationName/gco:CharacterString' // ]