Skip to content

Commit cb30bcb

Browse files
authored
Merge pull request #24 from topcoder-platform/PM-2648
PM-2648: Support wrapped PM role lookups for application emails
2 parents 33a0a15 + 1514601 commit cb30bcb

2 files changed

Lines changed: 157 additions & 14 deletions

File tree

src/shared/services/member.service.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,65 @@ describe('MemberService', () => {
8888
);
8989
});
9090

91+
it('returns subjects from legacy wrapped Identity role responses', async () => {
92+
httpServiceMock.get.mockImplementation(
93+
(url: string, options: { params?: { filter?: string } }) => {
94+
if (
95+
url === 'https://identity.test/roles' &&
96+
options.params?.filter === 'roleName=Project Manager'
97+
) {
98+
return of({
99+
data: {
100+
result: {
101+
content: [{ id: '201', roleName: 'Project Manager' }],
102+
},
103+
},
104+
});
105+
}
106+
107+
if (url === 'https://identity.test/roles/201') {
108+
return of({
109+
data: {
110+
result: {
111+
content: {
112+
subjects: [
113+
{
114+
subjectId: 5001,
115+
handle: 'pm-one',
116+
email: 'PM.One@Topcoder.com',
117+
},
118+
],
119+
},
120+
},
121+
},
122+
});
123+
}
124+
125+
return of({ data: {} });
126+
},
127+
);
128+
129+
const result = await service.getRoleSubjects('Project Manager');
130+
131+
expect(result).toEqual([
132+
{
133+
userId: 5001,
134+
handle: 'pm-one',
135+
email: 'pm.one@topcoder.com',
136+
},
137+
]);
138+
expect(httpServiceMock.get).toHaveBeenCalledWith(
139+
'https://identity.test/roles/201',
140+
expect.objectContaining({
141+
params: expect.objectContaining({
142+
fields: 'subjects',
143+
selector: 'subjects',
144+
perPage: 200,
145+
}),
146+
}),
147+
);
148+
});
149+
91150
it('returns empty list when role list lookup fails', async () => {
92151
httpServiceMock.get.mockImplementation((url: string) => {
93152
if (url === 'https://identity.test/roles') {

src/shared/services/member.service.ts

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,12 @@ export interface MemberRoleRecord {
1313

1414
type RoleSubjectRecord = {
1515
subjectID?: string | number;
16+
subjectId?: string | number;
1617
userId?: string | number;
1718
handle?: string;
1819
email?: string;
1920
};
2021

21-
type RoleDetailResponse = {
22-
subjects?: RoleSubjectRecord[];
23-
};
24-
2522
/**
2623
* Member and identity enrichment service.
2724
*
@@ -213,9 +210,9 @@ export class MemberService {
213210
* Looks up role members from Identity API by role name.
214211
*
215212
* Resolves `/roles?filter=roleName=<roleName>` and then
216-
* `/roles/:id?selector=subjects&perPage=200`, returning the merged
217-
* `subjects` list
218-
* normalized to `MemberDetail` shape.
213+
* `/roles/:id` with subject expansion, returning the merged `subjects` list
214+
* normalized to `MemberDetail` shape. Supports both direct array responses
215+
* and the legacy `{ result: { content } }` Identity response wrapper.
219216
*
220217
* Role-detail lookups are tolerant to partial failures; one failing role id
221218
* does not prevent subjects from other matching roles from being returned.
@@ -250,9 +247,9 @@ export class MemberService {
250247
}),
251248
);
252249

253-
const roles = Array.isArray(rolesResponse.data)
254-
? (rolesResponse.data as MemberRoleRecord[])
255-
: [];
250+
const roles = this.extractArrayPayload<MemberRoleRecord>(
251+
rolesResponse.data,
252+
);
256253

257254
const roleIds = roles
258255
.filter(
@@ -276,14 +273,14 @@ export class MemberService {
276273
'Content-Type': 'application/json',
277274
},
278275
params: {
276+
fields: 'subjects',
279277
selector: 'subjects',
280278
perPage: 200,
281279
},
282280
}),
283281
);
284282

285-
const payload = roleResponse.data as RoleDetailResponse;
286-
return Array.isArray(payload?.subjects) ? payload.subjects : [];
283+
return this.extractRoleSubjects(roleResponse.data);
287284
}),
288285
);
289286

@@ -310,7 +307,7 @@ export class MemberService {
310307
.trim()
311308
.toLowerCase();
312309
const subjectId = String(
313-
subject.subjectID || subject.userId || '',
310+
subject.subjectID || subject.subjectId || subject.userId || '',
314311
).trim();
315312

316313
const key = email || subjectId;
@@ -319,7 +316,8 @@ export class MemberService {
319316
}
320317

321318
uniqueSubjects.set(key, {
322-
userId: subject.subjectID || subject.userId || null,
319+
userId:
320+
subject.subjectID || subject.subjectId || subject.userId || null,
323321
handle: subject.handle || null,
324322
email,
325323
});
@@ -337,4 +335,90 @@ export class MemberService {
337335
async getRoleSubjectsByRoleName(roleName: string): Promise<MemberDetail[]> {
338336
return this.getRoleSubjects(roleName);
339337
}
338+
339+
/**
340+
* Extracts an array payload from Identity responses.
341+
*
342+
* @param payload Identity API response body in direct or legacy wrapped form.
343+
* @returns Extracted array or an empty array when the shape is unsupported.
344+
*/
345+
private extractArrayPayload<T>(payload: unknown): T[] {
346+
if (Array.isArray(payload)) {
347+
return payload as T[];
348+
}
349+
350+
if (!payload || typeof payload !== 'object') {
351+
return [];
352+
}
353+
354+
const response = payload as {
355+
content?: unknown;
356+
result?: {
357+
content?: unknown;
358+
};
359+
};
360+
361+
if (Array.isArray(response.result?.content)) {
362+
return response.result.content as T[];
363+
}
364+
365+
if (Array.isArray(response.content)) {
366+
return response.content as T[];
367+
}
368+
369+
return [];
370+
}
371+
372+
/**
373+
* Extracts role subjects from Identity role-detail responses.
374+
*
375+
* @param payload Identity role-detail body in direct, legacy wrapped, or list form.
376+
* @returns Role subject records or an empty array when no subjects are present.
377+
*/
378+
private extractRoleSubjects(payload: unknown): RoleSubjectRecord[] {
379+
const directSubjects = this.extractSubjectsFromObject(payload);
380+
if (directSubjects.length > 0) {
381+
return directSubjects;
382+
}
383+
384+
const listPayload = this.extractArrayPayload<RoleSubjectRecord>(payload);
385+
if (listPayload.length > 0) {
386+
return listPayload;
387+
}
388+
389+
if (!payload || typeof payload !== 'object') {
390+
return [];
391+
}
392+
393+
const response = payload as {
394+
content?: unknown;
395+
result?: {
396+
content?: unknown;
397+
};
398+
};
399+
400+
const resultSubjects = this.extractSubjectsFromObject(
401+
response.result?.content,
402+
);
403+
if (resultSubjects.length > 0) {
404+
return resultSubjects;
405+
}
406+
407+
return this.extractSubjectsFromObject(response.content);
408+
}
409+
410+
/**
411+
* Extracts a `subjects` array from an object-like response fragment.
412+
*
413+
* @param payload Response fragment that may contain a `subjects` property.
414+
* @returns Subjects array or an empty array when absent.
415+
*/
416+
private extractSubjectsFromObject(payload: unknown): RoleSubjectRecord[] {
417+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
418+
return [];
419+
}
420+
421+
const subjects = (payload as { subjects?: unknown }).subjects;
422+
return Array.isArray(subjects) ? (subjects as RoleSubjectRecord[]) : [];
423+
}
340424
}

0 commit comments

Comments
 (0)