Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,29 @@ const IONIC_SELECT_ARIA_REQUIRED = [
'aria-allowed-attr|ion-select[name="officeId"] >> #ion-sel-*',
'aria-allowed-attr|#office-parent >> #ion-sel-*',
];
/**
* The office select's own placeholder text, which has always failed AA and was never evaluated.
*
* Ionic renders `.select-placeholder` as `color: rgb(51, 51, 51)` at `opacity: 0.6`, which lands
* at #858585 on white — 3.69:1 against the 4.5:1 this scan asks for. Measured identically on
* `main`, so the colour is not new.
*
* What is new is that axe can see it. Until now this page carried an error toast at scan time
* (one unmocked request is enough), and an overlay puts `color-contrast` into axe's *incomplete*
* bucket for every node it covers — fourteen of them here — so the rule was never decided either
* way. The business-date reads behind the header no longer raise that toast, so the page is
* finally scannable and says what it had been hiding. `expectScanWasNotBlind` does not catch
* this case: other rules still passed, so the scan was not blind, only this rule was.
*
* Baselined rather than fixed because the fix is a theme change — Ionic's placeholder opacity,
* applied to every select and input in the application — which belongs with #484's contrast work
* rather than in a form-submission change.
*/
const CLIENT_FORM_BASELINE = new Set([
...IONIC_SELECT_ARIA_REQUIRED,
'role-img-alt|ion-icon[name="add-circle-outline"]',
'role-img-alt|.help-icon[name="help-circle-outline"]',
'color-contrast|.has-placeholder >> .select-placeholder',
]);
const CREATE_OFFICE_DIALOG_BASELINE = new Set(IONIC_SELECT_ARIA_REQUIRED);
/**
Expand Down
132 changes: 132 additions & 0 deletions e2e/double-submit-backend.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

/**
* #756. One double-click posted two repayments.
*
* Measured on a running Fineract: `POST /loans/{id}/transactions?command=repayment` twice, both
* `200`, two transactions of the same amount, and the borrower's balance reduced by double what
* they paid. Create Client made two clients the same way. The forms' `isSaving` flag is bound to
* `[disabled]`, which the second click of a double-click beats to the DOM.
*
* These run against the real platform because the assertion that matters is not "one request
* left the browser" but "the money moved once" — the first is how the UI behaves, the second is
* what the borrower's statement says.
*/

import { test, expect, Page, recordingTimeout } from './fixtures';
import { login } from './utils/fineract-login';
import { selectOption } from './utils/select-option';
import {
API_BASE,
createApiContext,
loanTransactions,
seedActiveLoan,
seedSuffix,
} from './utils/seed-api';

/** Counts the requests the page issues to a command endpoint while a block runs. */
async function countingPosts(
page: Page,
pattern: RegExp,
action: () => Promise<void>,
): Promise<number> {
const seen: string[] = [];
const listener = (request: { method: () => string; url: () => string }): void => {
if (request.method() === 'POST' && pattern.test(request.url())) seen.push(request.url());
};
page.on('request', listener);
try {
await action();
} finally {
page.off('request', listener);
}
return seen.length;
}

test.describe('A double-click submits once', () => {
test('a repayment is recorded once, not twice', async ({ page }) => {
test.setTimeout(recordingTimeout(240000));
const api = await createApiContext();
const loan = await seedActiveLoan(api, 'E2EDouble');

await login(page);
await page.goto(`/loans/${loan.loanId}/transactions/repayment`);

// The transaction date prefills from the loan template, and renders behind an
// ion-datetime-button with no native input — only the amount needs setting.
const amount = page.locator('input[name="transactionAmount"]');
await expect(amount).toBeVisible({ timeout: 20000 });
await amount.fill('100');

const posts = await countingPosts(
page,
/\/loans\/\d+\/transactions\?command=repayment/,
async () => {
// One double-click, as a cashier makes it — not two deliberate submissions.
await page.getByRole('button', { name: /^save$/i }).dblclick();
// A save returns to the record it affected (#798), so this is the loan, not the list.
await expect(page).toHaveURL(new RegExp(`/loans/view/${loan.loanId}$`), {
timeout: 20000,
});
},
);

expect(posts).toBe(1);

// And the platform agrees: one repayment of 100, not two.
const transactions = await loanTransactions(api, loan.loanId);
const hundreds = transactions.filter(
(transaction) => transaction.amount === 100 && transaction.manuallyReversed !== true,
);
expect(hundreds).toHaveLength(1);
});

test('a client is created once, not twice', async ({ page }) => {
test.setTimeout(recordingTimeout(240000));
const api = await createApiContext();
const surname = `Double${seedSuffix()}`;

await login(page);
// The form is a three-step wizard: office, then the name, then review.
await page.goto('/clients/create');
await selectOption(page, 'Office', 'Head Office');
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('textbox', { name: 'First Name' }).fill('E2EDbl');
await page.getByRole('textbox', { name: 'Last Name' }).fill(surname);
await page.getByRole('button', { name: 'Next' }).click();

const posts = await countingPosts(page, /\/clients(\?|$)/, async () => {
await page.getByRole('button', { name: /^save$/i }).dblclick();
// The created client's own page (#798). A form that never submitted stays on
// `/clients/create`, which this does not match — the id segment is what rules that out.
await expect(page).toHaveURL(/\/clients\/view\/\d+$/, { timeout: 20000 });
});

expect(posts).toBe(1);

// Duplicate clients break KYC uniqueness, so the platform side is asserted too.
const response = await api.get(`${API_BASE}/clients?displayName=${surname}&limit=50`);
const body = (await response.json()) as { pageItems?: { displayName?: string }[] };
const matches = (body.pageItems ?? []).filter((client) =>
(client.displayName ?? '').includes(surname),
);
expect(matches).toHaveLength(1);
});
});
9 changes: 7 additions & 2 deletions e2e/e2e-journey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,14 @@ async function mockDashboardCounts(page: Page) {
});
}

