import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; import { formatCentsWithOrganizationDefaults } from './utils/money'; import { createProjectViaApi, createPublicProjectViaApi, createProjectMemberViaApi, createTaskViaApi, createClientViaApi, createTimeEntryViaApi, archiveProjectViaApi, updateOrganizationSettingViaApi, } from './utils/api'; async function goToProjectsOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); } // Helper to clear localStorage before tests that check persistence async function clearProjectTableState(page: Page) { await page.evaluate(() => { localStorage.removeItem('project-table-state'); }); } // Create new project via modal test('test that creating and deleting a new project via the modal works', async ({ page }) => { const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByLabel('Project Name').fill(newProjectName); await Promise.all([ page.getByRole('button', { name: 'Create Project' }).click(), page.waitForResponse( async (response) => response.url().includes('/projects') && response.request().method() === 'POST' && response.status() === 201 && (await response.json()).data.id !== null && (await response.json()).data.color !== null && (await response.json()).data.client_id === null && (await response.json()).data.name === newProjectName ), ]); await expect(page.getByTestId('project_table')).toContainText(newProjectName); const moreButton = page.locator("[aria-label='Actions for Project " + newProjectName + "']"); await moreButton.click(); const deleteButton = page.locator("[aria-label='Delete Project " + newProjectName + "']"); await Promise.all([ deleteButton.click(), page.waitForResponse( async (response) => response.url().includes('/projects') && response.request().method() === 'DELETE' && response.status() === 204 ), ]); await expect(page.getByTestId('project_table')).not.toContainText(newProjectName); }); // Helper to select a status filter using the new dropdown UI async function selectStatusFilter(page: Page, status: 'Active' | 'Archived') { // Click the Filter button to open the dropdown await page.getByRole('button', { name: 'Filter projects' }).click(); // Click on Status submenu await page.getByRole('menuitem', { name: 'Status' }).click(); // Select the status option await page.getByRole('menuitem', { name: status }).click(); } // Helper to remove status filter by clicking the X on the badge async function removeStatusFilter(page: Page) { const statusBadge = page.getByTestId('status-filter-badge'); // Click the remove button (second button in the badge, contains XMarkIcon) await statusBadge.locator('button').last().click(); } test('test that archiving and unarchiving projects works', async ({ page, ctx }) => { const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000); await createProjectViaApi(ctx, { name: newProjectName }); await goToProjectsOverview(page); await clearProjectTableState(page); await page.reload(); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); // Archive the project await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Archive').first().click(); // Project should still be visible since default is "all" (no filter) await expect(page.getByText(newProjectName)).toBeVisible(); // Apply Active filter - archived project should disappear await selectStatusFilter(page, 'Active'); await expect(page.getByText(newProjectName)).not.toBeVisible(); // Remove Active filter and apply Archived filter await removeStatusFilter(page); await selectStatusFilter(page, 'Archived'); await expect(page.getByText(newProjectName)).toBeVisible(); // Unarchive the project await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Unarchive').first().click(); // Project should disappear from Archived view await expect(page.getByText(newProjectName)).not.toBeVisible(); // Remove Archived filter and apply Active filter to see the project await removeStatusFilter(page); await selectStatusFilter(page, 'Active'); await expect(page.getByText(newProjectName)).toBeVisible(); }); test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => { const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000); const newBillableRate = Math.round(Math.random() * 10000); await createProjectViaApi(ctx, { name: newProjectName }); await goToProjectsOverview(page); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').first().click(); // Set billable default to Billable await page.getByRole('dialog').locator('#billable').click(); await page.getByRole('option', { name: 'Billable', exact: true }).click(); // Set billable rate to Custom Rate await page.getByRole('dialog').locator('#billableRateType').click(); await page.getByRole('option', { name: 'Custom Rate' }).click(); await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString()); await page.getByRole('button', { name: 'Update Project' }).click(); await Promise.all([ page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(), page.waitForRequest( async (request) => request.url().includes('/projects/') && request.method() === 'PUT' && request.postDataJSON().billable_rate === newBillableRate * 100 ), page.waitForResponse( async (response) => response.url().includes('/projects/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.billable_rate === newBillableRate * 100 ), ]); await expect( page .getByRole('row') .first() .getByText(formatCentsWithOrganizationDefaults(newBillableRate * 100)) ).toBeVisible(); }); test('test that creating a project with default billable rate works', async ({ page }) => { const newProjectName = 'Default Rate Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByLabel('Project Name').fill(newProjectName); // Set billable default to Billable (leaves rate type as Default Rate) await page.getByRole('dialog').locator('#billable').click(); await page.getByRole('option', { name: 'Billable', exact: true }).click(); // Verify rate type is "Default Rate" and the rate input is disabled await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText( 'Default Rate' ); await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled(); await Promise.all([ page.getByRole('button', { name: 'Create Project' }).click(), page.waitForResponse( async (response) => response.url().includes('/projects') && response.request().method() === 'POST' && response.status() === 201 && (await response.json()).data.is_billable === true && (await response.json()).data.billable_rate === null ), ]); await expect(page.getByTestId('project_table')).toContainText(newProjectName); }); test('test that creating a non-billable project works', async ({ page }) => { const newProjectName = 'Non-Billable Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByLabel('Project Name').fill(newProjectName); // Billable default should already be "Non-billable" by default await expect(page.getByRole('dialog').locator('#billable')).toContainText('Non-billable'); await Promise.all([ page.getByRole('button', { name: 'Create Project' }).click(), page.waitForResponse( async (response) => response.url().includes('/projects') && response.request().method() === 'POST' && response.status() === 201 && (await response.json()).data.is_billable === false && (await response.json()).data.billable_rate === null ), ]); await expect(page.getByTestId('project_table')).toContainText(newProjectName); }); test('test that creating a public project via the modal works', async ({ page }) => { const newProjectName = 'Public Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByLabel('Project Name').fill(newProjectName); // Visibility defaults to Private — switch it to Public await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private'); await page.getByRole('dialog').locator('#visibility').click(); await page.getByRole('option', { name: 'Public' }).click(); await Promise.all([ page.getByRole('button', { name: 'Create Project' }).click(), page.waitForResponse( async (response) => response.url().includes('/projects') && response.request().method() === 'POST' && response.status() === 201 && (await response.json()).data.is_public === true ), ]); await expect(page.getByTestId('project_table')).toContainText(newProjectName); }); test('test that changing a project to public via the edit modal works', async ({ page, ctx }) => { const newProjectName = 'Edit Visibility Project ' + Math.floor(1 + Math.random() * 10000); await createProjectViaApi(ctx, { name: newProjectName }); await goToProjectsOverview(page); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); const projectRow = page.getByRole('row').filter({ hasText: newProjectName }).first(); await projectRow.getByRole('button').click(); await page.locator(`[aria-label='Edit Project ${newProjectName}']`).click(); // Loaded as Private — switch it to Public await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private'); await page.getByRole('dialog').locator('#visibility').click(); await page.getByRole('option', { name: 'Public' }).click(); await Promise.all([ page.getByRole('button', { name: 'Update Project' }).click(), page.waitForResponse( async (response) => response.url().includes('/projects/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.is_public === true ), ]); }); test('test that switching from custom rate to default rate clears billable rate', async ({ page, ctx, }) => { const newProjectName = 'Rate Switch Project ' + Math.floor(1 + Math.random() * 10000); // Create a project with an existing custom billable rate await createProjectViaApi(ctx, { name: newProjectName, is_billable: true, billable_rate: 15000, }); await goToProjectsOverview(page); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').first().click(); // Verify it loaded as Billable with Custom Rate await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable'); await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText( 'Custom Rate' ); // Switch to Default Rate await page.getByRole('dialog').locator('#billableRateType').click(); await page.getByRole('option', { name: 'Default Rate' }).click(); // Rate input should now be disabled await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled(); // Submit — billable_rate changes from 15000 to null, so confirmation dialog appears await page.getByRole('button', { name: 'Update Project' }).click(); await Promise.all([ page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(), page.waitForResponse( async (response) => response.url().includes('/projects/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.is_billable === true && (await response.json()).data.billable_rate === null ), ]); }); test('test that switching from billable to non-billable preserves rate settings', async ({ page, ctx, }) => { const newProjectName = 'Billable Reset Project ' + Math.floor(1 + Math.random() * 10000); // Create a project with a custom billable rate await createProjectViaApi(ctx, { name: newProjectName, is_billable: true, billable_rate: 20000, }); await goToProjectsOverview(page); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').first().click(); // Verify it loaded correctly as Billable with Custom Rate await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable'); await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText( 'Custom Rate' ); // Switch to Non-billable await page.getByRole('dialog').locator('#billable').click(); await page.getByRole('option', { name: 'Non-billable' }).click(); // Rate type should still be Custom Rate (not reset) await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText( 'Custom Rate' ); // Submit and verify project is non-billable but keeps its custom rate await Promise.all([ page.getByRole('button', { name: 'Update Project' }).click(), page.waitForResponse( async (response) => response.url().includes('/projects/') && response.request().method() === 'PUT' && response.status() === 200 && (await response.json()).data.is_billable === false && (await response.json()).data.billable_rate === 20000 ), ]); }); test('test that editing an existing billable project with default rate loads correctly', async ({ page, ctx, }) => { const newProjectName = 'Default Rate Edit Project ' + Math.floor(1 + Math.random() * 10000); // Create a project that is billable but has no custom rate (= default rate) await createProjectViaApi(ctx, { name: newProjectName, is_billable: true, billable_rate: null, }); await goToProjectsOverview(page); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').first().click(); // Verify it loaded as Billable with Default Rate await expect(page.getByRole('dialog').locator('#billable')).toContainText('Billable'); await expect(page.getByRole('dialog').locator('#billableRateType')).toContainText( 'Default Rate' ); await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled(); }); // Sorting tests test('test that sorting projects by all columns works', async ({ page, ctx }) => { // Seed projects with distinct values for each sortable column const clientAlpha = await createClientViaApi(ctx, { name: 'Alpha Client' }); const clientBeta = await createClientViaApi(ctx, { name: 'Beta Client' }); // Project A: client Alpha, low billable rate, has estimated time, active const projectA = await createProjectViaApi(ctx, { name: 'AAA Project', client_id: clientAlpha.id, is_billable: true, billable_rate: 5000, estimated_time: 36000, // 10h }); // Add 1h of time entries (10% progress) await createTimeEntryViaApi(ctx, { duration: '1h', projectId: projectA.id, }); // Project B: client Beta, high billable rate, has estimated time, archived const projectB = await createProjectViaApi(ctx, { name: 'BBB Project', client_id: clientBeta.id, is_billable: true, billable_rate: 15000, estimated_time: 7200, // 2h }); // Add 1h of time entries (50% progress) await createTimeEntryViaApi(ctx, { duration: '1h', projectId: projectB.id, }); await archiveProjectViaApi(ctx, { ...projectB, client_id: clientBeta.id, billable_rate: 15000, estimated_time: 7200, }); // Project C: no client, medium billable rate, no estimated time, active const projectC = await createProjectViaApi(ctx, { name: 'CCC Project', is_billable: true, billable_rate: 10000, }); // Add 3h of time entries await createTimeEntryViaApi(ctx, { duration: '3h', projectId: projectC.id, }); await goToProjectsOverview(page); await clearProjectTableState(page); await page.reload(); await expect(page.getByTestId('project_table')).toBeVisible(); await expect(page.getByText('AAA Project')).toBeVisible(); await expect(page.getByText('BBB Project')).toBeVisible(); await expect(page.getByText('CCC Project')).toBeVisible(); // Helper to get the visual order of our seeded projects by reading // all row text in a single evaluate call (avoids locator timing issues) const seededNames = ['AAA Project', 'BBB Project', 'CCC Project']; const getOrder = async (): Promise => { const allRowTexts = await page.evaluate(() => { const table = document.querySelector('[data-testid="project_table"]'); if (!table) return []; const rows = table.querySelectorAll('[role="row"]'); return Array.from(rows).map((row) => row.textContent ?? ''); }); const order: string[] = []; for (const text of allRowTexts) { const match = seededNames.find((name) => text.includes(name)); if (match) order.push(match); } return order; }; // Helper: click a column header and wait for sort to apply. // expectedFirstAmongSeeded = which of our 3 seeded projects should appear first const clickSortHeader = async (headerText: string, expectedFirstAmongSeeded: string) => { const header = page .locator('[data-testid="project_table"] .select-none', { hasText: headerText, }) .first(); await header.click(); // Wait until the expected project appears before the others among our seeded set await page.waitForFunction( ({ expected, names }) => { const table = document.querySelector('[data-testid="project_table"]'); if (!table) return false; const rows = table.querySelectorAll('[role="row"]'); let firstSeededIdx = -1; for (let i = 0; i < rows.length; i++) { const text = rows[i].textContent ?? ''; if (names.some((n: string) => text.includes(n))) { firstSeededIdx = i; break; } } if (firstSeededIdx === -1) return false; return (rows[firstSeededIdx].textContent ?? '').includes(expected); }, { expected: expectedFirstAmongSeeded, names: seededNames }, { timeout: 5000 } ); }; // --- Sort by Name --- // Default is name asc (A-Z) let order = await getOrder(); expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // Click to toggle to Z-A await clickSortHeader('Name', 'CCC Project'); order = await getOrder(); expect(order).toEqual(['CCC Project', 'BBB Project', 'AAA Project']); // --- Sort by Client (text: first click = A-Z, no-client last) --- await clickSortHeader('Client', 'AAA Project'); order = await getOrder(); expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // Alpha, Beta, No client // Reverse: Z-A, no-client still last await clickSortHeader('Client', 'BBB Project'); order = await getOrder(); expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // Beta, Alpha, No client // --- Sort by Total Time (numeric: first click = highest first) --- await clickSortHeader('Total Time', 'CCC Project'); order = await getOrder(); expect(order[0]).toBe('CCC Project'); // C=3h first, A and B tied at 1h // Reverse: lowest first await clickSortHeader('Total Time', 'AAA Project'); order = await getOrder(); expect(order[2]).toBe('CCC Project'); // C=3h last // --- Sort by Billable Rate (numeric: first click = highest first) --- await clickSortHeader('Billable Rate', 'BBB Project'); order = await getOrder(); expect(order).toEqual(['BBB Project', 'CCC Project', 'AAA Project']); // 15000, 10000, 5000 // Reverse: lowest first await clickSortHeader('Billable Rate', 'AAA Project'); order = await getOrder(); expect(order).toEqual(['AAA Project', 'CCC Project', 'BBB Project']); // 5000, 10000, 15000 // --- Sort by Progress (numeric: first click = highest first, no-estimate last) --- await clickSortHeader('Progress', 'BBB Project'); order = await getOrder(); expect(order).toEqual(['BBB Project', 'AAA Project', 'CCC Project']); // 50%, 10%, no estimate // Reverse: lowest first, no-estimate still last await clickSortHeader('Progress', 'AAA Project'); order = await getOrder(); expect(order).toEqual(['AAA Project', 'BBB Project', 'CCC Project']); // 10%, 50%, no estimate // --- Sort by Status (first click = active first, archived last) --- await expect(async () => { await clickSortHeader('Status', 'AAA Project'); order = await getOrder(); expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('AAA Project')); expect(order.indexOf('BBB Project')).toBeGreaterThan(order.indexOf('CCC Project')); }).toPass({ timeout: 5000 }); // Reverse: archived first await expect(async () => { await clickSortHeader('Status', 'BBB Project'); order = await getOrder(); expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('AAA Project')); expect(order.indexOf('BBB Project')).toBeLessThan(order.indexOf('CCC Project')); }).toPass({ timeout: 5000 }); }); // Filter tests test('test that filtering projects by status works', async ({ page, ctx }) => { const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000); await createProjectViaApi(ctx, { name: newProjectName }); await goToProjectsOverview(page); await clearProjectTableState(page); await page.reload(); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); // Archive the project await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Archive').first().click(); // Project should still be visible (default is "all" - no filter) await expect(page.getByText(newProjectName)).toBeVisible(); // Apply Active filter - archived project should disappear await selectStatusFilter(page, 'Active'); await expect(page.getByText(newProjectName)).not.toBeVisible(); // Remove Active filter - project should reappear (back to "all") await removeStatusFilter(page); await expect(page.getByText(newProjectName)).toBeVisible(); // Apply Archived filter - project should still be visible await selectStatusFilter(page, 'Archived'); await expect(page.getByText(newProjectName)).toBeVisible(); // Remove Archived filter and apply Active filter - project should not be visible await removeStatusFilter(page); await selectStatusFilter(page, 'Active'); await expect(page.getByText(newProjectName)).not.toBeVisible(); }); test('test that filter state persists after page reload', async ({ page }) => { await goToProjectsOverview(page); await clearProjectTableState(page); await page.reload(); // Apply Active status filter await selectStatusFilter(page, 'Active'); // Verify the filter badge is visible await expect(page.getByTestId('status-filter-badge')).toBeVisible(); // Reload the page await page.reload(); // Verify the filter badge is still visible after reload await expect(page.getByTestId('status-filter-badge')).toBeVisible(); }); test('test that sort state persists after page reload', async ({ page }) => { await goToProjectsOverview(page); await clearProjectTableState(page); await page.reload(); // Click on Name header twice to sort descending const nameHeader = page.getByText('Name').first(); await nameHeader.click(); await expect(nameHeader.locator('svg')).toBeVisible(); await nameHeader.click(); // Reload the page await page.reload(); // Verify descending sort indicator is visible on Name column await expect(page.getByTestId('project_table')).toBeVisible(); }); test('test that custom billable rate is displayed correctly on project detail page', async ({ page, ctx, }) => { const newProjectName = 'Billable Rate Project ' + Math.floor(1 + Math.random() * 10000); const newBillableRate = Math.round(10 + Math.random() * 1000); await createProjectViaApi(ctx, { name: newProjectName }); await goToProjectsOverview(page); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); // Edit the project to set a custom billable rate await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').first().click(); // Set billable default to Billable await page.getByRole('dialog').locator('#billable').click(); await page.getByRole('option', { name: 'Billable', exact: true }).click(); // Set billable rate to Custom Rate await page.getByRole('dialog').locator('#billableRateType').click(); await page.getByRole('option', { name: 'Custom Rate' }).click(); await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString()); await page.getByRole('button', { name: 'Update Project' }).click(); await Promise.all([ page.locator('button').filter({ hasText: 'Yes, update existing time' }).click(), page.waitForResponse( async (response) => response.url().includes('/projects/') && response.request().method() === 'PUT' && response.status() === 200 ), ]); // Navigate to the project detail page by clicking the project name await page.getByText(newProjectName).first().click(); await page.waitForURL(/\/projects\/[a-f0-9-]+/); // Verify the badge displays the correctly formatted billable rate const expectedFormattedRate = formatCentsWithOrganizationDefaults(newBillableRate * 100); await expect(page.locator('nav[aria-label="Breadcrumb"]').locator('..')).toContainText( expectedFormattedRate ); }); // Tests for estimated time input (Issue #460) test('test that creating a project with estimated time in human-readable format works', async ({ page, }) => { const newProjectName = 'Estimated Time Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByLabel('Project Name').fill(newProjectName); // Fill in estimated time using human-readable format const estimatedTimeInput = page.getByLabel('Time Estimated'); await estimatedTimeInput.fill('2h 30m'); await estimatedTimeInput.press('Tab'); await Promise.all([ page.getByRole('button', { name: 'Create Project' }).click(), page.waitForResponse( async (response) => response.url().includes('/projects') && response.request().method() === 'POST' && response.status() === 201 && // 2h 30m = 9000 seconds (await response.json()).data.estimated_time === 9000 ), ]); await expect(page.getByTestId('project_table')).toContainText(newProjectName); }); test('test that creating a project with estimated time using decimal notation works', async ({ page, }) => { const newProjectName = 'Decimal Estimated Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByLabel('Project Name').fill(newProjectName); // Fill in estimated time using decimal notation (1.5 hours = 1h 30m) const estimatedTimeInput = page.getByLabel('Time Estimated'); await estimatedTimeInput.fill('1.5'); await estimatedTimeInput.press('Tab'); await Promise.all([ page.getByRole('button', { name: 'Create Project' }).click(), page.waitForResponse( async (response) => response.url().includes('/projects') && response.request().method() === 'POST' && response.status() === 201 && // 1.5 hours = 5400 seconds (await response.json()).data.estimated_time === 5400 ), ]); await expect(page.getByTestId('project_table')).toContainText(newProjectName); }); test('test that creating a project with estimated time using comma decimal notation works', async ({ page, }) => { const newProjectName = 'Comma Decimal Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByLabel('Project Name').fill(newProjectName); // Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m) const estimatedTimeInput = page.getByLabel('Time Estimated'); await estimatedTimeInput.fill('2,5'); await estimatedTimeInput.press('Tab'); await Promise.all([ page.getByRole('button', { name: 'Create Project' }).click(), page.waitForResponse( async (response) => response.url().includes('/projects') && response.request().method() === 'POST' && response.status() === 201 && // 2.5 hours = 9000 seconds (await response.json()).data.estimated_time === 9000 ), ]); await expect(page.getByTestId('project_table')).toContainText(newProjectName); }); test('test that updating estimated time on existing project works', async ({ page, ctx }) => { const newProjectName = 'Update Estimated Project ' + Math.floor(1 + Math.random() * 10000); await createProjectViaApi(ctx, { name: newProjectName }); await goToProjectsOverview(page); await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 }); // Edit the project to add estimated time await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').first().click(); // Fill in estimated time const estimatedTimeInput = page.getByLabel('Time Estimated'); await estimatedTimeInput.fill('4h 15m'); await estimatedTimeInput.press('Tab'); await Promise.all([ page.getByRole('button', { name: 'Update Project' }).click(), page.waitForResponse( async (response) => response.url().includes('/projects/') && response.request().method() === 'PUT' && response.status() === 200 && // 4h 15m = 15300 seconds (await response.json()).data.estimated_time === 15300 ), ]); }); test('test that estimated time input displays formatted value after blur', async ({ page }) => { await goToProjectsOverview(page); await page.getByRole('button', { name: 'Create Project' }).click(); const estimatedTimeInput = page.getByLabel('Time Estimated'); // Enter time in various formats and check the displayed value await estimatedTimeInput.fill('90'); await estimatedTimeInput.press('Tab'); // 90 hours should be displayed as "90h 00min" (default format) await expect(estimatedTimeInput).toHaveValue(/90h/); await estimatedTimeInput.fill('1:30'); await estimatedTimeInput.press('Tab'); // 1:30 should be displayed as "1h 30min" await expect(estimatedTimeInput).toHaveValue(/1h.*30/); }); test('test that editing a task name on the project detail page works', async ({ page, ctx }) => { const projectName = 'Task Edit Project ' + Math.floor(1 + Math.random() * 10000); const originalTaskName = 'Original Task ' + Math.floor(1 + Math.random() * 10000); const updatedTaskName = 'Updated Task ' + Math.floor(1 + Math.random() * 10000); const project = await createProjectViaApi(ctx, { name: projectName }); await createTaskViaApi(ctx, { name: originalTaskName, project_id: project.id }); // Navigate to the project detail page await goToProjectsOverview(page); await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 }); await page.getByText(projectName).first().click(); await page.waitForURL(/\/projects\/[a-f0-9-]+/); // Verify task is visible await expect(page.getByTestId('task_table')).toContainText(originalTaskName); // Open edit modal via actions menu const moreButton = page.locator("[aria-label='Actions for Task " + originalTaskName + "']"); await moreButton.click(); await page.getByTestId('task_edit').click(); // Update the task name await page.locator('#taskName').fill(updatedTaskName); await Promise.all([ page.getByRole('button', { name: 'Update Task' }).click(), page.waitForResponse( async (response) => response.url().includes('/tasks') && response.request().method() === 'PUT' && response.status() === 200 ), ]); // Verify updated name is shown and old name is gone await expect(page.getByTestId('task_table')).toContainText(updatedTaskName); await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName); }); // ============================================= // Context Menu Tests // ============================================= test('test that project context menu edit updates the project', async ({ page, ctx }) => { const projectName = 'CtxEditProject ' + Math.floor(1 + Math.random() * 10000); const updatedName = 'CtxUpdatedProject ' + Math.floor(1 + Math.random() * 10000); await createProjectViaApi(ctx, { name: projectName }); await goToProjectsOverview(page); const row = page.getByRole('row').filter({ hasText: projectName }).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 page.getByPlaceholder('Project Name').fill(updatedName); await Promise.all([ page.getByRole('button', { name: 'Update Project' }).click(), page.waitForResponse( (response) => response.url().includes('/projects/') && response.request().method() === 'PUT' && response.status() === 200 ), ]); await expect(page.getByTestId('project_table')).toContainText(updatedName); await expect(page.getByTestId('project_table')).not.toContainText(projectName); }); test('test that project context menu archive archives the project', async ({ page, ctx }) => { const projectName = 'CtxArchiveProject ' + Math.floor(1 + Math.random() * 10000); await createProjectViaApi(ctx, { name: projectName }); await goToProjectsOverview(page); const row = page.getByRole('row').filter({ hasText: projectName }).first(); await expect(row).toBeVisible(); await row.click({ button: 'right' }); await expect(page.getByRole('menu')).toBeVisible(); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/projects') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('menuitem', { name: 'Archive' }).click(), ]); // After archiving, the project stays visible (default filter is 'all') but status changes to 'Archived' await expect(row).toContainText('Archived'); }); test('test that project context menu delete deletes the project', async ({ page, ctx }) => { const projectName = 'CtxDeleteProject ' + Math.floor(1 + Math.random() * 10000); await createProjectViaApi(ctx, { name: projectName }); await goToProjectsOverview(page); const row = page.getByRole('row').filter({ hasText: projectName }).first(); await expect(row).toBeVisible(); await row.click({ button: 'right' }); await expect(page.getByRole('menu')).toBeVisible(); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/projects') && response.request().method() === 'DELETE' && response.status() === 204 ), page.getByRole('menuitem', { name: 'Delete' }).click(), ]); await expect(page.getByTestId('project_table')).not.toContainText(projectName); }); // ============================================= // Employee Permission Tests // ============================================= test.describe('Employee Projects Restrictions', () => { test('employee can view public projects but cannot create', async ({ ctx, employee }) => { const projectName = 'EmpViewProj ' + Math.floor(Math.random() * 10000); await createPublicProjectViaApi(ctx, { name: projectName }); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000, }); // Employee can see the public project await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 }); // Employee cannot see Create Project button await expect( employee.page.getByRole('button', { name: 'Create Project' }) ).not.toBeVisible(); }); test('employee cannot see edit/delete/archive actions on projects', async ({ ctx, employee, }) => { const projectName = 'EmpActionsProj ' + Math.floor(Math.random() * 10000); await createPublicProjectViaApi(ctx, { name: projectName }); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 }); // Click the actions dropdown trigger to open the menu const actionsButton = employee.page.locator( `[aria-label='Actions for Project ${projectName}']` ); await actionsButton.click(); // The dropdown menu items (Edit, Archive, Delete) should NOT be visible await expect( employee.page.locator(`[aria-label='Edit Project ${projectName}']`) ).not.toBeVisible(); await expect( employee.page.locator(`[aria-label='Archive Project ${projectName}']`) ).not.toBeVisible(); await expect( employee.page.locator(`[aria-label='Delete Project ${projectName}']`) ).not.toBeVisible(); }); test('employee does not see private projects they are not a member of', async ({ ctx, employee, }) => { const publicName = 'EmpPublicVisible ' + Math.floor(Math.random() * 10000); const privateName = 'EmpPrivateHidden ' + Math.floor(Math.random() * 10000); await createPublicProjectViaApi(ctx, { name: publicName }); // createProjectViaApi defaults to is_public: false (private); the employee is not a member await createProjectViaApi(ctx, { name: privateName }); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 }); // The public project is visible — confirms the list has loaded await expect(employee.page.getByText(publicName)).toBeVisible({ timeout: 10000 }); // The private project the employee is not a member of must not appear await expect(employee.page.getByText(privateName)).not.toBeVisible(); }); test('employee can see a private project they are a member of', async ({ ctx, employee }) => { const projectName = 'EmpPrivateMember ' + Math.floor(Math.random() * 10000); const project = await createProjectViaApi(ctx, { name: projectName }); // Add the employee as a project member so the private project becomes visible to them await createProjectMemberViaApi(ctx, project.id, { member_id: employee.memberId }); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 }); // The private project is visible because the employee is a member await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 }); }); }); test.describe('Employee Billable Rate Visibility', () => { test('employee cannot see billable rate column by default', async ({ ctx, employee }) => { const projectName = 'EmpBillableProj ' + Math.floor(Math.random() * 10000); await createPublicProjectViaApi(ctx, { name: projectName, is_billable: true, billable_rate: 15000, }); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 }); // Billable Rate column should not be visible to employee by default await expect(employee.page.getByText('Billable Rate')).not.toBeVisible(); }); test('employee can see billable rate column when employees_can_see_billable_rates is enabled', async ({ ctx, employee, }) => { await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true }); const projectName = 'EmpBillableVisProj ' + Math.floor(Math.random() * 10000); await createPublicProjectViaApi(ctx, { name: projectName, is_billable: true, billable_rate: 20000, }); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 }); // Billable Rate column header should be visible await expect(employee.page.getByText('Billable Rate')).toBeVisible(); // The project row should show the formatted billable rate const projectRow = employee.page.getByRole('row').filter({ hasText: projectName }); await expect(projectRow).toContainText('200'); }); });