Extract PassDashboard Playwright e2e suite into standalone repo
Some checks failed
E2E Tests / test (push) Has been cancelled

Copies the Playwright end-to-end suite out of the PassDashboard app repo so
it can be run and deployed independently. Nothing was removed from the app
repo - this is a copy.

Carried over verbatim: tests/ (10 spec files, 4 helper modules, README) plus
playwright.config.ts and .env.test.example. The suite drives the deployed app
over HTTP and imports nothing from src/, which is what makes it standalone.

New for this repo:
- package.json with only the deps the suite actually uses (@playwright/test,
  allure-playwright, xlsx, typescript, @types/node) and the e2e scripts.
- tsconfig.json covering tests/ - the app repo's tsconfig.app.json only
  included src/, so these files were never typechecked before.
- .gitea/workflows/e2e-tests.yml for Gitea Actions. The app repo derived ENV
  from github.ref_name; that doesn't apply here since this repo has no
  per-environment branches, so the target is an explicit workflow_dispatch
  input and scheduled/push runs default to develop. Reports are uploaded as
  artifacts rather than deleted.
- .gitignore and README covering local setup and the required CI secrets.

The Jest unit tests under src/__tests__/ were deliberately left in the app
repo: they import application source directly (AuthContext, ProtectedRoute,
cryptoUtils, api) and cannot run without it.

Verified: npx tsc --noEmit is clean and playwright collects all 43 tests
across all 10 spec files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 15:50:50 +03:00
commit 7bf4343f88
23 changed files with 6568 additions and 0 deletions

101
tests/README.md Normal file
View File

@@ -0,0 +1,101 @@
# E2E Test Suite
Playwright end-to-end tests for the PassDashboard merchant portal. These
tests drive the real, deployed application over HTTP - they don't run
against a local build - so this folder can be copied into any checkout of
the repo (even a newer one) and will keep working as long as the app's URLs
and selectors haven't changed.
## Folder structure
```
tests/
helpers/
environment.ts # single source of truth for env + credential resolution
auth.ts # login()/loginAs() - reads TEST_CREDENTIALS from environment.ts
navigation.ts # navigateToX(page) helpers for each portal page
allure.ts # thin wrappers around Allure annotations (epic/feature/severity/tag)
auth.spec.ts # login, invalid credentials, required-field validation, logout
dashboard.spec.ts # stat cards, dropdown filter, date-range download, weekly sales chart
transactions.spec.ts # search/filter, date range, details, export, pagination
terminals.spec.ts # details, pagination, download, cashier remove/reassign, add terminal
refunds.spec.ts # search/filter, date range, details, export, pagination
discounts.spec.ts # cross-checks Transactions-page totals against the Discounts page
settlement.spec.ts # download -> format -> upload -> run settlement workflow
management.spec.ts # Users/Branches tabs: add cashier/finance user, add branch, reports
settings.spec.ts # Discount Rate Configuration (Settings > Discount rates tab)
roles.spec.ts # creates Cashier/Finance/Terminal Manager users, logs in as each,
# verifies their actual restricted navigation menu
```
Each spec file corresponds to one page/feature area of the portal. Shared
logic (login, navigation, environment/credential resolution) lives in
`helpers/` so spec files stay focused on the feature they're testing.
## Running against dev / staging / production
Environment and credentials are resolved once, centrally, in
`helpers/environment.ts`. It reads an `ENV` variable (accepts branch names
`develop`/`staging`/`main` - what CI naturally has on hand - or semantic
names `dev`/`staging`/`production`) and picks the matching base URL and
credential pair. `playwright.config.ts` and `helpers/auth.ts` both import
from this one module, so there is nowhere else that needs updating to add
or change an environment.
### Local setup (one-time)
```bash
cp .env.test.example .env.test.local
# then fill in your real credentials in .env.test.local
```
`.env.test.local` is git-ignored (matches the `*.local` pattern already in
`.gitignore`) - it never gets committed, and CI doesn't use it at all (CI
sets the same variable names directly from GitHub Secrets - see
`CI-CD-SETUP.md`).
### Running tests
```bash
npm run test:e2e:dev # https://devpro.babinnovations.com
npm run test:e2e:staging # https://staging.babinnovations.com
npm run test:e2e:prod # https://www.babinnovations.com/neopaas/portal
npm run test:e2e:ui # Playwright's interactive UI mode (uses ENV/.env.test.local as-is)
# Run a single file or a single test by name, same as any Playwright project:
npx playwright test tests/refunds.spec.ts
npx playwright test -g "should paginate through transaction list"
# Watch it run in a real browser window instead of headless:
npx playwright test tests/dashboard.spec.ts --headed
```
### Adding a new environment or changing a URL
Edit `baseURLs` and/or `credentials` in `helpers/environment.ts` - that's
the only file that needs to change. Everything else (playwright.config.ts,
auth.ts, CI workflow) reads through it.
## Adding a new spec file
Playwright is configured with an explicit `testMatch` allowlist in
`playwright.config.ts` (not a wildcard glob) - **new spec files must be
added to that list** or they'll silently never run. This bit us once
already (`settings.spec.ts` and `roles.spec.ts` were invisible to the
runner until added).
## Known gaps / intentionally not covered
- **Reconciliation page** - out of scope for now
- **Bulk disable/enable** (Terminals page) - requires a genuinely active
physical POS terminal to succeed server-side; not reliably reproducible
in the dev environment, so it isn't automated
- **Terminals > Bulk Upload**, **Settings > Account/Response Timeout/
Merchant Token/Trusted devices tabs**, **Dashboard notifications panel**,
**Arabic/RTL rendering**, **Forgot Password / 2FA** - never explored yet
- **Discount rate creation** (Settings > Discount rates > Create) - blocked
by a dev-environment data gap (the Card name dropdown has no options to
select), flagged as a product issue rather than fixed in the test
- A separate Jest unit-test layer exists under `src/__tests__/` - unrelated
to this Playwright suite and not covered by anything above

176
tests/auth.spec.ts Normal file
View File

@@ -0,0 +1,176 @@
import { test, expect } from '@playwright/test';
import { TEST_CREDENTIALS } from './helpers/auth';
import { epic, feature, severity, description } from './helpers/allure';
test.describe('Authentication Tests', () => {
test.setTimeout(90000);
test.beforeEach(async ({ page }) => {
epic('User Authentication');
feature('Login');
// Retry the initial navigation in place - on a loaded local machine a
// single headless navigation can occasionally stall well past one
// timeout even though the server itself responds in ~1-3s.
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await page.goto(TEST_CREDENTIALS.loginPath, { waitUntil: 'domcontentloaded' });
lastError = undefined;
break;
} catch (error) {
lastError = error;
console.log(`Login page navigation attempt ${attempt} failed, retrying...`);
}
}
if (lastError) throw lastError;
await page.waitForTimeout(2000);
// Switch to English by clicking the article element
console.log('Checking for language switcher...');
await page.waitForTimeout(1000);
const languageArticle = page.locator('article.ant-typography.font-poppins-500.css-1kfsfla').first();
const isVisible = await languageArticle.isVisible({ timeout: 2000 }).catch(() => false);
if (isVisible) {
const text = await languageArticle.textContent();
console.log(`Found language button with text: "${text}"`);
console.log('Clicking to switch language...');
await languageArticle.click();
await page.waitForTimeout(2000);
console.log('✓ Language switched');
} else {
console.log('Language button not found');
}
});
test('should successfully login with valid credentials', async ({ page }) => {
severity('critical');
description('Test successful login flow with valid user credentials');
console.log('Testing login with valid credentials...');
await test.step('Fill in username', async () => {
await page.fill('input[type="email"], input[name="email"], input[placeholder*="email" i]', TEST_CREDENTIALS.username);
});
await test.step('Fill in password', async () => {
await page.fill('input[type="password"], input[name="password"]', TEST_CREDENTIALS.password);
});
await test.step('Click login button', async () => {
await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")');
});
await test.step('Verify redirect to dashboard', async () => {
await page.waitForURL('**/dashboard**', { timeout: 30000 }).catch(() => {
console.log('Checking for successful navigation...');
});
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(2000);
const currentUrl = page.url();
console.log('Current URL after login:', currentUrl);
expect(currentUrl).toContain('dashboard');
});
console.log('Login successful');
});
test('should show error with invalid credentials', async ({ page }) => {
severity('critical');
description('Verify error handling for invalid login credentials');
console.log('Testing login with invalid credentials...');
await test.step('Fill in invalid username', async () => {
await page.fill('input[type="email"], input[name="email"], input[placeholder*="email" i]', 'invalid@example.com');
});
await test.step('Fill in invalid password', async () => {
await page.fill('input[type="password"], input[name="password"]', 'wrongpassword');
});
await test.step('Click login button', async () => {
await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")');
});
await test.step('Verify error message appears', async () => {
await page.waitForTimeout(2000);
const errorMessage = await page.locator('text=/error|invalid|incorrect|failed/i').first().isVisible().catch(() => false);
const stillOnLoginPage = page.url().includes('login');
console.log('Error message visible:', errorMessage);
console.log('Still on login page:', stillOnLoginPage);
expect(errorMessage || stillOnLoginPage).toBeTruthy();
});
console.log('Invalid credentials test completed');
});
test('should validate required fields', async ({ page }) => {
severity('normal');
description('Verify that required field validation works correctly');
console.log('Testing required field validation...');
await test.step('Click login without filling fields', async () => {
await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")');
await page.waitForTimeout(1000);
});
await test.step('Verify validation or staying on login page', async () => {
const stillOnLoginPage = page.url().includes('login');
console.log('Still on login page:', stillOnLoginPage);
expect(stillOnLoginPage).toBeTruthy();
});
console.log('Required field validation test completed');
});
test('should successfully logout', async ({ page }) => {
test.setTimeout(90000);
severity('critical');
description('Verify that clicking Logout redirects to login page and clears session');
// First login
await test.step('Login first', async () => {
await page.fill('input[type="email"], input[name="email"], input[placeholder*="email" i]', TEST_CREDENTIALS.username);
await page.fill('input[type="password"], input[name="password"]', TEST_CREDENTIALS.password);
await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")');
await page.waitForURL('**/dashboard**', { timeout: 30000 }).catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
console.log('✓ Logged in, URL:', page.url());
});
await test.step('Click Logout', async () => {
const logoutLink = page.locator('text=Logout').first();
if (!(await logoutLink.isVisible({ timeout: 2500 }).catch(() => false))) {
// Mobile layout might hide logout under a menu; try opening it.
const openMenu = page.locator('button[aria-label="Open menu"], button[aria-label="Open navigation"], button:has-text("Menu"), button:has-text("Open")');
if (await openMenu.isVisible({ timeout: 2500 }).catch(() => false)) {
await openMenu.click();
await page.waitForTimeout(1000);
}
}
await logoutLink.waitFor({ state: 'visible', timeout: 10000 });
await logoutLink.click();
await page.waitForTimeout(3000);
console.log('✓ Logout clicked');
});
await test.step('Verify redirected to login page', async () => {
await page.waitForURL('**/login**', { timeout: 10000 }).catch(() => {});
const currentUrl = page.url();
console.log('URL after logout:', currentUrl);
expect(currentUrl).toContain('login');
});
console.log('✓ Logout test completed');
});
});

422
tests/dashboard.spec.ts Normal file
View File

@@ -0,0 +1,422 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
import { epic, feature, severity, description, tag } from './helpers/allure';
import { navigateToTransactions } from './helpers/navigation';
import * as fs from 'fs';
// Parses either an ISO picker-cell title (YYYY-MM-DD) or a display-format
// table date ("Jul 6, 2026") into a comparable Date.
const toDate = (s: string | null | undefined): Date | null => {
if (!s) return null;
const iso = s.match(/(\d{4})-(\d{2})-(\d{2})/);
if (iso) return new Date(`${iso[1]}-${iso[2]}-${iso[3]}T00:00:00`);
const disp = s.match(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}),\s*(\d{4})/);
if (disp) {
const months: Record<string, string> = {
Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06',
Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12',
};
return new Date(`${disp[3]}-${months[disp[1]]}-${disp[2].padStart(2, '0')}T00:00:00`);
}
return null;
};
const toISO = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
test.describe('Dashboard Tests', () => {
test.setTimeout(90000);
test.beforeEach(async ({ page }) => {
epic('Dashboard');
await login(page);
await page.waitForTimeout(2000);
});
// Functional Tests
test('should use dropdown filter and verify data updates', async ({ page }) => {
feature('Dashboard Operations');
tag('functional');
severity('critical');
description('Test using the branch dropdown filter changes the dashboard\'s selected branch and data');
console.log('Testing dropdown filter functionality...');
// The real filter is the "All branches" Ant Design select at the top of
// the dashboard - generic `select, [role="combobox"]` selectors don't
// reliably target it, and this test previously had no assertions at all.
const branchSelect = page.locator('.ant-select').first();
await expect(branchSelect, 'Branch dropdown should be visible').toBeVisible({ timeout: 10000 });
const initialSelection = (await branchSelect.textContent()) || '';
console.log('Initial branch selection:', initialSelection.trim());
await test.step('Open dropdown and select a specific branch', async () => {
await branchSelect.click();
await page.waitForTimeout(1000);
const options = page.locator('.ant-select-item-option');
const optionCount = await options.count();
console.log('Available options:', optionCount);
expect(optionCount, 'There should be more than one branch option').toBeGreaterThan(1);
// Pick a real named branch, not "All branches"/"-1" itself
const namedOption = options.filter({ hasNotText: /^All branches$/i }).first();
const optionText = (await namedOption.textContent()) || '';
await namedOption.click();
await page.waitForTimeout(2000);
console.log('Selected option:', optionText.trim());
});
await test.step('Verify the dropdown reflects the new selection', async () => {
const updatedSelection = (await branchSelect.textContent()) || '';
console.log('Updated branch selection:', updatedSelection.trim());
expect(updatedSelection.trim(), 'Dropdown display should change after selecting a different branch').not.toBe(initialSelection.trim());
});
console.log('Dropdown filter test completed');
});
test('should select date range and download report with transactions', async ({ page }) => {
test.setTimeout(90000);
feature('Dashboard Operations');
tag('functional');
severity('critical');
description('Download a transaction report for the latest transaction date and verify the file contents');
console.log('Testing date range selection and download report...');
let targetIso = '';
let latestDateObj: Date | null = null;
await test.step('Determine target date from the latest transaction', async () => {
// The Dashboard page itself has no data table to read a date from, so
// briefly check the Transactions page - same anchoring approach used
// elsewhere, since a hardcoded date ("March 2026") eventually falls
// outside the calendar's reachable range as real time moves on.
await navigateToTransactions(page);
await page.waitForTimeout(2000);
const table = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first();
await table.locator('tbody tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 15000 });
const headers = await table.locator('thead th').allTextContents();
const dateIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'init date');
expect(dateIdx, 'An init date column should exist').toBeGreaterThanOrEqual(0);
const firstRowCells = await table.locator('tbody tr.ant-table-row').first().locator('td').allTextContents();
const latestDate = toDate(firstRowCells[dateIdx] || '');
expect(latestDate, 'The newest transaction should have a parseable date').not.toBeNull();
latestDateObj = latestDate;
targetIso = toISO(latestDate!);
console.log(`Target date from latest transaction (${firstRowCells[dateIdx]}): ${targetIso}`);
const dashboardNav = page.locator('button:has-text("Dashboard")').first();
await dashboardNav.click();
await page.waitForTimeout(2000);
});
await test.step('Open Download report modal', async () => {
const downloadButton = page.locator('button:has-text("Download report")').first();
await expect(downloadButton, 'Download report button should be visible').toBeVisible({ timeout: 10000 });
await downloadButton.click();
await page.waitForTimeout(1500);
console.log('✓ Download modal opened');
});
// Picks `iso` as a same-day range in the modal's date picker. Reusable so
// the download step can retry with an earlier date when the first one
// turns out to have no transactions to report on.
const selectSameDayRange = async (iso: string): Promise<void> => {
const dateField = page.locator('[role="dialog"] .ant-picker input[date-range="start"], .ant-modal .ant-picker input[date-range="start"]').first();
await expect(dateField, 'Date range start input should be visible').toBeVisible({ timeout: 10000 });
await dateField.click();
await page.waitForTimeout(1500);
const calendarVisible = await page.locator('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)').isVisible().catch(() => false);
expect(calendarVisible, 'Date range calendar should open').toBe(true);
const prevBtn = page.locator('.ant-picker-header-prev-btn').first();
let found = false;
for (let attempt = 0; attempt < 60; attempt++) {
found = await page.locator(`.ant-picker-cell-in-view[title="${iso}"]:not(.ant-picker-cell-disabled)`).first().isVisible().catch(() => false);
if (found) break;
await prevBtn.click();
await page.waitForTimeout(300);
}
expect(found, `Target date ${iso} should become available in the calendar`).toBe(true);
await page.locator(`.ant-picker-cell-in-view[title="${iso}"]`).first().click();
await page.waitForTimeout(800);
await page.locator(`.ant-picker-cell-in-view[title="${iso}"]`).first().click();
await page.waitForTimeout(1000);
console.log(`✓ Date selected: ${iso}`);
// innerText() on the dialog doesn't capture <input> values, so if the
// download never fires we can't otherwise tell whether the date range
// actually committed to the form - log the real input values and
// whether the calendar dropdown is still open (which would mean the
// range was never confirmed/closed).
const startVal = await page.locator('[role="dialog"] .ant-picker input[date-range="start"], .ant-modal .ant-picker input[date-range="start"]').first().inputValue().catch(() => '(unreadable)');
const endVal = await page.locator('[role="dialog"] .ant-picker input[date-range="end"], .ant-modal .ant-picker input[date-range="end"]').first().inputValue().catch(() => '(unreadable)');
const calendarStillOpen = await page.locator('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)').isVisible().catch(() => false);
console.log(`Date range input values -> start: "${startVal}", end: "${endVal}". Calendar still open: ${calendarStillOpen}`);
};
await test.step('Select the target date (same-day range)', async () => {
await selectSameDayRange(targetIso);
});
await test.step('Select CSV format', async () => {
const csvRadio = page.locator('input.ant-radio-input[value="2"]').first();
if (await csvRadio.isVisible({ timeout: 3000 }).catch(() => false)) {
await csvRadio.click({ force: true });
await page.waitForTimeout(500);
console.log('✓ CSV selected via radio');
} else {
const csvText = page.locator('text=CSV').first();
await expect(csvText, 'CSV option should be available').toBeVisible({ timeout: 5000 });
await csvText.click();
await page.waitForTimeout(500);
console.log('✓ CSV selected via text');
}
});
await test.step('Download and verify the file', async () => {
// Large result sets switch to an async email-delivery flow instead of
// a direct browser download (confirmed behavior of this same report
// feature elsewhere) - a single day is usually small enough to
// download directly, but treat the async message as a legitimate
// outcome too, not a failure.
// Record the report API traffic this click triggers - when neither a
// download nor the async message shows up, the response status is the
// only thing that says whether the request was even made, and how the
// backend answered.
const apiCalls: string[] = [];
page.on('response', (r) => {
if (/report|download|export/i.test(r.url())) {
apiCalls.push(`${r.status()} ${r.request().method()} ${r.url().slice(0, 200)}`);
}
});
const modalDownloadButton = page.locator('[role="dialog"] button:has-text("Download report"), .ant-modal button:has-text("Download")').first();
const clickAndWait = async (): Promise<import('@playwright/test').Download | null> => {
const downloadPromise = page.waitForEvent('download', { timeout: 45000 }).catch(() => null);
await expect(modalDownloadButton, 'Download button in modal should be visible').toBeVisible({ timeout: 5000 });
const btnEnabled = await modalDownloadButton.isEnabled().catch(() => false);
await modalDownloadButton.click();
console.log(`Download button clicked (enabled: ${btnEnabled}), waiting for download...`);
return downloadPromise;
};
let download = await clickAndWait();
// A single day can legitimately have no transactions to report on, in
// which case the backend answers 204 No Content and no file is ever
// produced. Rather than failing, walk back a few days and try again -
// any day with data proves the download works just as well.
for (let daysBack = 2; !download && daysBack <= 6 && latestDateObj; daysBack += 2) {
const fallbackIso = toISO(new Date(
latestDateObj.getFullYear(), latestDateObj.getMonth(), latestDateObj.getDate() - daysBack,
));
console.log(`No file for the selected day (API: ${apiCalls.join(' | ') || 'none'}) - retrying ${daysBack} day(s) earlier: ${fallbackIso}`);
apiCalls.length = 0;
await selectSameDayRange(fallbackIso);
download = await clickAndWait();
}
if (!download) {
const asyncMessage = await page.locator('text=/being generated|download link via email/i').first().isVisible({ timeout: 8000 }).catch(() => false);
if (!asyncMessage) {
// Neither outcome fired - dump what's actually on screen so the
// next failure (if any) says why instead of just "false".
const dialogText = await page.locator('[role="dialog"], .ant-modal-content').first().innerText().catch(() => '(no dialog/modal found)');
console.log('Neither download nor async message appeared. Dialog/modal content:', dialogText.slice(0, 500));
console.log('Report-related API calls seen:', apiCalls.length ? apiCalls.join(' | ') : '(none)');
const toastText = await page.locator('.ant-message, .ant-notification, [role="alert"]').allTextContents().catch(() => []);
console.log('Toast/notification text:', toastText.length ? toastText.join(' | ') : '(none)');
// 204 No Content means the backend had nothing to report for the
// requested range - there is no file to download, so asserting one
// would be wrong. Skip rather than fail, but log the request so a
// wrong requested range still stands out.
const noContent = apiCalls.find((c) => c.startsWith('204'));
test.skip(!!noContent, `Report API returned 204 No Content - nothing to download for the requested range (${noContent})`);
}
expect(asyncMessage, 'Either a direct download should fire, or the async email-report message should appear').toBe(true);
console.log('✓ Result set was too large for a direct download - async email-report flow triggered as expected');
return;
}
const fileName = download.suggestedFilename();
console.log('✓ Download captured! File name:', fileName);
const filePath = await download.path();
expect(filePath, 'Downloaded file should have a local path').toBeTruthy();
const stats = fs.statSync(filePath!);
console.log('File size:', (stats.size / 1024).toFixed(2), 'KB');
expect(stats.size, 'Downloaded file should have content').toBeGreaterThan(100);
if (/\.(csv|txt)$/i.test(fileName)) {
const fileContent = fs.readFileSync(filePath!, 'utf-8');
console.log('File content preview:', fileContent.substring(0, 300));
const hasTransactionData = /transaction|amount|date|payment|id/i.test(fileContent);
expect(hasTransactionData, `Downloaded file should contain transaction data for ${targetIso}`).toBe(true);
} else {
console.log(`Binary file format (${fileName}), skipping text content check`);
}
});
console.log('Download report test completed');
});
test('should click on dashboard card and navigate', async ({ page }) => {
feature('Dashboard Operations');
tag('functional');
severity('critical');
description('Test clicking the Purchase Transactions dashboard card navigates to the Transactions page');
console.log('Testing clickable dashboard cards...');
// The real dashboard stat cards don't use "card"/"widget"/"stat" class
// names (a `[class*="card" i]` selector matches zero elements) - they're
// plain divs styled with Tailwind's `cursor-pointer` utility class.
const purchaseCard = page.locator('article:has-text("Purchase Transactions")').locator('xpath=ancestor::div[contains(@class, "cursor-pointer")]').first();
await expect(purchaseCard, 'Purchase Transactions card should be visible').toBeVisible({ timeout: 10000 });
const urlBefore = page.url();
console.log('URL before click:', urlBefore);
await test.step('Click the Purchase Transactions card', async () => {
await purchaseCard.click();
await page.waitForTimeout(2000);
});
await test.step('Verify navigation to Transactions occurred', async () => {
const urlAfter = page.url();
console.log('URL after click:', urlAfter);
expect(urlAfter, 'Clicking the card should navigate away from the dashboard').not.toBe(urlBefore);
expect(urlAfter, 'Clicking the Purchase Transactions card should navigate to Transactions').toContain('/transactions');
});
console.log('Clickable card test completed');
});
test('should interact with multiple dashboard cards', async ({ page }) => {
feature('Dashboard Operations');
tag('functional');
severity('normal');
description('Test each main dashboard stat card has real content and navigates away when clicked');
console.log('Testing multiple dashboard cards...');
// The 3 real stat cards on this dashboard, confirmed live: Purchase
// Transactions, Refunds, Success Rate. Generic "[class*=card]"-style
// selectors match nothing (these are plain cursor-pointer divs), so we
// target them by their known labels instead.
const cardLabels = ['Purchase Transactions', 'Refunds', 'Success Rate'];
let cardsInteracted = 0;
for (const label of cardLabels) {
await test.step(`Verify and click "${label}" card`, async () => {
const card = page.locator(`article:has-text("${label}")`).locator('xpath=ancestor::div[contains(@class, "cursor-pointer")]').first();
await expect(card, `${label} card should be visible`).toBeVisible({ timeout: 10000 });
const cardText = (await card.textContent()) || '';
console.log(`${label} card content:`, cardText.trim().substring(0, 80));
expect(cardText.trim().length, `${label} card should have real content`).toBeGreaterThan(label.length);
const urlBefore = page.url();
await card.click();
await page.waitForTimeout(1500);
const urlAfter = page.url();
console.log(`${label}: ${urlBefore} -> ${urlAfter}`);
expect(urlAfter, `Clicking ${label} should navigate away from the dashboard`).not.toBe(urlBefore);
cardsInteracted++;
// Back to the dashboard for the next card
const dashboardNav = page.locator('button:has-text("Dashboard")').first();
await dashboardNav.click();
await page.waitForTimeout(1500);
});
}
expect(cardsInteracted, 'All 3 known dashboard cards should have been interacted with').toBe(cardLabels.length);
console.log('Multiple cards test completed');
});
test('should verify dropdown filter options are available', async ({ page }) => {
feature('Dashboard Operations');
tag('functional');
severity('normal');
description('Test that the branch dropdown filter has multiple real branch options available');
console.log('Testing dropdown filter options...');
const branchSelect = page.locator('.ant-select').first();
await expect(branchSelect, 'Branch dropdown should be visible').toBeVisible({ timeout: 10000 });
await branchSelect.click();
await page.waitForTimeout(1000);
const options = page.locator('.ant-select-item-option');
const optionCount = await options.count();
console.log('Dropdown options count:', optionCount);
expect(optionCount, 'There should be more than one branch option').toBeGreaterThan(1);
for (let i = 0; i < Math.min(optionCount, 5); i++) {
console.log(`Option ${i + 1}:`, (await options.nth(i).textContent())?.trim());
}
console.log('Dropdown options test completed');
});
test('should display the Weekly sales chart with day labels', async ({ page }) => {
feature('Dashboard Operations');
tag('functional');
severity('normal');
description('Verify the Weekly sales chart renders all 7 day labels and shows either real data or the correct empty state');
console.log('Testing Weekly sales chart...');
// The dashboard is slow to render on accounts with a large transaction
// history, so give the chart room to appear before asserting on it.
const chartHeading = page.locator('text="Weekly sales"').first();
await expect(chartHeading, 'Weekly sales chart heading should be visible').toBeVisible({ timeout: 30000 });
// Scope to the chart's card container (the heading's ancestor panel)
const chartCard = chartHeading.locator('xpath=ancestor::div[contains(@class,"rounded-3xl")]').first();
await expect(chartCard, 'Weekly sales chart card should be visible').toBeVisible({ timeout: 30000 });
// The card renders immediately with a "Loading..." placeholder while the
// chart data is still being fetched - the day labels only exist once
// that resolves, so wait it out before asserting on them.
await chartCard.locator('text=/^Loading\\.\\.\\.$/').first()
.waitFor({ state: 'hidden', timeout: 30000 }).catch(() => {});
await test.step('Verify all 7 day labels are present', async () => {
const days = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
for (const day of days) {
const dayLabel = chartCard.locator(`text="${day}"`).first();
await expect(dayLabel, `Day label "${day}" should be visible`).toBeVisible({ timeout: 5000 });
}
console.log('✓ All 7 day labels (MON-SUN) are visible');
});
await test.step('Verify the chart shows either real data or the correct empty state', async () => {
const emptyStateText = chartCard.locator('text=/no transactions this week/i').first();
const isEmpty = await emptyStateText.isVisible({ timeout: 3000 }).catch(() => false);
if (isEmpty) {
console.log('✓ Chart is in the empty state - "No transactions this week" correctly shown');
await expect(emptyStateText, 'Empty state message should be visible').toBeVisible();
} else {
const svgElements = chartCard.locator('svg');
const svgCount = await svgElements.count();
console.log(`Chart is populated - found ${svgCount} SVG element(s) rendering the chart`);
expect(svgCount, 'A populated chart should render at least one SVG element').toBeGreaterThan(0);
}
});
console.log('✓ Weekly sales chart test completed');
});
});

