Skip to content

Commit 6231731

Browse files
refactor(cf-deploy-config-writer): scope BTP destinations cache to call site (#4458) (#4461)
* refactor(cf-deploy-config-writer): scope BTP destinations cache to call site Replace module-level cachedDestinationsList with an optional cache parameter on getBTPDestinations() and getDestinationProperties(). Each caller controls cache lifetime — per-run caching is opt-in via a shared {} object, while the default empty {} gives a fresh fetch every call. Eliminates cross-test contamination and stale-cache issues within long-running processes. * Linting auto fix commit --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent f2ad5a8 commit 6231731

3 files changed

Lines changed: 184 additions & 10 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@sap-ux/cf-deploy-config-writer': patch
3+
---
4+
5+
refactor: scope BTP destinations cache to call site instead of module level
6+
7+
`getBTPDestinations` and `getDestinationProperties` now accept an optional
8+
`cache` parameter (`{ list?: Destinations }`). Each generator run passes its own
9+
cache object for deduplication within a single invocation; independent calls use
10+
independent caches by default. This removes the module-level mutable variable
11+
that caused cross-test contamination and prevented stale-cache detection.

packages/cf-deploy-config-writer/src/utils.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ import {
3535
} from './constants';
3636
import { type MTABaseConfig, type CFBaseConfig, type CFAppConfig } from './types';
3737

38-
let cachedDestinationsList: Destinations = {};
39-
4038
/**
4139
* Read manifest file for processing.
4240
*
@@ -91,17 +89,20 @@ export function toPosixPath(dirPath: string): string {
9189
* Retrieves destination configuration from SAP BTP when running in Business Application Studio.
9290
*
9391
* @param destination The destination name to look up in BTP destination service
92+
* @param cache Mutable object that holds the cached destinations list; pass a new `{}` per generator run
93+
* @param cache.list
9494
* @returns Object containing destination URL format flag and authentication type
9595
* @returns {boolean} destinationIsFullUrl - True if destination uses full URL format
9696
* @returns {Authentication | undefined} destinationAuthentication - Authentication type configured for the destination
9797
*/
9898
export async function getDestinationProperties(
99-
destination: string | undefined
99+
destination: string | undefined,
100+
cache: { list?: Destinations } = {}
100101
): Promise<{ destinationIsFullUrl: boolean; destinationAuthentication: Authentication | undefined }> {
101102
let destinationIsFullUrl = false;
102103
let destinationAuthentication;
103104
if (isAppStudio() && destination) {
104-
const destinations = await getBTPDestinations();
105+
const destinations = await getBTPDestinations(cache);
105106
if (destinations[destination]) {
106107
destinationIsFullUrl = isFullUrlDestination(destinations[destination]);
107108
destinationAuthentication = destinations[destination].Authentication as Authentication;
@@ -112,14 +113,15 @@ export async function getDestinationProperties(
112113

113114
/**
114115
* Retrieve the list of destinations from SAP BTP.
116+
* Caching is scoped to the provided cache object; pass a new `{}` per generator run.
115117
*
118+
* @param cache Mutable object that holds the cached list across multiple calls within one run
119+
* @param cache.list
116120
* @returns Destinations list
117121
*/
118-
export async function getBTPDestinations(): Promise<Destinations> {
119-
if (Object.keys(cachedDestinationsList).length === 0) {
120-
cachedDestinationsList = await listDestinations({ stripS4HCApiHosts: true });
121-
}
122-
return cachedDestinationsList;
122+
export async function getBTPDestinations(cache: { list?: Destinations } = {}): Promise<Destinations> {
123+
cache.list ??= await listDestinations({ stripS4HCApiHosts: true });
124+
return cache.list;
123125
}
124126

125127
/**

packages/cf-deploy-config-writer/test/unit/utils.test.ts

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
1-
import { validateVersion, toMtaModuleName, runCommand } from '../../src/utils';
1+
import {
2+
validateVersion,
3+
toMtaModuleName,
4+
runCommand,
5+
getBTPDestinations,
6+
getDestinationProperties
7+
} from '../../src/utils';
28
import { MTAVersion } from '../../src/constants';
39
import { CommandRunner } from '@sap-ux/nodejs-utils';
10+
import * as btp from '@sap-ux/btp-utils';
11+
12+
jest.mock('@sap-ux/btp-utils', () => ({
13+
...jest.requireActual('@sap-ux/btp-utils'),
14+
isAppStudio: jest.fn(),
15+
listDestinations: jest.fn()
16+
}));
417

518
describe('CF utils', () => {
619
beforeAll(async () => {
@@ -52,4 +65,152 @@ describe('CF utils', () => {
5265
jest.restoreAllMocks();
5366
});
5467
});
68+
69+
describe('getBTPDestinations', () => {
70+
let listDestinationsMock: jest.SpyInstance;
71+
72+
beforeEach(() => {
73+
listDestinationsMock = jest.spyOn(btp, 'listDestinations');
74+
listDestinationsMock.mockReset();
75+
});
76+
77+
afterEach(() => {
78+
jest.restoreAllMocks();
79+
});
80+
81+
test('fetches destinations and populates cache', async () => {
82+
const mockDestinations = { dest1: { Name: 'dest1' } } as any;
83+
listDestinationsMock.mockResolvedValue(mockDestinations);
84+
85+
const cache: { list?: any } = {};
86+
const result = await getBTPDestinations(cache);
87+
88+
expect(result).toBe(mockDestinations);
89+
expect(listDestinationsMock).toHaveBeenCalledTimes(1);
90+
expect(cache.list).toBe(mockDestinations);
91+
});
92+
93+
test('returns cached result without calling listDestinations again', async () => {
94+
const mockDestinations = { dest1: { Name: 'dest1' } } as any;
95+
listDestinationsMock.mockResolvedValue(mockDestinations);
96+
97+
const cache: { list?: any } = {};
98+
await getBTPDestinations(cache);
99+
const result = await getBTPDestinations(cache);
100+
101+
expect(result).toBe(mockDestinations);
102+
expect(listDestinationsMock).toHaveBeenCalledTimes(1);
103+
});
104+
105+
test('each independent cache object triggers a fresh fetch', async () => {
106+
const first = { dest1: { Name: 'dest1' } } as any;
107+
const second = { dest2: { Name: 'dest2' } } as any;
108+
listDestinationsMock.mockResolvedValueOnce(first).mockResolvedValueOnce(second);
109+
110+
const result1 = await getBTPDestinations({});
111+
const result2 = await getBTPDestinations({});
112+
113+
expect(result1).toBe(first);
114+
expect(result2).toBe(second);
115+
expect(listDestinationsMock).toHaveBeenCalledTimes(2);
116+
});
117+
118+
test('uses empty cache by default (no module-level state)', async () => {
119+
const mockDestinations = {} as any;
120+
listDestinationsMock.mockResolvedValue(mockDestinations);
121+
122+
await getBTPDestinations();
123+
await getBTPDestinations();
124+
125+
// Without a shared cache object, each call with default `{}` fetches fresh
126+
expect(listDestinationsMock).toHaveBeenCalledTimes(2);
127+
});
128+
});
129+
130+
describe('getDestinationProperties', () => {
131+
let isAppStudioMock: jest.SpyInstance;
132+
let listDestinationsMock: jest.SpyInstance;
133+
134+
const mockDestinations = {
135+
fullUrlDest: {
136+
Name: 'fullUrlDest',
137+
Authentication: 'OAuth2SAMLBearerAssertion',
138+
WebIDEAdditionalData: btp.WebIDEAdditionalData.FULL_URL,
139+
WebIDEUsage: btp.WebIDEUsage.ODATA_GENERIC
140+
},
141+
normalDest: {
142+
Name: 'normalDest',
143+
Authentication: 'NoAuthentication',
144+
WebIDEUsage: btp.WebIDEUsage.ODATA_GENERIC
145+
}
146+
} as any;
147+
148+
beforeEach(() => {
149+
isAppStudioMock = jest.spyOn(btp, 'isAppStudio');
150+
listDestinationsMock = jest.spyOn(btp, 'listDestinations').mockResolvedValue(mockDestinations);
151+
listDestinationsMock.mockReset();
152+
listDestinationsMock.mockResolvedValue(mockDestinations);
153+
});
154+
155+
afterEach(() => {
156+
jest.restoreAllMocks();
157+
});
158+
159+
test('returns defaults when not in AppStudio', async () => {
160+
isAppStudioMock.mockReturnValue(false);
161+
162+
const result = await getDestinationProperties('fullUrlDest');
163+
164+
expect(result.destinationIsFullUrl).toBe(false);
165+
expect(result.destinationAuthentication).toBeUndefined();
166+
expect(listDestinationsMock).not.toHaveBeenCalled();
167+
});
168+
169+
test('returns defaults when destination is undefined', async () => {
170+
isAppStudioMock.mockReturnValue(true);
171+
172+
const result = await getDestinationProperties(undefined);
173+
174+
expect(result.destinationIsFullUrl).toBe(false);
175+
expect(result.destinationAuthentication).toBeUndefined();
176+
expect(listDestinationsMock).not.toHaveBeenCalled();
177+
});
178+
179+
test('resolves full-URL destination in AppStudio', async () => {
180+
isAppStudioMock.mockReturnValue(true);
181+
182+
const result = await getDestinationProperties('fullUrlDest');
183+
184+
expect(result.destinationIsFullUrl).toBe(true);
185+
expect(result.destinationAuthentication).toBe('OAuth2SAMLBearerAssertion');
186+
});
187+
188+
test('resolves non-full-URL destination in AppStudio', async () => {
189+
isAppStudioMock.mockReturnValue(true);
190+
191+
const result = await getDestinationProperties('normalDest');
192+
193+
expect(result.destinationIsFullUrl).toBe(false);
194+
expect(result.destinationAuthentication).toBe('NoAuthentication');
195+
});
196+
197+
test('returns defaults when destination name not found in list', async () => {
198+
isAppStudioMock.mockReturnValue(true);
199+
200+
const result = await getDestinationProperties('unknownDest');
201+
202+
expect(result.destinationIsFullUrl).toBe(false);
203+
expect(result.destinationAuthentication).toBeUndefined();
204+
});
205+
206+
test('shared cache object avoids duplicate listDestinations calls', async () => {
207+
isAppStudioMock.mockReturnValue(true);
208+
209+
const cache: { list?: any } = {};
210+
await getDestinationProperties('fullUrlDest', cache);
211+
await getDestinationProperties('normalDest', cache);
212+
213+
expect(listDestinationsMock).toHaveBeenCalledTimes(1);
214+
});
215+
});
55216
});

0 commit comments

Comments
 (0)