Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .commit
Original file line number Diff line number Diff line change
@@ -1 +1 @@
800253d0db65542d7805bc592b40cf4fd7b0b785
f4c7da1f7712dbd18da89a1a6ed6062b8cb7523a
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
60 changes: 60 additions & 0 deletions imxweb/projects/qbm/src/lib/api-client/api-client-fetch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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>('ClassloggerService', ['debug', 'info']);
const translation = jasmine.createSpyObj<TranslateService>('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]',
}),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServiceitemListComponent>;
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({})),
});
});
Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down