545
tests/discounts.spec.ts Normal file
View File

@@ -0,0 +1,545 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
import { epic, feature, severity, description, tag } from './helpers/allure';
import { navigateToTransactions, navigateToDiscounts, navigateToSettings } from './helpers/navigation';
test.describe('Discounts & Fees Tests', () => {
test.setTimeout(300000);
test('should calculate discount and verify against transactions page', async ({ page }) => {
feature('Discounts Operations');
tag('functional');
severity('critical');
description('Get latest transaction date, calculate MDR+VAT+FEE from transactions, then verify matches Discounts page totals');
epic('Discounts & Fees');
let latestDate = '';
let txnTotalMDR = 0, txnTotalVAT = 0, txnTotalFee = 0, txnTotalDiscount = 0;
// Step 1: Go to Transactions page
await test.step('Step 1: Go to Transactions page', async () => {
await login(page);
await navigateToTransactions(page);
await page.waitForTimeout(2000);
console.log('✓ On Transactions page');
});
// Step 2: Find latest date with transactions
await test.step('Step 2: Find latest date with transactions', async () => {
await page.waitForTimeout(1000);
// Scroll the table horizontally to reveal the init date column
const tableContent = page.locator('.ant-table-content').first();
await tableContent.evaluate((element) => {
// Scroll to the right to reveal the init date column
element.scrollLeft = element.scrollWidth;
});
await page.waitForTimeout(500);
console.log('✓ Scrolled table horizontally to reveal init date column');
// Get the first real data row (skip the hidden ant-table-measure-row)
const firstRow = page.locator('tr.ant-table-row').first();
const rowText = await firstRow.textContent();
console.log('Full first row text after scroll:', rowText?.substring(0, 200));
// Extract date from the row (format: "Mon DD, YYYY" like "Jun 19, 2026")
const dateMatch = rowText?.match(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s*\d{4}/);
if (dateMatch) {
latestDate = dateMatch[0];
console.log('\n========== DATE EXTRACTED ==========');
console.log('✓ Latest transaction date:', latestDate);
console.log('====================================\n');
} else {
console.log('⚠ Could not extract date from first row');
throw new Error('Failed to extract date from transaction row');
}
});
// Step 3: Count how many transaction rows fall within the latest date
await test.step('Step 3: Count transactions within the latest date', async () => {
await page.waitForTimeout(500);
const monthRegex = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s*\d{4}/;
let totalMatching = 0;
let currentPage = 1;
let keepPaging = true;
while (keepPaging) {
// Wait for the table body to be present before reading
await page.locator('tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 10000 });
const rows = page.locator('tr.ant-table-row');
const rowCount = await rows.count();
// Capture the first row key on this page (used to detect page change later)
const firstKey = await rows.first().getAttribute('data-row-key');
const lastKey = await rows.nth(rowCount - 1).getAttribute('data-row-key');
// Read the actual page footer ("Page X of Y") for verification
const footer = await page.locator('text=/Page\\s+\\d+\\s+of\\s+\\d+/').first().textContent().catch(() => null);
let pageMatching = 0;
for (let i = 0; i < rowCount; i++) {
const rowText = await rows.nth(i).textContent();
const match = rowText?.match(monthRegex);
const rowDate = match ? match[0] : '';
if (rowDate === latestDate) {
pageMatching++;
}
}
totalMatching += pageMatching;
console.log(`[${footer?.trim() || 'page ' + currentPage}] firstID=${firstKey} lastID=${lastKey} -> ${pageMatching}/${rowCount} on ${latestDate}`);
// If not every row on this page matched, we've reached older dates - stop
if (pageMatching < rowCount) {
keepPaging = false;
break;
}
// All rows matched - go to next page
const nextPage = currentPage + 1;
const nextPageBtn = page.locator(`button:has-text("${nextPage}")`).first();
const hasNextPage = await nextPageBtn.isVisible({ timeout: 2000 }).catch(() => false);
if (!hasNextPage) {
keepPaging = false;
break;
}
await nextPageBtn.click();
// Wait until the first row's key actually changes (confirms the page re-rendered)
await page.waitForFunction(
(prevKey) => {
const row = document.querySelector('tr.ant-table-row');
return row && row.getAttribute('data-row-key') !== prevKey;
},
firstKey,
{ timeout: 10000 }
).catch(() => {
console.log('⚠ Page did not change after clicking next - stopping');
});
currentPage = nextPage;
}
console.log('\n========== ROW COUNT ==========');
console.log(`✓ Total transactions on ${latestDate}: ${totalMatching}`);
console.log('===============================\n');
});
// Step 4: Enable MDR, VAT, Fee columns via Manage columns modal
await test.step('Step 4: Enable MDR, VAT, Fee columns', async () => {
// Open the Manage columns modal
const manageColumnsBtn = page.locator('button:has-text("Manage columns"), button:has-text("columns")').first();
await manageColumnsBtn.click();
await page.waitForTimeout(1000);
console.log('✓ Opened Manage columns modal');
// Scope everything to the modal dialog so we don't hit the page's own search/checkboxes
const modal = page.locator('.ant-modal-content').first();
const searchInput = modal.locator('input[placeholder="Search"]').first();
// Helper: search for a column and enable its checkbox if not already checked
const enableColumn = async (term: string) => {
await searchInput.fill('');
await page.waitForTimeout(300);
await searchInput.fill(term);
await page.waitForTimeout(700);
// After filtering, the matching column's checkbox should be the visible one
const checkbox = modal.locator('input.ant-checkbox-input').first();
const exists = await checkbox.isVisible({ timeout: 3000 }).catch(() => false);
if (!exists) {
console.log(`⚠ No checkbox found for "${term}"`);
return;
}
const isChecked = await checkbox.isChecked().catch(() => false);
if (isChecked) {
console.log(`• "${term}" column already enabled`);
} else {
await checkbox.click();
await page.waitForTimeout(400);
console.log(`✓ Enabled "${term}" column`);
}
};
await enableColumn('mdr');
await enableColumn('vat');
await enableColumn('fee');
// Clear the search before closing
await searchInput.fill('');
await page.waitForTimeout(300);
// Click Done to apply
const doneBtn = modal.locator('button:has-text("Done")').first();
await doneBtn.click();
await page.waitForTimeout(1000);
console.log('✓ Applied column selection (clicked Done)');
});
// Step 5: Sum MDR + VAT + Fee across all transactions on the latest date
await test.step('Step 5: Sum MDR + VAT + Fee for the latest date', async () => {
const monthRegex = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s*\d{4}/;
// Go back to page 1 (confirm the table actually re-renders)
const beforeKey = await page.locator('tr.ant-table-row').first().getAttribute('data-row-key');
const firstPageBtn = page.getByRole('button', { name: '1', exact: true }).first();
if (await firstPageBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await firstPageBtn.scrollIntoViewIfNeeded().catch(() => {});
await firstPageBtn.click();
await page.waitForFunction(
(prevKey) => {
const r = document.querySelector('tr.ant-table-row');
return r && r.getAttribute('data-row-key') !== prevKey;
},
beforeKey,
{ timeout: 10000 }
).catch(() => console.log('⚠ Did not return to page 1 cleanly'));
await page.waitForTimeout(500);
}
console.log(`Back on page 1, first row ID: ${await page.locator('tr.ant-table-row').first().getAttribute('data-row-key')}`);
// Determine column indexes for MDR, VAT, Fee from the table header
const headerCells = page.locator('thead th');
const headerCount = await headerCells.count();
let mdrIdx = -1, vatIdx = -1, feeIdx = -1;
for (let i = 0; i < headerCount; i++) {
const t = (await headerCells.nth(i).textContent())?.trim();
if (t === 'MDR') mdrIdx = i;
else if (t === 'VAT') vatIdx = i;
else if (t === 'Fee') feeIdx = i;
}
console.log(`Column indexes -> MDR:${mdrIdx} VAT:${vatIdx} Fee:${feeIdx}`);
if (mdrIdx < 0 || vatIdx < 0 || feeIdx < 0) {
throw new Error('Could not locate MDR/VAT/Fee columns in header');
}
// Read a numeric value from a cell: the value sits in the last <article>,
// separate from the currency-icon SVG article.
const parseCell = async (cell: ReturnType<typeof page.locator>): Promise<number> => {
const valArticle = cell.locator('article').last();
const txt = (await valArticle.textContent().catch(() => '')) || '';
const num = parseFloat(txt.replace(/[^0-9.\-]/g, ''));
return isNaN(num) ? 0 : num;
};
let totalMDR = 0, totalVAT = 0, totalFee = 0, totalRows = 0;
let currentPage = 1;
let keepPaging = true;
while (keepPaging) {
await page.locator('tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 10000 });
const rows = page.locator('tr.ant-table-row');
const rowCount = await rows.count();
const firstKey = await rows.first().getAttribute('data-row-key');
let pageMatching = 0;
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const rowText = await row.textContent();
const m = rowText?.match(monthRegex);
const rowDate = m ? m[0] : '';
if (rowDate !== latestDate) continue;
const cells = row.locator('td');
const mdr = await parseCell(cells.nth(mdrIdx));
const vat = await parseCell(cells.nth(vatIdx));
const fee = await parseCell(cells.nth(feeIdx));
const rowDiscount = mdr + vat + fee;
totalMDR += mdr;
totalVAT += vat;
totalFee += fee;
totalRows++;
pageMatching++;
const id = await row.getAttribute('data-row-key');
console.log(` Row ${id}: MDR=${mdr.toFixed(2)} VAT=${vat.toFixed(2)} Fee=${fee.toFixed(2)} -> ${rowDiscount.toFixed(2)}`);
}
console.log(`[page ${currentPage}] matched ${pageMatching}/${rowCount} rows`);
// If not every row matched, we've reached older dates - stop
if (pageMatching < rowCount) {
keepPaging = false;
break;
}
// Go to next page
const nextPage = currentPage + 1;
const nextPageBtn = page.getByRole('button', { name: `${nextPage}`, exact: true }).first();
const hasNextPage = await nextPageBtn.isVisible({ timeout: 2000 }).catch(() => false);
if (!hasNextPage) {
keepPaging = false;
break;
}
await nextPageBtn.click();
await page.waitForFunction(
(prevKey) => {
const row = document.querySelector('tr.ant-table-row');
return row && row.getAttribute('data-row-key') !== prevKey;
},
firstKey,
{ timeout: 10000 }
).catch(() => {
console.log('⚠ Page did not change after clicking next - stopping');
});
currentPage = nextPage;
}
const grandTotal = totalMDR + totalVAT + totalFee;
txnTotalMDR = totalMDR;
txnTotalVAT = totalVAT;
txnTotalFee = totalFee;
txnTotalDiscount = grandTotal;
console.log('\n========== DISCOUNT TOTALS (Transactions page) ==========');
console.log(`Date: ${latestDate}`);
console.log(`Rows summed: ${totalRows}`);
console.log(`Total MDR: ${totalMDR.toFixed(2)}`);
console.log(`Total VAT: ${totalVAT.toFixed(2)}`);
console.log(`Total Fee: ${totalFee.toFixed(2)}`);
console.log(`Total Discount (MDR+VAT+Fee): ${grandTotal.toFixed(2)}`);
console.log('=========================================================\n');
});
// Step 6: Go to Discounts page and filter by the transaction date
await test.step('Step 6: Go to Discounts page and filter by date', async () => {
await navigateToDiscounts(page);
await page.waitForTimeout(2000);
console.log('✓ On Discounts & Fees page');
// Convert latestDate ("Jun 19, 2026") to picker title format ("2026-06-19")
const months: Record<string, string> = {
Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06',
Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12',
};
const parts = latestDate.match(/(\w+)\s+(\d{1,2}),\s*(\d{4})/);
if (!parts) {
throw new Error(`Could not parse latestDate: ${latestDate}`);
}
const titleDate = `${parts[3]}-${months[parts[1]]}-${parts[2].padStart(2, '0')}`;
console.log(`Target date cell title: ${titleDate}`);
// Open the date range picker by clicking the start date input
const startInput = page.locator('input[placeholder="Start date"]').first();
await startInput.click();
await page.waitForTimeout(1000);
console.log('✓ Opened date range picker');
// The calendar opens on the current month - page back until the
// target month is in view (this dataset's dates can be well over a
// year old, so the calendar needs more than a couple of clicks back).
const dayCell = page.locator(`td[title="${titleDate}"]`).first();
const prevBtn = page.locator('.ant-picker-header-prev-btn').first();
let dayCellFound = false;
for (let attempt = 0; attempt < 60; attempt++) {
dayCellFound = await dayCell.isVisible().catch(() => false);
if (dayCellFound) break;
await prevBtn.click();
await page.waitForTimeout(300);
}
expect(dayCellFound, `Target date ${titleDate} should become available in the calendar`).toBe(true);
// Click the target day cell for the start of the range
await dayCell.click();
await page.waitForTimeout(500);
console.log('✓ Selected start date');
// Click the same day cell again for the end of the range (single-day filter)
const dayCellEnd = page.locator(`td[title="${titleDate}"]`).first();
await dayCellEnd.click();
await page.waitForTimeout(1500);
console.log('✓ Selected end date (same day)');
// Verify the filter actually took: read back the start/end input values
const startVal = await page.locator('input[date-range="start"], input[placeholder="Start date"]').first().inputValue().catch(() => '');
const endVal = await page.locator('input[date-range="end"], input[placeholder="End date"]').first().inputValue().catch(() => '');
console.log(`Start input value: "${startVal}"`);
console.log(`End input value: "${endVal}"`);
const pickerOpen = await page.locator('.ant-picker-dropdown:visible').count().catch(() => 0);
console.log(`Picker dropdown still open: ${pickerOpen > 0 ? 'yes' : 'no'}`);
if (startVal === titleDate && endVal === titleDate) {
console.log(`✓ Date range correctly applied: ${titleDate} to ${titleDate}`);
} else {
console.log(`⚠ Date range may not have applied correctly (expected ${titleDate} for both)`);
}
console.log(`✓ Filtered Discounts page to ${latestDate}`);
});
// Step 7: Read the Discounts-page total and cross-check against Transactions
await test.step('Step 7: Cross-check total discount', async () => {
await page.waitForTimeout(1500);
const parseNum = (s: string | null | undefined): number => {
const n = parseFloat((s || '').replace(/[^0-9.\-]/g, ''));
return isNaN(n) ? 0 : n;
};
// Top "Total Discount" card (large number) - this is the source of truth
const totalDiscountCard = page.locator('article.text-4xl').first();
const cardText = await totalDiscountCard.textContent().catch(() => '');
const discountPageTotal = parseNum(cardText);
// Scheme Discount Breakdown table (scoped via its heading) for internal detail
const schemeTable = page.locator('xpath=//*[contains(text(),"Scheme Discount Breakdown")]/following::table[1]').first();
let breakdownMDR = 0, breakdownVAT = 0, breakdownFee = 0, breakdownRed = 0;
if (await schemeTable.isVisible({ timeout: 3000 }).catch(() => false)) {
// Map header columns to indexes
const headers = schemeTable.locator('thead th');
const headerCount = await headers.count();
let mdrIdx = -1, vatIdx = -1, feeIdx = -1, dfIdx = -1;
for (let i = 0; i < headerCount; i++) {
const t = (await headers.nth(i).textContent())?.trim().toLowerCase();
if (t === 'mdr') mdrIdx = i;
else if (t === 'vat') vatIdx = i;
else if (t?.includes('processing') || t === 'fee') feeIdx = i;
else if (t?.includes('discounts')) dfIdx = i;
}
// Sum across all card-scheme rows. Each amount cell renders a
// currency-icon SVG (with an embedded <style>.cls-1{fill:#231f20}...)
// in one <article> and the actual number in a later <article> - reading
// the whole <td>'s text (like the naive version below did) drags the
// icon's hex-color digits into the string and corrupts the parsed
// number. Scope to the last <article>, same fix already used for the
// Transactions-page MDR/VAT/Fee columns above.
const readCellNum = async (row: ReturnType<typeof schemeTable.locator>, idx: number): Promise<number> => {
if (idx < 0) return 0;
const cell = row.locator('td').nth(idx);
const valArticle = cell.locator('article').last();
const txt = (await valArticle.textContent().catch(() => '')) || (await cell.textContent().catch(() => '')) || '';
return parseNum(txt);
};
const rows = schemeTable.locator('tbody tr:not([aria-hidden="true"])');
const rowCount = await rows.count();
for (let r = 0; r < rowCount; r++) {
const row = rows.nth(r);
breakdownMDR += await readCellNum(row, mdrIdx);
breakdownVAT += await readCellNum(row, vatIdx);
breakdownFee += await readCellNum(row, feeIdx);
breakdownRed += await readCellNum(row, dfIdx);
}
}
const breakdownSum = breakdownMDR + breakdownVAT + breakdownFee;
console.log('\n========== CROSS-CHECK ==========');
console.log(`Date: ${latestDate}`);
console.log(`Transactions page sum (rounded per-row MDR+VAT+Fee): ${txnTotalDiscount.toFixed(2)}`);
console.log(` - MDR: ${txnTotalMDR.toFixed(2)}, VAT: ${txnTotalVAT.toFixed(2)}, Fee: ${txnTotalFee.toFixed(2)}`);
console.log(`Discounts page Total Discount card: ${discountPageTotal.toFixed(2)}`);
console.log(`Discounts page breakdown -> MDR: ${breakdownMDR.toFixed(2)}, VAT: ${breakdownVAT.toFixed(2)}, Fee: ${breakdownFee.toFixed(2)} (sum ${breakdownSum.toFixed(2)})`);
console.log(`Discounts page breakdown "Discounts & fees" column: ${breakdownRed.toFixed(2)}`);
const info = test.info();
// NOTE: the "Total Discount" card is scoped to the selected date range,
// but the Scheme/Terminal Discount Breakdown tables show all-time
// cumulative totals regardless of the date filter (confirmed against
// the live Terminal Discount Breakdown, which shows values far larger
// than any single day could produce). So card vs breakdown-sum is NOT
// expected to match - this is informational only, not an assertion.
const internalDiff = Math.abs(discountPageTotal - breakdownSum);
if (internalDiff < 0.01) {
console.log(`Discounts page card and breakdown happen to match (${discountPageTotal.toFixed(2)}) - breakdown is all-time, card is date-filtered`);
} else {
console.log(`Discounts page card (date-filtered: ${discountPageTotal.toFixed(2)}) vs breakdown (all-time: ${breakdownSum.toFixed(2)}) - expected to differ, not a bug`);
}
// Cross-check Transactions sum vs Discounts total
const diff = Math.abs(txnTotalDiscount - discountPageTotal);
const summary =
`Date: ${latestDate}\n` +
`Transactions page sum (rounded per-row): ${txnTotalDiscount.toFixed(2)}\n` +
`Discounts page total: ${discountPageTotal.toFixed(2)}\n` +
`Difference: ${diff.toFixed(2)}`;
await info.attach('discount-cross-check', { body: summary, contentType: 'text/plain' });
if (diff < 0.01) {
console.log(`✓ MATCH: totals are equal (${discountPageTotal.toFixed(2)})`);
} else {
const msg = `Calculation mismatch on ${latestDate}: Transactions page sum = ${txnTotalDiscount.toFixed(2)}, Discounts page total = ${discountPageTotal.toFixed(2)} (difference ${diff.toFixed(2)})`;
console.log(`${msg}`);
info.annotations.push({ type: 'mismatch', description: msg });
// Surface as a soft failure so the Playwright report flags it without aborting
expect.soft(txnTotalDiscount, msg).toBeCloseTo(discountPageTotal, 2);
}
console.log('=================================\n');
});
// Step 8: Read configured discount rates from Settings (source of truth)
await test.step('Step 8: Read discount rates from Settings', async () => {
await navigateToSettings(page);
await page.waitForTimeout(2000);
console.log('✓ On Settings page');
// Click the "Discount rates" tab
const discountRatesTab = page.getByText('Discount rates', { exact: true }).first();
await discountRatesTab.click();
await page.waitForTimeout(1500);
console.log('✓ Opened Discount rates');
const parseNum = (s: string | null | undefined): number => {
const n = parseFloat((s || '').replace(/[^0-9.\-]/g, ''));
return isNaN(n) ? 0 : n;
};
// Scope to the Discount Rate Configuration table
const ratesTable = page.locator('xpath=//*[contains(text(),"Discount Rate Configuration")]/following::table[1]').first();
await ratesTable.waitFor({ state: 'visible', timeout: 10000 });
// Map header columns to indexes
const headers = ratesTable.locator('thead th');
const headerCount = await headers.count();
let cardIdx = -1, mdrIdx = -1, vatIdx = -1, capIdx = -1, feeIdx = -1;
for (let i = 0; i < headerCount; i++) {
const t = (await headers.nth(i).textContent())?.trim().toLowerCase();
if (t === 'card') cardIdx = i;
else if (t === 'mdr') mdrIdx = i;
else if (t === 'vat') vatIdx = i;
else if (t?.includes('cap')) capIdx = i;
else if (t?.includes('processing') || t === 'fee') feeIdx = i;
}
console.log(`Rate columns -> card:${cardIdx} mdr:${mdrIdx} vat:${vatIdx} cap:${capIdx} fee:${feeIdx}`);
const rateMap: Record<string, { mdr: number; vat: number; cap: number; fee: number }> = {};
const rows = ratesTable.locator('tbody tr');
const rowCount = await rows.count();
for (let r = 0; r < rowCount; r++) {
const cellTexts = await rows.nth(r).locator('td').allTextContents();
const cardText = cellTexts[cardIdx] || '';
// Extract the English card-scheme name after the "/"
const nameMatch = cardText.match(/\/\s*([A-Za-z]+)/);
const cardName = (nameMatch ? nameMatch[1] : cardText.trim()).toLowerCase();
const mdr = parseNum(cellTexts[mdrIdx]);
const vat = parseNum(cellTexts[vatIdx]);
const cap = capIdx >= 0 ? parseNum(cellTexts[capIdx]) : 0;
const fee = feeIdx >= 0 ? parseNum(cellTexts[feeIdx]) : 0;
rateMap[cardName] = { mdr, vat, cap, fee };
console.log(` ${cardName}: MDR ${mdr}% | VAT ${vat}% | Cap ${cap} | Fee ${fee}%`);
}
console.log('\n========== DISCOUNT RATES (Settings) ==========');
console.log(JSON.stringify(rateMap, null, 2));
console.log('===============================================\n');
});
});
});

29
tests/helpers/allure.ts Normal file
View File

@@ -0,0 +1,29 @@
import { test } from '@playwright/test';
export function epic(name: string) {
return test.info().annotations.push({ type: 'epic', description: name });
}
export function feature(name: string) {
return test.info().annotations.push({ type: 'feature', description: name });
}
export function story(name: string) {
return test.info().annotations.push({ type: 'story', description: name });
}
export function severity(level: 'blocker' | 'critical' | 'normal' | 'minor' | 'trivial') {
return test.info().annotations.push({ type: 'severity', description: level });
}
export function tag(...tags: string[]) {
tags.forEach(t => test.info().annotations.push({ type: 'tag', description: t }));
}
export function description(text: string) {
return test.info().annotations.push({ type: 'description', description: text });
}
export function step<T>(name: string, body: () => Promise<T>): Promise<T> {
return test.step(name, body);
}

81
tests/helpers/auth.ts Normal file
View File

@@ -0,0 +1,81 @@
import { Page } from '@playwright/test';
import { getEnvironmentConfig } from './environment';
const env = getEnvironmentConfig();
export const TEST_CREDENTIALS = {
username: env.username,
password: env.password,
loginPath: process.env.TEST_LOGIN_PATH || '/neopaas/portal/login',
dashboardPath: '/dashboard',
};
export async function loginAs(page: Page, identifier: string, password: string): Promise<void> {
console.log('Starting login process...');
// The initial navigation is the most failure-prone step on a loaded local
// machine (headless browser launch/navigation can occasionally stall well
// past a single timeout even though the server itself responds in ~1-3s) -
// retry it in place rather than letting one slow navigation fail the whole
// test and force an expensive full test-level retry.
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await page.goto(TEST_CREDENTIALS.loginPath, { waitUntil: 'domcontentloaded' });
lastError = undefined;
break;
} catch (error) {
lastError = error;
console.log(`Login page navigation attempt ${attempt} failed, retrying...`);
}
}
if (lastError) throw lastError;
await page.waitForTimeout(2000);
// The button's own text is the language you'd switch TO, not the current
// one - so it reads "English" while the page is in Arabic (click to go to
// English), and "العربية" once already in English. The language preference
// persists across logout/login (e.g. via localStorage), so on a second
// login within the same test the page may already be in English - only
// click when the button would actually switch TO English.
console.log('Checking for language switcher...');
const languageArticle = page.locator('article.ant-typography.font-poppins-500.css-1kfsfla').first();
const isVisible = await languageArticle.isVisible({ timeout: 2000 }).catch(() => false);
if (isVisible) {
const text = (await languageArticle.textContent())?.trim();
console.log(`Found language button with text: "${text}"`);
if (text === 'English') {
console.log('Clicking to switch language to English...');
await languageArticle.click();
await page.waitForTimeout(2000);
console.log('✓ Language switched');
} else {
console.log('Already in English - no switch needed');
}
}
console.log('Filling username...');
await page.fill('input[type="email"], input[type="text"], input[name="email"], input[name="username"], input[placeholder*="email" i], input[placeholder*="username" i]', identifier);
console.log('Filling password...');
await page.fill('input[type="password"], input[name="password"]', password);
console.log('Clicking login button...');
await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")');
console.log('Waiting for redirect...');
await page.waitForURL(/\/(dashboard|terminals)/, { timeout: 30000 }).catch(() => {
console.log('Post-login URL not detected, checking for navigation...');
});
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForTimeout(2000);
console.log('Login completed successfully');
}
export async function login(page: Page): Promise<void> {
await loginAs(page, TEST_CREDENTIALS.username, TEST_CREDENTIALS.password);
}

