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:
34
.env.test.example
Normal file
34
.env.test.example
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Local e2e test credentials - copy this file to .env.test.local and fill in
|
||||||
|
# real values. .env.test.local is git-ignored (matches the `*.local` pattern
|
||||||
|
# in .gitignore) so real credentials never get committed.
|
||||||
|
#
|
||||||
|
# In CI, these same variable names are set directly from GitHub Secrets
|
||||||
|
# (see CI-CD-SETUP.md) - this file is only read for local runs.
|
||||||
|
|
||||||
|
# Which environment to run against: develop | staging | main
|
||||||
|
# (also accepts dev | staging | production - same thing)
|
||||||
|
ENV=develop
|
||||||
|
|
||||||
|
# Dev environment (used when ENV=develop/dev) - its own deployment, separate
|
||||||
|
# from staging. Falls back to TEST_USERNAME/TEST_PASSWORD below if unset.
|
||||||
|
DEV_URL=
|
||||||
|
DEV_USERNAME=
|
||||||
|
DEV_PASSWORD=
|
||||||
|
|
||||||
|
# Older shared dev credentials - kept as a fallback for DEV_USERNAME/PASSWORD
|
||||||
|
# above and for staging (see below), matching how CI already provisions it.
|
||||||
|
TEST_USERNAME=
|
||||||
|
TEST_PASSWORD=
|
||||||
|
|
||||||
|
# Staging credentials (optional - falls back to TEST_USERNAME/TEST_PASSWORD
|
||||||
|
# above if not set, matching how CI has always provisioned staging)
|
||||||
|
STAGING_TEST_USERNAME=
|
||||||
|
STAGING_TEST_PASSWORD=
|
||||||
|
|
||||||
|
# Production credentials (required when ENV=main/production - kept separate
|
||||||
|
# on purpose so dev credentials can never accidentally run against prod)
|
||||||
|
PROD_TEST_USERNAME=
|
||||||
|
PROD_TEST_PASSWORD=
|
||||||
|
|
||||||
|
# Optional: override the environment's default base URL entirely
|
||||||
|
# BASE_URL=
|
||||||
92
.gitea/workflows/e2e-tests.yml
Normal file
92
.gitea/workflows/e2e-tests.yml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
# This repo holds ONLY the automated test suite - it has no application code
|
||||||
|
# and nothing to build or deploy. So unlike the app repo (where the workflow
|
||||||
|
# derived ENV from github.ref_name), the environment under test is an explicit
|
||||||
|
# input here: this repo's `main` branch tests whichever deployment you point
|
||||||
|
# it at, not "the deployment matching this branch".
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: 'Environment to test'
|
||||||
|
required: true
|
||||||
|
default: 'develop'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- develop
|
||||||
|
- staging
|
||||||
|
- main
|
||||||
|
schedule:
|
||||||
|
# Nightly regression run against develop (02:00 UTC = 05:00 UTC+3).
|
||||||
|
- cron: '0 2 * * *'
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Determine environment
|
||||||
|
id: env
|
||||||
|
run: |
|
||||||
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
|
ENV="${{ github.event.inputs.environment }}"
|
||||||
|
else
|
||||||
|
# Scheduled runs and pushes/PRs to this repo's main always
|
||||||
|
# exercise develop - the test suite's own default target.
|
||||||
|
ENV="develop"
|
||||||
|
fi
|
||||||
|
echo "ENVIRONMENT=$ENV" >> $GITHUB_OUTPUT
|
||||||
|
echo "Testing on: $ENV"
|
||||||
|
|
||||||
|
# Credential selection per environment (dev/staging/production) is
|
||||||
|
# resolved centrally in tests/helpers/environment.ts, not here - all
|
||||||
|
# secrets are just passed through and the code picks the right pair
|
||||||
|
# based on ENV. This also means ENV=main correctly maps to production
|
||||||
|
# credentials/URL.
|
||||||
|
- run: npx playwright test --project=chromium --workers=1
|
||||||
|
env:
|
||||||
|
ENV: ${{ steps.env.outputs.ENVIRONMENT }}
|
||||||
|
DEV_URL: ${{ secrets.DEV_URL }}
|
||||||
|
DEV_USERNAME: ${{ secrets.DEV_USERNAME }}
|
||||||
|
DEV_PASSWORD: ${{ secrets.DEV_PASSWORD }}
|
||||||
|
TEST_USERNAME: ${{ secrets.TEST_USERNAME }}
|
||||||
|
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
|
||||||
|
STAGING_TEST_USERNAME: ${{ secrets.STAGING_TEST_USERNAME }}
|
||||||
|
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
||||||
|
PROD_TEST_USERNAME: ${{ secrets.PROD_TEST_USERNAME }}
|
||||||
|
PROD_TEST_PASSWORD: ${{ secrets.PROD_TEST_PASSWORD }}
|
||||||
|
|
||||||
|
# Reports are the whole point of a dedicated test repo, so keep them
|
||||||
|
# as artifacts instead of deleting them the way the app repo did.
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: playwright-report-${{ steps.env.outputs.ENVIRONMENT }}
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Upload Allure results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: allure-results-${{ steps.env.outputs.ENVIRONMENT }}
|
||||||
|
path: allure-results/
|
||||||
|
retention-days: 14
|
||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Local credentials - .env.test.local is matched by the *.local pattern below
|
||||||
|
# and must never be committed. Copy .env.test.example to create it.
|
||||||
|
*.local
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
allure-results/
|
||||||
|
allure-report/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
downloads/
|
||||||
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Frontend Automation
|
||||||
|
|
||||||
|
Playwright end-to-end test suite for the **PassDashboard** merchant portal
|
||||||
|
(NeoPass), extracted into its own repository so it can be run and deployed
|
||||||
|
independently of the application.
|
||||||
|
|
||||||
|
The tests drive the real, deployed application over HTTP — there is no
|
||||||
|
application code, build step, or local dev server in this repo. That is what
|
||||||
|
makes standalone operation possible: point the suite at a URL, give it
|
||||||
|
credentials, and run.
|
||||||
|
|
||||||
|
> The Jest unit tests under `src/__tests__/` in the app repo are **not** part
|
||||||
|
> of this suite. They import application source directly (`AuthContext`,
|
||||||
|
> `ProtectedRoute`, `cryptoUtils`, `api`) and therefore have to live alongside
|
||||||
|
> that source. They remain in the PassDashboard repo.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
cp .env.test.example .env.test.local # then fill in real credentials
|
||||||
|
npm run test:e2e:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environments
|
||||||
|
|
||||||
|
Environment and credentials are resolved in one place —
|
||||||
|
[tests/helpers/environment.ts](tests/helpers/environment.ts). It reads an `ENV`
|
||||||
|
variable and picks the matching base URL and credential pair. Both
|
||||||
|
[playwright.config.ts](playwright.config.ts) and
|
||||||
|
[tests/helpers/auth.ts](tests/helpers/auth.ts) read through it, so adding or
|
||||||
|
changing an environment is a one-file edit.
|
||||||
|
|
||||||
|
`ENV` accepts either branch-style names (`develop` / `staging` / `main`) or
|
||||||
|
semantic names (`dev` / `staging` / `production`) — they map to the same thing.
|
||||||
|
|
||||||
|
| Command | Target |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run test:e2e:dev` | `DEV_URL` (falls back to the shared staging URL) |
|
||||||
|
| `npm run test:e2e:staging` | `https://stagingenv.babinnovations.com` |
|
||||||
|
| `npm run test:e2e:prod` | `https://www.babinnovations.com/neopaas/portal` |
|
||||||
|
| `npm run test:e2e:ui` | Playwright UI mode, using `ENV` / `.env.test.local` as-is |
|
||||||
|
|
||||||
|
Set `BASE_URL` to override the resolved URL entirely.
|
||||||
|
|
||||||
|
## Running a subset
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/refunds.spec.ts
|
||||||
|
npx playwright test -g "should paginate through transaction list"
|
||||||
|
npx playwright test tests/dashboard.spec.ts --headed
|
||||||
|
npm run report # open the last HTML report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
`.env.test.local` is git-ignored via the `*.local` pattern and is only read for
|
||||||
|
local runs. CI never uses it — the workflow sets the same variable names from
|
||||||
|
Gitea Actions secrets. See [.env.test.example](.env.test.example) for the full
|
||||||
|
list and the fallback behaviour.
|
||||||
|
|
||||||
|
Required secrets in **Settings → Actions → Secrets** for CI:
|
||||||
|
|
||||||
|
| Secret | Needed for |
|
||||||
|
| --- | --- |
|
||||||
|
| `DEV_URL`, `DEV_USERNAME`, `DEV_PASSWORD` | `ENV=develop` |
|
||||||
|
| `TEST_USERNAME`, `TEST_PASSWORD` | shared fallback for dev and staging |
|
||||||
|
| `STAGING_TEST_USERNAME`, `STAGING_TEST_PASSWORD` | `ENV=staging` (optional; falls back to `TEST_*`) |
|
||||||
|
| `PROD_TEST_USERNAME`, `PROD_TEST_PASSWORD` | `ENV=main` — required, never falls back |
|
||||||
|
|
||||||
|
Production credentials are deliberately kept separate so a dev credential pair
|
||||||
|
can never accidentally run against production.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
[.gitea/workflows/e2e-tests.yml](.gitea/workflows/e2e-tests.yml) runs on Gitea
|
||||||
|
Actions. Because this repo has no per-environment branches, the target is an
|
||||||
|
explicit `workflow_dispatch` input rather than being derived from the branch
|
||||||
|
name; scheduled and push runs default to `develop`. The Playwright HTML report
|
||||||
|
and Allure results are uploaded as build artifacts.
|
||||||
|
|
||||||
|
Requires a Gitea Actions runner with the `ubuntu-latest` label. If your runner
|
||||||
|
uses a different label, change `runs-on` in the workflow.
|
||||||
|
|
||||||
|
## Suite layout
|
||||||
|
|
||||||
|
See [tests/README.md](tests/README.md) for the per-spec breakdown, the helper
|
||||||
|
modules, and the list of known gaps / intentionally uncovered areas.
|
||||||
|
|
||||||
|
**When adding a spec file:** `playwright.config.ts` uses an explicit
|
||||||
|
`testMatch` allowlist, not a wildcard glob. A new spec file that isn't added to
|
||||||
|
that list will silently never run.
|
||||||
295
package-lock.json
generated
Normal file
295
package-lock.json
generated
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend-automation",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "frontend-automation",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.44.0",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"allure-playwright": "^3.10.0",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.19.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
|
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/allure-js-commons": {
|
||||||
|
"version": "3.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/allure-js-commons/-/allure-js-commons-3.10.2.tgz",
|
||||||
|
"integrity": "sha512-5nAjUF1iNPSQMwKtEsadclSta8C3L705/k/pIzGb9M6P9krgfRaCyaEHt8pkLlCP9NFj5xk3grjtnAhiLeT+Kg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"md5": "^2.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"allure-playwright": "3.10.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"allure-playwright": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/allure-playwright": {
|
||||||
|
"version": "3.10.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/allure-playwright/-/allure-playwright-3.10.2.tgz",
|
||||||
|
"integrity": "sha512-CGyhxSvx2bIkojhBy0pbQZ12vkLbOE3FBh39WgzPs+katwnuGPUE0whJhryG8aD/dlDALxMehL9G0k8WYBx4tQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"allure-js-commons": "3.10.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@playwright/test": ">=1.53.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/charenc": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/crypt": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-buffer": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/md5": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"charenc": "0.0.2",
|
||||||
|
"crypt": "0.0.2",
|
||||||
|
"is-buffer": "~1.1.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend-automation",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Playwright end-to-end test suite for the PassDashboard merchant portal",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:dev": "ENV=develop playwright test",
|
||||||
|
"test:e2e:staging": "ENV=staging playwright test",
|
||||||
|
"test:e2e:prod": "ENV=production playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"report": "playwright show-report",
|
||||||
|
"install:browsers": "playwright install --with-deps chromium"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.44.0",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"allure-playwright": "^3.10.0",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
66
playwright.config.ts
Normal file
66
playwright.config.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import { getEnvironmentConfig } from './tests/helpers/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright configuration for PassDashboard test automation
|
||||||
|
* @see https://playwright.dev/docs/test-configuration
|
||||||
|
*
|
||||||
|
* Environment (dev/staging/production) and credentials are resolved once,
|
||||||
|
* centrally, in tests/helpers/environment.ts - see that file and
|
||||||
|
* .env.test.example for how to switch environments locally or in CI.
|
||||||
|
*/
|
||||||
|
const env = getEnvironmentConfig();
|
||||||
|
console.log(`[playwright.config] Running against ${env.envName} (${env.baseURL})`);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: 2,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [
|
||||||
|
['html'],
|
||||||
|
['allure-playwright', {
|
||||||
|
outputFolder: 'allure-results',
|
||||||
|
detail: true,
|
||||||
|
suiteTitle: true,
|
||||||
|
}],
|
||||||
|
],
|
||||||
|
use: {
|
||||||
|
baseURL: env.baseURL,
|
||||||
|
trace: 'off',
|
||||||
|
screenshot: 'off',
|
||||||
|
video: 'off',
|
||||||
|
actionTimeout: 30000,
|
||||||
|
navigationTimeout: 90000,
|
||||||
|
// Pin the browser clock to the platform's market timezone (UTC+3).
|
||||||
|
// The report date range is timezone-sensitive: picking 2026-07-31 on a
|
||||||
|
// UTC machine made the app request 2026-08-01 instead, which comes back
|
||||||
|
// 204 No Content, so no file ever downloads. CI runners are UTC while
|
||||||
|
// developers here run UTC+3, which is exactly why these download tests
|
||||||
|
// passed locally and failed only in CI. Pinning it makes both agree and
|
||||||
|
// matches how the product is actually used.
|
||||||
|
timezoneId: 'Asia/Riyadh',
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
// Run terminals before management so removed cashier frees up a terminal
|
||||||
|
testMatch: [
|
||||||
|
'**/auth.spec.ts',
|
||||||
|
'**/dashboard.spec.ts',
|
||||||
|
'**/terminals.spec.ts',
|
||||||
|
'**/transactions.spec.ts',
|
||||||
|
'**/discounts.spec.ts',
|
||||||
|
'**/refunds.spec.ts',
|
||||||
|
'**/settlement.spec.ts',
|
||||||
|
'**/management.spec.ts',
|
||||||
|
'**/settings.spec.ts',
|
||||||
|
'**/roles.spec.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
});
|
||||||
101
tests/README.md
Normal file
101
tests/README.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# E2E Test Suite
|
||||||
|
|
||||||
|
Playwright end-to-end tests for the PassDashboard merchant portal. These
|
||||||
|
tests drive the real, deployed application over HTTP - they don't run
|
||||||
|
against a local build - so this folder can be copied into any checkout of
|
||||||
|
the repo (even a newer one) and will keep working as long as the app's URLs
|
||||||
|
and selectors haven't changed.
|
||||||
|
|
||||||
|
## Folder structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
helpers/
|
||||||
|
environment.ts # single source of truth for env + credential resolution
|
||||||
|
auth.ts # login()/loginAs() - reads TEST_CREDENTIALS from environment.ts
|
||||||
|
navigation.ts # navigateToX(page) helpers for each portal page
|
||||||
|
allure.ts # thin wrappers around Allure annotations (epic/feature/severity/tag)
|
||||||
|
auth.spec.ts # login, invalid credentials, required-field validation, logout
|
||||||
|
dashboard.spec.ts # stat cards, dropdown filter, date-range download, weekly sales chart
|
||||||
|
transactions.spec.ts # search/filter, date range, details, export, pagination
|
||||||
|
terminals.spec.ts # details, pagination, download, cashier remove/reassign, add terminal
|
||||||
|
refunds.spec.ts # search/filter, date range, details, export, pagination
|
||||||
|
discounts.spec.ts # cross-checks Transactions-page totals against the Discounts page
|
||||||
|
settlement.spec.ts # download -> format -> upload -> run settlement workflow
|
||||||
|
management.spec.ts # Users/Branches tabs: add cashier/finance user, add branch, reports
|
||||||
|
settings.spec.ts # Discount Rate Configuration (Settings > Discount rates tab)
|
||||||
|
roles.spec.ts # creates Cashier/Finance/Terminal Manager users, logs in as each,
|
||||||
|
# verifies their actual restricted navigation menu
|
||||||
|
```
|
||||||
|
|
||||||
|
Each spec file corresponds to one page/feature area of the portal. Shared
|
||||||
|
logic (login, navigation, environment/credential resolution) lives in
|
||||||
|
`helpers/` so spec files stay focused on the feature they're testing.
|
||||||
|
|
||||||
|
## Running against dev / staging / production
|
||||||
|
|
||||||
|
Environment and credentials are resolved once, centrally, in
|
||||||
|
`helpers/environment.ts`. It reads an `ENV` variable (accepts branch names
|
||||||
|
`develop`/`staging`/`main` - what CI naturally has on hand - or semantic
|
||||||
|
names `dev`/`staging`/`production`) and picks the matching base URL and
|
||||||
|
credential pair. `playwright.config.ts` and `helpers/auth.ts` both import
|
||||||
|
from this one module, so there is nowhere else that needs updating to add
|
||||||
|
or change an environment.
|
||||||
|
|
||||||
|
### Local setup (one-time)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.test.example .env.test.local
|
||||||
|
# then fill in your real credentials in .env.test.local
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.test.local` is git-ignored (matches the `*.local` pattern already in
|
||||||
|
`.gitignore`) - it never gets committed, and CI doesn't use it at all (CI
|
||||||
|
sets the same variable names directly from GitHub Secrets - see
|
||||||
|
`CI-CD-SETUP.md`).
|
||||||
|
|
||||||
|
### Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e:dev # https://devpro.babinnovations.com
|
||||||
|
npm run test:e2e:staging # https://staging.babinnovations.com
|
||||||
|
npm run test:e2e:prod # https://www.babinnovations.com/neopaas/portal
|
||||||
|
|
||||||
|
npm run test:e2e:ui # Playwright's interactive UI mode (uses ENV/.env.test.local as-is)
|
||||||
|
|
||||||
|
# Run a single file or a single test by name, same as any Playwright project:
|
||||||
|
npx playwright test tests/refunds.spec.ts
|
||||||
|
npx playwright test -g "should paginate through transaction list"
|
||||||
|
|
||||||
|
# Watch it run in a real browser window instead of headless:
|
||||||
|
npx playwright test tests/dashboard.spec.ts --headed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a new environment or changing a URL
|
||||||
|
|
||||||
|
Edit `baseURLs` and/or `credentials` in `helpers/environment.ts` - that's
|
||||||
|
the only file that needs to change. Everything else (playwright.config.ts,
|
||||||
|
auth.ts, CI workflow) reads through it.
|
||||||
|
|
||||||
|
## Adding a new spec file
|
||||||
|
|
||||||
|
Playwright is configured with an explicit `testMatch` allowlist in
|
||||||
|
`playwright.config.ts` (not a wildcard glob) - **new spec files must be
|
||||||
|
added to that list** or they'll silently never run. This bit us once
|
||||||
|
already (`settings.spec.ts` and `roles.spec.ts` were invisible to the
|
||||||
|
runner until added).
|
||||||
|
|
||||||
|
## Known gaps / intentionally not covered
|
||||||
|
|
||||||
|
- **Reconciliation page** - out of scope for now
|
||||||
|
- **Bulk disable/enable** (Terminals page) - requires a genuinely active
|
||||||
|
physical POS terminal to succeed server-side; not reliably reproducible
|
||||||
|
in the dev environment, so it isn't automated
|
||||||
|
- **Terminals > Bulk Upload**, **Settings > Account/Response Timeout/
|
||||||
|
Merchant Token/Trusted devices tabs**, **Dashboard notifications panel**,
|
||||||
|
**Arabic/RTL rendering**, **Forgot Password / 2FA** - never explored yet
|
||||||
|
- **Discount rate creation** (Settings > Discount rates > Create) - blocked
|
||||||
|
by a dev-environment data gap (the Card name dropdown has no options to
|
||||||
|
select), flagged as a product issue rather than fixed in the test
|
||||||
|
- A separate Jest unit-test layer exists under `src/__tests__/` - unrelated
|
||||||
|
to this Playwright suite and not covered by anything above
|
||||||
176
tests/auth.spec.ts
Normal file
176
tests/auth.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { TEST_CREDENTIALS } from './helpers/auth';
|
||||||
|
import { epic, feature, severity, description } from './helpers/allure';
|
||||||
|
|
||||||
|
test.describe('Authentication Tests', () => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
epic('User Authentication');
|
||||||
|
feature('Login');
|
||||||
|
|
||||||
|
// Retry the initial navigation in place - on a loaded local machine a
|
||||||
|
// single headless navigation can occasionally stall well past one
|
||||||
|
// timeout even though the server itself responds in ~1-3s.
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
try {
|
||||||
|
await page.goto(TEST_CREDENTIALS.loginPath, { waitUntil: 'domcontentloaded' });
|
||||||
|
lastError = undefined;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
console.log(`Login page navigation attempt ${attempt} failed, retrying...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastError) throw lastError;
|
||||||
|
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Switch to English by clicking the article element
|
||||||
|
console.log('Checking for language switcher...');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const languageArticle = page.locator('article.ant-typography.font-poppins-500.css-1kfsfla').first();
|
||||||
|
const isVisible = await languageArticle.isVisible({ timeout: 2000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
const text = await languageArticle.textContent();
|
||||||
|
console.log(`Found language button with text: "${text}"`);
|
||||||
|
console.log('Clicking to switch language...');
|
||||||
|
await languageArticle.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('✓ Language switched');
|
||||||
|
} else {
|
||||||
|
console.log('Language button not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully login with valid credentials', async ({ page }) => {
|
||||||
|
severity('critical');
|
||||||
|
description('Test successful login flow with valid user credentials');
|
||||||
|
|
||||||
|
console.log('Testing login with valid credentials...');
|
||||||
|
|
||||||
|
await test.step('Fill in username', async () => {
|
||||||
|
await page.fill('input[type="email"], input[name="email"], input[placeholder*="email" i]', TEST_CREDENTIALS.username);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Fill in password', async () => {
|
||||||
|
await page.fill('input[type="password"], input[name="password"]', TEST_CREDENTIALS.password);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Click login button', async () => {
|
||||||
|
await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify redirect to dashboard', async () => {
|
||||||
|
await page.waitForURL('**/dashboard**', { timeout: 30000 }).catch(() => {
|
||||||
|
console.log('Checking for successful navigation...');
|
||||||
|
});
|
||||||
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const currentUrl = page.url();
|
||||||
|
console.log('Current URL after login:', currentUrl);
|
||||||
|
expect(currentUrl).toContain('dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Login successful');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show error with invalid credentials', async ({ page }) => {
|
||||||
|
severity('critical');
|
||||||
|
description('Verify error handling for invalid login credentials');
|
||||||
|
|
||||||
|
console.log('Testing login with invalid credentials...');
|
||||||
|
|
||||||
|
await test.step('Fill in invalid username', async () => {
|
||||||
|
await page.fill('input[type="email"], input[name="email"], input[placeholder*="email" i]', 'invalid@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Fill in invalid password', async () => {
|
||||||
|
await page.fill('input[type="password"], input[name="password"]', 'wrongpassword');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Click login button', async () => {
|
||||||
|
await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify error message appears', async () => {
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const errorMessage = await page.locator('text=/error|invalid|incorrect|failed/i').first().isVisible().catch(() => false);
|
||||||
|
const stillOnLoginPage = page.url().includes('login');
|
||||||
|
|
||||||
|
console.log('Error message visible:', errorMessage);
|
||||||
|
console.log('Still on login page:', stillOnLoginPage);
|
||||||
|
|
||||||
|
expect(errorMessage || stillOnLoginPage).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Invalid credentials test completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate required fields', async ({ page }) => {
|
||||||
|
severity('normal');
|
||||||
|
description('Verify that required field validation works correctly');
|
||||||
|
|
||||||
|
console.log('Testing required field validation...');
|
||||||
|
|
||||||
|
await test.step('Click login without filling fields', async () => {
|
||||||
|
await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify validation or staying on login page', async () => {
|
||||||
|
const stillOnLoginPage = page.url().includes('login');
|
||||||
|
console.log('Still on login page:', stillOnLoginPage);
|
||||||
|
expect(stillOnLoginPage).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Required field validation test completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully logout', async ({ page }) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
severity('critical');
|
||||||
|
description('Verify that clicking Logout redirects to login page and clears session');
|
||||||
|
|
||||||
|
// First login
|
||||||
|
await test.step('Login first', async () => {
|
||||||
|
await page.fill('input[type="email"], input[name="email"], input[placeholder*="email" i]', TEST_CREDENTIALS.username);
|
||||||
|
await page.fill('input[type="password"], input[name="password"]', TEST_CREDENTIALS.password);
|
||||||
|
await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")');
|
||||||
|
await page.waitForURL('**/dashboard**', { timeout: 30000 }).catch(() => {});
|
||||||
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||||
|
console.log('✓ Logged in, URL:', page.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Click Logout', async () => {
|
||||||
|
const logoutLink = page.locator('text=Logout').first();
|
||||||
|
|
||||||
|
if (!(await logoutLink.isVisible({ timeout: 2500 }).catch(() => false))) {
|
||||||
|
// Mobile layout might hide logout under a menu; try opening it.
|
||||||
|
const openMenu = page.locator('button[aria-label="Open menu"], button[aria-label="Open navigation"], button:has-text("Menu"), button:has-text("Open")');
|
||||||
|
if (await openMenu.isVisible({ timeout: 2500 }).catch(() => false)) {
|
||||||
|
await openMenu.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logoutLink.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
await logoutLink.click();
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
console.log('✓ Logout clicked');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify redirected to login page', async () => {
|
||||||
|
await page.waitForURL('**/login**', { timeout: 10000 }).catch(() => {});
|
||||||
|
const currentUrl = page.url();
|
||||||
|
console.log('URL after logout:', currentUrl);
|
||||||
|
expect(currentUrl).toContain('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ Logout test completed');
|
||||||
|
});
|
||||||
|
});
|
||||||
422
tests/dashboard.spec.ts
Normal file
422
tests/dashboard.spec.ts
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
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';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
// 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('Dashboard Tests', () => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
epic('Dashboard');
|
||||||
|
await login(page);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Functional Tests
|
||||||
|
test('should use dropdown filter and verify data updates', async ({ page }) => {
|
||||||
|
feature('Dashboard Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('critical');
|
||||||
|
description('Test using the branch dropdown filter changes the dashboard\'s selected branch and data');
|
||||||
|
|
||||||
|
console.log('Testing dropdown filter functionality...');
|
||||||
|
|
||||||
|
// The real filter is the "All branches" Ant Design select at the top of
|
||||||
|
// the dashboard - generic `select, [role="combobox"]` selectors don't
|
||||||
|
// reliably target it, and this test previously had no assertions at all.
|
||||||
|
const branchSelect = page.locator('.ant-select').first();
|
||||||
|
await expect(branchSelect, 'Branch dropdown should be visible').toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const initialSelection = (await branchSelect.textContent()) || '';
|
||||||
|
console.log('Initial branch selection:', initialSelection.trim());
|
||||||
|
|
||||||
|
await test.step('Open dropdown and select a specific branch', async () => {
|
||||||
|
await branchSelect.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const options = page.locator('.ant-select-item-option');
|
||||||
|
const optionCount = await options.count();
|
||||||
|
console.log('Available options:', optionCount);
|
||||||
|
expect(optionCount, 'There should be more than one branch option').toBeGreaterThan(1);
|
||||||
|
|
||||||
|
// Pick a real named branch, not "All branches"/"-1" itself
|
||||||
|
const namedOption = options.filter({ hasNotText: /^All branches$/i }).first();
|
||||||
|
const optionText = (await namedOption.textContent()) || '';
|
||||||
|
await namedOption.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('Selected option:', optionText.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify the dropdown reflects the new selection', async () => {
|
||||||
|
const updatedSelection = (await branchSelect.textContent()) || '';
|
||||||
|
console.log('Updated branch selection:', updatedSelection.trim());
|
||||||
|
expect(updatedSelection.trim(), 'Dropdown display should change after selecting a different branch').not.toBe(initialSelection.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Dropdown filter test completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should select date range and download report with transactions', async ({ page }) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
feature('Dashboard Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('critical');
|
||||||
|
description('Download a transaction report for the latest transaction date and verify the file contents');
|
||||||
|
|
||||||
|
console.log('Testing date range selection and download report...');
|
||||||
|
|
||||||
|
let targetIso = '';
|
||||||
|
let latestDateObj: Date | null = null;
|
||||||
|
|
||||||
|
await test.step('Determine target date from the latest transaction', async () => {
|
||||||
|
// The Dashboard page itself has no data table to read a date from, so
|
||||||
|
// briefly check the Transactions page - same anchoring approach used
|
||||||
|
// elsewhere, since a hardcoded date ("March 2026") eventually falls
|
||||||
|
// outside the calendar's reachable range as real time moves on.
|
||||||
|
await navigateToTransactions(page);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
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 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();
|
||||||
|
latestDateObj = latestDate;
|
||||||
|
targetIso = toISO(latestDate!);
|
||||||
|
console.log(`Target date from latest transaction (${firstRowCells[dateIdx]}): ${targetIso}`);
|
||||||
|
|
||||||
|
const dashboardNav = page.locator('button:has-text("Dashboard")').first();
|
||||||
|
await dashboardNav.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 10000 });
|
||||||
|
await downloadButton.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
console.log('✓ Download modal opened');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Picks `iso` as a same-day range in the modal's date picker. Reusable so
|
||||||
|
// the download step can retry with an earlier date when the first one
|
||||||
|
// turns out to have no transactions to report on.
|
||||||
|
const selectSameDayRange = async (iso: string): Promise<void> => {
|
||||||
|
const dateField = page.locator('[role="dialog"] .ant-picker input[date-range="start"], .ant-modal .ant-picker 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="${iso}"]:not(.ant-picker-cell-disabled)`).first().isVisible().catch(() => false);
|
||||||
|
if (found) break;
|
||||||
|
await prevBtn.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
expect(found, `Target date ${iso} should become available in the calendar`).toBe(true);
|
||||||
|
|
||||||
|
await page.locator(`.ant-picker-cell-in-view[title="${iso}"]`).first().click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.locator(`.ant-picker-cell-in-view[title="${iso}"]`).first().click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
console.log(`✓ Date selected: ${iso}`);
|
||||||
|
|
||||||
|
// 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('[role="dialog"] .ant-picker input[date-range="start"], .ant-modal .ant-picker input[date-range="start"]').first().inputValue().catch(() => '(unreadable)');
|
||||||
|
const endVal = await page.locator('[role="dialog"] .ant-picker input[date-range="end"], .ant-modal .ant-picker 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 the target date (same-day range)', async () => {
|
||||||
|
await selectSameDayRange(targetIso);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Select CSV format', async () => {
|
||||||
|
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 selected via radio');
|
||||||
|
} else {
|
||||||
|
const csvText = page.locator('text=CSV').first();
|
||||||
|
await expect(csvText, 'CSV option should be available').toBeVisible({ timeout: 5000 });
|
||||||
|
await csvText.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
console.log('✓ CSV selected via text');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Download and verify the file', async () => {
|
||||||
|
// Large result sets switch to an async email-delivery flow instead of
|
||||||
|
// a direct browser download (confirmed behavior of this same report
|
||||||
|
// feature elsewhere) - a single day is usually small enough to
|
||||||
|
// download directly, but treat the async message as a legitimate
|
||||||
|
// outcome too, 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 modalDownloadButton = page.locator('[role="dialog"] button:has-text("Download report"), .ant-modal button:has-text("Download")').first();
|
||||||
|
|
||||||
|
const clickAndWait = async (): Promise<import('@playwright/test').Download | null> => {
|
||||||
|
const downloadPromise = page.waitForEvent('download', { timeout: 45000 }).catch(() => null);
|
||||||
|
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...`);
|
||||||
|
return downloadPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
let download = await clickAndWait();
|
||||||
|
|
||||||
|
// A single day can legitimately have no transactions to report on, in
|
||||||
|
// which case the backend answers 204 No Content and no file is ever
|
||||||
|
// produced. Rather than failing, walk back a few days and try again -
|
||||||
|
// any day with data proves the download works just as well.
|
||||||
|
for (let daysBack = 2; !download && daysBack <= 6 && latestDateObj; daysBack += 2) {
|
||||||
|
const fallbackIso = toISO(new Date(
|
||||||
|
latestDateObj.getFullYear(), latestDateObj.getMonth(), latestDateObj.getDate() - daysBack,
|
||||||
|
));
|
||||||
|
console.log(`No file for the selected day (API: ${apiCalls.join(' | ') || 'none'}) - retrying ${daysBack} day(s) earlier: ${fallbackIso}`);
|
||||||
|
apiCalls.length = 0;
|
||||||
|
await selectSameDayRange(fallbackIso);
|
||||||
|
download = await clickAndWait();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!download) {
|
||||||
|
const asyncMessage = await page.locator('text=/being generated|download link via email/i').first().isVisible({ timeout: 8000 }).catch(() => false);
|
||||||
|
if (!asyncMessage) {
|
||||||
|
// Neither outcome fired - dump what's actually on screen so the
|
||||||
|
// next failure (if any) says why instead of just "false".
|
||||||
|
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 - there is no file to download, so asserting one
|
||||||
|
// would be wrong. Skip rather than fail, but 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 filePath = await download.path();
|
||||||
|
expect(filePath, 'Downloaded file should have a local path').toBeTruthy();
|
||||||
|
|
||||||
|
const stats = fs.statSync(filePath!);
|
||||||
|
console.log('File size:', (stats.size / 1024).toFixed(2), 'KB');
|
||||||
|
expect(stats.size, 'Downloaded file should have content').toBeGreaterThan(100);
|
||||||
|
|
||||||
|
if (/\.(csv|txt)$/i.test(fileName)) {
|
||||||
|
const fileContent = fs.readFileSync(filePath!, 'utf-8');
|
||||||
|
console.log('File content preview:', fileContent.substring(0, 300));
|
||||||
|
const hasTransactionData = /transaction|amount|date|payment|id/i.test(fileContent);
|
||||||
|
expect(hasTransactionData, `Downloaded file should contain transaction data for ${targetIso}`).toBe(true);
|
||||||
|
} else {
|
||||||
|
console.log(`Binary file format (${fileName}), skipping text content check`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Download report test completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should click on dashboard card and navigate', async ({ page }) => {
|
||||||
|
feature('Dashboard Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('critical');
|
||||||
|
description('Test clicking the Purchase Transactions dashboard card navigates to the Transactions page');
|
||||||
|
|
||||||
|
console.log('Testing clickable dashboard cards...');
|
||||||
|
|
||||||
|
// The real dashboard stat cards don't use "card"/"widget"/"stat" class
|
||||||
|
// names (a `[class*="card" i]` selector matches zero elements) - they're
|
||||||
|
// plain divs styled with Tailwind's `cursor-pointer` utility class.
|
||||||
|
const purchaseCard = page.locator('article:has-text("Purchase Transactions")').locator('xpath=ancestor::div[contains(@class, "cursor-pointer")]').first();
|
||||||
|
await expect(purchaseCard, 'Purchase Transactions card should be visible').toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const urlBefore = page.url();
|
||||||
|
console.log('URL before click:', urlBefore);
|
||||||
|
|
||||||
|
await test.step('Click the Purchase Transactions card', async () => {
|
||||||
|
await purchaseCard.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify navigation to Transactions occurred', async () => {
|
||||||
|
const urlAfter = page.url();
|
||||||
|
console.log('URL after click:', urlAfter);
|
||||||
|
expect(urlAfter, 'Clicking the card should navigate away from the dashboard').not.toBe(urlBefore);
|
||||||
|
expect(urlAfter, 'Clicking the Purchase Transactions card should navigate to Transactions').toContain('/transactions');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Clickable card test completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should interact with multiple dashboard cards', async ({ page }) => {
|
||||||
|
feature('Dashboard Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('normal');
|
||||||
|
description('Test each main dashboard stat card has real content and navigates away when clicked');
|
||||||
|
|
||||||
|
console.log('Testing multiple dashboard cards...');
|
||||||
|
|
||||||
|
// The 3 real stat cards on this dashboard, confirmed live: Purchase
|
||||||
|
// Transactions, Refunds, Success Rate. Generic "[class*=card]"-style
|
||||||
|
// selectors match nothing (these are plain cursor-pointer divs), so we
|
||||||
|
// target them by their known labels instead.
|
||||||
|
const cardLabels = ['Purchase Transactions', 'Refunds', 'Success Rate'];
|
||||||
|
let cardsInteracted = 0;
|
||||||
|
|
||||||
|
for (const label of cardLabels) {
|
||||||
|
await test.step(`Verify and click "${label}" card`, async () => {
|
||||||
|
const card = page.locator(`article:has-text("${label}")`).locator('xpath=ancestor::div[contains(@class, "cursor-pointer")]').first();
|
||||||
|
await expect(card, `${label} card should be visible`).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const cardText = (await card.textContent()) || '';
|
||||||
|
console.log(`${label} card content:`, cardText.trim().substring(0, 80));
|
||||||
|
expect(cardText.trim().length, `${label} card should have real content`).toBeGreaterThan(label.length);
|
||||||
|
|
||||||
|
const urlBefore = page.url();
|
||||||
|
await card.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const urlAfter = page.url();
|
||||||
|
console.log(`${label}: ${urlBefore} -> ${urlAfter}`);
|
||||||
|
expect(urlAfter, `Clicking ${label} should navigate away from the dashboard`).not.toBe(urlBefore);
|
||||||
|
cardsInteracted++;
|
||||||
|
|
||||||
|
// Back to the dashboard for the next card
|
||||||
|
const dashboardNav = page.locator('button:has-text("Dashboard")').first();
|
||||||
|
await dashboardNav.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(cardsInteracted, 'All 3 known dashboard cards should have been interacted with').toBe(cardLabels.length);
|
||||||
|
console.log('Multiple cards test completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should verify dropdown filter options are available', async ({ page }) => {
|
||||||
|
feature('Dashboard Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('normal');
|
||||||
|
description('Test that the branch dropdown filter has multiple real branch options available');
|
||||||
|
|
||||||
|
console.log('Testing dropdown filter options...');
|
||||||
|
|
||||||
|
const branchSelect = page.locator('.ant-select').first();
|
||||||
|
await expect(branchSelect, 'Branch dropdown should be visible').toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await branchSelect.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const options = page.locator('.ant-select-item-option');
|
||||||
|
const optionCount = await options.count();
|
||||||
|
console.log('Dropdown options count:', optionCount);
|
||||||
|
expect(optionCount, 'There should be more than one branch option').toBeGreaterThan(1);
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(optionCount, 5); i++) {
|
||||||
|
console.log(`Option ${i + 1}:`, (await options.nth(i).textContent())?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Dropdown options test completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display the Weekly sales chart with day labels', async ({ page }) => {
|
||||||
|
feature('Dashboard Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('normal');
|
||||||
|
description('Verify the Weekly sales chart renders all 7 day labels and shows either real data or the correct empty state');
|
||||||
|
|
||||||
|
console.log('Testing Weekly sales chart...');
|
||||||
|
|
||||||
|
// The dashboard is slow to render on accounts with a large transaction
|
||||||
|
// history, so give the chart room to appear before asserting on it.
|
||||||
|
const chartHeading = page.locator('text="Weekly sales"').first();
|
||||||
|
await expect(chartHeading, 'Weekly sales chart heading should be visible').toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
|
// Scope to the chart's card container (the heading's ancestor panel)
|
||||||
|
const chartCard = chartHeading.locator('xpath=ancestor::div[contains(@class,"rounded-3xl")]').first();
|
||||||
|
await expect(chartCard, 'Weekly sales chart card should be visible').toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
|
// The card renders immediately with a "Loading..." placeholder while the
|
||||||
|
// chart data is still being fetched - the day labels only exist once
|
||||||
|
// that resolves, so wait it out before asserting on them.
|
||||||
|
await chartCard.locator('text=/^Loading\\.\\.\\.$/').first()
|
||||||
|
.waitFor({ state: 'hidden', timeout: 30000 }).catch(() => {});
|
||||||
|
|
||||||
|
await test.step('Verify all 7 day labels are present', async () => {
|
||||||
|
const days = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
|
||||||
|
for (const day of days) {
|
||||||
|
const dayLabel = chartCard.locator(`text="${day}"`).first();
|
||||||
|
await expect(dayLabel, `Day label "${day}" should be visible`).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
console.log('✓ All 7 day labels (MON-SUN) are visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify the chart shows either real data or the correct empty state', async () => {
|
||||||
|
const emptyStateText = chartCard.locator('text=/no transactions this week/i').first();
|
||||||
|
const isEmpty = await emptyStateText.isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
console.log('✓ Chart is in the empty state - "No transactions this week" correctly shown');
|
||||||
|
await expect(emptyStateText, 'Empty state message should be visible').toBeVisible();
|
||||||
|
} else {
|
||||||
|
const svgElements = chartCard.locator('svg');
|
||||||
|
const svgCount = await svgElements.count();
|
||||||
|
console.log(`Chart is populated - found ${svgCount} SVG element(s) rendering the chart`);
|
||||||
|
expect(svgCount, 'A populated chart should render at least one SVG element').toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ Weekly sales chart test completed');
|
||||||
|
});
|
||||||
|
});
|
||||||
545
tests/discounts.spec.ts
Normal file
545
tests/discounts.spec.ts
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
import { epic, feature, severity, description, tag } from './helpers/allure';
|
||||||
|
import { navigateToTransactions, navigateToDiscounts, navigateToSettings } from './helpers/navigation';
|
||||||
|
|
||||||
|
test.describe('Discounts & Fees Tests', () => {
|
||||||
|
test.setTimeout(300000);
|
||||||
|
|
||||||
|
test('should calculate discount and verify against transactions page', async ({ page }) => {
|
||||||
|
feature('Discounts Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('critical');
|
||||||
|
description('Get latest transaction date, calculate MDR+VAT+FEE from transactions, then verify matches Discounts page totals');
|
||||||
|
epic('Discounts & Fees');
|
||||||
|
|
||||||
|
let latestDate = '';
|
||||||
|
let txnTotalMDR = 0, txnTotalVAT = 0, txnTotalFee = 0, txnTotalDiscount = 0;
|
||||||
|
|
||||||
|
// Step 1: Go to Transactions page
|
||||||
|
await test.step('Step 1: Go to Transactions page', async () => {
|
||||||
|
await login(page);
|
||||||
|
await navigateToTransactions(page);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('✓ On Transactions page');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Find latest date with transactions
|
||||||
|
await test.step('Step 2: Find latest date with transactions', async () => {
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Scroll the table horizontally to reveal the init date column
|
||||||
|
const tableContent = page.locator('.ant-table-content').first();
|
||||||
|
await tableContent.evaluate((element) => {
|
||||||
|
// Scroll to the right to reveal the init date column
|
||||||
|
element.scrollLeft = element.scrollWidth;
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
console.log('✓ Scrolled table horizontally to reveal init date column');
|
||||||
|
|
||||||
|
// Get the first real data row (skip the hidden ant-table-measure-row)
|
||||||
|
const firstRow = page.locator('tr.ant-table-row').first();
|
||||||
|
const rowText = await firstRow.textContent();
|
||||||
|
|
||||||
|
console.log('Full first row text after scroll:', rowText?.substring(0, 200));
|
||||||
|
|
||||||
|
// Extract date from the row (format: "Mon DD, YYYY" like "Jun 19, 2026")
|
||||||
|
const dateMatch = rowText?.match(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s*\d{4}/);
|
||||||
|
|
||||||
|
if (dateMatch) {
|
||||||
|
latestDate = dateMatch[0];
|
||||||
|
console.log('\n========== DATE EXTRACTED ==========');
|
||||||
|
console.log('✓ Latest transaction date:', latestDate);
|
||||||
|
console.log('====================================\n');
|
||||||
|
} else {
|
||||||
|
console.log('⚠ Could not extract date from first row');
|
||||||
|
throw new Error('Failed to extract date from transaction row');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Count how many transaction rows fall within the latest date
|
||||||
|
await test.step('Step 3: Count transactions within the latest date', async () => {
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const monthRegex = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s*\d{4}/;
|
||||||
|
|
||||||
|
let totalMatching = 0;
|
||||||
|
let currentPage = 1;
|
||||||
|
let keepPaging = true;
|
||||||
|
|
||||||
|
while (keepPaging) {
|
||||||
|
// Wait for the table body to be present before reading
|
||||||
|
await page.locator('tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
|
||||||
|
const rows = page.locator('tr.ant-table-row');
|
||||||
|
const rowCount = await rows.count();
|
||||||
|
|
||||||
|
// Capture the first row key on this page (used to detect page change later)
|
||||||
|
const firstKey = await rows.first().getAttribute('data-row-key');
|
||||||
|
const lastKey = await rows.nth(rowCount - 1).getAttribute('data-row-key');
|
||||||
|
|
||||||
|
// Read the actual page footer ("Page X of Y") for verification
|
||||||
|
const footer = await page.locator('text=/Page\\s+\\d+\\s+of\\s+\\d+/').first().textContent().catch(() => null);
|
||||||
|
|
||||||
|
let pageMatching = 0;
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
const rowText = await rows.nth(i).textContent();
|
||||||
|
const match = rowText?.match(monthRegex);
|
||||||
|
const rowDate = match ? match[0] : '';
|
||||||
|
if (rowDate === latestDate) {
|
||||||
|
pageMatching++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalMatching += pageMatching;
|
||||||
|
console.log(`[${footer?.trim() || 'page ' + currentPage}] firstID=${firstKey} lastID=${lastKey} -> ${pageMatching}/${rowCount} on ${latestDate}`);
|
||||||
|
|
||||||
|
// If not every row on this page matched, we've reached older dates - stop
|
||||||
|
if (pageMatching < rowCount) {
|
||||||
|
keepPaging = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All rows matched - go to next page
|
||||||
|
const nextPage = currentPage + 1;
|
||||||
|
const nextPageBtn = page.locator(`button:has-text("${nextPage}")`).first();
|
||||||
|
const hasNextPage = await nextPageBtn.isVisible({ timeout: 2000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (!hasNextPage) {
|
||||||
|
keepPaging = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextPageBtn.click();
|
||||||
|
|
||||||
|
// Wait until the first row's key actually changes (confirms the page re-rendered)
|
||||||
|
await page.waitForFunction(
|
||||||
|
(prevKey) => {
|
||||||
|
const row = document.querySelector('tr.ant-table-row');
|
||||||
|
return row && row.getAttribute('data-row-key') !== prevKey;
|
||||||
|
},
|
||||||
|
firstKey,
|
||||||
|
{ timeout: 10000 }
|
||||||
|
).catch(() => {
|
||||||
|
console.log('⚠ Page did not change after clicking next - stopping');
|
||||||
|
});
|
||||||
|
|
||||||
|
currentPage = nextPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n========== ROW COUNT ==========');
|
||||||
|
console.log(`✓ Total transactions on ${latestDate}: ${totalMatching}`);
|
||||||
|
console.log('===============================\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Enable MDR, VAT, Fee columns via Manage columns modal
|
||||||
|
await test.step('Step 4: Enable MDR, VAT, Fee columns', async () => {
|
||||||
|
// Open the Manage columns modal
|
||||||
|
const manageColumnsBtn = page.locator('button:has-text("Manage columns"), button:has-text("columns")').first();
|
||||||
|
await manageColumnsBtn.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
console.log('✓ Opened Manage columns modal');
|
||||||
|
|
||||||
|
// Scope everything to the modal dialog so we don't hit the page's own search/checkboxes
|
||||||
|
const modal = page.locator('.ant-modal-content').first();
|
||||||
|
const searchInput = modal.locator('input[placeholder="Search"]').first();
|
||||||
|
|
||||||
|
// Helper: search for a column and enable its checkbox if not already checked
|
||||||
|
const enableColumn = async (term: string) => {
|
||||||
|
await searchInput.fill('');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await searchInput.fill(term);
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
|
||||||
|
// After filtering, the matching column's checkbox should be the visible one
|
||||||
|
const checkbox = modal.locator('input.ant-checkbox-input').first();
|
||||||
|
const exists = await checkbox.isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
console.log(`⚠ No checkbox found for "${term}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isChecked = await checkbox.isChecked().catch(() => false);
|
||||||
|
if (isChecked) {
|
||||||
|
console.log(`• "${term}" column already enabled`);
|
||||||
|
} else {
|
||||||
|
await checkbox.click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
console.log(`✓ Enabled "${term}" column`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await enableColumn('mdr');
|
||||||
|
await enableColumn('vat');
|
||||||
|
await enableColumn('fee');
|
||||||
|
|
||||||
|
// Clear the search before closing
|
||||||
|
await searchInput.fill('');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Click Done to apply
|
||||||
|
const doneBtn = modal.locator('button:has-text("Done")').first();
|
||||||
|
await doneBtn.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
console.log('✓ Applied column selection (clicked Done)');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: Sum MDR + VAT + Fee across all transactions on the latest date
|
||||||
|
await test.step('Step 5: Sum MDR + VAT + Fee for the latest date', async () => {
|
||||||
|
const monthRegex = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s*\d{4}/;
|
||||||
|
|
||||||
|
// Go back to page 1 (confirm the table actually re-renders)
|
||||||
|
const beforeKey = await page.locator('tr.ant-table-row').first().getAttribute('data-row-key');
|
||||||
|
const firstPageBtn = page.getByRole('button', { name: '1', exact: true }).first();
|
||||||
|
if (await firstPageBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||||
|
await firstPageBtn.scrollIntoViewIfNeeded().catch(() => {});
|
||||||
|
await firstPageBtn.click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
(prevKey) => {
|
||||||
|
const r = document.querySelector('tr.ant-table-row');
|
||||||
|
return r && r.getAttribute('data-row-key') !== prevKey;
|
||||||
|
},
|
||||||
|
beforeKey,
|
||||||
|
{ timeout: 10000 }
|
||||||
|
).catch(() => console.log('⚠ Did not return to page 1 cleanly'));
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
console.log(`Back on page 1, first row ID: ${await page.locator('tr.ant-table-row').first().getAttribute('data-row-key')}`);
|
||||||
|
|
||||||
|
// Determine column indexes for MDR, VAT, Fee from the table header
|
||||||
|
const headerCells = page.locator('thead th');
|
||||||
|
const headerCount = await headerCells.count();
|
||||||
|
let mdrIdx = -1, vatIdx = -1, feeIdx = -1;
|
||||||
|
for (let i = 0; i < headerCount; i++) {
|
||||||
|
const t = (await headerCells.nth(i).textContent())?.trim();
|
||||||
|
if (t === 'MDR') mdrIdx = i;
|
||||||
|
else if (t === 'VAT') vatIdx = i;
|
||||||
|
else if (t === 'Fee') feeIdx = i;
|
||||||
|
}
|
||||||
|
console.log(`Column indexes -> MDR:${mdrIdx} VAT:${vatIdx} Fee:${feeIdx}`);
|
||||||
|
if (mdrIdx < 0 || vatIdx < 0 || feeIdx < 0) {
|
||||||
|
throw new Error('Could not locate MDR/VAT/Fee columns in header');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a numeric value from a cell: the value sits in the last <article>,
|
||||||
|
// separate from the currency-icon SVG article.
|
||||||
|
const parseCell = async (cell: ReturnType<typeof page.locator>): Promise<number> => {
|
||||||
|
const valArticle = cell.locator('article').last();
|
||||||
|
const txt = (await valArticle.textContent().catch(() => '')) || '';
|
||||||
|
const num = parseFloat(txt.replace(/[^0-9.\-]/g, ''));
|
||||||
|
return isNaN(num) ? 0 : num;
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalMDR = 0, totalVAT = 0, totalFee = 0, totalRows = 0;
|
||||||
|
let currentPage = 1;
|
||||||
|
let keepPaging = true;
|
||||||
|
|
||||||
|
while (keepPaging) {
|
||||||
|
await page.locator('tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
const rows = page.locator('tr.ant-table-row');
|
||||||
|
const rowCount = await rows.count();
|
||||||
|
const firstKey = await rows.first().getAttribute('data-row-key');
|
||||||
|
|
||||||
|
let pageMatching = 0;
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
const row = rows.nth(i);
|
||||||
|
const rowText = await row.textContent();
|
||||||
|
const m = rowText?.match(monthRegex);
|
||||||
|
const rowDate = m ? m[0] : '';
|
||||||
|
if (rowDate !== latestDate) continue;
|
||||||
|
|
||||||
|
const cells = row.locator('td');
|
||||||
|
const mdr = await parseCell(cells.nth(mdrIdx));
|
||||||
|
const vat = await parseCell(cells.nth(vatIdx));
|
||||||
|
const fee = await parseCell(cells.nth(feeIdx));
|
||||||
|
const rowDiscount = mdr + vat + fee;
|
||||||
|
|
||||||
|
totalMDR += mdr;
|
||||||
|
totalVAT += vat;
|
||||||
|
totalFee += fee;
|
||||||
|
totalRows++;
|
||||||
|
pageMatching++;
|
||||||
|
|
||||||
|
const id = await row.getAttribute('data-row-key');
|
||||||
|
console.log(` Row ${id}: MDR=${mdr.toFixed(2)} VAT=${vat.toFixed(2)} Fee=${fee.toFixed(2)} -> ${rowDiscount.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[page ${currentPage}] matched ${pageMatching}/${rowCount} rows`);
|
||||||
|
|
||||||
|
// If not every row matched, we've reached older dates - stop
|
||||||
|
if (pageMatching < rowCount) {
|
||||||
|
keepPaging = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go to next page
|
||||||
|
const nextPage = currentPage + 1;
|
||||||
|
const nextPageBtn = page.getByRole('button', { name: `${nextPage}`, exact: true }).first();
|
||||||
|
const hasNextPage = await nextPageBtn.isVisible({ timeout: 2000 }).catch(() => false);
|
||||||
|
if (!hasNextPage) {
|
||||||
|
keepPaging = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextPageBtn.click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
(prevKey) => {
|
||||||
|
const row = document.querySelector('tr.ant-table-row');
|
||||||
|
return row && row.getAttribute('data-row-key') !== prevKey;
|
||||||
|
},
|
||||||
|
firstKey,
|
||||||
|
{ timeout: 10000 }
|
||||||
|
).catch(() => {
|
||||||
|
console.log('⚠ Page did not change after clicking next - stopping');
|
||||||
|
});
|
||||||
|
currentPage = nextPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grandTotal = totalMDR + totalVAT + totalFee;
|
||||||
|
txnTotalMDR = totalMDR;
|
||||||
|
txnTotalVAT = totalVAT;
|
||||||
|
txnTotalFee = totalFee;
|
||||||
|
txnTotalDiscount = grandTotal;
|
||||||
|
console.log('\n========== DISCOUNT TOTALS (Transactions page) ==========');
|
||||||
|
console.log(`Date: ${latestDate}`);
|
||||||
|
console.log(`Rows summed: ${totalRows}`);
|
||||||
|
console.log(`Total MDR: ${totalMDR.toFixed(2)}`);
|
||||||
|
console.log(`Total VAT: ${totalVAT.toFixed(2)}`);
|
||||||
|
console.log(`Total Fee: ${totalFee.toFixed(2)}`);
|
||||||
|
console.log(`Total Discount (MDR+VAT+Fee): ${grandTotal.toFixed(2)}`);
|
||||||
|
console.log('=========================================================\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 6: Go to Discounts page and filter by the transaction date
|
||||||
|
await test.step('Step 6: Go to Discounts page and filter by date', async () => {
|
||||||
|
await navigateToDiscounts(page);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('✓ On Discounts & Fees page');
|
||||||
|
|
||||||
|
// Convert latestDate ("Jun 19, 2026") to picker title format ("2026-06-19")
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
const parts = latestDate.match(/(\w+)\s+(\d{1,2}),\s*(\d{4})/);
|
||||||
|
if (!parts) {
|
||||||
|
throw new Error(`Could not parse latestDate: ${latestDate}`);
|
||||||
|
}
|
||||||
|
const titleDate = `${parts[3]}-${months[parts[1]]}-${parts[2].padStart(2, '0')}`;
|
||||||
|
console.log(`Target date cell title: ${titleDate}`);
|
||||||
|
|
||||||
|
// Open the date range picker by clicking the start date input
|
||||||
|
const startInput = page.locator('input[placeholder="Start date"]').first();
|
||||||
|
await startInput.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
console.log('✓ Opened date range picker');
|
||||||
|
|
||||||
|
// The calendar opens on the current month - page back until the
|
||||||
|
// target month is in view (this dataset's dates can be well over a
|
||||||
|
// year old, so the calendar needs more than a couple of clicks back).
|
||||||
|
const dayCell = page.locator(`td[title="${titleDate}"]`).first();
|
||||||
|
const prevBtn = page.locator('.ant-picker-header-prev-btn').first();
|
||||||
|
let dayCellFound = false;
|
||||||
|
for (let attempt = 0; attempt < 60; attempt++) {
|
||||||
|
dayCellFound = await dayCell.isVisible().catch(() => false);
|
||||||
|
if (dayCellFound) break;
|
||||||
|
await prevBtn.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
expect(dayCellFound, `Target date ${titleDate} should become available in the calendar`).toBe(true);
|
||||||
|
|
||||||
|
// Click the target day cell for the start of the range
|
||||||
|
await dayCell.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
console.log('✓ Selected start date');
|
||||||
|
|
||||||
|
// Click the same day cell again for the end of the range (single-day filter)
|
||||||
|
const dayCellEnd = page.locator(`td[title="${titleDate}"]`).first();
|
||||||
|
await dayCellEnd.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
console.log('✓ Selected end date (same day)');
|
||||||
|
|
||||||
|
// Verify the filter actually took: read back the start/end input values
|
||||||
|
const startVal = await page.locator('input[date-range="start"], input[placeholder="Start date"]').first().inputValue().catch(() => '');
|
||||||
|
const endVal = await page.locator('input[date-range="end"], input[placeholder="End date"]').first().inputValue().catch(() => '');
|
||||||
|
console.log(`Start input value: "${startVal}"`);
|
||||||
|
console.log(`End input value: "${endVal}"`);
|
||||||
|
|
||||||
|
const pickerOpen = await page.locator('.ant-picker-dropdown:visible').count().catch(() => 0);
|
||||||
|
console.log(`Picker dropdown still open: ${pickerOpen > 0 ? 'yes' : 'no'}`);
|
||||||
|
|
||||||
|
if (startVal === titleDate && endVal === titleDate) {
|
||||||
|
console.log(`✓ Date range correctly applied: ${titleDate} to ${titleDate}`);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠ Date range may not have applied correctly (expected ${titleDate} for both)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ Filtered Discounts page to ${latestDate}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 7: Read the Discounts-page total and cross-check against Transactions
|
||||||
|
await test.step('Step 7: Cross-check total discount', async () => {
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const parseNum = (s: string | null | undefined): number => {
|
||||||
|
const n = parseFloat((s || '').replace(/[^0-9.\-]/g, ''));
|
||||||
|
return isNaN(n) ? 0 : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Top "Total Discount" card (large number) - this is the source of truth
|
||||||
|
const totalDiscountCard = page.locator('article.text-4xl').first();
|
||||||
|
const cardText = await totalDiscountCard.textContent().catch(() => '');
|
||||||
|
const discountPageTotal = parseNum(cardText);
|
||||||
|
|
||||||
|
// Scheme Discount Breakdown table (scoped via its heading) for internal detail
|
||||||
|
const schemeTable = page.locator('xpath=//*[contains(text(),"Scheme Discount Breakdown")]/following::table[1]').first();
|
||||||
|
let breakdownMDR = 0, breakdownVAT = 0, breakdownFee = 0, breakdownRed = 0;
|
||||||
|
|
||||||
|
if (await schemeTable.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||||
|
// Map header columns to indexes
|
||||||
|
const headers = schemeTable.locator('thead th');
|
||||||
|
const headerCount = await headers.count();
|
||||||
|
let mdrIdx = -1, vatIdx = -1, feeIdx = -1, dfIdx = -1;
|
||||||
|
for (let i = 0; i < headerCount; i++) {
|
||||||
|
const t = (await headers.nth(i).textContent())?.trim().toLowerCase();
|
||||||
|
if (t === 'mdr') mdrIdx = i;
|
||||||
|
else if (t === 'vat') vatIdx = i;
|
||||||
|
else if (t?.includes('processing') || t === 'fee') feeIdx = i;
|
||||||
|
else if (t?.includes('discounts')) dfIdx = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum across all card-scheme rows. Each amount cell renders a
|
||||||
|
// currency-icon SVG (with an embedded <style>.cls-1{fill:#231f20}...)
|
||||||
|
// in one <article> and the actual number in a later <article> - reading
|
||||||
|
// the whole <td>'s text (like the naive version below did) drags the
|
||||||
|
// icon's hex-color digits into the string and corrupts the parsed
|
||||||
|
// number. Scope to the last <article>, same fix already used for the
|
||||||
|
// Transactions-page MDR/VAT/Fee columns above.
|
||||||
|
const readCellNum = async (row: ReturnType<typeof schemeTable.locator>, idx: number): Promise<number> => {
|
||||||
|
if (idx < 0) return 0;
|
||||||
|
const cell = row.locator('td').nth(idx);
|
||||||
|
const valArticle = cell.locator('article').last();
|
||||||
|
const txt = (await valArticle.textContent().catch(() => '')) || (await cell.textContent().catch(() => '')) || '';
|
||||||
|
return parseNum(txt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = schemeTable.locator('tbody tr:not([aria-hidden="true"])');
|
||||||
|
const rowCount = await rows.count();
|
||||||
|
for (let r = 0; r < rowCount; r++) {
|
||||||
|
const row = rows.nth(r);
|
||||||
|
breakdownMDR += await readCellNum(row, mdrIdx);
|
||||||
|
breakdownVAT += await readCellNum(row, vatIdx);
|
||||||
|
breakdownFee += await readCellNum(row, feeIdx);
|
||||||
|
breakdownRed += await readCellNum(row, dfIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakdownSum = breakdownMDR + breakdownVAT + breakdownFee;
|
||||||
|
|
||||||
|
console.log('\n========== CROSS-CHECK ==========');
|
||||||
|
console.log(`Date: ${latestDate}`);
|
||||||
|
console.log(`Transactions page sum (rounded per-row MDR+VAT+Fee): ${txnTotalDiscount.toFixed(2)}`);
|
||||||
|
console.log(` - MDR: ${txnTotalMDR.toFixed(2)}, VAT: ${txnTotalVAT.toFixed(2)}, Fee: ${txnTotalFee.toFixed(2)}`);
|
||||||
|
console.log(`Discounts page Total Discount card: ${discountPageTotal.toFixed(2)}`);
|
||||||
|
console.log(`Discounts page breakdown -> MDR: ${breakdownMDR.toFixed(2)}, VAT: ${breakdownVAT.toFixed(2)}, Fee: ${breakdownFee.toFixed(2)} (sum ${breakdownSum.toFixed(2)})`);
|
||||||
|
console.log(`Discounts page breakdown "Discounts & fees" column: ${breakdownRed.toFixed(2)}`);
|
||||||
|
|
||||||
|
const info = test.info();
|
||||||
|
|
||||||
|
// NOTE: the "Total Discount" card is scoped to the selected date range,
|
||||||
|
// but the Scheme/Terminal Discount Breakdown tables show all-time
|
||||||
|
// cumulative totals regardless of the date filter (confirmed against
|
||||||
|
// the live Terminal Discount Breakdown, which shows values far larger
|
||||||
|
// than any single day could produce). So card vs breakdown-sum is NOT
|
||||||
|
// expected to match - this is informational only, not an assertion.
|
||||||
|
const internalDiff = Math.abs(discountPageTotal - breakdownSum);
|
||||||
|
if (internalDiff < 0.01) {
|
||||||
|
console.log(`Discounts page card and breakdown happen to match (${discountPageTotal.toFixed(2)}) - breakdown is all-time, card is date-filtered`);
|
||||||
|
} else {
|
||||||
|
console.log(`Discounts page card (date-filtered: ${discountPageTotal.toFixed(2)}) vs breakdown (all-time: ${breakdownSum.toFixed(2)}) - expected to differ, not a bug`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-check Transactions sum vs Discounts total
|
||||||
|
const diff = Math.abs(txnTotalDiscount - discountPageTotal);
|
||||||
|
const summary =
|
||||||
|
`Date: ${latestDate}\n` +
|
||||||
|
`Transactions page sum (rounded per-row): ${txnTotalDiscount.toFixed(2)}\n` +
|
||||||
|
`Discounts page total: ${discountPageTotal.toFixed(2)}\n` +
|
||||||
|
`Difference: ${diff.toFixed(2)}`;
|
||||||
|
|
||||||
|
await info.attach('discount-cross-check', { body: summary, contentType: 'text/plain' });
|
||||||
|
|
||||||
|
if (diff < 0.01) {
|
||||||
|
console.log(`✓ MATCH: totals are equal (${discountPageTotal.toFixed(2)})`);
|
||||||
|
} else {
|
||||||
|
const msg = `Calculation mismatch on ${latestDate}: Transactions page sum = ${txnTotalDiscount.toFixed(2)}, Discounts page total = ${discountPageTotal.toFixed(2)} (difference ${diff.toFixed(2)})`;
|
||||||
|
console.log(`⚠ ${msg}`);
|
||||||
|
info.annotations.push({ type: 'mismatch', description: msg });
|
||||||
|
// Surface as a soft failure so the Playwright report flags it without aborting
|
||||||
|
expect.soft(txnTotalDiscount, msg).toBeCloseTo(discountPageTotal, 2);
|
||||||
|
}
|
||||||
|
console.log('=================================\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 8: Read configured discount rates from Settings (source of truth)
|
||||||
|
await test.step('Step 8: Read discount rates from Settings', async () => {
|
||||||
|
await navigateToSettings(page);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('✓ On Settings page');
|
||||||
|
|
||||||
|
// Click the "Discount rates" tab
|
||||||
|
const discountRatesTab = page.getByText('Discount rates', { exact: true }).first();
|
||||||
|
await discountRatesTab.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
console.log('✓ Opened Discount rates');
|
||||||
|
|
||||||
|
const parseNum = (s: string | null | undefined): number => {
|
||||||
|
const n = parseFloat((s || '').replace(/[^0-9.\-]/g, ''));
|
||||||
|
return isNaN(n) ? 0 : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scope to the Discount Rate Configuration table
|
||||||
|
const ratesTable = page.locator('xpath=//*[contains(text(),"Discount Rate Configuration")]/following::table[1]').first();
|
||||||
|
await ratesTable.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
|
||||||
|
// Map header columns to indexes
|
||||||
|
const headers = ratesTable.locator('thead th');
|
||||||
|
const headerCount = await headers.count();
|
||||||
|
let cardIdx = -1, mdrIdx = -1, vatIdx = -1, capIdx = -1, feeIdx = -1;
|
||||||
|
for (let i = 0; i < headerCount; i++) {
|
||||||
|
const t = (await headers.nth(i).textContent())?.trim().toLowerCase();
|
||||||
|
if (t === 'card') cardIdx = i;
|
||||||
|
else if (t === 'mdr') mdrIdx = i;
|
||||||
|
else if (t === 'vat') vatIdx = i;
|
||||||
|
else if (t?.includes('cap')) capIdx = i;
|
||||||
|
else if (t?.includes('processing') || t === 'fee') feeIdx = i;
|
||||||
|
}
|
||||||
|
console.log(`Rate columns -> card:${cardIdx} mdr:${mdrIdx} vat:${vatIdx} cap:${capIdx} fee:${feeIdx}`);
|
||||||
|
|
||||||
|
const rateMap: Record<string, { mdr: number; vat: number; cap: number; fee: number }> = {};
|
||||||
|
|
||||||
|
const rows = ratesTable.locator('tbody tr');
|
||||||
|
const rowCount = await rows.count();
|
||||||
|
for (let r = 0; r < rowCount; r++) {
|
||||||
|
const cellTexts = await rows.nth(r).locator('td').allTextContents();
|
||||||
|
const cardText = cellTexts[cardIdx] || '';
|
||||||
|
// Extract the English card-scheme name after the "/"
|
||||||
|
const nameMatch = cardText.match(/\/\s*([A-Za-z]+)/);
|
||||||
|
const cardName = (nameMatch ? nameMatch[1] : cardText.trim()).toLowerCase();
|
||||||
|
|
||||||
|
const mdr = parseNum(cellTexts[mdrIdx]);
|
||||||
|
const vat = parseNum(cellTexts[vatIdx]);
|
||||||
|
const cap = capIdx >= 0 ? parseNum(cellTexts[capIdx]) : 0;
|
||||||
|
const fee = feeIdx >= 0 ? parseNum(cellTexts[feeIdx]) : 0;
|
||||||
|
|
||||||
|
rateMap[cardName] = { mdr, vat, cap, fee };
|
||||||
|
console.log(` ${cardName}: MDR ${mdr}% | VAT ${vat}% | Cap ${cap} | Fee ${fee}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n========== DISCOUNT RATES (Settings) ==========');
|
||||||
|
console.log(JSON.stringify(rateMap, null, 2));
|
||||||
|
console.log('===============================================\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
29
tests/helpers/allure.ts
Normal file
29
tests/helpers/allure.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { test } from '@playwright/test';
|
||||||
|
|
||||||
|
export function epic(name: string) {
|
||||||
|
return test.info().annotations.push({ type: 'epic', description: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function feature(name: string) {
|
||||||
|
return test.info().annotations.push({ type: 'feature', description: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function story(name: string) {
|
||||||
|
return test.info().annotations.push({ type: 'story', description: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function severity(level: 'blocker' | 'critical' | 'normal' | 'minor' | 'trivial') {
|
||||||
|
return test.info().annotations.push({ type: 'severity', description: level });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tag(...tags: string[]) {
|
||||||
|
tags.forEach(t => test.info().annotations.push({ type: 'tag', description: t }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function description(text: string) {
|
||||||
|
return test.info().annotations.push({ type: 'description', description: text });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function step<T>(name: string, body: () => Promise<T>): Promise<T> {
|
||||||
|
return test.step(name, body);
|
||||||
|
}
|
||||||
81
tests/helpers/auth.ts
Normal file
81
tests/helpers/auth.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Page } from '@playwright/test';
|
||||||
|
import { getEnvironmentConfig } from './environment';
|
||||||
|
|
||||||
|
const env = getEnvironmentConfig();
|
||||||
|
|
||||||
|
export const TEST_CREDENTIALS = {
|
||||||
|
username: env.username,
|
||||||
|
password: env.password,
|
||||||
|
loginPath: process.env.TEST_LOGIN_PATH || '/neopaas/portal/login',
|
||||||
|
dashboardPath: '/dashboard',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loginAs(page: Page, identifier: string, password: string): Promise<void> {
|
||||||
|
console.log('Starting login process...');
|
||||||
|
|
||||||
|
// The initial navigation is the most failure-prone step on a loaded local
|
||||||
|
// machine (headless browser launch/navigation can occasionally stall well
|
||||||
|
// past a single timeout even though the server itself responds in ~1-3s) -
|
||||||
|
// retry it in place rather than letting one slow navigation fail the whole
|
||||||
|
// test and force an expensive full test-level retry.
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
try {
|
||||||
|
await page.goto(TEST_CREDENTIALS.loginPath, { waitUntil: 'domcontentloaded' });
|
||||||
|
lastError = undefined;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
console.log(`Login page navigation attempt ${attempt} failed, retrying...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastError) throw lastError;
|
||||||
|
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// The button's own text is the language you'd switch TO, not the current
|
||||||
|
// one - so it reads "English" while the page is in Arabic (click to go to
|
||||||
|
// English), and "العربية" once already in English. The language preference
|
||||||
|
// persists across logout/login (e.g. via localStorage), so on a second
|
||||||
|
// login within the same test the page may already be in English - only
|
||||||
|
// click when the button would actually switch TO English.
|
||||||
|
console.log('Checking for language switcher...');
|
||||||
|
|
||||||
|
const languageArticle = page.locator('article.ant-typography.font-poppins-500.css-1kfsfla').first();
|
||||||
|
const isVisible = await languageArticle.isVisible({ timeout: 2000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
const text = (await languageArticle.textContent())?.trim();
|
||||||
|
console.log(`Found language button with text: "${text}"`);
|
||||||
|
if (text === 'English') {
|
||||||
|
console.log('Clicking to switch language to English...');
|
||||||
|
await languageArticle.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('✓ Language switched');
|
||||||
|
} else {
|
||||||
|
console.log('Already in English - no switch needed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Filling username...');
|
||||||
|
await page.fill('input[type="email"], input[type="text"], input[name="email"], input[name="username"], input[placeholder*="email" i], input[placeholder*="username" i]', identifier);
|
||||||
|
|
||||||
|
console.log('Filling password...');
|
||||||
|
await page.fill('input[type="password"], input[name="password"]', password);
|
||||||
|
|
||||||
|
console.log('Clicking login button...');
|
||||||
|
await page.click('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")');
|
||||||
|
|
||||||
|
console.log('Waiting for redirect...');
|
||||||
|
await page.waitForURL(/\/(dashboard|terminals)/, { timeout: 30000 }).catch(() => {
|
||||||
|
console.log('Post-login URL not detected, checking for navigation...');
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('Login completed successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(page: Page): Promise<void> {
|
||||||
|
await loginAs(page, TEST_CREDENTIALS.username, TEST_CREDENTIALS.password);
|
||||||
|
}
|
||||||
97
tests/helpers/environment.ts
Normal file
97
tests/helpers/environment.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// Loads KEY=VALUE pairs from a local .env-style file into process.env,
|
||||||
|
// without overwriting anything already set (real env vars / CI secrets
|
||||||
|
// always win over the file). No external dependency needed for this - the
|
||||||
|
// format is simple enough to parse directly.
|
||||||
|
function loadLocalEnvFile(filePath: string): void {
|
||||||
|
if (!fs.existsSync(filePath)) return;
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
for (const rawLine of content.split('\n')) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
const eqIdx = line.indexOf('=');
|
||||||
|
if (eqIdx === -1) continue;
|
||||||
|
const key = line.slice(0, eqIdx).trim();
|
||||||
|
let value = line.slice(eqIdx + 1).trim();
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (key && process.env[key] === undefined) {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .env.test.local is git-ignored (matches the existing `*.local` gitignore
|
||||||
|
// pattern) - it holds real local credentials and is never required in CI,
|
||||||
|
// where GitHub Secrets populate the same variable names directly.
|
||||||
|
// Resolved from process.cwd() (not __dirname/import.meta.url) since this
|
||||||
|
// project runs as an ES module, where __dirname isn't available - Playwright
|
||||||
|
// and its config are always invoked from the project root anyway.
|
||||||
|
loadLocalEnvFile(path.resolve(process.cwd(), '.env.test.local'));
|
||||||
|
|
||||||
|
export type EnvName = 'dev' | 'staging' | 'production';
|
||||||
|
|
||||||
|
// Accepts both branch names (develop/staging/main - what CI sets ENV to,
|
||||||
|
// since e2e-tests.yml derives it from github.ref_name) and semantic names
|
||||||
|
// (dev/staging/production), so the same ENV value works whether it came
|
||||||
|
// from a branch push or a manual `ENV=production npm run test:e2e:prod`.
|
||||||
|
const ENV_ALIASES: Record<string, EnvName> = {
|
||||||
|
develop: 'dev', dev: 'dev', development: 'dev', devpro: 'dev', local: 'dev', localenv: 'dev',
|
||||||
|
staging: 'staging', stage: 'staging',
|
||||||
|
main: 'production', production: 'production', prod: 'production',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveEnvName(): EnvName {
|
||||||
|
const raw = (process.env.ENV || 'develop').toLowerCase();
|
||||||
|
return ENV_ALIASES[raw] || 'dev';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvironmentConfig {
|
||||||
|
envName: EnvName;
|
||||||
|
baseURL: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnvironmentConfig(): EnvironmentConfig {
|
||||||
|
const envName = resolveEnvName();
|
||||||
|
|
||||||
|
// dev and staging are genuinely separate deployments/branches - dev has
|
||||||
|
// its own DEV_URL now (falls back to the old shared stagingenv URL if
|
||||||
|
// DEV_URL isn't set, e.g. for local runs that haven't configured it yet).
|
||||||
|
const baseURLs: Record<EnvName, string> = {
|
||||||
|
dev: process.env.DEV_URL || 'https://stagingenv.babinnovations.com',
|
||||||
|
staging: 'https://stagingenv.babinnovations.com',
|
||||||
|
production: 'https://www.babinnovations.com/neopaas/portal',
|
||||||
|
};
|
||||||
|
|
||||||
|
// dev prefers its own DEV_USERNAME/PASSWORD, falling back to the older
|
||||||
|
// shared TEST_USERNAME/PASSWORD pair for compatibility with existing local
|
||||||
|
// .env.test.local setups. Staging falls back to that same shared pair
|
||||||
|
// unless STAGING_TEST_* is explicitly set. Production always requires its
|
||||||
|
// own PROD_TEST_* pair.
|
||||||
|
const credentials: Record<EnvName, { username: string; password: string }> = {
|
||||||
|
dev: {
|
||||||
|
username: process.env.DEV_USERNAME || process.env.TEST_USERNAME || '',
|
||||||
|
password: process.env.DEV_PASSWORD || process.env.TEST_PASSWORD || '',
|
||||||
|
},
|
||||||
|
staging: {
|
||||||
|
username: process.env.STAGING_TEST_USERNAME || process.env.TEST_USERNAME || '',
|
||||||
|
password: process.env.STAGING_TEST_PASSWORD || process.env.TEST_PASSWORD || '',
|
||||||
|
},
|
||||||
|
production: {
|
||||||
|
username: process.env.PROD_TEST_USERNAME || '',
|
||||||
|
password: process.env.PROD_TEST_PASSWORD || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
envName,
|
||||||
|
baseURL: process.env.BASE_URL || baseURLs[envName],
|
||||||
|
username: credentials[envName].username,
|
||||||
|
password: credentials[envName].password,
|
||||||
|
};
|
||||||
|
}
|
||||||
172
tests/helpers/navigation.ts
Normal file
172
tests/helpers/navigation.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation helper that tries multiple methods to reach a page
|
||||||
|
* 1. Try clicking navigation link
|
||||||
|
* 2. Fall back to direct URL navigation
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function navigateToPage(
|
||||||
|
page: Page,
|
||||||
|
linkSelectors: string[],
|
||||||
|
urlPath: string,
|
||||||
|
pageName: string
|
||||||
|
): Promise<void> {
|
||||||
|
console.log(`Navigating to ${pageName}...`);
|
||||||
|
|
||||||
|
// Try clicking navigation links first
|
||||||
|
for (const selector of linkSelectors) {
|
||||||
|
const link = page.locator(selector).first();
|
||||||
|
const isVisible = await link.isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
console.log(`Found ${pageName} link, clicking...`);
|
||||||
|
await link.click();
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {});
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify we're on the right page
|
||||||
|
if (page.url().toLowerCase().includes(urlPath.toLowerCase())) {
|
||||||
|
console.log(`Successfully navigated to ${pageName} via link`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to direct URL navigation
|
||||||
|
console.log(`${pageName} link not found, using direct URL navigation...`);
|
||||||
|
try {
|
||||||
|
await page.goto(urlPath, { timeout: 15000, waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log(`Successfully navigated to ${pageName} via URL`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Failed to navigate to ${urlPath}:`, error);
|
||||||
|
// Try with base URL
|
||||||
|
const baseUrl = page.context().browser()?.contexts()[0]?.pages()[0]?.url() || 'https://devpro.babinnovations.com';
|
||||||
|
const fullUrl = new URL(urlPath, baseUrl).href;
|
||||||
|
console.log(`Retrying with full URL: ${fullUrl}`);
|
||||||
|
await page.goto(fullUrl, { timeout: 15000, waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log(`Successfully navigated to ${pageName} via full URL`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToTransactions(page: Page): Promise<void> {
|
||||||
|
await navigateToPage(
|
||||||
|
page,
|
||||||
|
[
|
||||||
|
'a:has-text("Transaction")',
|
||||||
|
'a:has-text("Transactions")',
|
||||||
|
'[href*="transaction" i]',
|
||||||
|
'nav a:has-text("Trans")',
|
||||||
|
],
|
||||||
|
'/transactions',
|
||||||
|
'Transactions'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToTerminals(page: Page): Promise<void> {
|
||||||
|
await navigateToPage(
|
||||||
|
page,
|
||||||
|
[
|
||||||
|
'a:has-text("Terminal")',
|
||||||
|
'a:has-text("Terminals")',
|
||||||
|
'[href*="terminal" i]',
|
||||||
|
'nav a:has-text("Term")',
|
||||||
|
],
|
||||||
|
'/terminals',
|
||||||
|
'Terminals'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToRefunds(page: Page): Promise<void> {
|
||||||
|
await navigateToPage(
|
||||||
|
page,
|
||||||
|
[
|
||||||
|
'a:has-text("Refund")',
|
||||||
|
'a:has-text("Refunds")',
|
||||||
|
'[href*="refund" i]',
|
||||||
|
'nav a:has-text("Ref")',
|
||||||
|
],
|
||||||
|
'/refunds',
|
||||||
|
'Refunds'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToReconciliation(page: Page): Promise<void> {
|
||||||
|
await navigateToPage(
|
||||||
|
page,
|
||||||
|
[
|
||||||
|
'a:has-text("Reconcil")',
|
||||||
|
'a:has-text("Reconciliation")',
|
||||||
|
'[href*="reconcil" i]',
|
||||||
|
],
|
||||||
|
'/reconciliation',
|
||||||
|
'Reconciliation'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToManagement(page: Page): Promise<void> {
|
||||||
|
await navigateToPage(
|
||||||
|
page,
|
||||||
|
[
|
||||||
|
'a:has-text("Manage")',
|
||||||
|
'a:has-text("Management")',
|
||||||
|
'[href*="manage" i]',
|
||||||
|
],
|
||||||
|
'/management',
|
||||||
|
'Management'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToAdmin(page: Page): Promise<void> {
|
||||||
|
await navigateToPage(
|
||||||
|
page,
|
||||||
|
[
|
||||||
|
'a:has-text("Admin")',
|
||||||
|
'[href*="admin" i]',
|
||||||
|
],
|
||||||
|
'/admin',
|
||||||
|
'Admin'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToSettlement(page: Page): Promise<void> {
|
||||||
|
await navigateToPage(
|
||||||
|
page,
|
||||||
|
[
|
||||||
|
'a:has-text("Settlement")',
|
||||||
|
'[href*="settlement" i]',
|
||||||
|
'nav a:has-text("Settle")',
|
||||||
|
],
|
||||||
|
'/settlement',
|
||||||
|
'Settlement'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToDiscounts(page: Page): Promise<void> {
|
||||||
|
await navigateToPage(
|
||||||
|
page,
|
||||||
|
[
|
||||||
|
'a:has-text("Discount")',
|
||||||
|
'a:has-text("Discounts")',
|
||||||
|
'[href*="discount" i]',
|
||||||
|
'nav a:has-text("Disc")',
|
||||||
|
],
|
||||||
|
'/discounts',
|
||||||
|
'Discounts & Fees'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function navigateToSettings(page: Page): Promise<void> {
|
||||||
|
await navigateToPage(
|
||||||
|
page,
|
||||||
|
[
|
||||||
|
'a:has-text("Settings")',
|
||||||
|
'[href*="settings" i]',
|
||||||
|
'nav a:has-text("Setting")',
|
||||||
|
],
|
||||||
|
'/settings',
|
||||||
|
'Settings'
|
||||||
|
);
|
||||||
|
}
|
||||||
1206
tests/management.spec.ts
Normal file
1206
tests/management.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
182
tests/roles.spec.ts
Normal file
182
tests/roles.spec.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login, loginAs } from './helpers/auth';
|
||||||
|
import { epic, feature, severity, description, tag } from './helpers/allure';
|
||||||
|
import { navigateToManagement } from './helpers/navigation';
|
||||||
|
|
||||||
|
// Confirmed live by actually logging in as each role: the nav menu is
|
||||||
|
// restricted per role, and Terminal Manager lands on /terminals (not
|
||||||
|
// /dashboard) since Dashboard isn't in its menu at all.
|
||||||
|
const EXPECTED_MENUS: Record<string, { visible: string[]; hidden: string[]; landingPath: string }> = {
|
||||||
|
CASHIER: {
|
||||||
|
visible: ['Dashboard', 'Transactions', 'Terminals', 'Refunds', 'Settings'],
|
||||||
|
hidden: ['Discounts & fees', 'Reconciliation', 'Settlement', 'Management'],
|
||||||
|
landingPath: '/dashboard',
|
||||||
|
},
|
||||||
|
FINANCE: {
|
||||||
|
visible: ['Dashboard', 'Transactions', 'Discounts & fees', 'Reconciliation', 'Refunds', 'Settlement', 'Settings'],
|
||||||
|
hidden: ['Terminals', 'Management'],
|
||||||
|
landingPath: '/dashboard',
|
||||||
|
},
|
||||||
|
TERMINAL_MANAGER: {
|
||||||
|
visible: ['Terminals', 'Settings'],
|
||||||
|
hidden: ['Dashboard', 'Transactions', 'Discounts & fees', 'Reconciliation', 'Refunds', 'Settlement', 'Management'],
|
||||||
|
landingPath: '/terminals',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createRoleUser(
|
||||||
|
page: import('@playwright/test').Page,
|
||||||
|
role: keyof typeof EXPECTED_MENUS,
|
||||||
|
fullName: string,
|
||||||
|
identifier: string,
|
||||||
|
password: string
|
||||||
|
): Promise<void> {
|
||||||
|
await navigateToManagement(page);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const addUserBtn = page.locator('button').filter({ hasText: 'Add User' }).first();
|
||||||
|
await addUserBtn.click({ force: true });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
await page.locator('#fullName').first().fill(fullName);
|
||||||
|
await page.locator('#username').first().fill(identifier);
|
||||||
|
await page.locator('#password').first().fill(password);
|
||||||
|
await page.locator('#confirmPassword').first().fill(password);
|
||||||
|
|
||||||
|
if (role !== 'CASHIER') {
|
||||||
|
const roleSelect = page.locator('.ant-select:has(.anticon-solution)').first();
|
||||||
|
await roleSelect.locator('.ant-select-selector').click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const label = role === 'FINANCE' ? 'Finance' : 'Terminal Manager';
|
||||||
|
const option = page.locator('.ant-select-item-option-content', { hasText: label }).first();
|
||||||
|
await option.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
await option.click();
|
||||||
|
// Wait for the dropdown to fully close - a leftover open dropdown here
|
||||||
|
// caused the *next* select's option locator to resolve to a stale,
|
||||||
|
// invisible element from this role dropdown instead.
|
||||||
|
await page.waitForSelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)', { state: 'hidden', timeout: 5000 }).catch(() => {});
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchSelect = page.locator('.ant-select').filter({ hasText: /Select a branch/i }).first();
|
||||||
|
if (await branchSelect.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||||
|
await branchSelect.click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const options = page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option');
|
||||||
|
const count = await options.count();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const t = (await options.nth(i).textContent())?.trim();
|
||||||
|
if (t && t !== '-' && t.length > 1) {
|
||||||
|
await options.nth(i).click();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'CASHIER') {
|
||||||
|
// Cashiers additionally require an unassociated Terminal Id.
|
||||||
|
const terminalSelect = page.locator('.ant-select:has(.anticon-mobile)').first();
|
||||||
|
await expect(terminalSelect, 'Terminal Id select should be visible for Cashier role').toBeVisible({ timeout: 5000 });
|
||||||
|
await terminalSelect.locator('.ant-select-selector').click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
|
const dropdownOptions = page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option');
|
||||||
|
const count = await dropdownOptions.count();
|
||||||
|
let selected = false;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const optText = (await dropdownOptions.nth(i).textContent())?.trim();
|
||||||
|
const idMatch = optText?.match(/Id:\s*(\d+)/i);
|
||||||
|
if (idMatch && idMatch[1].length >= 8) {
|
||||||
|
await dropdownOptions.nth(i).click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
selected = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(selected, 'Should be able to select an unassociated terminal for the new cashier').toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitBtn = page.locator('.ant-modal button:has-text("Add User")').last();
|
||||||
|
await submitBtn.click();
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
const modalStillOpen = await page.locator('.ant-modal-content').first().isVisible().catch(() => false);
|
||||||
|
expect(modalStillOpen, `${role} user creation should succeed (the modal should close)`).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Role-Based Access Tests', () => {
|
||||||
|
test.setTimeout(120000);
|
||||||
|
|
||||||
|
for (const role of Object.keys(EXPECTED_MENUS) as Array<keyof typeof EXPECTED_MENUS>) {
|
||||||
|
test(`should show correct restricted navigation for ${role} role after login`, async ({ page }) => {
|
||||||
|
epic('Role-Based Access Control');
|
||||||
|
feature('Role Permissions');
|
||||||
|
tag('functional');
|
||||||
|
severity('critical');
|
||||||
|
description(`Create a ${role} user as admin, then actually log in as that user and verify their navigation menu matches expected role permissions`);
|
||||||
|
|
||||||
|
console.log(`Testing ${role} role-based access...`);
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const fullName = `RoleTest ${role} ${timestamp}`;
|
||||||
|
const identifier = role === 'CASHIER'
|
||||||
|
? `roletest${role.toLowerCase()}${timestamp}`
|
||||||
|
: `roletest${role.toLowerCase().replace('_', '')}${timestamp}@test.com`;
|
||||||
|
const password = 'Mav@1234';
|
||||||
|
|
||||||
|
await test.step(`Log in as admin and create a new ${role} user`, async () => {
|
||||||
|
await login(page);
|
||||||
|
await createRoleUser(page, role, fullName, identifier, password);
|
||||||
|
console.log(`✓ Created ${role} user: ${identifier}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Log out of the admin session', async () => {
|
||||||
|
// Navigating straight to the login URL while still authenticated as
|
||||||
|
// admin just auto-redirects back to /dashboard - the login form
|
||||||
|
// never renders, so filling it later times out. Log out explicitly.
|
||||||
|
const logoutLink = page.locator('text=Logout').first();
|
||||||
|
await logoutLink.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
await logoutLink.click();
|
||||||
|
await page.waitForURL(/\/login/, { timeout: 10000 }).catch(() => {});
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
console.log('✓ Logged out of admin session, URL:', page.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step(`Log in as the new ${role} user`, async () => {
|
||||||
|
// A just-created account can take a moment to become authenticatable
|
||||||
|
// (backend provisioning lag) - retry the login itself rather than
|
||||||
|
// failing on the first attempt landing back on the login page.
|
||||||
|
let landed = false;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
await loginAs(page, identifier, password);
|
||||||
|
console.log(`✓ Logged in as ${role}, landed on: ${page.url()}`);
|
||||||
|
landed = page.url().includes(EXPECTED_MENUS[role].landingPath);
|
||||||
|
if (landed) break;
|
||||||
|
console.log(`Login attempt ${attempt} did not land on ${EXPECTED_MENUS[role].landingPath} - retrying...`);
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
}
|
||||||
|
expect(landed, `${role} should land on ${EXPECTED_MENUS[role].landingPath} after login`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify the navigation menu matches expected role permissions', async () => {
|
||||||
|
const { visible, hidden } = EXPECTED_MENUS[role];
|
||||||
|
|
||||||
|
for (const item of visible) {
|
||||||
|
const menuItem = page.locator('button').filter({ hasText: new RegExp(`^${item}$`) }).first();
|
||||||
|
await expect(menuItem, `${role} should see "${item}" in the menu`).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
console.log(`✓ ${role} sees all expected menu items: ${visible.join(', ')}`);
|
||||||
|
|
||||||
|
for (const item of hidden) {
|
||||||
|
const menuItem = page.locator('button').filter({ hasText: new RegExp(`^${item}$`) });
|
||||||
|
const count = await menuItem.count();
|
||||||
|
expect(count, `${role} should NOT see "${item}" in the menu`).toBe(0);
|
||||||
|
}
|
||||||
|
console.log(`✓ ${role} correctly does not see: ${hidden.join(', ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`${role} role-based access test completed`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
99
tests/settings.spec.ts
Normal file
99
tests/settings.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
import { epic, feature, severity, description, tag } from './helpers/allure';
|
||||||
|
import { navigateToSettings } from './helpers/navigation';
|
||||||
|
|
||||||
|
test.describe('Settings Tests', () => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
epic('Settings');
|
||||||
|
await login(page);
|
||||||
|
await navigateToSettings(page);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display Discount Rate Configuration for the Discounts settings tab', async ({ page }) => {
|
||||||
|
feature('Settings Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('normal');
|
||||||
|
description('Open the Discount rates settings tab and verify the configuration table renders correctly, whether rates are configured or not');
|
||||||
|
|
||||||
|
console.log('Testing Discount rates settings tab...');
|
||||||
|
|
||||||
|
await test.step('Open Discount rates tab', async () => {
|
||||||
|
const discountRatesTab = page.getByText('Discount rates', { exact: true }).first();
|
||||||
|
await expect(discountRatesTab, 'Discount rates tab should be visible').toBeVisible({ timeout: 10000 });
|
||||||
|
await discountRatesTab.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
console.log('✓ Opened Discount rates');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify the Discount Rate Configuration table', async () => {
|
||||||
|
const ratesTable = page.locator('xpath=//*[contains(text(),"Discount Rate Configuration")]/following::table[1]').first();
|
||||||
|
await expect(ratesTable, 'Discount Rate Configuration table should be visible').toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const headers = await ratesTable.locator('thead th').allTextContents();
|
||||||
|
console.log('Headers:', JSON.stringify(headers));
|
||||||
|
let cardIdx = -1, mdrIdx = -1, vatIdx = -1, capIdx = -1, feeIdx = -1;
|
||||||
|
for (let i = 0; i < headers.length; i++) {
|
||||||
|
const t = headers[i].trim().toLowerCase();
|
||||||
|
if (t === 'card') cardIdx = i;
|
||||||
|
else if (t === 'mdr') mdrIdx = i;
|
||||||
|
else if (t === 'vat') vatIdx = i;
|
||||||
|
else if (t.includes('cap')) capIdx = i;
|
||||||
|
else if (t.includes('processing') || t === 'fee') feeIdx = i;
|
||||||
|
}
|
||||||
|
expect(cardIdx, 'A Card column should exist').toBeGreaterThanOrEqual(0);
|
||||||
|
expect(mdrIdx, 'An MDR column should exist').toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// Real data rows only - excludes the hidden measure row (aria-hidden)
|
||||||
|
// and an empty-state "No data" placeholder row (both previously got
|
||||||
|
// miscounted by earlier code as bogus zero-value rate entries).
|
||||||
|
const rows = ratesTable.locator('tbody tr:not([aria-hidden="true"])');
|
||||||
|
const rowCount = await rows.count();
|
||||||
|
console.log(`Row count (excluding hidden measure row): ${rowCount}`);
|
||||||
|
|
||||||
|
const bodyText = await ratesTable.locator('tbody').innerText();
|
||||||
|
const isEmptyState = /no data/i.test(bodyText) && rowCount <= 1;
|
||||||
|
|
||||||
|
if (isEmptyState) {
|
||||||
|
console.log('✓ No discount rates configured - "No data" empty state correctly shown');
|
||||||
|
// Scope to a real text-bearing element - a plain `text=/no data/i`
|
||||||
|
// locator also matches a hidden SVG <title> icon label with the same
|
||||||
|
// text, which is never actually visible.
|
||||||
|
await expect(ratesTable.locator('article, span, div').filter({ hasText: /^No data$/i }).first(), 'Empty state should show "No data"').toBeVisible();
|
||||||
|
} else {
|
||||||
|
console.log(`✓ Found ${rowCount} configured discount rate row(s), validating each`);
|
||||||
|
expect(rowCount, 'There should be at least one configured rate row').toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Read a numeric value the same safe way used for the Discounts page
|
||||||
|
// breakdown table: the value sits in the last <article> in the cell,
|
||||||
|
// separate from an embedded currency/percent icon's <style> content.
|
||||||
|
const readCellNum = async (row: import('@playwright/test').Locator, idx: number): Promise<number> => {
|
||||||
|
if (idx < 0) return 0;
|
||||||
|
const cell = row.locator('td').nth(idx);
|
||||||
|
const valArticle = cell.locator('article').last();
|
||||||
|
const txt = (await valArticle.textContent().catch(() => '')) || (await cell.textContent().catch(() => '')) || '';
|
||||||
|
const n = parseFloat(txt.replace(/[^0-9.\-]/g, ''));
|
||||||
|
return isNaN(n) ? 0 : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let r = 0; r < rowCount; r++) {
|
||||||
|
const row = rows.nth(r);
|
||||||
|
const cardText = (await row.locator('td').nth(cardIdx).innerText().catch(() => '')).trim();
|
||||||
|
const mdr = await readCellNum(row, mdrIdx);
|
||||||
|
const vat = vatIdx >= 0 ? await readCellNum(row, vatIdx) : 0;
|
||||||
|
const cap = capIdx >= 0 ? await readCellNum(row, capIdx) : 0;
|
||||||
|
const fee = feeIdx >= 0 ? await readCellNum(row, feeIdx) : 0;
|
||||||
|
console.log(` ${cardText}: MDR ${mdr}% | VAT ${vat}% | Cap ${cap} | Fee ${fee}%`);
|
||||||
|
|
||||||
|
expect(cardText.length, `Row ${r + 1} should have a card scheme name`).toBeGreaterThan(0);
|
||||||
|
expect(mdr, `Row ${r + 1} (${cardText}) MDR should be a plausible percentage`).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(mdr, `Row ${r + 1} (${cardText}) MDR should be a plausible percentage`).toBeLessThanOrEqual(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ Discount rates settings test completed');
|
||||||
|
});
|
||||||
|
});
|
||||||
346
tests/settlement.spec.ts
Normal file
346
tests/settlement.spec.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
762
tests/terminals.spec.ts
Normal file
762
tests/terminals.spec.ts
Normal file
@@ -0,0 +1,762 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
import { epic, feature, severity, description, tag } from './helpers/allure';
|
||||||
|
import { navigateToTerminals, navigateToManagement } from './helpers/navigation';
|
||||||
|
|
||||||
|
test.describe('Terminal Tests', () => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
epic('Terminal Management');
|
||||||
|
await login(page);
|
||||||
|
await navigateToTerminals(page);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: View Terminal Details with Data Matching
|
||||||
|
test('should view terminal details with all information', async ({ page }) => {
|
||||||
|
feature('Terminal Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('critical');
|
||||||
|
description('View terminal details and verify Terminal Id matches and download button exists');
|
||||||
|
|
||||||
|
console.log('Testing terminal details view with data matching...');
|
||||||
|
|
||||||
|
// Variables to store data from table
|
||||||
|
let terminalIdValue = '';
|
||||||
|
|
||||||
|
await test.step('Capture Terminal Id from first row', async () => {
|
||||||
|
const firstRow = page.locator('table tbody tr:not([aria-hidden="true"])').first();
|
||||||
|
await expect(firstRow).toBeVisible();
|
||||||
|
|
||||||
|
const cells = firstRow.locator('td');
|
||||||
|
|
||||||
|
// Extract Terminal Id (column index 5)
|
||||||
|
terminalIdValue = (await cells.nth(5).textContent())?.trim() || '';
|
||||||
|
|
||||||
|
console.log('Captured Terminal Id from table:', terminalIdValue);
|
||||||
|
expect(terminalIdValue).not.toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Click Details button to view terminal details', async () => {
|
||||||
|
const firstRow = page.locator('table tbody tr:not([aria-hidden="true"])').first();
|
||||||
|
const detailsButton = firstRow.locator('button:has-text("Details")');
|
||||||
|
await expect(detailsButton).toBeVisible();
|
||||||
|
|
||||||
|
await detailsButton.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('Details button clicked');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify terminal details panel appears', async () => {
|
||||||
|
const detailsHeading = await page.locator('text=Terminal details').first().isVisible({ timeout: 5000 });
|
||||||
|
console.log('Terminal details heading visible:', detailsHeading);
|
||||||
|
expect(detailsHeading).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify Terminal Id matches in details panel', async () => {
|
||||||
|
if (terminalIdValue && terminalIdValue.trim() !== '' && terminalIdValue !== '-') {
|
||||||
|
const terminalIdLabel = await page.locator('text=Terminal Id :').isVisible({ timeout: 3000 });
|
||||||
|
console.log('Terminal Id label found:', terminalIdLabel);
|
||||||
|
expect(terminalIdLabel).toBeTruthy();
|
||||||
|
|
||||||
|
const terminalIdInDetails = await page.locator(`text=${terminalIdValue}`).first().isVisible({ timeout: 3000 });
|
||||||
|
console.log('Terminal Id value found in details:', terminalIdInDetails);
|
||||||
|
console.log('✓ Terminal Id matches:', terminalIdValue);
|
||||||
|
expect(terminalIdInDetails).toBeTruthy();
|
||||||
|
} else {
|
||||||
|
console.log('Terminal Id is empty or dash, skipping verification');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify Download button exists in details panel', async () => {
|
||||||
|
const downloadButton = page.locator('button:has-text("Download")').last();
|
||||||
|
const isVisible = await downloadButton.isVisible({ timeout: 3000 });
|
||||||
|
console.log('Download button in details panel visible:', isVisible);
|
||||||
|
expect(isVisible).toBeTruthy();
|
||||||
|
|
||||||
|
// Verify it has the download icon
|
||||||
|
const downloadIcon = downloadButton.locator('[data-icon="download"]');
|
||||||
|
const iconVisible = await downloadIcon.isVisible().catch(() => false);
|
||||||
|
console.log('Download icon visible:', iconVisible);
|
||||||
|
|
||||||
|
console.log('✓ Download button verified in terminal details');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Terminal details verification completed - Terminal Id matches and download button exists!');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Pagination Test
|
||||||
|
test('should paginate through terminal list', async ({ page }) => {
|
||||||
|
feature('Terminal Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('normal');
|
||||||
|
description('Test pagination controls navigate to page 2 and load a genuinely different set of terminals');
|
||||||
|
|
||||||
|
console.log('Testing terminal pagination...');
|
||||||
|
|
||||||
|
// Dismiss any open modals first
|
||||||
|
await page.keyboard.press('Escape').catch(() => {});
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const table = page.locator('table').first();
|
||||||
|
await table.locator('tbody tr:not([aria-hidden="true"])').first().waitFor({ state: 'visible', timeout: 15000 });
|
||||||
|
const headers = await table.locator('thead th').allTextContents();
|
||||||
|
// The first column is a blank icon/expander column - match "ID" exactly so
|
||||||
|
// we don't accidentally grab that blank column or "Terminal Id"/"Cashier ID".
|
||||||
|
const idIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'id');
|
||||||
|
expect(idIdx, 'An ID column should exist').toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
let firstPageId = '';
|
||||||
|
|
||||||
|
await test.step('Capture first terminal ID on page 1', async () => {
|
||||||
|
const cells = await table.locator('tbody tr:not([aria-hidden="true"])').first().locator('td').allTextContents();
|
||||||
|
firstPageId = (cells[idIdx] || '').trim();
|
||||||
|
expect(firstPageId, 'Page 1 first row should have a valid ID').toMatch(/^\d+$/);
|
||||||
|
console.log('First terminal ID on page 1:', firstPageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Click page 2 button', async () => {
|
||||||
|
// Match the exact page-number text so we don't accidentally hit an
|
||||||
|
// unrelated button that merely contains "2" somewhere in its label.
|
||||||
|
const page2Button = page.locator('button').filter({ hasText: /^2$/ }).first();
|
||||||
|
const hasPage2 = await page2Button.isVisible({ timeout: 10000 }).catch(() => false);
|
||||||
|
test.skip(!hasPage2, 'Not enough terminals in this environment to require a second page');
|
||||||
|
await page2Button.scrollIntoViewIfNeeded();
|
||||||
|
await page2Button.click();
|
||||||
|
console.log('Clicked page 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify page 2 loaded a different first row', async () => {
|
||||||
|
let secondPageId = '';
|
||||||
|
for (let attempt = 0; attempt < 30; attempt++) {
|
||||||
|
const cells = await table.locator('tbody tr:not([aria-hidden="true"])').first().locator('td').allTextContents().catch(() => [] as string[]);
|
||||||
|
secondPageId = (cells[idIdx] || '').trim();
|
||||||
|
if (secondPageId && secondPageId !== firstPageId) break;
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
}
|
||||||
|
console.log('First terminal 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);
|
||||||
|
|
||||||
|
const rowCount = await table.locator('tbody tr:not([aria-hidden="true"])').count();
|
||||||
|
expect(rowCount, 'Page 2 should have rows').toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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({ force: true });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('Navigated back to page 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Pagination test completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: Download Report Test
|
||||||
|
test('should download report and verify content', async ({ page }) => {
|
||||||
|
feature('Terminal Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('normal');
|
||||||
|
description('Download terminal report in XSLX, cross-check data against portal table');
|
||||||
|
|
||||||
|
console.log('Testing terminal report download with cross-check...');
|
||||||
|
|
||||||
|
// Collect table data first
|
||||||
|
const tableRows: string[][] = [];
|
||||||
|
|
||||||
|
await test.step('Collect terminal table data', async () => {
|
||||||
|
const table = page.locator('table').first();
|
||||||
|
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 rows = table.locator('tbody tr:not([aria-hidden="true"])');
|
||||||
|
const rowCount = await rows.count();
|
||||||
|
console.log(`Terminal rows: ${rowCount}`);
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
const cells = await rows.nth(i).locator('td').allTextContents();
|
||||||
|
const cleaned = cells.map(c => c.trim());
|
||||||
|
// Ant Design renders a "No data" placeholder row with no real ID when
|
||||||
|
// a duplicate/sync copy of the table is present - require a genuine
|
||||||
|
// numeric ID so that phantom row doesn't get counted as a real terminal.
|
||||||
|
if (/^\d+$/.test(cleaned[idIdx] || '')) tableRows.push(cleaned);
|
||||||
|
}
|
||||||
|
console.log(`Collected ${tableRows.length} rows`);
|
||||||
|
expect(tableRows.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
let downloadedContent = '';
|
||||||
|
|
||||||
|
await test.step('Download XSLX report', async () => {
|
||||||
|
const downloadButton = page.locator('button:has-text("Download report")').first();
|
||||||
|
await downloadButton.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Select XSLX — radio input with value="3"
|
||||||
|
const xlsxRadio = page.locator('input.ant-radio-input[value="3"]').first();
|
||||||
|
const xlsxVis = await xlsxRadio.isVisible().catch(() => false);
|
||||||
|
if (xlsxVis) {
|
||||||
|
await xlsxRadio.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
console.log('✓ XSLX selected via radio value=3');
|
||||||
|
} else {
|
||||||
|
// Fallback to wrapper
|
||||||
|
const wrapper = page.locator('.ant-radio-button-wrapper', { hasText: 'XSLX' }).first();
|
||||||
|
await wrapper.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
console.log('✓ XSLX selected via wrapper');
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download', { timeout: 15000 });
|
||||||
|
const dlBtn = page.locator('button.bg-gradient-to-tr:has-text("Download")').first();
|
||||||
|
await dlBtn.click();
|
||||||
|
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download).toBeTruthy();
|
||||||
|
const filePath = await download.path();
|
||||||
|
console.log('✓ Downloaded:', download.suggestedFilename());
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const XLSX = await import('xlsx');
|
||||||
|
const buf = fs.readFileSync(filePath);
|
||||||
|
const wb = XLSX.read(buf, { type: 'buffer' });
|
||||||
|
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
const rows: any[][] = XLSX.utils.sheet_to_json(sheet, { header: 1 });
|
||||||
|
downloadedContent = JSON.stringify(rows);
|
||||||
|
console.log(`XSLX rows: ${rows.length}`);
|
||||||
|
console.log('Header:', JSON.stringify(rows[0])?.substring(0, 200));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Cross-check portal data against downloaded file', async () => {
|
||||||
|
expect(downloadedContent).toBeTruthy();
|
||||||
|
const unmatched: string[] = [];
|
||||||
|
for (const row of tableRows) {
|
||||||
|
const key = row[0] || row[1]; // ID or name
|
||||||
|
if (!key || !downloadedContent.includes(key)) unmatched.push(key || '(blank)');
|
||||||
|
}
|
||||||
|
const matchCount = tableRows.length - unmatched.length;
|
||||||
|
console.log(`Matched ${matchCount}/${tableRows.length} rows in XSLX`);
|
||||||
|
expect(unmatched, `Every table row's ID should appear in the downloaded report (unmatched: ${unmatched.join(', ')})`).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ Download report + cross-check completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: Remove Cashier from Terminal
|
||||||
|
test('should remove cashier from terminal', async ({ page }) => {
|
||||||
|
feature('Terminal Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('critical');
|
||||||
|
description('Remove cashier from terminal and verify it now shows Unassigned in both the details panel and the terminals table');
|
||||||
|
|
||||||
|
console.log('Testing cashier removal from terminal...');
|
||||||
|
|
||||||
|
const table = page.locator('table').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 cashierColIdx = headers.findIndex((h) => h.trim().toLowerCase() === 'cashier');
|
||||||
|
console.log('Table headers:', headers);
|
||||||
|
expect(cashierColIdx, 'A Cashier column should exist').toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
let targetRow = -1;
|
||||||
|
let cashierName = '';
|
||||||
|
|
||||||
|
await test.step('Find a terminal with an assigned cashier', async () => {
|
||||||
|
const rows = table.locator('tbody tr:not([aria-hidden="true"])');
|
||||||
|
const rowCount = await rows.count();
|
||||||
|
console.log(`Total rows: ${rowCount}`);
|
||||||
|
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
const cashierText = (await rows.nth(i).locator('td').nth(cashierColIdx).textContent({ timeout: 2000 }))?.trim() || '';
|
||||||
|
console.log(`Row ${i} cashier: "${cashierText}"`);
|
||||||
|
if (cashierText && cashierText !== 'Unassigned' && cashierText !== '-') {
|
||||||
|
targetRow = i;
|
||||||
|
cashierName = cashierText;
|
||||||
|
console.log(`✓ Found terminal with assigned cashier at row ${i}: "${cashierName}"`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(targetRow, 'A terminal with an assigned cashier should exist').toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
await rows.nth(targetRow).locator('button:has-text("Details")').click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Click Remove Cashier button', async () => {
|
||||||
|
const removeCashierButton = page.locator('button:has-text("Remove Cashier")');
|
||||||
|
await expect(removeCashierButton, 'Remove Cashier button should be visible').toBeVisible({ timeout: 5000 });
|
||||||
|
await removeCashierButton.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('Remove Cashier button clicked');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify the terminals table row shows Unassigned', async () => {
|
||||||
|
// Removing the cashier closes the details panel automatically (same
|
||||||
|
// behavior the reassign-cashier test accounts for) - dismiss defensively
|
||||||
|
// in case that ever changes, then read the row straight from the table.
|
||||||
|
const closeBtn = page.locator('button:has-text("Close"), button.ant-modal-close, [aria-label="Close"]').first();
|
||||||
|
if (await closeBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||||
|
await closeBtn.click();
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press('Escape').catch(() => {});
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const cashierText = (await table.locator('tbody tr:not([aria-hidden="true"])').nth(targetRow).locator('td').nth(cashierColIdx).textContent({ timeout: 5000 }))?.trim() || '';
|
||||||
|
console.log(`Table row ${targetRow} cashier after removal: "${cashierText}" (was "${cashierName}")`);
|
||||||
|
expect(cashierText, 'Table row should show Unassigned after removal').toBe('Unassigned');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Remove cashier test completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: Remove cashier then reassign a different one
|
||||||
|
test('should reassign cashier to terminal', async ({ page }) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
feature('Terminal Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('critical');
|
||||||
|
description('Find terminal with cashier, remove cashier, then reassign a different cashier');
|
||||||
|
|
||||||
|
console.log('Testing remove + reassign different cashier...');
|
||||||
|
|
||||||
|
let targetTerminalRow = -1;
|
||||||
|
let originalCashier = '';
|
||||||
|
|
||||||
|
// Step 1: Find a terminal with an assigned cashier
|
||||||
|
await test.step('Find terminal with assigned cashier', async () => {
|
||||||
|
const headers = await page.locator('table thead th').allTextContents();
|
||||||
|
console.log('Table headers:', headers);
|
||||||
|
|
||||||
|
const rows = page.locator('table tbody tr:not([aria-hidden="true"])');
|
||||||
|
const rowCount = await rows.count();
|
||||||
|
console.log('Total rows:', rowCount);
|
||||||
|
|
||||||
|
// Debug: print first few rows
|
||||||
|
if (rowCount > 0) {
|
||||||
|
const firstRowCells = await rows.nth(0).locator('td').allTextContents();
|
||||||
|
console.log('First row cells:', firstRowCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find a terminal with an assigned cashier
|
||||||
|
let cashierColIdx = -1;
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
const allCells = await rows.nth(i).locator('td').allTextContents();
|
||||||
|
|
||||||
|
// Look for "Unassigned" to identify the cashier column
|
||||||
|
const unassignedIdx = allCells.findIndex(cell => cell.trim() === 'Unassigned');
|
||||||
|
if (unassignedIdx >= 0) {
|
||||||
|
cashierColIdx = unassignedIdx;
|
||||||
|
console.log('Found cashier column at index:', cashierColIdx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a terminal with an assigned cashier
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
const allCells = await rows.nth(i).locator('td').allTextContents();
|
||||||
|
const cashierText = (allCells[cashierColIdx] || '').trim();
|
||||||
|
|
||||||
|
if (cashierText && cashierText !== 'Unassigned' && cashierText !== '-') {
|
||||||
|
targetTerminalRow = i;
|
||||||
|
originalCashier = cashierText;
|
||||||
|
console.log(`✓ Found terminal with assigned cashier at row ${i}: "${originalCashier}"`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no assigned cashier found, use first terminal and assign one first
|
||||||
|
if (targetTerminalRow === -1) {
|
||||||
|
targetTerminalRow = 0;
|
||||||
|
originalCashier = 'Unassigned';
|
||||||
|
console.log('✓ No assigned cashier found. Will assign one to first terminal first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(targetTerminalRow).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Open details and assign cashier if needed, then remove
|
||||||
|
await test.step('Assign cashier if unassigned, then remove', async () => {
|
||||||
|
const row = page.locator('table tbody tr:not([aria-hidden="true"])').nth(targetTerminalRow);
|
||||||
|
await row.locator('button:has-text("Details")').click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// If terminal is unassigned, assign a cashier first
|
||||||
|
if (originalCashier === 'Unassigned') {
|
||||||
|
console.log('Terminal is unassigned. Assigning a cashier first...');
|
||||||
|
|
||||||
|
const reassignBtn = page.locator('button:has-text("reassignCashier")');
|
||||||
|
const reassignVis = await reassignBtn.isVisible({ timeout: 5000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (reassignVis) {
|
||||||
|
await reassignBtn.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Select first available cashier. Scope to the open modal/drawer and
|
||||||
|
// exclude read-only inputs: a separate, always-present, read-only
|
||||||
|
// combobox elsewhere on the page (id="rc_select_0") otherwise causes
|
||||||
|
// a strict-mode violation (2 elements matched) on the plain selector.
|
||||||
|
const detailsPanel = page.locator('.ant-modal-content, .ant-drawer-content, [role="dialog"]').first();
|
||||||
|
const searchInput = detailsPanel.locator('input.ant-select-selection-search-input[role="combobox"]:not([readonly])').first();
|
||||||
|
await searchInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
await searchInput.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const option = page.locator('.ant-select-item-option:visible').first();
|
||||||
|
const optText = await option.textContent();
|
||||||
|
await option.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
console.log('✓ Assigned cashier:', optText?.trim());
|
||||||
|
|
||||||
|
// Click Done
|
||||||
|
const doneBtn = page.locator('button.bg-green-500:has-text("Done")');
|
||||||
|
await doneBtn.click();
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
console.log('✓ Assignment confirmed');
|
||||||
|
|
||||||
|
// Close and reopen to see the assigned cashier
|
||||||
|
const closeBtn = page.locator('button:has-text("Close"), button.ant-modal-close, [aria-label="Close"]').first();
|
||||||
|
const closeVis = await closeBtn.isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
if (closeVis) {
|
||||||
|
await closeBtn.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen
|
||||||
|
await row.locator('button:has-text("Details")').click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now remove the cashier
|
||||||
|
const removeBtn = page.locator('button:has-text("Remove Cashier")');
|
||||||
|
const removeBtnVis = await removeBtn.isVisible({ timeout: 5000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (removeBtnVis) {
|
||||||
|
await removeBtn.click();
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
console.log('✓ Cashier removed');
|
||||||
|
} else {
|
||||||
|
console.log('⚠ Remove button not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait and check for reassignCashier button
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Try different selectors for the reassign button
|
||||||
|
let reassignBtn = await page.locator('button:has-text("reassignCashier")').isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (!reassignBtn) {
|
||||||
|
// Try alternative selectors
|
||||||
|
reassignBtn = await page.locator('button:has-text("Reassign")').isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reassignBtn) {
|
||||||
|
// Try looking for any button with "assign" in text
|
||||||
|
reassignBtn = await page.locator('button:has-text("assign")').isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ reassignCashier button visible:', reassignBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Close details, reopen to see reassignCashier button
|
||||||
|
await test.step('Close and reopen terminal details', async () => {
|
||||||
|
// Close the details panel
|
||||||
|
const closeBtn = page.locator('button:has-text("Close"), button.ant-modal-close, [aria-label="Close"]').first();
|
||||||
|
const closeVis = await closeBtn.isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
if (closeVis) {
|
||||||
|
await closeBtn.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen the same terminal details
|
||||||
|
const row = page.locator('table tbody tr:not([aria-hidden="true"])').nth(targetTerminalRow);
|
||||||
|
await row.locator('button:has-text("Details")').click();
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
console.log('✓ Terminal details reopened');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Click reassignCashier', async () => {
|
||||||
|
// Try different selectors for the reassign button
|
||||||
|
let reassignBtn = page.locator('button:has-text("reassignCashier")');
|
||||||
|
let vis = await reassignBtn.isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (!vis) {
|
||||||
|
reassignBtn = page.locator('button:has-text("Reassign")');
|
||||||
|
vis = await reassignBtn.isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vis) {
|
||||||
|
reassignBtn = page.locator('button:has-text("assign")');
|
||||||
|
vis = await reassignBtn.isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vis) {
|
||||||
|
await reassignBtn.scrollIntoViewIfNeeded().catch(() => {});
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await reassignBtn.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
console.log('✓ reassignCashier clicked');
|
||||||
|
} else {
|
||||||
|
console.log('⚠ reassignCashier button not found, trying force click');
|
||||||
|
try {
|
||||||
|
await reassignBtn.click({ force: true });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
console.log('✓ reassignCashier clicked (force)');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('⚠ Force click failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Select a different cashier', async () => {
|
||||||
|
const detailsPanel = page.locator('.ant-modal-content, .ant-drawer-content, [role="dialog"]').first();
|
||||||
|
const searchInput = detailsPanel.locator('input.ant-select-selection-search-input[role="combobox"]:not([readonly])').first();
|
||||||
|
await searchInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
|
||||||
|
// Click to open dropdown and see all available cashiers
|
||||||
|
await searchInput.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Get all available options
|
||||||
|
const options = page.locator('.ant-select-item-option:visible');
|
||||||
|
const optCount = await options.count();
|
||||||
|
|
||||||
|
if (optCount > 0) {
|
||||||
|
// Select the first available cashier (skip if it's the original)
|
||||||
|
const firstOption = options.first();
|
||||||
|
const optText = await firstOption.textContent();
|
||||||
|
|
||||||
|
// If first option is the original cashier, try the second one
|
||||||
|
if (optText?.trim() === originalCashier && optCount > 1) {
|
||||||
|
const secondOption = options.nth(1);
|
||||||
|
const secondText = await secondOption.textContent();
|
||||||
|
await secondOption.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
console.log('✓ Selected new cashier (second option):', secondText?.trim());
|
||||||
|
} else {
|
||||||
|
await firstOption.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
console.log('✓ Selected new cashier (first option):', optText?.trim());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No available cashiers in dropdown');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Click Done to confirm reassignment', async () => {
|
||||||
|
// Click the Done button to confirm
|
||||||
|
const doneBtn = page.locator('button:has-text("Done")');
|
||||||
|
const vis = await doneBtn.isVisible({ timeout: 5000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (vis) {
|
||||||
|
await doneBtn.click();
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
console.log('✓ Done clicked - cashier reassigned');
|
||||||
|
} else {
|
||||||
|
console.log('⚠ Done button not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify new cashier assigned in table', async () => {
|
||||||
|
// Close details panel
|
||||||
|
const closeBtn = page.locator('button:has-text("Close"), button.ant-modal-close, [aria-label="Close"]').first();
|
||||||
|
const closeVis = await closeBtn.isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
if (closeVis) {
|
||||||
|
await closeBtn.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the table row — the cashier column should now show the new cashier.
|
||||||
|
// Poll instead of a single fixed-wait read: under a full suite run the
|
||||||
|
// backend/table refresh can lag more than in isolation, which caused an
|
||||||
|
// intermittent false-negative here.
|
||||||
|
const headers = await page.locator('table thead th').allTextContents();
|
||||||
|
const cashierColIdx = headers.findIndex(h => h.toLowerCase().includes('cashier') && !h.toLowerCase().includes('id') && !h.toLowerCase().includes('username'));
|
||||||
|
const row = page.locator('table tbody tr:not([aria-hidden="true"])').nth(targetTerminalRow);
|
||||||
|
|
||||||
|
let cashierText = '';
|
||||||
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
|
cashierText = (await row.locator('td').nth(cashierColIdx >= 0 ? cashierColIdx : 3).textContent({ timeout: 5000 }).catch(() => ''))?.trim() || '';
|
||||||
|
if (cashierText && cashierText !== 'Unassigned' && cashierText !== '-' && cashierText !== originalCashier) break;
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
console.log(`Table row ${targetTerminalRow} cashier after reassign: "${cashierText}"`);
|
||||||
|
console.log(`Original cashier was: "${originalCashier}"`);
|
||||||
|
|
||||||
|
const isAssigned = cashierText && cashierText !== 'Unassigned' && cashierText !== '-';
|
||||||
|
const isDifferent = cashierText !== originalCashier;
|
||||||
|
|
||||||
|
console.log('Cashier is assigned:', isAssigned);
|
||||||
|
console.log('Cashier is different from original:', isDifferent);
|
||||||
|
|
||||||
|
expect(isAssigned).toBe(true);
|
||||||
|
expect(isDifferent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ Remove + reassign different cashier completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 7: Add Terminal
|
||||||
|
test('should add a new terminal', async ({ page }) => {
|
||||||
|
feature('Terminal Operations');
|
||||||
|
tag('functional');
|
||||||
|
severity('critical');
|
||||||
|
description('Add a new terminal with Terminal ID, Provider, Terminal Model and Mada Package');
|
||||||
|
|
||||||
|
console.log('Testing add terminal...');
|
||||||
|
|
||||||
|
// Generate a unique exactly-8-digit terminal ID (10000000–99999999)
|
||||||
|
const newTerminalId = String(10000000 + (Date.now() % 90000000)).slice(0, 8);
|
||||||
|
console.log('New Terminal ID to create:', newTerminalId);
|
||||||
|
|
||||||
|
await test.step('Click Add terminal button to open form', async () => {
|
||||||
|
// The first "Add terminal" button (anticon-plus) opens/shows the inline form
|
||||||
|
const addTerminalBtn = page.locator('button:has(.anticon-plus):has-text("Add terminal"), button:has([aria-label="plus"]):has-text("Add terminal")').first();
|
||||||
|
const isVisible = await addTerminalBtn.isVisible({ timeout: 5000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
await addTerminalBtn.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log('✓ Add terminal form opened');
|
||||||
|
} else {
|
||||||
|
// Fallback: try all buttons with "Add terminal" text and pick the one with anticon-plus (not anticon-plus-square)
|
||||||
|
const allAddBtns = page.locator('button:has-text("Add terminal")');
|
||||||
|
const count = await allAddBtns.count();
|
||||||
|
console.log(`Found ${count} "Add terminal" buttons`);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const btn = allAddBtns.nth(i);
|
||||||
|
const hasPlusSquare = await btn.locator('.anticon-plus-square').isVisible().catch(() => false);
|
||||||
|
if (!hasPlusSquare) {
|
||||||
|
await btn.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
console.log(`✓ Clicked "Add terminal" button ${i} (open form button)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Fill Terminal ID', async () => {
|
||||||
|
// Scope to the open modal: the page's top-level search box has the
|
||||||
|
// placeholder "Search by Terminal id or cashier name", which also
|
||||||
|
// matches a loose `placeholder*="Terminal Id" i` selector and, being
|
||||||
|
// first in DOM order, silently absorbed the fill instead of the real
|
||||||
|
// form field - leaving every submitted terminal with a blank ID.
|
||||||
|
const modal = page.locator('.ant-modal-content, .ant-drawer-content, [role="dialog"]').first();
|
||||||
|
const terminalIdInput = modal.locator('input[placeholder="Terminal Id"]').first();
|
||||||
|
await terminalIdInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
await terminalIdInput.fill(newTerminalId);
|
||||||
|
const filledValue = await terminalIdInput.inputValue();
|
||||||
|
expect(filledValue, 'Terminal ID field should contain the value we filled').toBe(newTerminalId);
|
||||||
|
console.log('✓ Terminal ID filled:', newTerminalId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Select Terminal Provider', async () => {
|
||||||
|
const providerSelect = page.locator('.ant-select').filter({ hasText: /Select terminal provider/i }).first();
|
||||||
|
const isVisible = await providerSelect.isVisible().catch(() => false);
|
||||||
|
if (isVisible) {
|
||||||
|
await providerSelect.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const firstOption = page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option').first();
|
||||||
|
const optText = await firstOption.textContent();
|
||||||
|
await firstOption.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
console.log('✓ Provider selected:', optText?.trim());
|
||||||
|
} else {
|
||||||
|
console.log('Provider dropdown not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Select Terminal Model', async () => {
|
||||||
|
const modelSelect = page.locator('.ant-select').filter({ hasText: /Select terminal model/i }).first();
|
||||||
|
const isVisible = await modelSelect.isVisible().catch(() => false);
|
||||||
|
if (isVisible) {
|
||||||
|
await modelSelect.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const firstOption = page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option').first();
|
||||||
|
const optText = await firstOption.textContent();
|
||||||
|
await firstOption.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
console.log('✓ Terminal model selected:', optText?.trim());
|
||||||
|
} else {
|
||||||
|
console.log('Terminal model dropdown not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Select Mada Package', async () => {
|
||||||
|
const packageSelect = page.locator('.ant-select').filter({ hasText: /Select mada package/i }).first();
|
||||||
|
const isVisible = await packageSelect.isVisible().catch(() => false);
|
||||||
|
if (isVisible) {
|
||||||
|
await packageSelect.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const firstOption = page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option').first();
|
||||||
|
const optText = await firstOption.textContent();
|
||||||
|
await firstOption.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
console.log('✓ Mada package selected:', optText?.trim());
|
||||||
|
} else {
|
||||||
|
console.log('Mada package dropdown not found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Click submit Add terminal button (anticon-plus-square)', async () => {
|
||||||
|
// The second "Add terminal" button (anticon-plus-square) submits the form
|
||||||
|
const submitBtn = page.locator('button:has(.anticon-plus-square):has-text("Add terminal"), button:has([aria-label="plus-square"]):has-text("Add terminal")').first();
|
||||||
|
const isVisible = await submitBtn.isVisible({ timeout: 3000 }).catch(() => false);
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
await submitBtn.click();
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
console.log('✓ Form submitted via Add terminal (plus-square) button');
|
||||||
|
} else {
|
||||||
|
// Fallback: find the button with anticon-plus-square among all "Add terminal" buttons
|
||||||
|
const allAddBtns = page.locator('button:has-text("Add terminal")');
|
||||||
|
const count = await allAddBtns.count();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const btn = allAddBtns.nth(i);
|
||||||
|
const hasPlusSquare = await btn.locator('.anticon-plus-square').isVisible().catch(() => false);
|
||||||
|
if (hasPlusSquare) {
|
||||||
|
await btn.click();
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
console.log(`✓ Submitted via "Add terminal" button ${i} (plus-square)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify new terminal appears in table', async () => {
|
||||||
|
// Dismiss any modals
|
||||||
|
await page.keyboard.press('Escape').catch(() => {});
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// The list has 200+ pages and a new terminal isn't guaranteed to sort
|
||||||
|
// onto page 1, so search for it directly instead of paging blindly.
|
||||||
|
const searchInput = page.locator('input[placeholder*="Search by Terminal id" i]').first();
|
||||||
|
await expect(searchInput, 'Terminal search input should be visible').toBeVisible({ timeout: 10000 });
|
||||||
|
await searchInput.fill(newTerminalId);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const terminalRow = page.locator('table tbody tr').filter({ hasText: newTerminalId }).first();
|
||||||
|
await expect(terminalRow, `New terminal ${newTerminalId} should appear in search results`).toBeVisible({ timeout: 10000 });
|
||||||
|
console.log(`✓ New terminal "${newTerminalId}" found via search`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Add terminal test completed');
|
||||||
|
});
|
||||||
|
});
|
||||||
854
tests/transactions.spec.ts
Normal file
854
tests/transactions.spec.ts
Normal file
@@ -0,0 +1,854 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["node", "@playwright/test"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
// The app repo's tsconfig.app.json only included `src`, so tests/ was
|
||||||
|
// never typechecked there. Turning these on surfaces four pre-existing
|
||||||
|
// unused locals in the specs (management/refunds/terminals) that are not
|
||||||
|
// worth touching as part of the extraction - left off so `npm run
|
||||||
|
// typecheck` stays a useful signal for real type errors.
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["tests", "playwright.config.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user