Files
integreat/e2e/transaction-edit.spec.ts
Bryce d0fad63e24 refactor(ssr): remove the EDN snapshot round-trip; transaction edit is a plain form (heuristic 2)
The wizard serialized the whole accumulating form state into a `snapshot` hidden field
(pr-str EDN + custom readers), decoded it every request, and merged step-params back in.
For this single-step modal the snapshot is pure redundancy: every value is either in the
entity or the live posted form. Remove it:

- render: EditWizard.render-wizard renders a plain form -- no snapshot / edit-path /
  current-step hidden fields; a single `db/id` hidden rides in the form instead.
- middleware: wrap-derive-state rebuilds :multi-form-state per request from the entity
  (loaded by the db/id hidden) overlaid with the live step-params, replacing
  wrap-init-multi-form-state + wrap-entity. The ~34 :snapshot reads are unchanged --
  :snapshot is now a derived map, not a round-tripped blob.
- editable fields (accounts, vendor, memo, approval, action, mode, amount-mode) come ONLY
  from the posted form (absent = cleared) so removing all account rows doesn't resurrect
  the entity's persisted accounts; only entity-only fields (db/id, client, amount, ...)
  come from the entity.
- delete the dead initial-edit-wizard-state and render-account-grid-body.
- e2e: make removeAllAccounts re-query each iteration (whole-form swaps stale a captured
  row index) and restore the percentage test to type-then-add ordering.

Scorecard: snapshot EDN round-trip + custom readers + merge-multi-form-state -> gone
(snapshot-field renders 0). Verified on a fresh server: full suite 38 pass / 1 unrelated
fail, swap 6/6, transaction-edit 8/8 -- same green as before, snapshot removed.
2026-06-03 15:20:26 -07:00

