From 4e55e42ee4b31585a5734c1d114432450799a5ec Mon Sep 17 00:00:00 2001 From: AJAL ODORA JONATHAN <43242517+ODORA0@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:41:37 +0300 Subject: [PATCH 1/4] fix(implementer-tools): handle paginated backend module fetch and correct moduleId mapping --- .../openmrs-backend-dependencies.ts | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts b/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts index c9bba5f4d..c5fa3c4eb 100644 --- a/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts +++ b/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts @@ -41,8 +41,8 @@ let cachedFrontendModules: Array; async function initInstalledBackendModules(): Promise> { try { - const response = await fetchInstalledBackendModules(); - return response.data.results; + const modules = await fetchInstalledBackendModules(); + return modules; } catch (err) { console.error(err); } @@ -88,10 +88,44 @@ function checkIfModulesAreInstalled( }; } -function fetchInstalledBackendModules() { - return openmrsFetch(`${restBaseUrl}/module?v=custom:(uuid,version)`, { - method: 'GET', - }); +async function fetchInstalledBackendModules(): Promise> { + const collected: Array = []; + let nextUrl: string | null = `${restBaseUrl}/module?v=default`; + let safetyCounter = 0; + const MAX_PAGES = 50; + + const resolveNext = (url?: string | null) => { + if (!url) return null; + if (/^https?:\/\//i.test(url)) return url; + if (url.startsWith('/')) return url; + return `${restBaseUrl}/${url.replace(/^\/?/, '')}`; + }; + + while (nextUrl && safetyCounter < MAX_PAGES) { + try { + const { data } = await openmrsFetch(nextUrl, { method: 'GET' }); + const rawResults: Array = Array.isArray(data?.results) ? data.results : []; + + // Use moduleid/moduleId for name matching against required dependency keys (e.g., 'reporting') + const pageResults: Array = rawResults.map((r) => ({ + uuid: r.moduleId ?? r.moduleid ?? r.uuid, + version: r.version, + })); + + collected.push(...pageResults); + + const links: Array<{ rel?: string; uri?: string; href?: string }> = Array.isArray(data?.links) ? data.links : []; + const nextLink = links.find((l) => (l.rel || '').toLowerCase() === 'next'); + + nextUrl = resolveNext(nextLink ? nextLink.uri || nextLink.href || null : null); + safetyCounter += 1; + } catch (e) { + console.error('Failed to fetch installed backend modules page', e); + break; + } + } + + return collected; } function getMissingBackendModules( From 23200a994dd40800f2d7d3643a3a99929ff6fa39 Mon Sep 17 00:00:00 2001 From: AJAL ODORA JONATHAN <43242517+ODORA0@users.noreply.github.com> Date: Wed, 8 Oct 2025 23:52:01 +0300 Subject: [PATCH 2/4] Optimize backend module fetching with custom API representation and improved error handling --- .../openmrs-backend-dependencies.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts b/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts index c5fa3c4eb..dc52e7f88 100644 --- a/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts +++ b/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts @@ -39,6 +39,8 @@ interface BackendModule { let cachedFrontendModules: Array; +const MAX_PAGES = 50; + async function initInstalledBackendModules(): Promise> { try { const modules = await fetchInstalledBackendModules(); @@ -90,9 +92,8 @@ function checkIfModulesAreInstalled( async function fetchInstalledBackendModules(): Promise> { const collected: Array = []; - let nextUrl: string | null = `${restBaseUrl}/module?v=default`; + let nextUrl: string | null = `${restBaseUrl}/module?v=custom:(uuid,version)`; let safetyCounter = 0; - const MAX_PAGES = 50; const resolveNext = (url?: string | null) => { if (!url) return null; @@ -104,11 +105,10 @@ async function fetchInstalledBackendModules(): Promise> { while (nextUrl && safetyCounter < MAX_PAGES) { try { const { data } = await openmrsFetch(nextUrl, { method: 'GET' }); - const rawResults: Array = Array.isArray(data?.results) ? data.results : []; + const rawResults: Array = Array.isArray(data?.results) ? data.results : []; - // Use moduleid/moduleId for name matching against required dependency keys (e.g., 'reporting') const pageResults: Array = rawResults.map((r) => ({ - uuid: r.moduleId ?? r.moduleid ?? r.uuid, + uuid: r.uuid, version: r.version, })); @@ -117,14 +117,20 @@ async function fetchInstalledBackendModules(): Promise> { const links: Array<{ rel?: string; uri?: string; href?: string }> = Array.isArray(data?.links) ? data.links : []; const nextLink = links.find((l) => (l.rel || '').toLowerCase() === 'next'); - nextUrl = resolveNext(nextLink ? nextLink.uri || nextLink.href || null : null); + nextUrl = resolveNext(nextLink?.uri ?? null); safetyCounter += 1; } catch (e) { - console.error('Failed to fetch installed backend modules page', e); + console.error(`Failed to fetch installed backend modules page ${safetyCounter + 1} (URL: ${nextUrl})`, e); break; } } + if (nextUrl && safetyCounter >= MAX_PAGES) { + console.warn( + `Reached maximum page limit (${MAX_PAGES}) while fetching backend modules. There may be more data available at: ${nextUrl}`, + ); + } + return collected; } From 93ece0db76477946ac0ed8b2d33f9d422e69a73b Mon Sep 17 00:00:00 2001 From: Dennis Kigen Date: Thu, 9 Oct 2025 16:04:21 +0300 Subject: [PATCH 3/4] Additional mods --- .../openmrs-backend-dependencies.ts | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts b/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts index dc52e7f88..e1ee861de 100644 --- a/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts +++ b/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts @@ -105,23 +105,18 @@ async function fetchInstalledBackendModules(): Promise> { while (nextUrl && safetyCounter < MAX_PAGES) { try { const { data } = await openmrsFetch(nextUrl, { method: 'GET' }); - const rawResults: Array = Array.isArray(data?.results) ? data.results : []; - - const pageResults: Array = rawResults.map((r) => ({ - uuid: r.uuid, - version: r.version, - })); + const pageResults: Array = Array.isArray(data?.results) ? data.results : []; collected.push(...pageResults); - const links: Array<{ rel?: string; uri?: string; href?: string }> = Array.isArray(data?.links) ? data.links : []; + const links: Array<{ rel?: string; uri?: string }> = Array.isArray(data?.links) ? data.links : []; const nextLink = links.find((l) => (l.rel || '').toLowerCase() === 'next'); nextUrl = resolveNext(nextLink?.uri ?? null); safetyCounter += 1; } catch (e) { console.error(`Failed to fetch installed backend modules page ${safetyCounter + 1} (URL: ${nextUrl})`, e); - break; + throw new Error(`Failed to fetch backend modules: ${e instanceof Error ? e.message : 'Unknown error'}`); } } @@ -169,11 +164,9 @@ function getInstalledAndRequiredBackendModules( version: declaredBackendModules[key], })); - return requiredModules.filter((requiredModule) => { - return installedBackendModules.find((installedModule) => { - return requiredModule.uuid === installedModule.uuid; - }); - }); + // Use Set for O(1) lookup instead of O(n) find + const installedUuids = new Set(installedBackendModules.map((module) => module.uuid)); + return requiredModules.filter((requiredModule) => installedUuids.has(requiredModule.uuid)); } function getInstalledVersion( @@ -181,7 +174,7 @@ function getInstalledVersion( installedBackendModules: Array, ) { const moduleName = installedAndRequiredBackendModule.uuid; - return installedBackendModules.find((mod) => mod.uuid == moduleName)?.version ?? ''; + return installedBackendModules.find((mod) => mod.uuid === moduleName)?.version ?? ''; } function getResolvedModuleType(requiredVersion: string, installedVersion: string): ResolvedBackendModuleType { From 14f7664eaa4f42ba367330959ddd08ba09565f25 Mon Sep 17 00:00:00 2001 From: Dennis Kigen Date: Thu, 9 Oct 2025 22:17:28 +0300 Subject: [PATCH 4/4] Add tests --- .../openmrs-backend-dependencies.test.ts | 434 ++++++++++++++++++ .../openmrs-backend-dependencies.ts | 20 +- 2 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.test.ts diff --git a/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.test.ts b/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.test.ts new file mode 100644 index 000000000..8763dfc31 --- /dev/null +++ b/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.test.ts @@ -0,0 +1,434 @@ +import { openmrsFetch, isVersionSatisfied } from '@openmrs/esm-framework'; +import { + checkModules, + hasInvalidDependencies, + clearCache, + type ResolvedBackendModuleType, +} from './openmrs-backend-dependencies'; + +jest.mock('@openmrs/esm-framework', () => ({ + openmrsFetch: jest.fn(), + isVersionSatisfied: jest.fn(), + restBaseUrl: '/ws/rest/v1', +})); + +const mockOpenmrsFetch = jest.mocked(openmrsFetch); +const mockIsVersionSatisfied = jest.mocked(isVersionSatisfied); + +describe('openmrs-backend-dependencies', () => { + beforeEach(() => { + jest.clearAllMocks(); + clearCache(); + window.installedModules = []; + }); + + describe('checkModules', () => { + it('should return empty array when no modules have backend dependencies', async () => { + mockOpenmrsFetch.mockResolvedValue({ + data: { + results: [], + links: [], + }, + } as any); + + window.installedModules = [ + ['@openmrs/esm-app-1', {}], + ['@openmrs/esm-app-2', {} as any], + ]; + + const result = await checkModules(); + + expect(result).toEqual([]); + }); + + it('should identify missing backend modules', async () => { + mockOpenmrsFetch.mockResolvedValue({ + data: { + results: [{ uuid: 'webservices.rest', version: '2.24.0' }], + links: [], + }, + } as any); + + window.installedModules = [['@openmrs/esm-test-app', { backendDependencies: { 'missing-module': '1.0.0' } }]]; + + const result = await checkModules(); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('@openmrs/esm-test-app'); + expect(result[0].dependencies).toHaveLength(1); + expect(result[0].dependencies[0]).toMatchObject({ + name: 'missing-module', + requiredVersion: '1.0.0', + type: 'missing', + }); + expect(result[0].dependencies[0].installedVersion).toBeUndefined(); + }); + + it('should identify version mismatches', async () => { + mockOpenmrsFetch.mockResolvedValue({ + data: { + results: [{ uuid: 'webservices.rest', version: '2.24.0' }], + links: [], + }, + } as any); + + mockIsVersionSatisfied.mockReturnValue(false); + + window.installedModules = [['@openmrs/esm-test-app', { backendDependencies: { 'webservices.rest': '^3.0.0' } }]]; + + const result = await checkModules(); + + expect(result[0].dependencies[0]).toMatchObject({ + name: 'webservices.rest', + requiredVersion: '^3.0.0', + installedVersion: '2.24.0', + type: 'version-mismatch', + }); + }); + + it('should mark satisfied dependencies as okay', async () => { + mockOpenmrsFetch.mockResolvedValue({ + data: { + results: [{ uuid: 'webservices.rest', version: '2.24.0' }], + links: [], + }, + } as any); + + mockIsVersionSatisfied.mockReturnValue(true); + + window.installedModules = [['@openmrs/esm-test-app', { backendDependencies: { 'webservices.rest': '^2.0.0' } }]]; + + const result = await checkModules(); + + expect(result[0].dependencies[0]).toMatchObject({ + name: 'webservices.rest', + requiredVersion: '^2.0.0', + installedVersion: '2.24.0', + type: 'okay', + }); + }); + + it('should handle multiple modules with mixed dependency states', async () => { + mockOpenmrsFetch.mockResolvedValue({ + data: { + results: [ + { uuid: 'webservices.rest', version: '2.24.0' }, + { uuid: 'fhir2', version: '1.5.0' }, + ], + links: [], + }, + } as any); + + mockIsVersionSatisfied.mockImplementation((required, installed) => { + if (required === '^2.0.0' && installed === '2.24.0') return true; + if (required === '^1.0.0' && installed === '1.5.0') return true; + return false; + }); + + window.installedModules = [ + [ + '@openmrs/esm-app-1', + { + backendDependencies: { + 'webservices.rest': '^2.0.0', + 'missing-module': '1.0.0', + }, + }, + ], + ['@openmrs/esm-app-2', { backendDependencies: { fhir2: '^1.0.0' } }], + ]; + + const result = await checkModules(); + + expect(result).toHaveLength(2); + expect(result[0].dependencies).toHaveLength(2); + expect(result[0].dependencies.find((d) => d.type === 'okay')).toBeDefined(); + expect(result[0].dependencies.find((d) => d.type === 'missing')).toBeDefined(); + expect(result[1].dependencies).toHaveLength(1); + expect(result[1].dependencies[0].type).toBe('okay'); + }); + + it('should include installed optional dependencies with required ones', async () => { + mockOpenmrsFetch.mockResolvedValue({ + data: { + results: [ + { uuid: 'webservices.rest', version: '2.24.0' }, + { uuid: 'fhir2', version: '1.5.0' }, + ], + links: [], + }, + } as any); + + mockIsVersionSatisfied.mockReturnValue(true); + + window.installedModules = [ + [ + '@openmrs/esm-test-app', + { + backendDependencies: { 'webservices.rest': '^2.0.0' }, + optionalBackendDependencies: { + fhir2: '^1.0.0', + 'optional-missing': '^1.0.0', + }, + }, + ], + ]; + + const result = await checkModules(); + + expect(result[0].dependencies).toHaveLength(2); + // Should include both required and installed optional + expect(result[0].dependencies.find((d) => d.name === 'webservices.rest')).toBeDefined(); + expect(result[0].dependencies.find((d) => d.name === 'fhir2')).toBeDefined(); + // Should not include missing optional dependencies + expect(result[0].dependencies.find((d) => d.name === 'optional-missing')).toBeUndefined(); + }); + + it('should extract version from optional dependencies with feature flags', async () => { + mockOpenmrsFetch.mockResolvedValue({ + data: { + results: [ + { uuid: 'webservices.rest', version: '2.24.0' }, + { uuid: 'fhir2', version: '1.5.0' }, + ], + links: [], + }, + } as any); + + mockIsVersionSatisfied.mockReturnValue(true); + + window.installedModules = [ + [ + '@openmrs/esm-test-app', + { + backendDependencies: { 'webservices.rest': '^2.0.0' }, + optionalBackendDependencies: { + fhir2: { + version: '^1.0.0', + feature: 'fhir-support' as any, + }, + }, + }, + ], + ]; + + const result = await checkModules(); + + const fhir2Dep = result[0].dependencies.find((d) => d.name === 'fhir2'); + expect(fhir2Dep).toBeDefined(); + expect(fhir2Dep?.requiredVersion).toBe('^1.0.0'); + }); + + it('should cache results across multiple calls', async () => { + mockOpenmrsFetch.mockResolvedValue({ + data: { + results: [{ uuid: 'webservices.rest', version: '2.24.0' }], + links: [], + }, + } as any); + + mockIsVersionSatisfied.mockReturnValue(true); + + window.installedModules = [['@openmrs/esm-test-app', { backendDependencies: { 'webservices.rest': '^2.0.0' } }]]; + + const result1 = await checkModules(); + const result2 = await checkModules(); + + // Should only fetch once due to caching + expect(mockOpenmrsFetch).toHaveBeenCalledTimes(1); + expect(result1).toBe(result2); // Same reference + }); + + it('should handle paginated backend module responses', async () => { + const page1Modules = Array.from({ length: 50 }, (_, i) => ({ + uuid: `module-${i}`, + version: '1.0.0', + })); + + const page2Modules = [{ uuid: 'module-50', version: '1.0.0' }]; + + mockOpenmrsFetch + .mockResolvedValueOnce({ + data: { + results: page1Modules, + links: [{ rel: 'next', uri: '/ws/rest/v1/module?startIndex=50' }], + }, + } as any) + .mockResolvedValueOnce({ + data: { + results: page2Modules, + links: [], + }, + } as any); + + mockIsVersionSatisfied.mockReturnValue(true); + + window.installedModules = [ + ['@openmrs/esm-test-app', { backendDependencies: { 'module-0': '1.0.0', 'module-50': '1.0.0' } }], + ]; + + const result = await checkModules(); + + // Should find both modules across pages + expect(result[0].dependencies).toHaveLength(2); + expect(result[0].dependencies.every((d) => d.type === 'okay')).toBe(true); + }); + + it('should handle fetch errors gracefully by returning empty backend modules', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + mockOpenmrsFetch.mockRejectedValue(new Error('Network error')); + + window.installedModules = [['@openmrs/esm-test-app', { backendDependencies: { 'webservices.rest': '2.0.0' } }]]; + + const result = await checkModules(); + + // Error should be logged + expect(consoleErrorSpy).toHaveBeenCalled(); + + // Should treat all dependencies as missing when fetch fails + expect(result).toHaveLength(1); + expect(result[0].dependencies).toHaveLength(1); + expect(result[0].dependencies[0].type).toBe('missing'); + + consoleErrorSpy.mockRestore(); + }); + + it('should warn when reaching pagination limit', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + mockOpenmrsFetch.mockResolvedValue({ + data: { + results: [{ uuid: 'test-module', version: '1.0.0' }], + links: [{ rel: 'next', uri: 'module?startIndex=50' }], + }, + } as any); + + window.installedModules = [['@openmrs/esm-test-app', { backendDependencies: { 'test-module': '1.0.0' } }]]; + + await checkModules(); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Reached maximum page limit')); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('hasInvalidDependencies', () => { + it('should return false when all dependencies are okay', () => { + const modules = [ + { + name: '@openmrs/esm-test-app', + dependencies: [ + { + name: 'webservices.rest', + requiredVersion: '^2.0.0', + installedVersion: '2.24.0', + type: 'okay' as ResolvedBackendModuleType, + }, + ], + }, + ]; + + expect(hasInvalidDependencies(modules)).toBe(false); + }); + + it('should return false when there are no dependencies', () => { + const modules = [ + { + name: '@openmrs/esm-test-app', + dependencies: [], + }, + ]; + + expect(hasInvalidDependencies(modules)).toBe(false); + }); + + it('should return true when there are missing dependencies', () => { + const modules = [ + { + name: '@openmrs/esm-test-app', + dependencies: [ + { + name: 'missing-module', + requiredVersion: '1.0.0', + type: 'missing' as ResolvedBackendModuleType, + }, + ], + }, + ]; + + expect(hasInvalidDependencies(modules)).toBe(true); + }); + + it('should return true when there are version mismatches', () => { + const modules = [ + { + name: '@openmrs/esm-test-app', + dependencies: [ + { + name: 'webservices.rest', + requiredVersion: '^3.0.0', + installedVersion: '2.24.0', + type: 'version-mismatch' as ResolvedBackendModuleType, + }, + ], + }, + ]; + + expect(hasInvalidDependencies(modules)).toBe(true); + }); + + it('should return true if any module has invalid dependencies', () => { + const modules = [ + { + name: '@openmrs/esm-app-1', + dependencies: [ + { + name: 'module-1', + requiredVersion: '1.0.0', + installedVersion: '1.0.0', + type: 'okay' as ResolvedBackendModuleType, + }, + ], + }, + { + name: '@openmrs/esm-app-2', + dependencies: [ + { + name: 'module-2', + requiredVersion: '2.0.0', + type: 'missing' as ResolvedBackendModuleType, + }, + ], + }, + ]; + + expect(hasInvalidDependencies(modules)).toBe(true); + }); + + it('should return true if any dependency in any module is invalid', () => { + const modules = [ + { + name: '@openmrs/esm-app', + dependencies: [ + { + name: 'module-1', + requiredVersion: '1.0.0', + installedVersion: '1.0.0', + type: 'okay' as ResolvedBackendModuleType, + }, + { + name: 'module-2', + requiredVersion: '2.0.0', + installedVersion: '1.0.0', + type: 'version-mismatch' as ResolvedBackendModuleType, + }, + ], + }, + ]; + + expect(hasInvalidDependencies(modules)).toBe(true); + }); + }); +}); diff --git a/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts b/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts index e1ee861de..a0db79163 100644 --- a/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts +++ b/packages/apps/esm-implementer-tools-app/src/backend-dependencies/openmrs-backend-dependencies.ts @@ -96,9 +96,15 @@ async function fetchInstalledBackendModules(): Promise> { let safetyCounter = 0; const resolveNext = (url?: string | null) => { - if (!url) return null; - if (/^https?:\/\//i.test(url)) return url; - if (url.startsWith('/')) return url; + if (!url) { + return null; + } + if (/^https?:\/\//i.test(url)) { + return url; + } + if (url.startsWith('/')) { + return url; + } return `${restBaseUrl}/${url.replace(/^\/?/, '')}`; }; @@ -115,7 +121,7 @@ async function fetchInstalledBackendModules(): Promise> { nextUrl = resolveNext(nextLink?.uri ?? null); safetyCounter += 1; } catch (e) { - console.error(`Failed to fetch installed backend modules page ${safetyCounter + 1} (URL: ${nextUrl})`, e); + console.error(`Failed to fetch backend modules on request ${safetyCounter + 1} (URL: ${nextUrl})`, e); throw new Error(`Failed to fetch backend modules: ${e instanceof Error ? e.message : 'Unknown error'}`); } } @@ -164,7 +170,6 @@ function getInstalledAndRequiredBackendModules( version: declaredBackendModules[key], })); - // Use Set for O(1) lookup instead of O(n) find const installedUuids = new Set(installedBackendModules.map((module) => module.uuid)); return requiredModules.filter((requiredModule) => installedUuids.has(requiredModule.uuid)); } @@ -209,3 +214,8 @@ export async function checkModules(): Promise> export function hasInvalidDependencies(frontendModules: Array) { return frontendModules.some((m) => m.dependencies.some((n) => n.type !== 'okay')); } + +// For use in tests +export function clearCache() { + cachedFrontendModules = undefined as any; +}