diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..7ee1ac2b --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,100 @@ +name: E2E regression (Playwright + MySQL 8) + +on: + pull_request: + push: + branches: [main, master, demo/qa-live-before] + workflow_dispatch: + +jobs: + e2e: + 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: + SPRING_DATASOURCE_URL: jdbc:mysql://127.0.0.1:3306/bankappdb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC + SPRING_DATASOURCE_USERNAME: root + SPRING_DATASOURCE_PASSWORD: Test@123 + MVNW_REPOURL: https://maven-central.storage-download.googleapis.com/maven2 + 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: Install e2e dependencies + working-directory: e2e + run: npm ci + + - name: Coverage matrix accounts for all plan rows + working-directory: e2e + run: npm run coverage:check + + - name: Type-check specs + working-directory: e2e + run: npx tsc -p . + + - name: Build and unit test against MySQL 8 + run: ./mvnw -B -s demo/maven-settings.xml verify + + - name: Start app + run: | + mkdir -p logs + setsid nohup java -jar target/bankapp-0.0.1-SNAPSHOT.jar > logs/app.log 2>&1 < /dev/null & + for i in $(seq 1 120); do + curl -sf -o /dev/null http://localhost:8080/login && exit 0 + sleep 1 + done + echo "App did not serve /login within 120s"; tail -n 80 logs/app.log; exit 1 + + - name: Install Playwright Chromium + working-directory: e2e + run: npx playwright install --with-deps chromium + + - name: Run Playwright regression + working-directory: e2e + run: npx playwright test + + - name: Publish coverage matrix with results + if: always() + working-directory: e2e + run: | + if [ -f results.json ]; then + node scripts/coverage.mjs --results results.json | tee coverage-matrix-results.md >> "$GITHUB_STEP_SUMMARY" + else + echo "Playwright did not produce results.json" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + e2e/playwright-report/ + e2e/test-results/ + e2e/results.json + e2e/coverage-matrix-results.md + logs/app.log + retention-days: 14 diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..e2a17b28 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +results.json diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..598b4648 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,16 @@ +# E2E regression suite + +Playwright automation of `qa/manual-test-plan.csv`. Each test title starts with its plan ID (`MT-001 ...`). +Rows that cannot be automated are listed with a reason in `qa/not-automated.csv`; `qa/coverage-matrix.md` is generated from both. + +```bash +demo/start-demo.sh # MySQL 8 + app on :8080 +cd e2e && npm ci +npx playwright install chromium +npx playwright test # BASE_URL overrides http://localhost:8080 +npm run coverage # regenerate qa/coverage-matrix.md +npm run coverage:check # fail if any plan row is unaccounted for or the matrix is stale +``` + +CI: `.github/workflows/e2e.yml` runs the suite against a MySQL 8 service and posts the matrix with per-row results to the job summary. +Each test creates its own uniquely named customers, so the suite needs no DB reset and runs in parallel. 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..ad29abb4 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,14 @@ +{ + "name": "bankapp-e2e", + "private": true, + "scripts": { + "test": "playwright test", + "coverage": "node scripts/coverage.mjs", + "coverage:check": "node scripts/coverage.mjs --check" + }, + "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..fb5b4861 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + workers: process.env.CI ? 2 : 4, + reporter: [ + ['list'], + ['html', { open: 'never' }], + ['json', { outputFile: 'results.json' }], + ], + 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/coverage.mjs b/e2e/scripts/coverage.mjs new file mode 100644 index 00000000..625e73ed --- /dev/null +++ b/e2e/scripts/coverage.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +// Builds qa/coverage-matrix.md from the manual plan, the Playwright specs and qa/not-automated.csv. +// node scripts/coverage.mjs write qa/coverage-matrix.md +// node scripts/coverage.mjs --check fail if any row is unaccounted for or the matrix is stale +// node scripts/coverage.mjs --results also print a matrix with CI results (for the job summary) +import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; +import { join, dirname, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const e2eDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = join(e2eDir, '..'); +const planPath = join(repoRoot, 'qa/manual-test-plan.csv'); +const notAutomatedPath = join(repoRoot, 'qa/not-automated.csv'); +const matrixPath = join(repoRoot, 'qa/coverage-matrix.md'); +const testsDir = join(e2eDir, 'tests'); + +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] ?? '']))); +} + +function scanSpecs() { + const found = 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})\b([^'"`]*)['"`]/g)) { + const list = found.get(m[1]) ?? []; + list.push({ file: relative(repoRoot, join(testsDir, file)), title: `${m[1]}${m[2]}` }); + found.set(m[1], list); + } + } + return found; +} + +function readResults(path) { + const byId = new Map(); + const walk = (suite) => { + for (const s of suite.suites ?? []) walk(s); + for (const spec of suite.specs ?? []) { + const id = spec.title.match(/^MT-\d{3}/)?.[0]; + if (!id) continue; + const statuses = spec.tests.flatMap((t) => t.results.map((r) => r.status)); + const ok = spec.ok && statuses.length > 0 && statuses.every((s) => s === 'passed'); + byId.set(id, ok ? 'passed' : statuses.includes('skipped') ? 'skipped' : 'failed'); + } + }; + walk(JSON.parse(readFileSync(path, 'utf8'))); + return byId; +} + +const cell = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/\|/g, '\\|').replace(/\n/g, ' '); + +function render(plan, specs, notAutomated, results) { + const automated = plan.filter((r) => specs.has(r.id)); + const lines = []; + lines.push('# Regression coverage matrix', ''); + lines.push('Generated by `e2e/scripts/coverage.mjs` from `qa/manual-test-plan.csv`, `e2e/tests/*.spec.ts` and `qa/not-automated.csv`. Do not edit by hand; run `npm run coverage` in `e2e/`.', ''); + lines.push(`**${plan.length} rows: ${automated.length} automated, ${plan.length - automated.length} not automated.**`, ''); + if (results) { + const counts = { passed: 0, failed: 0, skipped: 0, 'not run': 0 }; + for (const r of automated) counts[results.get(r.id) ?? 'not run']++; + lines.push(`CI result: ${counts.passed} passed, ${counts.failed} failed, ${counts.skipped} skipped, ${counts['not run']} not run.`, ''); + } + lines.push('| Area | Automated | Not automated |', '|---|---|---|'); + for (const area of [...new Set(plan.map((r) => r.area))]) { + const rows = plan.filter((r) => r.area === area); + const a = rows.filter((r) => specs.has(r.id)).length; + lines.push(`| ${area} | ${a} | ${rows.length - a} |`); + } + lines.push('', '## All rows', ''); + const head = ['ID', 'Area', 'Title', 'Priority', 'Status', 'Playwright test']; + if (results) head.push('CI result'); + lines.push(`| ${head.join(' | ')} |`, `|${head.map(() => '---').join('|')}|`); + for (const r of plan) { + const tests = specs.get(r.id); + const status = tests ? 'Automated' : 'Not automated'; + const where = tests ? tests.map((t) => `\`${t.file}\``).join(', ') : 'see below'; + const row = [r.id, r.area, cell(r.title), r.priority, status, where]; + if (results) row.push(tests ? results.get(r.id) ?? 'not run' : 'n/a'); + lines.push(`| ${row.join(' | ')} |`); + } + lines.push('', '## Rows not automated', ''); + lines.push('| ID | Title | Expected (from plan) | Why not automated | Evidence |', '|---|---|---|---|---|'); + for (const r of plan.filter((p) => !specs.has(p.id))) { + const na = notAutomated.get(r.id); + lines.push(`| ${r.id} | ${cell(r.title)} | ${cell(r.expected)} | ${cell(na?.reason)} | ${cell(na?.evidence) || '-'} |`); + } + return lines.join('\n') + '\n'; +} + +const args = process.argv.slice(2); +const plan = parseCsv(readFileSync(planPath, 'utf8')); +const notAutomated = new Map(parseCsv(readFileSync(notAutomatedPath, 'utf8')).map((r) => [r.id, r])); +const specs = scanSpecs(); +const planIds = new Set(plan.map((r) => r.id)); + +const errors = []; +if (plan.length !== planIds.size) errors.push('duplicate IDs in manual-test-plan.csv'); +for (const r of plan) { + const hasTest = specs.has(r.id); + const listed = notAutomated.has(r.id); + if (!hasTest && !listed) errors.push(`${r.id} has no Playwright test and is not in qa/not-automated.csv`); + if (hasTest && listed) errors.push(`${r.id} has a Playwright test but is also listed in qa/not-automated.csv`); + if (listed && !notAutomated.get(r.id).reason.trim()) errors.push(`${r.id} is not automated but has no reason`); +} +for (const id of specs.keys()) if (!planIds.has(id)) errors.push(`test references ${id}, which is not in the plan`); +for (const id of notAutomated.keys()) if (!planIds.has(id)) errors.push(`qa/not-automated.csv references ${id}, which is not in the plan`); + +const resultsIdx = args.indexOf('--results'); +let results; +if (resultsIdx !== -1) { + const p = args[resultsIdx + 1]; + if (p && existsSync(p)) results = readResults(p); + else errors.push(`results file not found: ${p}`); +} + +if (errors.length) { + console.error(`Coverage matrix check failed:\n- ${errors.join('\n- ')}`); + process.exit(1); +} + +const matrix = render(plan, specs, notAutomated, undefined); +if (args.includes('--check')) { + const current = existsSync(matrixPath) ? readFileSync(matrixPath, 'utf8') : ''; + if (current !== matrix) { + console.error('qa/coverage-matrix.md is out of date; run `npm run coverage` in e2e/ and commit the result.'); + process.exit(1); + } +} else if (!results) { + writeFileSync(matrixPath, matrix); +} +console.log(results ? render(plan, specs, notAutomated, results) : `All ${plan.length} rows accounted for (${[...planIds].filter((id) => specs.has(id)).length} automated).`); diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 00000000..c36d6b6b --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from '@playwright/test'; +import { PASSWORD, login, newCustomer, register, uniqueUsername } from './helpers'; + +const INVALID_LOGIN = 'Invalid username or password.'; + +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.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 register(page, uniqueUsername('reg')); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible(); + }); + + test('MT-005 Duplicate username is rejected', async ({ page }) => { + const username = uniqueUsername('dup'); + await register(page, username); + await expect(page).toHaveURL(/\/login$/); + await register(page, username); + await expect(page.getByText('User already present.')).toBeVisible(); + }); + + test('MT-006 Valid login opens dashboard', async ({ page }) => { + const username = await newCustomer(page, 'login'); + await expect(page.getByRole('heading', { name: `Welcome, ${username}` })).toBeVisible(); + }); + + test('MT-007 Wrong password is rejected', async ({ page }) => { + const username = uniqueUsername('wrongpw'); + await register(page, username); + await login(page, username, 'Not-the-password1'); + await expect(page).toHaveURL(/\/login\?error/); + await expect(page.getByText(INVALID_LOGIN)).toBeVisible(); + }); + + test('MT-008 Unknown username gets the same error', async ({ page }) => { + const known = uniqueUsername('known'); + await register(page, known); + await login(page, known, 'Not-the-password1'); + const wrongPasswordAlert = (await page.locator('.alert-danger').innerText()).trim(); + + await login(page, uniqueUsername('ghost'), PASSWORD); + await expect(page).toHaveURL(/\/login\?error/); + await expect(page.locator('.alert-danger')).toHaveText(wrongPasswordAlert); + await expect(page.locator('.alert-danger')).toHaveText(INVALID_LOGIN); + }); + + test('MT-009 Password is case sensitive', async ({ page }) => { + const username = uniqueUsername('case'); + await register(page, username); + const flipped = PASSWORD.replace(/[a-z]/gi, (c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase())); + expect(flipped).not.toBe(PASSWORD); + await login(page, username, flipped); + await expect(page).toHaveURL(/\/login\?error/); + await page.goto('/dashboard'); + await expect(page).toHaveURL(/\/login$/); + }); + + test('MT-010 Dashboard requires login', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page).toHaveURL(/\/login$/); + }); + + test('MT-011 Transaction history requires login', async ({ page }) => { + await page.goto('/transactions'); + await expect(page).toHaveURL(/\/login$/); + }); + + test('MT-012 Logout returns to login', async ({ page }) => { + await newCustomer(page, 'logout'); + 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 }) => { + await newCustomer(page, 'session'); + 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..34bb0a5d --- /dev/null +++ b/e2e/tests/cross-cutting.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; +import { deposit, expectBalance, login, newCustomer } from './helpers'; + +test.describe('Cross-cutting', () => { + test('MT-056 Customers only see their own account', async ({ browser }) => { + const ctxA = await browser.newContext(); + const ctxB = await browser.newContext(); + const pageA = await ctxA.newPage(); + const pageB = await ctxB.newPage(); + const userA = await newCustomer(pageA, 'alice'); + const userB = await newCustomer(pageB, 'bob'); + await deposit(pageA, '100'); + await deposit(pageB, '25'); + + await pageA.reload(); + await pageB.reload(); + await expect(pageA.getByRole('heading', { level: 2 })).toHaveText(`Welcome, ${userA}`); + await expectBalance(pageA, '100.00'); + await expect(pageB.getByRole('heading', { level: 2 })).toHaveText(`Welcome, ${userB}`); + await expectBalance(pageB, '25.00'); + await expect(pageA.getByText(userB)).toHaveCount(0); + await expect(pageB.getByText(userA)).toHaveCount(0); + await ctxA.close(); + await ctxB.close(); + }); + + test('MT-057 Balance survives logout', async ({ page }) => { + const username = await newCustomer(page, 'persist'); + await deposit(page, '100'); + await expectBalance(page, '100.00'); + await page.getByRole('link', { name: 'Logout' }).click(); + await expect(page).toHaveURL(/\/login\?logout$/); + await login(page, username); + await expect(page).toHaveURL(/\/dashboard$/); + await expectBalance(page, '100.00'); + }); + + // The plan does not name the bank, so the expected name is read from the brand in the page header. + test('MT-058 Bank name shows on every page tab', async ({ page }) => { + await page.goto('/login'); + const bankName = (await page.locator('.navbar-brand').innerText()).trim(); + expect(bankName).not.toBe(''); + + const titles: Record = {}; + titles.login = await page.title(); + await page.goto('/register'); + titles.register = await page.title(); + await newCustomer(page, 'title'); + titles.dashboard = await page.title(); + await page.goto('/transactions'); + titles.transactions = await page.title(); + + for (const [name, title] of Object.entries(titles)) { + expect(title, `${name} tab title`).toContain(bankName); + } + }); +}); diff --git a/e2e/tests/dashboard.spec.ts b/e2e/tests/dashboard.spec.ts new file mode 100644 index 00000000..880f27df --- /dev/null +++ b/e2e/tests/dashboard.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; +import { expectBalance, newCustomer, openPanel, panel } from './helpers'; + +test.describe('Dashboard', () => { + test('MT-014 New customer starts at zero', async ({ page }) => { + await newCustomer(page, 'zero'); + await expectBalance(page, '0.00'); + }); + + test('MT-015 Welcome message shows the customer', async ({ page }) => { + const username = await newCustomer(page, 'welcome'); + await expect(page.getByRole('heading', { level: 2 })).toHaveText(`Welcome, ${username}`); + }); + + test('MT-016 Account number is shown', async ({ page }) => { + await newCustomer(page, 'acctno'); + 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 }) => { + await newCustomer(page, 'nav'); + 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 newCustomer(page, 'pdep'); + await expect(panel(page, 'depositForm')).toBeHidden(); + await openPanel(page, 'depositForm'); + await expect(panel(page, 'depositForm').locator('input[name="amount"]')).toBeVisible(); + }); + + test('MT-019 Withdraw panel opens', async ({ page }) => { + await newCustomer(page, 'pwd'); + await expect(panel(page, 'withdrawForm')).toBeHidden(); + await openPanel(page, 'withdrawForm'); + await expect(panel(page, 'withdrawForm').locator('input[name="amount"]')).toBeVisible(); + }); + + test('MT-020 Transfer panel opens', async ({ page }) => { + await newCustomer(page, 'ptr'); + await expect(panel(page, 'transferForm')).toBeHidden(); + await page.getByRole('button', { name: /Transfer/ }).click(); + await expect(panel(page, 'transferForm').locator('input[name="toUsername"]')).toBeVisible(); + await expect(panel(page, 'transferForm').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..33d34348 --- /dev/null +++ b/e2e/tests/deposit.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '@playwright/test'; +import { deposit, expectBalance, historyEntries, newCustomer, openPanel, panel, trackPosts } from './helpers'; + +test.describe('Deposit', () => { + test('MT-022 Deposit increases balance', async ({ page }) => { + await newCustomer(page, 'dep'); + await deposit(page, '100'); + await expect(page).toHaveURL(/\/dashboard$/); + await expectBalance(page, '100.00'); + }); + + test('MT-023 Deposits add up', async ({ page }) => { + await newCustomer(page, 'dep2'); + 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 }) => { + await newCustomer(page, 'dephist'); + await deposit(page, '100'); + await expectBalance(page, '100.00'); + const rows = await historyEntries(page); + expect(rows).toEqual([expect.objectContaining({ type: 'Deposit', amount: '100.00' })]); + }); + + test('MT-025 Empty deposit is blocked', async ({ page }) => { + await newCustomer(page, 'depempty'); + const posts = trackPosts(page, '/deposit'); + await openPanel(page, 'depositForm'); + const amount = panel(page, 'depositForm').locator('input[name="amount"]'); + await panel(page, 'depositForm').getByRole('button', { name: 'Submit' }).click(); + expect(await amount.evaluate((el: HTMLInputElement) => el.validity.valueMissing)).toBe(true); + expect(posts).toHaveLength(0); + await page.reload(); + await expectBalance(page, '0.00'); + }); + + test('MT-026 Deposit field accepts whole dollars only', async ({ page }) => { + await newCustomer(page, 'depfrac'); + const posts = trackPosts(page, '/deposit'); + await openPanel(page, 'depositForm'); + const amount = panel(page, 'depositForm').locator('input[name="amount"]'); + await amount.fill('10.50'); + await panel(page, 'depositForm').getByRole('button', { name: 'Submit' }).click(); + expect(await amount.evaluate((el: HTMLInputElement) => el.validity.stepMismatch)).toBe(true); + expect(posts).toHaveLength(0); + await page.reload(); + await expectBalance(page, '0.00'); + }); + + test('MT-027 Large deposit is handled', async ({ page }) => { + await newCustomer(page, 'depbig'); + await deposit(page, '1000000'); + await expectBalance(page, '1000000.00'); + }); + + test('MT-028 Letters cannot be typed into amount', async ({ page }) => { + await newCustomer(page, 'depabc'); + await openPanel(page, 'depositForm'); + const amount = panel(page, 'depositForm').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..9b9a53c2 --- /dev/null +++ b/e2e/tests/helpers.ts @@ -0,0 +1,127 @@ +import { expect, Page, Browser } from '@playwright/test'; + +export const PASSWORD = 'Passw0rd!'; + +let counter = 0; +export function uniqueUsername(prefix = 'u'): string { + counter += 1; + const rand = Math.random().toString(36).slice(2, 8); + return `${prefix}_${Date.now().toString(36)}_${process.pid}_${counter}_${rand}`; +} + +export async function register(page: Page, username: string, password = PASSWORD) { + 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(); +} + +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 newCustomer(page: Page, prefix = 'u'): Promise { + const username = uniqueUsername(prefix); + await register(page, username); + await expect(page).toHaveURL(/\/login$/); + await login(page, username); + await expect(page).toHaveURL(/\/dashboard$/); + return username; +} + +/** Registers a second customer in an isolated browser context so the caller's session is untouched. */ +export async function registerOther(browser: Browser, prefix = 'r'): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + const username = uniqueUsername(prefix); + await register(page, username); + await expect(page).toHaveURL(/\/login$/); + await context.close(); + return username; +} + +export function balance(page: Page) { + return page.getByRole('heading', { level: 3 }); +} + +export async function expectBalance(page: Page, amount: string) { + await expect(balance(page)).toHaveText(`Current Balance: $${amount}`); +} + +export function panel(page: Page, id: 'depositForm' | 'withdrawForm' | 'transferForm') { + return page.locator(`#${id}`); +} + +async function openPanel(page: Page, id: 'depositForm' | 'withdrawForm' | 'transferForm') { + await page.locator(`button[data-target="#${id}"]`).click(); + await expect(panel(page, id)).toBeVisible(); +} + +export async function deposit(page: Page, amount: string) { + await page.goto('/dashboard'); + await openPanel(page, 'depositForm'); + await panel(page, 'depositForm').locator('input[name="amount"]').fill(amount); + await panel(page, 'depositForm').getByRole('button', { name: 'Submit' }).click(); +} + +export async function withdraw(page: Page, amount: string) { + await page.goto('/dashboard'); + await openPanel(page, 'withdrawForm'); + await panel(page, 'withdrawForm').locator('input[name="amount"]').fill(amount); + await panel(page, 'withdrawForm').getByRole('button', { name: 'Submit' }).click(); +} + +export async function transfer(page: Page, toUsername: string, amount: string) { + await page.goto('/dashboard'); + await openPanel(page, 'transferForm'); + const form = panel(page, 'transferForm'); + await form.locator('input[name="toUsername"]').fill(toUsername); + await form.locator('input[name="amount"]').fill(amount); + await form.getByRole('button', { name: 'Submit' }).click(); +} + +export { openPanel }; + +export function historyRows(page: Page) { + return page.locator('table tbody tr'); +} + +/** Returns [type, amount] pairs from the Transaction History table. */ +export async function historyEntries(page: Page): Promise> { + await page.goto('/transactions'); + await expect(page.getByRole('heading', { name: 'Transaction History' })).toBeVisible(); + const rows = historyRows(page); + const n = await rows.count(); + const out: Array<{ type: string; amount: string; date: string }> = []; + for (let i = 0; i < n; i++) { + const cells = rows.nth(i).locator('td'); + out.push({ + type: (await cells.nth(1).innerText()).trim(), + amount: (await cells.nth(2).innerText()).trim(), + date: (await cells.nth(3).innerText()).trim(), + }); + } + return out; +} + +/** Records every POST to the given path so tests can assert a form was not submitted. */ +export function trackPosts(page: Page, path: string): string[] { + const seen: string[] = []; + page.on('request', (req) => { + if (req.method() === 'POST' && new URL(req.url()).pathname === path) seen.push(req.url()); + }); + return seen; +} + +export async function balanceOf(browser: Browser, username: string): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + await login(page, username); + await expect(page).toHaveURL(/\/dashboard$/); + const text = (await balance(page).innerText()).trim(); + await context.close(); + return text; +} diff --git a/e2e/tests/transactions.spec.ts b/e2e/tests/transactions.spec.ts new file mode 100644 index 00000000..e876cd54 --- /dev/null +++ b/e2e/tests/transactions.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; +import { deposit, expectBalance, historyEntries, historyRows, newCustomer, registerOther, transfer, withdraw } from './helpers'; + +test.describe('Transactions', () => { + test('MT-049 New customer has empty history', async ({ page }) => { + await newCustomer(page, 'hist0'); + await page.goto('/transactions'); + await expect(page.locator('table thead th')).toHaveCount(4); + await expect(historyRows(page)).toHaveCount(0); + }); + + test('MT-050 History columns are correct', async ({ page }) => { + await newCustomer(page, 'cols'); + await page.goto('/transactions'); + await expect(page.locator('table thead th')).toHaveText(['ID', 'Type', 'Amount', 'Date']); + }); + + test('MT-051 All transaction types are listed', async ({ page, browser }) => { + await newCustomer(page, 'alltypes'); + const recipient = await registerOther(browser); + await deposit(page, '100'); + await withdraw(page, '10'); + await transfer(page, recipient, '20'); + await expectBalance(page, '70.00'); + const types = (await historyEntries(page)).map((r) => r.type); + expect(types).toContain('Deposit'); + expect(types).toContain('Withdrawal'); + expect(types).toContain(`Transfer Out to ${recipient}`); + }); + + test('MT-052 Back to Dashboard link works', async ({ page }) => { + await newCustomer(page, 'back'); + await page.goto('/transactions'); + await page.getByRole('link', { name: 'Back to Dashboard' }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole('heading', { name: /^Welcome, / })).toBeVisible(); + }); + + test('MT-053 Transactions link works from dashboard', async ({ page }) => { + await newCustomer(page, 'txlink'); + 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 newCustomer(page, 'dates'); + await deposit(page, '100'); + await deposit(page, '50'); + await expectBalance(page, '150.00'); + const rows = await historyEntries(page); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.date).toMatch(/^\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..f1f77577 --- /dev/null +++ b/e2e/tests/transfer.spec.ts @@ -0,0 +1,136 @@ +import { test, expect } from '@playwright/test'; +import { + balanceOf, + deposit, + expectBalance, + historyEntries, + login, + newCustomer, + openPanel, + panel, + registerOther, + trackPosts, + transfer, + uniqueUsername, +} from './helpers'; + +test.describe('Transfer', () => { + test('MT-036 Transfer debits the sender', async ({ page, browser }) => { + await newCustomer(page, 'snd'); + const recipient = await registerOther(browser); + await deposit(page, '100'); + await transfer(page, recipient, '30'); + await expect(page).toHaveURL(/\/dashboard$/); + await expectBalance(page, '70.00'); + }); + + test('MT-037 Transfer credits the recipient', async ({ page, browser }) => { + await newCustomer(page, 'snd'); + const recipient = await registerOther(browser); + await deposit(page, '100'); + await transfer(page, recipient, '30'); + await expectBalance(page, '70.00'); + + await page.getByRole('link', { name: 'Logout' }).click(); + await login(page, recipient); + await expect(page).toHaveURL(/\/dashboard$/); + await expectBalance(page, '30.00'); + }); + + test('MT-038 Sender history shows the transfer', async ({ page, browser }) => { + await newCustomer(page, 'snd'); + const recipient = await registerOther(browser); + await deposit(page, '100'); + await transfer(page, recipient, '30'); + await expectBalance(page, '70.00'); + const rows = await historyEntries(page); + expect(rows).toContainEqual(expect.objectContaining({ type: `Transfer Out to ${recipient}`, amount: '30.00' })); + }); + + test('MT-039 Recipient history shows the transfer', async ({ page, browser }) => { + const sender = await newCustomer(page, 'snd'); + const recipient = await registerOther(browser); + await deposit(page, '100'); + await transfer(page, recipient, '30'); + await expectBalance(page, '70.00'); + + await page.getByRole('link', { name: 'Logout' }).click(); + await login(page, recipient); + const rows = await historyEntries(page); + expect(rows).toContainEqual(expect.objectContaining({ type: `Transfer In from ${sender}`, amount: '30.00' })); + }); + + test('MT-040 Customer can transfer the full balance', async ({ page, browser }) => { + await newCustomer(page, 'snd'); + const recipient = await registerOther(browser); + await deposit(page, '100'); + await transfer(page, recipient, '100'); + 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 newCustomer(page, 'snd'); + const recipient = await registerOther(browser); + await deposit(page, '100'); + await transfer(page, recipient, '150'); + await expect(page.getByText('Insufficient funds')).toBeVisible(); + await page.goto('/dashboard'); + await expectBalance(page, '100.00'); + expect(await balanceOf(browser, recipient)).toBe('Current Balance: $0.00'); + }); + + // Plan does not state a starting balance; funds are deposited first so the + // insufficient-funds check (which runs before the recipient lookup) cannot mask this case. + test('MT-042 Transfer to unknown customer is rejected', async ({ page }) => { + await newCustomer(page, 'snd'); + await deposit(page, '100'); + await transfer(page, uniqueUsername('nobody'), '10'); + await expect(page.getByText('Recipient account not found')).toBeVisible(); + await page.goto('/dashboard'); + await expectBalance(page, '100.00'); + }); + + test('MT-043 Recipient is required', async ({ page }) => { + await newCustomer(page, 'snd'); + const posts = trackPosts(page, '/transfer'); + await openPanel(page, 'transferForm'); + const form = panel(page, 'transferForm'); + 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).toHaveLength(0); + }); + + test('MT-044 Amount is required', async ({ page, browser }) => { + await newCustomer(page, 'snd'); + const recipient = await registerOther(browser); + const posts = trackPosts(page, '/transfer'); + await openPanel(page, 'transferForm'); + const form = panel(page, 'transferForm'); + 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).toHaveLength(0); + }); + + test('MT-045 Multiple transfers add up', async ({ page, browser }) => { + await newCustomer(page, 'snd'); + const recipient = await registerOther(browser); + await deposit(page, '100'); + await transfer(page, recipient, '20'); + await expectBalance(page, '80.00'); + await transfer(page, recipient, '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 newCustomer(page, 'snd'); + await deposit(page, '100'); + await transfer(page, uniqueUsername('nobody'), '10'); + await expect(page.getByText('Recipient account not found')).toBeVisible(); + const rows = await historyEntries(page); + expect(rows.filter((r) => r.type.startsWith('Transfer'))).toEqual([]); + }); +}); diff --git a/e2e/tests/withdraw.spec.ts b/e2e/tests/withdraw.spec.ts new file mode 100644 index 00000000..5414c16e --- /dev/null +++ b/e2e/tests/withdraw.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '@playwright/test'; +import { deposit, expectBalance, historyEntries, newCustomer, openPanel, panel, trackPosts, withdraw } from './helpers'; + +test.describe('Withdraw', () => { + test('MT-029 Withdrawal decreases balance', async ({ page }) => { + await newCustomer(page, 'wd'); + await deposit(page, '100'); + await withdraw(page, '40'); + await expect(page).toHaveURL(/\/dashboard$/); + await expectBalance(page, '60.00'); + }); + + test('MT-030 Customer can withdraw the full balance', async ({ page }) => { + await newCustomer(page, 'wdall'); + await deposit(page, '100'); + await withdraw(page, '100'); + await expectBalance(page, '0.00'); + }); + + test('MT-031 Overdraw is rejected', async ({ page }) => { + await newCustomer(page, 'wdover'); + await deposit(page, '100'); + await withdraw(page, '500'); + await expect(page.getByText('Insufficient funds')).toBeVisible(); + 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 newCustomer(page, 'wdempty'); + await withdraw(page, '1'); + await expect(page.getByText('Insufficient funds')).toBeVisible(); + await page.goto('/dashboard'); + await expectBalance(page, '0.00'); + }); + + test('MT-033 Withdrawal appears in history', async ({ page }) => { + await newCustomer(page, 'wdhist'); + await deposit(page, '100'); + await withdraw(page, '40'); + await expectBalance(page, '60.00'); + const rows = await historyEntries(page); + expect(rows).toContainEqual(expect.objectContaining({ type: 'Withdrawal', amount: '40.00' })); + }); + + test('MT-034 Empty withdrawal is blocked', async ({ page }) => { + await newCustomer(page, 'wdblank'); + const posts = trackPosts(page, '/withdraw'); + await openPanel(page, 'withdrawForm'); + const amount = panel(page, 'withdrawForm').locator('input[name="amount"]'); + await panel(page, 'withdrawForm').getByRole('button', { name: 'Submit' }).click(); + expect(await amount.evaluate((el: HTMLInputElement) => el.validity.valueMissing)).toBe(true); + expect(posts).toHaveLength(0); + await expect(page).toHaveURL(/\/dashboard$/); + }); + + test('MT-035 Rejected withdrawal leaves no history', async ({ page }) => { + await newCustomer(page, 'wdnohist'); + await deposit(page, '100'); + await withdraw(page, '500'); + await expect(page.getByText('Insufficient funds')).toBeVisible(); + const rows = await historyEntries(page); + expect(rows.map((r) => r.type)).not.toContain('Withdrawal'); + }); +}); 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..2e84a174 --- /dev/null +++ b/qa/coverage-matrix.md @@ -0,0 +1,91 @@ +# Regression coverage matrix + +Generated by `e2e/scripts/coverage.mjs` from `qa/manual-test-plan.csv`, `e2e/tests/*.spec.ts` and `qa/not-automated.csv`. Do not edit by hand; run `npm run coverage` in `e2e/`. + +**60 rows: 54 automated, 6 not automated.** + +| Area | Automated | Not automated | +|---|---|---| +| Authentication | 13 | 0 | +| Dashboard | 7 | 1 | +| Deposit | 7 | 0 | +| Withdraw | 7 | 0 | +| Transfer | 11 | 2 | +| Transactions | 6 | 1 | +| Cross-cutting | 3 | 2 | + +## All rows + +| ID | Area | Title | Priority | Status | Playwright test | +|---|---|---|---|---|---| +| MT-001 | Authentication | Login page shows username and password fields | High | Automated | `e2e/tests/auth.spec.ts` | +| MT-002 | Authentication | Register link on login page opens registration | Medium | Automated | `e2e/tests/auth.spec.ts` | +| MT-003 | Authentication | Login link on register page returns to login | Low | Automated | `e2e/tests/auth.spec.ts` | +| MT-004 | Authentication | New customer can register | High | Automated | `e2e/tests/auth.spec.ts` | +| MT-005 | Authentication | Duplicate username is rejected | High | Automated | `e2e/tests/auth.spec.ts` | +| MT-006 | Authentication | Valid login opens dashboard | High | Automated | `e2e/tests/auth.spec.ts` | +| MT-007 | Authentication | Wrong password is rejected | High | Automated | `e2e/tests/auth.spec.ts` | +| MT-008 | Authentication | Unknown username gets the same error | High | Automated | `e2e/tests/auth.spec.ts` | +| MT-009 | Authentication | Password is case sensitive | Medium | Automated | `e2e/tests/auth.spec.ts` | +| MT-010 | Authentication | Dashboard requires login | High | Automated | `e2e/tests/auth.spec.ts` | +| MT-011 | Authentication | Transaction history requires login | High | Automated | `e2e/tests/auth.spec.ts` | +| MT-012 | Authentication | Logout returns to login | High | Automated | `e2e/tests/auth.spec.ts` | +| MT-013 | Authentication | Session ends after logout | High | Automated | `e2e/tests/auth.spec.ts` | +| MT-014 | Dashboard | New customer starts at zero | High | Automated | `e2e/tests/dashboard.spec.ts` | +| MT-015 | Dashboard | Welcome message shows the customer | Low | Automated | `e2e/tests/dashboard.spec.ts` | +| MT-016 | Dashboard | Account number is shown | Medium | Automated | `e2e/tests/dashboard.spec.ts` | +| MT-017 | Dashboard | Navigation links are present | Low | Automated | `e2e/tests/dashboard.spec.ts` | +| MT-018 | Dashboard | Deposit panel opens | Medium | Automated | `e2e/tests/dashboard.spec.ts` | +| MT-019 | Dashboard | Withdraw panel opens | Medium | Automated | `e2e/tests/dashboard.spec.ts` | +| MT-020 | Dashboard | Transfer panel opens | Medium | Automated | `e2e/tests/dashboard.spec.ts` | +| MT-021 | Dashboard | Brand colours and logo match the brand guide | Low | Not automated | see below | +| MT-022 | Deposit | Deposit increases balance | High | Automated | `e2e/tests/deposit.spec.ts` | +| MT-023 | Deposit | Deposits add up | High | Automated | `e2e/tests/deposit.spec.ts` | +| MT-024 | Deposit | Deposit appears in history | High | Automated | `e2e/tests/deposit.spec.ts` | +| MT-025 | Deposit | Empty deposit is blocked | Medium | Automated | `e2e/tests/deposit.spec.ts` | +| MT-026 | Deposit | Deposit field accepts whole dollars only | Medium | Automated | `e2e/tests/deposit.spec.ts` | +| MT-027 | Deposit | Large deposit is handled | Medium | Automated | `e2e/tests/deposit.spec.ts` | +| MT-028 | Deposit | Letters cannot be typed into amount | Low | Automated | `e2e/tests/deposit.spec.ts` | +| MT-029 | Withdraw | Withdrawal decreases balance | High | Automated | `e2e/tests/withdraw.spec.ts` | +| MT-030 | Withdraw | Customer can withdraw the full balance | High | Automated | `e2e/tests/withdraw.spec.ts` | +| MT-031 | Withdraw | Overdraw is rejected | High | Automated | `e2e/tests/withdraw.spec.ts` | +| MT-032 | Withdraw | Withdrawal from empty account is rejected | High | Automated | `e2e/tests/withdraw.spec.ts` | +| MT-033 | Withdraw | Withdrawal appears in history | High | Automated | `e2e/tests/withdraw.spec.ts` | +| MT-034 | Withdraw | Empty withdrawal is blocked | Medium | Automated | `e2e/tests/withdraw.spec.ts` | +| MT-035 | Withdraw | Rejected withdrawal leaves no history | High | Automated | `e2e/tests/withdraw.spec.ts` | +| MT-036 | Transfer | Transfer debits the sender | High | Automated | `e2e/tests/transfer.spec.ts` | +| MT-037 | Transfer | Transfer credits the recipient | High | Automated | `e2e/tests/transfer.spec.ts` | +| MT-038 | Transfer | Sender history shows the transfer | High | Automated | `e2e/tests/transfer.spec.ts` | +| MT-039 | Transfer | Recipient history shows the transfer | High | Automated | `e2e/tests/transfer.spec.ts` | +| MT-040 | Transfer | Customer can transfer the full balance | High | Automated | `e2e/tests/transfer.spec.ts` | +| MT-041 | Transfer | Transfer above balance is rejected | High | Automated | `e2e/tests/transfer.spec.ts` | +| MT-042 | Transfer | Transfer to unknown customer is rejected | High | Automated | `e2e/tests/transfer.spec.ts` | +| MT-043 | Transfer | Recipient is required | Medium | Automated | `e2e/tests/transfer.spec.ts` | +| MT-044 | Transfer | Amount is required | Medium | Automated | `e2e/tests/transfer.spec.ts` | +| MT-045 | Transfer | Multiple transfers add up | High | Automated | `e2e/tests/transfer.spec.ts` | +| MT-046 | Transfer | Rejected transfer leaves no history | High | Automated | `e2e/tests/transfer.spec.ts` | +| MT-047 | Transfer | Negative transfer amount | High | Not automated | see below | +| MT-048 | Transfer | Customer receives a transfer confirmation by SMS or email | Medium | Not automated | see below | +| MT-049 | Transactions | New customer has empty history | Medium | Automated | `e2e/tests/transactions.spec.ts` | +| MT-050 | Transactions | History columns are correct | Low | Automated | `e2e/tests/transactions.spec.ts` | +| MT-051 | Transactions | All transaction types are listed | High | Automated | `e2e/tests/transactions.spec.ts` | +| MT-052 | Transactions | Back to Dashboard link works | Low | Automated | `e2e/tests/transactions.spec.ts` | +| MT-053 | Transactions | Transactions link works from dashboard | Low | Automated | `e2e/tests/transactions.spec.ts` | +| MT-054 | Transactions | Every row has a date | Medium | Automated | `e2e/tests/transactions.spec.ts` | +| MT-055 | Transactions | Printed history is legible on the branch printer | Low | Not automated | see below | +| MT-056 | Cross-cutting | Customers only see their own account | High | Automated | `e2e/tests/cross-cutting.spec.ts` | +| MT-057 | Cross-cutting | Balance survives logout | High | Automated | `e2e/tests/cross-cutting.spec.ts` | +| MT-058 | Cross-cutting | Bank name shows on every page tab | Low | Automated | `e2e/tests/cross-cutting.spec.ts` | +| MT-059 | Cross-cutting | Screen reader reads balance and errors | Medium | Not automated | see below | +| MT-060 | Cross-cutting | First-time customer completes a transfer unaided | Low | Not automated | see below | + +## Rows not automated + +| ID | Title | Expected (from plan) | Why not automated | Evidence | +|---|---|---|---|---| +| MT-021 | Brand colours and logo match the brand guide | Brand team signs off | Expected result is a brand-team sign-off against a brand guide that is not in the repo; there is no machine-checkable pass criterion. | Observed: /images/wells-fargo-logo.png returns 302 to /login for logged-out visitors (SecurityConfig only permits /register), so the logo does not load on the login and register pages. | +| MT-047 | Negative transfer amount | System handles it appropriately | Expected result 'System handles it appropriately' is not defined; a test would have to invent the rule (reject? which message? which balances?). Needs a product decision. | Observed: POST /transfer with amount=-50 from a $0.00 account is accepted; sender shows $50.00, recipient $-50.00, sender history row 'Transfer Out to <recipient>' -50.00. No server-side guard on amount sign in AccountService.transferAmount. | +| MT-048 | Customer receives a transfer confirmation by SMS or email | Confirmation arrives within one minute | Needs a real phone and inbox, and the app has no SMS or email capability (no mail/SMS dependency or notification code in src/ or pom.xml). Would fail manually today; automatable only after the feature exists with a test inbox/SMS sandbox. | - | +| MT-055 | Printed history is legible on the branch printer | All columns readable on one page | Requires a physical branch printer; 'legible on one page' is a human judgement of printed output. | - | +| MT-059 | Screen reader reads balance and errors | Balance and error are announced clearly | Requires JAWS on Windows; Playwright cannot drive JAWS and 'announced clearly' is a subjective listening check. An accessibility-tree check would not verify this row. | Observed in templates: dashboard <label> elements have no for/id association with their inputs, and the error <p class="error"> has no role=alert or aria-live. | +| MT-060 | First-time customer completes a transfer unaided | Transfer completed in under 2 minutes without help | Moderated usability session with a first-time human participant; not a functional check. | - | diff --git a/qa/not-automated.csv b/qa/not-automated.csv new file mode 100644 index 00000000..7fe26ac7 --- /dev/null +++ b/qa/not-automated.csv @@ -0,0 +1,7 @@ +id,reason,evidence +MT-021,"Expected result is a brand-team sign-off against a brand guide that is not in the repo; there is no machine-checkable pass criterion.","Observed: /images/wells-fargo-logo.png returns 302 to /login for logged-out visitors (SecurityConfig only permits /register), so the logo does not load on the login and register pages." +MT-047,"Expected result 'System handles it appropriately' is not defined; a test would have to invent the rule (reject? which message? which balances?). Needs a product decision.","Observed: POST /transfer with amount=-50 from a $0.00 account is accepted; sender shows $50.00, recipient $-50.00, sender history row 'Transfer Out to ' -50.00. No server-side guard on amount sign in AccountService.transferAmount." +MT-048,"Needs a real phone and inbox, and the app has no SMS or email capability (no mail/SMS dependency or notification code in src/ or pom.xml). Would fail manually today; automatable only after the feature exists with a test inbox/SMS sandbox.", +MT-055,"Requires a physical branch printer; 'legible on one page' is a human judgement of printed output.", +MT-059,"Requires JAWS on Windows; Playwright cannot drive JAWS and 'announced clearly' is a subjective listening check. An accessibility-tree check would not verify this row.","Observed in templates: dashboard