- vendor-default-account now uses raw vendor default account (not client-specific override) - Account name is clientized via d-accounts/clientize only for single-client contexts - Added single-client-id helper that returns client ID only when user has exactly one client - Added multi-client e2e test verifying no pre-population across multiple clients - Updated test server to support multi-client mode switching via /test-set-client-mode - Test server now seeds a second client for multi-client scenarios
494 lines
18 KiB
TypeScript
494 lines
18 KiB
TypeScript
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 3 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');
|
|
});
|
|
});
|