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>
855 lines
40 KiB
TypeScript
855 lines
40 KiB
TypeScript
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');
|
||
});
|
||
});
|