From 4748d55a0b9b86a0a6b299760fa1fe299593ade3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:22:15 +0000 Subject: [PATCH] test: automate 54 of 60 manual QA rows as Playwright tests on MySQL 8 CI, with coverage matrix Co-Authored-By: Garret Andreine --- .github/workflows/e2e.yml | 81 +++++++++++++++++++ e2e/.gitignore | 3 + e2e/README.md | 12 +++ e2e/package-lock.json | 109 +++++++++++++++++++++++++ e2e/package.json | 13 +++ e2e/playwright.config.ts | 18 +++++ e2e/scripts/check-coverage.mjs | 82 +++++++++++++++++++ e2e/tests/auth.spec.ts | 119 +++++++++++++++++++++++++++ e2e/tests/cross-cutting.spec.ts | 49 ++++++++++++ e2e/tests/dashboard.spec.ts | 51 ++++++++++++ e2e/tests/deposit.spec.ts | 68 ++++++++++++++++ e2e/tests/helpers.ts | 138 ++++++++++++++++++++++++++++++++ e2e/tests/transactions.spec.ts | 60 ++++++++++++++ e2e/tests/transfer.spec.ts | 128 +++++++++++++++++++++++++++++ e2e/tests/withdraw.spec.ts | 67 ++++++++++++++++ e2e/tsconfig.json | 12 +++ qa/coverage-matrix.csv | 61 ++++++++++++++ 17 files changed, 1071 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/.gitignore create mode 100644 e2e/README.md create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/scripts/check-coverage.mjs create mode 100644 e2e/tests/auth.spec.ts create mode 100644 e2e/tests/cross-cutting.spec.ts create mode 100644 e2e/tests/dashboard.spec.ts create mode 100644 e2e/tests/deposit.spec.ts create mode 100644 e2e/tests/helpers.ts create mode 100644 e2e/tests/transactions.spec.ts create mode 100644 e2e/tests/transfer.spec.ts create mode 100644 e2e/tests/withdraw.spec.ts create mode 100644 e2e/tsconfig.json create mode 100644 qa/coverage-matrix.csv diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..29716f1a --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,81 @@ +name: e2e + +on: + pull_request: + push: + branches: [main, master, demo/qa-live-before] + workflow_dispatch: + +jobs: + playwright: + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + mysql: + image: mirror.gcr.io/library/mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: Test@123 + MYSQL_DATABASE: bankappdb + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -pTest@123" + --health-interval=5s --health-timeout=5s --health-retries=30 + env: + MVNW_REPOURL: https://maven-central.storage-download.googleapis.com/maven2 + SPRING_DATASOURCE_URL: jdbc:mysql://127.0.0.1:3306/bankappdb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC + BASE_URL: http://localhost:8080 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: e2e/package-lock.json + + - name: Coverage matrix accounts for all 60 plan rows + run: node e2e/scripts/check-coverage.mjs + + - name: Build app + run: ./mvnw -B -s demo/maven-settings.xml -DskipTests package + + - name: Start app against MySQL 8 + run: | + mysql_version=$(docker exec ${{ job.services.mysql.id }} mysql -uroot -pTest@123 -N -e 'select version()' 2>/dev/null) + echo "MySQL server version: $mysql_version" + [[ "$mysql_version" == 8.* ]] + setsid nohup java -jar target/bankapp-0.0.1-SNAPSHOT.jar > app.log 2>&1 < /dev/null & + for i in $(seq 1 120); do + curl -sf -o /dev/null "$BASE_URL/login" && { echo "app up after ${i}s"; exit 0; } + sleep 1 + done + tail -n 80 app.log + exit 1 + + - name: Install Playwright + working-directory: e2e + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Run Playwright tests + working-directory: e2e + run: npx playwright test + + - name: Upload report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + e2e/playwright-report + e2e/test-results + app.log + retention-days: 14 diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..945fcd0d --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +playwright-report/ +test-results/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..d8b7490e --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,12 @@ +# Playwright regression suite + +Automates `qa/manual-test-plan.csv`. `qa/coverage-matrix.csv` maps every plan row to its test or to the reason it stays manual; `scripts/check-coverage.mjs` fails CI if any row is missing, duplicated, or out of sync with the tests. + +```bash +demo/start-demo.sh # app on :8080 against MySQL 8 +cd e2e && npm ci && npx playwright install chromium +npx playwright test # BASE_URL overrides http://localhost:8080 +node scripts/check-coverage.mjs +``` + +Each test registers its own unique customers, so tests run in parallel against a shared database. CI: `.github/workflows/e2e.yml` (MySQL 8 service container). diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 00000000..e43f214a --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,109 @@ +{ + "name": "bankapp-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bankapp-e2e", + "devDependencies": { + "@playwright/test": "1.52.0", + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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/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/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..4276c29c --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "bankapp-e2e", + "private": true, + "scripts": { + "test": "playwright test", + "coverage:check": "node scripts/check-coverage.mjs" + }, + "devDependencies": { + "@playwright/test": "1.52.0", + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..869fd09f --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : 4, + reporter: process.env.CI + ? [['list'], ['html', { open: 'never' }], ['github']] + : [['list'], ['html', { open: 'never' }]], + use: { + baseURL: process.env.BASE_URL ?? 'http://localhost:8080', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/e2e/scripts/check-coverage.mjs b/e2e/scripts/check-coverage.mjs new file mode 100644 index 00000000..1baf290a --- /dev/null +++ b/e2e/scripts/check-coverage.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// Fails unless qa/coverage-matrix.csv accounts for every row of qa/manual-test-plan.csv exactly once, +// every "automated" row has a matching Playwright test, and every "manual" row has a reason and no test. +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +function parseCsv(text) { + const rows = []; + let row = [], field = '', quoted = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quoted) { + if (c === '"' && text[i + 1] === '"') { field += '"'; i++; } + else if (c === '"') quoted = false; + else field += c; + } else if (c === '"') quoted = true; + else if (c === ',') { row.push(field); field = ''; } + else if (c === '\n' || c === '\r') { + if (c === '\r' && text[i + 1] === '\n') i++; + row.push(field); field = ''; + if (row.some((f) => f !== '')) rows.push(row); + row = []; + } else field += c; + } + if (field !== '' || row.length) { row.push(field); rows.push(row); } + const [header, ...body] = rows; + return body.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] ?? '']))); +} + +const plan = parseCsv(readFileSync(join(root, 'qa/manual-test-plan.csv'), 'utf8')); +const matrix = parseCsv(readFileSync(join(root, 'qa/coverage-matrix.csv'), 'utf8')); + +const testsDir = join(root, 'e2e/tests'); +const testTitles = new Map(); +for (const file of readdirSync(testsDir).filter((f) => f.endsWith('.spec.ts'))) { + const src = readFileSync(join(testsDir, file), 'utf8'); + for (const m of src.matchAll(/\btest(?:\.\w+)?\(\s*'(MT-\d{3}) ([^']*)'/g)) { + const id = m[1]; + if (!testTitles.has(id)) testTitles.set(id, []); + testTitles.get(id).push({ file: `e2e/tests/${file}`, title: `${id} ${m[2]}` }); + } +} + +const errors = []; +const planIds = plan.map((r) => r.id); +const matrixById = new Map(); +for (const row of matrix) { + if (matrixById.has(row.id)) errors.push(`${row.id}: listed more than once in the coverage matrix`); + matrixById.set(row.id, row); +} + +for (const p of plan) { + const m = matrixById.get(p.id); + if (!m) { errors.push(`${p.id}: missing from the coverage matrix`); continue; } + if (m.title !== p.title) errors.push(`${p.id}: matrix title "${m.title}" does not match plan "${p.title}"`); + const tests = testTitles.get(p.id) ?? []; + if (m.status === 'automated') { + if (tests.length !== 1) errors.push(`${p.id}: marked automated but has ${tests.length} Playwright tests`); + else { + const t = tests[0]; + if (m.automated_test !== `${t.file} :: ${t.title}`) errors.push(`${p.id}: automated_test "${m.automated_test}" should be "${t.file} :: ${t.title}"`); + if (t.title !== `${p.id} ${p.title}`) errors.push(`${p.id}: test title "${t.title}" does not match plan title`); + } + } else if (m.status === 'manual') { + if (!m.notes.trim()) errors.push(`${p.id}: manual row needs a reason in notes`); + if (tests.length) errors.push(`${p.id}: marked manual but a Playwright test exists`); + } else errors.push(`${p.id}: unknown status "${m.status}"`); +} +for (const id of matrixById.keys()) if (!planIds.includes(id)) errors.push(`${id}: in the coverage matrix but not in the plan`); +for (const id of testTitles.keys()) if (!planIds.includes(id)) errors.push(`${id}: Playwright test has no plan row`); + +const automated = matrix.filter((r) => r.status === 'automated').length; +const manual = matrix.filter((r) => r.status === 'manual'); +console.log(`Plan rows: ${plan.length}. Automated: ${automated}. Manual: ${manual.length} (${manual.map((r) => r.id).join(', ')}).`); +if (errors.length) { + console.error(`Coverage matrix check FAILED:\n- ${errors.join('\n- ')}`); + process.exit(1); +} +console.log('Coverage matrix check passed: every plan row is accounted for exactly once.'); diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 00000000..f7cfeaa5 --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from '@playwright/test'; +import { PASSWORD, login, registerCustomer, uniqueName } from './helpers'; + +const INVALID_LOGIN = 'Invalid username or password.'; + +async function attemptLogin(page: import('@playwright/test').Page, username: string, password: string) { + await page.goto('/login'); + await page.locator('input[name="username"]').fill(username); + await page.locator('input[name="password"]').fill(password); + await page.getByRole('button', { name: 'Login' }).click(); +} + +test.describe('Authentication', () => { + test('MT-001 Login page shows username and password fields', async ({ page }) => { + await page.goto('/login'); + await expect(page.locator('input[name="username"]')).toBeVisible(); + await expect(page.locator('input[name="password"]')).toBeVisible(); + await expect(page.locator('input[name="password"]')).toHaveAttribute('type', 'password'); + await expect(page.getByRole('button', { name: 'Login' })).toBeVisible(); + }); + + test('MT-002 Register link on login page opens registration', async ({ page }) => { + await page.goto('/login'); + await page.getByRole('link', { name: 'Register here' }).click(); + await expect(page).toHaveURL(/\/register$/); + await expect(page.getByRole('heading', { name: 'Register a New Account' })).toBeVisible(); + }); + + test('MT-003 Login link on register page returns to login', async ({ page }) => { + await page.goto('/register'); + await page.getByRole('link', { name: 'Login here' }).click(); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible(); + }); + + test('MT-004 New customer can register', async ({ page }) => { + await page.goto('/register'); + await page.locator('input[name="username"]').fill(uniqueName('mt004')); + await page.locator('input[name="password"]').fill(PASSWORD); + await page.getByRole('button', { name: 'Register' }).click(); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible(); + }); + + test('MT-005 Duplicate username is rejected', async ({ page }) => { + const existing = await registerCustomer(page.request, 'mt005'); + await page.goto('/register'); + await page.locator('input[name="username"]').fill(existing.username); + await page.locator('input[name="password"]').fill('Another1!'); + await page.getByRole('button', { name: 'Register' }).click(); + await expect(page.getByText('User already present.')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Register a New Account' })).toBeVisible(); + }); + + test('MT-006 Valid login opens dashboard', async ({ page }) => { + const customer = await registerCustomer(page.request, 'mt006'); + await login(page, customer); + await expect(page.getByRole('heading', { name: `Welcome, ${customer.username}` })).toBeVisible(); + }); + + test('MT-007 Wrong password is rejected', async ({ page }) => { + const customer = await registerCustomer(page.request, 'mt007'); + await attemptLogin(page, customer.username, 'WrongPass1!'); + await expect(page).toHaveURL(/\/login\?error$/); + await expect(page.locator('.alert-danger')).toHaveText(INVALID_LOGIN); + }); + + test('MT-008 Unknown username gets the same error', async ({ page }) => { + const customer = await registerCustomer(page.request, 'mt008'); + await attemptLogin(page, customer.username, 'WrongPass1!'); + const wrongPasswordUrl = new URL(page.url()).pathname + new URL(page.url()).search; + const wrongPasswordMessage = (await page.locator('.alert-danger').innerText()).trim(); + + await attemptLogin(page, uniqueName('mt008_nobody'), 'WrongPass1!'); + expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe(wrongPasswordUrl); + await expect(page.locator('.alert-danger')).toHaveText(wrongPasswordMessage); + expect(wrongPasswordMessage).toBe(INVALID_LOGIN); + }); + + test('MT-009 Password is case sensitive', async ({ page }) => { + const customer = await registerCustomer(page.request, 'mt009'); + const flipped = [...customer.password] + .map((c) => (c === c.toUpperCase() ? c.toLowerCase() : c.toUpperCase())) + .join(''); + expect(flipped).not.toBe(customer.password); + await attemptLogin(page, customer.username, flipped); + await expect(page).toHaveURL(/\/login\?error$/); + await expect(page.locator('.alert-danger')).toHaveText(INVALID_LOGIN); + }); + + test('MT-010 Dashboard requires login', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible(); + }); + + test('MT-011 Transaction history requires login', async ({ page }) => { + await page.goto('/transactions'); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible(); + }); + + test('MT-012 Logout returns to login', async ({ page }) => { + const customer = await registerCustomer(page.request, 'mt012'); + await login(page, customer); + await page.getByRole('link', { name: 'Logout' }).click(); + await expect(page).toHaveURL(/\/login\?logout$/); + await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible(); + }); + + test('MT-013 Session ends after logout', async ({ page }) => { + const customer = await registerCustomer(page.request, 'mt013'); + await login(page, customer); + await page.getByRole('link', { name: 'Logout' }).click(); + await expect(page).toHaveURL(/\/login\?logout$/); + await page.goto('/dashboard'); + await expect(page).toHaveURL(/\/login$/); + }); +}); diff --git a/e2e/tests/cross-cutting.spec.ts b/e2e/tests/cross-cutting.spec.ts new file mode 100644 index 00000000..aff3604a --- /dev/null +++ b/e2e/tests/cross-cutting.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; +import { deposit, expectBalance, login, newCustomerSession, registerCustomer } from './helpers'; + +const BANK_NAME = 'Wells Fargo'; + +test.describe('Cross-cutting', () => { + test('MT-056 Customers only see their own account', async ({ browser }) => { + const contextA = await browser.newContext(); + const contextB = await browser.newContext(); + const pageA = await contextA.newPage(); + const pageB = await contextB.newPage(); + const [a, b] = await Promise.all([newCustomerSession(pageA, 'mt056a'), newCustomerSession(pageB, 'mt056b')]); + await deposit(pageA, 100); + await deposit(pageB, 25); + await Promise.all([pageA.reload(), pageB.reload()]); + + await expect(pageA.getByRole('heading', { level: 2 })).toHaveText(`Welcome, ${a.username}`); + await expectBalance(pageA, '100.00'); + await expect(pageA.locator('body')).not.toContainText(b.username); + + await expect(pageB.getByRole('heading', { level: 2 })).toHaveText(`Welcome, ${b.username}`); + await expectBalance(pageB, '25.00'); + await expect(pageB.locator('body')).not.toContainText(a.username); + + await contextA.close(); + await contextB.close(); + }); + + test('MT-057 Balance survives logout', async ({ page }) => { + const customer = await newCustomerSession(page, 'mt057'); + await deposit(page, 100); + await page.getByRole('link', { name: 'Logout' }).click(); + await expect(page).toHaveURL(/\/login\?logout$/); + await login(page, customer); + await expectBalance(page, '100.00'); + }); + + test('MT-058 Bank name shows on every page tab', async ({ page }) => { + const customer = await registerCustomer(page.request, 'mt058'); + await page.goto('/login'); + await expect(page).toHaveTitle(new RegExp(BANK_NAME)); + await page.goto('/register'); + await expect(page).toHaveTitle(new RegExp(BANK_NAME)); + await login(page, customer); + await expect(page).toHaveTitle(new RegExp(BANK_NAME)); + await page.goto('/transactions'); + await expect(page).toHaveTitle(new RegExp(BANK_NAME)); + }); +}); diff --git a/e2e/tests/dashboard.spec.ts b/e2e/tests/dashboard.spec.ts new file mode 100644 index 00000000..677aada7 --- /dev/null +++ b/e2e/tests/dashboard.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; +import { expectBalance, newCustomerSession, openPanel } from './helpers'; + +test.describe('Dashboard', () => { + test('MT-014 New customer starts at zero', async ({ page }) => { + await newCustomerSession(page, 'mt014'); + await expectBalance(page, '0.00'); + }); + + test('MT-015 Welcome message shows the customer', async ({ page }) => { + const customer = await newCustomerSession(page, 'mt015'); + await expect(page.getByRole('heading', { level: 2 })).toHaveText(`Welcome, ${customer.username}`); + }); + + test('MT-016 Account number is shown', async ({ page }) => { + await newCustomerSession(page, 'mt016'); + const details = page.locator('.account-details'); + await expect(details.getByRole('heading', { name: 'Account Details' })).toBeVisible(); + await expect(details.locator('p', { hasText: 'Account Number:' }).locator('span')).toHaveText(/^\d+$/); + }); + + test('MT-017 Navigation links are present', async ({ page }) => { + await newCustomerSession(page, 'mt017'); + const nav = page.locator('nav'); + for (const name of ['Dashboard', 'Transactions', 'Logout']) { + await expect(nav.getByRole('link', { name, exact: true })).toBeVisible(); + } + }); + + test('MT-018 Deposit panel opens', async ({ page }) => { + await newCustomerSession(page, 'mt018'); + await expect(page.locator('#depositForm input[name="amount"]')).toBeHidden(); + const form = await openPanel(page, 'Deposit'); + await expect(form.locator('input[name="amount"]')).toBeVisible(); + }); + + test('MT-019 Withdraw panel opens', async ({ page }) => { + await newCustomerSession(page, 'mt019'); + await expect(page.locator('#withdrawForm input[name="amount"]')).toBeHidden(); + const form = await openPanel(page, 'Withdraw'); + await expect(form.locator('input[name="amount"]')).toBeVisible(); + }); + + test('MT-020 Transfer panel opens', async ({ page }) => { + await newCustomerSession(page, 'mt020'); + await expect(page.locator('#transferForm input[name="toUsername"]')).toBeHidden(); + const form = await openPanel(page, 'Transfer Money'); + await expect(form.locator('input[name="toUsername"]')).toBeVisible(); + await expect(form.locator('input[name="amount"]')).toBeVisible(); + }); +}); diff --git a/e2e/tests/deposit.spec.ts b/e2e/tests/deposit.spec.ts new file mode 100644 index 00000000..c32301be --- /dev/null +++ b/e2e/tests/deposit.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; +import { deposit, expectBalance, historyRow, newCustomerSession, openHistory, openPanel, recordPosts } from './helpers'; + +test.describe('Deposit', () => { + test('MT-022 Deposit increases balance', async ({ page }) => { + await newCustomerSession(page, 'mt022'); + await deposit(page, 100); + await expectBalance(page, '100.00'); + }); + + test('MT-023 Deposits add up', async ({ page }) => { + await newCustomerSession(page, 'mt023'); + await deposit(page, 100); + await deposit(page, 250); + await expectBalance(page, '350.00'); + }); + + test('MT-024 Deposit appears in history', async ({ page }) => { + await newCustomerSession(page, 'mt024'); + await deposit(page, 100); + await openHistory(page); + const row = historyRow(page, 'Deposit'); + await expect(row).toHaveCount(1); + await expect(row.locator('td').nth(2)).toContainText('100.00'); + }); + + test('MT-025 Empty deposit is blocked', async ({ page }) => { + await newCustomerSession(page, 'mt025'); + const posts = recordPosts(page); + const form = await openPanel(page, 'Deposit'); + await form.getByRole('button', { name: 'Submit' }).click(); + const amount = form.locator('input[name="amount"]'); + expect(await amount.evaluate((el: HTMLInputElement) => el.validity.valueMissing)).toBe(true); + await expect(page).toHaveURL(/\/dashboard$/); + expect(posts).toEqual([]); + await page.reload(); + await expectBalance(page, '0.00'); + }); + + test('MT-026 Deposit field accepts whole dollars only', async ({ page }) => { + await newCustomerSession(page, 'mt026'); + const posts = recordPosts(page); + const form = await openPanel(page, 'Deposit'); + const amount = form.locator('input[name="amount"]'); + await amount.fill('10.50'); + await form.getByRole('button', { name: 'Submit' }).click(); + expect(await amount.evaluate((el: HTMLInputElement) => el.validity.stepMismatch)).toBe(true); + await expect(page).toHaveURL(/\/dashboard$/); + expect(posts).toEqual([]); + await page.reload(); + await expectBalance(page, '0.00'); + }); + + test('MT-027 Large deposit is handled', async ({ page }) => { + await newCustomerSession(page, 'mt027'); + await deposit(page, 1000000); + await expectBalance(page, '1000000.00'); + }); + + test('MT-028 Letters cannot be typed into amount', async ({ page }) => { + await newCustomerSession(page, 'mt028'); + const form = await openPanel(page, 'Deposit'); + const amount = form.locator('input[name="amount"]'); + await amount.click(); + await amount.pressSequentially('abc'); + await expect(amount).toHaveValue(''); + }); +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts new file mode 100644 index 00000000..b537be8f --- /dev/null +++ b/e2e/tests/helpers.ts @@ -0,0 +1,138 @@ +import { APIRequestContext, Browser, Page, expect } from '@playwright/test'; + +export const PASSWORD = 'Passw0rd!'; + +export interface Customer { + username: string; + password: string; +} + +let counter = 0; + +export function uniqueName(prefix: string): string { + counter += 1; + const rand = Math.random().toString(36).slice(2, 8); + return `${prefix}_${Date.now().toString(36)}${process.pid.toString(36)}${counter}${rand}`.toLowerCase(); +} + +/** Creates a customer through the real registration endpoint (no UI) for test setup. */ +export async function registerCustomer(request: APIRequestContext, prefix = 'qa'): Promise { + const customer = { username: uniqueName(prefix), password: PASSWORD }; + const res = await request.post('/register', { form: customer, maxRedirects: 0 }); + expect(res.status(), `register ${customer.username}`).toBe(302); + expect(res.headers()['location']).toMatch(/\/login$/); + return customer; +} + +export async function login(page: Page, customer: Customer): Promise { + await page.goto('/login'); + await page.locator('input[name="username"]').fill(customer.username); + await page.locator('input[name="password"]').fill(customer.password); + await page.getByRole('button', { name: 'Login' }).click(); + await expect(page).toHaveURL(/\/dashboard$/); +} + +export async function newCustomerSession(page: Page, prefix = 'qa'): Promise { + const customer = await registerCustomer(page.request, prefix); + await login(page, customer); + return customer; +} + +export function balance(page: Page) { + return page.locator('h3', { hasText: 'Current Balance:' }); +} + +export async function expectBalance(page: Page, amount: string): Promise { + await expect(balance(page)).toHaveText(`Current Balance: $${amount}`); +} + +export function errorMessage(page: Page) { + return page.locator('p.error'); +} + +type Panel = 'Deposit' | 'Withdraw' | 'Transfer Money'; +const panelIds: Record = { + Deposit: '#depositForm', + Withdraw: '#withdrawForm', + 'Transfer Money': '#transferForm', +}; + +/** Opens a Bootstrap collapse panel. Bootstrap JS comes from a CDN, so wait for it before clicking. */ +export async function openPanel(page: Page, panel: Panel) { + await page.waitForFunction(() => { + const w = window as unknown as { jQuery?: { fn?: { collapse?: unknown } } }; + return typeof w.jQuery?.fn?.collapse === 'function'; + }); + const form = page.locator(panelIds[panel]); + if (!(await form.isVisible())) { + await page.getByRole('button', { name: panel, exact: true }).click(); + } + await expect(form).toBeVisible(); + return form; +} + +async function submitAmount(page: Page, panel: 'Deposit' | 'Withdraw', amount: string | number) { + const form = await openPanel(page, panel); + await form.locator('input[name="amount"]').fill(String(amount)); + await Promise.all([ + page.waitForURL(/\/(dashboard|withdraw)$/), + form.getByRole('button', { name: 'Submit' }).click(), + ]); +} + +export async function deposit(page: Page, amount: string | number) { + await submitAmount(page, 'Deposit', amount); + await expect(page).toHaveURL(/\/dashboard$/); +} + +export async function withdraw(page: Page, amount: string | number) { + await submitAmount(page, 'Withdraw', amount); +} + +export async function transfer(page: Page, toUsername: string, amount: string | number) { + const form = await openPanel(page, 'Transfer Money'); + await form.locator('input[name="toUsername"]').fill(toUsername); + await form.locator('input[name="amount"]').fill(String(amount)); + await Promise.all([ + page.waitForURL(/\/(dashboard|transfer)$/), + form.getByRole('button', { name: 'Submit' }).click(), + ]); +} + +/** Records every POST the page sends, to prove a browser-blocked form never reached the server. */ +export function recordPosts(page: Page): string[] { + const posts: string[] = []; + page.on('request', (req) => { + if (req.method() === 'POST') posts.push(new URL(req.url()).pathname); + }); + return posts; +} + +export function historyRows(page: Page) { + return page.locator('table tbody tr'); +} + +export function historyRow(page: Page, type: string) { + return historyRows(page).filter({ has: page.locator('td:nth-child(2)', { hasText: new RegExp(`^${escapeRegExp(type)}$`) }) }); +} + +export async function openHistory(page: Page) { + await page.goto('/transactions'); + await expect(page.getByRole('heading', { name: 'Transaction History' })).toBeVisible(); +} + +/** Logs a customer in on a separate browser context and returns their dashboard balance text. */ +export async function balanceOf(browser: Browser, customer: Customer): Promise { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + await login(page, customer); + return (await balance(page).innerText()).trim(); + } finally { + await context.close(); + } +} + +export function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/e2e/tests/transactions.spec.ts b/e2e/tests/transactions.spec.ts new file mode 100644 index 00000000..74ab776b --- /dev/null +++ b/e2e/tests/transactions.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test'; +import { + deposit, historyRow, historyRows, newCustomerSession, openHistory, registerCustomer, transfer, withdraw, +} from './helpers'; + +const COLUMNS = ['ID', 'Type', 'Amount', 'Date']; + +test.describe('Transactions', () => { + test('MT-049 New customer has empty history', async ({ page }) => { + await newCustomerSession(page, 'mt049'); + await openHistory(page); + await expect(page.locator('table thead th')).toHaveText(COLUMNS); + await expect(historyRows(page)).toHaveCount(0); + }); + + test('MT-050 History columns are correct', async ({ page }) => { + await newCustomerSession(page, 'mt050'); + await openHistory(page); + await expect(page.locator('table thead th')).toHaveText(COLUMNS); + }); + + test('MT-051 All transaction types are listed', async ({ page }) => { + await newCustomerSession(page, 'mt051'); + const recipient = await registerCustomer(page.request, 'mt051r'); + await deposit(page, 100); + await withdraw(page, 10); + await transfer(page, recipient.username, 20); + await openHistory(page); + await expect(historyRow(page, 'Deposit')).toHaveCount(1); + await expect(historyRow(page, 'Withdrawal')).toHaveCount(1); + await expect(historyRow(page, `Transfer Out to ${recipient.username}`)).toHaveCount(1); + }); + + test('MT-052 Back to Dashboard link works', async ({ page }) => { + const customer = await newCustomerSession(page, 'mt052'); + await openHistory(page); + await page.getByRole('link', { name: 'Back to Dashboard' }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole('heading', { name: `Welcome, ${customer.username}` })).toBeVisible(); + }); + + test('MT-053 Transactions link works from dashboard', async ({ page }) => { + await newCustomerSession(page, 'mt053'); + await page.locator('nav').getByRole('link', { name: 'Transactions', exact: true }).click(); + await expect(page).toHaveURL(/\/transactions$/); + await expect(page.getByRole('heading', { name: 'Transaction History' })).toBeVisible(); + }); + + test('MT-054 Every row has a date', async ({ page }) => { + await newCustomerSession(page, 'mt054'); + await deposit(page, 100); + await deposit(page, 5); + await openHistory(page); + const rows = historyRows(page); + await expect(rows).toHaveCount(2); + for (const row of await rows.all()) { + await expect(row.locator('td').nth(3)).toHaveText(/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/); + } + }); +}); diff --git a/e2e/tests/transfer.spec.ts b/e2e/tests/transfer.spec.ts new file mode 100644 index 00000000..f1da8f78 --- /dev/null +++ b/e2e/tests/transfer.spec.ts @@ -0,0 +1,128 @@ +import { test, expect } from '@playwright/test'; +import { + balanceOf, deposit, errorMessage, expectBalance, historyRow, historyRows, login, newCustomerSession, openHistory, + openPanel, recordPosts, registerCustomer, transfer, uniqueName, +} from './helpers'; + +test.describe('Transfer', () => { + test('MT-036 Transfer debits the sender', async ({ page }) => { + await newCustomerSession(page, 'mt036s'); + const recipient = await registerCustomer(page.request, 'mt036r'); + await deposit(page, 100); + await transfer(page, recipient.username, 30); + await expect(errorMessage(page)).toHaveCount(0); + await expectBalance(page, '70.00'); + }); + + test('MT-037 Transfer credits the recipient', async ({ page, browser }) => { + await newCustomerSession(page, 'mt037s'); + const recipient = await registerCustomer(page.request, 'mt037r'); + await deposit(page, 100); + await transfer(page, recipient.username, 30); + await expectBalance(page, '70.00'); + expect(await balanceOf(browser, recipient)).toBe('Current Balance: $30.00'); + }); + + test('MT-038 Sender history shows the transfer', async ({ page }) => { + await newCustomerSession(page, 'mt038s'); + const recipient = await registerCustomer(page.request, 'mt038r'); + await deposit(page, 100); + await transfer(page, recipient.username, 30); + await openHistory(page); + const row = historyRow(page, `Transfer Out to ${recipient.username}`); + await expect(row).toHaveCount(1); + await expect(row.locator('td').nth(2)).toContainText('30.00'); + }); + + test('MT-039 Recipient history shows the transfer', async ({ page, browser }) => { + const sender = await newCustomerSession(page, 'mt039s'); + const recipient = await registerCustomer(page.request, 'mt039r'); + await deposit(page, 100); + await transfer(page, recipient.username, 30); + await expectBalance(page, '70.00'); + + const context = await browser.newContext(); + const recipientPage = await context.newPage(); + await login(recipientPage, recipient); + await openHistory(recipientPage); + const row = historyRow(recipientPage, `Transfer In from ${sender.username}`); + await expect(row).toHaveCount(1); + await expect(row.locator('td').nth(2)).toContainText('30.00'); + await context.close(); + }); + + test('MT-040 Customer can transfer the full balance', async ({ page, browser }) => { + await newCustomerSession(page, 'mt040s'); + const recipient = await registerCustomer(page.request, 'mt040r'); + await deposit(page, 100); + await transfer(page, recipient.username, 100); + await expect(errorMessage(page)).toHaveCount(0); + await expectBalance(page, '0.00'); + expect(await balanceOf(browser, recipient)).toBe('Current Balance: $100.00'); + }); + + test('MT-041 Transfer above balance is rejected', async ({ page, browser }) => { + await newCustomerSession(page, 'mt041s'); + const recipient = await registerCustomer(page.request, 'mt041r'); + await deposit(page, 100); + await transfer(page, recipient.username, 150); + await expect(errorMessage(page)).toHaveText('Insufficient funds'); + await page.goto('/dashboard'); + await expectBalance(page, '100.00'); + expect(await balanceOf(browser, recipient)).toBe('Current Balance: $0.00'); + }); + + // The app checks funds before it looks up the recipient, so the sender must hold at least + // the amount for 'Recipient account not found' to be reachable. + test('MT-042 Transfer to unknown customer is rejected', async ({ page }) => { + await newCustomerSession(page, 'mt042'); + await deposit(page, 50); + await transfer(page, uniqueName('mt042_nobody'), 10); + await expect(errorMessage(page)).toHaveText('Recipient account not found'); + await page.goto('/dashboard'); + await expectBalance(page, '50.00'); + }); + + test('MT-043 Recipient is required', async ({ page }) => { + await newCustomerSession(page, 'mt043'); + const posts = recordPosts(page); + const form = await openPanel(page, 'Transfer Money'); + await form.locator('input[name="amount"]').fill('10'); + await form.getByRole('button', { name: 'Submit' }).click(); + expect(await form.locator('input[name="toUsername"]').evaluate((el: HTMLInputElement) => el.validity.valueMissing)).toBe(true); + await expect(page).toHaveURL(/\/dashboard$/); + expect(posts).toEqual([]); + }); + + test('MT-044 Amount is required', async ({ page }) => { + await newCustomerSession(page, 'mt044'); + const recipient = await registerCustomer(page.request, 'mt044r'); + const posts = recordPosts(page); + const form = await openPanel(page, 'Transfer Money'); + await form.locator('input[name="toUsername"]').fill(recipient.username); + await form.getByRole('button', { name: 'Submit' }).click(); + expect(await form.locator('input[name="amount"]').evaluate((el: HTMLInputElement) => el.validity.valueMissing)).toBe(true); + await expect(page).toHaveURL(/\/dashboard$/); + expect(posts).toEqual([]); + }); + + test('MT-045 Multiple transfers add up', async ({ page, browser }) => { + await newCustomerSession(page, 'mt045s'); + const recipient = await registerCustomer(page.request, 'mt045r'); + await deposit(page, 100); + await transfer(page, recipient.username, 20); + await transfer(page, recipient.username, 30); + await expectBalance(page, '50.00'); + expect(await balanceOf(browser, recipient)).toBe('Current Balance: $50.00'); + }); + + test('MT-046 Rejected transfer leaves no history', async ({ page }) => { + await newCustomerSession(page, 'mt046'); + await deposit(page, 50); + await transfer(page, uniqueName('mt046_nobody'), 10); + await expect(errorMessage(page)).toHaveText('Recipient account not found'); + await openHistory(page); + await expect(historyRows(page).filter({ hasText: 'Transfer' })).toHaveCount(0); + await expect(historyRows(page)).toHaveCount(1); + }); +}); diff --git a/e2e/tests/withdraw.spec.ts b/e2e/tests/withdraw.spec.ts new file mode 100644 index 00000000..df5aea53 --- /dev/null +++ b/e2e/tests/withdraw.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test'; +import { + deposit, errorMessage, expectBalance, historyRow, newCustomerSession, openHistory, openPanel, recordPosts, withdraw, +} from './helpers'; + +test.describe('Withdraw', () => { + test('MT-029 Withdrawal decreases balance', async ({ page }) => { + await newCustomerSession(page, 'mt029'); + await deposit(page, 100); + await withdraw(page, 40); + await expectBalance(page, '60.00'); + }); + + test('MT-030 Customer can withdraw the full balance', async ({ page }) => { + await newCustomerSession(page, 'mt030'); + await deposit(page, 100); + await withdraw(page, 100); + await expect(errorMessage(page)).toHaveCount(0); + await expectBalance(page, '0.00'); + }); + + test('MT-031 Overdraw is rejected', async ({ page }) => { + await newCustomerSession(page, 'mt031'); + await deposit(page, 100); + await withdraw(page, 500); + await expect(errorMessage(page)).toHaveText('Insufficient funds'); + await expectBalance(page, '100.00'); + await page.goto('/dashboard'); + await expectBalance(page, '100.00'); + }); + + test('MT-032 Withdrawal from empty account is rejected', async ({ page }) => { + await newCustomerSession(page, 'mt032'); + await withdraw(page, 1); + await expect(errorMessage(page)).toHaveText('Insufficient funds'); + }); + + test('MT-033 Withdrawal appears in history', async ({ page }) => { + await newCustomerSession(page, 'mt033'); + await deposit(page, 100); + await withdraw(page, 40); + await openHistory(page); + const row = historyRow(page, 'Withdrawal'); + await expect(row).toHaveCount(1); + await expect(row.locator('td').nth(2)).toContainText('40.00'); + }); + + test('MT-034 Empty withdrawal is blocked', async ({ page }) => { + await newCustomerSession(page, 'mt034'); + const posts = recordPosts(page); + const form = await openPanel(page, 'Withdraw'); + await form.getByRole('button', { name: 'Submit' }).click(); + expect(await form.locator('input[name="amount"]').evaluate((el: HTMLInputElement) => el.validity.valueMissing)).toBe(true); + await expect(page).toHaveURL(/\/dashboard$/); + expect(posts).toEqual([]); + }); + + test('MT-035 Rejected withdrawal leaves no history', async ({ page }) => { + await newCustomerSession(page, 'mt035'); + await deposit(page, 100); + await withdraw(page, 500); + await expect(errorMessage(page)).toHaveText('Insufficient funds'); + await openHistory(page); + await expect(historyRow(page, 'Withdrawal')).toHaveCount(0); + await expect(historyRow(page, 'Deposit')).toHaveCount(1); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..42e30ec4 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["playwright.config.ts", "tests/**/*.ts"] +} diff --git a/qa/coverage-matrix.csv b/qa/coverage-matrix.csv new file mode 100644 index 00000000..68448641 --- /dev/null +++ b/qa/coverage-matrix.csv @@ -0,0 +1,61 @@ +id,area,title,priority,status,automated_test,notes +MT-001,Authentication,Login page shows username and password fields,High,automated,e2e/tests/auth.spec.ts :: MT-001 Login page shows username and password fields, +MT-002,Authentication,Register link on login page opens registration,Medium,automated,e2e/tests/auth.spec.ts :: MT-002 Register link on login page opens registration, +MT-003,Authentication,Login link on register page returns to login,Low,automated,e2e/tests/auth.spec.ts :: MT-003 Login link on register page returns to login, +MT-004,Authentication,New customer can register,High,automated,e2e/tests/auth.spec.ts :: MT-004 New customer can register, +MT-005,Authentication,Duplicate username is rejected,High,automated,e2e/tests/auth.spec.ts :: MT-005 Duplicate username is rejected, +MT-006,Authentication,Valid login opens dashboard,High,automated,e2e/tests/auth.spec.ts :: MT-006 Valid login opens dashboard, +MT-007,Authentication,Wrong password is rejected,High,automated,e2e/tests/auth.spec.ts :: MT-007 Wrong password is rejected, +MT-008,Authentication,Unknown username gets the same error,High,automated,e2e/tests/auth.spec.ts :: MT-008 Unknown username gets the same error,Compares URL and message with the wrong-password case. +MT-009,Authentication,Password is case sensitive,Medium,automated,e2e/tests/auth.spec.ts :: MT-009 Password is case sensitive, +MT-010,Authentication,Dashboard requires login,High,automated,e2e/tests/auth.spec.ts :: MT-010 Dashboard requires login, +MT-011,Authentication,Transaction history requires login,High,automated,e2e/tests/auth.spec.ts :: MT-011 Transaction history requires login, +MT-012,Authentication,Logout returns to login,High,automated,e2e/tests/auth.spec.ts :: MT-012 Logout returns to login,Logout flag asserted as /login?logout. +MT-013,Authentication,Session ends after logout,High,automated,e2e/tests/auth.spec.ts :: MT-013 Session ends after logout, +MT-014,Dashboard,New customer starts at zero,High,automated,e2e/tests/dashboard.spec.ts :: MT-014 New customer starts at zero, +MT-015,Dashboard,Welcome message shows the customer,Low,automated,e2e/tests/dashboard.spec.ts :: MT-015 Welcome message shows the customer, +MT-016,Dashboard,Account number is shown,Medium,automated,e2e/tests/dashboard.spec.ts :: MT-016 Account number is shown, +MT-017,Dashboard,Navigation links are present,Low,automated,e2e/tests/dashboard.spec.ts :: MT-017 Navigation links are present, +MT-018,Dashboard,Deposit panel opens,Medium,automated,e2e/tests/dashboard.spec.ts :: MT-018 Deposit panel opens, +MT-019,Dashboard,Withdraw panel opens,Medium,automated,e2e/tests/dashboard.spec.ts :: MT-019 Withdraw panel opens, +MT-020,Dashboard,Transfer panel opens,Medium,automated,e2e/tests/dashboard.spec.ts :: MT-020 Transfer panel opens, +MT-021,Dashboard,Brand colours and logo match the brand guide,Low,manual,,Expected result is a human sign-off by the brand team against a brand guide that is not in the repo; there is no machine-checkable pass criterion. Needs a brand reviewer. +MT-022,Deposit,Deposit increases balance,High,automated,e2e/tests/deposit.spec.ts :: MT-022 Deposit increases balance, +MT-023,Deposit,Deposits add up,High,automated,e2e/tests/deposit.spec.ts :: MT-023 Deposits add up, +MT-024,Deposit,Deposit appears in history,High,automated,e2e/tests/deposit.spec.ts :: MT-024 Deposit appears in history, +MT-025,Deposit,Empty deposit is blocked,Medium,automated,e2e/tests/deposit.spec.ts :: MT-025 Empty deposit is blocked,Proves no POST is sent (validity.valueMissing) and balance stays $0.00 after reload. +MT-026,Deposit,Deposit field accepts whole dollars only,Medium,automated,e2e/tests/deposit.spec.ts :: MT-026 Deposit field accepts whole dollars only,Proves validity.stepMismatch and no POST; balance stays $0.00. +MT-027,Deposit,Large deposit is handled,Medium,automated,e2e/tests/deposit.spec.ts :: MT-027 Large deposit is handled, +MT-028,Deposit,Letters cannot be typed into amount,Low,automated,e2e/tests/deposit.spec.ts :: MT-028 Letters cannot be typed into amount,"Types a, b, c key by key; Chromium keeps the field empty." +MT-029,Withdraw,Withdrawal decreases balance,High,automated,e2e/tests/withdraw.spec.ts :: MT-029 Withdrawal decreases balance, +MT-030,Withdraw,Customer can withdraw the full balance,High,automated,e2e/tests/withdraw.spec.ts :: MT-030 Customer can withdraw the full balance, +MT-031,Withdraw,Overdraw is rejected,High,automated,e2e/tests/withdraw.spec.ts :: MT-031 Overdraw is rejected, +MT-032,Withdraw,Withdrawal from empty account is rejected,High,automated,e2e/tests/withdraw.spec.ts :: MT-032 Withdrawal from empty account is rejected, +MT-033,Withdraw,Withdrawal appears in history,High,automated,e2e/tests/withdraw.spec.ts :: MT-033 Withdrawal appears in history, +MT-034,Withdraw,Empty withdrawal is blocked,Medium,automated,e2e/tests/withdraw.spec.ts :: MT-034 Empty withdrawal is blocked,Proves no POST is sent (validity.valueMissing). +MT-035,Withdraw,Rejected withdrawal leaves no history,High,automated,e2e/tests/withdraw.spec.ts :: MT-035 Rejected withdrawal leaves no history,"Plan says only ""Overdraw""; test uses the MT-031 setup (deposit 100, withdraw 500)." +MT-036,Transfer,Transfer debits the sender,High,automated,e2e/tests/transfer.spec.ts :: MT-036 Transfer debits the sender, +MT-037,Transfer,Transfer credits the recipient,High,automated,e2e/tests/transfer.spec.ts :: MT-037 Transfer credits the recipient,Self-contained: repeats the MT-036 setup instead of depending on it. +MT-038,Transfer,Sender history shows the transfer,High,automated,e2e/tests/transfer.spec.ts :: MT-038 Sender history shows the transfer,Self-contained: repeats the MT-036 setup. +MT-039,Transfer,Recipient history shows the transfer,High,automated,e2e/tests/transfer.spec.ts :: MT-039 Recipient history shows the transfer,Self-contained: repeats the MT-036 setup. +MT-040,Transfer,Customer can transfer the full balance,High,automated,e2e/tests/transfer.spec.ts :: MT-040 Customer can transfer the full balance, +MT-041,Transfer,Transfer above balance is rejected,High,automated,e2e/tests/transfer.spec.ts :: MT-041 Transfer above balance is rejected, +MT-042,Transfer,Transfer to unknown customer is rejected,High,automated,e2e/tests/transfer.spec.ts :: MT-042 Transfer to unknown customer is rejected,"Plan precondition gap: the app checks funds before looking up the recipient, so a $0 sender gets ""Insufficient funds"". Test deposits 50 first." +MT-043,Transfer,Recipient is required,Medium,automated,e2e/tests/transfer.spec.ts :: MT-043 Recipient is required,Proves no POST is sent (validity.valueMissing). +MT-044,Transfer,Amount is required,Medium,automated,e2e/tests/transfer.spec.ts :: MT-044 Amount is required,Proves no POST is sent (validity.valueMissing). +MT-045,Transfer,Multiple transfers add up,High,automated,e2e/tests/transfer.spec.ts :: MT-045 Multiple transfers add up, +MT-046,Transfer,Rejected transfer leaves no history,High,automated,e2e/tests/transfer.spec.ts :: MT-046 Rejected transfer leaves no history,Sender deposits 50 first (see MT-042). +MT-047,Transfer,Negative transfer amount,High,manual,,"Expected result ""System handles it appropriately"" is not testable and the plan defines no correct behaviour, so no expectation was invented. Observed in code: AccountService.transferAmount does not check the sign, so -50 moves 50 from the recipient to the sender. Needs a product decision on the expected result (for example: reject with a message)." +MT-048,Transfer,Customer receives a transfer confirmation by SMS or email,Medium,manual,,"Needs a real phone and inbox; CI has neither. The app has no SMS or email code or dependency (pom.xml, src/main), so there is nothing to test yet. Needs product to confirm whether the feature exists." +MT-049,Transactions,New customer has empty history,Medium,automated,e2e/tests/transactions.spec.ts :: MT-049 New customer has empty history, +MT-050,Transactions,History columns are correct,Low,automated,e2e/tests/transactions.spec.ts :: MT-050 History columns are correct, +MT-051,Transactions,All transaction types are listed,High,automated,e2e/tests/transactions.spec.ts :: MT-051 All transaction types are listed, +MT-052,Transactions,Back to Dashboard link works,Low,automated,e2e/tests/transactions.spec.ts :: MT-052 Back to Dashboard link works, +MT-053,Transactions,Transactions link works from dashboard,Low,automated,e2e/tests/transactions.spec.ts :: MT-053 Transactions link works from dashboard, +MT-054,Transactions,Every row has a date,Medium,automated,e2e/tests/transactions.spec.ts :: MT-054 Every row has a date, +MT-055,Transactions,Printed history is legible on the branch printer,Low,manual,,Needs a physical branch printer and a human to judge legibility. +MT-056,Cross-cutting,Customers only see their own account,High,automated,e2e/tests/cross-cutting.spec.ts :: MT-056 Customers only see their own account,Two isolated browser contexts at the same time. +MT-057,Cross-cutting,Balance survives logout,High,automated,e2e/tests/cross-cutting.spec.ts :: MT-057 Balance survives logout, +MT-058,Cross-cutting,Bank name shows on every page tab,Low,automated,e2e/tests/cross-cutting.spec.ts :: MT-058 Bank name shows on every page tab,"Bank name taken as ""Wells Fargo"" from the app branding." +MT-059,Cross-cutting,Screen reader reads balance and errors,Medium,manual,,"Needs JAWS on Windows and a human listener judging ""announced clearly""; an automated a11y scan in Chromium is not equivalent." +MT-060,Cross-cutting,First-time customer completes a transfer unaided,Low,manual,,"Moderated usability session with a real first-time customer; timing and ""without help"" are observed by a human."