// The header leaves out the business date when the instance has none (GET /businessdate
// answers []), so the header checks below need one to be configured.
// The header leaves out the business date when none *governs*: the app asks
// `GET /configurations/name/enable-business-date` before trusting `GET /businessdate`, because
// those rows persist after the feature is switched off and stop being maintained (#758). Both
// have to be answered for the header checks below.
async function mockBusinessDate(page: Page) {
await page.route('**/api/v1/configurations/name/enable-business-date**', async (route) => {
await route.fulfill({ json: { name: 'enable-business-date', enabled: true } });
});
await page.route('**/api/v1/businessdate**', async (route) => {
await route.fulfill({ json: [{ type: 'BUSINESS_DATE', date: [2026, 10, 2] }] });
});
Expand Down
8 changes: 6 additions & 2 deletions e2e/feature-coverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,12 @@ async function loginAndMockApi(page: Page) {
});
});

// The header leaves out the business date when the instance has none, and
// 'header shows logged-in user info' checks for it.
// The header leaves out the business date when none *governs*, and 'header shows logged-in
// user info' checks for it. The app asks whether the feature is enabled before trusting the
// stored rows, which persist after it is switched off (#758), so both are answered here.
await page.route('**/api/v1/configurations/name/enable-business-date**', async (route) => {
await route.fulfill({ json: { name: 'enable-business-date', enabled: true } });
});
await page.route('**/api/v1/businessdate**', async (route) => {
await route.fulfill({ json: [{ type: 'BUSINESS_DATE', date: [2026, 10, 2] }] });
});
Expand Down
8 changes: 8 additions & 0 deletions e2e/loan-delinquency-actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ async function signInWithLoan(page: Page, setup: Setup = {}): Promise<Probe> {
}),
),
);
// A tenant *using* the business date, which is what these cases are about: the app asks
// whether the feature is enabled before trusting the stored rows, because they persist after
// it is switched off and stop being maintained (#758). Without the first mock this tab would
// fall back to the platform's clock, which is the right answer for a tenant without the
// feature and the wrong one for these assertions.
await page.route('**/api/v1/configurations/name/enable-business-date**', (route) =>
route.fulfill(json({ name: 'enable-business-date', enabled: true })),
);
await page.route('**/api/v1/businessdate**', (route) =>
route.fulfill(json([{ type: 'BUSINESS_DATE', date: BUSINESS_DATE }])),
);
Expand Down
78 changes: 78 additions & 0 deletions e2e/tenant-date-defaults-backend.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* #758. Create Client and Create Loan defaulted their dates to the browser's day.
*
* Fineract decides what "the future" is by its own reckoning — the tenant's timezone, or the
* business date when that feature is on — so whenever the browser runs ahead, submitting the
* defaults was refused: `error.msg.clients.submittedOnDate.in.the.future` on the client form,
* and a `403` reading like a permission problem on the loan one. Every client and loan entered
* during those hours failed on first submit, and the user was pushed into editing dates by
* trial and error.
*
* The browser clock is moved a day forward here rather than waited for, with Playwright's clock
* API. `setFixedTime` is deliberate: it changes what `new Date()` answers without replacing the
* timer queue, which Angular and Ionic both run on.
*/

import { test, expect, recordingTimeout } from './fixtures';
import { login } from './utils/fineract-login';
import { selectOption } from './utils/select-option';
import { API_BASE, createApiContext, seedSuffix } from './utils/seed-api';

/** A day after the real one, which is what the defect needs to show itself. */
function tomorrow(): Date {
const date = new Date();
date.setDate(date.getDate() + 1);
return date;
}

