diff --git a/.commit b/.commit index 5b11d2744..4fb1af8e0 100644 --- a/.commit +++ b/.commit @@ -1 +1 @@ -800253d0db65542d7805bc592b40cf4fd7b0b785 +f4c7da1f7712dbd18da89a1a6ed6062b8cb7523a diff --git a/CHANGELOG.md b/CHANGELOG.md index d63982dbb..5548388f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### October 30, 2026 +- 741310 - Web Portal: Suppress empty peer-group warning outside peer-group selection. +- 741335 - Web Portal: Preserve group membership delete response + ### October 6, 2026 - 725999 - Web Portal: Search approvals after loading. - 736614 - Angular read-only view-property honors DateFormat metadata. diff --git a/imxweb/projects/qbm/src/lib/api-client/api-client-fetch.spec.ts b/imxweb/projects/qbm/src/lib/api-client/api-client-fetch.spec.ts index 449b74185..cda57ed2d 100644 --- a/imxweb/projects/qbm/src/lib/api-client/api-client-fetch.spec.ts +++ b/imxweb/projects/qbm/src/lib/api-client/api-client-fetch.spec.ts @@ -25,8 +25,11 @@ */ import { of } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; +import { ClassloggerService } from '../classlogger/classlogger.service'; import { ApiClientFetch } from './api-client-fetch'; import { ApiClientMethodFactoryMock } from './api-client.spec'; +import { MethodDescriptorService } from './dynamic-method/method-descriptor.service'; describe('ApiClientFetch', () => { const methodFactory = new ApiClientMethodFactoryMock(); @@ -163,3 +166,60 @@ describe('ApiClientFetch', () => { ); }); }); + +describe('ApiClientFetch membership DELETE response contract', () => { + const descriptor = new MethodDescriptorService().delete('/portal/ADSGroup/{groupId}/memberships/{accountId}', { + groupId: 'test-group', + accountId: 'test-account', + }); + + function createClient(response: Response): { client: ApiClientFetch; fetch: jasmine.Spy } { + const logger = jasmine.createSpyObj('ClassloggerService', ['debug', 'info']); + const translation = jasmine.createSpyObj('TranslateService', ['instant']); + translation.instant.and.callFake((key: string) => key.replace('#LDS#', '')); + const fetch = jasmine.createSpy('fetch').and.returnValue(Promise.resolve(response)); + return { client: new ApiClientFetch('', logger, translation, { fetch }), fetch }; + } + + it('accepts HTTP 204 without attempting to parse a body', async () => { + const { client } = createClient(new Response(null, { status: 204 })); + + await expectAsync(client.processRequest(descriptor)).toBeResolvedTo(undefined); + }); + + it('preserves the JSON action-result array returned by a successful membership DELETE', async () => { + const { client } = createClient(new Response('[]', { status: 200, headers: { 'Content-Type': 'application/json' } })); + + await expectAsync(client.processRequest(descriptor)).toBeResolvedTo([]); + }); + + it('preserves a nonempty text error returned with HTTP 200', async () => { + const { client } = createClient( + new Response('Membership cannot be removed', { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }), + ); + + await expectAsync(client.processRequest(descriptor)).toBeRejectedWithError('Membership cannot be removed'); + }); + + it('does not accept an empty text response with an HTTP error status', async () => { + const { client } = createClient( + new Response('', { status: 500, statusText: 'Internal Server Error', headers: { 'Content-Type': 'text/plain; charset=utf-8' } }), + ); + + await expectAsync(client.processRequest(descriptor)).toBeRejectedWithError( + 'The server returned an unexpected data type. (Internal Server Error)', + ); + }); + + it('preserves structured server errors', async () => { + const errors = [{ Message: 'Membership cannot be removed', Level: '42', Number: '23' }]; + const { client } = createClient(new Response(JSON.stringify(errors), { status: 500, headers: { 'Content-Type': 'application/json' } })); + + await expectAsync(client.processRequest(descriptor)).toBeRejectedWith( + jasmine.objectContaining({ + message: JSON.stringify(errors), + messageUserFriendly: 'Membership cannot be removed [23]', + }), + ); + }); +}); diff --git a/imxweb/projects/qer/src/lib/service-items/serviceitem-list/serviceitem-list.component.spec.ts b/imxweb/projects/qer/src/lib/service-items/serviceitem-list/serviceitem-list.component.spec.ts index cb4caefe0..d6e0395f2 100644 --- a/imxweb/projects/qer/src/lib/service-items/serviceitem-list/serviceitem-list.component.spec.ts +++ b/imxweb/projects/qer/src/lib/service-items/serviceitem-list/serviceitem-list.component.spec.ts @@ -25,28 +25,40 @@ */ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { TranslateService } from '@ngx-translate/core'; import { MockBuilder } from 'ng-mocks'; +import { of } from 'rxjs'; import { ServiceitemListComponent } from './serviceitem-list.component'; import { ServiceItemsService } from '../service-items.service'; -import { clearStylesFromDOM } from 'qbm'; +import { clearStylesFromDOM, MessageDialogComponent } from 'qbm'; import { PortalShopServiceitems } from '@imx-modules/imx-api-qer'; import { ServiceItemsModule } from '../service-items.module'; describe('ServiceitemListComponent', () => { let component: ServiceitemListComponent; let fixture: ComponentFixture; + let getServiceItems: jasmine.Spy; + let openDialog: jasmine.Spy; const serviceItems = { extendedData: { PeerGroupSize: 1 }, }; beforeEach(() => { + getServiceItems = jasmine.createSpy('get').and.returnValue(Promise.resolve(serviceItems)); + openDialog = jasmine.createSpy('open'); + return MockBuilder(ServiceitemListComponent) .mock(ServiceItemsModule) + .mock(MatDialog, { open: openDialog }) + .mock(TranslateService, { + get: jasmine.createSpy('get').and.callFake((key: string) => of(key)), + }) .mock(ServiceItemsService, { PortalShopServiceItemsSchema: PortalShopServiceitems.GetEntitySchema(), - get: jasmine.createSpy('get').and.returnValue(Promise.resolve(serviceItems)), + get: getServiceItems, getDataModel: jasmine.createSpy('getDataModel').and.returnValue(Promise.resolve({})), }); }); @@ -66,4 +78,91 @@ describe('ServiceitemListComponent', () => { expect(component.dstSettings).toBeDefined(); expect(component.peerGroupSize).toEqual(serviceItems.extendedData.PeerGroupSize); })); + + it('should not warn when adding products to a bundle without peer-group metadata', async () => { + const data = {}; + component.patternItemsMode = true; + getServiceItems.and.returnValue(Promise.resolve(data)); + + await component.getData(); + + expect(getServiceItems).toHaveBeenCalledWith(jasmine.objectContaining({ UID_PersonPeerGroup: undefined })); + expect(component.dstSettings.dataSource).toBeDefined(); + expect(component.noDataText).toBe('#LDS#No data'); + expect(openDialog).not.toHaveBeenCalled(); + }); + + [ + { description: 'missing extended data', data: {} }, + { description: 'missing peer-group size', data: { extendedData: {} } }, + { description: 'a zero peer-group size', data: { extendedData: { PeerGroupSize: 0 } } }, + ].forEach(({ description, data }) => { + it(`should not warn during ordinary product selection with ${description}`, async () => { + getServiceItems.and.returnValue(Promise.resolve(data)); + + await component.getData(); + + expect(component.peerGroupSize).toBe(0); + expect(component.noDataText).toBe('#LDS#No data'); + expect(openDialog).not.toHaveBeenCalled(); + }); + }); + + it('should not warn during reference-user selection without peer-group metadata', async () => { + component.referenceUserUid = 'reference-user'; + getServiceItems.and.returnValue(Promise.resolve({})); + + await component.getData(); + + expect(getServiceItems).toHaveBeenCalledWith( + jasmine.objectContaining({ UID_PersonReference: 'reference-user', UID_PersonPeerGroup: undefined }), + ); + expect(component.noDataText).toBe('#LDS#No data'); + expect(openDialog).not.toHaveBeenCalled(); + }); + + it('should preserve the warning and heading for an explicitly selected empty peer group', async () => { + component.uidPersonPeerGroup = 'peer-group-user'; + getServiceItems.and.returnValue(Promise.resolve({ extendedData: { PeerGroupSize: 0 } })); + + await component.getData(); + + expect(getServiceItems).toHaveBeenCalledWith(jasmine.objectContaining({ UID_PersonPeerGroup: 'peer-group-user' })); + expect(component.noDataText).toBe('#LDS#Heading Peer Group Is Empty'); + expect(openDialog).toHaveBeenCalledOnceWith(MessageDialogComponent, { + data: { + ShowOk: true, + Title: '#LDS#Heading Peer Group Is Empty', + Message: '#LDS#You cannot view products of your peer group. Your peer group is empty.', + }, + panelClass: 'imx-messageDialog', + }); + }); + + it('should not warn for an explicitly selected nonempty peer group', async () => { + component.uidPersonPeerGroup = 'peer-group-user'; + + await component.getData(); + + expect(component.peerGroupSize).toBe(1); + expect(component.noDataText).toBe('#LDS#No data'); + expect(openDialog).not.toHaveBeenCalled(); + }); + + it('should reset the peer-group heading when returning to ordinary product selection', async () => { + component.uidPersonPeerGroup = 'peer-group-user'; + getServiceItems.and.returnValue(Promise.resolve({ extendedData: { PeerGroupSize: 0 } })); + await component.getData(); + expect(component.noDataText).toBe('#LDS#Heading Peer Group Is Empty'); + expect(openDialog).toHaveBeenCalledTimes(1); + + component.uidPersonPeerGroup = undefined; + getServiceItems.and.returnValue(Promise.resolve({})); + openDialog.calls.reset(); + + await component.getData(); + + expect(component.noDataText).toBe('#LDS#No data'); + expect(openDialog).not.toHaveBeenCalled(); + }); }); diff --git a/imxweb/projects/qer/src/lib/service-items/serviceitem-list/serviceitem-list.component.ts b/imxweb/projects/qer/src/lib/service-items/serviceitem-list/serviceitem-list.component.ts index 922e1da20..a0bd70ad4 100644 --- a/imxweb/projects/qer/src/lib/service-items/serviceitem-list/serviceitem-list.component.ts +++ b/imxweb/projects/qer/src/lib/service-items/serviceitem-list/serviceitem-list.component.ts @@ -216,7 +216,7 @@ export class ServiceitemListComponent implements AfterViewInit, OnChanges, OnDes this.peerGroupSize = data.extendedData?.PeerGroupSize ?? 0; - if (this.peerGroupSize === 0) { + if (this.uidPersonPeerGroup && this.peerGroupSize === 0) { this.noDataText = '#LDS#Heading Peer Group Is Empty'; this.dialog.open(MessageDialogComponent, { data: {