blob: 507a5342ebdc65875c65b8a0659d811f5d18f66f [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createSpyObj, SpyObj } from '../../testing/mocks';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { of } from 'rxjs';
import { GroupViewComponent } from './group-view.component';
import { GroupDetail } from './group-detail.model';
import { BASE_PATH, GroupsService } from '../../api';
import { provideFakeAdapters, FakeOverlayAdapter } from '../../testing/adapters';
/**
* As `app.config.ts` computes it: the configured API url with a trailing `/v1` removed, since
* the generated services prepend their own version segment. Using `/api/v1` here would let a
* call site that forgot the `/v1` pass this spec and 404 in the browser.
*/
const API = '/api';
const HEAD_OFFICE = 'Head Office';
const AMINA = 'Amina Yusuf';
/** An active group with one member and one committee role, as the platform returns it. */
const GROUP: GroupDetail = {
id: 7,
accountNo: '000000007',
name: 'Kibera Womens Group',
status: { id: 300, code: 'clientStatusType.active', value: 'Active' },
active: true,
activationDate: [2026, 1, 15],
officeId: 1,
officeName: HEAD_OFFICE,
staffId: 4,
staffName: 'Okoth, Grace',
clientMembers: [
{
id: 11,
accountNo: '000000011',
displayName: AMINA,
status: { id: 300, value: 'Active' },
officeName: HEAD_OFFICE,
},
{
id: 12,
accountNo: '000000012',
displayName: 'Beatrice Wanjiru',
status: { id: 100, value: 'Pending' },
officeName: HEAD_OFFICE,
},
],
// Deliberately only one of the two. The screen must not read this list.
activeClientMembers: [{ id: 11, displayName: AMINA }],
groupRoles: [{ id: 3, role: { id: 13, name: 'Leader' }, clientId: 11, clientName: AMINA }],
timeline: { submittedOnDate: [2026, 1, 1] },
};
describe('GroupViewComponent', () => {
let fixture: ComponentFixture<GroupViewComponent>;
let component: GroupViewComponent;
let httpMock: HttpTestingController;
let groupsService: SpyObj<GroupsService>;
let overlay: FakeOverlayAdapter;
beforeEach(async () => {
groupsService = createSpyObj(['postGroupsGroupId', 'getGroupsTemplate']);
groupsService.postGroupsGroupId.mockReturnValue(of({}) as never);
groupsService.getGroupsTemplate.mockReturnValue(of({}) as never);
const adapters = provideFakeAdapters();
overlay = adapters.overlay;
await TestBed.configureTestingModule({
imports: [GroupViewComponent],
providers: [
provideNoopAnimations(),
provideHttpClient(),
provideHttpClientTesting(),
...adapters.providers,
{ provide: BASE_PATH, useValue: API },
{ provide: GroupsService, useValue: groupsService },
{ provide: Router, useValue: createSpyObj(['navigate']) },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => '7' } } } },
],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(GroupViewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
/** Answers the outstanding group fetch and asserts it asked for the associations. */
function flushGroup(body: GroupDetail = GROUP): void {
const request = httpMock.expectOne((req) => req.url === `${API}/v1/groups/7`);
expect(request.request.params.get('associations')).toBe('all');
request.flush(body);
fixture.detectChanges();
}
afterEach(() => httpMock.verify());
it('requests the group with its associations', () => {
flushGroup();
expect(component.group()?.name).toBe('Kibera Womens Group');
});
it('lists every member, not only the active ones', () => {
flushGroup();
// `activeClientMembers` holds one of the two. A pending member that the officer cannot see
// is a member they cannot chase, and the count would disagree with what may be removed.
expect(component.members().map((member) => member.id)).toEqual([11, 12]);
});
it('offers a retry rather than an empty group when the load fails', () => {
httpMock
.expectOne((req) => req.url === `${API}/v1/groups/7`)
.flush('boom', { status: 500, statusText: 'Server Error' });
fixture.detectChanges();
expect(component.hasError()).toBe(true);
expect(component.group()).toBeNull();
const error = fixture.nativeElement.querySelector('[data-testid="group-load-error"]');
expect(error).not.toBeNull();
});
it('sends the closure reason and a Fineract-formatted date when closing', async () => {
flushGroup();
overlay.nextModalResult = { actionDate: '2026-03-09', closureReasonId: 42 };
await component.onClose();
const [groupId, body, command] = groupsService.postGroupsGroupId.mock.lastCall!;
expect(groupId).toBe(7);
expect(command).toBe('close');
expect(body).toEqual(
expect.objectContaining({
// Zero-padded: Fineract parses strictly against `dd MMMM yyyy` and a bare '9' 500s.
closureDate: '09 March 2026',
closureReasonId: 42,
dateFormat: 'dd MMMM yyyy',
locale: 'en',
}),
);
// The command's effects reach past the field it names, so the screen re-reads rather than
// patching its copy.
flushGroup();
});
it('names the assignment, not the role, when unassigning a committee role', async () => {
flushGroup();
overlay.nextModalResult = true; // the confirmation
await component.onUnassignRole(component.roles()[0]);
const [groupId, , command, roleId] = groupsService.postGroupsGroupId.mock.lastCall!;
expect(groupId).toBe(7);
expect(command).toBe('unassignRole');
// 3 is the assignment id; 13 is the role's code-value id. Sending 13 answers 404 for a
// "group role" that plainly exists, so the distinction is worth pinning down.
expect(roleId).toBe(3);
flushGroup();
});
it('passes the group office and current members to the add-member dialog', async () => {
flushGroup();
overlay.nextModalResult = undefined; // dismissed without a selection
await component.onAddMembers();
const request = overlay.modals.at(-1);
expect(request?.inputs?.['data']).toEqual(
expect.objectContaining({ mode: 'add', officeId: 1 }),
);
// Without the exclusion the dialog offers a client who is already in the group, and the
// platform rejects associating them twice.
const data = request?.inputs?.['data'] as { members: { id?: number }[] };
expect(data.members.map((member) => member.id)).toEqual([11, 12]);
expect(groupsService.postGroupsGroupId).not.toHaveBeenCalled();
});
it('gates the lifecycle actions on the status id', () => {
flushGroup({ ...GROUP, status: { id: 100, value: 'Pending' } });
expect(component.isPending()).toBe(true);
expect(component.isActive()).toBe(false);
expect(component.isClosed()).toBe(false);
});
});