Skip to content

Commit 500e69a

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

3 files changed

Lines changed: 301 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: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
2+
import { buildCloseClientPayload, CloseClientFormValue, isValidDate } from './close-client.utils';
3+
4+
describe('buildCloseClientPayload', () => {
5+
const DEFAULT_LOCALE = 'en';
6+
const DEFAULT_DATE_FORMAT = 'dd MMMM yyyy';
7+
const MOCK_FORMATTED_DATE = '03 November 2025';
8+
9+
let formatDateSpy: jest.MockedFunction<(date: Date, format: string) => string>;
10+
11+
const mockDateUtils = {
12+
formatDate: (date: Date, format: string) => formatDateSpy(date, format)
13+
};
14+
15+
beforeEach(() => {
16+
formatDateSpy = jest.fn(() => MOCK_FORMATTED_DATE);
17+
});
18+
19+
const makeValidForm = (overrides: Partial<CloseClientFormValue> = {}): CloseClientFormValue => ({
20+
closureDate: new Date(2025, 10, 3),
21+
closureReasonId: 1,
22+
...overrides
23+
});
24+
25+
const callBuildPayload = (
26+
formValue: CloseClientFormValue,
27+
options?: {
28+
locale?: string;
29+
dateFormat?: string;
30+
}
31+
) =>
32+
buildCloseClientPayload(
33+
formValue,
34+
mockDateUtils,
35+
options?.locale ?? DEFAULT_LOCALE,
36+
options?.dateFormat ?? DEFAULT_DATE_FORMAT
37+
);
38+
39+
describe('Valid inputs', () => {
40+
it('formats Date closureDate', () => {
41+
const date = new Date(2025, 10, 3);
42+
43+
const result = callBuildPayload(makeValidForm({ closureDate: date }));
44+
45+
expect(formatDateSpy).toHaveBeenCalledWith(date, DEFAULT_DATE_FORMAT);
46+
expect(formatDateSpy).toHaveBeenCalledTimes(1);
47+
expect(result.closureDate).toBe(MOCK_FORMATTED_DATE);
48+
});
49+
50+
it('passes through pre-formatted string closureDate', () => {
51+
const preFormattedDate = '03 November 2025';
52+
53+
const result = callBuildPayload(makeValidForm({ closureDate: preFormattedDate }));
54+
55+
expect(formatDateSpy).not.toHaveBeenCalled();
56+
expect(result.closureDate).toBe(preFormattedDate);
57+
});
58+
59+
it('supports custom locale', () => {
60+
const result = callBuildPayload(makeValidForm(), {
61+
locale: 'fr'
62+
});
63+
64+
expect(result.locale).toBe('fr');
65+
});
66+
67+
it('supports custom dateFormat', () => {
68+
const customFormat = 'yyyy-MM-dd';
69+
70+
const result = callBuildPayload(makeValidForm(), {
71+
dateFormat: customFormat
72+
});
73+
74+
expect(result.dateFormat).toBe(customFormat);
75+
});
76+
77+
it('preserves closureReasonId', () => {
78+
const result = callBuildPayload(makeValidForm({ closureReasonId: 42 }));
79+
80+
expect(result.closureReasonId).toBe(42);
81+
});
82+
});
83+
84+
describe('Immutability', () => {
85+
it('does not mutate input object', () => {
86+
const formValue = makeValidForm();
87+
const snapshot = { ...formValue };
88+
89+
callBuildPayload(formValue);
90+
91+
expect(formValue).toEqual(snapshot);
92+
});
93+
});
94+
95+
describe('closureDate validation', () => {
96+
it('rejects invalid Date object', () => {
97+
const invalidDate = new Date('invalid');
98+
99+
expect(isValidDate(invalidDate)).toBe(false);
100+
101+
expect(() => callBuildPayload(makeValidForm({ closureDate: invalidDate }))).toThrow(TypeError);
102+
103+
expect(formatDateSpy).not.toHaveBeenCalled();
104+
});
105+
106+
it.each([
107+
null,
108+
undefined,
109+
12345,
110+
{} as unknown as Date,
111+
''
112+
])('rejects invalid closureDate: %p', (value) => {
113+
expect(() =>
114+
callBuildPayload(
115+
makeValidForm({
116+
closureDate: value as unknown as Date
117+
})
118+
)
119+
).toThrow(TypeError);
120+
});
121+
});
122+
123+
describe('closureReasonId validation', () => {
124+
it.each([
125+
0,
126+
-1,
127+
1.5,
128+
Number.NaN
129+
])('rejects invalid closureReasonId: %p', (value) => {
130+
expect(() =>
131+
callBuildPayload(
132+
makeValidForm({
133+
closureReasonId: value
134+
})
135+
)
136+
).toThrow(TypeError);
137+
});
138+
139+
it('accepts valid boundary values', () => {
140+
expect(() => callBuildPayload(makeValidForm({ closureReasonId: 1 }))).not.toThrow();
141+
142+
expect(() =>
143+
callBuildPayload(
144+
makeValidForm({
145+
closureReasonId: Number.MAX_SAFE_INTEGER
146+
})
147+
)
148+
).not.toThrow();
149+
});
150+
151+
it('rejects missing closureReasonId', () => {
152+
expect(() =>
153+
callBuildPayload({
154+
closureDate: new Date(),
155+
closureReasonId: undefined as unknown as number
156+
})
157+
).toThrow(TypeError);
158+
});
159+
});
160+
161+
describe('isValidDate', () => {
162+
it('returns true for valid Date objects', () => {
163+
expect(isValidDate(new Date())).toBe(true);
164+
expect(isValidDate(new Date(2025, 10, 3))).toBe(true);
165+
expect(isValidDate(new Date('2025-03-24'))).toBe(true);
166+
});
167+
168+
it('returns false for invalid Date objects', () => {
169+
expect(isValidDate(new Date('invalid'))).toBe(false);
170+
expect(isValidDate(new Date(Number.NaN))).toBe(false);
171+
});
172+
173+
it.each([
174+
'2025-03-24',
175+
123456,
176+
null,
177+
undefined,
178+
{}
179+
])('returns false for non-Date values: %p', (value) => {
180+
expect(isValidDate(value)).toBe(false);
181+
});
182+
});
183+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
/**
10+
* Form values submitted by the close client form.
11+
* Allows flexibility in closureDate type (Date object or pre-formatted string).
12+
*/
13+
export interface CloseClientFormValue {
14+
closureDate: Date | string;
15+
closureReasonId: number;
16+
[key: string]: unknown;
17+
}
18+
19+
/**
20+
* Validated payload structure sent to the Apache Fineract API.
21+
* All date values are guaranteed to be properly formatted strings.
22+
*/
23+
export interface CloseClientPayload {
24+
closureDate: string;
25+
closureReasonId: number;
26+
dateFormat: string;
27+
locale: string;
28+
[key: string]: unknown;
29+
}
30+
31+
/**
32+
* Type guard to validate if a value is a valid Date object.
33+
* Rejects invalid Date instances (e.g., `new Date('invalid')`).
34+
*
35+
* @param value - The value to check
36+
* @returns True if value is a valid Date object with a valid time value
37+
* @example
38+
* const date = new Date('2025-03-24');
39+
* if (isValidDate(date)) {
40+
* // safely use date
41+
* }
42+
*/
43+
export function isValidDate(value: unknown): value is Date {
44+
return value instanceof Date && !Number.isNaN(value.getTime());
45+
}
46+
47+
/**
48+
* Builds a validated payload for the close client API request.
49+
*
50+
* Ensures the closureDate is properly formatted as a string:
51+
* - Valid Date objects are formatted using the provided dateUtils
52+
* - String values are passed through (assumes pre-formatted)
53+
* - Invalid types throw a TypeError for early error detection
54+
*
55+
* @param formValue - The form data from the close client form
56+
* @param dateUtils - Utility object with formatDate function
57+
* @param locale - ISO locale code (e.g., 'en', 'fr')
58+
* @param dateFormat - Date format pattern (e.g., 'dd MMMM yyyy')
59+
* @returns Validated payload ready for API submission
60+
* @throws {TypeError} If closureDate is not a valid Date or string
61+
* @example
62+
* const payload = buildCloseClientPayload(
63+
* { closureDate: new Date(2025, 10, 3), closureReasonId: 1 },
64+
* dateUtils,
65+
* 'en',
66+
* 'dd MMMM yyyy'
67+
* );
68+
*/
69+
export function buildCloseClientPayload(
70+
formValue: CloseClientFormValue,
71+
dateUtils: { formatDate: (date: Date, format: string) => string },
72+
locale: string,
73+
dateFormat: string
74+
): CloseClientPayload {
75+
validateClosureReasonId(formValue.closureReasonId);
76+
77+
let closureDate: string;
78+
79+
if (isValidDate(formValue.closureDate)) {
80+
closureDate = dateUtils.formatDate(formValue.closureDate, dateFormat);
81+
} else if (typeof formValue.closureDate === 'string' && formValue.closureDate.trim().length > 0) {
82+
closureDate = formValue.closureDate;
83+
} else {
84+
throw new TypeError(
85+
`Invalid closureDate: received ${typeof formValue.closureDate} with value "${formValue.closureDate}". Expected a valid Date or non-empty string.`
86+
);
87+
}
88+
89+
return {
90+
...formValue,
91+
closureDate,
92+
dateFormat,
93+
locale
94+
};
95+
}
96+
97+
/**
98+
* Validates that closureReasonId is a positive integer.
99+
*
100+
* @param closureReasonId - The closure reason ID to validate
101+
* @throws {TypeError} If closureReasonId is not a valid positive integer
102+
* @internal
103+
*/
104+
function validateClosureReasonId(closureReasonId: number): void {
105+
if (!Number.isInteger(closureReasonId) || closureReasonId <= 0) {
106+
throw new TypeError(`Invalid closureReasonId: expected a positive integer, received ${closureReasonId}`);
107+
}
108+
}

0 commit comments

Comments
 (0)