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>
346 lines
15 KiB
TypeScript
346 lines
15 KiB
TypeScript
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');
|
|
});
|
|
}); |