mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
|
import { test } from '../playwright/fixtures';
|
|
import { expect } from '@playwright/test';
|
|
import type { Page } from '@playwright/test';
|
|
import {
|
|
createBillableProjectViaApi,
|
|
createProjectViaApi,
|
|
createBareTimeEntryViaApi,
|
|
createTimeEntryViaApi,
|
|
} from './utils/api';
|
|
|
|
async function goToCalendar(page: Page) {
|
|
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
|
|
}
|
|
|
|
/**
|
|
* These tests verify that changing the project on a time entry via the calendar
|
|
* updates the billable status to match the new project's is_billable setting.
|
|
*
|
|
* Issue: https://github.com/solidtime-io/solidtime/issues/981
|
|
*/
|
|
|
|
test('test that changing project in calendar edit modal from non-billable to billable updates billable status', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
const billableProjectName = 'Billable Cal Project ' + Math.floor(1 + Math.random() * 10000);
|
|
|
|
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
|
await createBareTimeEntryViaApi(ctx, 'Test billable calendar', '1h');
|
|
|
|
await goToCalendar(page);
|
|
|
|
// Click on the time entry event in the calendar
|
|
await page.locator('.fc-event').filter({ hasText: 'Test billable calendar' }).first().click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
|
|
// Verify initially non-billable
|
|
await expect(
|
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })
|
|
).toBeVisible();
|
|
|
|
// Select the billable project
|
|
await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();
|
|
await page.getByRole('option', { name: billableProjectName }).click();
|
|
|
|
// Verify the billable dropdown updated to Billable
|
|
await expect(
|
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })
|
|
).toBeVisible();
|
|
|
|
// Save and verify
|
|
const [updateResponse] = await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/time-entries/') &&
|
|
response.request().method() === 'PUT' &&
|
|
response.status() === 200
|
|
),
|
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
|
]);
|
|
const responseBody = await updateResponse.json();
|
|
expect(responseBody.data.billable).toBe(true);
|
|
});
|
|
|
|
test('test that changing project in calendar edit modal from billable to non-billable updates billable status', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
const billableProjectName = 'Billable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000);
|
|
const nonBillableProjectName =
|
|
'NonBillable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000);
|
|
|
|
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
|
await createProjectViaApi(ctx, { name: nonBillableProjectName });
|
|
await createBareTimeEntryViaApi(ctx, 'Test billable cal reverse', '1h');
|
|
|
|
await goToCalendar(page);
|
|
|
|
// Click on the time entry event in the calendar
|
|
await page
|
|
.locator('.fc-event')
|
|
.filter({ hasText: 'Test billable cal reverse' })
|
|
.first()
|
|
.click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
|
|
// First assign the billable project
|
|
await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();
|
|
await page.getByRole('option', { name: billableProjectName }).click();
|
|
|
|
// Verify billable status flipped to Billable
|
|
await expect(
|
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })
|
|
).toBeVisible();
|
|
|
|
// Now switch to the non-billable project
|
|
await page.getByRole('dialog').getByRole('button', { name: billableProjectName }).click();
|
|
await page.getByRole('option', { name: nonBillableProjectName }).click();
|
|
|
|
// Verify billable status reverted to Non-Billable
|
|
await expect(
|
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })
|
|
).toBeVisible();
|
|
|
|
// Save and verify
|
|
const [updateResponse] = await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/time-entries/') &&
|
|
response.request().method() === 'PUT' &&
|
|
response.status() === 200
|
|
),
|
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
|
]);
|
|
const responseBody = await updateResponse.json();
|
|
expect(responseBody.data.billable).toBe(false);
|
|
});
|
|
|
|
test('test that opening calendar edit modal for a time entry with manually overridden billable status preserves that status', async ({
|
|
page,
|
|
ctx,
|
|
}) => {
|
|
const billableProjectName =
|
|
'Billable Cal Persist Project ' + Math.floor(1 + Math.random() * 10000);
|
|
|
|
await createBillableProjectViaApi(ctx, { name: billableProjectName });
|
|
await createBareTimeEntryViaApi(ctx, 'Test cal persist override', '1h');
|
|
|
|
await goToCalendar(page);
|
|
|
|
// Click on the time entry event in the calendar
|
|
await page
|
|
.locator('.fc-event')
|
|
.filter({ hasText: 'Test cal persist override' })
|
|
.first()
|
|
.click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
|
|
// Assign the billable project
|
|
await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click();
|
|
await page.getByRole('option', { name: billableProjectName }).click();
|
|
|
|
// Verify it auto-set to Billable
|
|
await expect(
|
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' })
|
|
).toBeVisible();
|
|
|
|
// Now manually override billable to Non-Billable via the dropdown
|
|
await page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }).click();
|
|
await page.getByRole('option', { name: 'Non Billable' }).click();
|
|
|
|
// Verify it shows Non-Billable now
|
|
await expect(
|
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })
|
|
).toBeVisible();
|
|
|
|
// Save
|
|
const [firstSaveResponse] = await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/time-entries/') &&
|
|
response.request().method() === 'PUT' &&
|
|
response.status() === 200
|
|
),
|
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
|
]);
|
|
const firstBody = await firstSaveResponse.json();
|
|
expect(firstBody.data.billable).toBe(false);
|
|
|
|
// Re-open the edit modal from the calendar — the project_id watcher should NOT override billable
|
|
await page
|
|
.locator('.fc-event')
|
|
.filter({ hasText: 'Test cal persist override' })
|
|
.first()
|
|
.click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
|
|
// The billable dropdown should still show Non-Billable
|
|
await expect(
|
|
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' })
|
|
).toBeVisible();
|
|
|
|
// Save without changes and verify the response still has billable=false
|
|
const [updateResponse] = await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/time-entries/') &&
|
|
response.request().method() === 'PUT' &&
|
|
response.status() === 200
|
|
),
|
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
|
]);
|
|
const responseBody = await updateResponse.json();
|
|
expect(responseBody.data.billable).toBe(false);
|
|
});
|
|
|
|
test('test that calendar page loads and displays time entries', async ({ page, ctx }) => {
|
|
await createBareTimeEntryViaApi(ctx, 'Calendar display test', '1h');
|
|
|
|
await goToCalendar(page);
|
|
|
|
// Calendar container should be visible
|
|
await expect(page.locator('.fc')).toBeVisible();
|
|
|
|
// The time entry should appear as a calendar event
|
|
await expect(
|
|
page.locator('.fc-event').filter({ hasText: 'Calendar display test' }).first()
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('test that calendar navigation buttons work', async ({ page }) => {
|
|
await goToCalendar(page);
|
|
await expect(page.locator('.fc')).toBeVisible();
|
|
|
|
// Click the "next" button to navigate forward
|
|
await page.locator('button.fc-next-button').click();
|
|
await expect(page.locator('.fc')).toBeVisible();
|
|
|
|
// Click the "prev" button to navigate back
|
|
await page.locator('button.fc-prev-button').click();
|
|
await expect(page.locator('.fc')).toBeVisible();
|
|
|
|
// Navigate forward first so "today" button becomes enabled, then click it
|
|
await page.locator('button.fc-next-button').click();
|
|
await page.locator('button.fc-today-button').click();
|
|
await expect(page.locator('.fc')).toBeVisible();
|
|
});
|
|
|
|
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
|
const originalDescription = 'Edit me in calendar ' + Math.floor(1 + Math.random() * 10000);
|
|
const updatedDescription = 'Updated in calendar ' + Math.floor(1 + Math.random() * 10000);
|
|
await createBareTimeEntryViaApi(ctx, originalDescription, '1h');
|
|
|
|
await goToCalendar(page);
|
|
|
|
// Click on the time entry event
|
|
await page.locator('.fc-event').filter({ hasText: originalDescription }).first().click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
|
|
// Update the description (edit modal uses placeholder, not data-testid)
|
|
const descriptionInput = page.getByRole('dialog').getByPlaceholder('What did you work on?');
|
|
await descriptionInput.fill(updatedDescription);
|
|
|
|
// Save and verify
|
|
const [editResponse] = await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/time-entries/') &&
|
|
response.request().method() === 'PUT' &&
|
|
response.status() === 200
|
|
),
|
|
page.getByRole('button', { name: 'Update Time Entry' }).click(),
|
|
]);
|
|
const editBody = await editResponse.json();
|
|
expect(editBody.data.description).toBe(updatedDescription);
|
|
|
|
// Verify the updated description is shown in the calendar UI
|
|
await expect(
|
|
page.locator('.fc-event').filter({ hasText: updatedDescription }).first()
|
|
).toBeVisible();
|
|
// Verify the old description is no longer shown
|
|
await expect(
|
|
page.locator('.fc-event').filter({ hasText: originalDescription })
|
|
).not.toBeVisible();
|
|
});
|
|
|
|
test('test that deleting time entry from calendar modal works', async ({ page, ctx }) => {
|
|
const description = 'Delete me from calendar ' + Math.floor(1 + Math.random() * 10000);
|
|
await createBareTimeEntryViaApi(ctx, description, '1h');
|
|
|
|
await goToCalendar(page);
|
|
|
|
// Click on the time entry event
|
|
await page.locator('.fc-event').filter({ hasText: description }).first().click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
|
|
// Click the delete button
|
|
await Promise.all([
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/time-entries/') &&
|
|
response.request().method() === 'DELETE' &&
|
|
response.status() === 204
|
|
),
|
|
page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(),
|
|
]);
|
|
|
|
// 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();
|
|
});
|
|
});
|