diff --git a/src/app/clients/clients-view/client-actions/close-client/close-client.component.ts b/src/app/clients/clients-view/client-actions/close-client/close-client.component.ts index a493f595cf..b683d46207 100644 --- a/src/app/clients/clients-view/client-actions/close-client/close-client.component.ts +++ b/src/app/clients/clients-view/client-actions/close-client/close-client.component.ts @@ -8,14 +8,15 @@ /** Angular Imports */ import { Component, OnInit, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; /** Custom Services */ import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; import { SettingsService } from 'app/settings/settings.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { buildCloseClientPayload } from './close-client.utils'; /** * Close Client Component @@ -29,12 +30,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; ] }) export class CloseClientComponent implements OnInit { - private formBuilder = inject(UntypedFormBuilder); - private clientsService = inject(ClientsService); - private dateUtils = inject(Dates); - private route = inject(ActivatedRoute); - private router = inject(Router); - private settingsService = inject(SettingsService); + private readonly formBuilder = inject(UntypedFormBuilder); + private readonly clientsService = inject(ClientsService); + private readonly dateUtils = inject(Dates); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly settingsService = inject(SettingsService); /** Minimum date allowed. */ minDate = new Date(2000, 0, 1); @@ -87,18 +88,9 @@ export class CloseClientComponent implements OnInit { * Submits the form and closes the client. */ submit() { - const closeClientFormData = this.closeClientForm.value; const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; - const prevClosedDate: Date = this.closeClientForm.value.closureDate; - if (closeClientFormData.closureDate instanceof Date) { - closeClientFormData.closureDate = this.dateUtils.formatDate(prevClosedDate, dateFormat); - } - const data = { - ...closeClientFormData, - dateFormat, - locale - }; + const data = buildCloseClientPayload(this.closeClientForm.value, this.dateUtils, locale, dateFormat); this.clientsService.executeClientCommand(this.clientId, 'close', data).subscribe(() => { this.router.navigate(['../../'], { relativeTo: this.route }); }); diff --git a/src/app/clients/clients-view/client-actions/close-client/close-client.utils.spec.ts b/src/app/clients/clients-view/client-actions/close-client/close-client.utils.spec.ts new file mode 100644 index 0000000000..ccf90667d5 --- /dev/null +++ b/src/app/clients/clients-view/client-actions/close-client/close-client.utils.spec.ts @@ -0,0 +1,235 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { buildCloseClientPayload, CloseClientFormValue, isValidDate } from './close-client.utils'; + +describe('buildCloseClientPayload', () => { + const DEFAULT_LOCALE = 'en'; + const DEFAULT_DATE_FORMAT = 'dd MMMM yyyy'; + const MOCK_FORMATTED_DATE = '03 November 2025'; + + let formatDateSpy: jest.MockedFunction<(date: Date, format: string) => string>; + + const mockDateUtils = { + formatDate: (date: Date, format: string) => formatDateSpy(date, format) + }; + + beforeEach(() => { + formatDateSpy = jest.fn(() => MOCK_FORMATTED_DATE); + }); + + const makeValidForm = (overrides: Partial = {}): CloseClientFormValue => ({ + closureDate: new Date(2025, 10, 3), + closureReasonId: 1, + ...overrides + }); + + const callBuildPayload = ( + formValue: CloseClientFormValue, + options?: { + locale?: string; + dateFormat?: string; + } + ) => + buildCloseClientPayload( + formValue, + mockDateUtils, + options?.locale ?? DEFAULT_LOCALE, + options?.dateFormat ?? DEFAULT_DATE_FORMAT + ); + + describe('Valid inputs', () => { + it('formats Date closureDate', () => { + const date = new Date(2025, 10, 3); + + const result = callBuildPayload(makeValidForm({ closureDate: date })); + + expect(formatDateSpy).toHaveBeenCalledWith(date, DEFAULT_DATE_FORMAT); + expect(formatDateSpy).toHaveBeenCalledTimes(1); + expect(result.closureDate).toBe(MOCK_FORMATTED_DATE); + }); + + it('passes through pre-formatted string closureDate', () => { + const preFormattedDate = '03 November 2025'; + + const result = callBuildPayload(makeValidForm({ closureDate: preFormattedDate })); + + expect(formatDateSpy).not.toHaveBeenCalled(); + expect(result.closureDate).toBe(preFormattedDate); + }); + + it('supports custom locale', () => { + const result = callBuildPayload(makeValidForm(), { + locale: 'fr' + }); + + expect(result.locale).toBe('fr'); + }); + + it('supports custom dateFormat', () => { + const customFormat = 'yyyy-MM-dd'; + + const result = callBuildPayload(makeValidForm(), { + dateFormat: customFormat + }); + + expect(result.dateFormat).toBe(customFormat); + }); + + it('preserves closureReasonId', () => { + const result = callBuildPayload(makeValidForm({ closureReasonId: 42 })); + + expect(result.closureReasonId).toBe(42); + }); + }); + + describe('Immutability', () => { + it('does not mutate input object', () => { + const formValue = makeValidForm(); + const originalDate = new Date(formValue.closureDate as Date); + const originalReasonId = formValue.closureReasonId; + + callBuildPayload(formValue); + + expect(formValue.closureDate).toEqual(originalDate); + expect(formValue.closureReasonId).toBe(originalReasonId); + }); + }); + + describe('closureDate validation', () => { + it('rejects invalid Date object', () => { + const invalidDate = new Date('invalid'); + + expect(isValidDate(invalidDate)).toBe(false); + + expect(() => callBuildPayload(makeValidForm({ closureDate: invalidDate }))).toThrow(TypeError); + expect(() => callBuildPayload(makeValidForm({ closureDate: invalidDate }))).toThrow( + /Invalid closureDate.*Expected a valid Date or valid date string/ + ); + + expect(formatDateSpy).not.toHaveBeenCalled(); + }); + + it.each([ + null, + undefined, + 12345, + {} as unknown as Date, + '' + ])('rejects invalid closureDate: %p', (value) => { + const callWithValue = () => callBuildPayload(makeValidForm({ closureDate: value as unknown as Date })); + expect(callWithValue).toThrow(TypeError); + expect(callWithValue).toThrow(/Invalid closureDate.*Expected a valid Date or valid date string/); + }); + + it('rejects whitespace-only closureDate string', () => { + const callWithWhitespace = () => callBuildPayload(makeValidForm({ closureDate: ' ' as unknown as Date })); + expect(callWithWhitespace).toThrow(TypeError); + expect(callWithWhitespace).toThrow(/Invalid closureDate.*Expected a valid Date or valid date string/); + + expect(formatDateSpy).not.toHaveBeenCalled(); + }); + + it('rejects malformed closureDate string', () => { + const callWithMalformed = () => callBuildPayload(makeValidForm({ closureDate: 'not-a-date' as unknown as Date })); + expect(callWithMalformed).toThrow(TypeError); + expect(callWithMalformed).toThrow(/Invalid closureDate.*Expected a valid Date or valid date string/); + + expect(formatDateSpy).not.toHaveBeenCalled(); + }); + }); + + describe('closureReasonId validation', () => { + it.each([ + 0, + -1, + 1.5, + Number.NaN + ])('rejects invalid closureReasonId: %p', (value) => { + const callWithReasonId = () => callBuildPayload(makeValidForm({ closureReasonId: value })); + expect(callWithReasonId).toThrow(TypeError); + expect(callWithReasonId).toThrow(/Invalid closureReasonId.*expected a positive integer/); + }); + + it('accepts valid boundary values', () => { + expect(() => callBuildPayload(makeValidForm({ closureReasonId: 1 }))).not.toThrow(); + + expect(() => + callBuildPayload( + makeValidForm({ + closureReasonId: Number.MAX_SAFE_INTEGER + }) + ) + ).not.toThrow(); + }); + + it('rejects missing closureReasonId', () => { + const callWithMissingReasonId = () => + callBuildPayload({ closureDate: new Date(), closureReasonId: undefined as unknown as number }); + expect(callWithMissingReasonId).toThrow(TypeError); + expect(callWithMissingReasonId).toThrow(/Invalid closureReasonId.*expected a positive integer/); + }); + + it('converts closureReasonId string to number', () => { + const result = callBuildPayload( + makeValidForm({ + closureReasonId: '42' as unknown as number + }) + ); + + expect(result.closureReasonId).toBe(42); + expect(typeof result.closureReasonId).toBe('number'); + }); + + it('converts stringified boundary values correctly', () => { + const result = callBuildPayload( + makeValidForm({ + closureReasonId: '1' as unknown as number + }) + ); + + expect(result.closureReasonId).toBe(1); + }); + + it.each([ + '0', + '-1', + '1.5', + 'not-a-number' + ])('converts string %p to number and validates', (value) => { + const testValue = value as unknown as number; + const callWithStringReasonId = () => callBuildPayload(makeValidForm({ closureReasonId: testValue })); + expect(callWithStringReasonId).toThrow(TypeError); + expect(callWithStringReasonId).toThrow(/Invalid closureReasonId.*expected a positive integer/); + }); + }); + + describe('isValidDate', () => { + it('returns true for valid Date objects', () => { + expect(isValidDate(new Date())).toBe(true); + expect(isValidDate(new Date(2025, 10, 3))).toBe(true); + expect(isValidDate(new Date('2025-03-24'))).toBe(true); + }); + + it('returns false for invalid Date objects', () => { + expect(isValidDate(new Date('invalid'))).toBe(false); + expect(isValidDate(new Date(Number.NaN))).toBe(false); + }); + + it.each([ + '2025-03-24', + 123456, + null, + undefined, + {} + ])('returns false for non-Date values: %p', (value) => { + expect(isValidDate(value)).toBe(false); + }); + }); +}); diff --git a/src/app/clients/clients-view/client-actions/close-client/close-client.utils.ts b/src/app/clients/clients-view/client-actions/close-client/close-client.utils.ts new file mode 100644 index 0000000000..8291d8e565 --- /dev/null +++ b/src/app/clients/clients-view/client-actions/close-client/close-client.utils.ts @@ -0,0 +1,134 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Form values submitted by the close client form. + * Allows flexibility in closureDate type (Date object or pre-formatted string). + */ +export interface CloseClientFormValue { + closureDate: Date | string; + closureReasonId: number | string; + [key: string]: unknown; +} + +/** + * Validated payload structure sent to the Apache Fineract API. + * All date values are guaranteed to be properly formatted strings. + */ +export interface CloseClientPayload { + closureDate: string; + closureReasonId: number; + dateFormat: string; + locale: string; + [key: string]: unknown; +} + +/** + * Type guard to validate if a value is a valid Date object. + * Rejects invalid Date instances (e.g., `new Date('invalid')`). + * + * @param value - The value to check + * @returns True if value is a valid Date object with a valid time value + * @example + * const date = new Date('2025-03-24'); + * if (isValidDate(date)) { + * // safely use date + * } + */ +export function isValidDate(value: unknown): value is Date { + return value instanceof Date && !Number.isNaN(value.getTime()); +} + +/** + * Validates if a string represents a valid date. + * Rejects malformed date strings like "not-a-date" or whitespace-only strings. + * + * @param dateString - The string to validate as a date + * @returns True if the string can be parsed into a valid date + * @example + * isValidDateString('2025-03-24') // true + * isValidDateString('not-a-date') // false + * isValidDateString(' ') // false + */ +function isValidDateString(dateString: string): boolean { + const trimmed = dateString.trim(); + if (trimmed.length === 0) { + return false; + } + + // Try to parse as Date - if it results in Invalid Date, reject it + const parsedDate = new Date(trimmed); + return !Number.isNaN(parsedDate.getTime()); +} + +/** + * Builds a validated payload for the close client API request. + * + * Ensures the closureDate is properly formatted as a string: + * - Valid Date objects are formatted using the provided dateUtils + * - String values are passed through (assumes pre-formatted) + * - Invalid types throw a TypeError for early error detection + * + * @param formValue - The form data from the close client form + * @param dateUtils - Utility object with formatDate function + * @param locale - ISO locale code (e.g., 'en', 'fr') + * @param dateFormat - Date format pattern (e.g., 'dd MMMM yyyy') + * @returns Validated payload ready for API submission + * @throws {TypeError} If closureDate is not a valid Date or string + * @example + * const payload = buildCloseClientPayload( + * { closureDate: new Date(2025, 10, 3), closureReasonId: 1 }, + * dateUtils, + * 'en', + * 'dd MMMM yyyy' + * ); + */ +export function buildCloseClientPayload( + formValue: CloseClientFormValue, + dateUtils: { formatDate: (date: Date, format: string) => string }, + locale: string, + dateFormat: string +): CloseClientPayload { + const closureReasonId = + typeof formValue.closureReasonId === 'string' ? Number(formValue.closureReasonId) : formValue.closureReasonId; + + validateClosureReasonId(closureReasonId); + + let closureDate: string; + + if (isValidDate(formValue.closureDate)) { + closureDate = dateUtils.formatDate(formValue.closureDate, dateFormat); + } else if (typeof formValue.closureDate === 'string' && isValidDateString(formValue.closureDate)) { + closureDate = formValue.closureDate.trim(); + } else { + throw new TypeError( + `Invalid closureDate: received ${typeof formValue.closureDate} with value "${formValue.closureDate}". Expected a valid Date or valid date string.` + ); + } + + return { + ...formValue, + closureDate, + closureReasonId, + dateFormat, + locale + }; +} + +/** + * Validates that closureReasonId is a positive integer. + * + * @param closureReasonId - The closure reason ID to validate + * @throws {TypeError} If closureReasonId is not a valid positive integer + * @internal + */ +function validateClosureReasonId(closureReasonId: number): void { + if (!Number.isInteger(closureReasonId) || closureReasonId <= 0) { + throw new TypeError(`Invalid closureReasonId: expected a positive integer, received ${closureReasonId}`); + } +}