View File

@@ -0,0 +1,97 @@
import * as fs from 'fs';
import * as path from 'path';
// Loads KEY=VALUE pairs from a local .env-style file into process.env,
// without overwriting anything already set (real env vars / CI secrets
// always win over the file). No external dependency needed for this - the
// format is simple enough to parse directly.
function loadLocalEnvFile(filePath: string): void {
if (!fs.existsSync(filePath)) return;
const content = fs.readFileSync(filePath, 'utf-8');
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eqIdx = line.indexOf('=');
if (eqIdx === -1) continue;
const key = line.slice(0, eqIdx).trim();
let value = line.slice(eqIdx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (key && process.env[key] === undefined) {
process.env[key] = value;
}
}
}
// .env.test.local is git-ignored (matches the existing `*.local` gitignore
// pattern) - it holds real local credentials and is never required in CI,
// where GitHub Secrets populate the same variable names directly.
// Resolved from process.cwd() (not __dirname/import.meta.url) since this
// project runs as an ES module, where __dirname isn't available - Playwright
// and its config are always invoked from the project root anyway.
loadLocalEnvFile(path.resolve(process.cwd(), '.env.test.local'));
export type EnvName = 'dev' | 'staging' | 'production';
// Accepts both branch names (develop/staging/main - what CI sets ENV to,
// since e2e-tests.yml derives it from github.ref_name) and semantic names
// (dev/staging/production), so the same ENV value works whether it came
// from a branch push or a manual `ENV=production npm run test:e2e:prod`.
const ENV_ALIASES: Record<string, EnvName> = {
develop: 'dev', dev: 'dev', development: 'dev', devpro: 'dev', local: 'dev', localenv: 'dev',
staging: 'staging', stage: 'staging',
main: 'production', production: 'production', prod: 'production',
};
export function resolveEnvName(): EnvName {
const raw = (process.env.ENV || 'develop').toLowerCase();
return ENV_ALIASES[raw] || 'dev';
}
export interface EnvironmentConfig {
envName: EnvName;
baseURL: string;
username: string;
password: string;
}
export function getEnvironmentConfig(): EnvironmentConfig {
const envName = resolveEnvName();
// dev and staging are genuinely separate deployments/branches - dev has
// its own DEV_URL now (falls back to the old shared stagingenv URL if
// DEV_URL isn't set, e.g. for local runs that haven't configured it yet).
const baseURLs: Record<EnvName, string> = {
dev: process.env.DEV_URL || 'https://stagingenv.babinnovations.com',
staging: 'https://stagingenv.babinnovations.com',
production: 'https://www.babinnovations.com/neopaas/portal',
};
// dev prefers its own DEV_USERNAME/PASSWORD, falling back to the older
// shared TEST_USERNAME/PASSWORD pair for compatibility with existing local
// .env.test.local setups. Staging falls back to that same shared pair
// unless STAGING_TEST_* is explicitly set. Production always requires its
// own PROD_TEST_* pair.
const credentials: Record<EnvName, { username: string; password: string }> = {
dev: {
username: process.env.DEV_USERNAME || process.env.TEST_USERNAME || '',
password: process.env.DEV_PASSWORD || process.env.TEST_PASSWORD || '',
},
staging: {
username: process.env.STAGING_TEST_USERNAME || process.env.TEST_USERNAME || '',
password: process.env.STAGING_TEST_PASSWORD || process.env.TEST_PASSWORD || '',
},
production: {
username: process.env.PROD_TEST_USERNAME || '',
password: process.env.PROD_TEST_PASSWORD || '',
},
};
return {
envName,
baseURL: process.env.BASE_URL || baseURLs[envName],
username: credentials[envName].username,
password: credentials[envName].password,
};
}

172
tests/helpers/navigation.ts Normal file
View File

@@ -0,0 +1,172 @@
import { Page } from '@playwright/test';
/**
* Navigation helper that tries multiple methods to reach a page
* 1. Try clicking navigation link
* 2. Fall back to direct URL navigation
*/
export async function navigateToPage(
page: Page,
linkSelectors: string[],
urlPath: string,
pageName: string
): Promise<void> {
console.log(`Navigating to ${pageName}...`);
// Try clicking navigation links first
for (const selector of linkSelectors) {
const link = page.locator(selector).first();
const isVisible = await link.isVisible({ timeout: 3000 }).catch(() => false);
if (isVisible) {
console.log(`Found ${pageName} link, clicking...`);
await link.click();
await page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(1000);
// Verify we're on the right page
if (page.url().toLowerCase().includes(urlPath.toLowerCase())) {
console.log(`Successfully navigated to ${pageName} via link`);
return;
}
}
}
// Fall back to direct URL navigation
console.log(`${pageName} link not found, using direct URL navigation...`);
try {
await page.goto(urlPath, { timeout: 15000, waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
console.log(`Successfully navigated to ${pageName} via URL`);
} catch (error) {
console.log(`Failed to navigate to ${urlPath}:`, error);
// Try with base URL
const baseUrl = page.context().browser()?.contexts()[0]?.pages()[0]?.url() || 'https://devpro.babinnovations.com';
const fullUrl = new URL(urlPath, baseUrl).href;
console.log(`Retrying with full URL: ${fullUrl}`);
await page.goto(fullUrl, { timeout: 15000, waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
console.log(`Successfully navigated to ${pageName} via full URL`);
}
}
export async function navigateToTransactions(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Transaction")',
'a:has-text("Transactions")',
'[href*="transaction" i]',
'nav a:has-text("Trans")',
],
'/transactions',
'Transactions'
);
}
export async function navigateToTerminals(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Terminal")',
'a:has-text("Terminals")',
'[href*="terminal" i]',
'nav a:has-text("Term")',
],
'/terminals',
'Terminals'
);
}
export async function navigateToRefunds(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Refund")',
'a:has-text("Refunds")',
'[href*="refund" i]',
'nav a:has-text("Ref")',
],
'/refunds',
'Refunds'
);
}
export async function navigateToReconciliation(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Reconcil")',
'a:has-text("Reconciliation")',
'[href*="reconcil" i]',
],
'/reconciliation',
'Reconciliation'
);
}
export async function navigateToManagement(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Manage")',
'a:has-text("Management")',
'[href*="manage" i]',
],
'/management',
'Management'
);
}
export async function navigateToAdmin(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Admin")',
'[href*="admin" i]',
],
'/admin',
'Admin'
);
}
export async function navigateToSettlement(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Settlement")',
'[href*="settlement" i]',
'nav a:has-text("Settle")',
],
'/settlement',
'Settlement'
);
}
export async function navigateToDiscounts(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Discount")',
'a:has-text("Discounts")',
'[href*="discount" i]',
'nav a:has-text("Disc")',
],
'/discounts',
'Discounts & Fees'
);
}
export async function navigateToSettings(page: Page): Promise<void> {
await navigateToPage(
page,
[
'a:has-text("Settings")',
'[href*="settings" i]',
'nav a:has-text("Setting")',
],
'/settings',
'Settings'
);
}

1206
tests/management.spec.ts Normal file

File diff suppressed because it is too large Load Diff

836
tests/refunds.spec.ts Normal file
View File