test.describe('Date defaults come from the platform, not the browser', () => {
test('a client can be created while the browser is a day ahead', async ({ page }) => {
test.setTimeout(recordingTimeout(240000));
// Fixed before the app loads, so every `new Date()` in it reads the later day.
await page.clock.setFixedTime(tomorrow());

await login(page);
const surname = `AheadClient${seedSuffix()}`;

await page.goto('/clients/create');
await selectOption(page, 'Office', 'Head Office');
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('textbox', { name: 'First Name' }).fill('E2EAhead');
await page.getByRole('textbox', { name: 'Last Name' }).fill(surname);
await page.getByRole('button', { name: 'Next' }).click();

// No date is touched. Before the fix this submitted the browser's tomorrow and the platform
// refused it; the form stayed put with a toast naming an ISO date.
await page.getByRole('button', { name: /^save$/i }).click();
// The created client's own page (#798). A refused save stays on `/clients/create`, which
// this does not match — the id segment is what rules that out.
await expect(page).toHaveURL(/\/clients\/view\/\d+$/, { timeout: 20000 });

// The platform holding the record is the assertion — a navigation alone could mean a toast
// was missed.
const api = await createApiContext();
const response = await api.get(`${API_BASE}/clients?displayName=${surname}&limit=50`);
const body = (await response.json()) as { pageItems?: { displayName?: string }[] };
expect(
(body.pageItems ?? []).filter((client) => (client.displayName ?? '').includes(surname)),
).toHaveLength(1);
});
});
5 changes: 0 additions & 5 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -2714,11 +2714,6 @@
"count": 1
}
},
"src/app/layout/header.component.test.ts": {
"local/no-generated-api-import": {
"count": 1
}
},
"src/app/layout/header.component.ts": {
"local/no-generated-api-import": {
"count": 1
Expand Down
2 changes: 2 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ const BACKEND_SPECS = [
'client-transfer.spec.ts',
'client-legal-form-search.spec.ts',
'deposit-account-servicing.spec.ts',
'double-submit-backend.spec.ts',
'tenant-date-defaults-backend.spec.ts',
'deposit-lifecycle-backend.spec.ts',
'deposit-product-configuration.spec.ts',
'full-demo.spec.ts',
Expand Down
8 changes: 8 additions & 0 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
import { correlationIdInterceptor } from './core/interceptors/correlation-id.interceptor';
import { errorInterceptor } from './core/interceptors/error.interceptor';
import { idempotencyInterceptor } from './core/interceptors/idempotency.interceptor';
import { loadingInterceptor } from './core/interceptors/loading.interceptor';
import { retryInterceptor } from './core/interceptors/retry.interceptor';
import { serverClockInterceptor } from './core/interceptors/server-clock.interceptor';
import { GlobalErrorHandler } from './core/errors/global-error-handler';
import { ConfigService } from './core/services/config.service';
import { BrandingService } from './core/services/branding.service';
Expand Down Expand Up @@ -103,6 +105,12 @@ export const appConfig: ApplicationConfig = {
authInterceptor,
errorInterceptor,
loadingInterceptor,
// After `loadingInterceptor` so a collapsed double submit counts as the one request it
// becomes, and before `retryInterceptor` so a retried attempt carries the same
// idempotency key as the attempt it repeats.
idempotencyInterceptor,
// Last, so it sees the response of every attempt rather than only the first.
serverClockInterceptor,
retryInterceptor,
]),
),
Expand Down
6 changes: 5 additions & 1 deletion src/app/core/adapters/api/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,11 @@
"postGlaccountsUploadtemplate",
"putGlaccountsGlAccountId"
],
"GlobalConfigurationService": ["getConfigurations", "putConfigurationsConfigId"],
"GlobalConfigurationService": [
"getConfigurations",
"getConfigurationsNameName",
"putConfigurationsConfigId"
],
"GroupsLevelService": ["getGrouplevels"],
"GroupsService": [
"getGroups",
Expand Down
11 changes: 11 additions & 0 deletions src/app/core/adapters/api/business-date.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ export interface BusinessDateEntry {
/** Application contract for reading the platform's business dates. */
export interface BusinessDateApi {
getBusinessDates(): Observable<readonly BusinessDateEntry[]>;

/**
* Whether the business-date feature is switched on for this tenant.
*
* Asked because `GET /businessdate` answers either way. The rows persist after the feature is
* disabled, and they stop being maintained — measured on a running Fineract, a tenant with the
* feature off still reported `BUSINESS_DATE 2026-10-17` on the 18th, and
* `POST /businessdate` refused to update it with `business.date.is.not.enabled`. A screen that
* read the row without asking this seeded every date field a day in the past.
*/
isBusinessDateEnabled(): Observable<boolean>;
}

export const BUSINESS_DATE_API = new InjectionToken<BusinessDateApi>('BUSINESS_DATE_API', {
Expand Down
Loading
Loading