Files
Frontend_automation/tests/transactions.spec.ts
Rediet Wogayehu 7bf4343f88
Some checks failed
E2E Tests / test (push) Has been cancelled
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

855 lines
40 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});