From b796d232f5b0b3752a81117408f7f2f34b21f95a Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Mon, 5 May 2025 17:23:01 +0200 Subject: [PATCH] add reporting tests for detailed, project filter, billable filter, tag filter --- e2e/reporting.spec.ts | 187 +++++++++++++++++- e2e/time.spec.ts | 8 +- .../ui/src/TimeEntry/TimeEntryCreateModal.vue | 3 +- .../TimeEntry/TimeEntryRowDurationInput.vue | 2 +- 4 files changed, 189 insertions(+), 11 deletions(-) diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts index 102a54c7..2e07c9ba 100644 --- a/e2e/reporting.spec.ts +++ b/e2e/reporting.spec.ts @@ -1,5 +1,186 @@ -// TODO: Test filter +import { expect, Page } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; -// TODO: Test date range -// TODO: Test grouping and sub-grouping + +async function goToTimeOverview(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); +} + +async function goToReporting(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/reporting'); +} + +async function goToReportingDetailed(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/detailed'); +} + +async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) { + // First create the project through the Projects page + await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(projectName); + await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click(); + + // Wait for the project to be created and visible in the list + await page.getByText(projectName).waitFor({ state: 'visible' }); + + // Then create the time entry + await goToTimeOverview(page); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + + // Fill in the time entry details + await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`); + + await page.getByRole('button', { name: 'No Project' }).click(); + await page.getByText(projectName).click(); + + // Set duration + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + + // Submit the time entry + await Promise.all([ + page.getByRole('button', { name: 'Create Time Entry' }).click(), + page.waitForLoadState('networkidle') + ]); +} + +async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) { + await goToTimeOverview(page); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + + // Fill in the time entry details + await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`); + + // Add tag + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByText('Create new tag').click(); + await page.getByPlaceholder('Tag Name').fill(tagName); + await page.getByRole('button', { name: 'Create Tag' }).click(); + await page.waitForLoadState('networkidle'); + + // Set duration + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + + // Submit the time entry + await page.getByRole('button', { name: 'Create Time Entry' }).click(); +} + +async function createTimeEntryWithBillableStatus(page: Page, isBillable: boolean, duration: string) { + await goToTimeOverview(page); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + + // Fill in the time entry details + await page.getByTestId('time_entry_description').fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`); + + // Set billable status + await page.getByRole('button', { name: 'Non-Billable' }).click(); + if (!isBillable) { + await page.getByRole('option', { name: 'Non Billable', exact: true }).click(); + } else { + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + } + + // Set duration + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + + // Submit the time entry + await page.getByRole('button', { name: 'Create Time Entry' }).click(); +} + +test('test that project filtering works in reporting', async ({ page }) => { + const project1 = 'Test Project 1 ' + Math.floor(Math.random() * 10000); + const project2 = 'Test Project 2 ' + Math.floor(Math.random() * 10000); + + // Create time entries for both projects + await createTimeEntryWithProject(page, project1, '1h'); + await createTimeEntryWithProject(page, project2, '2h'); + + // Go to reporting and filter by project1 + await goToReporting(page); + await page.getByRole('button', { name: 'Project' }).nth(0).click(); + await page.getByText(project1).click(); + + await Promise.all([ + // escape + page.keyboard.press('Escape'), + // wait for API request to finish + page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200) + ]); + await page.waitForLoadState('networkidle'); + + // Verify only project1 time entries are shown + await expect(page.getByText(project1)).toBeVisible(); + await expect(page.getByText(project2)).not.toBeVisible(); +}); + +test('test that tag filtering works in reporting', async ({ page }) => { + const tag1 = 'Test Tag 1 ' + Math.floor(Math.random() * 10000); + const tag2 = 'Test Tag 2 ' + Math.floor(Math.random() * 10000); + + // Create time entries with different tags + await createTimeEntryWithTag(page, tag1, '1h'); + await createTimeEntryWithTag(page, tag2, '2h'); + + // Go to reporting and filter by tag1 + await goToReporting(page); + // wait for all requests to finish + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByText(tag1).click(); + + await Promise.all([ + // escape + page.keyboard.press('Escape'), + // wait for API request to finish + page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200) + ]); + + // Verify only time entries with tag1 are shown + await expect(page.getByText('1h 00min').first()).toBeVisible(); +}); + +test('test that billable status filtering works in reporting', async ({ page }) => { + // Create billable and non-billable time entries + await createTimeEntryWithBillableStatus(page, true, '1h'); + await createTimeEntryWithBillableStatus(page, false, '2h'); + + // Go to reporting and filter by billable + await goToReporting(page); + + await page.getByRole('button', { name: 'Billable' }).click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + + await Promise.all([ + // escape + page.keyboard.press('Escape'), + // wait for API request to finish + page.waitForResponse(response => response.url().includes('/time-entries/aggregate') && response.status() === 200) + ]); + await page.waitForLoadState('networkidle'); + + await expect(page.getByText('1h 00min').first()).toBeVisible(); +}); + + +test('test that detailed view shows time entries correctly', async ({ page }) => { + const projectName = 'Detailed View Project ' + Math.floor(Math.random() * 10000); + + // Create a time entry + await createTimeEntryWithProject(page, projectName, '1h'); + + // Go to detailed reporting view + await goToReportingDetailed(page); + + // Verify the time entry is shown with all details + await expect(page.getByText(projectName, { exact: true })).toBeVisible(); + await expect(page.locator('input[name="Duration"]')).toHaveValue('1h 00min'); + await expect(page.getByText('Time entry for ' + projectName, { exact: true })).toBeVisible(); +}); + +// TODO: test that date range filtering works in reporting diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index 14155158..da2aa7ce 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -218,9 +218,7 @@ test('test that updating a the duration in the overview works on blur', async ({ const newTimeEntry = timeEntryRows.first(); await assertThatTimeEntryRowIsStopped(newTimeEntry); await page.waitForTimeout(1500); - const timeEntryDurationInput = newTimeEntry.getByTestId( - 'time_entry_duration_input' - ); + const timeEntryDurationInput = newTimeEntry.locator('input[name="Duration"]'); await timeEntryDurationInput.fill('20min'); await Promise.all([ @@ -238,9 +236,7 @@ test('test that updating a the duration in the overview works on blur', async ({ timeEntryDurationInput.press('Tab'), ]); - await expect( - newTimeEntry.getByTestId('time_entry_duration_input') - ).toHaveValue('0h 20min'); + await expect(timeEntryDurationInput).toHaveValue('0h 20min'); }); // Test that start stop button stops running timer diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue index 765203cc..1e670dd6 100644 --- a/resources/js/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue +++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue @@ -230,7 +230,8 @@ type BillableOption = {
+ v-model:end="localEnd" + name="Duration">
diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue index 62e15a23..dc8fd9f8 100644 --- a/resources/js/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue +++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue @@ -63,7 +63,7 @@ function selectInput(event: Event) {