@@ -0,0 +1,836 @@
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import { login } from './helpers/auth';
import { epic, feature, severity, description, tag } from './helpers/allure';
import { navigateToRefunds } from './helpers/navigation';
// Parses either an ISO picker-cell title (YYYY-MM-DD) or a display-format
// table date ("Apr 26, 2026") into a comparable Date.
const toDate = (s: string | null | undefined): Date | null => {
if (!s) return null;
const iso = s.match(/(\d{4})-(\d{2})-(\d{2})/);
if (iso) return new Date(`${iso[1]}-${iso[2]}-${iso[3]}T00:00:00`);
const disp = s.match(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}),\s*(\d{4})/);
if (disp) {
const months: Record<string, string> = {
Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06',
Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12',
};
return new Date(`${disp[3]}-${months[disp[1]]}-${disp[2].padStart(2, '0')}T00:00:00`);
}
return null;
};
const toISO = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
test.describe('Refund Tests', () => {
test.setTimeout(90000);
test.beforeEach(async ({ page }) => {
epic('Refund Management');
await login(page);
await navigateToRefunds(page);
await page.waitForTimeout(2000);
});
test('should have filter or search functionality', async ({ page }) => {
feature('Refund Display');
severity('normal');
description('Verify that filter or search controls are available and can search by RRN');
console.log('Checking for filter/search functionality...');
let rrnValue = '';
await test.step('Get RRN from refunds (check multiple pages)', async () => {
// Wait for table to load properly
await page.waitForTimeout(2000);
// Check pages 1, 2, and 3 for RRN
for (let pageNum = 1; pageNum <= 3 && !rrnValue; pageNum++) {
if (pageNum > 1) {
console.log(`Navigating to page ${pageNum}...`);
const pageButton = page.locator(`button:has-text("${pageNum}")`).first();
const isVisible = await pageButton.isVisible().catch(() => false);
if (isVisible) {
await pageButton.click();
await page.waitForTimeout(3000);
console.log(`✓ Navigated to page ${pageNum}`);
} else {
console.log(`Page ${pageNum} button not found`);
break;
}
}
// Get all table rows
const rows = page.locator('table tbody tr');
const rowCount = await rows.count();
console.log(`Page ${pageNum}: ${rowCount} rows`);
// Check each row for RRN column (usually 9th column based on structure)
for (let i = 0; i < Math.min(rowCount, 15); i++) {
try {
const row = rows.nth(i);
const cells = row.locator('td');
const cellCount = await cells.count();
// RRN is typically in column 8 (0-indexed) based on: ID, Amount, Net, Status, Date, Terminal Id, Type, Card Type, RRN
if (cellCount >= 9) {
const rrnCell = cells.nth(8);
const rrnText = await rrnCell.textContent({ timeout: 1000 });
if (rrnText && rrnText.trim() !== '-' && /^\d{12}$/.test(rrnText.trim())) {
rrnValue = rrnText.trim();
console.log(`Found RRN on page ${pageNum}, row ${i + 1}:`, rrnValue);
break;
}
}
} catch (e) {
continue;
}
}
}
// Navigate back to page 1 for search
if (rrnValue) {
console.log('Navigating back to page 1 to perform search...');
const page1Button = page.locator('button:has-text("1")').first();
const page1Visible = await page1Button.isVisible().catch(() => false);
if (page1Visible) {
await page1Button.click();
await page.waitForTimeout(3000);
console.log('✓ Back on page 1');
}
} else {
console.log('Could not extract RRN from pages 1-3, will skip search test');
}
});
await test.step('Verify search input exists', async () => {
const searchInput = await page.locator('input[type="search"], input[placeholder*="search" i]').first().isVisible().catch(() => false);
const filterButton = await page.locator('button:has-text("Filter"), [class*="filter" i]').first().isVisible().catch(() => false);
console.log('Search input visible:', searchInput);
console.log('Filter button visible:', filterButton);
expect(searchInput || filterButton).toBeTruthy();
});
await test.step(`Search by RRN: ${rrnValue}`, async () => {
if (!rrnValue) {
console.log('No RRN value to search, skipping search test');
return;
}
const searchInput = page.locator('input[type="search"], input[placeholder*="search" i]').first();
const isVisible = await searchInput.isVisible().catch(() => false);
if (isVisible) {
console.log(`Entering RRN: ${rrnValue} in search field...`);
await searchInput.clear();
await searchInput.fill(rrnValue);
await page.waitForTimeout(1000);
await page.keyboard.press('Enter');
await page.waitForTimeout(3000);
console.log('Search triggered');
const resultCount = await page.locator('table tbody tr').count();
console.log('Search result count:', resultCount);
const rrnFound = await page.locator(`text=${rrnValue}`).first().isVisible().catch(() => false);
console.log(`RRN ${rrnValue} found in results:`, rrnFound);
if (rrnFound) {
console.log('✓ Search by RRN successful');
expect(rrnFound).toBeTruthy();
} else if (resultCount > 0) {
console.log('⚠ Results found but RRN not visible (may be in different format)');
expect(resultCount).toBeGreaterThan(0);
} else {
console.log(`⚠ No results found for RRN ${rrnValue}`);
}
} else {
console.log('Search input not found');
}
});
console.log('Filter/search functionality verified');
});
// Functional Tests
test('should filter refunds by date range', async ({ page }) => {
test.setTimeout(90000);
feature('Refund Operations');
tag('functional');
severity('critical');
description('Test filtering refunds by selecting a valid date range, then assert every returned row falls within that range');
console.log('Testing date range filter...');
let initialRowCount = 0;
let filteredRowCount = 0;
let startTitle = '';
let endTitle = '';
await test.step('Count initial refunds', async () => {
initialRowCount = await page.locator('table tbody tr').count();
console.log('Initial refund count:', initialRowCount);
});
await test.step('Open filter controls', async () => {
const filterButton = page.locator('button:has-text("Filter"), [class*="filter" i]').first();
const isVisible = await filterButton.isVisible().catch(() => false);
if (isVisible) {
await filterButton.click();
await page.waitForTimeout(1000);
console.log('Filter controls opened');
}
});
await test.step('Click date range picker to open calendar', async () => {
const datePickerSelectors = [
'input[placeholder="startDate"]',
'input[date-range="start"]',
'.ant-picker input[date-range="start"]',
'input[type="date"]',
'input[placeholder*="date" i]'
];
let calendarOpened = false;
for (const selector of datePickerSelectors) {
const dateField = page.locator(selector).first();
const isVisible = await dateField.isVisible().catch(() => false);
if (isVisible) {
console.log(`Found date picker with selector: ${selector}`);
await dateField.click();
await page.waitForTimeout(1500);
const calendarVisible = await page.locator('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)').isVisible().catch(() => false);
console.log('Calendar opened:', calendarVisible);
if (calendarVisible) {
calendarOpened = true;
break;
}
}
}
expect(calendarOpened, 'Date range calendar should open').toBe(true);
});
await test.step('Select a valid ordered date range (start <= end)', async () => {
// Collect all selectable in-view day cells and sort their dates ascending
const cells = page.locator('.ant-picker-cell-in-view:not(.ant-picker-cell-disabled)');
const titles: string[] = (await cells.evaluateAll(
(els) => els.map((e) => e.getAttribute('title')).filter((t): t is string => !!t)
));
const unique = Array.from(new Set(titles)).sort(); // ISO strings sort chronologically
expect(unique.length, 'There should be selectable date cells').toBeGreaterThan(1);
// Pick a forward range: earliest available as start, a later date as end
startTitle = unique[0];
endTitle = unique[Math.min(unique.length - 1, 20)];
// Guarantee start <= end
if (toDate(startTitle)! > toDate(endTitle)!) {
[startTitle, endTitle] = [endTitle, startTitle];
}
console.log(`Range -> start: ${startTitle}, end: ${endTitle}`);
// Click start then end by exact title
await page.locator(`.ant-picker-cell-in-view[title="${startTitle}"]`).first().click();
await page.waitForTimeout(800);
await page.locator(`.ant-picker-cell-in-view[title="${endTitle}"]`).first().click();
await page.waitForTimeout(1000);
console.log('✓ Start and end dates selected');
// Assert the range is valid and ordered
const startDate = toDate(startTitle);
const endDate = toDate(endTitle);
expect(startDate, 'Start date should be valid').not.toBeNull();
expect(endDate, 'End date should be valid').not.toBeNull();
expect(startDate!.getTime(), 'Start should be on/before end').toBeLessThanOrEqual(endDate!.getTime());
});
await test.step('Apply filter', async () => {
const applyButton = page.locator('button:has-text("Apply"), button:has-text("Search"), button[type="submit"]').first();
const isVisible = await applyButton.isVisible().catch(() => false);
if (isVisible) {
await applyButton.click();
await page.waitForTimeout(3000);
console.log('Filter applied');
}
// Close any lingering date-picker dropdown so it doesn't interfere with reads
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(500);
});
await test.step('Assert every returned row date is valid and within range', async () => {
const startDate = toDate(startTitle)!;
const endDate = toDate(endTitle)!;
// Make the end inclusive through the whole day
endDate.setHours(23, 59, 59, 999);
// Scope to the refunds data table (the "RRN" header is unique to it,
// which avoids matching the date-picker calendar tables).
const dataTable = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first();
const headers = await dataTable.locator('thead th').allTextContents();
const dateIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'date');
console.log(`Date column index: ${dateIdx} (headers: ${headers.join(' | ')})`);
expect(dateIdx, 'A date column should exist').toBeGreaterThanOrEqual(0);
const rows = dataTable.locator('tbody tr.ant-table-row');
const rowCount = await rows.count();
filteredRowCount = rowCount;
console.log(`Validating ${rowCount} rows are within ${startTitle} .. ${endTitle}`);
let checked = 0;
for (let i = 0; i < rowCount; i++) {
const cellTexts = await rows.nth(i).locator('td').allTextContents();
const cellText = cellTexts[dateIdx] || '';
const rowDate = toDate(cellText);
// Each row must expose a valid, parseable date
expect(rowDate, `Row ${i + 1} should have a valid date (got "${cellText}")`).not.toBeNull();
// And it must fall within the selected range
const t = rowDate!.getTime();
expect(
t >= startDate.getTime() && t <= endDate.getTime(),
`Row ${i + 1} date ${cellText} should be within ${startTitle}..${endTitle}`
).toBe(true);
checked++;
}
console.log(`✓ All ${checked} returned rows fall within the selected range`);
});
console.log('Date range filter test completed');
});
test('should view refund details', async ({ page }) => {
test.setTimeout(90000);
feature('Refund Operations');
tag('functional');
severity('critical');
description('Test clicking on Receipt button to view refund details and verify data matches');
console.log('Testing refund details view...');
let refundId = '';
let refundAmount = '';
let refundStatus = '';
await test.step('Capture refund data from table row', async () => {
await page.waitForTimeout(2000);
// Scope to the refunds data table via its unique "RRN" header
const dataTable = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first();
const headers = await dataTable.locator('thead th').allTextContents();
const idIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'id');
const amtIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'amount');
const statusIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'status');
console.log(`Columns -> id:${idIdx} amount:${amtIdx} status:${statusIdx}`);
const noData = await dataTable.locator('tbody', { hasText: 'No data' }).first().isVisible({ timeout: 5000 }).catch(() => false);
test.skip(noData, 'No refund records available in this environment to view details for');
// First real data row that has a Receipt button
const firstRow = dataTable.locator('tbody tr.ant-table-row:has(button:has-text("Receipt"))').first();
await expect(firstRow, 'A refund row with a Receipt button should exist').toBeVisible({ timeout: 10000 });
const cellTexts = await firstRow.locator('td').allTextContents();
refundId = (cellTexts[idIdx] || '').replace(/\D/g, '').trim();
const amtMatch = (cellTexts[amtIdx] || '').match(/(\d+\.\d{2})/);
refundAmount = amtMatch ? amtMatch[1] : '';
refundStatus = (cellTexts[statusIdx] || '').trim();
console.log(`Captured -> ID: ${refundId}, Amount: ${refundAmount}, Status: ${refundStatus}`);
// Assert we actually captured meaningful row data
expect(refundId, 'Refund ID should be captured').toMatch(/^\d+$/);
expect(refundAmount, 'Refund amount should be captured').toMatch(/^\d+\.\d{2}$/);
expect(refundStatus.length, 'Refund status should be captured').toBeGreaterThan(0);
});
await test.step('Click on Receipt button', async () => {
const receiptButton = page.locator('table tbody tr button:has-text("Receipt")').first();
await expect(receiptButton, 'Receipt button should be visible').toBeVisible({ timeout: 10000 });
console.log('Clicking Receipt button...');
await receiptButton.click();
await page.waitForTimeout(2500);
console.log('✓ Receipt button clicked');
});
await test.step('Verify refund details match captured row', async () => {
// The receipt opens in a modal/drawer/dialog
const panel = page.locator('.ant-modal-content, .ant-drawer-content, [role="dialog"]').first();
await expect(panel, 'Refund details panel should open').toBeVisible({ timeout: 10000 });
const panelText = (await panel.textContent()) || '';
console.log('Panel text (first 300):', panelText.substring(0, 300));
// Cross-check the captured values appear in the opened details
expect(panelText, `Details panel should contain captured ID ${refundId}`).toContain(refundId);
expect(panelText, `Details panel should contain captured amount ${refundAmount}`).toContain(refundAmount);
// Status may be rendered differently; surface as a soft check
if (refundStatus) {
expect.soft(panelText, `Details panel should contain status ${refundStatus}`).toContain(refundStatus);
}
console.log('✓ Details panel matches captured row data');
});
console.log('Refund details view test completed');
});
test('should export refunds', async ({ page }) => {
test.setTimeout(90000);
feature('Refund Operations');
tag('functional');
severity('normal');
description('Export refunds as CSV for a date range and verify the file contains the refund transactions shown in the table');
console.log('Testing refund export (CSV) + content cross-check...');
let downloadSuccessful = false;
let fileName = '';
let startTitle = '';
let endTitle = '';
const monthMap: Record<string, string> = { Jan:'01',Feb:'02',Mar:'03',Apr:'04',May:'05',Jun:'06',Jul:'07',Aug:'08',Sep:'09',Oct:'10',Nov:'11',Dec:'12' };
const expectedIds: string[] = [];
const toISO = (s: string): string | null => {
const m = s.match(/([A-Za-z]{3})\s+(\d{1,2}),\s*(\d{4})/);
if (!m) return null;
return `${m[3]}-${monthMap[m[1]]}-${m[2].padStart(2, '0')}`;
};
// Determine the date range from the first table row, then capture the IDs
// of all rows that fall within that range so we can verify them in the CSV.
await test.step('Capture all refund rows in range across pages', async () => {
const dataTable = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first();
const noData = await dataTable.locator('tbody', { hasText: 'No data' }).first().isVisible({ timeout: 5000 }).catch(() => false);
test.skip(noData, 'No refund records available in this environment to export');
// Wait for the refunds table to actually render its rows
await dataTable.locator('tbody tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 15000 });
const headers = await dataTable.locator('thead th').allTextContents();
const idIdx = headers.findIndex(h => h.trim().toLowerCase() === 'id');
const dateIdx = headers.findIndex(h => h.trim().toLowerCase() === 'date');
expect(idIdx, 'ID column should exist').toBeGreaterThanOrEqual(0);
expect(dateIdx, 'Date column should exist').toBeGreaterThanOrEqual(0);
// Use the first (newest) row's date to define a same-month range: 1st -> that day
const firstRowCells = await dataTable.locator('tbody tr.ant-table-row').first().locator('td').allTextContents();
const firstIso = toISO(firstRowCells[dateIdx] || '');
expect(firstIso, 'First row should have a parseable date').not.toBeNull();
const [yy, mm, dd] = firstIso!.split('-');
startTitle = `${yy}-${mm}-01`;
endTitle = `${yy}-${mm}-${dd}`;
console.log(`Target range: ${startTitle} .. ${endTitle}`);
// Log the total results indicator if present ("... of N results")
const totalText = await page.locator('text=/of\\s+[\\d,]+\\s+results/i').first().textContent().catch(() => null);
console.log(`Results indicator: ${totalText ? totalText.trim() : 'not found'}`);
// Paginate (list is newest-first) collecting every in-range refund ID.
// Stop when a row older than the range start appears, or no next page.
let currentPage = 1;
let keepPaging = true;
const maxPages = 50; // safety cap
while (keepPaging && currentPage <= maxPages) {
const rows = dataTable.locator('tbody tr.ant-table-row');
await rows.first().waitFor({ state: 'visible', timeout: 10000 });
const rowCount = await rows.count();
const firstCellsForKey = await rows.first().locator('td').allTextContents();
const firstRowId = (firstCellsForKey[idIdx] || '').replace(/\D/g, '').trim();
let sawOlder = false;
let inRangeThisPage = 0;
const pageDates: string[] = [];
for (let i = 0; i < rowCount; i++) {
const cells = await rows.nth(i).locator('td').allTextContents();
const iso = toISO(cells[dateIdx] || '');
const id = (cells[idIdx] || '').replace(/\D/g, '').trim();
if (!iso) continue;
pageDates.push(iso);
if (iso < startTitle) { sawOlder = true; continue; } // older than range
if (iso > endTitle) continue; // newer than range (skip)
if (id) { expectedIds.push(id); inRangeThisPage++; }
}
const minDate = pageDates.length ? pageDates.reduce((a, b) => (a < b ? a : b)) : 'n/a';
const maxDate = pageDates.length ? pageDates.reduce((a, b) => (a > b ? a : b)) : 'n/a';
console.log(`Page ${currentPage}: ${rowCount} total rows, dates ${minDate}..${maxDate}, ${inRangeThisPage} in-range`);
// Once we hit dates older than the range start, no later pages can be in range
if (sawOlder) { keepPaging = false; break; }
// Move to the next page if it exists. Pagination number buttons carry
// the distinctive "border-2 rounded-lg font-medium" classes.
const nextPage = currentPage + 1;
const allPageBtns = await page.locator('button.border-2.rounded-lg.font-medium').allTextContents();
console.log(`Pagination buttons present: [${allPageBtns.join(', ')}]`);
const nextBtn = page.locator('button.border-2.rounded-lg.font-medium')
.filter({ hasText: new RegExp(`^${nextPage}$`) })
.first();
if (!(await nextBtn.isVisible({ timeout: 2000 }).catch(() => false))) {
console.log(`No page ${nextPage} button - reached last page`);
keepPaging = false;
break;
}
await nextBtn.click();
await page.waitForTimeout(2000); // give the next page's data time to load
// Poll until the first row's ID actually changes (confirms page advanced)
let changed = false;
for (let w = 0; w < 30; w++) {
const nkCells = await dataTable.locator('tbody tr.ant-table-row').first().locator('td').allTextContents().catch(() => [] as string[]);
const nk = (nkCells[idIdx] || '').replace(/\D/g, '').trim();
if (nk && nk !== firstRowId) { changed = true; break; }
await page.waitForTimeout(400);
}
if (!changed) {
console.log(`Page did not advance to ${nextPage} - stopping`);
keepPaging = false;
break;
}
await page.waitForTimeout(500); // let all rows on the new page render
currentPage = nextPage;
}
// De-duplicate just in case
const unique = Array.from(new Set(expectedIds));
expectedIds.length = 0;
expectedIds.push(...unique);
console.log(`Captured ${expectedIds.length} unique refund IDs in range across ${currentPage} page(s)`);
expect(expectedIds.length, 'At least one refund row should fall in range').toBeGreaterThan(0);
// Return to page 1 so the export modal opens from a consistent state
const page1Btn = page.locator('button.border-2.rounded-lg.font-medium')
.filter({ hasText: /^1$/ })
.first();
if (await page1Btn.isVisible({ timeout: 2000 }).catch(() => false)) {
await page1Btn.click();
await page.waitForTimeout(1000);
}
});
await test.step('Open Download report modal', async () => {
const downloadButton = page.locator('button:has-text("Download report")').first();
await expect(downloadButton, 'Download report button should be visible').toBeVisible({ timeout: 5000 });
await downloadButton.click();
await page.waitForTimeout(1500);
console.log('✓ Download report modal opened');
});
await test.step('Select CSV file format', async () => {
const csvOption = page.locator('label:has-text("CSV"), .ant-radio-wrapper:has-text("CSV")').first();
const isVisible = await csvOption.isVisible().catch(() => false);
if (isVisible) {
await csvOption.click();
} else {
// Fallback: click the radio input next to the CSV text
await page.getByText('CSV', { exact: true }).click();
}
await page.waitForTimeout(500);
console.log('✓ Selected CSV format');
});
await test.step('Select date range matching refund data', async () => {
const dateField = page.locator('[role="dialog"] input[date-range="start"], input[date-range="start"]').first();
await expect(dateField, 'Date range input should be visible').toBeVisible();
await dateField.click();
await page.waitForTimeout(1500);
// Navigate the calendar back until the start cell is visible
for (let i = 0; i < 15; i++) {
const startCell = page.locator(`.ant-picker-cell[title="${startTitle}"]`).first();
if (await startCell.isVisible().catch(() => false)) break;
await page.locator('.ant-picker-header-prev-btn').first().click();
await page.waitForTimeout(400);
}
await page.locator(`.ant-picker-cell[title="${startTitle}"]`).first().click();
await page.waitForTimeout(500);
console.log('✓ Start date:', startTitle);
await page.locator(`.ant-picker-cell[title="${endTitle}"]`).first().click();
await page.waitForTimeout(800);
console.log('✓ End date:', endTitle);
});
await test.step('Download CSV and verify contents against refund rows', async () => {
const downloadPromise = page.waitForEvent('download', { timeout: 30000 }).catch(() => null);
const modalDownloadButton = page.locator('[role="dialog"] button:has-text("Download")').first();
await expect(modalDownloadButton, 'Modal Download button should be visible').toBeVisible();
console.log('Clicking download button...');
await modalDownloadButton.click();
const download = await downloadPromise;
expect(download, 'A file download should be triggered').not.toBeNull();
fileName = download!.suggestedFilename();
console.log('✓ Download captured! File name:', fileName);
downloadSuccessful = true;
// Save and read the CSV content
const savePath = `test-results/${fileName}`;
await download!.saveAs(savePath);
const csv = fs.readFileSync(savePath, 'utf-8');
console.log(`CSV size: ${csv.length} chars; first line: ${csv.split('\n')[0]?.substring(0, 120)}`);
expect(csv.length, 'CSV should not be empty').toBeGreaterThan(0);
// Every refund ID shown in the table (within range) must be present in the CSV
const missing = expectedIds.filter(id => !csv.includes(id));
console.log(`Verifying ${expectedIds.length} IDs in CSV; missing: ${missing.length ? missing.join(', ') : 'none'}`);
expect(missing, `All table refund IDs should appear in the CSV (missing: ${missing.join(', ')})`).toHaveLength(0);
console.log('✓ CSV contains all refund transactions from the table for the selected range');
});
console.log('Refund export test completed');
});
test('should paginate through refund list', async ({ page }) => {
test.setTimeout(90000);
feature('Refund Operations');
tag('functional');
severity('normal');
description('Test pagination controls navigate to page 2 and load a genuinely different set of rows');
console.log('Testing pagination...');
// Scope to the refunds data table via its unique "RRN" header, consistent
// with the other tests in this file.
const dataTable = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first();
const noData = await dataTable.locator('tbody', { hasText: 'No data' }).first().isVisible({ timeout: 5000 }).catch(() => false);
test.skip(noData, 'No refund records available in this environment to paginate through');
await dataTable.locator('tbody tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 15000 });
const headers = await dataTable.locator('thead th').allTextContents();
const idIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'id');
expect(idIdx, 'ID column should exist').toBeGreaterThanOrEqual(0);
let firstPageId = '';
await test.step('Capture first row ID on page 1', async () => {
const cells = await dataTable.locator('tbody tr.ant-table-row').first().locator('td').allTextContents();
firstPageId = (cells[idIdx] || '').replace(/\D/g, '').trim();
expect(firstPageId, 'Page 1 first row should have a valid ID').toMatch(/^\d+$/);
console.log('First row ID on page 1:', firstPageId);
});
await test.step('Click page 2 button', async () => {
// Page-number buttons carry the distinctive "border-2 rounded-lg font-medium"
// classes; matching exact text avoids accidentally hitting unrelated buttons
// that merely contain "2" (e.g. a date like "2026").
const page2Button = page.locator('button.border-2.rounded-lg.font-medium').filter({ hasText: /^2$/ }).first();
const hasPage2 = await page2Button.isVisible({ timeout: 10000 }).catch(() => false);
test.skip(!hasPage2, 'Not enough refunds in this environment to require a second page');
await page2Button.scrollIntoViewIfNeeded();
console.log('Clicking page 2 button...');
await page2Button.click();
});
await test.step('Verify page 2 loaded a different first row', async () => {
let secondPageId = '';
for (let attempt = 0; attempt < 30; attempt++) {
const cells = await dataTable.locator('tbody tr.ant-table-row').first().locator('td').allTextContents().catch(() => [] as string[]);
secondPageId = (cells[idIdx] || '').replace(/\D/g, '').trim();
if (secondPageId && secondPageId !== firstPageId) break;
await page.waitForTimeout(400);
}
console.log('First row ID on page 2:', secondPageId);
expect(secondPageId, 'Page 2 first row should have a valid ID').toMatch(/^\d+$/);
expect(secondPageId, 'Page 2 should show a different first row than page 1').not.toBe(firstPageId);
});
console.log('Pagination test completed');
});
test('should filter refunds by Approved status and date range', async ({ page }) => {
test.setTimeout(90000);
feature('Refund Operations');
tag('functional');
severity('normal');
description('Test filtering refunds by Approved status and date range');
console.log('Testing status and date filter...');
let initialRowCount = 0;
let startTitle = '';
let endTitle = '';
await test.step('Count initial refunds', async () => {
initialRowCount = await page.locator('table tbody tr').count();
console.log('Initial refund count:', initialRowCount);
});
await test.step('Determine target date range from the latest refund', async () => {
// Anchor the range on the newest row actually in the table (same-month
// 1st -> that day), rather than "today": this dev dataset's last refund
// can be weeks old, so a range ending "today" often covers zero rows.
const dataTable = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first();
const noData = await dataTable.locator('tbody', { hasText: 'No data' }).first().isVisible({ timeout: 5000 }).catch(() => false);
test.skip(noData, 'No refund records available in this environment to filter by status/date');
await dataTable.locator('tbody tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 15000 });
const headers = await dataTable.locator('thead th').allTextContents();
const dateIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'date');
expect(dateIdx, 'A date column should exist').toBeGreaterThanOrEqual(0);
const firstRowCells = await dataTable.locator('tbody tr.ant-table-row').first().locator('td').allTextContents();
const latestDate = toDate(firstRowCells[dateIdx] || '');
expect(latestDate, 'The newest row should have a parseable date').not.toBeNull();
startTitle = toISO(new Date(latestDate!.getFullYear(), latestDate!.getMonth(), 1));
endTitle = toISO(latestDate!);
console.log(`Target range from latest refund (${firstRowCells[dateIdx]}): ${startTitle}..${endTitle}`);
});
await test.step('Open filter controls', async () => {
const filterButton = page.locator('button:has-text("Filter")').first();
const isVisible = await filterButton.isVisible().catch(() => false);
if (isVisible) {
await filterButton.click();
await page.waitForTimeout(1000);
console.log('✓ Filter controls opened');
} else {
console.log('Filter button not found');
}
});
await test.step('Select Approved status only', async () => {
// Every status checkbox (Initilized, Pending, Approved, Canceled, ...) is
// checked by default, i.e. "show all statuses". To filter to Approved-only
// we must uncheck every OTHER status and leave Approved checked - simply
// clicking the Approved label toggles it OFF and leaves everything else
// showing, which is the opposite of what this test needs.
const statusSection = page.locator('article:has-text("Transaction Status")').locator('xpath=following-sibling::div[1]');
await expect(statusSection, 'Transaction Status section should be visible').toBeVisible({ timeout: 10000 });
const statusLabels = statusSection.locator('label.ant-checkbox-wrapper');
const count = await statusLabels.count();
expect(count, 'There should be status checkboxes').toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
const label = statusLabels.nth(i);
const text = (await label.innerText()).trim();
const isChecked = await label.locator('input[type="checkbox"]').isChecked();
if (text === 'Approved') {
if (!isChecked) {
console.log('Approved was unchecked, checking it...');
await label.click();
await page.waitForTimeout(300);
}
} else if (isChecked) {
console.log(`Unchecking status: ${text}`);
await label.click();
await page.waitForTimeout(300);
}
}
console.log('✓ Only Approved status left checked');
});
await test.step('Select the target date range (anchored on the latest refund)', async () => {
const dateField = page.locator('input[date-range="start"]').first();
await expect(dateField, 'Date range start input should be visible').toBeVisible({ timeout: 10000 });
await dateField.click();
await page.waitForTimeout(1500);
const calendarVisible = await page.locator('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)').isVisible().catch(() => false);
expect(calendarVisible, 'Date range calendar should open').toBe(true);
// Navigate the calendar backward (bounded) until the target start date
// is actually in view - the picker defaults to the current month, which
// may be well after this dev dataset's latest refund.
const prevBtn = page.locator('.ant-picker-header-prev-btn').first();
let found = false;
for (let attempt = 0; attempt < 60; attempt++) {
found = await page.locator(`.ant-picker-cell-in-view[title="${startTitle}"]:not(.ant-picker-cell-disabled)`).first().isVisible().catch(() => false);
if (found) break;
await prevBtn.click();
await page.waitForTimeout(300);
}
expect(found, `Target start date ${startTitle} should become available in the calendar`).toBe(true);
console.log(`Range -> start: ${startTitle}, end: ${endTitle}`);
await page.locator(`.ant-picker-cell-in-view[title="${startTitle}"]`).first().click();
await page.waitForTimeout(800);
await page.locator(`.ant-picker-cell-in-view[title="${endTitle}"]`).first().click();
await page.waitForTimeout(1000);
console.log('✓ Start and end dates selected');
});
await test.step('Apply filter', async () => {
const applyButton = page.locator('button:has-text("Apply"), button:has-text("Search"), button[type="submit"]').first();
const isVisible = await applyButton.isVisible().catch(() => false);
if (isVisible) {
await applyButton.click();
await page.waitForTimeout(3000);
console.log('✓ Filter applied');
}
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(500);
});
await test.step('Assert every returned row is Approved and within the date range', async () => {
const startDate = toDate(startTitle)!;
const endDate = toDate(endTitle)!;
endDate.setHours(23, 59, 59, 999);
const dataTable = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first();
const headers = await dataTable.locator('thead th').allTextContents();
const statusIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'status');
const dateIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'date');
console.log(`Columns -> status:${statusIdx} date:${dateIdx} (headers: ${headers.join(' | ')})`);
expect(statusIdx, 'A status column should exist').toBeGreaterThanOrEqual(0);
expect(dateIdx, 'A date column should exist').toBeGreaterThanOrEqual(0);
const rows = dataTable.locator('tbody tr.ant-table-row');
const rowCount = await rows.count();
console.log(`Validating ${rowCount} rows are Approved and within ${startTitle}..${endTitle}`);
for (let i = 0; i < rowCount; i++) {
const cellTexts = await rows.nth(i).locator('td').allTextContents();
const statusText = (cellTexts[statusIdx] || '').trim();
expect(statusText.toLowerCase(), `Row ${i + 1} status should be Approved (got "${statusText}")`).toBe('approved');
const rowDate = toDate(cellTexts[dateIdx] || '');
expect(rowDate, `Row ${i + 1} should have a valid date`).not.toBeNull();
const t = rowDate!.getTime();
expect(
t >= startDate.getTime() && t <= endDate.getTime(),
`Row ${i + 1} date ${cellTexts[dateIdx]} should be within ${startTitle}..${endTitle}`
).toBe(true);
}
console.log(`✓ All ${rowCount} returned rows are Approved and within range`);
});
console.log('Status and date filter test completed');
});
});