508 lines
20 KiB
TypeScript

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")');
// Transactions with 0-1 accounts open in "simple" mode, which has no account
// grid. Switch to "advanced" mode (a whole-form morph swap) so the grid the
// rest of these helpers manipulate is present.
const advancedLink = page.locator('a:has-text("Switch to advanced mode")');
if (await advancedLink.count()) {
await advancedLink.first().click();
}
// 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) {
// Account search is backed by Solr (unavailable in tests). Drive the typeahead the
// way a user does, using the Alpine v3 API: open the tippy dropdown, inject a result
// into the component's `elements`, then click it. This runs the real click handler,
// Alpine reactivity and the HTMX swap exactly as in production -- unlike poking the
// long-removed Alpine v2 `__x` internal, which silently no-ops on Alpine v3 and left
// the posted account empty.
const accountKey = accountName === 'Test' ? 'test-account' : 'second-account';
const label = `${accountName} Account`;
const testInfo = await getTestInfo(page);
const accountId = testInfo.accounts[accountKey];
if (!accountId) {
throw new Error(`Could not find account with name ${accountName}`);
}
const row = page.locator('#account-grid-body tbody tr.account-row').nth(rowIndex);
const typeahead = row.locator('div.relative[x-data]').first();
await typeahead.locator('a[x-ref="input"]').click();
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.waitFor({ state: 'visible' });
await search.fill('te');
await typeahead.evaluate((el: any, opt: { id: number; label: string }) => {
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
}, { id: accountId, label });
await page.locator('[data-tippy-root] a', { hasText: label }).first().click();
// Wait for the change-gated whole-form swap to settle.
await page.waitForTimeout(400);
}
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<string> {
const row = await findAccountRow(page, rowIndex);
const locationSelect = row.locator('select[name*="location"]').first();
return await locationSelect.inputValue();
}
async function removeAllAccounts(page: any) {
// Re-query each iteration: every remove is a whole-form swap that re-renders the rows,
// so a row index captured up front goes stale. Click the last remove button until none
// remain.
for (let guard = 0; guard < 20; guard++) {
const removeButtons = page.locator('#account-grid-body .account-remove-action');
if (await removeButtons.count() === 0) break;
await removeButtons.last().click();
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('/edit-form-changed') && 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('/edit-form-changed') && 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('splits a transaction 50/50 in percentage mode and stores it as dollars', async ({ page }) => {
// Transaction 0 is $100. Code it 50% / 50% across two accounts in percentage mode and
// verify the save-time %->$ conversion stores/displays $50 + $50 on reopen.
//
// This intentionally types a percentage and THEN adds another row -- a whole-form
// operation. The operation handlers now rebuild from the live posted form, not the
// stale snapshot, so the first row's typed 50% survives (it used to revert, yielding a
// 66.67/33.33 split).
await openEditModal(page, 0);
await removeAllAccounts(page);
await toggleToPercentMode(page);
await addNewAccount(page);
await selectAccountFromTypeahead(page, 0, 'Test');
await setAccountAmount(page, 0, '50');
await addNewAccount(page);
await selectAccountFromTypeahead(page, 1, 'Second');
await setAccountAmount(page, 1, '50');
await saveTransaction(page);
// Reopen: dollar mode is the default, and each account is the converted $50.
await openEditModal(page, 0);
await page.waitForTimeout(500);
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
await expect(dollarRadio).toBeChecked();
const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
const val1 = await (await findAccountRow(page, 1)).locator('.account-amount-field').inputValue();
expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
expect(parseFloat(val1)).toBeCloseTo(50.0, 1);
});
});
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. The modal is single-page now (no multi-step wizard
// navigation), so the action tabs -- including "Link to payment" -- are available
// immediately; callers click the tab they need.
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal');
}
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-vals*="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-form-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());
// The populated account amount should equal this transaction's amount (the vendor
// default fills the single row with the whole amount). Read the actual amount from
// the grid's transaction-total row rather than hard-coding it -- table row order is
// not pinned across same-date seed transactions.
const txTotalText = await page.locator('.account-grand-total-row').innerText();
const txTotal = parseFloat(txTotalText.replace(/[^0-9.]/g, ''));
expect(txTotal).toBeGreaterThan(0);
const amountInput = page.locator('.account-amount-field').first();
const amountValue = await amountInput.inputValue();
expect(parseFloat(amountValue)).toBeCloseTo(txTotal, 1);
});
});
// Drives the *real* vendor typeahead the way a user does: open the dropdown,
// click a rendered result. The vendor search is backed by Solr (unavailable in
// tests), so the result option is injected into the typeahead's Alpine
// `elements` instead of being fetched. Everything else -- the dropdown's own
// search input firing a native `change` on blur, the `value = element` click
// handler, the Alpine reactivity, and the HTMX round-trip to
// `edit-form-changed` (op=vendor-changed) -- runs exactly as in production. This is the flow that
// regressed: a stale native `change` from the search input used to win the race
// and revert the vendor to its previous value.
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
const wrapper = page.locator('div[hx-vals*="vendor-changed"]').first();
const typeahead = wrapper.locator('div.relative[x-data]').first();
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
await typeahead.locator('a[x-ref="input"]').click();
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.waitFor({ state: 'visible' });
// Type under the 3-char search threshold so no Solr request fires and clears
// our injected option, while still dirtying the input so it fires a native
// `change` on blur -- the event that used to clobber the selection.
await search.fill('te');
// Inject a clickable result into the typeahead's Alpine state.
await typeahead.evaluate(
(el: HTMLElement, opt: { id: number; label: string }) => {
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
},
{ id: vendorId, label: vendorName }
);
// Click the rendered option: fires the search input's native change (stale
// value) AND the synthetic change carrying the new value, then HTMX swaps.
await page.locator('[data-tippy-root] a', { hasText: vendorName }).first().click();
await page.waitForResponse(
(response: any) =>
response.url().includes('/edit-form-changed') && response.status() === 200
);
await page.waitForTimeout(500);
}
// Opens the edit modal and activates the Manual tab, waiting on the vendor
// typeahead rather than the account grid (which only exists in advanced mode).
async function openManualVendorSection(page: any, transactionIndex: number) {
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
const editButton = page
.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]')
.nth(transactionIndex);
await editButton.click();
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal');
await page.click('button:has-text("Manual")');
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
}
test.describe('Transaction Edit Vendor Selection', () => {
test('selecting a vendor from the dropdown updates the displayed vendor', async ({ page }) => {
await openManualVendorSection(page, 3);
const testInfo = await getTestInfo(page);
const vendorId: number = testInfo.accounts.vendor;
await selectVendorViaDropdown(page, vendorId, 'Test Vendor');
// The displayed vendor label must reflect the selection after the HTMX
// round-trip. Before the fix this reverted to blank because a stale
// `change` event submitted the previous vendor and its response won.
const label = page
.locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]')
.first();
await expect(label).toHaveText('Test Vendor');
// The server-rendered hidden input must carry the newly selected vendor id.
const hidden = page
.locator(
'div[hx-vals*="vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
)
.first();
await expect(hidden).toHaveValue(vendorId.toString());
});
});
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();
});
});