Skip to content

Commit 777a6f8

Browse files
committed
WEB-892 feat: Extract close client payload construction into utility with tests
1 parent f82ad09 commit 777a6f8

3 files changed

Lines changed: 400 additions & 18 deletions

File tree

src/app/clients/clients-view/client-actions/close-client/close-client.component.ts

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88

99
/** Angular Imports */
1010
import { Component, OnInit, inject } from '@angular/core';
11-
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
12-
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
11+
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
12+
import { ActivatedRoute, Router } from '@angular/router';
1313

1414
/** Custom Services */
1515
import { ClientsService } from 'app/clients/clients.service';
1616
import { Dates } from 'app/core/utils/dates';
1717
import { SettingsService } from 'app/settings/settings.service';
1818
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
19+
import { buildCloseClientPayload } from './close-client.utils';
1920

2021
/**
2122
* Close Client Component
@@ -29,12 +30,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
2930
]
3031
})
3132
export class CloseClientComponent implements OnInit {
32-
private formBuilder = inject(UntypedFormBuilder);
33-
private clientsService = inject(ClientsService);
34-
private dateUtils = inject(Dates);
35-
private route = inject(ActivatedRoute);
36-
private router = inject(Router);
37-
private settingsService = inject(SettingsService);
33+
private readonly formBuilder = inject(UntypedFormBuilder);
34+
private readonly clientsService = inject(ClientsService);
35+
private readonly dateUtils = inject(Dates);
36+
private readonly route = inject(ActivatedRoute);
37+
private readonly router = inject(Router);
38+
private readonly settingsService = inject(SettingsService);
3839

3940
/** Minimum date allowed. */
4041
minDate = new Date(2000, 0, 1);
@@ -87,18 +88,9 @@ export class CloseClientComponent implements OnInit {
8788
* Submits the form and closes the client.
8889
*/
8990
submit() {
90-
const closeClientFormData = this.closeClientForm.value;
9191
const locale = this.settingsService.language.code;
9292
const dateFormat = this.settingsService.dateFormat;
93-
const prevClosedDate: Date = this.closeClientForm.value.closureDate;
94-
if (closeClientFormData.closureDate instanceof Date) {
95-
closeClientFormData.closureDate = this.dateUtils.formatDate(prevClosedDate, dateFormat);
96-
}
97-
const data = {
98-
...closeClientFormData,
99-
dateFormat,
100-
locale
101-
};
93+
const data = buildCloseClientPayload(this.closeClientForm.value, this.dateUtils, locale, dateFormat);
10294
this.clientsService.executeClientCommand(this.clientId, 'close', data).subscribe(() => {
10395
this.router.navigate(['../../'], { relativeTo: this.route });
10496
});
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/**
2+
* Copyright since 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
10+
import { buildCloseClientPayload, CloseClientFormValue, isValidDate } from './close-client.utils';
11+
12+
describe('buildCloseClientPayload', () => {
13+
const DEFAULT_LOCALE = 'en';
14+
const DEFAULT_DATE_FORMAT = 'dd MMMM yyyy';
15+
const MOCK_FORMATTED_DATE = '03 November 2025';
16+
17+
let formatDateSpy: jest.MockedFunction<(date: Date, format: string) => string>;
18+
19+
const mockDateUtils = {
20+
formatDate: (date: Date, format: string) => formatDateSpy(date, format)
21+
};
22+
23+
beforeEach(() => {
24+
formatDateSpy = jest.fn(() => MOCK_FORMATTED_DATE);
25+
});
26+
27+
const makeValidForm = (overrides: Partial<CloseClientFormValue> = {}): CloseClientFormValue => ({
28+
closureDate: new Date(2025, 10, 3),
29+
closureReasonId: 1,
30+
...overrides
31+
});
32+
33+
const callBuildPayload = (
34+
formValue: CloseClientFormValue,
35+
options?: {
36+
locale?: string;
37+
dateFormat?: string;
38+
}
39+
) =>
40+
buildCloseClientPayload(
41+
formValue,
42+
mockDateUtils,
43+
options?.locale ?? DEFAULT_LOCALE,
44+
options?.dateFormat ?? DEFAULT_DATE_FORMAT
45+
);
46+
47+
describe('Valid inputs', () => {
48+
it('formats Date closureDate', () => {
49+
const date = new Date(2025, 10, 3);
50+
51+
const result = callBuildPayload(makeValidForm({ closureDate: date }));
52+
53+
expect(formatDateSpy).toHaveBeenCalledWith(date, DEFAULT_DATE_FORMAT);
54+
expect(formatDateSpy).toHaveBeenCalledTimes(1);
55+
expect(result.closureDate).toBe(MOCK_FORMATTED_DATE);
56+
});
57+
58+
it('passes through pre-formatted string closureDate', () => {
59+
const preFormattedDate = '03 November 2025';
60+
61+
const result = callBuildPayload(makeValidForm({ closureDate: preFormattedDate }));
62+
63+
expect(formatDateSpy).not.toHaveBeenCalled();
64+
expect(result.closureDate).toBe(preFormattedDate);
65+
});
66+
67+
it('supports custom locale', () => {
68+
const result = callBuildPayload(makeValidForm(), {
69+
locale: 'fr'
70+
});
71+
72+
expect(result.locale).toBe('fr');
73+
});
74+
75+
it('supports custom dateFormat', () => {
76+
const customFormat = 'yyyy-MM-dd';
77+
78+
const result = callBuildPayload(makeValidForm(), {
79+
dateFormat: customFormat
80+
});
81+
82+
expect(result.dateFormat).toBe(customFormat);
83+
});
84+
85+
it('preserves closureReasonId', () => {
86+
const result = callBuildPayload(makeValidForm({ closureReasonId: 42 }));
87+
88+
expect(result.closureReasonId).toBe(42);
89+
});
90+
});
91+
92+
describe('Immutability', () => {
93+
it('does not mutate input object', () => {
94+
const formValue = makeValidForm();
95+
const snapshot = { ...formValue };
96+
97+
callBuildPayload(formValue);
98+
99+
expect(formValue).toEqual(snapshot);
100+
});
101+
});
102+
103+
describe('closureDate validation', () => {
104+
it('rejects invalid Date object', () => {
105+
const invalidDate = new Date('invalid');
106+
107+
expect(isValidDate(invalidDate)).toBe(false);
108+
109+
expect(() => callBuildPayload(makeValidForm({ closureDate: invalidDate }))).toThrow(TypeError);
110+
111+
expect(formatDateSpy).not.toHaveBeenCalled();
112+
});
113+
114+
it.each([
115+
null,
116+
undefined,
117+
12345,
118+
{} as unknown as Date,
119+
''
120+
])('rejects invalid closureDate: %p', (value) => {
121+
expect(() =>
122+
callBuildPayload(
123+
makeValidForm({
124+
closureDate: value as unknown as Date
125+
})
126+
)
127+
).toThrow(TypeError);
128+
});
129+
130+
it('rejects whitespace-only closureDate string', () => {
131+
expect(() =>
132+
callBuildPayload(
133+
makeValidForm({
134+
closureDate: ' ' as unknown as Date
135+
})
136+
)
137+
).toThrow(TypeError);
138+
139+
expect(formatDateSpy).not.toHaveBeenCalled();
140+
});
141+
142+
it('rejects malformed closureDate string', () => {
143+
expect(() =>
144+
callBuildPayload(
145+
makeValidForm({
146+
closureDate: 'not-a-date' as unknown as Date
147+
})
148+
)
149+
).toThrow(TypeError);
150+
151+
expect(formatDateSpy).not.toHaveBeenCalled();
152+
});
153+
});
154+
155+
describe('closureReasonId validation', () => {
156+
it.each([
157+
0,
158+
-1,
159+
1.5,
160+
Number.NaN
161+
])('rejects invalid closureReasonId: %p', (value) => {
162+
expect(() =>
163+
callBuildPayload(
164+
makeValidForm({
165+
closureReasonId: value
166+
})
167+
)
168+
).toThrow(TypeError);
169+
});
170+
171+
it('accepts valid boundary values', () => {
172+
expect(() => callBuildPayload(makeValidForm({ closureReasonId: 1 }))).not.toThrow();
173+
174+
expect(() =>
175+
callBuildPayload(
176+
makeValidForm({
177+
closureReasonId: Number.MAX_SAFE_INTEGER
178+
})
179+
)
180+
).not.toThrow();
181+
});
182+
183+
it('rejects missing closureReasonId', () => {
184+
expect(() =>
185+
callBuildPayload({
186+
closureDate: new Date(),
187+
closureReasonId: undefined as unknown as number
188+
})
189+
).toThrow(TypeError);
190+
});
191+
192+
it('converts closureReasonId string to number', () => {
193+
const result = callBuildPayload(
194+
makeValidForm({
195+
closureReasonId: '42' as unknown as number
196+
})
197+
);
198+
199+
expect(result.closureReasonId).toBe(42);
200+
expect(typeof result.closureReasonId).toBe('number');
201+
});
202+
203+
it('converts stringified boundary values correctly', () => {
204+
const result = callBuildPayload(
205+
makeValidForm({
206+
closureReasonId: '1' as unknown as number
207+
})
208+
);
209+
210+
expect(result.closureReasonId).toBe(1);
211+
});
212+
213+
it.each([
214+
'0',
215+
'-1',
216+
'1.5',
217+
'not-a-number'
218+
])('converts string %p to number and validates', (value) => {
219+
const testValue = value as unknown as number;
220+
expect(() =>
221+
callBuildPayload(
222+
makeValidForm({
223+
closureReasonId: testValue
224+
})
225+
)
226+
).toThrow(TypeError);
227+
});
228+
});
229+
230+
describe('isValidDate', () => {
231+
it('returns true for valid Date objects', () => {
232+
expect(isValidDate(new Date())).toBe(true);
233+
expect(isValidDate(new Date(2025, 10, 3))).toBe(true);
234+
expect(isValidDate(new Date('2025-03-24'))).toBe(true);
235+
});
236+
237+
it('returns false for invalid Date objects', () => {
238+
expect(isValidDate(new Date('invalid'))).toBe(false);
239+
expect(isValidDate(new Date(Number.NaN))).toBe(false);
240+
});
241+
242+
it.each([
243+
'2025-03-24',
244+
123456,
245+
null,
246+
undefined,
247+
{}
248+
])('returns false for non-Date values: %p', (value) => {
249+
expect(isValidDate(value)).toBe(false);
250+
});
251+
});
252+
});

0 commit comments

Comments
 (0)