mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-05-07 20:32:26 +00:00
add e2e tests for employee restrictions
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
createBillableProjectViaApi,
|
||||
createProjectViaApi,
|
||||
createBareTimeEntryViaApi,
|
||||
createTimeEntryViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
async function goToCalendar(page: Page) {
|
||||
@@ -288,3 +289,38 @@ test('test that deleting time entry from calendar modal works', async ({ page, c
|
||||
// Verify the event is removed from the calendar
|
||||
await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible();
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Employee Permission Tests
|
||||
// =============================================
|
||||
|
||||
test.describe('Employee Calendar Isolation', () => {
|
||||
test('employee can only see their own time entries on the calendar', async ({
|
||||
ctx,
|
||||
employee,
|
||||
}) => {
|
||||
// Owner creates a time entry for today
|
||||
const ownerDescription = 'OwnerCalEntry ' + Math.floor(Math.random() * 10000);
|
||||
await createBareTimeEntryViaApi(ctx, ownerDescription, '1h');
|
||||
|
||||
// Create a time entry for the employee for today
|
||||
const employeeDescription = 'EmpCalEntry ' + Math.floor(Math.random() * 10000);
|
||||
await createTimeEntryViaApi(
|
||||
{ ...ctx, memberId: employee.memberId },
|
||||
{ description: employeeDescription, duration: '30min' }
|
||||
);
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
||||
await expect(employee.page.locator('.fc')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Employee's event IS visible
|
||||
await expect(
|
||||
employee.page.locator('.fc-event').filter({ hasText: employeeDescription }).first()
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Owner's event is NOT visible
|
||||
await expect(
|
||||
employee.page.locator('.fc-event').filter({ hasText: ownerDescription })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+89
-1
@@ -2,7 +2,12 @@ import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { createClientViaApi } from './utils/api';
|
||||
import {
|
||||
createClientViaApi,
|
||||
createProjectMemberViaApi,
|
||||
createProjectViaApi,
|
||||
createPublicProjectViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
async function goToClientsOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||
@@ -125,3 +130,86 @@ test('test that deleting a client via actions menu works', async ({ page, ctx })
|
||||
|
||||
await expect(page.getByTestId('client_table')).not.toContainText(clientName);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Employee Permission Tests
|
||||
// =============================================
|
||||
|
||||
test.describe('Employee Clients Restrictions', () => {
|
||||
test('employee can view clients but cannot create', async ({ ctx, employee }) => {
|
||||
// Create a client with a public project so the employee can see the client
|
||||
const clientName = 'EmpViewClient ' + Math.floor(Math.random() * 10000);
|
||||
const client = await createClientViaApi(ctx, { name: clientName });
|
||||
await createPublicProjectViaApi(ctx, { name: 'EmpClientProj', client_id: client.id });
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||
await expect(employee.page.getByTestId('clients_view')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Employee can see the client
|
||||
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Employee cannot see Create Client button
|
||||
await expect(
|
||||
employee.page.getByRole('button', { name: 'Create Client' })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('employee cannot see edit/delete/archive actions on clients', async ({
|
||||
ctx,
|
||||
employee,
|
||||
}) => {
|
||||
const clientName = 'EmpActionsClient ' + Math.floor(Math.random() * 10000);
|
||||
const client = await createClientViaApi(ctx, { name: clientName });
|
||||
await createPublicProjectViaApi(ctx, { name: 'EmpClientActProj', client_id: client.id });
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click the actions dropdown trigger to open the menu
|
||||
const actionsButton = employee.page.locator(
|
||||
`[aria-label='Actions for Client ${clientName}']`
|
||||
);
|
||||
await actionsButton.click();
|
||||
|
||||
// The dropdown menu items (Edit, Archive, Delete) should NOT be visible
|
||||
await expect(
|
||||
employee.page.locator(`[aria-label='Edit Client ${clientName}']`)
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
employee.page.locator(`[aria-label='Archive Client ${clientName}']`)
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
employee.page.locator(`[aria-label='Delete Client ${clientName}']`)
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('employee can see client when they are a member of its private project', async ({
|
||||
ctx,
|
||||
employee,
|
||||
}) => {
|
||||
const clientName = 'EmpPrivateClient ' + Math.floor(Math.random() * 10000);
|
||||
const client = await createClientViaApi(ctx, { name: clientName });
|
||||
|
||||
// Create a private project under this client
|
||||
const project = await createProjectViaApi(ctx, {
|
||||
name: 'PrivateProj',
|
||||
client_id: client.id,
|
||||
is_public: false,
|
||||
});
|
||||
|
||||
// Add the employee as a project member
|
||||
await createProjectMemberViaApi(ctx, project.id, {
|
||||
member_id: employee.memberId,
|
||||
});
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/clients');
|
||||
await expect(employee.page.getByTestId('clients_view')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Employee can see the client because they are a member of its private project
|
||||
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -400,3 +400,69 @@ test.describe('Command Palette', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Employee Permission Tests
|
||||
// =============================================
|
||||
|
||||
test.describe('Employee Command Palette Restrictions', () => {
|
||||
test('employee command palette does not show restricted navigation commands', async ({
|
||||
employee,
|
||||
}) => {
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Open command palette
|
||||
await employee.page.getByTestId('command_palette_button').click();
|
||||
await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Available navigation commands
|
||||
await expect(employee.page.getByRole('option', { name: 'Go to Dashboard' })).toBeVisible();
|
||||
await expect(employee.page.getByRole('option', { name: 'Go to Time' })).toBeVisible();
|
||||
await expect(employee.page.getByRole('option', { name: 'Go to Calendar' })).toBeVisible();
|
||||
|
||||
// Restricted commands should NOT be visible
|
||||
await expect(
|
||||
employee.page.getByRole('option', { name: 'Go to Members' })
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
employee.page.getByRole('option', { name: 'Go to Settings' })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('employee command palette does not show create commands for restricted entities', async ({
|
||||
employee,
|
||||
}) => {
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Open command palette
|
||||
await employee.page.getByTestId('command_palette_button').click();
|
||||
await expect(employee.page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Search for "Create" to filter
|
||||
await employee.page.locator('[role="dialog"] input').fill('Create');
|
||||
await employee.page.waitForTimeout(300);
|
||||
|
||||
// Should NOT see create commands for restricted entities
|
||||
await expect(
|
||||
employee.page.getByRole('option', { name: 'Create Project' })
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
employee.page.getByRole('option', { name: 'Create Client' })
|
||||
).not.toBeVisible();
|
||||
await expect(employee.page.getByRole('option', { name: 'Create Tag' })).not.toBeVisible();
|
||||
await expect(
|
||||
employee.page.getByRole('option', { name: 'Invite Member' })
|
||||
).not.toBeVisible();
|
||||
|
||||
// Should still see Create Time Entry (employees can create time entries)
|
||||
await expect(
|
||||
employee.page.getByRole('option', { name: 'Create Time Entry' })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, test } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
assertThatTimerHasStarted,
|
||||
assertThatTimerIsStopped,
|
||||
newTimeEntryResponse,
|
||||
startOrStopTimerWithButton,
|
||||
stoppedTimeEntryResponse,
|
||||
} from './utils/currentTimeEntry';
|
||||
import { createBareTimeEntryViaApi } from './utils/api';
|
||||
|
||||
async function goToDashboard(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
}
|
||||
|
||||
test('test that dashboard loads with all expected sections', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await expect(page.getByTestId('dashboard_view')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Timer section (scoped to dashboard_timer to avoid matching sidebar timer)
|
||||
await expect(page.getByTestId('time_entry_description')).toBeVisible();
|
||||
await expect(page.getByTestId('dashboard_timer').getByTestId('timer_button')).toBeVisible();
|
||||
|
||||
// Dashboard cards
|
||||
await expect(page.getByText('Recent Time Entries', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Last 7 Days', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Activity Graph', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Team Activity', { exact: true })).toBeVisible();
|
||||
|
||||
// Weekly overview section
|
||||
await expect(page.getByText('This Week', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that dashboard shows time entry data after creating entries', async ({ page, ctx }) => {
|
||||
await createBareTimeEntryViaApi(ctx, 'Dashboard test entry', '1h');
|
||||
|
||||
await goToDashboard(page);
|
||||
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
||||
|
||||
// The "Last 7 Days" or "This Week" section should reflect tracked time
|
||||
await expect(page.getByText('This Week', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that timer on dashboard can start and stop', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
await Promise.all([stoppedTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that weekly overview section displays stat cards', async ({ page, ctx }) => {
|
||||
await createBareTimeEntryViaApi(ctx, 'Stats test entry', '2h');
|
||||
|
||||
await goToDashboard(page);
|
||||
|
||||
// Verify stat card labels are visible
|
||||
await expect(page.getByText('Spent Time')).toBeVisible();
|
||||
await expect(page.getByText('Billable Time')).toBeVisible();
|
||||
await expect(page.getByText('Billable Amount')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that stopping timer refreshes dashboard data', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
|
||||
// Start timer
|
||||
await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
|
||||
await assertThatTimerHasStarted(page);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Stop timer and verify dashboard queries are refetched
|
||||
await Promise.all([
|
||||
stoppedTimeEntryResponse(page),
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/charts/') &&
|
||||
response.request().method() === 'GET' &&
|
||||
response.status() === 200
|
||||
),
|
||||
startOrStopTimerWithButton(page),
|
||||
]);
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Employee Permission Tests
|
||||
// =============================================
|
||||
|
||||
test.describe('Employee Dashboard Restrictions', () => {
|
||||
test('employee dashboard loads and timer is functional', async ({ employee }) => {
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Timer should be available
|
||||
await expect(
|
||||
employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
|
||||
).toBeVisible();
|
||||
await expect(employee.page.getByTestId('time_entry_description')).toBeEditable();
|
||||
});
|
||||
|
||||
test('employee cannot see Team Activity card', async ({ employee }) => {
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Other dashboard cards should be visible
|
||||
await expect(employee.page.getByText('Recent Time Entries', { exact: true })).toBeVisible();
|
||||
|
||||
// Team Activity should NOT be visible for employees
|
||||
await expect(employee.page.getByText('Team Activity', { exact: true })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { expect, test } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import type { Page } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
async function goToImportExport(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/import');
|
||||
}
|
||||
|
||||
test('test that import page loads with type dropdown and file upload', async ({ page }) => {
|
||||
await goToImportExport(page);
|
||||
await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Import section
|
||||
await expect(page.getByRole('heading', { name: 'Import Data' })).toBeVisible();
|
||||
await expect(page.locator('#importType')).toBeVisible();
|
||||
|
||||
// Export section
|
||||
await expect(page.getByRole('heading', { name: 'Export Data' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that selecting an import type shows instructions', async ({ page }) => {
|
||||
await goToImportExport(page);
|
||||
|
||||
// Select a Toggl import type
|
||||
await page.getByLabel('Import Type').selectOption({ index: 1 });
|
||||
|
||||
// Instructions should appear
|
||||
await expect(page.getByText('Instructions:')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that importing without selecting type shows error', async ({ page }) => {
|
||||
await goToImportExport(page);
|
||||
|
||||
// Click Import Data without selecting a type
|
||||
await page.getByRole('button', { name: 'Import Data' }).click();
|
||||
|
||||
// Should show an error notification
|
||||
await expect(page.getByText('Please select the import type')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that importing without selecting file shows error', async ({ page }) => {
|
||||
await goToImportExport(page);
|
||||
|
||||
// Select an import type first
|
||||
await page.getByLabel('Import Type').selectOption({ index: 1 });
|
||||
|
||||
// Click Import Data without selecting a file
|
||||
await page.getByRole('button', { name: 'Import Data' }).click();
|
||||
|
||||
// Should show an error notification
|
||||
await expect(
|
||||
page.getByText('Please select the CSV or ZIP file that you want to import')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that export button triggers export and shows success modal', async ({ page }) => {
|
||||
await goToImportExport(page);
|
||||
await expect(page.getByRole('button', { name: 'Export Organization Data' })).toBeVisible();
|
||||
|
||||
// Override window.open to prevent the page from navigating away to the
|
||||
// download URL (the app uses window.open(url, '_self') which would navigate
|
||||
// away before we can verify the success modal)
|
||||
await page.evaluate(() => {
|
||||
window.open = () => null;
|
||||
});
|
||||
|
||||
// Click Export Organization Data and wait for the API response
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/export') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 200,
|
||||
{ timeout: 60000 }
|
||||
),
|
||||
page.getByRole('button', { name: 'Export Organization Data' }).click(),
|
||||
]);
|
||||
|
||||
// Success modal should appear after export completes
|
||||
await expect(page.getByText('The export was successful!')).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that import type dropdown has multiple options', async ({ page }) => {
|
||||
await goToImportExport(page);
|
||||
|
||||
// The dropdown should load with options from the API
|
||||
await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/importers') &&
|
||||
response.request().method() === 'GET' &&
|
||||
response.status() === 200
|
||||
);
|
||||
|
||||
// Verify the select has options besides the default placeholder
|
||||
const options = page.getByLabel('Import Type').locator('option');
|
||||
const count = await options.count();
|
||||
// Should have at least the placeholder + some import types
|
||||
expect(count).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('test that importing a generic time entries CSV works', async ({ page }) => {
|
||||
await goToImportExport(page);
|
||||
await expect(page.getByTestId('import_view')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Select "Generic Time Entries" import type
|
||||
await page.getByLabel('Import Type').selectOption({ label: 'Generic Time Entries' });
|
||||
await expect(page.getByText('Instructions:')).toBeVisible();
|
||||
|
||||
// Upload the test CSV file
|
||||
const csvPath = path.resolve('resources/testfiles/generic_time_entries_import_test_1.csv');
|
||||
await page.locator('#file-upload').setInputFiles(csvPath);
|
||||
|
||||
// Click Import and wait for the API response
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/import') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 200,
|
||||
{ timeout: 30000 }
|
||||
),
|
||||
page.getByRole('button', { name: 'Import Data' }).click(),
|
||||
]);
|
||||
|
||||
// Verify success modal with import results
|
||||
await expect(page.getByRole('heading', { name: 'Import Result' })).toBeVisible();
|
||||
await expect(page.getByText('The import was successful!')).toBeVisible();
|
||||
|
||||
// The CSV has 2 time entries, 1 client, 2 projects, 1 task
|
||||
await expect(page.getByText('Time entries created:').locator('..')).toContainText('2');
|
||||
await expect(page.getByText('Projects created:').locator('..')).toContainText('2');
|
||||
await expect(page.getByText('Clients created:').locator('..')).toContainText('1');
|
||||
await expect(page.getByText('Tasks created:').locator('..')).toContainText('1');
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Employee Permission Tests
|
||||
// =============================================
|
||||
|
||||
test.describe('Employee Import Restrictions', () => {
|
||||
test('employee does not see Import / Export link in the sidebar', async ({ employee }) => {
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// The Import / Export link should NOT be visible in the sidebar for employees
|
||||
await expect(
|
||||
employee.page.getByRole('link', { name: 'Import / Export' })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -445,6 +445,33 @@ test('test that invitation can be resent', async ({ page }) => {
|
||||
]);
|
||||
});
|
||||
|
||||
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`;
|
||||
@@ -459,3 +486,49 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
|
||||
// The accepted invitation should not be visible
|
||||
await expect(page.getByText(memberEmail)).not.toBeVisible();
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// 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('client_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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -364,3 +364,32 @@ test('test that format settings persist after page reload', async ({ page }) =>
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY');
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Employee Permission Tests
|
||||
// =============================================
|
||||
|
||||
test.describe('Employee Organization Settings Restrictions', () => {
|
||||
test('employee can see org name but not editable settings', async ({ ctx, employee }) => {
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/teams/' + ctx.orgId);
|
||||
|
||||
// Organization Name section is visible (but inputs are disabled)
|
||||
await expect(
|
||||
employee.page.getByRole('heading', { name: 'Organization Name', level: 3 })
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Editable settings sections should NOT be visible
|
||||
await expect(
|
||||
employee.page.getByRole('heading', { name: 'Billable Rate', level: 3 })
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
employee.page.getByRole('heading', { name: 'Format Settings', level: 3 })
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
employee.page.getByRole('heading', { name: 'Organization Settings', level: 3 })
|
||||
).not.toBeVisible();
|
||||
|
||||
// Save button should not be visible (employee cannot update)
|
||||
await expect(employee.page.getByRole('button', { name: 'Save' })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+99
-1
@@ -4,7 +4,12 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { formatCentsWithOrganizationDefaults } from './utils/money';
|
||||
import type { CurrencyFormat } from '../resources/js/packages/ui/src/utils/money';
|
||||
import { createProjectViaApi, createTaskViaApi } from './utils/api';
|
||||
import {
|
||||
createProjectViaApi,
|
||||
createPublicProjectViaApi,
|
||||
createTaskViaApi,
|
||||
updateOrganizationSettingViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
async function goToProjectsOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
@@ -502,3 +507,96 @@ test('test that editing a task name on the project detail page works', async ({
|
||||
await expect(page.getByTestId('task_table')).toContainText(updatedTaskName);
|
||||
await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// 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.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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
createTimeEntryViaApi,
|
||||
createTimeEntryWithTagViaApi,
|
||||
createTimeEntryWithBillableStatusViaApi,
|
||||
createBareTimeEntryViaApi,
|
||||
createPublicProjectViaApi,
|
||||
updateOrganizationSettingViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
// Each test registers a new user and creates test data via API
|
||||
@@ -859,3 +862,129 @@ test('test that export dropdown shows all export options', async ({ page, ctx })
|
||||
await expect(page.getByRole('menuitem', { name: 'Export as CSV' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Export as ODS' })).toBeVisible();
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Employee Permission Tests
|
||||
// =============================================
|
||||
|
||||
test.describe('Employee Reporting Restrictions', () => {
|
||||
test('employee can access overview reporting and sees own data', async ({ ctx, employee }) => {
|
||||
// Owner creates a time entry
|
||||
await createBareTimeEntryViaApi(ctx, 'Owner report entry', '2h');
|
||||
|
||||
// Create employee time entry
|
||||
await createTimeEntryViaApi(
|
||||
{ ...ctx, memberId: employee.memberId },
|
||||
{ description: 'Emp report entry', duration: '1h' }
|
||||
);
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
|
||||
await expect(employee.page.getByTestId('reporting_view')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Employee's data should be visible (1h)
|
||||
await expect(
|
||||
employee.page.getByTestId('reporting_view').getByText('1h 00min').first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('employee can access detailed reporting and sees only own entries', async ({
|
||||
ctx,
|
||||
employee,
|
||||
}) => {
|
||||
// Owner creates time entries
|
||||
const ownerDescription = 'OwnerDetailEntry ' + Math.floor(Math.random() * 10000);
|
||||
await createBareTimeEntryViaApi(ctx, ownerDescription, '2h');
|
||||
|
||||
// Create employee time entry
|
||||
const empDescription = 'EmpDetailEntry ' + Math.floor(Math.random() * 10000);
|
||||
await createTimeEntryViaApi(
|
||||
{ ...ctx, memberId: employee.memberId },
|
||||
{ description: empDescription, duration: '1h' }
|
||||
);
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed');
|
||||
await expect(employee.page.getByTestId('reporting_view')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Employee's entry IS visible
|
||||
await expect(
|
||||
employee.page.getByTestId('reporting_view').locator(`text=${empDescription}`).first()
|
||||
).toBeAttached({ timeout: 10000 });
|
||||
|
||||
// Owner's entry is NOT visible
|
||||
await expect(
|
||||
employee.page.getByTestId('reporting_view').locator(`text=${ownerDescription}`)
|
||||
).not.toBeAttached();
|
||||
});
|
||||
|
||||
test('employee cannot see shared reports tab in reporting', async ({ employee }) => {
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
|
||||
await expect(employee.page.getByTestId('reporting_view')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Overview and Detailed tabs should be visible (scope to main to avoid sidebar matches)
|
||||
const mainContent = employee.page.getByRole('main');
|
||||
await expect(mainContent.getByRole('link', { name: 'Overview' })).toBeVisible();
|
||||
await expect(mainContent.getByRole('link', { name: 'Detailed' })).toBeVisible();
|
||||
|
||||
// Shared tab should NOT be visible for employees
|
||||
await expect(mainContent.getByRole('link', { name: 'Shared' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('employee cannot see Cost column in reporting by default', async ({ ctx, employee }) => {
|
||||
const project = await createPublicProjectViaApi(ctx, {
|
||||
name: 'EmpBillProj',
|
||||
is_billable: true,
|
||||
billable_rate: 10000,
|
||||
});
|
||||
await createTimeEntryViaApi(
|
||||
{ ...ctx, memberId: employee.memberId },
|
||||
{ description: 'Emp cost entry', duration: '1h', projectId: project.id }
|
||||
);
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
|
||||
await expect(employee.page.getByTestId('reporting_view')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Cost column header should NOT be visible
|
||||
await expect(employee.page.getByText('Cost', { exact: true })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('employee can see Cost column when employees_can_see_billable_rates is enabled', async ({
|
||||
ctx,
|
||||
employee,
|
||||
}) => {
|
||||
await updateOrganizationSettingViaApi(ctx, { employees_can_see_billable_rates: true });
|
||||
|
||||
const project = await createPublicProjectViaApi(ctx, {
|
||||
name: 'EmpBillVisProj',
|
||||
is_billable: true,
|
||||
billable_rate: 10000,
|
||||
});
|
||||
await createTimeEntryViaApi(
|
||||
{ ...ctx, memberId: employee.memberId },
|
||||
{
|
||||
description: 'Emp cost visible entry',
|
||||
duration: '1h',
|
||||
projectId: project.id,
|
||||
billable: true,
|
||||
}
|
||||
);
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
|
||||
await expect(employee.page.getByTestId('reporting_view')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Cost column header should be visible
|
||||
await expect(employee.page.getByText('Cost', { exact: true })).toBeVisible();
|
||||
|
||||
// 1h at 100.00/h billable rate = 100.00 cost (shown in row and total)
|
||||
await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+60
-1
@@ -2,7 +2,13 @@ import { expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { createProjectViaApi, createTaskViaApi, createClientViaApi } from './utils/api';
|
||||
import {
|
||||
createProjectViaApi,
|
||||
createPublicProjectViaApi,
|
||||
createTaskViaApi,
|
||||
createClientViaApi,
|
||||
updateOrganizationSettingViaApi,
|
||||
} from './utils/api';
|
||||
|
||||
async function goToProjectsOverview(page: Page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
@@ -188,3 +194,56 @@ test('test that multiple tasks are displayed on project detail page', async ({ p
|
||||
await expect(page.getByText(taskName1)).toBeVisible();
|
||||
await expect(page.getByText(taskName2)).toBeVisible();
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Employee Permission Tests
|
||||
// =============================================
|
||||
|
||||
test.describe('Employee Tasks Restrictions', () => {
|
||||
test('employee cannot see task management actions when employees_can_manage_tasks is disabled', async ({
|
||||
ctx,
|
||||
employee,
|
||||
}) => {
|
||||
// Create a public project with a task
|
||||
const projectName = 'EmpTaskProj ' + Math.floor(Math.random() * 10000);
|
||||
const taskName = 'EmpTask ' + Math.floor(Math.random() * 10000);
|
||||
const project = await createPublicProjectViaApi(ctx, { name: projectName });
|
||||
await createTaskViaApi(ctx, { name: taskName, project_id: project.id });
|
||||
|
||||
// Navigate to the project detail page
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
||||
await employee.page.getByText(projectName).first().click();
|
||||
await employee.page.waitForURL(/\/projects\/[a-f0-9-]+/);
|
||||
|
||||
// Task should be visible
|
||||
await expect(employee.page.getByText(taskName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Create Task button should not be visible
|
||||
await expect(employee.page.getByRole('button', { name: 'Create Task' })).not.toBeVisible();
|
||||
|
||||
// Task actions button should not be visible
|
||||
const actionsButton = employee.page.locator(`[aria-label='Actions for Task ${taskName}']`);
|
||||
await expect(actionsButton).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('employee can manage tasks when employees_can_manage_tasks is enabled', async ({
|
||||
ctx,
|
||||
employee,
|
||||
}) => {
|
||||
// Enable the setting
|
||||
await updateOrganizationSettingViaApi(ctx, { employees_can_manage_tasks: true });
|
||||
|
||||
const projectName = 'EmpTaskMgmtProj ' + Math.floor(Math.random() * 10000);
|
||||
await createPublicProjectViaApi(ctx, { name: projectName });
|
||||
|
||||
// Navigate to the project detail page
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
||||
await employee.page.getByText(projectName).first().click();
|
||||
await employee.page.waitForURL(/\/projects\/[a-f0-9-]+/);
|
||||
|
||||
// Create Task button SHOULD be visible
|
||||
await expect(employee.page.getByRole('button', { name: 'Create Task' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
createProjectViaApi,
|
||||
createBillableProjectViaApi,
|
||||
createBareTimeEntryViaApi,
|
||||
createTimeEntryViaApi,
|
||||
updateOrganizationCurrencyViaWeb,
|
||||
} from './utils/api';
|
||||
|
||||
// Date picker button name patterns for different date formats
|
||||
@@ -602,6 +604,32 @@ test('test that date picker displays date in organization date format', async ({
|
||||
|
||||
// TODO: Test that project can be created in the time entry row
|
||||
|
||||
test('test that billable icon shows dollar sign for USD currency on time entry row', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await updateOrganizationCurrencyViaWeb(ctx, 'USD');
|
||||
await goToTimeOverview(page);
|
||||
await createEmptyTimeEntry(page);
|
||||
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
||||
const billableButton = timeEntryRow.getByRole('button', { name: 'Non Billable' }).first();
|
||||
await expect(billableButton).toBeVisible();
|
||||
await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 8 14');
|
||||
});
|
||||
|
||||
test('test that billable icon shows euro sign for EUR currency on time entry row', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
await updateOrganizationCurrencyViaWeb(ctx, 'EUR');
|
||||
await goToTimeOverview(page);
|
||||
await createEmptyTimeEntry(page);
|
||||
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
||||
const billableButton = timeEntryRow.getByRole('button', { name: 'Non Billable' }).first();
|
||||
await expect(billableButton).toBeVisible();
|
||||
await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 12 12');
|
||||
});
|
||||
|
||||
test('test that editing billable status via the edit modal works', async ({ page }) => {
|
||||
await goToTimeOverview(page);
|
||||
await createEmptyTimeEntry(page);
|
||||
@@ -1399,3 +1427,106 @@ test('test that time entries page loads multiple entries created via API', async
|
||||
const count = await timeEntryRows.count();
|
||||
expect(count).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Employee Permission Tests
|
||||
// =============================================
|
||||
|
||||
test.describe('Employee Time Entry Isolation', () => {
|
||||
test('employee can only see their own time entries on the time page', async ({
|
||||
ctx,
|
||||
employee,
|
||||
}) => {
|
||||
// Owner creates a time entry
|
||||
const ownerDescription = 'OwnerWork ' + Math.floor(Math.random() * 10000);
|
||||
await createBareTimeEntryViaApi(ctx, ownerDescription, '1h');
|
||||
|
||||
// Create a time entry for the employee using the owner's context
|
||||
const employeeDescription = 'EmpWork ' + Math.floor(Math.random() * 10000);
|
||||
await createTimeEntryViaApi(
|
||||
{ ...ctx, memberId: employee.memberId },
|
||||
{ description: employeeDescription, duration: '30min' }
|
||||
);
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
await expect(
|
||||
employee.page.getByTestId('dashboard_timer').getByTestId('timer_button')
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Employee's time entry IS visible
|
||||
const employeeRow = employee.page
|
||||
.locator('[data-testid="time_entry_row"]')
|
||||
.filter({ hasText: employeeDescription });
|
||||
await expect(employeeRow).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Owner's time entry is NOT visible
|
||||
const ownerRow = employee.page
|
||||
.locator('[data-testid="time_entry_row"]')
|
||||
.filter({ hasText: ownerDescription });
|
||||
await expect(ownerRow).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('employee can edit their own time entry', async ({ ctx, employee }) => {
|
||||
const description = 'EmpEditEntry ' + Math.floor(Math.random() * 10000);
|
||||
await createTimeEntryViaApi(
|
||||
{ ...ctx, memberId: employee.memberId },
|
||||
{ description, duration: '1h' }
|
||||
);
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
const timeEntryRow = employee.page
|
||||
.locator('[data-testid="time_entry_row"]')
|
||||
.filter({ hasText: description });
|
||||
await expect(timeEntryRow).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Update description
|
||||
const updatedDescription = 'Updated ' + description;
|
||||
const descriptionInput = timeEntryRow.getByTestId('time_entry_description').first();
|
||||
await descriptionInput.fill(updatedDescription);
|
||||
await Promise.all([
|
||||
employee.page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200
|
||||
),
|
||||
descriptionInput.press('Tab'),
|
||||
]);
|
||||
|
||||
// Verify updated description
|
||||
await expect(timeEntryRow.getByTestId('time_entry_description').first()).toHaveValue(
|
||||
updatedDescription
|
||||
);
|
||||
});
|
||||
|
||||
test('employee can delete their own time entry', async ({ ctx, employee }) => {
|
||||
const description = 'EmpDeleteEntry ' + Math.floor(Math.random() * 10000);
|
||||
await createTimeEntryViaApi(
|
||||
{ ...ctx, memberId: employee.memberId },
|
||||
{ description, duration: '1h' }
|
||||
);
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/time');
|
||||
const timeEntryRow = employee.page
|
||||
.locator('[data-testid="time_entry_row"]')
|
||||
.filter({ hasText: description });
|
||||
await expect(timeEntryRow).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Delete via actions menu
|
||||
await timeEntryRow
|
||||
.getByRole('button', { name: 'Actions for the time entry' })
|
||||
.first()
|
||||
.click();
|
||||
await Promise.all([
|
||||
employee.page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/time-entries') &&
|
||||
response.request().method() === 'DELETE'
|
||||
),
|
||||
employee.page.getByTestId('time_entry_delete').click(),
|
||||
]);
|
||||
|
||||
// Verify entry is gone
|
||||
await expect(timeEntryRow).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './utils/currentTimeEntry';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { newTagResponse } from './utils/tags';
|
||||
import { updateOrganizationCurrencyViaWeb } from './utils/api';
|
||||
|
||||
// Date picker button name patterns for different date formats
|
||||
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
|
||||
@@ -28,6 +29,24 @@ test('test that starting and stopping a timer without description and project wo
|
||||
await assertThatTimerIsStopped(page);
|
||||
});
|
||||
|
||||
test('test that billable icon shows dollar sign for USD currency', async ({ page, ctx }) => {
|
||||
await updateOrganizationCurrencyViaWeb(ctx, 'USD');
|
||||
await goToDashboard(page);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
|
||||
await expect(billableButton).toBeVisible();
|
||||
await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 8 14');
|
||||
});
|
||||
|
||||
test('test that billable icon shows euro sign for EUR currency', async ({ page, ctx }) => {
|
||||
await updateOrganizationCurrencyViaWeb(ctx, 'EUR');
|
||||
await goToDashboard(page);
|
||||
await page.waitForLoadState('networkidle');
|
||||
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
|
||||
await expect(billableButton).toBeVisible();
|
||||
await expect(billableButton.locator('svg')).toHaveAttribute('viewBox', '0 0 12 12');
|
||||
});
|
||||
|
||||
test('test that starting and stopping a timer with a description works', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
// Wait for the description input to be editable before filling
|
||||
|
||||
@@ -155,6 +155,21 @@ function randomColor(): string {
|
||||
// Entity creation
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
export async function createPublicProjectViaApi(
|
||||
ctx: TestContext,
|
||||
data: {
|
||||
name: string;
|
||||
is_billable?: boolean;
|
||||
billable_rate?: number | null;
|
||||
client_id?: string | null;
|
||||
}
|
||||
) {
|
||||
return createProjectViaApi(ctx, {
|
||||
...data,
|
||||
is_public: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createProjectViaApi(
|
||||
ctx: TestContext,
|
||||
data: {
|
||||
@@ -164,6 +179,7 @@ export async function createProjectViaApi(
|
||||
billable_rate?: number | null;
|
||||
client_id?: string | null;
|
||||
estimated_time?: number | null;
|
||||
is_public?: boolean;
|
||||
}
|
||||
) {
|
||||
const response = await ctx.request.post(
|
||||
@@ -176,6 +192,7 @@ export async function createProjectViaApi(
|
||||
billable_rate: data.billable_rate ?? null,
|
||||
client_id: data.client_id ?? null,
|
||||
estimated_time: data.estimated_time ?? null,
|
||||
is_public: data.is_public ?? false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test';
|
||||
import type { Browser, Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
|
||||
import { getInvitationAcceptUrl } from './mailpit';
|
||||
import type { TestContext } from './api';
|
||||
|
||||
/**
|
||||
* Register a new user in a fresh browser context and return the page + context.
|
||||
@@ -66,3 +67,98 @@ export async function inviteAndAcceptMember(
|
||||
// 4. Clean up
|
||||
await secondUser.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up an employee member in the owner's organization.
|
||||
* Returns the employee's page, their member ID, and a cleanup function.
|
||||
*
|
||||
* The owner page (from the fixture) is used to invite the employee.
|
||||
* Test data should be created via the owner's ctx.
|
||||
*
|
||||
* IMPORTANT: Projects must be created with is_public: true for the employee to see them,
|
||||
* or the employee must be added as a project member via createProjectMemberViaApi.
|
||||
* Clients are only visible to employees if they have at least one visible project.
|
||||
* Tags are visible to all org members with tags:view permission.
|
||||
*/
|
||||
export async function setupEmployeeUser(
|
||||
ownerPage: Page,
|
||||
ownerCtx: TestContext,
|
||||
browser: Browser
|
||||
): Promise<{
|
||||
employeePage: Page;
|
||||
employeeMemberId: string;
|
||||
closeEmployee: () => Promise<void>;
|
||||
}> {
|
||||
const memberId = Math.floor(Math.random() * 100000);
|
||||
const memberEmail = `employee+${memberId}@emp-perms.test`;
|
||||
const memberName = 'Emp ' + memberId;
|
||||
|
||||
// Register the employee user first
|
||||
const employee = await registerUser(browser, memberName, memberEmail);
|
||||
|
||||
// Send invitation from the owner
|
||||
await ownerPage.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||
await ownerPage.getByRole('button', { name: 'Invite Member' }).click();
|
||||
await expect(ownerPage.getByPlaceholder('Member Email')).toBeVisible();
|
||||
await ownerPage.getByPlaceholder('Member Email').fill(memberEmail);
|
||||
await ownerPage.getByRole('button', { name: 'Employee' }).click();
|
||||
await Promise.all([
|
||||
ownerPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 204
|
||||
),
|
||||
ownerPage.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||
]);
|
||||
|
||||
// Accept the invitation
|
||||
const acceptUrl = await getInvitationAcceptUrl(employee.page.request, memberEmail);
|
||||
await employee.page.goto(acceptUrl);
|
||||
await employee.page.waitForURL(/dashboard/);
|
||||
|
||||
// Navigate to dashboard explicitly and wait for it to load to ensure the correct org context.
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify we're on the correct organization (John's Organization).
|
||||
const orgSwitcherText = await employee.page
|
||||
.getByTestId('organization_switcher')
|
||||
.first()
|
||||
.textContent();
|
||||
if (!orgSwitcherText?.includes("John's Organization")) {
|
||||
// Switch to the owner's org using the PUT /current-team endpoint
|
||||
const cookies = await employee.page.context().cookies();
|
||||
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
|
||||
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
|
||||
|
||||
await employee.page.request.put(`${PLAYWRIGHT_BASE_URL}/current-team`, {
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': xsrfToken,
|
||||
Accept: 'text/html',
|
||||
},
|
||||
data: { team_id: ownerCtx.orgId },
|
||||
});
|
||||
|
||||
// Reload to pick up the new org
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
|
||||
await expect(employee.page.getByTestId('dashboard_view')).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
// Find the employee's member ID in the owner's organization
|
||||
const membersResponse = await ownerCtx.request.get(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ownerCtx.orgId}/members`
|
||||
);
|
||||
expect(membersResponse.status()).toBe(200);
|
||||
const membersBody = await membersResponse.json();
|
||||
const employeeMember = membersBody.data.find(
|
||||
(m: { role: string; name: string }) => m.role === 'employee' && m.name === memberName
|
||||
);
|
||||
expect(employeeMember).toBeTruthy();
|
||||
|
||||
return {
|
||||
employeePage: employee.page,
|
||||
employeeMemberId: employeeMember.id,
|
||||
closeEmployee: employee.close,
|
||||
};
|
||||
}
|
||||
|
||||
+21
-1
@@ -1,17 +1,27 @@
|
||||
import { test as baseTest } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from './config';
|
||||
import { type TestContext, setupTestContext } from '../e2e/utils/api';
|
||||
import { setupEmployeeUser } from '../e2e/utils/members';
|
||||
|
||||
export * from '@playwright/test';
|
||||
export type { TestContext };
|
||||
|
||||
export interface EmployeeFixture {
|
||||
page: Page;
|
||||
memberId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API-based authentication fixture - creates a new user via HTTP requests instead of UI interactions.
|
||||
* This is ~10-25x faster than UI-based authentication (~100-200ms vs ~3-5s).
|
||||
*
|
||||
* Uses page.context().request() to ensure cookies are shared between the API request and page.
|
||||
*/
|
||||
export const test = baseTest.extend<{ ctx: TestContext }, { workerStorageState: string }>({
|
||||
export const test = baseTest.extend<
|
||||
{ ctx: TestContext; employee: EmployeeFixture },
|
||||
{ workerStorageState: string }
|
||||
>({
|
||||
page: async ({ page }, use) => {
|
||||
// Generate unique email for this test
|
||||
const email = `john+${Date.now()}_${Math.floor(Math.random() * 10000)}@doe.com`;
|
||||
@@ -80,4 +90,14 @@ export const test = baseTest.extend<{ ctx: TestContext }, { workerStorageState:
|
||||
const ctx = await setupTestContext(page);
|
||||
await use(ctx);
|
||||
},
|
||||
|
||||
employee: async ({ page, ctx, browser }, use) => {
|
||||
const { employeePage, employeeMemberId, closeEmployee } = await setupEmployeeUser(
|
||||
page,
|
||||
ctx,
|
||||
browser
|
||||
);
|
||||
await use({ page: employeePage, memberId: employeeMemberId });
|
||||
await closeEmployee();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,6 +67,12 @@ const { groupByOptions, getNameForReportingRowEntry, emptyPlaceholder } = report
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
const showBillableRate = computed(() => {
|
||||
return !!(
|
||||
getCurrentRole() !== 'employee' || organization?.value?.employees_can_see_billable_rates
|
||||
);
|
||||
});
|
||||
|
||||
// Ensure sub-group falls back when it collides with group
|
||||
watch(
|
||||
group,
|
||||
@@ -280,12 +286,14 @@ const tableData = computed(() => {
|
||||
groupByOptions.filter((el) => el.value !== group)
|
||||
"></ReportingGroupBySelect>
|
||||
</div>
|
||||
<div class="grid items-center" style="grid-template-columns: 1fr 100px 150px">
|
||||
<div
|
||||
class="grid items-center"
|
||||
:style="`grid-template-columns: 1fr 100px ${showBillableRate ? '150px' : ''}`">
|
||||
<div
|
||||
class="contents [&>*]:border-card-background-separator [&>*]:border-b [&>*]:bg-secondary [&>*]:pb-1.5 [&>*]:pt-1 text-text-tertiary text-sm">
|
||||
<div class="pl-6">Name</div>
|
||||
<div class="text-right">Duration</div>
|
||||
<div class="text-right pr-6">Cost</div>
|
||||
<div v-if="showBillableRate" class="text-right pr-6">Cost</div>
|
||||
</div>
|
||||
<template
|
||||
v-if="
|
||||
@@ -297,6 +305,7 @@ const tableData = computed(() => {
|
||||
:key="entry.description ?? 'none'"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:type="aggregatedTableTimeEntries.grouped_type"
|
||||
:show-cost="showBillableRate"
|
||||
:entry="entry"></ReportingRow>
|
||||
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">
|
||||
<div class="flex items-center pl-6 font-medium">
|
||||
@@ -311,7 +320,9 @@ const tableData = computed(() => {
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="justify-end pr-6 flex items-center font-medium">
|
||||
<div
|
||||
v-if="showBillableRate"
|
||||
class="justify-end pr-6 flex items-center font-medium">
|
||||
{{
|
||||
aggregatedTableTimeEntries.cost
|
||||
? formatCents(
|
||||
@@ -328,7 +339,8 @@ const tableData = computed(() => {
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="chart flex flex-col items-center justify-center py-12 col-span-3">
|
||||
class="chart flex flex-col items-center justify-center py-12"
|
||||
:class="showBillableRate ? 'col-span-3' : 'col-span-2'">
|
||||
<p class="text-lg text-text-primary font-medium">No time entries found</p>
|
||||
<p>Try to change the filters and time range</p>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ const props = defineProps<{
|
||||
entry: AggregatedGroupedData;
|
||||
indent?: boolean;
|
||||
currency: string;
|
||||
showCost?: boolean;
|
||||
}>();
|
||||
|
||||
const expanded = ref(false);
|
||||
@@ -50,7 +51,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="justify-end pr-6 flex items-center">
|
||||
<div v-if="showCost" class="justify-end pr-6 flex items-center">
|
||||
{{
|
||||
entry.cost
|
||||
? formatCents(
|
||||
@@ -66,12 +67,14 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
||||
</div>
|
||||
<div
|
||||
v-if="expanded && entry.grouped_data"
|
||||
class="col-span-3 grid bg-tertiary"
|
||||
style="grid-template-columns: 1fr 150px 150px">
|
||||
:class="showCost ? 'col-span-3' : 'col-span-2'"
|
||||
class="grid bg-tertiary"
|
||||
:style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`">
|
||||
<ReportingRow
|
||||
v-for="subEntry in entry.grouped_data"
|
||||
:key="subEntry.description ?? 'none'"
|
||||
:currency="props.currency"
|
||||
:show-cost="showCost"
|
||||
indent
|
||||
:entry="subEntry"></ReportingRow>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||
import { canViewReport } from '@/utils/permissions';
|
||||
import { computed } from 'vue';
|
||||
defineProps<{
|
||||
import TabBar from '@/Components/Common/TabBar/TabBar.vue';
|
||||
import TabBarItem from '@/Components/Common/TabBar/TabBarItem.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
active: 'reporting' | 'detailed' | 'shared';
|
||||
}>();
|
||||
|
||||
const showSharedReports = computed(() => canViewReport());
|
||||
|
||||
const tabs = computed(() => {
|
||||
const items = [
|
||||
{ value: 'reporting', label: 'Overview', href: route('reporting') },
|
||||
{ value: 'detailed', label: 'Detailed', href: route('reporting.detailed') },
|
||||
];
|
||||
if (showSharedReports.value) {
|
||||
items.push({
|
||||
value: 'shared',
|
||||
label: 'Shared',
|
||||
href: route('reporting.shared'),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
function hrefForTab(value: string) {
|
||||
return tabs.value.find((tab) => tab.value === value)?.href;
|
||||
}
|
||||
|
||||
function onTabChange(value: string | number) {
|
||||
const href = hrefForTab(String(value));
|
||||
if (href) {
|
||||
router.visit(href);
|
||||
}
|
||||
}
|
||||
|
||||
function onTabHover(value: string) {
|
||||
const href = hrefForTab(value);
|
||||
if (href) {
|
||||
router.prefetch(href, {}, { cacheFor: '1m' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabBar :model-value="active">
|
||||
<TabBarItem value="reporting" @click="router.visit(route('reporting'))"
|
||||
>Overview</TabBarItem
|
||||
>
|
||||
<TabBarItem value="detailed" @click="router.visit(route('reporting.detailed'))"
|
||||
>Detailed</TabBarItem
|
||||
>
|
||||
<TabBar :default-value="props.active" @update:model-value="onTabChange">
|
||||
<TabBarItem
|
||||
v-if="showSharedReports"
|
||||
value="shared"
|
||||
@click="router.visit(route('reporting.shared'))"
|
||||
>Shared</TabBarItem
|
||||
>
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:value="tab.value"
|
||||
@mouseenter="onTabHover(tab.value)">
|
||||
{{ tab.label }}
|
||||
</TabBarItem>
|
||||
</TabBar>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -14,13 +14,12 @@ import { useProjectsStore } from '@/utils/useProjects';
|
||||
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
|
||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||
import { useClientsStore } from '@/utils/useClients';
|
||||
import type { CreateClientBody, Client, CreateProjectBody, Project } from '@/packages/api/src';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { getCurrentRole } from '@/utils/useUser';
|
||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||
import { getCurrentOrganizationId, getCurrentRole } from '@/utils/useUser';
|
||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
|
||||
@@ -31,7 +30,7 @@ import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
|
||||
// Fetch data using TanStack Query
|
||||
const { projects } = useProjectsQuery();
|
||||
const { clients } = useClientsQuery();
|
||||
const { organization } = storeToRefs(useOrganizationStore());
|
||||
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
|
||||
|
||||
// Table state persisted in localStorage
|
||||
interface ProjectTableState {
|
||||
|
||||
@@ -5,7 +5,6 @@ import SectionBorder from '@/Components/SectionBorder.vue';
|
||||
import UpdateTeamNameForm from '@/Pages/Teams/Partials/UpdateTeamNameForm.vue';
|
||||
import type { Organization } from '@/types/models';
|
||||
import type { Permissions, Role } from '@/types/jetstream';
|
||||
import { canUpdateOrganization } from '@/utils/permissions';
|
||||
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
||||
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
|
||||
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
|
||||
@@ -46,13 +45,13 @@ onMounted(async () => {
|
||||
<UpdateTeamNameForm :team="team" :permissions="permissions" />
|
||||
|
||||
<SectionBorder />
|
||||
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
|
||||
<OrganizationBillableRate v-if="permissions.canUpdateTeam" :team="team" />
|
||||
<SectionBorder />
|
||||
|
||||
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
|
||||
<OrganizationFormatSettings v-if="permissions.canUpdateTeam" :team="team" />
|
||||
<SectionBorder />
|
||||
|
||||
<OrganizationTimeEntrySettings v-if="canUpdateOrganization()" />
|
||||
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
|
||||
<SectionBorder />
|
||||
|
||||
<template v-if="permissions.canDeleteTeam && !team.personal_team">
|
||||
|
||||
@@ -39,7 +39,7 @@ const maxWidthClass = computed(() => {
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="close">
|
||||
<DialogContent :class="maxWidthClass">
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user