237 lines
9.3 KiB
JavaScript
237 lines
9.3 KiB
JavaScript
/**
|
|
* =============================================================================
|
|
* UAT_Partners_Billing -- Partners portal + firm-level billing
|
|
* =============================================================================
|
|
*
|
|
* Covers:
|
|
* PART-* : Partner portal (/partner/...) and partner-facing review routes
|
|
* BILL-* : Firm billing — invoices, payments, receipts, fee structures
|
|
* RBAC-* : Access control checks across both modules
|
|
* SEC-* : CSRF rejection and anonymous access probes
|
|
*
|
|
* Required .env additions:
|
|
* INVOICE_A_ID= # a seeded firm invoice id (status: draft)
|
|
* PAYMENT_A_ID= # a seeded payment id against INVOICE_A_ID
|
|
* PARTNER_TASK_A_ID= # a seeded service task assigned for partner review
|
|
*
|
|
* Tests skip gracefully when env vars are absent.
|
|
* =============================================================================
|
|
*/
|
|
|
|
const { test, expect } = require('@playwright/test');
|
|
require('dotenv').config();
|
|
const { BASE_URL, absoluteUrl } = require('../fixtures/url');
|
|
const { login } = require('../fixtures/auth');
|
|
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
|
|
const { expectBlockedOrSafe } = require('../fixtures/assertions');
|
|
|
|
async function safeGoto(page, route) {
|
|
const resp = await page.goto(route).catch(() => null);
|
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
return resp;
|
|
}
|
|
|
|
function idOr(envKey, fallback = '1') {
|
|
return process.env[envKey] || fallback;
|
|
}
|
|
|
|
function skipIfMissing(envKey) {
|
|
if (!process.env[envKey]) test.skip(true, `Set ${envKey} in .env after seeding`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Partners portal
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('PART: Partner portal', () => {
|
|
|
|
test('[V25-PART-001] PART-001 Partner dashboard loads', async ({ page }) => {
|
|
await login(page, 'Partner');
|
|
const resp = await safeGoto(page, '/partner/dashboard');
|
|
await expectNoBackendError(page);
|
|
expect(resp.status()).toBeLessThan(500);
|
|
});
|
|
|
|
test('[V25-PART-002] PART-002 Partner client list loads', async ({ page }) => {
|
|
await login(page, 'Partner');
|
|
await safeGoto(page, '/partner/clients');
|
|
await expectNoBackendError(page);
|
|
});
|
|
|
|
test('[V25-PART-003] PART-003 Partner reviews list loads', async ({ page }) => {
|
|
await login(page, 'Partner');
|
|
await safeGoto(page, '/partner/reviews');
|
|
await expectNoBackendError(page);
|
|
});
|
|
|
|
test('[V25-PART-004] PART-004 Partner task review page loads', async ({ page }) => {
|
|
skipIfMissing('PARTNER_TASK_A_ID');
|
|
await login(page, 'Partner');
|
|
await safeGoto(page, `/partner/tasks/${idOr('PARTNER_TASK_A_ID')}/review`);
|
|
await expectNoBackendError(page);
|
|
});
|
|
|
|
test('[V25-PART-005] PART-005 Partner task review CSRF-less POST is rejected', async ({ request }) => {
|
|
skipIfMissing('PARTNER_TASK_A_ID');
|
|
const resp = await request.post(
|
|
`${BASE_URL}/partner/tasks/${idOr('PARTNER_TASK_A_ID')}/review`,
|
|
{ form: { status: 'approved', csrf_token: '' } }
|
|
).catch(() => null);
|
|
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
|
|
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
|
|
});
|
|
|
|
test('[V25-PART-006] PART-006 Staff cannot access partner dashboard', async ({ page }) => {
|
|
await login(page, 'Staff');
|
|
const resp = await safeGoto(page, '/partner/dashboard');
|
|
await expectNoBackendError(page);
|
|
await expectBlockedOrSafe(page, resp);
|
|
});
|
|
|
|
test('[V25-PART-007] PART-007 Client cannot access partner reviews', async ({ page }) => {
|
|
await login(page, 'Client');
|
|
const resp = await safeGoto(page, '/partner/reviews');
|
|
await expectNoBackendError(page);
|
|
await expectBlockedOrSafe(page, resp);
|
|
});
|
|
|
|
test('[V25-PART-008] PART-008 Anonymous access to partner dashboard is blocked', async ({ page }) => {
|
|
const resp = await safeGoto(page, '/partner/dashboard');
|
|
await expectBlockedOrSafe(page, resp);
|
|
});
|
|
|
|
test('[V25-PART-009] PART-009 Non-existent task review ID returns safe response', async ({ page }) => {
|
|
await login(page, 'Partner');
|
|
const resp = await safeGoto(page, '/partner/tasks/999999999/review');
|
|
await expectNoBackendError(page);
|
|
await expectBlockedOrSafe(page, resp);
|
|
});
|
|
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Firm billing — invoices
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test.describe('BILL: Invoices and payments', () => {
|
|
|
|
test('[V25-BILL-001] BILL-001 Invoice list loads for Firm Admin', async ({ page }) => {
|
|
await login(page, 'Firm Admin');
|
|
await safeGoto(page, '/billing');
|
|
await expectNoBackendError(page);
|
|
});
|
|
|
|
test('[V25-BILL-002] BILL-002 Invoice create form loads', async ({ page }) => {
|
|
await login(page, 'Firm Admin');
|
|
await safeGoto(page, '/billing/invoices/new');
|
|
await expectNoBackendError(page);
|
|
});
|
|
|
|
test('[V25-BILL-003] BILL-003 Invoice detail page loads', async ({ page }) => {
|
|
skipIfMissing('INVOICE_A_ID');
|
|
await login(page, 'Firm Admin');
|
|
await safeGoto(page, `/billing/invoices/${idOr('INVOICE_A_ID')}`);
|
|
await expectNoBackendError(page);
|
|
});
|
|
|
|
test('[V25-BILL-004] BILL-004 Invoice print page loads', async ({ page }) => {
|
|
skipIfMissing('INVOICE_A_ID');
|
|
await login(page, 'Firm Admin');
|
|
await safeGoto(page, `/billing/invoices/${idOr('INVOICE_A_ID')}/print`);
|
|
await expectNoBackendError(page);
|
|
});
|
|
|
|
test('[V25-BILL-005] BILL-005 Invoice payments subpage loads', async ({ page }) => {
|
|
skipIfMissing('INVOICE_A_ID');
|
|
await login(page, 'Firm Admin');
|
|
await safeGoto(page, `/billing/invoices/${idOr('INVOICE_A_ID')}/payments`);
|
|
await expectNoBackendError(page);
|
|
});
|
|
|
|
test('[V25-BILL-006] BILL-006 Payment receipt loads', async ({ page }) => {
|
|
skipIfMissing('PAYMENT_A_ID');
|
|
await login(page, 'Firm Admin');
|
|
await safeGoto(page, `/billing/payments/${idOr('PAYMENT_A_ID')}/receipt`);
|
|
await expectNoBackendError(page);
|
|
});
|
|
|
|
test('[V25-BILL-007] BILL-007 Payments list loads', async ({ page }) => {
|
|
await login(page, 'Firm Admin');
|
|
await safeGoto(page, '/billing/payments');
|
|
await expectNoBackendError(page);
|
|
});
|
|
|
|
test('[V25-BILL-008] BILL-008 Fee structures list loads', async ({ page }) => {
|
|
await login(page, 'Firm Admin');
|
|
await safeGoto(page, '/billing/fee-structures/list');
|
|
await expectNoBackendError(page);
|
|
});
|
|
|
|
test('[V25-BILL-009] BILL-009 Invoice post CSRF-less POST is rejected', async ({ request }) => {
|
|
skipIfMissing('INVOICE_A_ID');
|
|
const resp = await request.post(
|
|
`${BASE_URL}/billing/${idOr('INVOICE_A_ID')}/issue`,
|
|
{ form: { csrf_token: '' } }
|
|
).catch(() => null);
|
|
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
|
|
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
|
|
});
|
|
|
|
test('[V25-BILL-010] BILL-010 New payment CSRF-less POST is rejected', async ({ request }) => {
|
|
skipIfMissing('INVOICE_A_ID');
|
|
const resp = await request.post(
|
|
`${BASE_URL}/billing/${idOr('INVOICE_A_ID')}/payments/new`,
|
|
{ form: { payment_date: '2026-06-27', amount_received: '1000', csrf_token: '' } }
|
|
).catch(() => null);
|
|
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
|
|
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
|
|
});
|
|
|
|
test('[V25-BILL-011] BILL-011 Staff cannot access invoice list', async ({ page }) => {
|
|
await login(page, 'Staff');
|
|
const resp = await safeGoto(page, '/billing');
|
|
await expectNoBackendError(page);
|
|
await expectBlockedOrSafe(page, resp);
|
|
});
|
|
|
|
test('[V25-BILL-012] BILL-012 Client cannot access billing admin', async ({ page }) => {
|
|
await login(page, 'Client');
|
|
const resp = await safeGoto(page, '/billing/invoices/new');
|
|
await expectNoBackendError(page);
|
|
await expectBlockedOrSafe(page, resp);
|
|
});
|
|
|
|
test('[V25-BILL-013] BILL-013 Anonymous access to billing is blocked', async ({ page }) => {
|
|
const resp = await safeGoto(page, '/billing');
|
|
await expectBlockedOrSafe(page, resp);
|
|
});
|
|
|
|
test('[V25-BILL-014] BILL-014 Non-existent invoice ID returns safe response', async ({ page }) => {
|
|
await login(page, 'Firm Admin');
|
|
const resp = await safeGoto(page, '/billing/invoices/999999999');
|
|
await expectNoBackendError(page);
|
|
await expectBlockedOrSafe(page, resp);
|
|
});
|
|
|
|
test('[V25-BILL-015] BILL-015 Invoice create with blank form shows validation, not 500', async ({ page }) => {
|
|
await login(page, 'Firm Admin');
|
|
await safeGoto(page, '/billing/invoices/new');
|
|
const submit = page.locator('button[type="submit"], input[type="submit"]').first();
|
|
if (await submit.count()) {
|
|
await submit.click().catch(() => {});
|
|
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
}
|
|
await expectNoBackendError(page);
|
|
});
|
|
|
|
test('[V25-BILL-016] BILL-016 Fee structure import template download works', async ({ page }) => {
|
|
await login(page, 'Firm Admin');
|
|
const [download] = await Promise.all([
|
|
page.waitForEvent('download'),
|
|
page.goto('/billing/fee-structures/template').catch(() => null),
|
|
]);
|
|
expect(await download.suggestedFilename()).toMatch(/fee.*structure|template|xlsx/i);
|
|
});
|
|
|
|
});
|