import { test, expect } from '@playwright/test'; async function openEditModal(page: any, transactionIndex: number = 0) { // Navigate to transactions page await page.goto('/transaction2'); // Wait for the table to load await page.waitForSelector('table tbody tr'); // Find and click the edit button for the specified transaction const editButton = page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(transactionIndex); await editButton.click(); // Wait for the modal to open await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); await page.waitForSelector('#wizardmodal'); // The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure // the manual account coding form is active. await page.click('button:has-text("Manual")'); // Wait for the manual form to appear await page.waitForSelector('#account-grid-body'); } let testInfoCache: any = null; async function getTestInfo(page: any) { // Always fetch fresh to handle server restarts const response = await page.request.get('/test-info'); testInfoCache = await response.json(); return testInfoCache; } async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) { // The account search uses Solr which isn't available in tests. // Instead, we directly set the hidden input value via JavaScript. // Get all rows except the new-row, total, balance, and transaction total rows const allRows = page.locator('#account-grid-body tbody tr'); const rowCount = await allRows.count(); // Find the row that has a hidden input for account (actual account rows) 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*="transaction-account/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}`); } // Find the hidden input for the account const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first(); // Get account IDs from test-info endpoint const testInfo = await getTestInfo(page); const accountKey = accountName === 'Test' ? 'test-account' : 'second-account'; const accountId = testInfo.accounts[accountKey]; if (!accountId) { throw new Error(`Could not find account with name ${accountName}`); } // Set the hidden input value and trigger change // Also update Alpine.js data to prevent it from overwriting our value await hiddenInput.evaluate((el: HTMLInputElement, value: string) => { // Set the DOM value el.value = value; // Update Alpine.js component data const alpineEl = el.closest('[x-data]'); if (alpineEl && (alpineEl as any).__x) { (alpineEl as any).__x.$data.value.value = parseInt(value); (alpineEl as any).__x.$data.value.label = 'Selected Account'; } // Also update any parent Alpine model (accountId) const rowEl = el.closest('tr[x-data]'); if (rowEl && (rowEl as any).__x) { (rowEl as any).__x.$data.accountId = parseInt(value); } el.dispatchEvent(new Event('change', { bubbles: true })); }, accountId.toString()); // Wait for any HTMX updates await page.waitForTimeout(300); } async function findAccountRow(page: any, rowIndex: number) { const accountRows = page.locator('#account-grid-body tbody tr.account-row'); const rowCount = await accountRows.count(); if (rowIndex >= rowCount) { throw new Error(`Could not find account row at index ${rowIndex}. Only ${rowCount} account rows found.`); } return accountRows.nth(rowIndex); } async function setAccountAmount(page: any, rowIndex: number, amount: string) { const row = await findAccountRow(page, rowIndex); const amountInput = row.locator('.account-amount-field'); await amountInput.fill(amount); await amountInput.dispatchEvent('change'); await page.waitForTimeout(300); } async function addNewAccount(page: any) { // Click the "New account" button await page.click('text=New account'); // Wait for the new row to be added await page.waitForTimeout(500); } 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 getAccountLocation(page: any, rowIndex: number): Promise { const row = await findAccountRow(page, rowIndex); const locationSelect = row.locator('select[name*="location"]').first(); return await locationSelect.inputValue(); } async function removeAllAccounts(page: any) { const accountRows = page.locator('#account-grid-body tbody tr.account-row'); const rowCount = await accountRows.count(); for (let i = rowCount - 1; i >= 0; i--) { const row = accountRows.nth(i); const removeButton = row.locator('.account-remove-action'); await removeButton.click(); // Wait for the Alpine.js removal animation (500ms + buffer) await page.waitForTimeout(700); } } async function saveTransaction(page: any) { // Click the save button to submit the form via HTMX await page.locator('.wizard-save-action').click(); // Wait for the modal to close (longer timeout for parallel test load) await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'hidden', timeout: 20000 }); } async function toggleToPercentMode(page: any) { const percentRadio = page.locator('input[name="step-params[amount-mode]"][value="%"]'); await percentRadio.click(); // Wait for HTMX to swap the grid body await page.waitForResponse(response => response.url().includes('/toggle-amount-mode') && response.status() === 200 ); await page.waitForTimeout(200); } async function toggleToDollarMode(page: any) { const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]'); await dollarRadio.click(); // Wait for HTMX to swap the grid body await page.waitForResponse(response => response.url().includes('/toggle-amount-mode') && response.status() === 200 ); await page.waitForTimeout(200); } test.describe.configure({ mode: 'serial' }); test.describe('Transaction Edit Shared Location', () => { test('should spread Shared location to client locations on save and display correctly on reopen', async ({ page }) => { // Use the second transaction to avoid interfering with other tests const transactionIndex = 1; // Step 1: Open edit modal and add an account with Shared location await openEditModal(page, transactionIndex); // Remove any existing accounts from previous tests await removeAllAccounts(page); // Add a new account row await addNewAccount(page); // Select the account await selectAccountFromTypeahead(page, 0, 'Test'); // Set location to Shared await setAccountLocation(page, 0, 'Shared'); // Set amount to $200 (the full transaction amount for the second transaction) await setAccountAmount(page, 0, '200'); // Save the transaction await saveTransaction(page); // Step 2: Re-open and verify location is not "Shared" but the actual client location await openEditModal(page, transactionIndex); // Wait for accounts to load await page.waitForTimeout(500); // Get the location of the first account const location = await getAccountLocation(page, 0); // The location should be the actual client location ("DT" in test data), not "Shared" expect(location).not.toBe('Shared'); expect(location).toBe('DT'); }); }); test.describe('Transaction Edit Full Workflow', () => { test('should code transaction with vendor using percentage, then split 50/50, then switch to dollars', async ({ page }) => { // Step 1: Open edit modal and code with 100% to one account await openEditModal(page); // Switch to percentage mode first (this re-renders the grid from server state) await toggleToPercentMode(page); // Check if there's already an account from previous tests const allRows = page.locator('#account-grid-body tbody tr'); const hasExistingAccount = await allRows.locator('input[name*="transaction-account/account"]').count() > 0; if (!hasExistingAccount) { // Add a new account row if none exist await addNewAccount(page); } // Select the account await selectAccountFromTypeahead(page, 0, 'Test'); // Set amount to 100% await setAccountAmount(page, 0, '100'); // Save the transaction await saveTransaction(page); // Step 2: Re-open and split 50/50 with two accounts await openEditModal(page); // Note: amount-mode is UI-only state, so it resets to $ when re-opening // Switch back to percentage mode await toggleToPercentMode(page); // The existing account from step 1 should already be there // Change its amount from 100% to 50% await setAccountAmount(page, 0, '50'); // Add a second account at 50% await addNewAccount(page); await page.waitForTimeout(1000); await selectAccountFromTypeahead(page, 1, 'Second'); await setAccountAmount(page, 1, '50'); // Save await saveTransaction(page); // Step 3: Re-open and verify dollar amounts await openEditModal(page); // The accounts should be persisted from the previous save // Wait for accounts to load await page.waitForTimeout(500); // Verify we're in dollar mode (default) const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]'); await expect(dollarRadio).toBeChecked(); // Verify amounts are in dollars (converted from percentages on save) const row0 = await findAccountRow(page, 0); const row1 = await findAccountRow(page, 1); const amount0 = row0.locator('.account-amount-field'); const amount1 = row1.locator('.account-amount-field'); // Each should be $50.00 (or close to it) const val0 = await amount0.inputValue(); const val1 = await amount1.inputValue(); expect(parseFloat(val0)).toBeCloseTo(50.0, 1); expect(parseFloat(val1)).toBeCloseTo(50.0, 1); // Save await saveTransaction(page); }); }); test.describe('Transaction Edit Validation', () => { test('should show validation error when account totals do not match transaction amount', async ({ page }) => { // Use the third transaction to avoid interference from other tests await openEditModal(page, 2); // Stay in dollar mode (default) // Remove any existing accounts from previous tests await removeAllAccounts(page); await page.waitForTimeout(2000); // Add an account await addNewAccount(page); await selectAccountFromTypeahead(page, 0, 'Test'); // Set amount to $50 (which doesn't match the $300 transaction) await setAccountAmount(page, 0, '50'); // Try to save - this should fail because $50 != $300 // Click the save button to submit the form via HTMX await page.locator('.wizard-save-action').click(); // Wait for the response (longer timeout for parallel test load) await page.waitForTimeout(3000); // Modal should still be open (save failed) await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); // The form should still be present const form = page.locator('#wizard-form'); await expect(form).toBeVisible(); // Verify the account row is still there with our $50 value const amountInput = page.locator('.account-amount-field').first(); const value = await amountInput.inputValue(); expect(parseFloat(value)).toBeCloseTo(50.0, 1); // Verify the user-friendly error message is displayed const errorElement = page.locator('#form-errors .error-content'); await expect(errorElement).toBeVisible(); const errorText = await errorElement.textContent(); expect(errorText).toContain('The total of your expense accounts ($50.00) must equal the transaction amount ($300.00)'); }); }); async function openEditModalForTransaction(page: any, description: string) { // Navigate to transactions page await page.goto('/transaction2'); // Wait for the table to load await page.waitForSelector('table tbody tr'); // Find the row with the specific description and click its edit button const row = page.locator('table tbody tr', { hasText: description }).first(); const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first(); await editButton.click(); // Wait for the modal to open await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); await page.waitForSelector('#wizardmodal'); // Click Next to go to the links step (button says "Transaction Actions") await page.click('button:has-text("Transaction Actions")'); // Wait for the links step to load await page.waitForSelector('text=Transaction Actions', { state: 'visible' }); } async function selectVendorFromTypeahead(page: any, vendorName: string) { const testInfo = await getTestInfo(page); const vendorId = testInfo.accounts.vendor; if (!vendorId) { throw new Error(`Could not find vendor with name ${vendorName}`); } const vendorContainer = page.locator('div[hx-post*="edit-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()); await vendorContainer.evaluate((el: HTMLElement) => { el.dispatchEvent(new Event('change', { bubbles: true })); }); await page.waitForResponse(response => response.url().includes('/edit-vendor-changed') && response.status() === 200); await page.waitForTimeout(500); } test.describe('Transaction Edit Vendor Pre-population', () => { test('should start with no account rows when transaction has no accounts', async ({ page }) => { await openEditModal(page, 3); await page.click('button:has-text("Manual")'); await page.waitForSelector('#account-grid-body'); // Remove any existing accounts from previous tests await removeAllAccounts(page); await page.waitForTimeout(1000); const accountRows = page.locator('#account-grid-body tbody tr.account-row'); const rowCount = await accountRows.count(); expect(rowCount).toBe(0); }); test('should pre-populate default account when vendor is selected', async ({ page }) => { await openEditModal(page, 3); await page.click('button:has-text("Manual")'); await page.waitForSelector('#account-grid-body'); // Remove any existing accounts from previous tests await removeAllAccounts(page); await page.waitForTimeout(1000); const accountRows = page.locator('#account-grid-body tbody tr.account-row'); const initialRowCount = await accountRows.count(); expect(initialRowCount).toBe(0); await selectVendorFromTypeahead(page, 'Test Vendor'); const rowsAfterVendor = page.locator('#account-grid-body tbody tr.account-row'); const rowCountAfter = await rowsAfterVendor.count(); expect(rowCountAfter).toBe(1); const accountHidden = page.locator('input[type="hidden"][name*="transaction-account/account"]').first(); const accountValue = await accountHidden.inputValue(); const testInfo = await getTestInfo(page); expect(accountValue).toBe(testInfo.accounts['test-account'].toString()); const amountInput = page.locator('.account-amount-field').first(); const amountValue = await amountInput.inputValue(); expect(parseFloat(amountValue)).toBeCloseTo(400.0, 1); }); }); test.describe('Transaction Link Date Display', () => { test('should show payment date when linking to payment', async ({ page }) => { await openEditModalForTransaction(page, 'Transaction for payment link'); // Click on "Link to payment" tab await page.click('button:has-text("Link to payment")'); await page.waitForTimeout(500); // Verify the payment option shows the date await expect(page.locator('#payment-matches')).toContainText('Available Payments'); await expect(page.locator('#payment-matches')).toContainText('06/14/2023'); }); test('should show invoice date when linking to unpaid invoice', async ({ page }) => { await openEditModalForTransaction(page, 'Transaction for unpaid invoice link'); // Click on "Link to unpaid invoices" tab await page.click('button:has-text("Link to unpaid invoices")'); await page.waitForTimeout(500); // Verify the invoice option shows the date await expect(page.locator('text=Available Unpaid Invoices')).toBeVisible(); await expect(page.locator('text=UNPAID-001')).toBeVisible(); await expect(page.locator('text=07/19/2023')).toBeVisible(); }); });