diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..e216fe2d --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,90 @@ +name: e2e (Playwright on MySQL 8) + +on: + pull_request: + push: + branches: [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 every manual row + run: node e2e/scripts/check-coverage.mjs + + - name: Confirm MySQL 8 + run: | + version="$(docker exec ${{ job.services.mysql.id }} mysql -uroot -pTest@123 -N -e 'select version()' 2>/dev/null)" + echo "MySQL server version: $version" + [[ "$version" == 8.* ]] + + - name: Build app + run: ./mvnw -B -s demo/maven-settings.xml -DskipTests package + + - name: Start app + run: | + setsid nohup java -jar target/bankapp-0.0.1-SNAPSHOT.jar > app.log 2>&1 < /dev/null & + for _ in $(seq 1 120); do + curl -sf -o /dev/null "$BASE_URL/login" && exit 0 + sleep 1 + done + tail -n 100 app.log + exit 1 + + - name: Install Playwright + working-directory: e2e + run: | + npm ci + npx playwright install --with-deps chromium + + - name: Typecheck tests + working-directory: e2e + run: npx tsc --noEmit + + - name: Run Playwright suite + working-directory: e2e + run: npx playwright test + + - name: Upload Playwright 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/.gitignore b/.gitignore index 45ff9094..9cec0d13 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ build/ ### VS Code ### .vscode/ + +### Playwright (e2e/) ### +e2e/node_modules/ +e2e/test-results/ +e2e/playwright-report/ 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..a0d37d0e --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "bankapp-e2e", + "private": true, + "description": "Playwright automation of qa/manual-test-plan.csv", + "scripts": { + "test": "playwright test", + "typecheck": "tsc --noEmit", + "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..428f2bba --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + workers: process.env.CI ? 2 : undefined, + timeout: 60_000, + expect: { timeout: 10_000 }, + reporter: [ + ['list'], + ['html', { open: 'never', outputFolder: 'playwright-report' }], + ['junit', { outputFile: 'test-results/junit.xml' }], + ], + 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..4e9519ec --- /dev/null +++ b/e2e/scripts/check-coverage.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// Fails unless qa/coverage-matrix.md accounts for every row of qa/manual-test-plan.csv exactly once, +// every "Automated" row has exactly one Playwright test titled with its ID, and every +// "Not automated" 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)), '..', '..'); +const errors = []; + +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); rows.push(row); row = []; field = ''; + } else field += c; + } + if (field || row.length) { row.push(field); rows.push(row); } + return rows.filter((r) => r.some((f) => f.trim())); +} + +const [header, ...planRows] = parseCsv(readFileSync(join(root, 'qa/manual-test-plan.csv'), 'utf8')); +const idCol = header.indexOf('id'); +const planIds = planRows.map((r) => r[idCol]); +if (new Set(planIds).size !== planIds.length) errors.push('qa/manual-test-plan.csv has duplicate IDs'); + +const matrix = new Map(); +for (const line of readFileSync(join(root, 'qa/coverage-matrix.md'), 'utf8').split('\n')) { + const cells = line.split('|').map((c) => c.trim()); + if (!/^MT-\d{3}$/.test(cells[1] ?? '')) continue; + const [, id, , , , status, test, notes] = cells; + if (matrix.has(id)) errors.push(`${id} appears more than once in the coverage matrix`); + matrix.set(id, { status, test, notes }); +} + +const testsById = new Map(); +const testsDir = join(root, 'e2e/tests'); +for (const file of readdirSync(testsDir).filter((f) => f.endsWith('.spec.ts'))) { + const source = readFileSync(join(testsDir, file), 'utf8'); + for (const m of source.matchAll(/\btest(?:\.\w+)?\(\s*['"`](MT-\d{3})\b/g)) { + testsById.set(m[1], [...(testsById.get(m[1]) ?? []), file]); + } +} + +for (const id of planIds) { + const entry = matrix.get(id); + const tests = testsById.get(id) ?? []; + if (!entry) { errors.push(`${id} is in the plan but missing from the coverage matrix`); continue; } + if (entry.status === 'Automated') { + if (tests.length !== 1) errors.push(`${id} is marked Automated but has ${tests.length} Playwright tests`); + else if (!entry.test.includes(tests[0])) errors.push(`${id} matrix points at "${entry.test}" but the test is in ${tests[0]}`); + } else if (entry.status === 'Not automated') { + if (tests.length) errors.push(`${id} is marked Not automated but has a test in ${tests.join(', ')}`); + if (!entry.notes) errors.push(`${id} is marked Not automated without a reason`); + } else errors.push(`${id} has unknown status "${entry.status}"`); +} +for (const id of [...matrix.keys(), ...testsById.keys()]) { + if (!planIds.includes(id)) errors.push(`${id} is not in qa/manual-test-plan.csv`); +} + +const automated = planIds.filter((id) => matrix.get(id)?.status === 'Automated').length; +if (errors.length) { + console.error(`Coverage matrix check FAILED:\n- ${[...new Set(errors)].join('\n- ')}`); + process.exit(1); +} +console.log(`Coverage matrix OK: ${planIds.length} plan rows, ${automated} automated, ${planIds.length - automated} not automated (with reasons).`); diff --git a/e2e/tests/authentication.spec.ts b/e2e/tests/authentication.spec.ts new file mode 100644 index 00000000..b2f20f61 --- /dev/null +++ b/e2e/tests/authentication.spec.ts @@ -0,0 +1,115 @@ +import { expect, test } from '@playwright/test'; +import { PASSWORD, login, loginToDashboard, loginUrl, registerViaHttp, uniqueUsername } from './helpers'; + +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(loginUrl); + await expect(page.getByRole('heading', { name: 'Login', exact: true })).toBeVisible(); + }); + + test('MT-004 New customer can register', async ({ page }) => { + await page.goto('/register'); + await page.locator('input[name="username"]').fill(uniqueUsername('mt004')); + await page.locator('input[name="password"]').fill(PASSWORD); + await page.getByRole('button', { name: 'Register' }).click(); + await expect(page).toHaveURL(loginUrl); + await expect(page.getByRole('heading', { name: 'Login', exact: true })).toBeVisible(); + }); + + test('MT-005 Duplicate username is rejected', async ({ page, request }) => { + const username = uniqueUsername('mt005'); + await registerViaHttp(request, username); + await page.goto('/register'); + await page.locator('input[name="username"]').fill(username); + await page.locator('input[name="password"]').fill(PASSWORD); + 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, request }) => { + const username = uniqueUsername('mt006'); + await registerViaHttp(request, username); + await loginToDashboard(page, username); + await expect(page.getByRole('heading', { level: 2 })).toHaveText(`Welcome, ${username}`); + }); + + test('MT-007 Wrong password is rejected', async ({ page, request }) => { + const username = uniqueUsername('mt007'); + await registerViaHttp(request, username); + await login(page, username, 'WrongPassw0rd!'); + await expect(page).toHaveURL(/\/login\?error$/); + await expect(page.locator('.alert-danger')).toHaveText('Invalid username or password.'); + }); + + test('MT-008 Unknown username gets the same error', async ({ page, request }) => { + const known = uniqueUsername('mt008'); + await registerViaHttp(request, known); + await login(page, known, 'WrongPassw0rd!'); + const wrongPasswordMessage = (await page.locator('.alert-danger').innerText()).trim(); + + await login(page, uniqueUsername('mt008_missing'), PASSWORD); + await expect(page).toHaveURL(/\/login\?error$/); + await expect(page.locator('.alert-danger')).toHaveText(wrongPasswordMessage); + await expect(page.locator('.alert-danger')).toHaveText('Invalid username or password.'); + }); + + test('MT-009 Password is case sensitive', async ({ page, request }) => { + const username = uniqueUsername('mt009'); + await registerViaHttp(request, username, 'CaseSensitive1'); + await login(page, username, 'casesensitive1'); + await expect(page).toHaveURL(/\/login\?error$/); + await login(page, username, 'CASESENSITIVE1'); + await expect(page).toHaveURL(/\/login\?error$/); + await expect(page.locator('.alert-danger')).toHaveText('Invalid username or password.'); + }); + + test('MT-010 Dashboard requires login', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page).toHaveURL(loginUrl); + await expect(page.getByRole('heading', { name: 'Login', exact: true })).toBeVisible(); + }); + + test('MT-011 Transaction history requires login', async ({ page }) => { + await page.goto('/transactions'); + await expect(page).toHaveURL(loginUrl); + await expect(page.getByRole('heading', { name: 'Login', exact: true })).toBeVisible(); + }); + + test('MT-012 Logout returns to login', async ({ page, request }) => { + const username = uniqueUsername('mt012'); + await registerViaHttp(request, username); + await loginToDashboard(page, username); + await page.getByRole('link', { name: 'Logout' }).click(); + await expect(page).toHaveURL(/\/login\?logout$/); + await expect(page.getByRole('heading', { name: 'Login', exact: true })).toBeVisible(); + }); + + test('MT-013 Session ends after logout', async ({ page, request }) => { + const username = uniqueUsername('mt013'); + await registerViaHttp(request, username); + await loginToDashboard(page, username); + await page.getByRole('link', { name: 'Logout' }).click(); + await expect(page).toHaveURL(/\/login\?logout$/); + await page.goto('/dashboard'); + await expect(page).toHaveURL(loginUrl); + await expect(page.getByText(`Welcome, ${username}`)).toHaveCount(0); + }); +}); diff --git a/e2e/tests/cross-cutting.spec.ts b/e2e/tests/cross-cutting.spec.ts new file mode 100644 index 00000000..9dafb0f2 --- /dev/null +++ b/e2e/tests/cross-cutting.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test'; +import { deposit, expectBalance, loginToDashboard, newCustomer, registerViaHttp, sessionFor, uniqueUsername } from './helpers'; + +const BANK_NAME = 'Wells Fargo'; + +test.describe('Cross-cutting', () => { + test('MT-056 Customers only see their own account', async ({ page, request, browser }) => { + const alice = await newCustomer(page, request, 'mt056_a'); + await deposit(page, '100'); + const bob = uniqueUsername('mt056_b'); + await registerViaHttp(request, bob); + const bobPage = await sessionFor(browser, bob); + await deposit(bobPage, '250'); + + await page.reload(); + await expect(page.getByRole('heading', { level: 2 })).toHaveText(`Welcome, ${alice}`); + await expectBalance(page, '100.00'); + await expect(page.getByText(bob)).toHaveCount(0); + + await expect(bobPage.getByRole('heading', { level: 2 })).toHaveText(`Welcome, ${bob}`); + await expectBalance(bobPage, '250.00'); + await expect(bobPage.getByText(alice)).toHaveCount(0); + await bobPage.context().close(); + }); + + test('MT-057 Balance survives logout', async ({ page, request }) => { + const username = await newCustomer(page, request, 'mt057'); + await deposit(page, '100'); + await expectBalance(page, '100.00'); + await page.getByRole('link', { name: 'Logout' }).click(); + await expect(page).toHaveURL(/\/login\?logout$/); + await loginToDashboard(page, username); + await expectBalance(page, '100.00'); + }); + + test('MT-058 Bank name shows on every page tab', async ({ page, request }) => { + 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 newCustomer(page, request, 'mt058'); + 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..8dca5676 --- /dev/null +++ b/e2e/tests/dashboard.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test'; +import { expectBalance, newCustomer, openPanel } from './helpers'; + +test.describe('Dashboard', () => { + test('MT-014 New customer starts at zero', async ({ page, request }) => { + await newCustomer(page, request, 'mt014'); + await expectBalance(page, '0.00'); + }); + + test('MT-015 Welcome message shows the customer', async ({ page, request }) => { + const username = await newCustomer(page, request, 'mt015'); + await expect(page.getByRole('heading', { level: 2 })).toHaveText(`Welcome, ${username}`); + }); + + test('MT-016 Account number is shown', async ({ page, request }) => { + await newCustomer(page, request, 'mt016'); + const details = page.locator('.account-details'); + await expect(details.getByRole('heading', { name: 'Account Details' })).toBeVisible(); + const accountNumber = details.locator('p', { hasText: 'Account Number:' }).locator('span'); + await expect(accountNumber).toHaveText(/^\d+$/); + }); + + test('MT-017 Navigation links are present', async ({ page, request }) => { + await newCustomer(page, request, '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, request }) => { + await newCustomer(page, request, '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, request }) => { + await newCustomer(page, request, '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, request }) => { + await newCustomer(page, request, 'mt020'); + await expect(page.locator('#transferForm input[name="toUsername"]')).toBeHidden(); + const form = await openPanel(page, 'transfer'); + 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..c689dc8a --- /dev/null +++ b/e2e/tests/deposit.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from '@playwright/test'; +import { deposit, expectBalance, historyTypesAndAmounts, newCustomer, openPanel, openTransactions, recordPosts } from './helpers'; + +test.describe('Deposit', () => { + test('MT-022 Deposit increases balance', async ({ page, request }) => { + await newCustomer(page, request, 'mt022'); + await deposit(page, '100'); + await expectBalance(page, '100.00'); + }); + + test('MT-023 Deposits add up', async ({ page, request }) => { + await newCustomer(page, request, 'mt023'); + await deposit(page, '100'); + await expectBalance(page, '100.00'); + await deposit(page, '250'); + await expectBalance(page, '350.00'); + }); + + test('MT-024 Deposit appears in history', async ({ page, request }) => { + await newCustomer(page, request, 'mt024'); + await deposit(page, '100'); + await openTransactions(page); + expect(await historyTypesAndAmounts(page)).toEqual([{ type: 'Deposit', amount: '100.00' }]); + }); + + test('MT-025 Empty deposit is blocked', async ({ page, request }) => { + await newCustomer(page, request, 'mt025'); + const posts = recordPosts(page, '/deposit'); + 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); + expect(posts).toEqual([]); + await page.reload(); + await expectBalance(page, '0.00'); + }); + + test('MT-026 Deposit field accepts whole dollars only', async ({ page, request }) => { + await newCustomer(page, request, 'mt026'); + const posts = recordPosts(page, '/deposit'); + 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); + expect(posts).toEqual([]); + await page.reload(); + await expectBalance(page, '0.00'); + }); + + test('MT-027 Large deposit is handled', async ({ page, request }) => { + await newCustomer(page, request, 'mt027'); + await deposit(page, '1000000'); + await expectBalance(page, '1000000.00'); + }); + + test('MT-028 Letters cannot be typed into amount', async ({ page, request }) => { + await newCustomer(page, request, 'mt028'); + const form = await openPanel(page, 'deposit'); + const amount = form.locator('input[name="amount"]'); + await amount.click(); + await page.keyboard.type('abc'); + await expect(amount).toHaveValue(''); + }); +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts new file mode 100644 index 00000000..f1b965fc --- /dev/null +++ b/e2e/tests/helpers.ts @@ -0,0 +1,121 @@ +import { APIRequestContext, Browser, Page, expect } from '@playwright/test'; + +export const PASSWORD = 'Passw0rd!'; + +let sequence = 0; + +/** Every test creates its own customers, so tests never share balances and can run in parallel. */ +export function uniqueUsername(prefix = 'qa'): string { + sequence += 1; + const random = Math.random().toString(36).slice(2, 7); + return `${prefix}_${Date.now().toString(36)}_${process.pid}_${sequence}_${random}`; +} + +export async function registerViaHttp(request: APIRequestContext, username: string, password = PASSWORD) { + const response = await request.post('/register', { form: { username, password }, maxRedirects: 0 }); + expect(response.status(), `register ${username}`).toBe(302); + expect(response.headers()['location']).toMatch(/\/login$/); +} + +export async function login(page: Page, username: string, password = PASSWORD) { + 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(); +} + +export async function loginToDashboard(page: Page, username: string, password = PASSWORD) { + await login(page, username, password); + await expect(page).toHaveURL(/\/dashboard$/); +} + +export async function newCustomer(page: Page, request: APIRequestContext, prefix = 'qa'): Promise { + const username = uniqueUsername(prefix); + await registerViaHttp(request, username); + await loginToDashboard(page, username); + return username; +} + +/** Opens a second, fully separate browser session (own cookies) logged in as `username`. */ +export async function sessionFor(browser: Browser, username: string): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + await loginToDashboard(page, username); + return page; +} + +export async function expectBalance(page: Page, amount: string) { + await expect(page.locator('h3')).toHaveText(`Current Balance: $${amount}`); +} + +type Panel = 'deposit' | 'withdraw' | 'transfer'; +const panelButton: Record = { deposit: 'Deposit', withdraw: 'Withdraw', transfer: 'Transfer Money' }; + +export function panelForm(page: Page, panel: Panel) { + return page.locator(`#${panel}Form form`); +} + +/** Dashboard panels are Bootstrap collapses loaded from a CDN; clicking before the plugin loads does nothing. */ +export async function openPanel(page: Page, panel: Panel) { + await page.waitForFunction(() => { + const jq = (window as unknown as { jQuery?: { fn?: { collapse?: unknown } } }).jQuery; + return !!jq?.fn?.collapse; + }); + await page.getByRole('button', { name: panelButton[panel], exact: true }).click(); + await expect(page.locator(`#${panel}Form`)).toHaveClass(/\bshow\b/); + return panelForm(page, panel); +} + +async function submitAndWaitForLoad(page: Page, form: ReturnType) { + await Promise.all([page.waitForEvent('load'), form.getByRole('button', { name: 'Submit' }).click()]); +} + +export async function deposit(page: Page, amount: string) { + const form = await openPanel(page, 'deposit'); + await form.locator('input[name="amount"]').fill(amount); + await submitAndWaitForLoad(page, form); +} + +export async function withdraw(page: Page, amount: string) { + const form = await openPanel(page, 'withdraw'); + await form.locator('input[name="amount"]').fill(amount); + await submitAndWaitForLoad(page, form); +} + +export async function transfer(page: Page, toUsername: string, amount: string) { + const form = await openPanel(page, 'transfer'); + await form.locator('input[name="toUsername"]').fill(toUsername); + await form.locator('input[name="amount"]').fill(amount); + await submitAndWaitForLoad(page, form); +} + +/** Records every POST to `path` so a test can prove the browser never submitted the form. */ +export function recordPosts(page: Page, path: string): string[] { + const posts: string[] = []; + page.on('request', (req) => { + if (req.method() === 'POST' && new URL(req.url()).pathname === path) posts.push(req.url()); + }); + return posts; +} + +export async function openTransactions(page: Page) { + await page.goto('/transactions'); + await expect(page.getByRole('heading', { name: 'Transaction History' })).toBeVisible(); +} + +export function historyRows(page: Page) { + return page.locator('table tbody tr'); +} + +export async function historyTypesAndAmounts(page: Page): Promise> { + const rows = historyRows(page); + const count = await rows.count(); + const result: Array<{ type: string; amount: string }> = []; + for (let i = 0; i < count; i++) { + const cells = rows.nth(i).locator('td'); + result.push({ type: (await cells.nth(1).innerText()).trim(), amount: (await cells.nth(2).innerText()).trim() }); + } + return result; +} + +export const loginUrl = /\/login(;jsessionid=[^?]*)?(\?.*)?$/; diff --git a/e2e/tests/transactions.spec.ts b/e2e/tests/transactions.spec.ts new file mode 100644 index 00000000..ade7f1ae --- /dev/null +++ b/e2e/tests/transactions.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test'; +import { + deposit, + historyRows, + historyTypesAndAmounts, + newCustomer, + openTransactions, + registerViaHttp, + transfer, + uniqueUsername, + withdraw, +} from './helpers'; + +test.describe('Transactions', () => { + test('MT-049 New customer has empty history', async ({ page, request }) => { + await newCustomer(page, request, 'mt049'); + await openTransactions(page); + await expect(page.locator('table thead th')).toHaveCount(4); + await expect(historyRows(page)).toHaveCount(0); + }); + + test('MT-050 History columns are correct', async ({ page, request }) => { + await newCustomer(page, request, 'mt050'); + await openTransactions(page); + await expect(page.locator('table thead th')).toHaveText(['ID', 'Type', 'Amount', 'Date']); + }); + + test('MT-051 All transaction types are listed', async ({ page, request }) => { + const recipient = uniqueUsername('mt051_to'); + await registerViaHttp(request, recipient); + await newCustomer(page, request, 'mt051'); + await deposit(page, '100'); + await withdraw(page, '10'); + await transfer(page, recipient, '20'); + await openTransactions(page); + const rows = await historyTypesAndAmounts(page); + expect(rows).toContainEqual({ type: 'Deposit', amount: '100.00' }); + expect(rows).toContainEqual({ type: 'Withdrawal', amount: '10.00' }); + expect(rows).toContainEqual({ type: `Transfer Out to ${recipient}`, amount: '20.00' }); + }); + + test('MT-052 Back to Dashboard link works', async ({ page, request }) => { + const username = await newCustomer(page, request, 'mt052'); + await openTransactions(page); + await page.getByRole('link', { name: 'Back to Dashboard' }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole('heading', { level: 2 })).toHaveText(`Welcome, ${username}`); + }); + + test('MT-053 Transactions link works from dashboard', async ({ page, request }) => { + await newCustomer(page, request, '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, request }) => { + await newCustomer(page, request, 'mt054'); + await deposit(page, '100'); + await withdraw(page, '10'); + await openTransactions(page); + const rows = historyRows(page); + await expect(rows).toHaveCount(2); + for (let i = 0; i < 2; i++) { + await expect(rows.nth(i).locator('td').nth(3)).toHaveText(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/); + } + }); +}); diff --git a/e2e/tests/transfer.spec.ts b/e2e/tests/transfer.spec.ts new file mode 100644 index 00000000..4daeb59f --- /dev/null +++ b/e2e/tests/transfer.spec.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { + deposit, + expectBalance, + historyTypesAndAmounts, + newCustomer, + openPanel, + openTransactions, + recordPosts, + registerViaHttp, + sessionFor, + transfer, + uniqueUsername, +} from './helpers'; + +test.describe('Transfer', () => { + test('MT-036 Transfer debits the sender', async ({ page, request }) => { + const recipient = uniqueUsername('mt036_to'); + await registerViaHttp(request, recipient); + await newCustomer(page, request, 'mt036'); + await deposit(page, '100'); + await transfer(page, recipient, '30'); + await expectBalance(page, '70.00'); + }); + + test('MT-037 Transfer credits the recipient', async ({ page, request, browser }) => { + const recipient = uniqueUsername('mt037_to'); + await registerViaHttp(request, recipient); + await newCustomer(page, request, 'mt037'); + await deposit(page, '100'); + await transfer(page, recipient, '30'); + await expectBalance(page, '70.00'); + + const recipientPage = await sessionFor(browser, recipient); + await expectBalance(recipientPage, '30.00'); + await recipientPage.context().close(); + }); + + test('MT-038 Sender history shows the transfer', async ({ page, request }) => { + const recipient = uniqueUsername('mt038_to'); + await registerViaHttp(request, recipient); + await newCustomer(page, request, 'mt038'); + await deposit(page, '100'); + await transfer(page, recipient, '30'); + await openTransactions(page); + expect(await historyTypesAndAmounts(page)).toContainEqual({ type: `Transfer Out to ${recipient}`, amount: '30.00' }); + }); + + test('MT-039 Recipient history shows the transfer', async ({ page, request, browser }) => { + const recipient = uniqueUsername('mt039_to'); + await registerViaHttp(request, recipient); + const sender = await newCustomer(page, request, 'mt039'); + await deposit(page, '100'); + await transfer(page, recipient, '30'); + + const recipientPage = await sessionFor(browser, recipient); + await openTransactions(recipientPage); + expect(await historyTypesAndAmounts(recipientPage)).toEqual([{ type: `Transfer In from ${sender}`, amount: '30.00' }]); + await recipientPage.context().close(); + }); + + test('MT-040 Customer can transfer the full balance', async ({ page, request, browser }) => { + const recipient = uniqueUsername('mt040_to'); + await registerViaHttp(request, recipient); + await newCustomer(page, request, 'mt040'); + await deposit(page, '100'); + await transfer(page, recipient, '100'); + await expectBalance(page, '0.00'); + + const recipientPage = await sessionFor(browser, recipient); + await expectBalance(recipientPage, '100.00'); + await recipientPage.context().close(); + }); + + test('MT-041 Transfer above balance is rejected', async ({ page, request, browser }) => { + const recipient = uniqueUsername('mt041_to'); + await registerViaHttp(request, recipient); + await newCustomer(page, request, 'mt041'); + await deposit(page, '100'); + await transfer(page, recipient, '150'); + await expect(page.locator('p.error')).toHaveText('Insufficient funds'); + await page.goto('/dashboard'); + await expectBalance(page, '100.00'); + + const recipientPage = await sessionFor(browser, recipient); + await expectBalance(recipientPage, '0.00'); + await recipientPage.context().close(); + }); + + // Precondition: the sender holds funds. The app checks the balance before the recipient, + // so an empty account would see 'Insufficient funds' instead of the message under test. + test('MT-042 Transfer to unknown customer is rejected', async ({ page, request }) => { + await newCustomer(page, request, 'mt042'); + await deposit(page, '100'); + await transfer(page, uniqueUsername('mt042_missing'), '10'); + await expect(page.locator('p.error')).toHaveText('Recipient account not found'); + await page.goto('/dashboard'); + await expectBalance(page, '100.00'); + }); + + test('MT-043 Recipient is required', async ({ page, request }) => { + await newCustomer(page, request, 'mt043'); + const posts = recordPosts(page, '/transfer'); + const form = await openPanel(page, 'transfer'); + 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); + expect(posts).toEqual([]); + }); + + test('MT-044 Amount is required', async ({ page, request }) => { + const recipient = uniqueUsername('mt044_to'); + await registerViaHttp(request, recipient); + await newCustomer(page, request, 'mt044'); + const posts = recordPosts(page, '/transfer'); + const form = await openPanel(page, 'transfer'); + await form.locator('input[name="toUsername"]').fill(recipient); + await form.getByRole('button', { name: 'Submit' }).click(); + expect(await form.locator('input[name="amount"]').evaluate((el: HTMLInputElement) => el.validity.valueMissing)).toBe(true); + expect(posts).toEqual([]); + }); + + test('MT-045 Multiple transfers add up', async ({ page, request, browser }) => { + const recipient = uniqueUsername('mt045_to'); + await registerViaHttp(request, recipient); + await newCustomer(page, request, 'mt045'); + await deposit(page, '100'); + await transfer(page, recipient, '20'); + await expectBalance(page, '80.00'); + await transfer(page, recipient, '30'); + await expectBalance(page, '50.00'); + + const recipientPage = await sessionFor(browser, recipient); + await expectBalance(recipientPage, '50.00'); + await recipientPage.context().close(); + }); + + test('MT-046 Rejected transfer leaves no history', async ({ page, request }) => { + await newCustomer(page, request, 'mt046'); + await deposit(page, '100'); + await transfer(page, uniqueUsername('mt046_missing'), '10'); + await expect(page.locator('p.error')).toHaveText('Recipient account not found'); + await openTransactions(page); + const types = (await historyTypesAndAmounts(page)).map((r) => r.type); + expect(types.filter((t) => t.includes('Transfer'))).toEqual([]); + expect(types).toEqual(['Deposit']); + }); +}); diff --git a/e2e/tests/withdraw.spec.ts b/e2e/tests/withdraw.spec.ts new file mode 100644 index 00000000..6a9d98fb --- /dev/null +++ b/e2e/tests/withdraw.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; +import { deposit, expectBalance, historyTypesAndAmounts, newCustomer, openPanel, openTransactions, recordPosts, withdraw } from './helpers'; + +test.describe('Withdraw', () => { + test('MT-029 Withdrawal decreases balance', async ({ page, request }) => { + await newCustomer(page, request, '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, request }) => { + await newCustomer(page, request, 'mt030'); + await deposit(page, '100'); + await withdraw(page, '100'); + await expectBalance(page, '0.00'); + }); + + test('MT-031 Overdraw is rejected', async ({ page, request }) => { + await newCustomer(page, request, 'mt031'); + await deposit(page, '100'); + await withdraw(page, '500'); + await expect(page.locator('p.error')).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, request }) => { + await newCustomer(page, request, 'mt032'); + await withdraw(page, '1'); + await expect(page.locator('p.error')).toHaveText('Insufficient funds'); + await expectBalance(page, '0.00'); + }); + + test('MT-033 Withdrawal appears in history', async ({ page, request }) => { + await newCustomer(page, request, 'mt033'); + await deposit(page, '100'); + await withdraw(page, '40'); + await openTransactions(page); + expect(await historyTypesAndAmounts(page)).toContainEqual({ type: 'Withdrawal', amount: '40.00' }); + }); + + test('MT-034 Empty withdrawal is blocked', async ({ page, request }) => { + await newCustomer(page, request, 'mt034'); + const posts = recordPosts(page, '/withdraw'); + 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); + expect(posts).toEqual([]); + await expect(page).toHaveURL(/\/dashboard$/); + }); + + test('MT-035 Rejected withdrawal leaves no history', async ({ page, request }) => { + await newCustomer(page, request, 'mt035'); + await deposit(page, '100'); + await withdraw(page, '500'); + await expect(page.locator('p.error')).toHaveText('Insufficient funds'); + await openTransactions(page); + const types = (await historyTypesAndAmounts(page)).map((r) => r.type); + expect(types).toEqual(['Deposit']); + }); +}); 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.md b/qa/coverage-matrix.md new file mode 100644 index 00000000..bcb73646 --- /dev/null +++ b/qa/coverage-matrix.md @@ -0,0 +1,81 @@ +# Coverage matrix: qa/manual-test-plan.csv → Playwright + +All 60 manual rows are accounted for: **54 automated**, **6 not automated** (reasons below). Automated tests live in `e2e/tests/` and run in CI (`.github/workflows/e2e.yml`) against the real app on a MySQL 8 service container. Each test title starts with its plan ID. `node e2e/scripts/check-coverage.mjs` (also run in CI) fails if any plan row is missing from this table, if an Automated row has no matching test, or if a Not automated row has no reason. + +Run locally: `demo/start-demo.sh` then `cd e2e && npm ci && npx playwright test`. Tests create their own unique customers, so they need no fixtures and can run in parallel. + +| Area | Rows | Automated | Not automated | +|---|---|---|---| +| Authentication | 13 | 13 | 0 | +| Dashboard | 8 | 7 | 1 | +| Deposit | 7 | 7 | 0 | +| Withdraw | 7 | 7 | 0 | +| Transfer | 13 | 11 | 2 | +| Transactions | 7 | 6 | 1 | +| Cross-cutting | 5 | 3 | 2 | +| **Total** | **60** | **54** | **6** | + +## All rows + +| ID | Area | Priority | Title | Status | Playwright test | Notes / reason not automated | +|---|---|---|---|---|---|---| +| MT-001 | Authentication | High | Login page shows username and password fields | Automated | `e2e/tests/authentication.spec.ts` › MT-001 Login page shows username and password fields | | +| MT-002 | Authentication | Medium | Register link on login page opens registration | Automated | `e2e/tests/authentication.spec.ts` › MT-002 Register link on login page opens registration | | +| MT-003 | Authentication | Low | Login link on register page returns to login | Automated | `e2e/tests/authentication.spec.ts` › MT-003 Login link on register page returns to login | | +| MT-004 | Authentication | High | New customer can register | Automated | `e2e/tests/authentication.spec.ts` › MT-004 New customer can register | | +| MT-005 | Authentication | High | Duplicate username is rejected | Automated | `e2e/tests/authentication.spec.ts` › MT-005 Duplicate username is rejected | | +| MT-006 | Authentication | High | Valid login opens dashboard | Automated | `e2e/tests/authentication.spec.ts` › MT-006 Valid login opens dashboard | | +| MT-007 | Authentication | High | Wrong password is rejected | Automated | `e2e/tests/authentication.spec.ts` › MT-007 Wrong password is rejected | | +| MT-008 | Authentication | High | Unknown username gets the same error | Automated | `e2e/tests/authentication.spec.ts` › MT-008 Unknown username gets the same error | Asserts the unknown-user message is identical to the wrong-password message. | +| MT-009 | Authentication | Medium | Password is case sensitive | Automated | `e2e/tests/authentication.spec.ts` › MT-009 Password is case sensitive | | +| MT-010 | Authentication | High | Dashboard requires login | Automated | `e2e/tests/authentication.spec.ts` › MT-010 Dashboard requires login | | +| MT-011 | Authentication | High | Transaction history requires login | Automated | `e2e/tests/authentication.spec.ts` › MT-011 Transaction history requires login | | +| MT-012 | Authentication | High | Logout returns to login | Automated | `e2e/tests/authentication.spec.ts` › MT-012 Logout returns to login | | +| MT-013 | Authentication | High | Session ends after logout | Automated | `e2e/tests/authentication.spec.ts` › MT-013 Session ends after logout | | +| MT-014 | Dashboard | High | New customer starts at zero | Automated | `e2e/tests/dashboard.spec.ts` › MT-014 New customer starts at zero | | +| MT-015 | Dashboard | Low | Welcome message shows the customer | Automated | `e2e/tests/dashboard.spec.ts` › MT-015 Welcome message shows the customer | | +| MT-016 | Dashboard | Medium | Account number is shown | Automated | `e2e/tests/dashboard.spec.ts` › MT-016 Account number is shown | | +| MT-017 | Dashboard | Low | Navigation links are present | Automated | `e2e/tests/dashboard.spec.ts` › MT-017 Navigation links are present | | +| MT-018 | Dashboard | Medium | Deposit panel opens | Automated | `e2e/tests/dashboard.spec.ts` › MT-018 Deposit panel opens | | +| MT-019 | Dashboard | Medium | Withdraw panel opens | Automated | `e2e/tests/dashboard.spec.ts` › MT-019 Withdraw panel opens | | +| MT-020 | Dashboard | Medium | Transfer panel opens | Automated | `e2e/tests/dashboard.spec.ts` › MT-020 Transfer panel opens | | +| MT-021 | Dashboard | Low | Brand colours and logo match the brand guide | Not automated | — | Expected result is a human sign-off ("Brand team signs off") against a brand guide that is not in the repo. There is no machine-checkable pass condition; inventing pixel/colour assertions would not be the brand team's judgment. | +| MT-022 | Deposit | High | Deposit increases balance | Automated | `e2e/tests/deposit.spec.ts` › MT-022 Deposit increases balance | | +| MT-023 | Deposit | High | Deposits add up | Automated | `e2e/tests/deposit.spec.ts` › MT-023 Deposits add up | | +| MT-024 | Deposit | High | Deposit appears in history | Automated | `e2e/tests/deposit.spec.ts` › MT-024 Deposit appears in history | | +| MT-025 | Deposit | Medium | Empty deposit is blocked | Automated | `e2e/tests/deposit.spec.ts` › MT-025 Empty deposit is blocked | Proves no POST /deposit was sent (HTML5 `required`) and balance stays $0.00 after reload. | +| MT-026 | Deposit | Medium | Deposit field accepts whole dollars only | Automated | `e2e/tests/deposit.spec.ts` › MT-026 Deposit field accepts whole dollars only | Browser marks 10.50 as `stepMismatch` (input has default step=1); no POST sent, balance unchanged. | +| MT-027 | Deposit | Medium | Large deposit is handled | Automated | `e2e/tests/deposit.spec.ts` › MT-027 Large deposit is handled | | +| MT-028 | Deposit | Low | Letters cannot be typed into amount | Automated | `e2e/tests/deposit.spec.ts` › MT-028 Letters cannot be typed into amount | Types real keystrokes; Chromium drops letters in `type=number`. | +| MT-029 | Withdraw | High | Withdrawal decreases balance | Automated | `e2e/tests/withdraw.spec.ts` › MT-029 Withdrawal decreases balance | | +| MT-030 | Withdraw | High | Customer can withdraw the full balance | Automated | `e2e/tests/withdraw.spec.ts` › MT-030 Customer can withdraw the full balance | | +| MT-031 | Withdraw | High | Overdraw is rejected | Automated | `e2e/tests/withdraw.spec.ts` › MT-031 Overdraw is rejected | | +| MT-032 | Withdraw | High | Withdrawal from empty account is rejected | Automated | `e2e/tests/withdraw.spec.ts` › MT-032 Withdrawal from empty account is rejected | | +| MT-033 | Withdraw | High | Withdrawal appears in history | Automated | `e2e/tests/withdraw.spec.ts` › MT-033 Withdrawal appears in history | | +| MT-034 | Withdraw | Medium | Empty withdrawal is blocked | Automated | `e2e/tests/withdraw.spec.ts` › MT-034 Empty withdrawal is blocked | Proves no POST /withdraw was sent. | +| MT-035 | Withdraw | High | Rejected withdrawal leaves no history | Automated | `e2e/tests/withdraw.spec.ts` › MT-035 Rejected withdrawal leaves no history | | +| MT-036 | Transfer | High | Transfer debits the sender | Automated | `e2e/tests/transfer.spec.ts` › MT-036 Transfer debits the sender | | +| MT-037 | Transfer | High | Transfer credits the recipient | Automated | `e2e/tests/transfer.spec.ts` › MT-037 Transfer credits the recipient | Recipient checked in a separate browser session. | +| MT-038 | Transfer | High | Sender history shows the transfer | Automated | `e2e/tests/transfer.spec.ts` › MT-038 Sender history shows the transfer | | +| MT-039 | Transfer | High | Recipient history shows the transfer | Automated | `e2e/tests/transfer.spec.ts` › MT-039 Recipient history shows the transfer | | +| MT-040 | Transfer | High | Customer can transfer the full balance | Automated | `e2e/tests/transfer.spec.ts` › MT-040 Customer can transfer the full balance | | +| MT-041 | Transfer | High | Transfer above balance is rejected | Automated | `e2e/tests/transfer.spec.ts` › MT-041 Transfer above balance is rejected | Checks both sender ($100.00) and recipient ($0.00) balances. | +| MT-042 | Transfer | High | Transfer to unknown customer is rejected | Automated | `e2e/tests/transfer.spec.ts` › MT-042 Transfer to unknown customer is rejected | Precondition added: sender deposits 100 first. The app checks funds before the recipient, so a $0 sender would get "Insufficient funds" instead. | +| MT-043 | Transfer | Medium | Recipient is required | Automated | `e2e/tests/transfer.spec.ts` › MT-043 Recipient is required | Proves no POST /transfer was sent. | +| MT-044 | Transfer | Medium | Amount is required | Automated | `e2e/tests/transfer.spec.ts` › MT-044 Amount is required | Proves no POST /transfer was sent. | +| MT-045 | Transfer | High | Multiple transfers add up | Automated | `e2e/tests/transfer.spec.ts` › MT-045 Multiple transfers add up | | +| MT-046 | Transfer | High | Rejected transfer leaves no history | Automated | `e2e/tests/transfer.spec.ts` › MT-046 Rejected transfer leaves no history | | +| MT-047 | Transfer | High | Negative transfer amount | Not automated | — | Expected result "System handles it appropriately" is not defined, so there is nothing to assert without inventing a requirement. Observed today (MySQL 8, manual curl): with sender $100 and recipient $0, transferring -50 is accepted; sender becomes $150.00, recipient $-50.00, history shows "Transfer Out to " -50.00. A deposit of -50 is also accepted and lowers the balance. The amount inputs have no `min`, so the browser does not block it. Needs a product decision (reject, and with what message) before a test can be written. | +| MT-048 | Transfer | Medium | Customer receives a transfer confirmation by SMS or email | Not automated | — | The app has no SMS or email capability (no mail/SMS dependency or code in pom.xml or src/), so there is nothing to observe. Also requires a real phone and inbox. Becomes automatable once a notification service exists (e.g. against a test mailbox). | +| MT-049 | Transactions | Medium | New customer has empty history | Automated | `e2e/tests/transactions.spec.ts` › MT-049 New customer has empty history | | +| MT-050 | Transactions | Low | History columns are correct | Automated | `e2e/tests/transactions.spec.ts` › MT-050 History columns are correct | | +| MT-051 | Transactions | High | All transaction types are listed | Automated | `e2e/tests/transactions.spec.ts` › MT-051 All transaction types are listed | | +| MT-052 | Transactions | Low | Back to Dashboard link works | Automated | `e2e/tests/transactions.spec.ts` › MT-052 Back to Dashboard link works | | +| MT-053 | Transactions | Low | Transactions link works from dashboard | Automated | `e2e/tests/transactions.spec.ts` › MT-053 Transactions link works from dashboard | | +| MT-054 | Transactions | Medium | Every row has a date | Automated | `e2e/tests/transactions.spec.ts` › MT-054 Every row has a date | Asserts ISO date and time (e.g. 2026-10-23T13:48:14.679626) in every row. | +| MT-055 | Transactions | Low | Printed history is legible on the branch printer | Not automated | — | Requires a physical branch printer and a human reading the printout. A print-to-PDF check would not prove legibility on that printer. | +| MT-056 | Cross-cutting | High | Customers only see their own account | Automated | `e2e/tests/cross-cutting.spec.ts` › MT-056 Customers only see their own account | Two isolated browser contexts; each sees only its own name and balance. | +| MT-057 | Cross-cutting | High | Balance survives logout | Automated | `e2e/tests/cross-cutting.spec.ts` › MT-057 Balance survives logout | | +| MT-058 | Cross-cutting | Low | Bank name shows on every page tab | Automated | `e2e/tests/cross-cutting.spec.ts` › MT-058 Bank name shows on every page tab | Asserts every tab title contains "Wells Fargo". | +| MT-059 | Cross-cutting | Medium | Screen reader reads balance and errors | Not automated | — | Requires JAWS on Windows and a human judging that announcements are "clear". An automated axe/ARIA scan could catch some issues but is not equivalent; can be added as a separate, smaller check if wanted. | +| MT-060 | Cross-cutting | Low | First-time customer completes a transfer unaided | Not automated | — | Moderated usability session with a real first-time customer; outcome depends on a human participant ("unaided", "under 2 minutes"). |