182
tests/roles.spec.ts Normal file
View File

@@ -0,0 +1,182 @@
import { test, expect } from '@playwright/test';
import { login, loginAs } from './helpers/auth';
import { epic, feature, severity, description, tag } from './helpers/allure';
import { navigateToManagement } from './helpers/navigation';
// Confirmed live by actually logging in as each role: the nav menu is
// restricted per role, and Terminal Manager lands on /terminals (not
// /dashboard) since Dashboard isn't in its menu at all.
const EXPECTED_MENUS: Record<string, { visible: string[]; hidden: string[]; landingPath: string }> = {
CASHIER: {
visible: ['Dashboard', 'Transactions', 'Terminals', 'Refunds', 'Settings'],
hidden: ['Discounts & fees', 'Reconciliation', 'Settlement', 'Management'],
landingPath: '/dashboard',
},
FINANCE: {
visible: ['Dashboard', 'Transactions', 'Discounts & fees', 'Reconciliation', 'Refunds', 'Settlement', 'Settings'],
hidden: ['Terminals', 'Management'],
landingPath: '/dashboard',
},
TERMINAL_MANAGER: {
visible: ['Terminals', 'Settings'],
hidden: ['Dashboard', 'Transactions', 'Discounts & fees', 'Reconciliation', 'Refunds', 'Settlement', 'Management'],
landingPath: '/terminals',
},
};
async function createRoleUser(
page: import('@playwright/test').Page,
role: keyof typeof EXPECTED_MENUS,
fullName: string,
identifier: string,
password: string
): Promise<void> {
await navigateToManagement(page);
await page.waitForTimeout(2000);
const addUserBtn = page.locator('button').filter({ hasText: 'Add User' }).first();
await addUserBtn.click({ force: true });
await page.waitForTimeout(1500);
await page.locator('#fullName').first().fill(fullName);
await page.locator('#username').first().fill(identifier);
await page.locator('#password').first().fill(password);
await page.locator('#confirmPassword').first().fill(password);
if (role !== 'CASHIER') {
const roleSelect = page.locator('.ant-select:has(.anticon-solution)').first();
await roleSelect.locator('.ant-select-selector').click();
await page.waitForTimeout(800);
const label = role === 'FINANCE' ? 'Finance' : 'Terminal Manager';
const option = page.locator('.ant-select-item-option-content', { hasText: label }).first();
await option.waitFor({ state: 'visible', timeout: 5000 });
await option.click();
// Wait for the dropdown to fully close - a leftover open dropdown here
// caused the *next* select's option locator to resolve to a stale,
// invisible element from this role dropdown instead.
await page.waitForSelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)', { state: 'hidden', timeout: 5000 }).catch(() => {});
await page.waitForTimeout(800);
}
const branchSelect = page.locator('.ant-select').filter({ hasText: /Select a branch/i }).first();
if (await branchSelect.isVisible({ timeout: 3000 }).catch(() => false)) {
await branchSelect.click();
await page.waitForTimeout(800);
const options = page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option');
const count = await options.count();
for (let i = 0; i < count; i++) {
const t = (await options.nth(i).textContent())?.trim();
if (t && t !== '-' && t.length > 1) {
await options.nth(i).click();
break;
}
}
await page.waitForTimeout(500);
}
if (role === 'CASHIER') {
// Cashiers additionally require an unassociated Terminal Id.
const terminalSelect = page.locator('.ant-select:has(.anticon-mobile)').first();
await expect(terminalSelect, 'Terminal Id select should be visible for Cashier role').toBeVisible({ timeout: 5000 });
await terminalSelect.locator('.ant-select-selector').click();
await page.waitForTimeout(1200);
const dropdownOptions = page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option');
const count = await dropdownOptions.count();
let selected = false;
for (let i = 0; i < count; i++) {
const optText = (await dropdownOptions.nth(i).textContent())?.trim();
const idMatch = optText?.match(/Id:\s*(\d+)/i);
if (idMatch && idMatch[1].length >= 8) {
await dropdownOptions.nth(i).click({ force: true });
await page.waitForTimeout(500);
selected = true;
break;
}
}
expect(selected, 'Should be able to select an unassociated terminal for the new cashier').toBe(true);
}
const submitBtn = page.locator('.ant-modal button:has-text("Add User")').last();
await submitBtn.click();
await page.waitForTimeout(3000);
const modalStillOpen = await page.locator('.ant-modal-content').first().isVisible().catch(() => false);
expect(modalStillOpen, `${role} user creation should succeed (the modal should close)`).toBe(false);
}
test.describe('Role-Based Access Tests', () => {
test.setTimeout(120000);
for (const role of Object.keys(EXPECTED_MENUS) as Array<keyof typeof EXPECTED_MENUS>) {
test(`should show correct restricted navigation for ${role} role after login`, async ({ page }) => {
epic('Role-Based Access Control');
feature('Role Permissions');
tag('functional');
severity('critical');
description(`Create a ${role} user as admin, then actually log in as that user and verify their navigation menu matches expected role permissions`);
console.log(`Testing ${role} role-based access...`);
const timestamp = Date.now();
const fullName = `RoleTest ${role} ${timestamp}`;
const identifier = role === 'CASHIER'
? `roletest${role.toLowerCase()}${timestamp}`
: `roletest${role.toLowerCase().replace('_', '')}${timestamp}@test.com`;
const password = 'Mav@1234';
await test.step(`Log in as admin and create a new ${role} user`, async () => {
await login(page);
await createRoleUser(page, role, fullName, identifier, password);
console.log(`✓ Created ${role} user: ${identifier}`);
});
await test.step('Log out of the admin session', async () => {
// Navigating straight to the login URL while still authenticated as
// admin just auto-redirects back to /dashboard - the login form
// never renders, so filling it later times out. Log out explicitly.
const logoutLink = page.locator('text=Logout').first();
await logoutLink.waitFor({ state: 'visible', timeout: 10000 });
await logoutLink.click();
await page.waitForURL(/\/login/, { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(1500);
console.log('✓ Logged out of admin session, URL:', page.url());
});
await test.step(`Log in as the new ${role} user`, async () => {
// A just-created account can take a moment to become authenticatable
// (backend provisioning lag) - retry the login itself rather than
// failing on the first attempt landing back on the login page.
let landed = false;
for (let attempt = 1; attempt <= 3; attempt++) {
await loginAs(page, identifier, password);
console.log(`✓ Logged in as ${role}, landed on: ${page.url()}`);
landed = page.url().includes(EXPECTED_MENUS[role].landingPath);
if (landed) break;
console.log(`Login attempt ${attempt} did not land on ${EXPECTED_MENUS[role].landingPath} - retrying...`);
await page.waitForTimeout(3000);
}
expect(landed, `${role} should land on ${EXPECTED_MENUS[role].landingPath} after login`).toBe(true);
});
await test.step('Verify the navigation menu matches expected role permissions', async () => {
const { visible, hidden } = EXPECTED_MENUS[role];
for (const item of visible) {
const menuItem = page.locator('button').filter({ hasText: new RegExp(`^${item}$`) }).first();
await expect(menuItem, `${role} should see "${item}" in the menu`).toBeVisible({ timeout: 10000 });
}
console.log(`${role} sees all expected menu items: ${visible.join(', ')}`);
for (const item of hidden) {
const menuItem = page.locator('button').filter({ hasText: new RegExp(`^${item}$`) });
const count = await menuItem.count();
expect(count, `${role} should NOT see "${item}" in the menu`).toBe(0);
}
console.log(`${role} correctly does not see: ${hidden.join(', ')}`);
});
console.log(`${role} role-based access test completed`);
});
}
});

99
tests/settings.spec.ts Normal file
View File

@@ -0,0 +1,99 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
import { epic, feature, severity, description, tag } from './helpers/allure';
import { navigateToSettings } from './helpers/navigation';
test.describe('Settings Tests', () => {
test.setTimeout(90000);
test.beforeEach(async ({ page }) => {
epic('Settings');
await login(page);
await navigateToSettings(page);
await page.waitForTimeout(2000);
});
test('should display Discount Rate Configuration for the Discounts settings tab', async ({ page }) => {
feature('Settings Operations');
tag('functional');
severity('normal');
description('Open the Discount rates settings tab and verify the configuration table renders correctly, whether rates are configured or not');
console.log('Testing Discount rates settings tab...');
await test.step('Open Discount rates tab', async () => {
const discountRatesTab = page.getByText('Discount rates', { exact: true }).first();
await expect(discountRatesTab, 'Discount rates tab should be visible').toBeVisible({ timeout: 10000 });
await discountRatesTab.click();
await page.waitForTimeout(1500);
console.log('✓ Opened Discount rates');
});
await test.step('Verify the Discount Rate Configuration table', async () => {
const ratesTable = page.locator('xpath=//*[contains(text(),"Discount Rate Configuration")]/following::table[1]').first();
await expect(ratesTable, 'Discount Rate Configuration table should be visible').toBeVisible({ timeout: 10000 });
const headers = await ratesTable.locator('thead th').allTextContents();
console.log('Headers:', JSON.stringify(headers));
let cardIdx = -1, mdrIdx = -1, vatIdx = -1, capIdx = -1, feeIdx = -1;
for (let i = 0; i < headers.length; i++) {
const t = headers[i].trim().toLowerCase();
if (t === 'card') cardIdx = i;
else if (t === 'mdr') mdrIdx = i;
else if (t === 'vat') vatIdx = i;
else if (t.includes('cap')) capIdx = i;
else if (t.includes('processing') || t === 'fee') feeIdx = i;
}
expect(cardIdx, 'A Card column should exist').toBeGreaterThanOrEqual(0);
expect(mdrIdx, 'An MDR column should exist').toBeGreaterThanOrEqual(0);
// Real data rows only - excludes the hidden measure row (aria-hidden)
// and an empty-state "No data" placeholder row (both previously got
// miscounted by earlier code as bogus zero-value rate entries).
const rows = ratesTable.locator('tbody tr:not([aria-hidden="true"])');
const rowCount = await rows.count();
console.log(`Row count (excluding hidden measure row): ${rowCount}`);
const bodyText = await ratesTable.locator('tbody').innerText();
const isEmptyState = /no data/i.test(bodyText) && rowCount <= 1;
if (isEmptyState) {
console.log('✓ No discount rates configured - "No data" empty state correctly shown');
// Scope to a real text-bearing element - a plain `text=/no data/i`
// locator also matches a hidden SVG <title> icon label with the same
// text, which is never actually visible.
await expect(ratesTable.locator('article, span, div').filter({ hasText: /^No data$/i }).first(), 'Empty state should show "No data"').toBeVisible();
} else {
console.log(`✓ Found ${rowCount} configured discount rate row(s), validating each`);
expect(rowCount, 'There should be at least one configured rate row').toBeGreaterThan(0);
// Read a numeric value the same safe way used for the Discounts page
// breakdown table: the value sits in the last <article> in the cell,
// separate from an embedded currency/percent icon's <style> content.
const readCellNum = async (row: import('@playwright/test').Locator, idx: number): Promise<number> => {
if (idx < 0) return 0;
const cell = row.locator('td').nth(idx);
const valArticle = cell.locator('article').last();
const txt = (await valArticle.textContent().catch(() => '')) || (await cell.textContent().catch(() => '')) || '';
const n = parseFloat(txt.replace(/[^0-9.\-]/g, ''));
return isNaN(n) ? 0 : n;
};
for (let r = 0; r < rowCount; r++) {
const row = rows.nth(r);
const cardText = (await row.locator('td').nth(cardIdx).innerText().catch(() => '')).trim();
const mdr = await readCellNum(row, mdrIdx);
const vat = vatIdx >= 0 ? await readCellNum(row, vatIdx) : 0;
const cap = capIdx >= 0 ? await readCellNum(row, capIdx) : 0;
const fee = feeIdx >= 0 ? await readCellNum(row, feeIdx) : 0;
console.log(` ${cardText}: MDR ${mdr}% | VAT ${vat}% | Cap ${cap} | Fee ${fee}%`);
expect(cardText.length, `Row ${r + 1} should have a card scheme name`).toBeGreaterThan(0);
expect(mdr, `Row ${r + 1} (${cardText}) MDR should be a plausible percentage`).toBeGreaterThanOrEqual(0);
expect(mdr, `Row ${r + 1} (${cardText}) MDR should be a plausible percentage`).toBeLessThanOrEqual(100);
}
}
});
console.log('✓ Discount rates settings test completed');
});
});

346
tests/settlement.spec.ts Normal file
View File

@@ -0,0 +1,346 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
import { epic, feature, severity, description, tag } from './helpers/allure';
import { navigateToSettlement, navigateToTransactions } from './helpers/navigation';
import * as fs from 'fs';
import * as path from 'path';
import * as XLSX from 'xlsx';
// Parses either an ISO picker-cell title (YYYY-MM-DD) or a display-format
// table date ("Jul 6, 2026") into a comparable Date.
const toDate = (s: string | null | undefined): Date | null => {
if (!s) return null;
const iso = s.match(/(\d{4})-(\d{2})-(\d{2})/);
if (iso) return new Date(`${iso[1]}-${iso[2]}-${iso[3]}T00:00:00`);
const disp = s.match(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}),\s*(\d{4})/);
if (disp) {
const months: Record<string, string> = {
Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06',
Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12',
};
return new Date(`${disp[3]}-${months[disp[1]]}-${disp[2].padStart(2, '0')}T00:00:00`);
}
return null;
};
const toISO = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
// Navigates the calendar backward (bounded) until the target ISO date is
// visible, then clicks it. The picker defaults to the current month, which
// may be well after this dev dataset's latest transaction.
const selectCalendarDate = async (page: import('@playwright/test').Page, targetIso: string, prevBtnSelector: string) => {
const prevBtn = page.locator(prevBtnSelector).first();
let found = false;
for (let attempt = 0; attempt < 60; attempt++) {
found = await page.locator(`.ant-picker-cell-in-view[title="${targetIso}"]:not(.ant-picker-cell-disabled)`).first().isVisible().catch(() => false);
if (found) break;
await prevBtn.click();
await page.waitForTimeout(300);
}
expect(found, `Target date ${targetIso} should become available in the calendar`).toBe(true);
await page.locator(`.ant-picker-cell-in-view[title="${targetIso}"]`).first().click();
};
test.describe('Settlement Tests', () => {
test.setTimeout(120000);
test.beforeEach(async ({ page }) => {
epic('Settlement Management');
await login(page);
});
test('should download transactions, format, upload and run settlement', async ({ page }) => {
test.setTimeout(180000);
feature('Settlement Operations');
tag('functional');
severity('critical');
description('Download XLSX from Transactions, strip headers/summary, upload formatted file to Settlement, click Start Settlement');
let downloadedFilePath = '';
let formattedFilePath = '';
let targetIso = '';
// Step 1: Download XLSX from Transactions
await test.step('Navigate to Transactions', async () => {
await navigateToTransactions(page);
await page.waitForTimeout(5000);
// Wait for table to load
await page.locator('table tbody tr td').first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => {});
await page.waitForTimeout(2000);
const rows = await page.locator('table tbody tr').filter({ has: page.locator('td') }).count();
console.log(`Transactions rows: ${rows}`);
expect(rows).toBeGreaterThan(0);
});
await test.step('Determine target date from the latest transaction', async () => {
// Anchor on the newest row actually in the table rather than a fixed
// date - this dev dataset's "today" moves on, so a hardcoded date
// eventually falls outside the calendar's visible range entirely and
// silently selects nothing, which is why the download never fired.
const table = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first();
await table.locator('tbody tr:not([aria-hidden="true"])').first().waitFor({ state: 'visible', timeout: 15000 });
const headers = await table.locator('thead th').allTextContents();
const dateIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'init date');
expect(dateIdx, 'An init date column should exist').toBeGreaterThanOrEqual(0);
const firstRowCells = await table.locator('tbody tr:not([aria-hidden="true"])').first().locator('td').allTextContents();
const latestDate = toDate(firstRowCells[dateIdx] || '');
expect(latestDate, 'The newest transaction should have a parseable date').not.toBeNull();
targetIso = toISO(latestDate!);
console.log(`Target date from latest transaction (${firstRowCells[dateIdx]}): ${targetIso}`);
});
await test.step('Open download report modal', async () => {
const downloadBtn = page.locator('button:has-text("Download report")').first();
await downloadBtn.waitFor({ state: 'visible', timeout: 10000 });
await downloadBtn.click();
await page.waitForTimeout(2000);
console.log('✓ Download modal opened');
});
await test.step('Select date range: latest transaction date', async () => {
const dateField = page.locator('input[date-range="start"]').first();
await expect(dateField, 'Date range start input should be visible').toBeVisible({ timeout: 10000 });
await dateField.click();
await page.waitForTimeout(1500);
// Click the target date twice — start and end on the same day
await selectCalendarDate(page, targetIso, '.ant-picker-header-prev-btn');
await page.waitForTimeout(500);
await selectCalendarDate(page, targetIso, '.ant-picker-header-prev-btn');
await page.waitForTimeout(1000);
console.log(`✓ Date: ${targetIso}`);
});
await test.step('Select XSLX and download', async () => {
const xlsxRadio = page.locator('.ant-radio-button-wrapper', { hasText: 'XSLX' }).first();
const xlsxVis = await xlsxRadio.isVisible().catch(() => false);
if (xlsxVis) {
await xlsxRadio.click();
await page.waitForTimeout(2000);
console.log('✓ XSLX selected');
} else {
await page.locator('span:text-is("XSLX")').first().click();
await page.waitForTimeout(2000);
}
await page.waitForTimeout(3000);
// Find the download button
let dlBtn = page.locator('button:has-text("Download")').last();
let btnVisible = await dlBtn.isVisible({ timeout: 5000 }).catch(() => false);
if (!btnVisible) {
dlBtn = page.locator('button[type="button"]:has-text("Download")').first();
btnVisible = await dlBtn.isVisible({ timeout: 5000 }).catch(() => false);
}
console.log('Download button visible:', btnVisible);
if (!btnVisible) {
console.log('⚠ Download button not found. Skipping settlement test.');
test.skip();
return;
}
// Set up download listener - use same pattern as transactions test
const downloadPromise = page.waitForEvent('download', { timeout: 30000 }).catch(() => null);
// Scroll button into view
await dlBtn.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
// Click the download button
console.log('Clicking download button...');
await dlBtn.click();
// Wait for download
const download = await downloadPromise;
if (download) {
downloadedFilePath = (await download.path()) || '';
console.log('✓ Downloaded:', download.suggestedFilename());
} else {
// Try to find the file in common download locations
console.log('⚠ Download event did not fire, checking for file...');
const possiblePaths = [
'/tmp/transaction_report.xlsx',
'/home/runner/Downloads/transaction_report.xlsx',
process.env.HOME + '/Downloads/transaction_report.xlsx'
];
for (const possiblePath of possiblePaths) {
if (fs.existsSync(possiblePath)) {
downloadedFilePath = possiblePath;
console.log('✓ Found downloaded file at:', downloadedFilePath);
break;
}
}
if (!downloadedFilePath) {
console.log('⚠ Download failed: file not found and download event did not fire. Skipping settlement test.');
test.skip();
return;
}
}
});
// Step 2: Parse XLSX, strip headers/summary, keep only APPROVED/DECLINED
await test.step('Format downloaded file — keep only Approved/Declined transactions', async () => {
expect(downloadedFilePath).toBeTruthy();
const fileBuffer = fs.readFileSync(downloadedFilePath);
const workbook = XLSX.read(fileBuffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const allRows: any[][] = XLSX.utils.sheet_to_json(sheet, { header: 1 });
console.log(`Original XLSX rows: ${allRows.length}`);
for (let i = 0; i < Math.min(allRows.length, 10); i++) {
console.log(`Row ${i}: ${JSON.stringify(allRows[i])?.substring(0, 150)}`);
}
// Find the header row (starts with "id")
let headerIdx = -1;
for (let i = 0; i < allRows.length; i++) {
const firstCell = String(allRows[i]?.[0] || '').toLowerCase().trim();
if (firstCell === 'id') {
headerIdx = i;
break;
}
}
console.log(`Header row index: ${headerIdx}`);
expect(headerIdx).toBeGreaterThanOrEqual(0);
const headerRow = allRows[headerIdx];
// Find the status column index
let statusColIdx = -1;
for (let c = 0; c < headerRow.length; c++) {
if (String(headerRow[c]).toLowerCase().trim() === 'status') {
statusColIdx = c;
break;
}
}
console.log(`Status column index: ${statusColIdx}`);
// Find where SUMMARY starts
let summaryIdx = allRows.length;
for (let i = headerIdx + 1; i < allRows.length; i++) {
const firstCell = String(allRows[i]?.[0] || '').toLowerCase().trim();
if (firstCell === '' || firstCell.includes('summary') || firstCell.includes('total')) {
summaryIdx = i;
break;
}
}
// Get data rows and filter by Approved/Declined only. The raw export
// values are "APPROVE"/"DECLINE" (not "APPROVED"/"DECLINED" like the UI
// labels), so match by prefix rather than exact equality.
const dataRows = allRows.slice(headerIdx + 1, summaryIdx);
const filteredRows = dataRows.filter(row => {
const status = String(row[statusColIdx] || '').toUpperCase().trim();
return status.startsWith('APPROVE') || status.startsWith('DECLINE');
});
console.log(`Data rows: ${dataRows.length}, After filter (Approved/Declined only): ${filteredRows.length}`);
for (const row of filteredRows) {
console.log(` Status: ${row[statusColIdx]}, ID: ${row[0]}`);
}
test.skip(filteredRows.length === 0, 'No Approved/Declined transactions available in this environment to settle');
// Build clean file: header + filtered data rows
const cleanRows = [headerRow, ...filteredRows];
const newSheet = XLSX.utils.aoa_to_sheet(cleanRows);
const newWorkbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(newWorkbook, newSheet, 'Transactions');
formattedFilePath = path.join(path.dirname(downloadedFilePath), 'formatted_transactions.xlsx');
const outBuffer = XLSX.write(newWorkbook, { type: 'buffer', bookType: 'xlsx' });
fs.writeFileSync(formattedFilePath, outBuffer);
console.log(`✓ Formatted file saved with ${filteredRows.length} transactions`);
expect(fs.existsSync(formattedFilePath)).toBeTruthy();
});
// Step 3: Go to Settlement, select date, upload, start
await test.step('Navigate to Settlement', async () => {
await navigateToSettlement(page);
await page.waitForTimeout(5000);
// Wait for the page to fully load
await page.locator('input[date-range="start"]').first().waitFor({ state: 'visible', timeout: 20000 }).catch(() => {});
await page.waitForTimeout(2000);
console.log('✓ On Settlement page');
});
await test.step('Select date range on Settlement: latest transaction date', async () => {
const dateField = page.locator('input[date-range="start"]').first();
await expect(dateField, 'Settlement date field should be visible').toBeVisible({ timeout: 10000 });
await dateField.click();
await page.waitForTimeout(2000);
// Same date used for the Transactions download above, so the uploaded
// file's transactions correspond to the settlement date being run.
await selectCalendarDate(page, targetIso, '.ant-picker-prev-btn');
await page.waitForTimeout(500);
await selectCalendarDate(page, targetIso, '.ant-picker-prev-btn');
await page.waitForTimeout(1000);
console.log(`✓ Settlement date: ${targetIso}`);
});
await test.step('Upload formatted file', async () => {
expect(formattedFilePath).toBeTruthy();
const fileInput = page.locator('input[name="file"][type="file"]').first();
await fileInput.setInputFiles(formattedFilePath);
await page.waitForTimeout(5000);
console.log('✓ File uploaded');
const noFileMsg = await page.locator('text=/No file uploaded/i').isVisible().catch(() => false);
console.log('No file message visible:', noFileMsg);
expect(noFileMsg).toBeFalsy();
});
await test.step('Click Start Settlement', async () => {
const startBtn = page.locator('button:has-text("Start Settlement")').first();
await startBtn.waitFor({ state: 'visible', timeout: 10000 });
await startBtn.click();
await page.waitForTimeout(5000);
console.log('✓ Start Settlement clicked');
});
await test.step('Verify settlement results popup', async () => {
await page.waitForTimeout(5000);
// Check for a modal/popup with settlement results
const modalText = await page.locator('.ant-modal, [role="dialog"]').first().textContent({ timeout: 10000 }).catch(() => '');
console.log('Settlement popup content:', modalText?.substring(0, 500));
// Check page body too
const bodyText = await page.locator('body').textContent().catch(() => '');
const fullText = (modalText || '') + (bodyText || '');
const hasMatched = fullText.toLowerCase().includes('matched');
const hasMismatched = fullText.toLowerCase().includes('mismatched') || fullText.toLowerCase().includes('mismatch');
const hasNotMatched = fullText.toLowerCase().includes('not matched');
console.log('Has "matched":', hasMatched);
console.log('Has "mismatched":', hasMismatched);
console.log('Has "not matched":', hasNotMatched);
// Log specific counts if visible
const matchedCount = fullText.match(/(\d+)\s*matched/i);
const mismatchedCount = fullText.match(/(\d+)\s*(?:mis|not\s*)matched/i);
if (matchedCount) console.log('Matched count:', matchedCount[1]);
if (mismatchedCount) console.log('Mismatched count:', mismatchedCount[1]);
expect(hasMatched || hasMismatched || hasNotMatched).toBeTruthy();
});
console.log('✓ Settlement test completed');
});
});

