mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
942 lines
37 KiB
TypeScript
942 lines
37 KiB
TypeScript
// TODO: Edit Billable Rate
|
|
// TODO: Resend Email Invitation
|
|
// TODO: Remove Invitation
|
|
import { expect, test } from '../playwright/fixtures';
|
|
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
|
import type { Page } from '@playwright/test';
|
|
import { inviteAndAcceptMember } from './utils/members';
|
|
import {
|
|
createPlaceholderMemberViaImportApi,
|
|
getMembersViaApi,
|
|
updateMemberBillableRateViaApi,
|
|
updateOrganizationSettingViaApi,
|
|
} from './utils/api';
|
|
import { getTableRowNames } from './utils/table';
|
|
|
|
// Tests that invite + accept members need more time
|
|
test.describe.configure({ timeout: 45000 });
|
|
|
|
async function goToMembersPage(page: Page) {
|
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
|
}
|
|
|
|
async function openInviteMemberModal(page: Page) {
|
|
await Promise.all([
|
|
page.getByRole('button', { name: 'Invite Member' }).click(),
|
|
expect(page.getByPlaceholder('Member Email')).toBeVisible(),
|
|
]);
|
|
}
|
|
|
|
test('test that new manager can be invited and accepted', async ({ page, browser }) => {
|
|
const memberId = Math.round(Math.random() * 100000);
|
|
const memberEmail = `manager+${memberId}@invite.test`;
|
|
|
|
await inviteAndAcceptMember(page, browser, 'Invited Mgr', memberEmail, 'Manager');
|
|
|
|
// Verify the member appears in the members table with the correct role
|
|
await goToMembersPage(page);
|
|
const memberRow = page.getByRole('row').filter({ hasText: 'Invited Mgr' });
|
|
await expect(memberRow).toBeVisible();
|
|
await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('test that new employee can be invited and accepted', async ({ page, browser }) => {
|
|
const memberId = Math.round(Math.random() * 100000);
|
|
const memberEmail = `employee+${memberId}@invite.test`;
|
|
|
|
await inviteAndAcceptMember(page, browser, 'Invited Emp', memberEmail, 'Employee');
|
|
|
|
// Verify the member appears in the members table with the correct role
|
|
await goToMembersPage(page);
|
|
const memberRow = page.getByRole('row').filter({ hasText: 'Invited Emp' });
|
|
await expect(memberRow).toBeVisible();
|
|
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('test that new admin can be invited and accepted', async ({ page, browser }) => {
|
|
const memberId = Math.round(Math.random() * 100000);
|
|
const memberEmail = `admin+${memberId}@invite.test`;
|
|
|
|
await inviteAndAcceptMember(page, browser, 'Invited Adm', memberEmail, 'Administrator');
|
|
|
|
// Verify the member appears in the members table with the correct role
|
|
await goToMembersPage(page);
|
|
const memberRow = page.getByRole('row').filter({ hasText: 'Invited Adm' });
|
|
await expect(memberRow).toBeVisible();
|
|
await expect(memberRow.getByText('Admin', { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('test that error shows if no role is selected', async ({ page }) => {
|
|
await goToMembersPage(page);
|
|
await openInviteMemberModal(page);
|
|
const noRoleId = Math.round(Math.random() * 10000);
|
|
|
|
await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`);
|
|
await Promise.all([
|
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
|
expect(page.getByText('Please select a role')).toBeVisible(),
|
|
]);
|
|
});
|
|
|
|
test('test that organization billable rate can be updated with all existing time entries', async ({
|
|
page,
|
|
}) => {
|
|
await goToMembersPage(page);
|
|
const newBillableRate = Math.round(Math.random() * 10000);
|
|
await page.getByRole('row').first().getByRole('button').click();
|
|
await page.getByRole('menuitem').getByText('Edit').click();
|
|
await page.getByRole('combobox').last().click();
|
|
await page.getByRole('option', { name: 'Custom Rate' }).click();
|
|
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
|
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
|
|
|
await Promise.all([
|
|
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
|
|
page.waitForRequest(
|
|
async (request) =>
|
|
request.url().includes('/members/') &&
|
|
request.method() === 'PUT' &&
|
|
request.postDataJSON().billable_rate === newBillableRate * 100
|
|
),
|
|
page.waitForResponse(
|
|
async (response) =>
|
|
response.url().includes('/organizations/') &&
|
|
response.request().method() === 'PUT' &&
|
|
response.status() === 200 &&
|
|
(await response.json()).data.billable_rate === newBillableRate * 100
|
|
),
|
|
]);
|
|
});
|
|
|
|
test('test that switching member billable rate from custom back to default rate works', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
// Set a known org billable rate
|
|
await updateOrganizationSettingViaApi(ctx, { billable_rate: 12000 });
|
|
|
|
// Create a placeholder member with a custom billable rate
|
|
await createPlaceholderMemberViaImportApi(ctx, 'CustomToDefault Member');
|
|
const members = await getMembersViaApi(ctx);
|
|
const member = members.find((m) => m.name === 'CustomToDefault Member');
|
|
expect(member).toBeDefined();
|
|
await updateMemberBillableRateViaApi(ctx, member!.id, 25000);
|
|
|
|
await goToMembersPage(page);
|
|
const memberRow = page.getByRole('row').filter({ hasText: 'CustomToDefault Member' });
|
|
await expect(memberRow).toBeVisible();
|
|
|
|
// Open edit modal
|
|
await memberRow.getByRole('button').click();
|
|
await page.getByRole('menuitem').getByText('Edit').click();
|
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
|
|
|
// Verify it starts on Custom Rate
|
|
const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();
|
|
await expect(billableCombobox).toContainText('Custom Rate');
|
|
|
|
// Switch to Default Rate
|
|
await billableCombobox.click();
|
|
await page.getByRole('option', { name: 'Default Rate' }).click();
|
|
await expect(billableCombobox).toContainText('Default Rate');
|
|
|
|
// Verify the billable rate input is disabled
|
|
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
|
|
|
|
// Submit — billable_rate changes from 25000 to null, so confirmation dialog appears
|
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
|
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
|
|
await expect(page.getByText('the default rate of the organization')).toBeVisible();
|
|
|
|
// Confirm the update
|
|
await Promise.all([
|
|
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
|
|
page.waitForRequest(
|
|
(request) =>
|
|
request.url().includes('/members/') &&
|
|
request.method() === 'PUT' &&
|
|
request.postDataJSON().billable_rate === null
|
|
),
|
|
]);
|
|
|
|
// Verify both dialogs are closed
|
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
|
});
|
|
|
|
test('test that default rate shows disabled input with organization billable rate', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
// Set a known org billable rate (150.00)
|
|
await updateOrganizationSettingViaApi(ctx, { billable_rate: 15000 });
|
|
|
|
await goToMembersPage(page);
|
|
|
|
// Open edit modal for the owner (who uses default rate by default)
|
|
await page.getByRole('row').first().getByRole('button').click();
|
|
await page.getByRole('menuitem').getByText('Edit').click();
|
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
|
|
|
// Verify it's on Default Rate
|
|
const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();
|
|
await expect(billableCombobox).toContainText('Default Rate');
|
|
|
|
// Verify the input is disabled and shows the org rate (formatted with currency)
|
|
const billableInput = page.getByPlaceholder('Billable Rate');
|
|
await expect(billableInput).toBeDisabled();
|
|
await expect(billableInput).toHaveAttribute('aria-valuenow', '150');
|
|
|
|
// Close the dialog
|
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
|
});
|
|
|
|
test('test that cancelling the billable rate confirmation dialog does not update the member', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
// Create a placeholder member with a custom billable rate
|
|
await createPlaceholderMemberViaImportApi(ctx, 'CancelConfirm Member');
|
|
const members = await getMembersViaApi(ctx);
|
|
const member = members.find((m) => m.name === 'CancelConfirm Member');
|
|
expect(member).toBeDefined();
|
|
await updateMemberBillableRateViaApi(ctx, member!.id, 10000);
|
|
|
|
await goToMembersPage(page);
|
|
const memberRow = page.getByRole('row').filter({ hasText: 'CancelConfirm Member' });
|
|
await expect(memberRow).toBeVisible();
|
|
|
|
// Open edit modal
|
|
await memberRow.getByRole('button').click();
|
|
await page.getByRole('menuitem').getByText('Edit').click();
|
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
|
|
|
// Change the billable rate
|
|
await page.getByPlaceholder('Billable Rate').fill('200');
|
|
|
|
// Click Update Member — confirmation dialog should appear
|
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
|
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
|
|
|
|
// Set up listener to verify no PUT request is sent after cancel
|
|
let putRequestSent = false;
|
|
page.on('request', (request) => {
|
|
if (request.url().includes('/members/') && request.method() === 'PUT') {
|
|
putRequestSent = true;
|
|
}
|
|
});
|
|
|
|
// Click Cancel on the confirmation dialog
|
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
|
|
|
// Verify confirmation dialog is closed
|
|
await expect(
|
|
page.getByRole('heading', { name: 'Update Member Billable Rate' })
|
|
).not.toBeVisible();
|
|
|
|
// Verify no API call was made
|
|
expect(putRequestSent).toBe(false);
|
|
});
|
|
|
|
test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => {
|
|
const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000);
|
|
|
|
// Create a placeholder member via import
|
|
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
|
|
|
|
// Go to members page and verify placeholder exists with role "Placeholder"
|
|
await goToMembersPage(page);
|
|
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
|
|
await expect(memberRow).toBeVisible();
|
|
await expect(memberRow.getByText('Placeholder', { exact: true })).toBeVisible();
|
|
|
|
// Open the edit modal for the placeholder member
|
|
await memberRow.getByRole('button').click();
|
|
await page.getByRole('menuitem').getByText('Edit').click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
|
|
|
// Change role to Employee
|
|
const roleSelect = page.getByRole('dialog').getByRole('combobox').first();
|
|
await roleSelect.click();
|
|
await expect(page.getByRole('option', { name: 'Employee' })).toBeVisible();
|
|
await page.getByRole('option', { name: 'Employee' }).click();
|
|
await expect(roleSelect).toContainText('Employee');
|
|
|
|
// Submit the change - the API should reject it with 400
|
|
await Promise.all([
|
|
page.getByRole('button', { name: 'Update Member' }).click(),
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/members/') &&
|
|
response.request().method() === 'PUT' &&
|
|
response.status() === 400
|
|
),
|
|
]);
|
|
|
|
// Verify error notification is shown
|
|
await expect(page.getByText('Failed to update member')).toBeVisible();
|
|
});
|
|
|
|
test('test that changing member role updates the role in the member table', async ({
|
|
page,
|
|
browser,
|
|
}) => {
|
|
const memberId = Math.floor(Math.random() * 100000);
|
|
const memberEmail = `member+${memberId}@rolechange.test`;
|
|
|
|
// Invite and accept a new Employee member
|
|
await inviteAndAcceptMember(page, browser, 'Jane Smith', memberEmail, 'Employee');
|
|
|
|
// Verify the new member appears with the Employee role
|
|
await goToMembersPage(page);
|
|
const memberRow = page.getByRole('row').filter({ hasText: 'Jane Smith' });
|
|
await expect(memberRow).toBeVisible();
|
|
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
|
|
|
|
// Open the edit modal
|
|
await memberRow.getByRole('button').click();
|
|
await page.getByRole('menuitem').getByText('Edit').click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
|
|
|
// Change role to Manager
|
|
const roleSelect = page.getByRole('dialog').getByRole('combobox').first();
|
|
await roleSelect.click();
|
|
await expect(page.getByRole('option', { name: 'Manager' })).toBeVisible();
|
|
await page.getByRole('option', { name: 'Manager' }).click();
|
|
await expect(roleSelect).toContainText('Manager');
|
|
|
|
// Submit the change and verify the API call succeeds
|
|
await Promise.all([
|
|
page.getByRole('button', { name: 'Update Member' }).click(),
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/members/') &&
|
|
response.request().method() === 'PUT' &&
|
|
response.status() === 200
|
|
),
|
|
]);
|
|
|
|
// Verify dialog closed
|
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
|
|
|
// Verify the role updated in the table
|
|
await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('test that merging a placeholder member works', async ({ page, ctx }) => {
|
|
const placeholderName = 'Merge Target ' + Math.floor(Math.random() * 10000);
|
|
|
|
// Create a placeholder member via import
|
|
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
|
|
|
|
// Go to members page
|
|
await goToMembersPage(page);
|
|
await expect(page.getByText(placeholderName)).toBeVisible();
|
|
|
|
// Find the placeholder member row and open actions menu
|
|
const placeholderRow = page.getByRole('row').filter({ hasText: placeholderName });
|
|
await placeholderRow.getByRole('button').click();
|
|
|
|
// Click Merge
|
|
await page.getByTestId('member_merge').click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible();
|
|
|
|
// Select the current user (the owner) as merge target via MemberCombobox
|
|
// The MemberCombobox renders a Button as trigger; clicking it opens the popover with the combobox input
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click();
|
|
|
|
// Wait for dropdown options to load
|
|
const firstOption = page.getByRole('option').first();
|
|
await expect(firstOption).toBeVisible({ timeout: 10000 });
|
|
await firstOption.click();
|
|
|
|
// Submit merge
|
|
await Promise.all([
|
|
page.getByRole('button', { name: 'Merge Member' }).click(),
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/member/') &&
|
|
response.url().includes('/merge-into') &&
|
|
response.ok()
|
|
),
|
|
]);
|
|
|
|
// Wait for merge dialog to close after successful merge
|
|
await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible();
|
|
|
|
// Verify placeholder member is no longer in the members table
|
|
await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible();
|
|
});
|
|
|
|
test('test that deleting a placeholder member works', async ({ page, ctx }) => {
|
|
const placeholderName = 'Delete Target ' + Math.floor(Math.random() * 10000);
|
|
|
|
// Create a placeholder member via import
|
|
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
|
|
|
|
// Go to members page
|
|
await goToMembersPage(page);
|
|
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
|
|
await expect(memberRow).toBeVisible();
|
|
|
|
// Open actions menu and click Delete
|
|
await memberRow.getByRole('button').click();
|
|
await page.getByRole('menuitem').getByText('Delete').click();
|
|
|
|
// Verify delete modal is shown
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible();
|
|
|
|
// Try to delete without checking the confirmation checkbox
|
|
await page.getByRole('button', { name: 'Delete Member' }).click();
|
|
|
|
// Should show validation error
|
|
await expect(
|
|
page.getByText('You must confirm that you understand the consequences of this action')
|
|
).toBeVisible();
|
|
|
|
// Check the confirmation checkbox
|
|
await page.getByRole('checkbox').click();
|
|
|
|
// Click Delete Member button and wait for API response
|
|
await Promise.all([
|
|
page.getByRole('button', { name: 'Delete Member' }).click(),
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/members/') &&
|
|
response.request().method() === 'DELETE' &&
|
|
response.ok()
|
|
),
|
|
]);
|
|
|
|
// Verify modal is closed
|
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
|
|
|
// Verify member is removed from the table
|
|
await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible();
|
|
});
|
|
|
|
test('test that member delete modal can be cancelled', async ({ page, ctx }) => {
|
|
const placeholderName = 'Delete Cancel ' + Math.floor(Math.random() * 10000);
|
|
|
|
// Create a placeholder member via import
|
|
await createPlaceholderMemberViaImportApi(ctx, placeholderName);
|
|
|
|
// Go to members page
|
|
await goToMembersPage(page);
|
|
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
|
|
await expect(memberRow).toBeVisible();
|
|
|
|
// Open actions menu and click Delete
|
|
await memberRow.getByRole('button').click();
|
|
await page.getByRole('menuitem').getByText('Delete').click();
|
|
|
|
// Verify delete modal is shown
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
|
|
// Set up listener to verify no DELETE request is sent
|
|
let deleteRequestSent = false;
|
|
page.on('request', (request) => {
|
|
if (request.url().includes('/members/') && request.method() === 'DELETE') {
|
|
deleteRequestSent = true;
|
|
}
|
|
});
|
|
|
|
// Click Cancel
|
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
|
|
|
// Verify modal is closed
|
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
|
|
|
// Verify member is still in the table
|
|
await expect(memberRow).toBeVisible();
|
|
|
|
// Verify no DELETE request was sent
|
|
expect(deleteRequestSent).toBe(false);
|
|
});
|
|
|
|
test('test that organization owner cannot be deleted', async ({ page }) => {
|
|
await goToMembersPage(page);
|
|
|
|
// Find the owner row (John Doe with Owner role)
|
|
const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' });
|
|
await expect(ownerRow).toBeVisible();
|
|
|
|
// Open the actions menu for the owner
|
|
await ownerRow.getByRole('button').click();
|
|
|
|
// Click Delete
|
|
await page.getByRole('menuitem').getByText('Delete').click();
|
|
|
|
// Verify delete modal is shown
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
|
|
// Check the confirmation checkbox
|
|
await page.getByRole('checkbox').click();
|
|
|
|
// Try to delete - should fail with 400 error
|
|
const responsePromise = page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/members/') && response.request().method() === 'DELETE'
|
|
);
|
|
await page.getByRole('button', { name: 'Delete Member' }).click();
|
|
const response = await responsePromise;
|
|
|
|
// Verify the API returned an error status
|
|
expect(response.status()).toBe(400);
|
|
|
|
// Close the modal by pressing Escape
|
|
await page.keyboard.press('Escape');
|
|
|
|
// Refresh and verify the owner is still there
|
|
await goToMembersPage(page);
|
|
await expect(page.getByRole('row').filter({ hasText: 'Owner' })).toBeVisible();
|
|
});
|
|
|
|
// =============================================
|
|
// Context Menu Tests
|
|
// =============================================
|
|
|
|
test('test that member context menu edit updates the member billable rate', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
const memberName = 'CtxEditMember ' + Math.floor(1 + Math.random() * 10000);
|
|
await createPlaceholderMemberViaImportApi(ctx, memberName);
|
|
await goToMembersPage(page);
|
|
|
|
const row = page.getByRole('row').filter({ hasText: memberName }).first();
|
|
await expect(row).toBeVisible();
|
|
await row.click({ button: 'right' });
|
|
await expect(page.getByRole('menu')).toBeVisible();
|
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
|
|
|
|
// Change billable rate from default to custom
|
|
const billableRateSelect = page.getByRole('dialog').getByRole('combobox').last();
|
|
await billableRateSelect.click();
|
|
await page.getByRole('option', { name: 'Custom Rate' }).click();
|
|
|
|
// Set a custom billable rate
|
|
await page.getByPlaceholder('Billable Rate').fill('150');
|
|
|
|
// Click Update Member — confirmation dialog should appear
|
|
await page.getByRole('button', { name: 'Update Member' }).click();
|
|
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
|
|
|
|
// Confirm the billable rate change
|
|
await Promise.all([
|
|
page.getByRole('button', { name: 'Yes, update existing time entries' }).click(),
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/members/') &&
|
|
response.request().method() === 'PUT' &&
|
|
response.status() === 200
|
|
),
|
|
]);
|
|
|
|
// Verify dialog closed
|
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
|
});
|
|
|
|
test('test that member context menu merge merges the member', async ({ page, ctx }) => {
|
|
const memberName = 'CtxMergeMember ' + Math.floor(1 + Math.random() * 10000);
|
|
await createPlaceholderMemberViaImportApi(ctx, memberName);
|
|
await goToMembersPage(page);
|
|
|
|
const row = page.getByRole('row').filter({ hasText: memberName }).first();
|
|
await expect(row).toBeVisible();
|
|
await row.click({ button: 'right' });
|
|
await expect(page.getByRole('menu')).toBeVisible();
|
|
await page.getByRole('menuitem', { name: 'Merge' }).click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible();
|
|
|
|
// Select the first available member as merge target
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click();
|
|
const firstOption = page.getByRole('option').first();
|
|
await expect(firstOption).toBeVisible({ timeout: 10000 });
|
|
await firstOption.click();
|
|
|
|
// Submit merge
|
|
await Promise.all([
|
|
page.getByRole('button', { name: 'Merge Member' }).click(),
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/member/') &&
|
|
response.url().includes('/merge-into') &&
|
|
response.ok()
|
|
),
|
|
]);
|
|
|
|
// Verify placeholder member is no longer visible
|
|
await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible();
|
|
await expect(page.getByRole('main').getByText(memberName)).not.toBeVisible();
|
|
});
|
|
|
|
test('test that member context menu deactivate deactivates the member', async ({
|
|
page,
|
|
browser,
|
|
}) => {
|
|
const memberId = Math.floor(Math.random() * 100000);
|
|
const memberEmail = `member+${memberId}@deactivate.test`;
|
|
const memberName = 'Deactivate Target';
|
|
|
|
// Invite and accept a new Employee member
|
|
await inviteAndAcceptMember(page, browser, memberName, memberEmail, 'Employee');
|
|
|
|
await goToMembersPage(page);
|
|
const row = page.getByRole('row').filter({ hasText: memberName }).first();
|
|
await expect(row).toBeVisible();
|
|
|
|
// Open context menu and click Deactivate
|
|
await row.click({ button: 'right' });
|
|
await expect(page.getByRole('menu')).toBeVisible();
|
|
await page.getByRole('menuitem', { name: 'Deactivate' }).click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: 'Deactivate User' })).toBeVisible();
|
|
|
|
// Confirm deactivation
|
|
await Promise.all([
|
|
page.getByRole('button', { name: 'Deactivate' }).click(),
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/make-placeholder') &&
|
|
response.request().method() === 'POST' &&
|
|
response.ok()
|
|
),
|
|
]);
|
|
|
|
// Verify dialog closed and member role changed to Placeholder
|
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
|
await expect(row.getByText('Placeholder', { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('test that member context menu delete deletes the member', async ({ page, ctx }) => {
|
|
const memberName = 'CtxDeleteMember ' + Math.floor(1 + Math.random() * 10000);
|
|
await createPlaceholderMemberViaImportApi(ctx, memberName);
|
|
await goToMembersPage(page);
|
|
|
|
const row = page.getByRole('row').filter({ hasText: memberName }).first();
|
|
await expect(row).toBeVisible();
|
|
await row.click({ button: 'right' });
|
|
await expect(page.getByRole('menu')).toBeVisible();
|
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible();
|
|
|
|
// Check the confirmation checkbox
|
|
await page.getByRole('checkbox').click();
|
|
|
|
// Click Delete Member button and wait for API response
|
|
await Promise.all([
|
|
page.getByRole('button', { name: 'Delete Member' }).click(),
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/members/') &&
|
|
response.request().method() === 'DELETE' &&
|
|
response.ok()
|
|
),
|
|
]);
|
|
|
|
// Verify modal closed and member removed from table
|
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
|
await expect(page.getByRole('main').getByText(memberName)).not.toBeVisible();
|
|
});
|
|
|
|
// =============================================
|
|
// Invitations Tab Tests
|
|
// =============================================
|
|
|
|
test('test that invitation shows in invitations tab and can be revoked', async ({ page }) => {
|
|
const inviteEmail = `invite+${Math.floor(Math.random() * 100000)}@pending.test`;
|
|
|
|
await goToMembersPage(page);
|
|
await openInviteMemberModal(page);
|
|
|
|
await page.getByPlaceholder('Member Email').fill(inviteEmail);
|
|
await page.getByRole('button', { name: 'Employee' }).click();
|
|
await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/invitations') &&
|
|
response.request().method() === 'POST' &&
|
|
response.status() === 204
|
|
),
|
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
|
]);
|
|
|
|
// Wait for modal to close
|
|
await expect(page.getByPlaceholder('Member Email')).not.toBeVisible();
|
|
|
|
// Switch to Invitations tab and verify the invitation is visible
|
|
await page.getByText('Invitations', { exact: true }).click();
|
|
await expect(page.getByText(inviteEmail)).toBeVisible();
|
|
|
|
// Find and click the actions menu for this invitation
|
|
const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail });
|
|
await invitationRow.getByRole('button').click();
|
|
await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/invitations/') &&
|
|
response.request().method() === 'DELETE' &&
|
|
response.status() === 204
|
|
),
|
|
page.getByRole('menuitem').getByText('Delete').click(),
|
|
]);
|
|
|
|
// Verify invitation is removed
|
|
await expect(page.getByText(inviteEmail)).not.toBeVisible();
|
|
});
|
|
|
|
test('test that invitation can be resent', async ({ page }) => {
|
|
const inviteEmail = `resend+${Math.floor(Math.random() * 100000)}@invite.test`;
|
|
|
|
await goToMembersPage(page);
|
|
await openInviteMemberModal(page);
|
|
|
|
await page.getByPlaceholder('Member Email').fill(inviteEmail);
|
|
await page.getByRole('button', { name: 'Employee' }).click();
|
|
await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/invitations') &&
|
|
response.request().method() === 'POST' &&
|
|
response.status() === 204
|
|
),
|
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
|
]);
|
|
|
|
// Wait for modal to close
|
|
await expect(page.getByPlaceholder('Member Email')).not.toBeVisible();
|
|
|
|
// Switch to Invitations tab
|
|
await page.getByText('Invitations', { exact: true }).click();
|
|
await expect(page.getByText(inviteEmail)).toBeVisible();
|
|
|
|
// Find and click the actions menu, then resend
|
|
const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail });
|
|
await invitationRow.getByRole('button').click();
|
|
// Wait for dropdown menu to appear
|
|
await expect(page.getByRole('menuitem').getByText('Resend Invitation')).toBeVisible();
|
|
await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/resend') && response.request().method() === 'POST'
|
|
),
|
|
page.getByRole('menuitem').getByText('Resend Invitation').click(),
|
|
]);
|
|
});
|
|
|
|
test('test that admin user cannot transfer ownership', async ({ page, browser }) => {
|
|
const memberId = Math.floor(Math.random() * 100000);
|
|
const memberEmail = `admin+${memberId}@perms.test`;
|
|
|
|
// Invite and accept an admin member
|
|
await inviteAndAcceptMember(
|
|
page,
|
|
browser,
|
|
'Admin User ' + memberId,
|
|
memberEmail,
|
|
'Administrator'
|
|
);
|
|
|
|
// Go to members page and verify the admin exists
|
|
await goToMembersPage(page);
|
|
const adminRow = page.getByRole('row').filter({ hasText: 'Admin User' });
|
|
await expect(adminRow).toBeVisible();
|
|
|
|
// The owner should still be the owner
|
|
const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' });
|
|
await expect(ownerRow).toBeVisible();
|
|
|
|
// Open actions menu for the admin - should NOT have "Transfer Ownership" option
|
|
await adminRow.getByRole('button').click();
|
|
await expect(page.getByRole('menuitem').getByText('Edit')).toBeVisible();
|
|
});
|
|
|
|
test('test that accepted invitation disappears from invitations tab', async ({ page, browser }) => {
|
|
const memberId = Math.round(Math.random() * 100000);
|
|
const memberEmail = `accepted+${memberId}@invite.test`;
|
|
|
|
// Invite and accept the member
|
|
await inviteAndAcceptMember(page, browser, 'Accepted Member', memberEmail, 'Employee');
|
|
|
|
// Go to members page and switch to Invitations tab
|
|
await goToMembersPage(page);
|
|
await page.getByRole('tab', { name: 'Invitations' }).click();
|
|
|
|
// The accepted invitation should not be visible
|
|
await expect(page.getByText(memberEmail)).not.toBeVisible();
|
|
});
|
|
|
|
// =============================================
|
|
// Sorting Tests
|
|
// =============================================
|
|
|
|
// Helper to clear localStorage before tests that check sorting
|
|
async function clearMemberTableState(page: Page) {
|
|
await page.evaluate(() => {
|
|
localStorage.removeItem('member-table-state');
|
|
});
|
|
}
|
|
|
|
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
|
|
// Create two placeholder members with names that sort predictably around "John Doe"
|
|
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
|
|
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
|
|
|
|
await goToMembersPage(page);
|
|
await clearMemberTableState(page);
|
|
await page.reload();
|
|
|
|
const table = page.getByTestId('member_table');
|
|
await expect(table).toBeVisible();
|
|
|
|
// -- Name sorting (default is already name asc after clearing state) --
|
|
const nameHeader = table.getByText('Name').first();
|
|
let names = await getTableRowNames(table);
|
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
|
|
|
|
await nameHeader.click(); // toggle to desc
|
|
names = await getTableRowNames(table);
|
|
expect(names.indexOf('ZZZ SortLast')).toBeLessThan(names.indexOf('AAA SortFirst'));
|
|
|
|
// -- Role sorting --
|
|
const roleHeader = table.getByText('Role').first();
|
|
await roleHeader.click(); // asc: Owner(0) < Placeholder(4)
|
|
names = await getTableRowNames(table);
|
|
const ownerIdx = names.indexOf('John Doe');
|
|
const placeholderIdx = names.indexOf('AAA SortFirst');
|
|
expect(ownerIdx).toBeLessThan(placeholderIdx);
|
|
|
|
await roleHeader.click(); // desc: Placeholder first
|
|
names = await getTableRowNames(table);
|
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
|
|
|
|
// -- Status sorting --
|
|
const statusHeader = table.getByText('Status').first();
|
|
await statusHeader.click(); // asc: Active(0) < Inactive(1)
|
|
names = await getTableRowNames(table);
|
|
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
|
|
|
|
await statusHeader.click(); // desc: Inactive first
|
|
names = await getTableRowNames(table);
|
|
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
|
|
|
|
// -- Email: just verify sort indicator appears --
|
|
const emailHeader = table.getByText('Email').first();
|
|
await emailHeader.click();
|
|
await expect(emailHeader.locator('svg')).toBeVisible();
|
|
});
|
|
|
|
test('test that member sort state persists after page reload', async ({ page }) => {
|
|
await goToMembersPage(page);
|
|
await clearMemberTableState(page);
|
|
await page.reload();
|
|
|
|
const table = page.getByTestId('member_table');
|
|
await expect(table).toBeVisible();
|
|
|
|
// Click Role header twice to set descending sort
|
|
const roleHeader = table.getByText('Role').first();
|
|
await roleHeader.click();
|
|
await expect(roleHeader.locator('svg')).toBeVisible();
|
|
await roleHeader.click();
|
|
await expect(roleHeader.locator('svg')).toBeVisible();
|
|
|
|
// Reload the page
|
|
await page.reload();
|
|
|
|
// Verify the sort indicator is still visible on Role column
|
|
await expect(page.getByTestId('member_table')).toBeVisible();
|
|
await expect(
|
|
page.getByTestId('member_table').getByText('Role').first().locator('svg')
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('test that sorting members by billable rate works', async ({ page, ctx }) => {
|
|
// Create two placeholder members and set different billable rates
|
|
await createPlaceholderMemberViaImportApi(ctx, 'HighRate Member');
|
|
await createPlaceholderMemberViaImportApi(ctx, 'LowRate Member');
|
|
|
|
const members = await getMembersViaApi(ctx);
|
|
const highRateMember = members.find((m) => m.name === 'HighRate Member');
|
|
const lowRateMember = members.find((m) => m.name === 'LowRate Member');
|
|
expect(highRateMember).toBeDefined();
|
|
expect(lowRateMember).toBeDefined();
|
|
|
|
await updateMemberBillableRateViaApi(ctx, highRateMember!.id, 20000);
|
|
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
|
|
|
|
await goToMembersPage(page);
|
|
await clearMemberTableState(page);
|
|
await page.reload();
|
|
|
|
const table = page.getByTestId('member_table');
|
|
await expect(table).toBeVisible();
|
|
|
|
// First click = desc (highest first), null rates last
|
|
const billableHeader = table.getByText('Billable Rate').first();
|
|
await billableHeader.click();
|
|
await expect(billableHeader.locator('svg')).toBeVisible();
|
|
let names = await getTableRowNames(table);
|
|
expect(names.indexOf('HighRate Member')).toBeLessThan(names.indexOf('LowRate Member'));
|
|
|
|
// Second click = asc (lowest first), null rates still last
|
|
await billableHeader.click();
|
|
names = await getTableRowNames(table);
|
|
expect(names.indexOf('LowRate Member')).toBeLessThan(names.indexOf('HighRate Member'));
|
|
});
|
|
|
|
// =============================================
|
|
// Employee Permission Tests
|
|
// =============================================
|
|
|
|
test.describe('Employee Sidebar Navigation', () => {
|
|
test('employee sidebar shows correct navigation links', async ({ employee }) => {
|
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
|
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Visible links
|
|
await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
|
|
await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible();
|
|
await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible();
|
|
await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible();
|
|
await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible();
|
|
await expect(employee.page.getByRole('link', { name: 'Tags' })).toBeVisible();
|
|
|
|
// Hidden links
|
|
await expect(employee.page.getByRole('link', { name: 'Members' })).not.toBeVisible();
|
|
await expect(
|
|
employee.page.getByRole('link', { name: 'Settings', exact: true })
|
|
).not.toBeVisible();
|
|
});
|
|
|
|
test('employee cannot see members list or invite members', async ({ employee }) => {
|
|
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
|
|
|
// Page loads but the members API returns 403 (no members:view permission)
|
|
await expect(employee.page.getByRole('heading', { name: 'Members' })).toBeVisible({
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Member table is empty — no rows rendered (only headers)
|
|
await expect(employee.page.getByTestId('member_table').locator('[role="row"]')).toHaveCount(
|
|
0
|
|
);
|
|
|
|
// Employee should NOT see the Invite Member button
|
|
await expect(
|
|
employee.page.getByRole('button', { name: 'Invite member' })
|
|
).not.toBeVisible();
|
|
});
|
|
});
|