Files
Frontend_automation/tests/transactions.spec.ts

855 lines
40 KiB
TypeScript
Raw Permalink Normal View History

Extract PassDashboard Playwright e2e suite into standalone repo 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>
2026-08-03 15:50:50 +03:00
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');
});
});