762
tests/terminals.spec.ts Normal file
View File

@@ -0,0 +1,762 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
import { epic, feature, severity, description, tag } from './helpers/allure';
import { navigateToTerminals, navigateToManagement } from './helpers/navigation';
test.describe('Terminal Tests', () => {
test.setTimeout(90000);
test.beforeEach(async ({ page }) => {
epic('Terminal Management');
await login(page);
await navigateToTerminals(page);
await page.waitForTimeout(2000);
});
// Test 2: View Terminal Details with Data Matching
test('should view terminal details with all information', async ({ page }) => {
feature('Terminal Operations');
tag('functional');
severity('critical');
description('View terminal details and verify Terminal Id matches and download button exists');
console.log('Testing terminal details view with data matching...');
// Variables to store data from table
let terminalIdValue = '';
await test.step('Capture Terminal Id from first row', async () => {
const firstRow = page.locator('table tbody tr:not([aria-hidden="true"])').first();
await expect(firstRow).toBeVisible();
const cells = firstRow.locator('td');
// Extract Terminal Id (column index 5)
terminalIdValue = (await cells.nth(5).textContent())?.trim() || '';
console.log('Captured Terminal Id from table:', terminalIdValue);
expect(terminalIdValue).not.toBe('');
});
await test.step('Click Details button to view terminal details', async () => {
const firstRow = page.locator('table tbody tr:not([aria-hidden="true"])').first();
const detailsButton = firstRow.locator('button:has-text("Details")');
await expect(detailsButton).toBeVisible();
await detailsButton.click();
await page.waitForTimeout(2000);
console.log('Details button clicked');
});
await test.step('Verify terminal details panel appears', async () => {
const detailsHeading = await page.locator('text=Terminal details').first().isVisible({ timeout: 5000 });
console.log('Terminal details heading visible:', detailsHeading);
expect(detailsHeading).toBeTruthy();
});
await test.step('Verify Terminal Id matches in details panel', async () => {
if (terminalIdValue && terminalIdValue.trim() !== '' && terminalIdValue !== '-') {
const terminalIdLabel = await page.locator('text=Terminal Id :').isVisible({ timeout: 3000 });
console.log('Terminal Id label found:', terminalIdLabel);
expect(terminalIdLabel).toBeTruthy();
const terminalIdInDetails = await page.locator(`text=${terminalIdValue}`).first().isVisible({ timeout: 3000 });
console.log('Terminal Id value found in details:', terminalIdInDetails);
console.log('✓ Terminal Id matches:', terminalIdValue);
expect(terminalIdInDetails).toBeTruthy();
} else {
console.log('Terminal Id is empty or dash, skipping verification');
}
});
await test.step('Verify Download button exists in details panel', async () => {
const downloadButton = page.locator('button:has-text("Download")').last();
const isVisible = await downloadButton.isVisible({ timeout: 3000 });
console.log('Download button in details panel visible:', isVisible);
expect(isVisible).toBeTruthy();
// Verify it has the download icon
const downloadIcon = downloadButton.locator('[data-icon="download"]');
const iconVisible = await downloadIcon.isVisible().catch(() => false);
console.log('Download icon visible:', iconVisible);
console.log('✓ Download button verified in terminal details');
});
console.log('Terminal details verification completed - Terminal Id matches and download button exists!');
});
// Test 3: Pagination Test
test('should paginate through terminal list', async ({ page }) => {
feature('Terminal Operations');
tag('functional');
severity('normal');
description('Test pagination controls navigate to page 2 and load a genuinely different set of terminals');
console.log('Testing terminal pagination...');
// Dismiss any open modals first
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(500);
const table = page.locator('table').first();
await table.locator('tbody tr:not([aria-hidden="true"])').first().waitFor({ state: 'visible', timeout: 15000 });
const headers = await table.locator('thead th').allTextContents();
// The first column is a blank icon/expander column - match "ID" exactly so
// we don't accidentally grab that blank column or "Terminal Id"/"Cashier ID".
const idIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'id');
expect(idIdx, 'An ID column should exist').toBeGreaterThanOrEqual(0);
let firstPageId = '';
await test.step('Capture first terminal ID on page 1', async () => {
const cells = await table.locator('tbody tr:not([aria-hidden="true"])').first().locator('td').allTextContents();
firstPageId = (cells[idIdx] || '').trim();
expect(firstPageId, 'Page 1 first row should have a valid ID').toMatch(/^\d+$/);
console.log('First terminal ID on page 1:', firstPageId);
});
await test.step('Click page 2 button', async () => {
// Match the exact page-number text so we don't accidentally hit an
// unrelated button that merely contains "2" somewhere in its label.
const page2Button = page.locator('button').filter({ hasText: /^2$/ }).first();
const hasPage2 = await page2Button.isVisible({ timeout: 10000 }).catch(() => false);
test.skip(!hasPage2, 'Not enough terminals in this environment to require a second page');
await page2Button.scrollIntoViewIfNeeded();
await page2Button.click();
console.log('Clicked page 2');
});
await test.step('Verify page 2 loaded a different first row', async () => {
let secondPageId = '';
for (let attempt = 0; attempt < 30; attempt++) {
const cells = await table.locator('tbody tr:not([aria-hidden="true"])').first().locator('td').allTextContents().catch(() => [] as string[]);
secondPageId = (cells[idIdx] || '').trim();
if (secondPageId && secondPageId !== firstPageId) break;
await page.waitForTimeout(400);
}
console.log('First terminal ID on page 2:', secondPageId);
expect(secondPageId, 'Page 2 first row should have a valid ID').toMatch(/^\d+$/);
expect(secondPageId, 'Page 2 should show a different first row than page 1').not.toBe(firstPageId);
const rowCount = await table.locator('tbody tr:not([aria-hidden="true"])').count();
expect(rowCount, 'Page 2 should have rows').toBeGreaterThan(0);
});
await test.step('Navigate back to page 1', async () => {
const page1Button = page.locator('button').filter({ hasText: /^1$/ }).first();
await expect(page1Button, 'A page 1 button should exist').toBeVisible({ timeout: 10000 });
await page1Button.click({ force: true });
await page.waitForTimeout(2000);
console.log('Navigated back to page 1');
});
console.log('Pagination test completed');
});
// Test 4: Download Report Test
test('should download report and verify content', async ({ page }) => {
feature('Terminal Operations');
tag('functional');
severity('normal');
description('Download terminal report in XSLX, cross-check data against portal table');
console.log('Testing terminal report download with cross-check...');
// Collect table data first
const tableRows: string[][] = [];
await test.step('Collect terminal table data', async () => {
const table = page.locator('table').first();
const headers = await table.locator('thead th').allTextContents();
const idIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'id');
expect(idIdx, 'An ID column should exist').toBeGreaterThanOrEqual(0);
const rows = table.locator('tbody tr:not([aria-hidden="true"])');
const rowCount = await rows.count();
console.log(`Terminal rows: ${rowCount}`);
for (let i = 0; i < rowCount; i++) {
const cells = await rows.nth(i).locator('td').allTextContents();
const cleaned = cells.map(c => c.trim());
// Ant Design renders a "No data" placeholder row with no real ID when
// a duplicate/sync copy of the table is present - require a genuine
// numeric ID so that phantom row doesn't get counted as a real terminal.
if (/^\d+$/.test(cleaned[idIdx] || '')) tableRows.push(cleaned);
}
console.log(`Collected ${tableRows.length} rows`);
expect(tableRows.length).toBeGreaterThan(0);
});
let downloadedContent = '';
await test.step('Download XSLX report', async () => {
const downloadButton = page.locator('button:has-text("Download report")').first();
await downloadButton.click();
await page.waitForTimeout(1000);
// Select XSLX — radio input with value="3"
const xlsxRadio = page.locator('input.ant-radio-input[value="3"]').first();
const xlsxVis = await xlsxRadio.isVisible().catch(() => false);
if (xlsxVis) {
await xlsxRadio.click({ force: true });
await page.waitForTimeout(500);
console.log('✓ XSLX selected via radio value=3');
} else {
// Fallback to wrapper
const wrapper = page.locator('.ant-radio-button-wrapper', { hasText: 'XSLX' }).first();
await wrapper.click();
await page.waitForTimeout(500);
console.log('✓ XSLX selected via wrapper');
}
const downloadPromise = page.waitForEvent('download', { timeout: 15000 });
const dlBtn = page.locator('button.bg-gradient-to-tr:has-text("Download")').first();
await dlBtn.click();
const download = await downloadPromise;
expect(download).toBeTruthy();
const filePath = await download.path();
console.log('✓ Downloaded:', download.suggestedFilename());
if (filePath) {
const fs = await import('fs');
const XLSX = await import('xlsx');
const buf = fs.readFileSync(filePath);
const wb = XLSX.read(buf, { type: 'buffer' });
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows: any[][] = XLSX.utils.sheet_to_json(sheet, { header: 1 });
downloadedContent = JSON.stringify(rows);
console.log(`XSLX rows: ${rows.length}`);
console.log('Header:', JSON.stringify(rows[0])?.substring(0, 200));
}
});
await test.step('Cross-check portal data against downloaded file', async () => {
expect(downloadedContent).toBeTruthy();
const unmatched: string[] = [];
for (const row of tableRows) {
const key = row[0] || row[1]; // ID or name
if (!key || !downloadedContent.includes(key)) unmatched.push(key || '(blank)');
}
const matchCount = tableRows.length - unmatched.length;
console.log(`Matched ${matchCount}/${tableRows.length} rows in XSLX`);
expect(unmatched, `Every table row's ID should appear in the downloaded report (unmatched: ${unmatched.join(', ')})`).toEqual([]);
});
console.log('✓ Download report + cross-check completed');
});
// Test 5: Remove Cashier from Terminal
test('should remove cashier from terminal', async ({ page }) => {
feature('Terminal Operations');
tag('functional');
severity('critical');
description('Remove cashier from terminal and verify it now shows Unassigned in both the details panel and the terminals table');
console.log('Testing cashier removal from terminal...');
const table = page.locator('table').first();
await table.locator('tbody tr:not([aria-hidden="true"])').first().waitFor({ state: 'visible', timeout: 15000 });
const headers = await table.locator('thead th').allTextContents();
const cashierColIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'cashier');
console.log('Table headers:', headers);
expect(cashierColIdx, 'A Cashier column should exist').toBeGreaterThanOrEqual(0);
let targetRow = -1;
let cashierName = '';
await test.step('Find a terminal with an assigned cashier', async () => {
const rows = table.locator('tbody tr:not([aria-hidden="true"])');
const rowCount = await rows.count();
console.log(`Total rows: ${rowCount}`);
for (let i = 0; i < rowCount; i++) {
const cashierText = (await rows.nth(i).locator('td').nth(cashierColIdx).textContent({ timeout: 2000 }))?.trim() || '';
console.log(`Row ${i} cashier: "${cashierText}"`);
if (cashierText && cashierText !== 'Unassigned' && cashierText !== '-') {
targetRow = i;
cashierName = cashierText;
console.log(`✓ Found terminal with assigned cashier at row ${i}: "${cashierName}"`);
break;
}
}
expect(targetRow, 'A terminal with an assigned cashier should exist').toBeGreaterThanOrEqual(0);
await rows.nth(targetRow).locator('button:has-text("Details")').click();
await page.waitForTimeout(2000);
});
await test.step('Click Remove Cashier button', async () => {
const removeCashierButton = page.locator('button:has-text("Remove Cashier")');
await expect(removeCashierButton, 'Remove Cashier button should be visible').toBeVisible({ timeout: 5000 });
await removeCashierButton.click();
await page.waitForTimeout(2000);
console.log('Remove Cashier button clicked');
});
await test.step('Verify the terminals table row shows Unassigned', async () => {
// Removing the cashier closes the details panel automatically (same
// behavior the reassign-cashier test accounts for) - dismiss defensively
// in case that ever changes, then read the row straight from the table.
const closeBtn = page.locator('button:has-text("Close"), button.ant-modal-close, [aria-label="Close"]').first();
if (await closeBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await closeBtn.click();
} else {
await page.keyboard.press('Escape').catch(() => {});
}
await page.waitForTimeout(2000);
const cashierText = (await table.locator('tbody tr:not([aria-hidden="true"])').nth(targetRow).locator('td').nth(cashierColIdx).textContent({ timeout: 5000 }))?.trim() || '';
console.log(`Table row ${targetRow} cashier after removal: "${cashierText}" (was "${cashierName}")`);
expect(cashierText, 'Table row should show Unassigned after removal').toBe('Unassigned');
});
console.log('Remove cashier test completed');
});
// Test 6: Remove cashier then reassign a different one
test('should reassign cashier to terminal', async ({ page }) => {
test.setTimeout(90000);
feature('Terminal Operations');
tag('functional');
severity('critical');
description('Find terminal with cashier, remove cashier, then reassign a different cashier');
console.log('Testing remove + reassign different cashier...');
let targetTerminalRow = -1;
let originalCashier = '';
// Step 1: Find a terminal with an assigned cashier
await test.step('Find terminal with assigned cashier', async () => {
const headers = await page.locator('table thead th').allTextContents();
console.log('Table headers:', headers);
const rows = page.locator('table tbody tr:not([aria-hidden="true"])');
const rowCount = await rows.count();
console.log('Total rows:', rowCount);
// Debug: print first few rows
if (rowCount > 0) {
const firstRowCells = await rows.nth(0).locator('td').allTextContents();
console.log('First row cells:', firstRowCells);
}
// Try to find a terminal with an assigned cashier
let cashierColIdx = -1;
for (let i = 0; i < rowCount; i++) {
const allCells = await rows.nth(i).locator('td').allTextContents();
// Look for "Unassigned" to identify the cashier column
const unassignedIdx = allCells.findIndex(cell => cell.trim() === 'Unassigned');
if (unassignedIdx >= 0) {
cashierColIdx = unassignedIdx;
console.log('Found cashier column at index:', cashierColIdx);
break;
}
}
// Find a terminal with an assigned cashier
for (let i = 0; i < rowCount; i++) {
const allCells = await rows.nth(i).locator('td').allTextContents();
const cashierText = (allCells[cashierColIdx] || '').trim();
if (cashierText && cashierText !== 'Unassigned' && cashierText !== '-') {
targetTerminalRow = i;
originalCashier = cashierText;
console.log(`✓ Found terminal with assigned cashier at row ${i}: "${originalCashier}"`);
break;
}
}
// If no assigned cashier found, use first terminal and assign one first
if (targetTerminalRow === -1) {
targetTerminalRow = 0;
originalCashier = 'Unassigned';
console.log('✓ No assigned cashier found. Will assign one to first terminal first.');
}
expect(targetTerminalRow).toBeGreaterThanOrEqual(0);
});
// Step 2: Open details and assign cashier if needed, then remove
await test.step('Assign cashier if unassigned, then remove', async () => {
const row = page.locator('table tbody tr:not([aria-hidden="true"])').nth(targetTerminalRow);
await row.locator('button:has-text("Details")').click();
await page.waitForTimeout(2000);
// If terminal is unassigned, assign a cashier first
if (originalCashier === 'Unassigned') {
console.log('Terminal is unassigned. Assigning a cashier first...');
const reassignBtn = page.locator('button:has-text("reassignCashier")');
const reassignVis = await reassignBtn.isVisible({ timeout: 5000 }).catch(() => false);
if (reassignVis) {
await reassignBtn.click();
await page.waitForTimeout(1000);
// Select first available cashier. Scope to the open modal/drawer and
// exclude read-only inputs: a separate, always-present, read-only
// combobox elsewhere on the page (id="rc_select_0") otherwise causes
// a strict-mode violation (2 elements matched) on the plain selector.
const detailsPanel = page.locator('.ant-modal-content, .ant-drawer-content, [role="dialog"]').first();
const searchInput = detailsPanel.locator('input.ant-select-selection-search-input[role="combobox"]:not([readonly])').first();
await searchInput.waitFor({ state: 'visible', timeout: 5000 });
await searchInput.click();
await page.waitForTimeout(1500);
const option = page.locator('.ant-select-item-option:visible').first();
const optText = await option.textContent();
await option.click();
await page.waitForTimeout(1000);
console.log('✓ Assigned cashier:', optText?.trim());
// Click Done
const doneBtn = page.locator('button.bg-green-500:has-text("Done")');
await doneBtn.click();
await page.waitForTimeout(3000);
console.log('✓ Assignment confirmed');
// Close and reopen to see the assigned cashier
const closeBtn = page.locator('button:has-text("Close"), button.ant-modal-close, [aria-label="Close"]').first();
const closeVis = await closeBtn.isVisible({ timeout: 3000 }).catch(() => false);
if (closeVis) {
await closeBtn.click();
await page.waitForTimeout(2000);
} else {
await page.keyboard.press('Escape');
await page.waitForTimeout(2000);
}
// Reopen
await row.locator('button:has-text("Details")').click();
await page.waitForTimeout(2000);
}
}
// Now remove the cashier
const removeBtn = page.locator('button:has-text("Remove Cashier")');
const removeBtnVis = await removeBtn.isVisible({ timeout: 5000 }).catch(() => false);
if (removeBtnVis) {
await removeBtn.click();
await page.waitForTimeout(3000);
console.log('✓ Cashier removed');
} else {
console.log('⚠ Remove button not found');
}
// Wait and check for reassignCashier button
await page.waitForTimeout(2000);
// Try different selectors for the reassign button
let reassignBtn = await page.locator('button:has-text("reassignCashier")').isVisible({ timeout: 3000 }).catch(() => false);
if (!reassignBtn) {
// Try alternative selectors
reassignBtn = await page.locator('button:has-text("Reassign")').isVisible({ timeout: 3000 }).catch(() => false);
}
if (!reassignBtn) {
// Try looking for any button with "assign" in text
reassignBtn = await page.locator('button:has-text("assign")').isVisible({ timeout: 3000 }).catch(() => false);
}
console.log('✓ reassignCashier button visible:', reassignBtn);
});
// Step 3: Close details, reopen to see reassignCashier button
await test.step('Close and reopen terminal details', async () => {
// Close the details panel
const closeBtn = page.locator('button:has-text("Close"), button.ant-modal-close, [aria-label="Close"]').first();
const closeVis = await closeBtn.isVisible({ timeout: 3000 }).catch(() => false);
if (closeVis) {
await closeBtn.click();
await page.waitForTimeout(2000);
} else {
await page.keyboard.press('Escape');
await page.waitForTimeout(2000);
}
// Reopen the same terminal details
const row = page.locator('table tbody tr:not([aria-hidden="true"])').nth(targetTerminalRow);
await row.locator('button:has-text("Details")').click();
await page.waitForTimeout(3000);
console.log('✓ Terminal details reopened');
});
await test.step('Click reassignCashier', async () => {
// Try different selectors for the reassign button
let reassignBtn = page.locator('button:has-text("reassignCashier")');
let vis = await reassignBtn.isVisible({ timeout: 3000 }).catch(() => false);
if (!vis) {
reassignBtn = page.locator('button:has-text("Reassign")');
vis = await reassignBtn.isVisible({ timeout: 3000 }).catch(() => false);
}
if (!vis) {
reassignBtn = page.locator('button:has-text("assign")');
vis = await reassignBtn.isVisible({ timeout: 3000 }).catch(() => false);
}
if (vis) {
await reassignBtn.scrollIntoViewIfNeeded().catch(() => {});
await page.waitForTimeout(500);
await reassignBtn.click();
await page.waitForTimeout(1000);
console.log('✓ reassignCashier clicked');
} else {
console.log('⚠ reassignCashier button not found, trying force click');
try {
await reassignBtn.click({ force: true });
await page.waitForTimeout(1000);
console.log('✓ reassignCashier clicked (force)');
} catch (e) {
console.log('⚠ Force click failed:', e);
}
}
});
await test.step('Select a different cashier', async () => {
const detailsPanel = page.locator('.ant-modal-content, .ant-drawer-content, [role="dialog"]').first();
const searchInput = detailsPanel.locator('input.ant-select-selection-search-input[role="combobox"]:not([readonly])').first();
await searchInput.waitFor({ state: 'visible', timeout: 5000 });
// Click to open dropdown and see all available cashiers
await searchInput.click();
await page.waitForTimeout(1500);
// Get all available options
const options = page.locator('.ant-select-item-option:visible');
const optCount = await options.count();
if (optCount > 0) {
// Select the first available cashier (skip if it's the original)
const firstOption = options.first();
const optText = await firstOption.textContent();
// If first option is the original cashier, try the second one
if (optText?.trim() === originalCashier && optCount > 1) {
const secondOption = options.nth(1);
const secondText = await secondOption.textContent();
await secondOption.click();
await page.waitForTimeout(1000);
console.log('✓ Selected new cashier (second option):', secondText?.trim());
} else {
await firstOption.click();
await page.waitForTimeout(1000);
console.log('✓ Selected new cashier (first option):', optText?.trim());
}
} else {
console.log('No available cashiers in dropdown');
}
});
await test.step('Click Done to confirm reassignment', async () => {
// Click the Done button to confirm
const doneBtn = page.locator('button:has-text("Done")');
const vis = await doneBtn.isVisible({ timeout: 5000 }).catch(() => false);
if (vis) {
await doneBtn.click();
await page.waitForTimeout(3000);
console.log('✓ Done clicked - cashier reassigned');
} else {
console.log('⚠ Done button not found');
}
});
await test.step('Verify new cashier assigned in table', async () => {
// Close details panel
const closeBtn = page.locator('button:has-text("Close"), button.ant-modal-close, [aria-label="Close"]').first();
const closeVis = await closeBtn.isVisible({ timeout: 3000 }).catch(() => false);
if (closeVis) {
await closeBtn.click();
await page.waitForTimeout(2000);
} else {
await page.keyboard.press('Escape');
await page.waitForTimeout(2000);
}
// Check the table row — the cashier column should now show the new cashier.
// Poll instead of a single fixed-wait read: under a full suite run the
// backend/table refresh can lag more than in isolation, which caused an
// intermittent false-negative here.
const headers = await page.locator('table thead th').allTextContents();
const cashierColIdx = headers.findIndex(h => h.toLowerCase().includes('cashier') && !h.toLowerCase().includes('id') && !h.toLowerCase().includes('username'));
const row = page.locator('table tbody tr:not([aria-hidden="true"])').nth(targetTerminalRow);
let cashierText = '';
for (let attempt = 0; attempt < 20; attempt++) {
cashierText = (await row.locator('td').nth(cashierColIdx >= 0 ? cashierColIdx : 3).textContent({ timeout: 5000 }).catch(() => ''))?.trim() || '';
if (cashierText && cashierText !== 'Unassigned' && cashierText !== '-' && cashierText !== originalCashier) break;
await page.waitForTimeout(500);
}
console.log(`Table row ${targetTerminalRow} cashier after reassign: "${cashierText}"`);
console.log(`Original cashier was: "${originalCashier}"`);
const isAssigned = cashierText && cashierText !== 'Unassigned' && cashierText !== '-';
const isDifferent = cashierText !== originalCashier;
console.log('Cashier is assigned:', isAssigned);
console.log('Cashier is different from original:', isDifferent);
expect(isAssigned).toBe(true);
expect(isDifferent).toBe(true);
});
console.log('✓ Remove + reassign different cashier completed');
});
// Test 7: Add Terminal
test('should add a new terminal', async ({ page }) => {
feature('Terminal Operations');
tag('functional');
severity('critical');
description('Add a new terminal with Terminal ID, Provider, Terminal Model and Mada Package');
console.log('Testing add terminal...');
// Generate a unique exactly-8-digit terminal ID (1000000099999999)
const newTerminalId = String(10000000 + (Date.now() % 90000000)).slice(0, 8);
console.log('New Terminal ID to create:', newTerminalId);
await test.step('Click Add terminal button to open form', async () => {
// The first "Add terminal" button (anticon-plus) opens/shows the inline form
const addTerminalBtn = page.locator('button:has(.anticon-plus):has-text("Add terminal"), button:has([aria-label="plus"]):has-text("Add terminal")').first();
const isVisible = await addTerminalBtn.isVisible({ timeout: 5000 }).catch(() => false);
if (isVisible) {
await addTerminalBtn.click();
await page.waitForTimeout(2000);
console.log('✓ Add terminal form opened');
} else {
// Fallback: try all buttons with "Add terminal" text and pick the one with anticon-plus (not anticon-plus-square)
const allAddBtns = page.locator('button:has-text("Add terminal")');
const count = await allAddBtns.count();
console.log(`Found ${count} "Add terminal" buttons`);
for (let i = 0; i < count; i++) {
const btn = allAddBtns.nth(i);
const hasPlusSquare = await btn.locator('.anticon-plus-square').isVisible().catch(() => false);
if (!hasPlusSquare) {
await btn.click();
await page.waitForTimeout(2000);
console.log(`✓ Clicked "Add terminal" button ${i} (open form button)`);
break;
}
}
}
});
await test.step('Fill Terminal ID', async () => {
// Scope to the open modal: the page's top-level search box has the
// placeholder "Search by Terminal id or cashier name", which also
// matches a loose `placeholder*="Terminal Id" i` selector and, being
// first in DOM order, silently absorbed the fill instead of the real
// form field - leaving every submitted terminal with a blank ID.
const modal = page.locator('.ant-modal-content, .ant-drawer-content, [role="dialog"]').first();
const terminalIdInput = modal.locator('input[placeholder="Terminal Id"]').first();
await terminalIdInput.waitFor({ state: 'visible', timeout: 5000 });
await terminalIdInput.fill(newTerminalId);
const filledValue = await terminalIdInput.inputValue();
expect(filledValue, 'Terminal ID field should contain the value we filled').toBe(newTerminalId);
console.log('✓ Terminal ID filled:', newTerminalId);
});
await test.step('Select Terminal Provider', async () => {
const providerSelect = page.locator('.ant-select').filter({ hasText: /Select terminal provider/i }).first();
const isVisible = await providerSelect.isVisible().catch(() => false);
if (isVisible) {
await providerSelect.click();
await page.waitForTimeout(1000);
const firstOption = page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option').first();
const optText = await firstOption.textContent();
await firstOption.click({ force: true });
await page.waitForTimeout(500);
console.log('✓ Provider selected:', optText?.trim());
} else {
console.log('Provider dropdown not found');
}
});
await test.step('Select Terminal Model', async () => {
const modelSelect = page.locator('.ant-select').filter({ hasText: /Select terminal model/i }).first();
const isVisible = await modelSelect.isVisible().catch(() => false);
if (isVisible) {
await modelSelect.click();
await page.waitForTimeout(1000);
const firstOption = page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option').first();
const optText = await firstOption.textContent();
await firstOption.click({ force: true });
await page.waitForTimeout(500);
console.log('✓ Terminal model selected:', optText?.trim());
} else {
console.log('Terminal model dropdown not found');
}
});
await test.step('Select Mada Package', async () => {
const packageSelect = page.locator('.ant-select').filter({ hasText: /Select mada package/i }).first();
const isVisible = await packageSelect.isVisible().catch(() => false);
if (isVisible) {
await packageSelect.click();
await page.waitForTimeout(1000);
const firstOption = page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option').first();
const optText = await firstOption.textContent();
await firstOption.click({ force: true });
await page.waitForTimeout(500);
console.log('✓ Mada package selected:', optText?.trim());
} else {
console.log('Mada package dropdown not found');
}
});
await test.step('Click submit Add terminal button (anticon-plus-square)', async () => {
// The second "Add terminal" button (anticon-plus-square) submits the form
const submitBtn = page.locator('button:has(.anticon-plus-square):has-text("Add terminal"), button:has([aria-label="plus-square"]):has-text("Add terminal")').first();
const isVisible = await submitBtn.isVisible({ timeout: 3000 }).catch(() => false);
if (isVisible) {
await submitBtn.click();
await page.waitForTimeout(3000);
console.log('✓ Form submitted via Add terminal (plus-square) button');
} else {
// Fallback: find the button with anticon-plus-square among all "Add terminal" buttons
const allAddBtns = page.locator('button:has-text("Add terminal")');
const count = await allAddBtns.count();
for (let i = 0; i < count; i++) {
const btn = allAddBtns.nth(i);
const hasPlusSquare = await btn.locator('.anticon-plus-square').isVisible().catch(() => false);
if (hasPlusSquare) {
await btn.click();
await page.waitForTimeout(3000);
console.log(`✓ Submitted via "Add terminal" button ${i} (plus-square)`);
break;
}
}
}
});
await test.step('Verify new terminal appears in table', async () => {
// Dismiss any modals
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(1000);
// The list has 200+ pages and a new terminal isn't guaranteed to sort
// onto page 1, so search for it directly instead of paging blindly.
const searchInput = page.locator('input[placeholder*="Search by Terminal id" i]').first();
await expect(searchInput, 'Terminal search input should be visible').toBeVisible({ timeout: 10000 });
await searchInput.fill(newTerminalId);
await page.waitForTimeout(2000);
const terminalRow = page.locator('table tbody tr').filter({ hasText: newTerminalId }).first();
await expect(terminalRow, `New terminal ${newTerminalId} should appear in search results`).toBeVisible({ timeout: 10000 });
console.log(`✓ New terminal "${newTerminalId}" found via search`);
});
console.log('Add terminal test completed');
});
});

