Skip to content

Commit cf02a62

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

3 files changed

Lines changed: 379 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: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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 originalDate = new Date(formValue.closureDate as Date);
96+
const originalReasonId = formValue.closureReasonId;
97+
98+
callBuildPayload(formValue);
99+
100+
expect(formValue.closureDate).toEqual(originalDate);
101+
expect(formValue.closureReasonId).toBe(originalReasonId);
102+
});
103+
});
104+
105+
describe('closureDate validation', () => {
106+
it('rejects invalid Date object', () => {
107+
const invalidDate = new Date('invalid');
108+
109+
expect(isValidDate(invalidDate)).toBe(false);
110+
111+
expect(() => callBuildPayload(makeValidForm({ closureDate: invalidDate }))).toThrow(TypeError);
112+
expect(() => callBuildPayload(makeValidForm({ closureDate: invalidDate }))).toThrow(
113+
/Invalid closureDate.*Expected a valid Date or valid date string/
114+
);
115+
116+
expect(formatDateSpy).not.toHaveBeenCalled();
117+
});
118+
119+
it.each([
120+
null,
121+
undefined,
122+
12345,
123+
{} as unknown as Date,
124+
''
125+
])('rejects invalid closureDate: %p', (value) => {
126+
const callWithValue = () => callBuildPayload(makeValidForm({ closureDate: value as unknown as Date }));
127+
expect(callWithValue).toThrow(TypeError);
128+
expect(callWithValue).toThrow(/Invalid closureDate.*Expected a valid Date or valid date string/);
129+
});
130+
131+
it('rejects whitespace-only closureDate string', () => {
132+
const callWithWhitespace = () => callBuildPayload(makeValidForm({ closureDate: ' ' as unknown as Date }));
133+
expect(callWithWhitespace).toThrow(TypeError);
134+
expect(callWithWhitespace).toThrow(/Invalid closureDate.*Expected a valid Date or valid date string/);
135+
136+
expect(formatDateSpy).not.toHaveBeenCalled();
137+
});
138+
139+
it('rejects malformed closureDate string', () => {
140+
const callWithMalformed = () => callBuildPayload(makeValidForm({ closureDate: 'not-a-date' as unknown as Date }));
141+
expect(callWithMalformed).toThrow(TypeError);
142+
expect(callWithMalformed).toThrow(/Invalid closureDate.*Expected a valid Date or valid date string/);
143+
144+
expect(formatDateSpy).not.toHaveBeenCalled();
145+
});
146+
});
147+
148+
describe('closureReasonId validation', () => {
149+
it.each([
150+
0,
151+
-1,
152+
1.5,
153+
Number.NaN
154+
])('rejects invalid closureReasonId: %p', (value) => {
155+
const callWithReasonId = () => callBuildPayload(makeValidForm({ closureReasonId: value }));
156+
expect(callWithReasonId).toThrow(TypeError);
157+
expect(callWithReasonId).toThrow(/Invalid closureReasonId.*expected a positive integer/);
158+
});
159+
160+
it('accepts valid boundary values', () => {
161+
expect(() => callBuildPayload(makeValidForm({ closureReasonId: 1 }))).not.toThrow();
162+
163+
expect(() =>
164+
callBuildPayload(
165+
makeValidForm({
166+
closureReasonId: Number.MAX_SAFE_INTEGER
167+
})
168+
)
169+
).not.toThrow();
170+
});
171+
172+
it('rejects missing closureReasonId', () => {
173+
const callWithMissingReasonId = () =>
174+
callBuildPayload({ closureDate: new Date(), closureReasonId: undefined as unknown as number });
175+
expect(callWithMissingReasonId).toThrow(TypeError);
176+
expect(callWithMissingReasonId).toThrow(/Invalid closureReasonId.*expected a positive integer/);
177+
});
178+
179+
it('converts closureReasonId string to number', () => {
180+
const result = callBuildPayload(
181+
makeValidForm({
182+
closureReasonId: '42' as unknown as number
183+
})
184+
);
185+
186+
expect(result.closureReasonId).toBe(42);
187+
expect(typeof result.closureReasonId).toBe('number');
188+
});
189+
190+
it('converts stringified boundary values correctly', () => {
191+
const result = callBuildPayload(
192+
makeValidForm({
193+
closureReasonId: '1' as unknown as number
194+
})
195+
);
196+
197+
expect(result.closureReasonId).toBe(1);
198+
});
199+
200+
it.each([
201+
'0',
202+
'-1',
203+
'1.5',
204+
'not-a-number'
205+
])('converts string %p to number and validates', (value) => {
206+
const testValue = value as unknown as number;
207+
const callWithStringReasonId = () => callBuildPayload(makeValidForm({ closureReasonId: testValue }));
208+
expect(callWithStringReasonId).toThrow(TypeError);
209+
expect(callWithStringReasonId).toThrow(/Invalid closureReasonId.*expected a positive integer/);
210+
});
211+
});
212+
213+
describe('isValidDate', () => {
214+
it('returns true for valid Date objects', () => {
215+
expect(isValidDate(new Date())).toBe(true);
216+
expect(isValidDate(new Date(2025, 10, 3))).toBe(true);
217+
expect(isValidDate(new Date('2025-03-24'))).toBe(true);
218+
});
219+
220+
it('returns false for invalid Date objects', () => {
221+
expect(isValidDate(new Date('invalid'))).toBe(false);
222+
expect(isValidDate(new Date(Number.NaN))).toBe(false);
223+
});
224+
225+
it.each([
226+
'2025-03-24',
227+
123456,
228+
null,
229+
undefined,
230+
{}
231+
])('returns false for non-Date values: %p', (value) => {
232+
expect(isValidDate(value)).toBe(false);
233+
});
234+
});
235+
});

0 commit comments

Comments
 (0)