diff --git a/e2e/invoice-bulk-edit.spec.ts b/e2e/invoice-bulk-edit.spec.ts new file mode 100644 index 00000000..444cc2f3 --- /dev/null +++ b/e2e/invoice-bulk-edit.spec.ts @@ -0,0 +1,128 @@ +import { test, expect } from '@playwright/test'; + +// Characterization spec for the Invoice Bulk Edit modal. Captures CURRENT +// (pre-migration) behavior so the wizard -> plain-Selmer migration can be proven +// behavior-preserving. Reset the shared dataset before each test for isolation. +test.beforeEach(async ({ request }) => { + await request.post('/test-reset'); +}); + +async function getTestInfo(page: any) { + return (await page.request.get('/test-info')).json(); +} + +async function navigateToInvoices(page: any) { + await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' }); + await page.goto('/invoice'); + await page.waitForSelector('#entity-table tbody tr'); +} + +async function selectFirstInvoice(page: any) { + await page.locator('#entity-table tbody input[type="checkbox"]').first().click(); + await page.waitForTimeout(200); +} + +async function openBulkEditModal(page: any) { + await page.locator('button:has-text("Bulk Edit")').first().click(); + await page.waitForSelector('#wizardmodal'); +} + +async function addNewAccount(page: any) { + await page.locator('a:has-text("New account")').first().click(); + await page.waitForTimeout(500); +} + +// Set an account on a row by replacing its Alpine-managed hidden input with a plain +// one (Solr-backed typeahead is unavailable in tests), then dispatching the location +// reload -- the same approach the bulk-code spec uses. +async function setRowAccount(page: any, rowIndex: number, accountId: string) { + const rows = page.locator('#wizard-form tbody tr'); + const row = rows.nth(rowIndex); + const hidden = row.locator('input[type="hidden"][name*="[account]"]').first(); + await hidden.evaluate((el: HTMLInputElement, value: string) => { + const n = document.createElement('input'); + n.type = 'hidden'; n.name = el.name; n.value = value; + el.parentNode!.replaceChild(n, el); + }, accountId); + await page.waitForTimeout(200); + const loc = row.locator('[x-dispatch\\:changed]').first(); + if (await loc.count() > 0) { + await loc.evaluate((el: HTMLElement) => el.dispatchEvent(new CustomEvent('changed', { bubbles: true }))); + await page.waitForTimeout(400); + } +} + +async function setRowPercentage(page: any, rowIndex: number, pct: string) { + const row = page.locator('#wizard-form tbody tr').nth(rowIndex); + const input = row.locator('input.amount-field, input[name*="percentage"]').first(); + await input.fill(pct); + await input.dispatchEvent('change'); + await page.waitForTimeout(200); +} + +async function submitForm(page: any) { + await page.locator('#wizard-form').evaluate((f: HTMLFormElement) => + f.dispatchEvent(new Event('submit', { bubbles: true }))); +} + +test.describe.configure({ mode: 'serial' }); + +test.describe('Invoice Bulk Edit (characterization)', () => { + test('opens the modal with the expense-account grid', async ({ page }) => { + await navigateToInvoices(page); + await selectFirstInvoice(page); + await openBulkEditModal(page); + + const modal = page.locator('#wizardmodal'); + await expect(modal).toContainText('Bulk editing 1 invoices'); + await expect(modal).toContainText('Account'); + await expect(modal).toContainText('Location'); + await expect(modal).toContainText('TOTAL'); + await expect(modal).toContainText('BALANCE'); + // a default expense-account row is present, plus the New account button + expect(await modal.locator('input[name*="[expense-accounts]"][name*="[account]"]').count()).toBeGreaterThanOrEqual(1); + await expect(modal.locator('a:has-text("New account")')).toBeVisible(); + }); + + test('New account adds an expense-account row', async ({ page }) => { + await navigateToInvoices(page); + await selectFirstInvoice(page); + await openBulkEditModal(page); + + const accountRows = () => page.locator('#wizard-form input[name*="[expense-accounts]"][name*="[account]"]'); + const before = await accountRows().count(); + await addNewAccount(page); + expect(await accountRows().count()).toBe(before + 1); + }); + + test('saving a 100% account coding closes the modal', async ({ page }) => { + const info = await getTestInfo(page); + await navigateToInvoices(page); + await selectFirstInvoice(page); + await openBulkEditModal(page); + + // the default row is at 100% already; set its account and save + await setRowAccount(page, 0, info.accounts['test-account'].toString()); + await setRowPercentage(page, 0, '100'); + await submitForm(page); + + // a successful save fires modalclose -> the modal closes + await expect(page.locator('#wizardmodal')).toBeHidden({ timeout: 8000 }); + }); + + test('rejects when account percentages do not total 100%', async ({ page }) => { + const info = await getTestInfo(page); + await navigateToInvoices(page); + await selectFirstInvoice(page); + await openBulkEditModal(page); + + await setRowAccount(page, 0, info.accounts['test-account'].toString()); + await setRowPercentage(page, 0, '50'); + await submitForm(page); + await page.waitForTimeout(1000); + + // modal stays open on validation failure + await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); + await expect(page.locator('#wizardmodal')).toContainText('does not equal 100%'); + }); +});