import { test, expect } from '@playwright/test'; let testInfoCache: any = null; async function getTestInfo(page: any) { const response = await page.request.get('/test-info'); testInfoCache = await response.json(); return testInfoCache; } async function navigateToTransactions(page: any, clientMode: string = 'mine') { await page.setExtraHTTPHeaders({ 'x-clients': clientMode === 'all' ? '"all"' : '"mine"' }); await page.goto('/transaction2'); await page.waitForSelector('table tbody tr'); } async function selectTransactionByIndex(page: any, index: number) { const rows = page.locator('table tbody tr'); const row = rows.nth(index); const checkbox = row.locator('input[type="checkbox"][name="id"]').first(); await checkbox.click(); await page.waitForTimeout(200); } async function selectAllTransactions(page: any) { const headerCheckbox = page.locator('input#checkbox-all').first(); await headerCheckbox.click(); await page.waitForTimeout(200); } async function openBulkCodeModal(page: any) { const codeButton = page.locator('button:has-text("Code")').first(); await codeButton.click(); await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); await page.waitForSelector('#wizardmodal'); } async function closeBulkCodeModal(page: any) { // The success response swaps the modal content, but the modal holder stays open // Wait for the success message to appear await page.waitForSelector('text=Successfully coded', { timeout: 10000 }); } async function selectAccountFromTypeahead(page: any, rowIndex: number, accountKey: string) { const testInfo = await getTestInfo(page); const accountId = testInfo.accounts[accountKey]; if (!accountId) { throw new Error(`Could not find account with key ${accountKey}`); } const allRows = page.locator('#account-entries tbody tr'); const rowCount = await allRows.count(); let accountRow = null; let accountRowIndex = 0; for (let i = 0; i < rowCount; i++) { const row = allRows.nth(i); const hasAccountInput = await row.locator('input[name*="account"]').count() > 0; if (hasAccountInput) { if (accountRowIndex === rowIndex) { accountRow = row; break; } accountRowIndex++; } } if (!accountRow) { throw new Error(`Could not find account row at index ${rowIndex}`); } const hiddenInput = accountRow.locator('input[type="hidden"][name*="[account]"]').first(); await hiddenInput.evaluate((el: HTMLInputElement, value: string) => { // Replace the Alpine-managed hidden input with a plain one const newInput = document.createElement('input'); newInput.type = 'hidden'; newInput.name = el.name; newInput.value = value; el.parentNode.replaceChild(newInput, el); }, accountId.toString()); await page.waitForTimeout(300); // Trigger the location select reload by dispatching 'changed' event const locationContainer = accountRow.locator('[x-dispatch\\:changed]').first(); if (await locationContainer.count() > 0) { await locationContainer.evaluate((el: HTMLElement) => { el.dispatchEvent(new CustomEvent('changed', { bubbles: true })); }); await page.waitForTimeout(500); } } async function findAccountRow(page: any, rowIndex: number) { const allRows = page.locator('#account-entries tbody tr'); const rowCount = await allRows.count(); let accountRowIndex = 0; for (let i = 0; i < rowCount; i++) { const row = allRows.nth(i); const hasAccountInput = await row.locator('input[name*="account"]').count() > 0; if (hasAccountInput) { if (accountRowIndex === rowIndex) { return row; } accountRowIndex++; } } throw new Error(`Could not find account row at index ${rowIndex}`); } async function setAccountPercentage(page: any, rowIndex: number, percentage: string) { const row = await findAccountRow(page, rowIndex); const percentageInput = row.locator('input[name*="percentage"]').first(); await percentageInput.fill(percentage); await percentageInput.dispatchEvent('change'); await page.waitForTimeout(300); } async function setAccountLocation(page: any, rowIndex: number, location: string) { const row = await findAccountRow(page, rowIndex); const locationSelect = row.locator('select[name*="location"]').first(); // If the option doesn't exist, add it (for testing invalid locations) const optionExists = await locationSelect.locator(`option[value="${location}"]`).count() > 0; if (!optionExists) { await locationSelect.evaluate((el: HTMLSelectElement, value: string) => { const option = document.createElement('option'); option.value = value; option.textContent = value; el.appendChild(option); }, location); } await locationSelect.selectOption(location); await locationSelect.dispatchEvent('change'); await page.waitForTimeout(300); } async function addNewAccount(page: any) { const newAccountButton = page.locator('a:has-text("New account")').first(); await newAccountButton.click(); await page.waitForTimeout(500); } async function submitBulkCodeForm(page: any) { const form = page.locator('#wizard-form'); await form.evaluate((el: HTMLFormElement) => { el.dispatchEvent(new Event('submit', { bubbles: true })); }); } async function getModalErrorText(page: any) { const errorElement = page.locator('#form-errors .error-content'); try { await errorElement.waitFor({ state: 'visible', timeout: 3000 }); return await errorElement.textContent(); } catch { return null; } } test.describe.configure({ mode: 'serial' }); test.describe('Bulk Code Transactions - Happy Path', () => { test('should bulk code a single transaction with vendor, status, and 100% account', async ({ page }) => { await navigateToTransactions(page); await selectTransactionByIndex(page, 0); await openBulkCodeModal(page); // Verify modal shows correct count await expect(page.locator('text=Bulk editing 1 transactions')).toBeVisible(); // Select vendor const vendorHidden = page.locator('input[type="hidden"][name="step-params[vendor]"]').first(); const testInfo = await getTestInfo(page); await vendorHidden.evaluate((el: HTMLInputElement, value: string) => { const newInput = document.createElement('input'); newInput.type = 'hidden'; newInput.name = el.name; newInput.value = value; el.parentNode.replaceChild(newInput, el); }, testInfo.accounts.vendor.toString()); await page.waitForTimeout(300); // Select approval status const statusSelect = page.locator('select[name="step-params[approval-status]"]').first(); await statusSelect.selectOption('approved'); // Add account await addNewAccount(page); await selectAccountFromTypeahead(page, 0, 'test-account'); await setAccountLocation(page, 0, 'Shared'); await setAccountPercentage(page, 0, '100'); // Submit await submitBulkCodeForm(page); await closeBulkCodeModal(page); // Verify success by checking table refreshed await page.waitForSelector('table tbody tr'); }); test('should bulk code multiple selected transactions', async ({ page }) => { await navigateToTransactions(page); await selectTransactionByIndex(page, 0); await selectTransactionByIndex(page, 1); await openBulkCodeModal(page); // Should show count of selected transactions await expect(page.locator('text=Bulk editing 2 transactions')).toBeVisible(); // Add account at 100% await addNewAccount(page); await selectAccountFromTypeahead(page, 0, 'test-account'); await setAccountLocation(page, 0, 'Shared'); await setAccountPercentage(page, 0, '100'); await submitBulkCodeForm(page); await closeBulkCodeModal(page); await page.waitForSelector('table tbody tr'); }); test('should bulk code all visible transactions via header checkbox', async ({ page }) => { await navigateToTransactions(page); await selectAllTransactions(page); await openBulkCodeModal(page); // Should show all transactions await expect(page.locator('text=Bulk editing 5 transactions')).toBeVisible(); // Add account at 100% await addNewAccount(page); await selectAccountFromTypeahead(page, 0, 'test-account'); await setAccountLocation(page, 0, 'Shared'); await setAccountPercentage(page, 0, '100'); await submitBulkCodeForm(page); await closeBulkCodeModal(page); await page.waitForSelector('table tbody tr'); }); }); test.describe('Bulk Code Transactions - Validation', () => { test('should reject when no vendor, status, or accounts provided', async ({ page }) => { await navigateToTransactions(page); await selectTransactionByIndex(page, 0); await openBulkCodeModal(page); // Submit without any changes await submitBulkCodeForm(page); await page.waitForTimeout(1000); // Modal should still be open await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); }); test('should reject when account percentages total less than 100%', async ({ page }) => { await navigateToTransactions(page); await selectTransactionByIndex(page, 0); await openBulkCodeModal(page); await addNewAccount(page); await selectAccountFromTypeahead(page, 0, 'test-account'); await setAccountLocation(page, 0, 'Shared'); await setAccountPercentage(page, 0, '50'); await submitBulkCodeForm(page); await page.waitForTimeout(1000); // Modal should still be open await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); // Should show validation error const errorText = await getModalErrorText(page); expect(errorText).toContain('does not equal 100%'); }); test('should reject when account percentages total more than 100%', async ({ page }) => { await navigateToTransactions(page); await selectTransactionByIndex(page, 0); await openBulkCodeModal(page); await addNewAccount(page); await selectAccountFromTypeahead(page, 0, 'test-account'); await setAccountLocation(page, 0, 'Shared'); await setAccountPercentage(page, 0, '150'); await submitBulkCodeForm(page); await page.waitForTimeout(1000); // Modal should still be open await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); }); test('should reject invalid location for account with fixed location', async ({ page }) => { await navigateToTransactions(page); await selectTransactionByIndex(page, 0); await openBulkCodeModal(page); await addNewAccount(page); // Use the fixed-location account await selectAccountFromTypeahead(page, 0, 'fixed-location-account'); // Try to set wrong location (account is fixed to "DT", try "INVALID") await setAccountLocation(page, 0, 'INVALID'); await setAccountPercentage(page, 0, '100'); await submitBulkCodeForm(page); await page.waitForTimeout(1000); // Modal should still be open await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); // Should show validation error about location mismatch const errorText = await getModalErrorText(page); expect(errorText).toContain('location'); }); test('should reject location not belonging to client', async ({ page }) => { await navigateToTransactions(page); await selectTransactionByIndex(page, 0); await openBulkCodeModal(page); await addNewAccount(page); await selectAccountFromTypeahead(page, 0, 'test-account'); // Client only has "DT" location, try "GR" await setAccountLocation(page, 0, 'GR'); await setAccountPercentage(page, 0, '100'); await submitBulkCodeForm(page); await page.waitForTimeout(1000); // Modal should still be open await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); // Should show validation error const errorText = await getModalErrorText(page); expect(errorText).toContain('location'); }); }); test.describe('Bulk Code Transactions - Account Distribution', () => { test('should split 50/50 between two accounts', async ({ page }) => { await navigateToTransactions(page); await selectTransactionByIndex(page, 0); await openBulkCodeModal(page); // First account at 50% await addNewAccount(page); await selectAccountFromTypeahead(page, 0, 'test-account'); await setAccountLocation(page, 0, 'DT'); await setAccountPercentage(page, 0, '50'); // Second account at 50% await addNewAccount(page); await selectAccountFromTypeahead(page, 1, 'second-account'); await setAccountLocation(page, 1, 'DT'); await setAccountPercentage(page, 1, '50'); await submitBulkCodeForm(page); await closeBulkCodeModal(page); await page.waitForSelector('table tbody tr'); }); test('should allow Shared location for account without fixed location', async ({ page }) => { await navigateToTransactions(page); await selectTransactionByIndex(page, 0); await openBulkCodeModal(page); await addNewAccount(page); await selectAccountFromTypeahead(page, 0, 'test-account'); await setAccountLocation(page, 0, 'Shared'); await setAccountPercentage(page, 0, '100'); await submitBulkCodeForm(page); await closeBulkCodeModal(page); await page.waitForSelector('table tbody tr'); }); }); test.describe('Bulk Code Transactions - Vendor Pre-population', () => { test('should pre-populate default account when vendor is selected', async ({ page }) => { // Ensure single-client mode await page.request.get('/test-set-client-mode?mode=single-client'); await navigateToTransactions(page); await selectTransactionByIndex(page, 0); await openBulkCodeModal(page); // Select vendor (test vendor has default-account set to test-account) const testInfo = await getTestInfo(page); const vendorId = testInfo.accounts.vendor; // The vendor typeahead dispatches change from its parent div // We need to set the hidden input and dispatch change on the container const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first(); const vendorHidden = vendorContainer.locator('input[type="hidden"]').first(); await vendorHidden.evaluate((el: HTMLInputElement, value: string) => { const newInput = document.createElement('input'); newInput.type = 'hidden'; newInput.name = el.name; newInput.value = value; el.parentNode.replaceChild(newInput, el); }, vendorId.toString()); // Dispatch change on the container to trigger HTMX await vendorContainer.evaluate((el: HTMLElement) => { el.dispatchEvent(new Event('change', { bubbles: true })); }); // Wait for HTMX response await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200); await page.waitForTimeout(500); // Account should be pre-populated - check for account row const accountRows = page.locator('#account-entries tbody tr'); const rowCount = await accountRows.count(); // Should have at least 1 account row (the default account) plus the new-row button expect(rowCount).toBeGreaterThanOrEqual(2); // The account should have a hidden input with the test-account ID const accountHidden = page.locator('input[type="hidden"][name*="[account]"]').first(); const accountValue = await accountHidden.inputValue(); expect(accountValue).toBe(testInfo.accounts['test-account'].toString()); // Percentage should be 100 const percentageInput = page.locator('input[name*="percentage"]').first(); const percentageValue = await percentageInput.inputValue(); expect(percentageValue).toBe('100'); // Submit should succeed await submitBulkCodeForm(page); await closeBulkCodeModal(page); await page.waitForSelector('table tbody tr'); }); test('should NOT pre-populate default account when user has multiple clients', async ({ page }) => { // Switch to multi-client mode await page.request.get('/test-set-client-mode?mode=multi-client'); // Use 'all' to see all clients in the database await navigateToTransactions(page, 'all'); await selectTransactionByIndex(page, 0); await openBulkCodeModal(page); // Select vendor const testInfo = await getTestInfo(page); const vendorId = testInfo.accounts.vendor; const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first(); const vendorHidden = vendorContainer.locator('input[type="hidden"]').first(); await vendorHidden.evaluate((el: HTMLInputElement, value: string) => { const newInput = document.createElement('input'); newInput.type = 'hidden'; newInput.name = el.name; newInput.value = value; el.parentNode.replaceChild(newInput, el); }, vendorId.toString()); // Dispatch change on the container to trigger HTMX await vendorContainer.evaluate((el: HTMLElement) => { el.dispatchEvent(new Event('change', { bubbles: true })); }); // Wait for HTMX response await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200); await page.waitForTimeout(500); // Should NOT have pre-populated account rows - only the "New account" button row const accountRows = page.locator('#account-entries tbody tr'); const rowCount = await accountRows.count(); // With multi-client, no pre-population should happen, so only 1 row (the "New account" button) expect(rowCount).toBe(1); // Switch back to single-client mode for other tests await page.request.get('/test-set-client-mode?mode=single-client'); }); });