diff --git a/.env.example b/.env.example index 52cf5a5..2fc90cb 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,14 @@ -WEBSITE_URL= \ No newline at end of file +WEBSITE_URL= + +TEST_USER_NAME= +TEST_USER_EMAIL= +TEST_USER_PASSWORD= + +TEST_ADMIN_EMAIL= +TEST_ADMIN_PASSWORD= + +TEST_LAWYER_EMAIL= +TEST_LAWYER_PASSWORD= + +TEST_CLIENT_EMAIL= +TEST_CLIENT_PASSWORD= \ No newline at end of file diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index b5b5749..fd8fe82 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -10,6 +10,15 @@ jobs: runs-on: ubuntu-latest env: WEBSITE_URL: ${{ secrets.WEBSITE_URL }} + TEST_USER_NAME: ${{ secrets.TEST_USER_NAME }} + TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }} + TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }} + TEST_ADMIN_EMAIL: ${{ secrets.TEST_ADMIN_EMAIL }} + TEST_ADMIN_PASSWORD: ${{ secrets.TEST_ADMIN_PASSWORD }} + TEST_LAWYER_EMAIL: ${{ secrets.TEST_LAWYER_EMAIL }} + TEST_LAWYER_PASSWORD: ${{ secrets.TEST_LAWYER_PASSWORD }} + TEST_CLIENT_EMAIL: ${{ secrets.TEST_CLIENT_EMAIL }} + TEST_CLIENT_PASSWORD: ${{ secrets.TEST_CLIENT_PASSWORD }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -19,6 +28,8 @@ jobs: run: npm ci - name: Install Playwright Browsers run: npx playwright install --with-deps + - name: Run auth setup + run: npx playwright test tests/auth.setup.ts --project=setup - name: Run Playwright tests run: npx playwright test - uses: actions/upload-artifact@v4 diff --git a/fixtures/billing-data.builder.ts b/fixtures/billing-data.builder.ts new file mode 100644 index 0000000..698b78c --- /dev/null +++ b/fixtures/billing-data.builder.ts @@ -0,0 +1,82 @@ +import crypto from 'crypto'; +import { BillingData } from '../types/BillingData'; + +export class BillingDataBuilder { + private data: Partial = {}; + + constructor() { + this.withDefaults(); + } + + // Defaults + private withDefaults(): this { + const uniqueId = this.generateUniqueId(); + this.data = { + amount: this.generateRandomAmount(), + description: `Legal consultation services ${uniqueId}`, + dueDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0], + status: 'unpaid' + }; + return this; + } + + // Setters + withCaseName(caseName: string): this { + this.data.caseName = caseName; + return this; + } + + withAmount(amount: string): this { + this.data.amount = amount; + return this; + } + + withRandomAmount(min = 1000, max = 9999): this { + this.data.amount = this.generateRandomAmount(min, max); + return this; + } + + withStatus(status: 'unpaid' | 'paid' | 'overdue'): this { + this.data.status = status; + return this; + } + + withDescription(description: string): this { + this.data.description = description; + return this; + } + + withDueDate(dueDate: string): this { + this.data.dueDate = dueDate; + return this; + } + + private generateUniqueId(): string { + const timestamp = Date.now(); + const random = crypto.randomUUID().slice(0, 8); + return `${timestamp}-${random}`; + } + + private generateRandomAmount(min = 1000, max = 9999): string { + return Math.floor(Math.random() * (max - min + 1) + min).toString(); + } + + build(): BillingData { + const { caseName, amount, description } = this.data; + if (!caseName) throw new Error('caseName is required'); + if (!amount) throw new Error('amount is required'); + if (!description) throw new Error('description is required'); + + return this.data as BillingData; + } + + static create(overrides?: Partial): BillingData { + const builder = new BillingDataBuilder(); + if (overrides) Object.assign(builder.data, overrides); + return builder.build(); + } +} + +export function generateBillingData(overrides?: Partial): BillingData { + return BillingDataBuilder.create(overrides); +} diff --git a/fixtures/cases-data.builder.ts b/fixtures/cases-data.builder.ts new file mode 100644 index 0000000..e2f34f2 --- /dev/null +++ b/fixtures/cases-data.builder.ts @@ -0,0 +1,71 @@ +import crypto from 'crypto'; +import { CaseData } from '../types/CaseData'; + +export class CaseDataBuilder { + private data: Partial = {}; + + constructor() { + this.withDefaults(); + } + + // Defaults + private withDefaults(): this { + const uniqueId = this.generateUniqueId(); + this.data = { + title: `Case ${uniqueId}`, + description: `Case description ${uniqueId}`, + status: 'open' + }; + return this; + } + + // Setters + withClient(client: string): this { + this.data.client = client; + return this; + } + + withLawyer(lawyer: string): this { + this.data.lawyer = lawyer; + return this; + } + + withTitle(title: string): this { + this.data.title = title; + return this; + } + + withDescription(description: string): this { + this.data.description = description; + return this; + } + + withStatus(status: 'open' | 'closed' | 'pending'): this { + this.data.status = status; + return this; + } + + private generateUniqueId(): string { + const timestamp = Date.now(); + const random = crypto.randomUUID().slice(0, 6); + return `${timestamp}-${random}`; + } + + build(): CaseData { + const { title, description } = this.data; + if (!title) throw new Error('title is required'); + if (!description) throw new Error('description is required'); + + return this.data as CaseData; + } + + static create(overrides?: Partial): CaseData { + const builder = new CaseDataBuilder(); + if (overrides) Object.assign(builder.data, overrides); + return builder.build(); + } +} + +export function generateCaseData(overrides?: Partial): CaseData { + return CaseDataBuilder.create(overrides); +} diff --git a/package-lock.json b/package-lock.json index 5895cf8..07287ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,21 +9,21 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "dotenv": "^17.2.1" + "dotenv": "^17.2.3" }, "devDependencies": { - "@playwright/test": "^1.55.0", + "@playwright/test": "^1.56.1", "@types/node": "^24.3.0" } }, "node_modules/@playwright/test": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", - "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.55.0" + "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -43,9 +43,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", - "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -70,13 +70,13 @@ } }, "node_modules/playwright": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", - "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.55.0" + "playwright-core": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -89,9 +89,9 @@ } }, "node_modules/playwright-core": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", - "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 3acebe3..cad940e 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,10 @@ "license": "ISC", "description": "", "devDependencies": { - "@playwright/test": "^1.55.0", + "@playwright/test": "^1.56.1", "@types/node": "^24.3.0" }, "dependencies": { - "dotenv": "^17.2.1" + "dotenv": "^17.2.3" } } diff --git a/pages/BillingPage.ts b/pages/BillingPage.ts new file mode 100644 index 0000000..dca1c8d --- /dev/null +++ b/pages/BillingPage.ts @@ -0,0 +1,125 @@ +import { Page, Locator } from '@playwright/test'; +import { BillingData } from '../types/BillingData'; + +export class BillingPage { + constructor(private page: Page) {} + + async navigateToBillingList() { + await this.page.goto('/billing/list.php'); + await this.page.waitForSelector('table tbody tr'); + } + + async clickCreateBilling() { + const createButton = this.page.getByRole('link', { name: /Create Billing/i }); + await createButton.waitFor({ state: 'visible', timeout: 5000 }); + await createButton.click(); + await this.page.waitForSelector('form'); + } + + async fillBillingForm(data: BillingData): Promise { + const form = this.page.locator('form'); + + // Select case + const caseSelect = form.getByLabel(/Select Case/i); + if (data.caseName) { + await caseSelect.selectOption(data.caseName); + } + + // Amount + const amountField = form.getByLabel(/Amount/i); + await amountField.fill(data.amount); + const formattedAmount = await amountField.inputValue(); + + // Description + const descriptionField = form.getByLabel(/Description/i); + await descriptionField.fill(data.description); + + // Status (optional) + if (data.status) { + const statusSelect = form.getByLabel(/Status/i); + if (await statusSelect.isVisible()) { + await statusSelect.selectOption(data.status); + } + } + + // Due Date (optional) + if (data.dueDate) { + const dueDateField = form.getByLabel(/Due Date/i); + if (await dueDateField.isVisible()) { + await dueDateField.evaluate((el, value) => { + (el as HTMLInputElement).value = value; + }, data.dueDate); + } + } + + return { ...data, amount: formattedAmount }; + } + + async submitBillingForm() { + const form = this.page.locator('form'); + const submitButton = form.getByRole('button', { name: /Create Billing/i }); + await submitButton.waitFor({ state: 'visible', timeout: 5000 }); + await submitButton.click(); + await this.page.waitForSelector('table, .alert, .success'); + } + + async findBillingRow(searchText: string): Promise { + return this.page.locator('table tbody tr').filter({ + has: this.page.locator(`td:has-text("${searchText}")`) + }); + } + + async getBillingRowData(row: Locator) { + const cells = await row.locator('td').all(); + return { + case: (await cells[0]?.textContent())?.trim() || '', + client: (await cells[1]?.textContent())?.trim() || '', + amount: (await cells[2]?.textContent())?.trim() || '', + statusDropdown: row.locator('select.status-dropdown'), + dueDate: (await cells[4]?.textContent())?.trim() || '', + description: (await cells[5]?.textContent())?.trim() || '', + }; + } + + async getBillingRowDataAsClient(row: Locator) { + const cells = await row.locator('td').all(); + return { + case: (await cells[0]?.textContent())?.trim() || '', + client: (await cells[1]?.textContent())?.trim() || '', + amount: (await cells[2]?.textContent())?.trim() || '', + status: (await cells[3]?.textContent())?.trim() || '', + dueDate: (await cells[4]?.textContent())?.trim() || '', + description: (await cells[5]?.textContent())?.trim() || '', + }; + } + + async selectFirstAvailableCase(): Promise<{ value: string; name: string }> { + const form = this.page.locator('form'); + const caseSelect = form.getByLabel(/Select Case/i); + + const options = await caseSelect.locator('option[value]').all(); + if (options.length < 2) { + throw new Error('No cases available for billing. Please create at least one case.'); + } + + // Skip placeholder (index 0) + const firstRealOption = options[1]; + const value = (await firstRealOption.getAttribute('value')) || ''; + const name = (await firstRealOption.textContent())?.trim() || ''; + + return { value, name }; + } + + async selectCaseByTitle(title: string): Promise<{ value: string; name: string } | null> { + const form = this.page.locator('form'); + const caseSelect = form.getByLabel(/Select Case/i); + + const caseOption = caseSelect.locator('option', { hasText: title }).first(); + if (await caseOption.count() === 0) return null; + + const value = (await caseOption.getAttribute('value')) || ''; + const name = (await caseOption.textContent())?.trim() || ''; + + return { value, name }; + } +} diff --git a/pages/CasesPage.ts b/pages/CasesPage.ts new file mode 100644 index 0000000..8803db3 --- /dev/null +++ b/pages/CasesPage.ts @@ -0,0 +1,101 @@ +import { Page, Locator } from '@playwright/test'; +import { CaseData } from '../types/CaseData'; + +export class CasesPage { + constructor(private page: Page) {} + + async navigateToCasesList() { + await this.page.goto('/cases/list.php'); + await this.page.waitForSelector('table tbody tr'); + } + + async caseExists(title: string): Promise { + const existingCase = this.page.locator('table tbody tr').filter({ + has: this.page.locator(`td:has-text("${title}")`) + }); + return (await existingCase.count()) > 0; + } + + async navigateToCreateCase() { + await this.page.getByRole('link', { name: /Add Case/i }).click(); + await this.page.waitForSelector('form'); + } + + async fillCaseForm(data: CaseData) { + const form = this.page.locator('form'); + + // Title + const titleField = form.locator( + 'input[name="title"], input[name="case_title"], input#title, input#case_title' + ).first(); + await titleField.waitFor({ state: 'visible', timeout: 5000 }); + await titleField.fill(data.title); + + // Client + const clientSelect = form.locator('select[name*="client"], select#client_id, [name="client_id"]').first(); + await this.trySelectOption(clientSelect, data.client, 'Client'); + + // Lawyer + const lawyerSelect = form.locator( + 'select[name*="lawyer"], select#lawyer_id, select[name="assigned_lawyer"], select#assigned_lawyer' + ).first(); + await this.trySelectOption(lawyerSelect, data.lawyer ?? 'John Doeadeer', 'Lawyer'); + + // Description + const descriptionField = form.locator('textarea[name*="description"], #description').first(); + if (await descriptionField.isVisible()) { + await descriptionField.fill(data.description); + } + + // Case type + await this.selectFirstAvailableOption(form.locator('select[name*="type"], select#case_type').first()); + + // Status + if (data.status) { + const statusSelect = form.locator('select[name*="status"], select#status, select#case_status').first(); + if (await statusSelect.isVisible()) { + await statusSelect.selectOption(data.status); + } + } + + return form; + } + + private async trySelectOption(select: Locator, value?: string, label?: string) { + if (!(await select.isVisible())) return; + + if (value) { + const option = select.locator('option', { hasText: new RegExp(value, 'i') }).first(); + if (await option.count()) { + const val = await option.getAttribute('value'); + if (val) { + await select.selectOption(val); + return; + } + } + console.warn(`⚠️ ${label ?? 'Option'} "${value}" not found, selecting first available`); + } + + await this.selectFirstAvailableOption(select); + } + + private async selectFirstAvailableOption(select: Locator) { + if (!(await select.isVisible())) return; + + const options = await select.locator('option[value]:not([disabled])').all(); + if (options.length > 1) { + // skip placeholder + const first = options[1]; + const val = await first.getAttribute('value'); + if (val) await select.selectOption(val); + } else if (options.length === 1) { + const val = await options[0].getAttribute('value'); + if (val) await select.selectOption(val); + } + } + + async submitCaseForm() { + const form = this.page.locator('form'); + await form.getByRole('button', { name: /Create|Submit|Save/i }).click(); + } +} diff --git a/playwright.config.ts b/playwright.config.ts index 870311b..0b66e0e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -34,6 +34,11 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + }, + { name: 'chromium', use: { ...devices['Desktop Chrome'] }, diff --git a/tests/auth.setup.ts b/tests/auth.setup.ts new file mode 100644 index 0000000..0132d11 --- /dev/null +++ b/tests/auth.setup.ts @@ -0,0 +1,44 @@ +import { test as setup, expect } from '@playwright/test'; + +const TEST_ADMIN_EMAIL = process.env.TEST_ADMIN_EMAIL; +const TEST_ADMIN_PASSWORD = process.env.TEST_ADMIN_PASSWORD; +const TEST_LAWYER_EMAIL = process.env.TEST_LAWYER_EMAIL; +const TEST_LAWYER_PASSWORD = process.env.TEST_LAWYER_PASSWORD; +const TEST_CLIENT_EMAIL = process.env.TEST_CLIENT_EMAIL; +const TEST_CLIENT_PASSWORD = process.env.TEST_CLIENT_PASSWORD; + +const adminFile = 'playwright/.auth/admin.json'; + +setup('authenticate as admin', async ({ page }) => { + await page.goto('/login.php'); + + await page.fill('input[name="email"]', TEST_ADMIN_EMAIL!); + await page.fill('input[name="password"]', TEST_ADMIN_PASSWORD!); + await page.getByRole('button', { name: 'Login Now' }).click(); + + await page.context().storageState({ path: adminFile }); +}); + +const clientFile = 'playwright/.auth/client.json'; + +setup('authenticate as client', async ({ page }) => { + await page.goto('/login.php'); + + await page.fill('input[name="email"]', TEST_CLIENT_EMAIL!); + await page.fill('input[name="password"]', TEST_CLIENT_PASSWORD!); + await page.getByRole('button', { name: 'Login Now' }).click(); + + await page.context().storageState({ path: clientFile }); +}); + +const lawyerFile = 'playwright/.auth/lawyer.json'; + +setup('authenticate as lawyer', async ({ page }) => { + await page.goto('/login.php'); + + await page.fill('input[name="email"]', TEST_LAWYER_EMAIL!); + await page.fill('input[name="password"]', TEST_LAWYER_PASSWORD!); + await page.getByRole('button', { name: 'Login Now' }).click(); + + await page.context().storageState({ path: lawyerFile }); +}); \ No newline at end of file diff --git a/tests/auth.spec.ts b/tests/auth.spec.ts new file mode 100644 index 0000000..96cdf5c --- /dev/null +++ b/tests/auth.spec.ts @@ -0,0 +1,159 @@ +import { test, expect } from '@playwright/test'; +import crypto from 'crypto'; + +// Localize environment variables +const TEST_USER_NAME = process.env.TEST_USER_NAME; +const TEST_USER_EMAIL = process.env.TEST_USER_EMAIL; +const TEST_USER_PASSWORD = process.env.TEST_USER_PASSWORD; +const TEST_ADMIN_EMAIL = process.env.TEST_ADMIN_EMAIL; +const TEST_ADMIN_PASSWORD = process.env.TEST_ADMIN_PASSWORD; +const TEST_LAWYER_EMAIL = process.env.TEST_LAWYER_EMAIL; +const TEST_LAWYER_PASSWORD = process.env.TEST_LAWYER_PASSWORD; +const TEST_CLIENT_EMAIL = process.env.TEST_CLIENT_EMAIL; +const TEST_CLIENT_PASSWORD = process.env.TEST_CLIENT_PASSWORD; + +// Check if required environment variables are set +if (!TEST_USER_NAME || !TEST_USER_EMAIL || !TEST_USER_PASSWORD) { + throw new Error('Missing required environment variables: TEST_USER_NAME, TEST_USER_EMAIL, TEST_USER_PASSWORD'); +} + +if (!TEST_ADMIN_EMAIL || !TEST_ADMIN_PASSWORD) { + throw new Error('Missing required environment variables: TEST_ADMIN_EMAIL, TEST_ADMIN_PASSWORD'); +} + +if (!TEST_LAWYER_EMAIL || !TEST_LAWYER_PASSWORD) { + throw new Error('Missing required environment variables: TEST_LAWYER_EMAIL, TEST_LAWYER_PASSWORD'); +} + +if (!TEST_CLIENT_EMAIL || !TEST_CLIENT_PASSWORD) { + throw new Error('Missing required environment variables: TEST_CLIENT_EMAIL, TEST_CLIENT_PASSWORD'); +} + +// Setup test - ensure test user exists for duplicate tests +test.beforeAll('setup: create test user if not exists', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + await page.goto('/register.php'); + + // Try to create the test user + await page.fill('input[name="first_name"]', TEST_USER_NAME); + await page.fill('input[name="last_name"]', TEST_USER_NAME); + await page.fill('input[name="email"]', TEST_USER_EMAIL); + await page.fill('input[name="password"]', TEST_USER_PASSWORD); + + await page.getByRole('button', { name: 'Create Account' }).click(); + + // Don't fail if user already exists - that's what we want + console.log('Test user setup completed (may already exist)'); + } catch (error) { + console.log('Test user setup: user might already exist'); + } finally { + await context.close(); + } +}); + +function generateUniqueUser() { + const uniqueId = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; + return { + first_name: `${uniqueId}`, + last_name: `TestUser`, + email: `test+${uniqueId}@example.com`, + password: 'Password123!' + }; +} + +test.describe("User Registration", () => { + + test('users can register with unique email', async ({ page }) => { + await page.goto('/register.php'); + + const user = generateUniqueUser(); + + // Fill in registration form with unique email + await page.fill('input[name="first_name"]', user.first_name); + await page.fill('input[name="last_name"]', user.last_name); + await page.fill('input[name="email"]', user.email); + await page.fill('input[name="password"]', user.password); + await page.fill('input[name="confirm_password"]', user.password); + await page.getByRole('button', { name: 'Create Account' }).click(); + + await expect(page).toHaveURL(/login/); + }); + + test('users cannot register with duplicate email', async ({ page }) => { + await page.goto('/register.php'); + + const user = generateUniqueUser(); + + await page.fill('input[name="first_name"]', user.first_name); + await page.fill('input[name="last_name"]', user.last_name); + await page.fill('input[name="email"]', TEST_USER_EMAIL); + await page.fill('input[name="password"]', user.password); + await page.fill('input[name="confirm_password"]', user.password); + await page.getByRole('button', { name: 'Create Account' }).click(); + + await expect(page.locator('.alert-danger')).toBeVisible(); + }); +}) + +test.describe("User Login", () => { + + test('users can login with correct email and password', async ({ page }) => { + await page.goto('/login.php'); + + await page.fill('input[name="email"]', TEST_USER_EMAIL!); + await page.fill('input[name="password"]', TEST_USER_PASSWORD!); + await page.getByRole('button', { name: 'Login' }).click(); + + await expect(page).toHaveURL(/dashboard/); + }); + + test('users cannot login with incorrect email or password', async ({ page }) => { + await page.goto('/login.php'); + + await page.fill('input[name="email"]', TEST_USER_EMAIL!); + await page.fill('input[name="password"]', 'wrongpassword123'); + await page.getByRole('button', { name: 'Login' }).click(); + + await expect(page.locator('.alert-danger')).toBeVisible(); + await expect(page).toHaveURL(/login/); + }); +}) + +test.describe("Role Based Access Control", () => { + + test.describe("Admin Role", () => { + test.use({ storageState: 'playwright/.auth/admin.json' }); + + test('admin users can access admin features', async ({ page }) => { + await page.goto('/login.php'); + + await expect(page).toHaveURL(/dashboard/); + await expect(page.locator('span.badge-role')).toHaveText(/Admin/i); + }); + }); + + test.describe("Lawyer Role", () => { + test.use({ storageState: 'playwright/.auth/lawyer.json' }); + + test('lawyers can access lawyer features', async ({ page }) => { + await page.goto('/login.php'); + + await expect(page).toHaveURL(/dashboard/); + await expect(page.locator('span.badge-role')).toHaveText(/Lawyer/i); + }); + }); + + test.describe("Client Role", () => { + test.use({ storageState: 'playwright/.auth/client.json' }); + + test('clients can access client features', async ({ page }) => { + await page.goto('/login.php'); + + await expect(page).toHaveURL(/dashboard/); + await expect(page.locator('span.badge-role')).toHaveText(/Client/i); + }); + }); +}); diff --git a/tests/billing.spec.ts b/tests/billing.spec.ts new file mode 100644 index 0000000..586d98f --- /dev/null +++ b/tests/billing.spec.ts @@ -0,0 +1,348 @@ +// spec: billing-test-plan.md +// seed: tests/seed.spec.ts + +import { test, expect, Page } from '@playwright/test'; +import { CaseData } from '../types/CaseData.ts'; +import { CasesPage } from '../pages/CasesPage.ts'; +import { BillingPage } from '../pages/BillingPage.ts'; +import { BillingDataBuilder } from '../fixtures/billing-data.builder.ts'; +import { CaseDataBuilder } from '../fixtures/cases-data.builder.ts'; + + +const CONFIG = { + MAX_PAGINATION_PAGES: process.env.CI ? 20 : 10, + NAVIGATION_TIMEOUT: 10000, + DEFAULT_TIMEOUT: 5000, +}; + +class NavigationHelper { + constructor(private page: Page) {} + + async navigateToBilling() { + const navMenu = this.page.locator('aside.sidebar'); // Adjust selector as needed + if (await navMenu.isVisible()) { + await navMenu.hover(); + await this.page.waitForTimeout(300); + } + + const billingLink = this.page.getByRole('link', { name: /Billing/i }).first(); + await billingLink.waitFor({ state: 'visible', timeout: CONFIG.NAVIGATION_TIMEOUT }); + await billingLink.click(); + await this.page.waitForLoadState('networkidle'); + await expect(this.page).toHaveURL(/billing/); + } +} + +async function findInPaginatedTable( + page: Page, + searchText: string, + maxPages = CONFIG.MAX_PAGINATION_PAGES +): Promise { + for (let currentPage = 1; currentPage <= maxPages; currentPage++) { + + await page.waitForSelector('table tbody tr', { + state: 'attached', + timeout: CONFIG.DEFAULT_TIMEOUT, + }).catch(() => {}); + + const foundRow = page.locator('table tbody tr', { + has: page.locator(`td:has-text("${searchText}")`), + }); + + if ((await foundRow.count()) > 0) { + return true; + } + + const nextButton = page.getByRole('link', { name: /Next/i }); + if ((await nextButton.count()) === 0) { + return false; + } + + const isDisabled = + (await nextButton.isDisabled().catch(() => false)) || + (await nextButton.evaluate( + (el) => el.classList.contains('disabled') || el.parentElement?.classList.contains('disabled') + ).catch(() => false)); + + if (isDisabled) { + return false; + } + + await nextButton.click(); + await page.waitForLoadState('networkidle'); + } + + return false; +} + +test.describe('Lawyer Invoice Generation', () => { + let testCaseData: CaseData; + let testCaseExists = false; + + test.use({ storageState: 'playwright/.auth/lawyer.json' }); + + // ─────────────────────────────────────────────────────────────── + // Setup: Ensure a test case exists before running billing tests + // ─────────────────────────────────────────────────────────────── + test.beforeAll(async ({ browser }) => { + testCaseData = new CaseDataBuilder().build(); + + const context = await browser.newContext({ storageState: 'playwright/.auth/lawyer.json' }); + const page = await context.newPage(); + + try { + const casesPage = new CasesPage(page); + await casesPage.navigateToCasesList(); + + if (await casesPage.caseExists(testCaseData.title)) { + testCaseExists = true; + } else { + await casesPage.navigateToCreateCase(); + await casesPage.fillCaseForm(testCaseData); + await casesPage.submitCaseForm(); + + testCaseExists = true; + } + } catch (error) { + testCaseExists = false; + } finally { + await context.close(); + } + }); + + test('lawyer can generate invoice', async ({ page }) => { + test.skip(!testCaseExists, 'Test case prerequisite failed — skipping test'); + + // ARRANGE + await page.goto('/dashboard.php'); + await expect(page).toHaveURL(/dashboard/); + + const nav = new NavigationHelper(page); + await nav.navigateToBilling(); + + const billingPage = new BillingPage(page); + await billingPage.clickCreateBilling(); + + const selectedCase = ( + await billingPage.selectCaseByTitle(testCaseData.title) || (await billingPage.selectFirstAvailableCase()) + ); // Ensure a case is selected (any for this test) + + const billingData = new BillingDataBuilder().withCaseName(selectedCase!.name).build(); + + // ACT + const submittedData = await billingPage.fillBillingForm(billingData); + await billingPage.submitBillingForm(); + await billingPage.navigateToBillingList(); + + // ASSERT + const found = await findInPaginatedTable(page, billingData.description); + expect(found).toBeTruthy(); // Billing entry is on the paginated table + + const billingRow = await billingPage.findBillingRow(billingData.description); + const rowData = await billingPage.getBillingRowData(billingRow); + + // Assert Name + expect(rowData.case).toContain(billingData.caseName); + + // Assert Client + expect(rowData.client).toBeTruthy(); + + // Assert Status + const statusValue = await rowData.statusDropdown.inputValue(); + expect(statusValue).toBe(submittedData.status || 'unpaid'); + + // Assert Amount + expect(Number(submittedData.amount)).toBe(Number(rowData.amount)); + + // Assert Due Date + expect(rowData.dueDate).toBe(submittedData.dueDate || ''); + + // Assert Description + expect(rowData.description).toBe(billingData.description); + + }); + + test('system can support multiple billing for same case', async ({ page }) => { + test.skip(!testCaseExists, 'Test case prerequisite failed — skipping test'); + + // ARRANGE + await page.goto('/dashboard.php'); + const nav = new NavigationHelper(page); + await nav.navigateToBilling(); + + const billingPage = new BillingPage(page); + + await billingPage.clickCreateBilling(); + const selectedCase = + (await billingPage.selectCaseByTitle(testCaseData.title)) || + (await billingPage.selectFirstAvailableCase()); + + const billing1Data = new BillingDataBuilder() + .withCaseName(selectedCase!.name) + .withDescription(`First invoice for case ${Date.now()}`) + .withAmount('5000') + .build(); + + await billingPage.fillBillingForm({ ...billing1Data, caseName: selectedCase!.name }); + await billingPage.submitBillingForm(); + + await billingPage.navigateToBillingList(); + await billingPage.clickCreateBilling(); + await billingPage.selectCaseByTitle(testCaseData.title); + + // ACT + const billing2Data = new BillingDataBuilder() + .withCaseName(selectedCase!.name) + .withDescription(`Second invoice for case ${Date.now()}`) + .withAmount('7500') + .build(); + + await billingPage.fillBillingForm({ ...billing2Data, caseName: selectedCase!.name }); + await billingPage.submitBillingForm(); + + await billingPage.navigateToBillingList(); + + const found1 = await findInPaginatedTable(page, billing1Data.description); + expect(found1).toBeTruthy(); + + const row1 = await billingPage.findBillingRow(billing1Data.description); + const rowData1 = await billingPage.getBillingRowData(row1); + + await billingPage.navigateToBillingList(); + const found2 = await findInPaginatedTable(page, billing2Data.description); + expect(found2).toBeTruthy(); + + const row2 = await billingPage.findBillingRow(billing2Data.description); + const rowData2 = await billingPage.getBillingRowData(row2); + + // Assert + expect(rowData1.case).toBe(rowData2.case); + }); +}); + + +test.describe('Client Invoice Viewing', () => { + let sharedInvoiceDescription: string; + let testCaseData: CaseData; + let sharedBillingData: any; // Add this line + + + test.beforeAll(async ({ browser }) => { + let clientName: string = ''; + + // Step 1: Log in as client to get their name from profile + const clientContext = await browser.newContext({ storageState: 'playwright/.auth/client.json' }); + const clientPage = await clientContext.newPage(); + + try { + await clientPage.goto('/dashboard.php'); + + // Get client name directly from the dropdown toggle + const userDropdown = clientPage.locator('a.nav-link.dropdown-toggle[data-bs-toggle="dropdown"]').first(); + const dropdownText = await userDropdown.textContent(); + + if (dropdownText) { + // Extract just the name, removing the icon and extra whitespace + clientName = dropdownText.replace(/\s+/g, ' ').trim(); + // Remove any leading/trailing whitespace and get just the name part + const nameParts = clientName.split(' ').filter(part => part && !part.includes('bi-')); + clientName = nameParts.join(' ').trim(); + } + + if (!clientName) { + throw new Error('Could not extract client name from dropdown'); + } + } finally { + await clientContext.close(); + } + + // Step 2: Lawyer creates a case with the specific client and invoice + const lawyerContext = await browser.newContext({ storageState: 'playwright/.auth/lawyer.json' }); + const lawyerPage = await lawyerContext.newPage(); + + try { + // Create a case with the specific client + testCaseData = new CaseDataBuilder() + .withClient(clientName) // Use the client name we just retrieved + .build(); + + const casesPage = new CasesPage(lawyerPage); + await casesPage.navigateToCasesList(); + + if (!(await casesPage.caseExists(testCaseData.title))) { + await casesPage.navigateToCreateCase(); + await casesPage.fillCaseForm(testCaseData); + await casesPage.submitCaseForm(); + } + + // Create invoice for that case + await lawyerPage.goto('/dashboard.php'); + + const nav = new NavigationHelper(lawyerPage); + await nav.navigateToBilling(); + + const billingPage = new BillingPage(lawyerPage); + await billingPage.clickCreateBilling(); + + const selectedCase = await billingPage.selectCaseByTitle(testCaseData.title) || + await billingPage.selectFirstAvailableCase(); + + const billingData = new BillingDataBuilder() + .withCaseName(selectedCase!.name) + .withDescription(`Client viewable invoice ${Date.now()}`) + .build(); + + sharedInvoiceDescription = billingData.description; + sharedBillingData = await billingPage.fillBillingForm({ ...billingData, caseName: selectedCase!.name }); // Save submitted data + await billingPage.submitBillingForm(); + + } finally { + await lawyerContext.close(); + } + }); + + test.use({ storageState: 'playwright/.auth/client.json' }); + + test('client can view their invoices', async ({ page }) => { + test.skip(!sharedInvoiceDescription, 'Invoice prerequisite failed — skipping test'); + + await page.goto('/dashboard.php'); + await expect(page).toHaveURL(/dashboard/); + + const nav = new NavigationHelper(page); + await nav.navigateToBilling(); + + // Verify billing table is visible + const billingTable = page.locator('table'); + await expect(billingTable).toBeVisible(); + + // Verify the invoice created by lawyer is visible to client + const found = await findInPaginatedTable(page, sharedInvoiceDescription); + expect(found).toBeTruthy(); + + const billingPage = new BillingPage(page); + const billingRow = await billingPage.findBillingRow(sharedInvoiceDescription); + const rowData = await billingPage.getBillingRowDataAsClient(billingRow); + + // Assert Case Name + expect(rowData.case).toContain(testCaseData.title); + + // Assert Client + expect(rowData.client).toBeTruthy(); + + // Assert Amount + const displayedAmount = parseFloat(rowData.amount.replace(/[^\d.]/g, '')); + const submittedAmount = parseFloat(sharedBillingData.amount.replace(/[^\d.]/g, '')); + expect(displayedAmount).toBe(submittedAmount); // jus make it work + + // Assert Status + expect(rowData.status.toLowerCase()).toBe(sharedBillingData.status); + + // Assert Due Date + expect(rowData.dueDate).toBe(sharedBillingData.dueDate); + + // Assert Description + expect(rowData.description).toBe(sharedInvoiceDescription); + }); + +}); \ No newline at end of file diff --git a/tests/example.spec.ts b/tests/example.spec.ts index 6f6cc6f..df01c6f 100644 --- a/tests/example.spec.ts +++ b/tests/example.spec.ts @@ -11,8 +11,8 @@ test('register page', async ({ page }) => { await page.goto('/login.php'); // Click the get started link. - await page.getByRole('link', { name: 'Create an account' }).click(); + await page.getByRole('link', { name: 'Create Account' }).click(); // Expects page to have a button with the name of Create Account. - await expect(page.getByRole('button', { name: 'Register' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Create Account' })).toBeVisible(); }); \ No newline at end of file diff --git a/types/BillingData.ts b/types/BillingData.ts new file mode 100644 index 0000000..38beeb0 --- /dev/null +++ b/types/BillingData.ts @@ -0,0 +1,8 @@ + +export interface BillingData { + caseName: string; + amount: string; + description: string; + status?: 'unpaid' | 'paid' | 'overdue'; + dueDate?: string; +} \ No newline at end of file diff --git a/types/CaseData.ts b/types/CaseData.ts new file mode 100644 index 0000000..4c7f7de --- /dev/null +++ b/types/CaseData.ts @@ -0,0 +1,7 @@ +export interface CaseData { + client?: string; + lawyer?: string; + title: string; + description: string; + status?: 'open' | 'closed' | 'pending'; +}