diff --git a/playwright/tests/clients/lifecycle/activate.spec.ts b/playwright/tests/clients/lifecycle/activate.spec.ts new file mode 100644 index 0000000000..065f271861 --- /dev/null +++ b/playwright/tests/clients/lifecycle/activate.spec.ts @@ -0,0 +1,67 @@ +/** + * Copyright since 2026 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 { test, expect } from '../../../fixtures/test-fixtures'; +import { createTestClient } from '../../../factories/client.factory'; +import { ClientViewPage } from '../../../pages/client-view.page'; +import { ActivateClientPage } from '../../../pages/client-actions/activate-client.page'; + +/** + * WA-3.3 client lifecycle happy path: Pending → Active. + * + * Pattern: + * 1. Factory creates the predecessor-state client (pending). + * 2. UI drives the transition via `chooseAction('Activate')`. + * 3. Assert snackbar copy + `getClient().status.value` + + * timeline entry (`activatedOnDate`). + */ +const SUBMITTED_ON_DATE = '01 January 2024'; +const ACTIVATION_DATE = '02 January 2024'; + +test.describe('Client lifecycle · Activate (Pending → Active)', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + const creds = localStorage.getItem('mifosXCredentials'); + if (creds) { + sessionStorage.setItem('mifosXCredentials', creds); + } + }); + }); + + test('activates a pending client from the client actions flow', async ({ + page, + fineractApi, + apiSetup, + cleanupGuard + }) => { + const client = await createTestClient(apiSetup, cleanupGuard, { + submittedOnDate: SUBMITTED_ON_DATE + }); + + const clientViewPage = new ClientViewPage(page, client.resourceId); + const activatePage = new ActivateClientPage(page, client.resourceId); + + await clientViewPage.navigate(); + await clientViewPage.waitForLoad(); + await clientViewPage.chooseAction('Activate'); + + await activatePage.waitForLoad(); + await activatePage.submitActivation({ activationDate: ACTIVATION_DATE }); + + await clientViewPage.waitForLoad(); + await expect(clientViewPage.successSnackbar).toContainText('Client activated successfully.'); + + const clientDetails = await fineractApi.getClient(client.resourceId); + expect(clientDetails.status?.value).toBe('Active'); + expect(clientDetails.timeline?.activatedOnDate).toEqual([ + 2024, + 1, + 2 + ]); + }); +}); diff --git a/playwright/tests/clients/lifecycle/reactivate-after-close.spec.ts b/playwright/tests/clients/lifecycle/reactivate-after-close.spec.ts new file mode 100644 index 0000000000..0c3f123c49 --- /dev/null +++ b/playwright/tests/clients/lifecycle/reactivate-after-close.spec.ts @@ -0,0 +1,85 @@ +/** + * Copyright since 2026 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 { test, expect } from '../../../fixtures/test-fixtures'; +import { ClientViewPage } from '../../../pages/client-view.page'; +import { ReactivateClientPage } from '../../../pages/client-actions/reactivate-client.page'; + +/** + * WA-3.3 client lifecycle happy path: Closed → Pending (Reactivate). + * + * Pattern: + * 1. API creates an active client, then closes it, so the client + * enters the predecessor `Closed` state. This flow deliberately + * bypasses `createTestClient` because active/closed clients + * cannot be hard-deleted by the cleanup guard. + * 2. UI drives the transition via `chooseAction('Reactivate')`. + * 3. Assert snackbar copy + `getClient().status.value` returns to + * `Pending` (Fineract's documented reactivate destination — a + * subsequent Activate is required to reach `Active`) + the + * `closedOnDate` timeline entry is cleared. + */ +const SUBMITTED_ON_DATE = '01 January 2024'; +const ACTIVATION_DATE = '02 January 2024'; +const CLOSURE_DATE = '03 January 2024'; +const REACTIVATION_DATE = '04 January 2024'; +const CLOSURE_REASON_NAME = 'E2E Close Client Reason'; + +test.describe('Client lifecycle · Reactivate (Closed → Pending)', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + const creds = localStorage.getItem('mifosXCredentials'); + if (creds) { + sessionStorage.setItem('mifosXCredentials', creds); + } + }); + }); + + test('reactivates a closed client from the client actions flow', async ({ page, fineractApi }) => { + const closureReason = await fineractApi.ensureClientClosureReason(CLOSURE_REASON_NAME); + const officeId = await fineractApi.getFirstOfficeId(); + + const uniqueSuffix = Date.now(); + const createClientResponse = await fineractApi.createActiveClient(officeId, { + firstname: `Reactivate${uniqueSuffix}`, + lastname: 'Client', + submittedOnDate: SUBMITTED_ON_DATE, + activationDate: ACTIVATION_DATE + }); + + const clientId = createClientResponse.resourceId; + await fineractApi.closeClient(clientId, closureReason.id, CLOSURE_DATE); + + const clientViewPage = new ClientViewPage(page, clientId); + const reactivatePage = new ReactivateClientPage(page, clientId); + + await clientViewPage.navigate(); + await clientViewPage.waitForLoad(); + await clientViewPage.chooseAction('Reactivate'); + + await reactivatePage.waitForLoad(); + await reactivatePage.submitReactivation({ reactivationDate: REACTIVATION_DATE }); + + await clientViewPage.waitForLoad(); + await expect(clientViewPage.successSnackbar).toContainText('Client reactivated successfully.'); + + const clientDetails = await fineractApi.getClient(clientId); + // In Fineract, reactivating a closed client moves it back to + // `Pending` state — a subsequent Activate action is required to + // return to `Active`. The Pending status combined with the cleared + // `closedOnDate` timeline entry is the definitive evidence that + // the reactivate command was recorded server-side. + expect(clientDetails.status?.value).toBe('Pending'); + expect(clientDetails.timeline?.closedOnDate).toBeFalsy(); + expect(clientDetails.timeline?.submittedOnDate).toEqual([ + 2024, + 1, + 1 + ]); + }); +}); diff --git a/playwright/tests/clients/lifecycle/reject.spec.ts b/playwright/tests/clients/lifecycle/reject.spec.ts new file mode 100644 index 0000000000..bf3a3c8046 --- /dev/null +++ b/playwright/tests/clients/lifecycle/reject.spec.ts @@ -0,0 +1,79 @@ +/** + * Copyright since 2026 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 { test, expect } from '../../../fixtures/test-fixtures'; +import { createTestClient } from '../../../factories/client.factory'; +import { ClientViewPage } from '../../../pages/client-view.page'; +import { RejectClientPage } from '../../../pages/client-actions/reject-client.page'; + +/** + * WA-3.3 client lifecycle happy path: Pending → Rejected. + * + * Pattern: + * 1. Factory creates a pending client. + * 2. UI drives the transition via `chooseAction('Reject')`. + * 3. Assert snackbar copy + `getClient().status.value` + + * timeline entry (`rejectedOnDate`). + */ +const SUBMITTED_ON_DATE = '01 January 2024'; +const REJECTION_DATE = '02 January 2024'; +const REJECTION_REASON_NAME = 'E2E Reject Client Reason'; + +test.describe('Client lifecycle · Reject (Pending → Rejected)', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + const creds = localStorage.getItem('mifosXCredentials'); + if (creds) { + sessionStorage.setItem('mifosXCredentials', creds); + } + }); + }); + + test('rejects a pending client from the client actions flow', async ({ + page, + fineractApi, + apiSetup, + cleanupGuard + }) => { + await fineractApi.ensureClientRejectionReason(REJECTION_REASON_NAME); + + const client = await createTestClient(apiSetup, cleanupGuard, { + submittedOnDate: SUBMITTED_ON_DATE + }); + + const clientViewPage = new ClientViewPage(page, client.resourceId); + const rejectPage = new RejectClientPage(page, client.resourceId); + + await clientViewPage.navigate(); + await clientViewPage.waitForLoad(); + await clientViewPage.chooseAction('Reject'); + + await rejectPage.waitForLoad(); + await rejectPage.submitRejection({ + rejectionDate: REJECTION_DATE, + reasonName: REJECTION_REASON_NAME + }); + + await clientViewPage.waitForLoad(); + await expect(clientViewPage.successSnackbar).toContainText('Client rejected successfully.'); + + const clientDetails = await fineractApi.getClient(client.resourceId); + expect(clientDetails.status?.value).toBe('Rejected'); + // Fineract's GET /clients/{id} response does not surface `rejectedOnDate` + // in the timeline payload for rejected clients (verified against the + // running backend on 2026-07-15). The status transition itself is the + // definitive evidence of the reject action; we still assert that the + // pre-existing `submittedOnDate` timeline entry is preserved so any + // future regression that wipes the timeline object is caught. + expect(clientDetails.timeline?.submittedOnDate).toEqual([ + 2024, + 1, + 1 + ]); + }); +}); diff --git a/playwright/tests/clients/lifecycle/undo-rejection.spec.ts b/playwright/tests/clients/lifecycle/undo-rejection.spec.ts new file mode 100644 index 0000000000..8dfc43c114 --- /dev/null +++ b/playwright/tests/clients/lifecycle/undo-rejection.spec.ts @@ -0,0 +1,77 @@ +/** + * Copyright since 2026 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 { test, expect } from '../../../fixtures/test-fixtures'; +import { createTestClient } from '../../../factories/client.factory'; +import { ClientViewPage } from '../../../pages/client-view.page'; +import { ROUTES } from '../../../config/routes'; + +/** + * WA-3.3 client lifecycle happy path: Rejected → Pending (Undo Rejection). + * + * Pattern: + * 1. Factory creates a pending client; API immediately rejects it so + * the client enters the predecessor `Rejected` state. + * 2. UI drives the transition via `chooseAction('Undo Rejection')`. + * Undo Rejection carries a single `reopenedDate` control and has + * no dedicated page object yet, so the field is driven inline. + * 3. Assert snackbar copy + `getClient().status.value` returns to + * `Pending` + `timeline.rejectedOnDate` is cleared. + */ +const SUBMITTED_ON_DATE = '01 January 2024'; +const REJECTION_DATE = '02 January 2024'; +const REOPENED_DATE = '03 January 2024'; +const REJECTION_REASON_NAME = 'E2E Reject Client Reason'; + +test.describe('Client lifecycle · Undo Rejection (Rejected → Pending)', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + const creds = localStorage.getItem('mifosXCredentials'); + if (creds) { + sessionStorage.setItem('mifosXCredentials', creds); + } + }); + }); + + test('undoes a rejection from the client actions flow', async ({ page, fineractApi, apiSetup, cleanupGuard }) => { + const rejectionReason = await fineractApi.ensureClientRejectionReason(REJECTION_REASON_NAME); + + const client = await createTestClient(apiSetup, cleanupGuard, { + submittedOnDate: SUBMITTED_ON_DATE + }); + + // Predecessor state: reject through the API so the UI transition + // under test is Undo Rejection, not the initial rejection. + await fineractApi.rejectClient(client.resourceId, rejectionReason.id, REJECTION_DATE); + + const clientViewPage = new ClientViewPage(page, client.resourceId); + + await clientViewPage.navigate(); + await clientViewPage.waitForLoad(); + await clientViewPage.chooseAction('Undo Rejection'); + + await expect(page).toHaveURL(new RegExp(`${ROUTES.clientAction(client.resourceId, 'Undo Rejection')}$`)); + + const reopenedDateInput = page.locator('input[formcontrolname="reopenedDate"]'); + await reopenedDateInput.waitFor({ state: 'visible', timeout: 30000 }); + await reopenedDateInput.fill(REOPENED_DATE); + await reopenedDateInput.blur(); + + const confirmButton = page.getByRole('button', { name: 'Confirm' }); + await expect(confirmButton).toBeEnabled(); + await confirmButton.click(); + + await clientViewPage.waitForLoad(); + await expect(clientViewPage.successSnackbar).toContainText('Client rejection undone successfully.'); + + const clientDetails = await fineractApi.getClient(client.resourceId); + expect(clientDetails.status?.value).toBe('Pending'); + // Fineract clears the rejection timestamp when a rejection is undone. + expect(clientDetails.timeline?.rejectedOnDate).toBeFalsy(); + }); +}); diff --git a/playwright/tests/clients/lifecycle/withdraw.spec.ts b/playwright/tests/clients/lifecycle/withdraw.spec.ts new file mode 100644 index 0000000000..06b3785bdd --- /dev/null +++ b/playwright/tests/clients/lifecycle/withdraw.spec.ts @@ -0,0 +1,79 @@ +/** + * Copyright since 2026 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 { test, expect } from '../../../fixtures/test-fixtures'; +import { createTestClient } from '../../../factories/client.factory'; +import { ClientViewPage } from '../../../pages/client-view.page'; +import { WithdrawClientPage } from '../../../pages/client-actions/withdraw-client.page'; + +/** + * WA-3.3 client lifecycle happy path: Pending → Withdrawn. + * + * Pattern: + * 1. Factory creates a pending client. + * 2. UI drives the transition via `chooseAction('Withdraw')`. + * 3. Assert snackbar copy + `getClient().status.value` + + * timeline entry (`withdrawnOnDate`). + */ +const SUBMITTED_ON_DATE = '01 January 2024'; +const WITHDRAWAL_DATE = '02 January 2024'; +const WITHDRAWAL_REASON_NAME = 'E2E Withdraw Client Reason'; + +test.describe('Client lifecycle · Withdraw (Pending → Withdrawn)', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + const creds = localStorage.getItem('mifosXCredentials'); + if (creds) { + sessionStorage.setItem('mifosXCredentials', creds); + } + }); + }); + + test('withdraws a pending client from the client actions flow', async ({ + page, + fineractApi, + apiSetup, + cleanupGuard + }) => { + await fineractApi.ensureClientWithdrawalReason(WITHDRAWAL_REASON_NAME); + + const client = await createTestClient(apiSetup, cleanupGuard, { + submittedOnDate: SUBMITTED_ON_DATE + }); + + const clientViewPage = new ClientViewPage(page, client.resourceId); + const withdrawPage = new WithdrawClientPage(page, client.resourceId); + + await clientViewPage.navigate(); + await clientViewPage.waitForLoad(); + await clientViewPage.chooseAction('Withdraw'); + + await withdrawPage.waitForLoad(); + await withdrawPage.submitWithdrawal({ + withdrawalDate: WITHDRAWAL_DATE, + reasonName: WITHDRAWAL_REASON_NAME + }); + + await clientViewPage.waitForLoad(); + await expect(clientViewPage.successSnackbar).toContainText('Client withdrawn successfully.'); + + const clientDetails = await fineractApi.getClient(client.resourceId); + expect(clientDetails.status?.value).toBe('Withdrawn'); + // Fineract's GET /clients/{id} response does not surface `withdrawnOnDate` + // in the timeline payload for withdrawn clients (verified against the + // running backend on 2026-07-15). The status transition itself is the + // definitive evidence of the withdraw action; we still assert that the + // pre-existing `submittedOnDate` timeline entry is preserved so any + // future regression that wipes the timeline object is caught. + expect(clientDetails.timeline?.submittedOnDate).toEqual([ + 2024, + 1, + 1 + ]); + }); +});