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