854
tests/transactions.spec.ts Normal file
View File

@@ -0,0 +1,854 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
import { epic, feature, severity, description, tag } from './helpers/allure';
import { navigateToTransactions } from './helpers/navigation';
// Parses either an ISO picker-cell title (YYYY-MM-DD) or a display-format
// table date ("Jul 6, 2026") into a comparable Date.
const toDate = (s: string | null | undefined): Date | null => {
if (!s) return null;
const iso = s.match(/(\d{4})-(\d{2})-(\d{2})/);
if (iso) return new Date(`${iso[1]}-${iso[2]}-${iso[3]}T00:00:00`);
const disp = s.match(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}),\s*(\d{4})/);
if (disp) {
const months: Record<string, string> = {
Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06',
Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12',
};
return new Date(`${disp[3]}-${months[disp[1]]}-${disp[2].padStart(2, '0')}T00:00:00`);
}
return null;
};
const toISO = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
test.describe('Transaction Tests', () => {
test.setTimeout(90000);
test.beforeEach(async ({ page }) => {
epic('Transaction Management');
await login(page);
await navigateToTransactions(page);
await page.waitForTimeout(2000);
});
test('should have filter or search functionality', async ({ page }) => {
feature('Transaction Display');
severity('normal');
description('Verify that filter or search controls are available and can search by RRN');
console.log('Checking for filter/search functionality...');
let rrnValue = '';
await test.step('Get RRN from first transaction', async () => {
// On accounts with a large transaction history the table can take
// ~10s to populate - wait for a row to actually render rather than
// a fixed sleep, or every cell lookup below sees an empty table.
await page.locator('table tbody tr.ant-table-row').first()
.waitFor({ state: 'visible', timeout: 30000 }).catch(() => {});
// Try to get RRN from the visible table data
// Look for all cells that might contain RRN (12-digit numbers)
const allCells = page.locator('table tbody tr td');
const cellCount = await allCells.count();
console.log(`Total cells in table: ${cellCount}`);
// Search through cells for a 12-digit number (typical RRN format)
for (let i = 0; i < Math.min(cellCount, 50); i++) {
try {
const cellText = await allCells.nth(i).textContent({ timeout: 1000 });
if (cellText && /^\d{12}$/.test(cellText.trim())) {
rrnValue = cellText.trim();
console.log(`Found RRN at cell ${i}:`, rrnValue);
break;
}
} catch (e) {
// Skip cells that timeout
continue;
}
}
if (!rrnValue) {
console.log('Could not extract RRN from table, will skip search test');
}
});
await test.step('Verify search input exists', async () => {
const searchInput = await page.locator('input[type="search"], input[placeholder*="search" i]').first().isVisible().catch(() => false);
const filterButton = await page.locator('button:has-text("Filter"), [class*="filter" i]').first().isVisible().catch(() => false);
console.log('Search input visible:', searchInput);
console.log('Filter button visible:', filterButton);
expect(searchInput || filterButton).toBeTruthy();
});
await test.step(`Search by RRN: ${rrnValue}`, async () => {
if (!rrnValue) {
console.log('No RRN value to search, skipping search test');
return;
}
const searchInput = page.locator('input[type="search"], input[placeholder*="search" i]').first();
const isVisible = await searchInput.isVisible().catch(() => false);
if (isVisible) {
console.log(`Entering RRN: ${rrnValue} in search field...`);
await searchInput.clear();
await searchInput.fill(rrnValue);
await page.waitForTimeout(1000);
// Trigger search
await page.keyboard.press('Enter');
await page.waitForTimeout(3000);
console.log('Search triggered');
// Verify results
const resultCount = await page.locator('table tbody tr').count();
console.log('Search result count:', resultCount);
// Check if RRN appears in results
const rrnFound = await page.locator(`text=${rrnValue}`).first().isVisible().catch(() => false);
console.log(`RRN ${rrnValue} found in results:`, rrnFound);
if (rrnFound) {
console.log('✓ Search by RRN successful');
expect(rrnFound).toBeTruthy();
} else if (resultCount > 0) {
console.log('⚠ Results found but RRN not visible (may be in different format)');
expect(resultCount).toBeGreaterThan(0);
} else {
console.log(`⚠ No results found for RRN ${rrnValue}`);
}
} else {
console.log('Search input not found');
}
});
console.log('Filter/search functionality verified');
});
// Functional Tests
test('should filter transactions by date range', async ({ page }) => {
feature('Transaction Operations');
tag('functional');
severity('critical');
description('Filter transactions by a date range anchored on the latest real transaction, then assert every returned row falls within it');
console.log('Testing date range filter...');
let startTitle = '';
let endTitle = '';
// Scope to the transactions data table via its unique "RRN" header, and
// anchor the range on the newest row actually in the table (same-month
// 1st -> that day) rather than a fixed date - this dev dataset's "today"
// moves on, so a hardcoded date (the old "March 2026" here) eventually
// falls outside the calendar's visible range and silently selects nothing.
const table = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first();
await test.step('Determine target date range from the latest transaction', async () => {
await table.locator('tbody tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 15000 });
const headers = await table.locator('thead th').allTextContents();
const dateIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'init date');
expect(dateIdx, 'An init date column should exist').toBeGreaterThanOrEqual(0);
const firstRowCells = await table.locator('tbody tr.ant-table-row').first().locator('td').allTextContents();
const latestDate = toDate(firstRowCells[dateIdx] || '');
expect(latestDate, 'The newest transaction should have a parseable date').not.toBeNull();
startTitle = toISO(new Date(latestDate!.getFullYear(), latestDate!.getMonth(), 1));
endTitle = toISO(latestDate!);
console.log(`Target range from latest transaction (${firstRowCells[dateIdx]}): ${startTitle}..${endTitle}`);
});
await test.step('Open filter controls', async () => {
const filterButton = page.locator('button:has-text("Filter"), [class*="filter" i]').first();
const isVisible = await filterButton.isVisible().catch(() => false);
if (isVisible) {
await filterButton.click();
await page.waitForTimeout(1000);
console.log('Filter controls opened');
}
});
await test.step('Select the target date range', async () => {
const dateField = page.locator('input[date-range="start"]').first();
await expect(dateField, 'Date range start input should be visible').toBeVisible({ timeout: 10000 });
await dateField.click();
await page.waitForTimeout(1500);
const calendarVisible = await page.locator('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)').isVisible().catch(() => false);
expect(calendarVisible, 'Date range calendar should open').toBe(true);
// Navigate the calendar backward (bounded) until the target start date
// is actually in view.
const prevBtn = page.locator('.ant-picker-header-prev-btn').first();
let found = false;
for (let attempt = 0; attempt < 60; attempt++) {
found = await page.locator(`.ant-picker-cell-in-view[title="${startTitle}"]:not(.ant-picker-cell-disabled)`).first().isVisible().catch(() => false);
if (found) break;
await prevBtn.click();
await page.waitForTimeout(300);
}
expect(found, `Target start date ${startTitle} should become available in the calendar`).toBe(true);
await page.locator(`.ant-picker-cell-in-view[title="${startTitle}"]`).first().click();
await page.waitForTimeout(800);
await page.locator(`.ant-picker-cell-in-view[title="${endTitle}"]`).first().click();
await page.waitForTimeout(1000);
console.log(`✓ Date range selected: ${startTitle}..${endTitle}`);
});
await test.step('Apply filter', async () => {
const applyButton = page.locator('button:has-text("Apply"), button:has-text("Search"), button[type="submit"]').first();
const isVisible = await applyButton.isVisible().catch(() => false);
if (isVisible) {
await applyButton.click();
await page.waitForTimeout(3000);
console.log('Filter applied');
}
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(500);
});
await test.step('Assert every returned row date is valid and within range', async () => {
const startDate = toDate(startTitle)!;
const endDate = toDate(endTitle)!;
endDate.setHours(23, 59, 59, 999);
const headers = await table.locator('thead th').allTextContents();
const dateIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'init date');
console.log(`Date column index: ${dateIdx} (headers: ${headers.join(' | ')})`);
expect(dateIdx, 'A date column should exist').toBeGreaterThanOrEqual(0);
const rows = table.locator('tbody tr.ant-table-row');
const rowCount = await rows.count();
console.log(`Validating ${rowCount} rows are within ${startTitle}..${endTitle}`);
for (let i = 0; i < rowCount; i++) {
const cellTexts = await rows.nth(i).locator('td').allTextContents();
const cellText = cellTexts[dateIdx] || '';
const rowDate = toDate(cellText);
expect(rowDate, `Row ${i + 1} should have a valid date (got "${cellText}")`).not.toBeNull();
const t = rowDate!.getTime();
expect(
t >= startDate.getTime() && t <= endDate.getTime(),
`Row ${i + 1} date ${cellText} should be within ${startTitle}..${endTitle}`
).toBe(true);
}
console.log(`✓ All ${rowCount} returned rows fall within the selected range`);
});
console.log('Date range filter test completed');
});
test('should view transaction details', async ({ page }) => {
feature('Transaction Operations');
tag('functional');
severity('critical');
description('Test clicking on Receipt button to view transaction details and verify data matches between table and details panel');
console.log('Testing transaction details view...');
let transactionId = '';
let transactionAmount = '';
let transactionStatus = '';
let transactionRRN = '';
await test.step('Capture transaction data from table row', async () => {
// On accounts with a large transaction history the table can take
// ~10s to populate - wait for a row to actually render rather than
// a fixed sleep, or the Receipt lookup below finds an empty table.
const firstRow = page.locator('table tbody tr:has(button:has-text("Receipt"))').first();
await firstRow.waitFor({ state: 'visible', timeout: 30000 }).catch(() => {});
const isVisible = await firstRow.isVisible().catch(() => false);
if (isVisible) {
// Get the entire row text to extract data
const rowText = await firstRow.textContent();
console.log('Row text:', rowText?.substring(0, 200));
if (rowText) {
// Extract ID (6 digits at the start)
const idMatch = rowText.match(/^(\d{6})/);
if (idMatch) {
transactionId = idMatch[1];
console.log('Transaction ID:', transactionId);
}
// Extract Amount (number with .00 format)
const amountMatch = rowText.match(/(\d+\.\d{2})/);
if (amountMatch) {
transactionAmount = amountMatch[1];
console.log('Transaction Amount:', transactionAmount);
}
// Extract Status
if (rowText.includes('Approved')) {
transactionStatus = 'Approved';
} else if (rowText.includes('Declined')) {
transactionStatus = 'Declined';
} else if (rowText.includes('Initilized')) {
transactionStatus = 'Initilized';
} else if (rowText.includes('Timeout')) {
transactionStatus = 'Timeout';
}
console.log('Transaction Status:', transactionStatus);
// Extract RRN (12 digits)
const rrnMatch = rowText.match(/(\d{12})/);
if (rrnMatch) {
transactionRRN = rrnMatch[1];
console.log('Transaction RRN:', transactionRRN);
}
}
}
});
await test.step('Click on Receipt button in Action column', async () => {
await page.locator('table tbody tr button:has-text("Receipt")').first()
.waitFor({ state: 'visible', timeout: 30000 }).catch(() => {});
const receiptButtonSelectors = [
'table tbody tr button:has-text("Receipt")',
'table tbody tr button.text-neo-primary:has-text("Receipt")',
'button.text-neo-primary.underline:has-text("Receipt")'
];
let buttonClicked = false;
for (const selector of receiptButtonSelectors) {
const receiptButton = page.locator(selector).first();
const isVisible = await receiptButton.isVisible().catch(() => false);
if (isVisible) {
console.log(`Found Receipt button with selector: ${selector}`);
console.log('Clicking Receipt button...');
await receiptButton.click();
await page.waitForTimeout(2000);
console.log('✓ Receipt button clicked');
buttonClicked = true;
break;
}
}
if (!buttonClicked) {
console.log('Receipt button not found');
}
});
await test.step('Verify transaction details panel opened', async () => {
const detailsPanel = await page.locator('text=Transaction Details').first().isVisible().catch(() => false);
const amountField = await page.locator('text=/Amount|amount/i').first().isVisible().catch(() => false);
console.log('Transaction Details panel visible:', detailsPanel);
console.log('Amount field visible:', amountField);
expect(detailsPanel || amountField).toBeTruthy();
});
await test.step('Verify transaction ID matches', async () => {
if (transactionId) {
const idInDetails = await page.locator(`text=${transactionId}`).first().isVisible().catch(() => false);
console.log(`Transaction ID ${transactionId} found in details:`, idInDetails);
if (idInDetails) {
console.log('✓ Transaction ID matches');
expect(idInDetails).toBeTruthy();
}
}
});
await test.step('Verify amount matches', async () => {
if (transactionAmount) {
const amountInDetails = await page.locator(`text=${transactionAmount}`).first().isVisible().catch(() => false);
console.log(`Amount ${transactionAmount} found in details:`, amountInDetails);
if (amountInDetails) {
console.log('✓ Amount matches');
expect(amountInDetails).toBeTruthy();
} else {
console.log('⚠ Amount not found in exact format, checking for partial match');
}
}
});
await test.step('Verify status matches', async () => {
if (transactionStatus) {
const statusInDetails = await page.locator(`text=${transactionStatus}`).first().isVisible().catch(() => false);
console.log(`Status "${transactionStatus}" found in details:`, statusInDetails);
if (statusInDetails) {
console.log('✓ Status matches');
expect(statusInDetails).toBeTruthy();
}
}
});
await test.step('Verify RRN matches', async () => {
if (transactionRRN) {
const rrnInDetails = await page.locator(`text=${transactionRRN}`).first().isVisible().catch(() => false);
console.log(`RRN ${transactionRRN} found in details:`, rrnInDetails);
if (rrnInDetails) {
console.log('✓ RRN matches');
expect(rrnInDetails).toBeTruthy();
}
}
});
await test.step('Verify additional transaction details are displayed', async () => {
const hasScheme = await page.locator('text=/Scheme|scheme|mada|visa|mastercard/i').first().isVisible().catch(() => false);
const hasTerminal = await page.locator('text=/Terminal|terminal/i').first().isVisible().catch(() => false);
const hasTimeline = await page.locator('text=Transaction Timeline').first().isVisible().catch(() => false);
const hasReceipt = await page.locator('text=/Receipt|Approval code|Authorization/i').first().isVisible().catch(() => false);
console.log('Scheme field visible:', hasScheme);
console.log('Terminal field visible:', hasTerminal);
console.log('Transaction Timeline visible:', hasTimeline);
console.log('Receipt information visible:', hasReceipt);
const detailsCount = [hasScheme, hasTerminal, hasTimeline, hasReceipt].filter(Boolean).length;
console.log(`${detailsCount}/4 additional detail sections visible`);
expect(detailsCount).toBeGreaterThan(1);
});
await test.step('Close details panel', async () => {
const closeButton = page.locator('button:has-text("Close"), button:has-text("×"), [aria-label*="close" i]').first();
const isVisible = await closeButton.isVisible().catch(() => false);
if (isVisible) {
await closeButton.click();
await page.waitForTimeout(1000);
console.log('Details panel closed');
} else {
console.log('Close button not found, panel may close automatically');
}
});
console.log('Transaction details view test completed with full verification');
});
test('should export transactions', async ({ page }) => {
feature('Transaction Operations');
tag('functional');
severity('normal');
description('Export transactions as CSV for the latest transaction date, and verify the file contains those transactions');
console.log('Testing transaction export...');
const table = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first();
let startTitle = '';
let endTitle = '';
const expectedIds: string[] = [];
await test.step('Determine target date and capture in-range IDs', async () => {
await table.locator('tbody tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 15000 });
const headers = await table.locator('thead th').allTextContents();
const idIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'id');
const dateIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'init date');
expect(idIdx, 'An ID column should exist').toBeGreaterThanOrEqual(0);
expect(dateIdx, 'An init date column should exist').toBeGreaterThanOrEqual(0);
const firstRowCells = await table.locator('tbody tr.ant-table-row').first().locator('td').allTextContents();
const latestDate = toDate(firstRowCells[dateIdx] || '');
expect(latestDate, 'The newest transaction should have a parseable date').not.toBeNull();
// Use a same-day range (start == end): the export backend has a known
// bug where any multi-day date range silently fails to produce a
// download (confirmed against both a 1-week and a 2-month range),
// while a single day works reliably every time.
startTitle = toISO(latestDate!);
endTitle = toISO(latestDate!);
console.log(`Target date from latest transaction (${firstRowCells[dateIdx]}): ${startTitle}`);
const startBound = toDate(startTitle)!;
const endBound = toDate(endTitle)!;
endBound.setHours(23, 59, 59, 999);
const rows = table.locator('tbody tr.ant-table-row');
const rowCount = await rows.count();
for (let i = 0; i < rowCount; i++) {
const cells = await rows.nth(i).locator('td').allTextContents();
const rowDate = toDate(cells[dateIdx] || '');
if (!rowDate) continue;
const t = rowDate.getTime();
if (t >= startBound.getTime() && t <= endBound.getTime()) {
const id = (cells[idIdx] || '').replace(/\D/g, '').trim();
if (id) expectedIds.push(id);
}
}
console.log(`Captured ${expectedIds.length} in-range transaction IDs on the current page`);
expect(expectedIds.length, 'At least one transaction row should fall in range').toBeGreaterThan(0);
});
await test.step('Open Download report modal', async () => {
const downloadButton = page.locator('button:has-text("Download report")').first();
await expect(downloadButton, 'Download report button should be visible').toBeVisible({ timeout: 5000 });
await downloadButton.click();
await page.waitForTimeout(1500);
console.log('✓ Download report modal opened');
});
await test.step('Select the target date range', async () => {
const dateField = page.locator('input[date-range="start"]').first();
await expect(dateField, 'Date range start input should be visible').toBeVisible({ timeout: 10000 });
await dateField.click();
await page.waitForTimeout(1500);
const calendarVisible = await page.locator('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)').isVisible().catch(() => false);
expect(calendarVisible, 'Date range calendar should open').toBe(true);
const prevBtn = page.locator('.ant-picker-header-prev-btn').first();
let found = false;
for (let attempt = 0; attempt < 60; attempt++) {
found = await page.locator(`.ant-picker-cell-in-view[title="${startTitle}"]:not(.ant-picker-cell-disabled)`).first().isVisible().catch(() => false);
if (found) break;
await prevBtn.click();
await page.waitForTimeout(300);
}
expect(found, `Target start date ${startTitle} should become available in the calendar`).toBe(true);
await page.locator(`.ant-picker-cell-in-view[title="${startTitle}"]`).first().click();
await page.waitForTimeout(800);
await page.locator(`.ant-picker-cell-in-view[title="${endTitle}"]`).first().click();
await page.waitForTimeout(1000);
console.log(`✓ Date range selected: ${startTitle}..${endTitle}`);
// innerText() on the dialog doesn't capture <input> values, so if the
// download never fires we can't otherwise tell whether the date range
// actually committed to the form - log the real input values and
// whether the calendar dropdown is still open (which would mean the
// range was never confirmed/closed).
const startVal = await page.locator('input[date-range="start"]').first().inputValue().catch(() => '(unreadable)');
const endVal = await page.locator('input[date-range="end"]').first().inputValue().catch(() => '(unreadable)');
const calendarStillOpen = await page.locator('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)').isVisible().catch(() => false);
console.log(`Date range input values -> start: "${startVal}", end: "${endVal}". Calendar still open: ${calendarStillOpen}`);
});
await test.step('Select CSV format', async () => {
// Default format is PDF (radio value="1"); switch to CSV (value="2")
// so the downloaded file's content can actually be cross-checked.
const csvRadio = page.locator('input.ant-radio-input[value="2"]').first();
if (await csvRadio.isVisible({ timeout: 3000 }).catch(() => false)) {
await csvRadio.click({ force: true });
await page.waitForTimeout(500);
console.log('✓ CSV format selected');
} else {
console.log('CSV radio not found, proceeding with default format');
}
});
await test.step('Download and verify the file contains the in-range transactions', async () => {
// Large result sets switch to an async email-delivery flow ("Your
// transaction report is being generated... link via email") instead of
// firing a direct browser download. A single day is usually small
// enough to download directly, but this dev dataset grows constantly,
// so treat the async message as a legitimate outcome, not a failure.
// Record the report API traffic this click triggers - when neither a
// download nor the async message shows up, the response status is the
// only thing that says whether the request was even made, and how the
// backend answered.
const apiCalls: string[] = [];
page.on('response', (r) => {
if (/report|download|export/i.test(r.url())) {
apiCalls.push(`${r.status()} ${r.request().method()} ${r.url().slice(0, 200)}`);
}
});
const downloadPromise = page.waitForEvent('download', { timeout: 45000 }).catch(() => null);
const modalDownloadButton = page.locator('[role="dialog"] button:has-text("Download"), .ant-modal button:has-text("Download")').first();
await expect(modalDownloadButton, 'Download button in modal should be visible').toBeVisible({ timeout: 5000 });
const btnEnabled = await modalDownloadButton.isEnabled().catch(() => false);
await modalDownloadButton.click();
console.log(`Download button clicked (enabled: ${btnEnabled}), waiting for download...`);
const download = await downloadPromise;
if (!download) {
const asyncMessage = await page.locator('text=/being generated|download link via email/i').first().isVisible({ timeout: 8000 }).catch(() => false);
if (!asyncMessage) {
const dialogText = await page.locator('[role="dialog"], .ant-modal-content').first().innerText().catch(() => '(no dialog/modal found)');
console.log('Neither download nor async message appeared. Dialog/modal content:', dialogText.slice(0, 500));
console.log('Report-related API calls seen:', apiCalls.length ? apiCalls.join(' | ') : '(none)');
const toastText = await page.locator('.ant-message, .ant-notification, [role="alert"]').allTextContents().catch(() => []);
console.log('Toast/notification text:', toastText.length ? toastText.join(' | ') : '(none)');
// 204 No Content means the backend had nothing to report for the
// requested range, so no file is ever produced and asserting one
// would be wrong. This test cross-checks the CSV against IDs
// captured for this specific date, so retrying a different date
// would invalidate that comparison - skip instead, and log the
// request so a wrong requested range still stands out.
const noContent = apiCalls.find((c) => c.startsWith('204'));
test.skip(!!noContent, `Report API returned 204 No Content - nothing to download for the requested range (${noContent})`);
}
expect(asyncMessage, 'Either a direct download should fire, or the async email-report message should appear').toBe(true);
console.log('✓ Result set was too large for a direct download - async email-report flow triggered as expected');
return;
}
const fileName = download.suggestedFilename();
console.log('✓ Download captured! File name:', fileName);
const isValidFormat = /\.(csv|xlsx|xls|pdf)$/i.test(fileName);
expect(isValidFormat, `Downloaded file should have a valid export extension (got "${fileName}")`).toBe(true);
const filePath = await download.path();
expect(filePath, 'Downloaded file should have a local path').toBeTruthy();
if (/\.csv$/i.test(fileName) && filePath) {
const fs = await import('fs');
const content = fs.readFileSync(filePath, 'utf-8');
console.log(`CSV size: ${content.length} chars`);
const missing = expectedIds.filter((id) => !content.includes(id));
console.log(`Verifying ${expectedIds.length} IDs in CSV; missing: ${missing.length ? missing.join(', ') : 'none'}`);
expect(missing, `All captured transaction IDs should appear in the CSV (missing: ${missing.join(', ')})`).toEqual([]);
} else {
console.log(`Downloaded format (${fileName}) is not CSV - skipping text content cross-check`);
}
});
console.log('Transaction export test completed');
});
test('should paginate through transaction list', async ({ page }) => {
feature('Transaction Operations');
tag('functional');
severity('normal');
description('Test pagination controls walk through pages 1-7, each loading a genuinely new set of rows, then back to page 1');
console.log('Testing pagination...');
const table = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first();
await table.locator('tbody tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 15000 });
const headers = await table.locator('thead th').allTextContents();
const idIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'id');
expect(idIdx, 'An ID column should exist').toBeGreaterThanOrEqual(0);
const firstRowId = async (): Promise<string> => {
const cells = await table.locator('tbody tr.ant-table-row').first().locator('td').allTextContents().catch(() => [] as string[]);
return (cells[idIdx] || '').trim();
};
const waitForIdNotIn = async (seen: Set<string>): Promise<string> => {
let id = '';
for (let attempt = 0; attempt < 30; attempt++) {
id = await firstRowId();
if (id && !seen.has(id)) break;
await page.waitForTimeout(400);
}
return id;
};
const seenIds: string[] = [];
const LAST_PAGE = 7;
await test.step('Capture first row ID on page 1', async () => {
const id = await firstRowId();
expect(id, 'Page 1 first row should have a valid ID').toMatch(/^\d+$/);
seenIds.push(id);
console.log(`Page 1 first row ID: ${id}`);
});
const hasPage2 = await page.locator('button').filter({ hasText: /^2$/ }).first().isVisible({ timeout: 10000 }).catch(() => false);
test.skip(!hasPage2, 'Not enough transactions in this environment to require a second page');
for (let pageNum = 2; pageNum <= LAST_PAGE; pageNum++) {
let ranOutOfPages = false;
await test.step(`Navigate to page ${pageNum} and verify a new first row`, async () => {
const pageButton = page.locator('button').filter({ hasText: new RegExp(`^${pageNum}$`) }).first();
const hasPage = await pageButton.isVisible({ timeout: 10000 }).catch(() => false);
if (!hasPage) {
// Fewer pages exist in this environment than LAST_PAGE assumes -
// that's fine, the test's goal is just to verify pagination works
// across however many pages actually exist.
console.log(`No page ${pageNum} button - reached the last page (${pageNum - 1} pages total)`);
ranOutOfPages = true;
return;
}
await pageButton.scrollIntoViewIfNeeded();
await pageButton.click();
const id = await waitForIdNotIn(new Set(seenIds));
expect(id, `Page ${pageNum} first row should have a valid ID`).toMatch(/^\d+$/);
expect(seenIds, `Page ${pageNum} should show a first row not already seen on an earlier page (got "${id}")`).not.toContain(id);
seenIds.push(id);
console.log(`Page ${pageNum} first row ID: ${id}`);
});
if (ranOutOfPages) break;
}
await test.step('Navigate back to page 1', async () => {
const page1Button = page.locator('button').filter({ hasText: /^1$/ }).first();
await expect(page1Button, 'A page 1 button should exist').toBeVisible({ timeout: 10000 });
await page1Button.click();
let backToOriginal = false;
for (let attempt = 0; attempt < 30; attempt++) {
const currentId = await firstRowId();
if (currentId === seenIds[0]) { backToOriginal = true; break; }
await page.waitForTimeout(400);
}
expect(backToOriginal, 'Should return to the original page 1 first row').toBe(true);
console.log('✓ Successfully returned to page 1');
});
console.log(`Pagination test completed - walked through ${LAST_PAGE} pages, all first rows distinct`);
});
test('should filter transactions by Approved status and date range', async ({ page }) => {
feature('Transaction Operations');
tag('functional');
severity('critical');
description('Filter transactions to Approved-only within a date range anchored on the latest transaction, then assert every returned row is Approved and in range');
console.log('Testing filter by Approved status and date range...');
const table = page.locator('table').filter({ has: page.locator('th:has-text("RRN")') }).first();
let startTitle = '';
let endTitle = '';
await test.step('Determine target date range from the latest transaction', async () => {
await table.locator('tbody tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 15000 });
const headers = await table.locator('thead th').allTextContents();
const dateIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'init date');
expect(dateIdx, 'An init date column should exist').toBeGreaterThanOrEqual(0);
const firstRowCells = await table.locator('tbody tr.ant-table-row').first().locator('td').allTextContents();
const latestDate = toDate(firstRowCells[dateIdx] || '');
expect(latestDate, 'The newest transaction should have a parseable date').not.toBeNull();
// A narrow few-day window ending on the latest transaction, rather
// than the whole month - keeps the result set small enough to
// actually eyeball/debug, and a full month isn't needed to prove the
// status filter works.
startTitle = toISO(new Date(latestDate!.getFullYear(), latestDate!.getMonth(), latestDate!.getDate() - 2));
endTitle = toISO(latestDate!);
console.log(`Target range from latest transaction (${firstRowCells[dateIdx]}): ${startTitle}..${endTitle}`);
});
await test.step('Open filter modal', async () => {
const filterButton = page.locator('button:has-text("Filter")').first();
await expect(filterButton, 'Filter button should be visible').toBeVisible({ timeout: 10000 });
await filterButton.click();
await page.waitForTimeout(1500);
console.log('✓ Filter modal opened');
});
await test.step('Select Approved status only', async () => {
// Every status checkbox is checked by default (i.e. "show all
// statuses"). To filter to Approved-only we must uncheck every OTHER
// status and leave Approved checked - simply clicking the Approved
// label toggles it OFF and leaves everything else showing, which is
// the opposite of what this test needs (same bug found and fixed in
// the equivalent refunds test).
const statusSection = page.locator('article:has-text("Transaction Status")').locator('xpath=following-sibling::div[1]');
const statusLabels = statusSection.locator('label.ant-checkbox-wrapper');
const count = await statusLabels.count();
expect(count, 'There should be status checkboxes').toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
const label = statusLabels.nth(i);
const text = (await label.innerText()).trim();
const isChecked = await label.locator('input[type="checkbox"]').isChecked();
if (text === 'Approved') {
if (!isChecked) {
console.log('Approved was unchecked, checking it...');
await label.click();
await page.waitForTimeout(300);
}
} else if (isChecked) {
console.log(`Unchecking status: ${text}`);
await label.click();
await page.waitForTimeout(300);
}
}
console.log('✓ Only Approved status left checked');
});
await test.step('Select the target date range', async () => {
const dateField = page.locator('input[date-range="start"]').first();
await expect(dateField, 'Date range start input should be visible').toBeVisible({ timeout: 10000 });
await dateField.click();
await page.waitForTimeout(1500);
const calendarVisible = await page.locator('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)').isVisible().catch(() => false);
expect(calendarVisible, 'Date range calendar should open').toBe(true);
const prevBtn = page.locator('.ant-picker-header-prev-btn').first();
let found = false;
for (let attempt = 0; attempt < 60; attempt++) {
found = await page.locator(`.ant-picker-cell-in-view[title="${startTitle}"]:not(.ant-picker-cell-disabled)`).first().isVisible().catch(() => false);
if (found) break;
await prevBtn.click();
await page.waitForTimeout(300);
}
expect(found, `Target start date ${startTitle} should become available in the calendar`).toBe(true);
await page.locator(`.ant-picker-cell-in-view[title="${startTitle}"]`).first().click();
await page.waitForTimeout(800);
await page.locator(`.ant-picker-cell-in-view[title="${endTitle}"]`).first().click();
await page.waitForTimeout(1000);
console.log(`✓ Date range selected: ${startTitle}..${endTitle}`);
});
await test.step('Apply filters', async () => {
const applyButton = page.locator('button:has-text("Apply")').first();
await expect(applyButton, 'Apply button should be visible').toBeVisible({ timeout: 5000 });
await applyButton.click();
// The filtered table can take a few seconds to fully settle -
// reading rows too soon after clicking Apply intermittently caught
// the table mid-refresh, showing a stale row that didn't match the
// new filter yet.
await page.waitForTimeout(6000);
console.log('✓ Filters applied');
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(500);
});
await test.step('Assert every returned row is Approved and within the date range', async () => {
const startDate = toDate(startTitle)!;
const endDate = toDate(endTitle)!;
endDate.setHours(23, 59, 59, 999);
const headers = await table.locator('thead th').allTextContents();
const statusIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'status');
const dateIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'init date');
console.log(`Columns -> status:${statusIdx} date:${dateIdx} (headers: ${headers.join(' | ')})`);
expect(statusIdx, 'A status column should exist').toBeGreaterThanOrEqual(0);
expect(dateIdx, 'A date column should exist').toBeGreaterThanOrEqual(0);
const rows = table.locator('tbody tr.ant-table-row');
const rowCount = await rows.count();
console.log(`Validating ${rowCount} rows are Approved and within ${startTitle}..${endTitle}`);
for (let i = 0; i < rowCount; i++) {
const cellTexts = await rows.nth(i).locator('td').allTextContents();
const statusText = (cellTexts[statusIdx] || '').trim();
expect(statusText.toLowerCase(), `Row ${i + 1} status should be Approved (got "${statusText}")`).toBe('approved');
const rowDate = toDate(cellTexts[dateIdx] || '');
expect(rowDate, `Row ${i + 1} should have a valid date`).not.toBeNull();
const t = rowDate!.getTime();
expect(
t >= startDate.getTime() && t <= endDate.getTime(),
`Row ${i + 1} date ${cellTexts[dateIdx]} should be within ${startTitle}..${endTitle}`
).toBe(true);
}
console.log(`✓ All ${rowCount} returned rows are Approved and within range`);
});
console.log('Filter by Approved status and date range test completed');
});
});