Files
Frontend_automation/tests/settlement.spec.ts

346 lines
15 KiB
TypeScript
Raw Normal View History

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