diff --git a/e2e/shared-reports-public.spec.ts b/e2e/shared-reports-public.spec.ts new file mode 100644 index 00000000..d44f103a --- /dev/null +++ b/e2e/shared-reports-public.spec.ts @@ -0,0 +1,508 @@ +import { expect, Page, Browser } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; + +async function goToSharedReports(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared'); +} + +async function goToReporting(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/reporting'); +} + +async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) { + 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(); + await page.getByText(projectName).waitFor({ state: 'visible' }); + + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`); + await page.getByRole('button', { name: 'No Project' }).click(); + await page.getByText(projectName).click(); + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await Promise.all([ + page.getByRole('button', { name: 'Create Time Entry' }).click(), + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + ]); +} + +async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) { + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`); + 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'); + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await page.getByRole('button', { name: 'Create Time Entry' }).click(); +} + +async function createTimeEntryWithBillableStatus( + page: Page, + isBillable: boolean, + duration: string +) { + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page + .getByTestId('time_entry_description') + .fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`); + 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(); + } + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await page.getByRole('button', { name: 'Create Time Entry' }).click(); +} + +async function createReport( + page: Page, + reportName: string, + options: { + projectFilter?: string; + tagFilter?: string; + billableFilter?: 'billable' | 'non-billable' | 'all'; + timeRange?: { start: string; end: string }; + } = {} +) { + await goToReporting(page); + await page.waitForLoadState('networkidle'); + + // Apply filters if specified + if (options.projectFilter) { + await page.getByRole('button', { name: 'Project' }).nth(0).click(); + await page.getByText(options.projectFilter).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + } + + if (options.tagFilter) { + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByText(options.tagFilter).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + } + + if (options.billableFilter && options.billableFilter !== 'all') { + await page.getByRole('button', { name: 'Billable' }).click(); + if (options.billableFilter === 'billable') { + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + } else { + await page.getByRole('option', { name: 'Non Billable', exact: true }).click(); + } + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + } + + // Set custom time range if specified + if (options.timeRange) { + await page.getByRole('button', { name: 'This Week' }).click(); + await page.getByRole('option', { name: 'Custom Range' }).click(); + await page.locator('input[name="startDate"]').fill(options.timeRange.start); + await page.locator('input[name="endDate"]').fill(options.timeRange.end); + await page.getByRole('button', { name: 'Apply' }).click(); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + } + + await page.waitForLoadState('networkidle'); + + // Save the report + await page.getByRole('button', { name: 'Save Report' }).click(); + await page.getByLabel('Report Name').fill(reportName); + await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(); + await page.waitForLoadState('networkidle'); +} + +async function makeReportPublic(page: Page, reportName: string): Promise { + await goToSharedReports(page); + await page.waitForLoadState('networkidle'); + + // Find the report row and click the edit button + const reportRow = page.locator('tr').filter({ hasText: reportName }); + await reportRow.getByRole('button', { name: 'Edit' }).click(); + + // Make the report public + await page.getByRole('switch', { name: 'Make report public' }).click(); + + // Wait for the API response + await page.waitForResponse( + (response) => response.url().includes('/reports/') && response.status() === 200 + ); + + // Save the changes + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + // Get the public URL + const copyButton = reportRow.getByRole('button', { name: 'Copy URL' }); + await copyButton.click(); + + // Extract the URL from clipboard or from the button's data attribute + const publicUrl = await page.evaluate(() => navigator.clipboard.readText()); + + return publicUrl; +} + +async function createUnauthenticatedPage(browser: Browser): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + return page; +} + +test('access public shared report without authentication', async ({ page, browser }) => { + const projectName = 'Public Access Project ' + Math.floor(Math.random() * 10000); + const reportName = 'Public Access Report ' + Math.floor(Math.random() * 10000); + + // Create test data with authenticated user + await createTimeEntryWithProject(page, projectName, '2h 30min'); + + // Create and make report public + await createReport(page, reportName, { projectFilter: projectName }); + const publicUrl = await makeReportPublic(page, reportName); + + // Create unauthenticated page + const unauthenticatedPage = await createUnauthenticatedPage(browser); + + // Access the public report URL + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify the report is accessible and displays data + await expect(unauthenticatedPage.getByText(reportName)).toBeVisible(); + await expect(unauthenticatedPage.getByText(projectName)).toBeVisible(); + await expect(unauthenticatedPage.getByText('2h 30min')).toBeVisible(); + + // Verify no authentication elements are present + await expect(unauthenticatedPage.getByRole('button', { name: 'Login' })).not.toBeVisible(); + await expect(unauthenticatedPage.getByRole('button', { name: 'Register' })).not.toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('access public shared report with project filter shows filtered data', async ({ + page, + browser, +}) => { + const projectName = 'Filtered Project ' + Math.floor(Math.random() * 10000); + const otherProjectName = 'Other Project ' + Math.floor(Math.random() * 10000); + const reportName = 'Filtered Report ' + Math.floor(Math.random() * 10000); + + // Create test data for two projects + await createTimeEntryWithProject(page, projectName, '1h 30min'); + await createTimeEntryWithProject(page, otherProjectName, '45min'); + + // Create and make report public with project filter + await createReport(page, reportName, { projectFilter: projectName }); + const publicUrl = await makeReportPublic(page, reportName); + + // Create unauthenticated page + const unauthenticatedPage = await createUnauthenticatedPage(browser); + + // Access the public report URL + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify only filtered project data is shown + await expect(unauthenticatedPage.getByText(projectName)).toBeVisible(); + await expect(unauthenticatedPage.getByText(otherProjectName)).not.toBeVisible(); + await expect(unauthenticatedPage.getByText('1h 30min')).toBeVisible(); + await expect(unauthenticatedPage.getByText('45min')).not.toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('access public shared report with tag filter shows filtered data', async ({ + page, + browser, +}) => { + const tagName = 'PublicTag' + Math.floor(Math.random() * 10000); + const otherTagName = 'PrivateTag' + Math.floor(Math.random() * 10000); + const reportName = 'Tag Filtered Report ' + Math.floor(Math.random() * 10000); + + // Create test data for two tags + await createTimeEntryWithTag(page, tagName, '2h'); + await createTimeEntryWithTag(page, otherTagName, '1h'); + + // Create and make report public with tag filter + await createReport(page, reportName, { tagFilter: tagName }); + const publicUrl = await makeReportPublic(page, reportName); + + // Create unauthenticated page + const unauthenticatedPage = await createUnauthenticatedPage(browser); + + // Access the public report URL + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify only filtered tag data is shown + await expect(unauthenticatedPage.getByText(tagName)).toBeVisible(); + await expect(unauthenticatedPage.getByText(otherTagName)).not.toBeVisible(); + await expect(unauthenticatedPage.getByText('2h 00min')).toBeVisible(); + await expect(unauthenticatedPage.getByText('1h 00min')).not.toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('access public shared report with billable filter shows filtered data', async ({ + page, + browser, +}) => { + const reportName = 'Billable Filtered Report ' + Math.floor(Math.random() * 10000); + + // Create test data for billable and non-billable entries + await createTimeEntryWithBillableStatus(page, true, '3h'); + await createTimeEntryWithBillableStatus(page, false, '1h 30min'); + + // Create and make report public with billable filter + await createReport(page, reportName, { billableFilter: 'billable' }); + const publicUrl = await makeReportPublic(page, reportName); + + // Create unauthenticated page + const unauthenticatedPage = await createUnauthenticatedPage(browser); + + // Access the public report URL + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify only billable data is shown + await expect(unauthenticatedPage.getByText('3h 00min')).toBeVisible(); + await expect(unauthenticatedPage.getByText('1h 30min')).not.toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('access public shared report with custom time range shows filtered data', async ({ + page, + browser, +}) => { + const projectName = 'TimeRange Project ' + Math.floor(Math.random() * 10000); + const reportName = 'TimeRange Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithProject(page, projectName, '2h 15min'); + + // Create and make report public with custom time range + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 7); + const endDate = new Date(); + + await createReport(page, reportName, { + projectFilter: projectName, + timeRange: { + start: startDate.toISOString().split('T')[0], + end: endDate.toISOString().split('T')[0], + }, + }); + const publicUrl = await makeReportPublic(page, reportName); + + // Create unauthenticated page + const unauthenticatedPage = await createUnauthenticatedPage(browser); + + // Access the public report URL + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify the data is shown within the time range + await expect(unauthenticatedPage.getByText(projectName)).toBeVisible(); + await expect(unauthenticatedPage.getByText('2h 15min')).toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('access public shared report with multiple filters shows correctly filtered data', async ({ + page, + browser, +}) => { + const projectName = 'MultiFilter Project ' + Math.floor(Math.random() * 10000); + const tagName = 'MultiTag' + Math.floor(Math.random() * 10000); + const reportName = 'MultiFilter Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithProject(page, projectName, '1h'); + + // Create a time entry with project, tag, and billable status + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page.getByTestId('time_entry_description').fill('Multi-filter entry'); + + // Set project + await page.getByRole('button', { name: 'No Project' }).click(); + await page.getByText(projectName).click(); + + // Set 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 as billable + await page.getByRole('button', { name: 'Non-Billable' }).click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + + await page.locator('[role="dialog"] input[name="Duration"]').fill('2h 30min'); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await page.getByRole('button', { name: 'Create Time Entry' }).click(); + + // Create and make report public with multiple filters + await createReport(page, reportName, { + projectFilter: projectName, + tagFilter: tagName, + billableFilter: 'billable', + }); + const publicUrl = await makeReportPublic(page, reportName); + + // Create unauthenticated page + const unauthenticatedPage = await createUnauthenticatedPage(browser); + + // Access the public report URL + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify the filtered data is shown + await expect(unauthenticatedPage.getByText(projectName)).toBeVisible(); + await expect(unauthenticatedPage.getByText(tagName)).toBeVisible(); + await expect(unauthenticatedPage.getByText('2h 30min')).toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('cannot access private shared report without authentication', async ({ page, browser }) => { + const projectName = 'Private Project ' + Math.floor(Math.random() * 10000); + const reportName = 'Private Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithProject(page, projectName, '1h'); + + // Create report but don't make it public + await createReport(page, reportName, { projectFilter: projectName }); + + // Try to access the shared reports page without authentication + const unauthenticatedPage = await createUnauthenticatedPage(browser); + await unauthenticatedPage.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared'); + + // Should redirect to login or show unauthorized + await expect(unauthenticatedPage.getByRole('button', { name: 'Login' })).toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('cannot access public shared report with invalid share secret', async ({ page, browser }) => { + const projectName = 'Invalid Secret Project ' + Math.floor(Math.random() * 10000); + const reportName = 'Invalid Secret Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithProject(page, projectName, '1h'); + + // Create and make report public + await createReport(page, reportName, { projectFilter: projectName }); + await makeReportPublic(page, reportName); + + // Create unauthenticated page + const unauthenticatedPage = await createUnauthenticatedPage(browser); + + // Try to access with invalid share secret + const invalidUrl = PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-123'; + await unauthenticatedPage.goto(invalidUrl); + + // Should show error or not found + await expect(unauthenticatedPage.getByText('Report not found')).toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('public shared report displays charts and visualizations', async ({ page, browser }) => { + const projectName = 'Chart Project ' + Math.floor(Math.random() * 10000); + const reportName = 'Chart Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithProject(page, projectName, '4h'); + + // Create and make report public + await createReport(page, reportName, { projectFilter: projectName }); + const publicUrl = await makeReportPublic(page, reportName); + + // Create unauthenticated page + const unauthenticatedPage = await createUnauthenticatedPage(browser); + + // Access the public report URL + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify charts are displayed + await expect(unauthenticatedPage.locator('canvas')).toBeVisible(); + + // Verify summary statistics + await expect(unauthenticatedPage.getByText('Total Time')).toBeVisible(); + await expect(unauthenticatedPage.getByText('4h 00min')).toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('public shared report shows correct report metadata', async ({ page, browser }) => { + const projectName = 'Metadata Project ' + Math.floor(Math.random() * 10000); + const reportName = 'Metadata Report ' + Math.floor(Math.random() * 10000); + const description = 'This is a public report showing project data'; + + // Create test data + await createTimeEntryWithProject(page, projectName, '1h 45min'); + + // Create report + await createReport(page, reportName, { projectFilter: projectName }); + + // Add description and make public + await goToSharedReports(page); + await page.waitForLoadState('networkidle'); + + const reportRow = page.locator('tr').filter({ hasText: reportName }); + await reportRow.getByRole('button', { name: 'Edit' }).click(); + await page.getByLabel('Description').fill(description); + await page.getByRole('switch', { name: 'Make report public' }).click(); + await page.waitForResponse( + (response) => response.url().includes('/reports/') && response.status() === 200 + ); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + // Get public URL + const copyButton = reportRow.getByRole('button', { name: 'Copy URL' }); + await copyButton.click(); + const publicUrl = await page.evaluate(() => navigator.clipboard.readText()); + + // Create unauthenticated page + const unauthenticatedPage = await createUnauthenticatedPage(browser); + + // Access the public report URL + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify report metadata + await expect(unauthenticatedPage.getByText(reportName)).toBeVisible(); + await expect(unauthenticatedPage.getByText(description)).toBeVisible(); + + await unauthenticatedPage.close(); +}); diff --git a/e2e/shared-reports-validation.spec.ts b/e2e/shared-reports-validation.spec.ts new file mode 100644 index 00000000..ce5a87d7 --- /dev/null +++ b/e2e/shared-reports-validation.spec.ts @@ -0,0 +1,542 @@ +import { expect, Page, Browser } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; + +async function goToSharedReports(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared'); +} + +async function goToReporting(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/reporting'); +} + +async function createTimeEntryWithProject( + page: Page, + projectName: string, + duration: string, + description: string = '' +) { + 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(); + await page.getByText(projectName).waitFor({ state: 'visible' }); + + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page + .getByTestId('time_entry_description') + .fill(description || `Time entry for ${projectName}`); + await page.getByRole('button', { name: 'No Project' }).click(); + await page.getByText(projectName).click(); + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await Promise.all([ + page.getByRole('button', { name: 'Create Time Entry' }).click(), + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + ]); +} + +async function createTimeEntryWithTag( + page: Page, + tagName: string, + duration: string, + description: string = '' +) { + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page + .getByTestId('time_entry_description') + .fill(description || `Time entry with tag ${tagName}`); + 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'); + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await page.getByRole('button', { name: 'Create Time Entry' }).click(); +} + +async function createTimeEntryWithBillableStatus( + page: Page, + isBillable: boolean, + duration: string, + description: string = '' +) { + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page + .getByTestId('time_entry_description') + .fill(description || `Time entry ${isBillable ? 'billable' : 'non-billable'}`); + 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(); + } + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await page.getByRole('button', { name: 'Create Time Entry' }).click(); +} + +async function createReport( + page: Page, + reportName: string, + options: { + projectFilter?: string; + tagFilter?: string; + billableFilter?: 'billable' | 'non-billable' | 'all'; + timeRange?: { start: string; end: string }; + } = {} +) { + await goToReporting(page); + await page.waitForLoadState('networkidle'); + + // Apply filters if specified + if (options.projectFilter) { + await page.getByRole('button', { name: 'Project' }).nth(0).click(); + await page.getByText(options.projectFilter).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + } + + if (options.tagFilter) { + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByText(options.tagFilter).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + } + + if (options.billableFilter && options.billableFilter !== 'all') { + await page.getByRole('button', { name: 'Billable' }).click(); + if (options.billableFilter === 'billable') { + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + } else { + await page.getByRole('option', { name: 'Non Billable', exact: true }).click(); + } + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + } + + // Set custom time range if specified + if (options.timeRange) { + await page.getByRole('button', { name: 'This Week' }).click(); + await page.getByRole('option', { name: 'Custom Range' }).click(); + await page.locator('input[name="startDate"]').fill(options.timeRange.start); + await page.locator('input[name="endDate"]').fill(options.timeRange.end); + await page.getByRole('button', { name: 'Apply' }).click(); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + } + + await page.waitForLoadState('networkidle'); + + // Save the report + await page.getByRole('button', { name: 'Save Report' }).click(); + await page.getByLabel('Report Name').fill(reportName); + await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(); + await page.waitForLoadState('networkidle'); +} + +async function makeReportPublic(page: Page, reportName: string): Promise { + await goToSharedReports(page); + await page.waitForLoadState('networkidle'); + + // Find the report row and click the edit button + const reportRow = page.locator('tr').filter({ hasText: reportName }); + await reportRow.getByRole('button', { name: 'Edit' }).click(); + + // Make the report public + await page.getByRole('switch', { name: 'Make report public' }).click(); + + // Wait for the API response + await page.waitForResponse( + (response) => response.url().includes('/reports/') && response.status() === 200 + ); + + // Save the changes + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + // Get the public URL + const copyButton = reportRow.getByRole('button', { name: 'Copy URL' }); + await copyButton.click(); + + // Extract the URL from clipboard or from the button's data attribute + const publicUrl = await page.evaluate(() => navigator.clipboard.readText()); + + return publicUrl; +} + +async function createUnauthenticatedPage(browser: Browser): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + return page; +} + +test('verify shared report data accuracy with project filter', async ({ page, browser }) => { + const projectName = 'Accuracy Project ' + Math.floor(Math.random() * 10000); + const otherProjectName = 'Other Accuracy Project ' + Math.floor(Math.random() * 10000); + const reportName = 'Accuracy Report ' + Math.floor(Math.random() * 10000); + + // Create test data with specific durations + await createTimeEntryWithProject(page, projectName, '2h 30min', 'Task 1'); + await createTimeEntryWithProject(page, projectName, '1h 15min', 'Task 2'); + await createTimeEntryWithProject(page, otherProjectName, '3h', 'Other task'); + + // Create and make report public with project filter + await createReport(page, reportName, { projectFilter: projectName }); + const publicUrl = await makeReportPublic(page, reportName); + + // Verify data in authenticated reporting view + await goToReporting(page); + await page.getByRole('button', { name: 'Project' }).nth(0).click(); + await page.getByText(projectName).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + + // Note expected total: 2h 30min + 1h 15min = 3h 45min + await expect(page.getByText('3h 45min')).toBeVisible(); + + // Verify same data in public view + const unauthenticatedPage = await createUnauthenticatedPage(browser); + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify total time matches + await expect(unauthenticatedPage.getByText('3h 45min')).toBeVisible(); + await expect(unauthenticatedPage.getByText(projectName)).toBeVisible(); + await expect(unauthenticatedPage.getByText('Task 1')).toBeVisible(); + await expect(unauthenticatedPage.getByText('Task 2')).toBeVisible(); + await expect(unauthenticatedPage.getByText(otherProjectName)).not.toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('verify shared report data accuracy with tag filter', async ({ page, browser }) => { + const tagName = 'AccuracyTag' + Math.floor(Math.random() * 10000); + const otherTagName = 'OtherTag' + Math.floor(Math.random() * 10000); + const reportName = 'Tag Accuracy Report ' + Math.floor(Math.random() * 10000); + + // Create test data with specific durations + await createTimeEntryWithTag(page, tagName, '1h 30min', 'Tagged task 1'); + await createTimeEntryWithTag(page, tagName, '2h 15min', 'Tagged task 2'); + await createTimeEntryWithTag(page, otherTagName, '45min', 'Other tagged task'); + + // Create and make report public with tag filter + await createReport(page, reportName, { tagFilter: tagName }); + const publicUrl = await makeReportPublic(page, reportName); + + // Verify data in authenticated reporting view + await goToReporting(page); + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByText(tagName).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + + // Note expected total: 1h 30min + 2h 15min = 3h 45min + await expect(page.getByText('3h 45min')).toBeVisible(); + + // Verify same data in public view + const unauthenticatedPage = await createUnauthenticatedPage(browser); + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify total time matches + await expect(unauthenticatedPage.getByText('3h 45min')).toBeVisible(); + await expect(unauthenticatedPage.getByText(tagName)).toBeVisible(); + await expect(unauthenticatedPage.getByText('Tagged task 1')).toBeVisible(); + await expect(unauthenticatedPage.getByText('Tagged task 2')).toBeVisible(); + await expect(unauthenticatedPage.getByText(otherTagName)).not.toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('verify shared report data accuracy with billable filter', async ({ page, browser }) => { + const reportName = 'Billable Accuracy Report ' + Math.floor(Math.random() * 10000); + + // Create test data with specific durations + await createTimeEntryWithBillableStatus(page, true, '2h', 'Billable task 1'); + await createTimeEntryWithBillableStatus(page, true, '1h 30min', 'Billable task 2'); + await createTimeEntryWithBillableStatus(page, false, '45min', 'Non-billable task'); + + // Create and make report public with billable filter + await createReport(page, reportName, { billableFilter: 'billable' }); + const publicUrl = await makeReportPublic(page, reportName); + + // Verify data in authenticated reporting view + await goToReporting(page); + await page.getByRole('button', { name: 'Billable' }).click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + + // Note expected total: 2h + 1h 30min = 3h 30min + await expect(page.getByText('3h 30min')).toBeVisible(); + + // Verify same data in public view + const unauthenticatedPage = await createUnauthenticatedPage(browser); + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify total time matches + await expect(unauthenticatedPage.getByText('3h 30min')).toBeVisible(); + await expect(unauthenticatedPage.getByText('Billable task 1')).toBeVisible(); + await expect(unauthenticatedPage.getByText('Billable task 2')).toBeVisible(); + await expect(unauthenticatedPage.getByText('Non-billable task')).not.toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('verify shared report data accuracy with non-billable filter', async ({ page, browser }) => { + const reportName = 'Non-Billable Accuracy Report ' + Math.floor(Math.random() * 10000); + + // Create test data with specific durations + await createTimeEntryWithBillableStatus(page, false, '1h 45min', 'Non-billable task 1'); + await createTimeEntryWithBillableStatus(page, false, '2h 30min', 'Non-billable task 2'); + await createTimeEntryWithBillableStatus(page, true, '1h', 'Billable task'); + + // Create and make report public with non-billable filter + await createReport(page, reportName, { billableFilter: 'non-billable' }); + const publicUrl = await makeReportPublic(page, reportName); + + // Verify data in authenticated reporting view + await goToReporting(page); + await page.getByRole('button', { name: 'Billable' }).click(); + await page.getByRole('option', { name: 'Non Billable', exact: true }).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + + // Note expected total: 1h 45min + 2h 30min = 4h 15min + await expect(page.getByText('4h 15min')).toBeVisible(); + + // Verify same data in public view + const unauthenticatedPage = await createUnauthenticatedPage(browser); + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify total time matches + await expect(unauthenticatedPage.getByText('4h 15min')).toBeVisible(); + await expect(unauthenticatedPage.getByText('Non-billable task 1')).toBeVisible(); + await expect(unauthenticatedPage.getByText('Non-billable task 2')).toBeVisible(); + await expect(unauthenticatedPage.getByText('Billable task')).not.toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('verify shared report data accuracy with multiple filters', async ({ page, browser }) => { + const projectName = 'MultiAccuracy Project ' + Math.floor(Math.random() * 10000); + const tagName = 'MultiAccuracyTag' + Math.floor(Math.random() * 10000); + const reportName = 'MultiAccuracy Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithProject(page, projectName, '1h', 'Project only'); + + // Create a time entry with project, tag, and billable status + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page.getByTestId('time_entry_description').fill('Multi-filter matched entry'); + + // Set project + await page.getByRole('button', { name: 'No Project' }).click(); + await page.getByText(projectName).click(); + + // Set 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 as billable + await page.getByRole('button', { name: 'Non-Billable' }).click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + + await page.locator('[role="dialog"] input[name="Duration"]').fill('2h 30min'); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await page.getByRole('button', { name: 'Create Time Entry' }).click(); + + // Create another entry that won't match all filters + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page.getByTestId('time_entry_description').fill('Partial match entry'); + + // Set same project but different tag and non-billable + await page.getByRole('button', { name: 'No Project' }).click(); + await page.getByText(projectName).click(); + + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByText('Create new tag').click(); + await page.getByPlaceholder('Tag Name').fill('DifferentTag'); + await page.getByRole('button', { name: 'Create Tag' }).click(); + await page.waitForLoadState('networkidle'); + + await page.locator('[role="dialog"] input[name="Duration"]').fill('1h 15min'); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await page.getByRole('button', { name: 'Create Time Entry' }).click(); + + // Create and make report public with multiple filters + await createReport(page, reportName, { + projectFilter: projectName, + tagFilter: tagName, + billableFilter: 'billable', + }); + const publicUrl = await makeReportPublic(page, reportName); + + // Verify data in authenticated reporting view + await goToReporting(page); + await page.getByRole('button', { name: 'Project' }).nth(0).click(); + await page.getByText(projectName).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByText(tagName).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + + await page.getByRole('button', { name: 'Billable' }).click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + + // Should only show the entry that matches all filters (2h 30min) + await expect(page.getByText('2h 30min')).toBeVisible(); + + // Verify same data in public view + const unauthenticatedPage = await createUnauthenticatedPage(browser); + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify only the matching entry is shown + await expect(unauthenticatedPage.getByText('2h 30min')).toBeVisible(); + await expect(unauthenticatedPage.getByText('Multi-filter matched entry')).toBeVisible(); + await expect(unauthenticatedPage.getByText('Project only')).not.toBeVisible(); + await expect(unauthenticatedPage.getByText('Partial match entry')).not.toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('verify shared report data accuracy with time range filter', async ({ page, browser }) => { + const projectName = 'TimeRange Accuracy Project ' + Math.floor(Math.random() * 10000); + const reportName = 'TimeRange Accuracy Report ' + Math.floor(Math.random() * 10000); + + // Create test data within date range + await createTimeEntryWithProject(page, projectName, '1h 30min', 'Within range 1'); + await createTimeEntryWithProject(page, projectName, '2h 15min', 'Within range 2'); + + // Create and make report public with time range + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 1); + const endDate = new Date(); + endDate.setDate(endDate.getDate() + 1); + + await createReport(page, reportName, { + projectFilter: projectName, + timeRange: { + start: startDate.toISOString().split('T')[0], + end: endDate.toISOString().split('T')[0], + }, + }); + const publicUrl = await makeReportPublic(page, reportName); + + // Verify data in authenticated reporting view + await goToReporting(page); + await page.getByRole('button', { name: 'Project' }).nth(0).click(); + await page.getByText(projectName).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + + await page.getByRole('button', { name: 'This Week' }).click(); + await page.getByRole('option', { name: 'Custom Range' }).click(); + await page.locator('input[name="startDate"]').fill(startDate.toISOString().split('T')[0]); + await page.locator('input[name="endDate"]').fill(endDate.toISOString().split('T')[0]); + await page.getByRole('button', { name: 'Apply' }).click(); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + + // Note expected total: 1h 30min + 2h 15min = 3h 45min + await expect(page.getByText('3h 45min')).toBeVisible(); + + // Verify same data in public view + const unauthenticatedPage = await createUnauthenticatedPage(browser); + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify total time matches + await expect(unauthenticatedPage.getByText('3h 45min')).toBeVisible(); + await expect(unauthenticatedPage.getByText('Within range 1')).toBeVisible(); + await expect(unauthenticatedPage.getByText('Within range 2')).toBeVisible(); + + await unauthenticatedPage.close(); +}); + +test('verify shared report shows zero data when no entries match filters', async ({ + page, + browser, +}) => { + const projectName = 'NoMatch Project ' + Math.floor(Math.random() * 10000); + const tagName = 'NoMatchTag' + Math.floor(Math.random() * 10000); + const reportName = 'NoMatch Report ' + Math.floor(Math.random() * 10000); + + // Create test data that won't match our filters + await createTimeEntryWithProject(page, 'Other Project', '1h', 'Other entry'); + + // Create and make report public with filters that won't match + await createReport(page, reportName, { + projectFilter: projectName, // This project doesn't exist + tagFilter: tagName, // This tag doesn't exist + }); + const publicUrl = await makeReportPublic(page, reportName); + + // Verify data in public view shows zero/empty results + const unauthenticatedPage = await createUnauthenticatedPage(browser); + await unauthenticatedPage.goto(publicUrl); + await unauthenticatedPage.waitForLoadState('networkidle'); + + // Verify no data is shown + await expect(unauthenticatedPage.getByText('0h 00min')).toBeVisible(); + await expect(unauthenticatedPage.getByText('No data available')).toBeVisible(); + + await unauthenticatedPage.close(); +}); diff --git a/e2e/shared-reports.spec.ts b/e2e/shared-reports.spec.ts new file mode 100644 index 00000000..b1d46470 --- /dev/null +++ b/e2e/shared-reports.spec.ts @@ -0,0 +1,392 @@ +import { expect, Page } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; + +async function goToSharedReports(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared'); +} + +async function goToReporting(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/reporting'); +} + +async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) { + 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(); + await page.getByText(projectName).waitFor({ state: 'visible' }); + + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`); + await page.getByRole('button', { name: 'No Project' }).click(); + await page.getByText(projectName).click(); + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await Promise.all([ + page.getByRole('button', { name: 'Create Time Entry' }).click(), + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + ]); +} + +async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) { + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`); + 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'); + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await page.getByRole('button', { name: 'Create Time Entry' }).click(); +} + +async function createTimeEntryWithBillableStatus( + page: Page, + isBillable: boolean, + duration: string +) { + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page + .getByTestId('time_entry_description') + .fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`); + 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(); + } + await page.locator('[role="dialog"] input[name="Duration"]').fill(duration); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await page.getByRole('button', { name: 'Create Time Entry' }).click(); +} + +async function createReport( + page: Page, + reportName: string, + options: { + projectFilter?: string; + tagFilter?: string; + billableFilter?: 'billable' | 'non-billable' | 'all'; + timeRange?: { start: string; end: string }; + } = {} +) { + await goToReporting(page); + await page.waitForLoadState('networkidle'); + + // Apply filters if specified + if (options.projectFilter) { + await page.getByRole('button', { name: 'Project' }).nth(0).click(); + await page.getByText(options.projectFilter).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + } + + if (options.tagFilter) { + await page.getByRole('button', { name: 'Tags' }).click(); + await page.getByText(options.tagFilter).click(); + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + } + + if (options.billableFilter && options.billableFilter !== 'all') { + await page.getByRole('button', { name: 'Billable' }).click(); + if (options.billableFilter === 'billable') { + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + } else { + await page.getByRole('option', { name: 'Non Billable', exact: true }).click(); + } + await page.keyboard.press('Escape'); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + } + + // Set custom time range if specified + if (options.timeRange) { + await page.getByRole('button', { name: 'This Week' }).click(); + await page.getByRole('option', { name: 'Custom Range' }).click(); + await page.locator('input[name="startDate"]').fill(options.timeRange.start); + await page.locator('input[name="endDate"]').fill(options.timeRange.end); + await page.getByRole('button', { name: 'Apply' }).click(); + await page.waitForResponse( + (response) => + response.url().includes('/time-entries/aggregate') && response.status() === 200 + ); + } + + await page.waitForLoadState('networkidle'); + + // Save the report + await page.getByRole('button', { name: 'Save Report' }).click(); + await page.getByLabel('Report Name').fill(reportName); + await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(); + await page.waitForLoadState('networkidle'); +} + +async function makeReportPublic(page: Page, reportName: string): Promise { + await goToSharedReports(page); + await page.waitForLoadState('networkidle'); + + // Find the report row and click the edit button + const reportRow = page.locator('tr').filter({ hasText: reportName }); + await reportRow.getByRole('button', { name: 'Edit' }).click(); + + // Make the report public + await page.getByRole('switch', { name: 'Make report public' }).click(); + + // Wait for the API response + await page.waitForResponse( + (response) => response.url().includes('/reports/') && response.status() === 200 + ); + + // Save the changes + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + // Get the public URL + const copyButton = reportRow.getByRole('button', { name: 'Copy URL' }); + await copyButton.click(); + + // Extract the URL from clipboard or from the button's data attribute + const publicUrl = await page.evaluate(() => navigator.clipboard.readText()); + + return publicUrl; +} + +test('create shared report with project filter', async ({ page }) => { + const projectName = 'Shared Report Project ' + Math.floor(Math.random() * 10000); + const reportName = 'Project Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithProject(page, projectName, '2h'); + await createTimeEntryWithProject(page, 'Other Project', '1h'); + + // Create a report with project filter + await createReport(page, reportName, { projectFilter: projectName }); + + // Make the report public + const publicUrl = await makeReportPublic(page, reportName); + + // Verify the report appears in shared reports list + await expect(page.getByText(reportName)).toBeVisible(); + await expect(page.getByText('Public')).toBeVisible(); + + expect(publicUrl).toContain('/shared-report#'); +}); + +test('create shared report with tag filter', async ({ page }) => { + const tagName = 'SharedTag' + Math.floor(Math.random() * 10000); + const reportName = 'Tag Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithTag(page, tagName, '1h 30min'); + await createTimeEntryWithTag(page, 'OtherTag', '45min'); + + // Create a report with tag filter + await createReport(page, reportName, { tagFilter: tagName }); + + // Make the report public + const publicUrl = await makeReportPublic(page, reportName); + + // Verify the report appears in shared reports list + await expect(page.getByText(reportName)).toBeVisible(); + await expect(page.getByText('Public')).toBeVisible(); + + expect(publicUrl).toContain('/shared-report#'); +}); + +test('create shared report with billable filter', async ({ page }) => { + const reportName = 'Billable Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithBillableStatus(page, true, '2h'); + await createTimeEntryWithBillableStatus(page, false, '1h'); + + // Create a report with billable filter + await createReport(page, reportName, { billableFilter: 'billable' }); + + // Make the report public + const publicUrl = await makeReportPublic(page, reportName); + + // Verify the report appears in shared reports list + await expect(page.getByText(reportName)).toBeVisible(); + await expect(page.getByText('Public')).toBeVisible(); + + expect(publicUrl).toContain('/shared-report#'); +}); + +test('create shared report with custom time range', async ({ page }) => { + const projectName = 'TimeRange Project ' + Math.floor(Math.random() * 10000); + const reportName = 'TimeRange Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithProject(page, projectName, '3h'); + + // Create a report with custom time range (last 30 days) + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 30); + const endDate = new Date(); + + await createReport(page, reportName, { + projectFilter: projectName, + timeRange: { + start: startDate.toISOString().split('T')[0], + end: endDate.toISOString().split('T')[0], + }, + }); + + // Make the report public + const publicUrl = await makeReportPublic(page, reportName); + + // Verify the report appears in shared reports list + await expect(page.getByText(reportName)).toBeVisible(); + await expect(page.getByText('Public')).toBeVisible(); + + expect(publicUrl).toContain('/shared-report#'); +}); + +test('create shared report with multiple filters', async ({ page }) => { + const projectName = 'MultiFilter Project ' + Math.floor(Math.random() * 10000); + const tagName = 'MultiTag' + Math.floor(Math.random() * 10000); + const reportName = 'MultiFilter Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithProject(page, projectName, '2h'); + + // Create a time entry with both project and tag + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await page.getByRole('button', { name: 'Manual time entry' }).click(); + await page.getByTestId('time_entry_description').fill('Multi-filter entry'); + + // Set project + await page.getByRole('button', { name: 'No Project' }).click(); + await page.getByText(projectName).click(); + + // Set 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 as billable + await page.getByRole('button', { name: 'Non-Billable' }).click(); + await page.getByRole('option', { name: 'Billable', exact: true }).click(); + + await page.locator('[role="dialog"] input[name="Duration"]').fill('1h 30min'); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + await page.getByRole('button', { name: 'Create Time Entry' }).click(); + + // Create a report with multiple filters + await createReport(page, reportName, { + projectFilter: projectName, + tagFilter: tagName, + billableFilter: 'billable', + }); + + // Make the report public + const publicUrl = await makeReportPublic(page, reportName); + + // Verify the report appears in shared reports list + await expect(page.getByText(reportName)).toBeVisible(); + await expect(page.getByText('Public')).toBeVisible(); + + expect(publicUrl).toContain('/shared-report#'); +}); + +test('toggle report visibility from public to private', async ({ page }) => { + const projectName = 'Toggle Project ' + Math.floor(Math.random() * 10000); + const reportName = 'Toggle Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithProject(page, projectName, '1h'); + + // Create a report + await createReport(page, reportName, { projectFilter: projectName }); + + // Make the report public + await makeReportPublic(page, reportName); + + // Verify it's public + await expect(page.getByText('Public')).toBeVisible(); + + // Make it private again + const reportRow = page.locator('tr').filter({ hasText: reportName }); + await reportRow.getByRole('button', { name: 'Edit' }).click(); + await page.getByRole('switch', { name: 'Make report public' }).click(); + await page.waitForResponse( + (response) => response.url().includes('/reports/') && response.status() === 200 + ); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + // Verify it's now private + await expect(page.getByText('Private')).toBeVisible(); + await expect(page.getByText('Public')).not.toBeVisible(); +}); + +test('edit shared report name and description', async ({ page }) => { + const projectName = 'Edit Project ' + Math.floor(Math.random() * 10000); + const reportName = 'Original Report ' + Math.floor(Math.random() * 10000); + const updatedName = 'Updated Report ' + Math.floor(Math.random() * 10000); + const description = 'This is an updated description'; + + // Create test data + await createTimeEntryWithProject(page, projectName, '1h'); + + // Create a report + await createReport(page, reportName, { projectFilter: projectName }); + + // Make the report public + await makeReportPublic(page, reportName); + + // Edit the report + const reportRow = page.locator('tr').filter({ hasText: reportName }); + await reportRow.getByRole('button', { name: 'Edit' }).click(); + + await page.getByLabel('Report Name').fill(updatedName); + await page.getByLabel('Description').fill(description); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForLoadState('networkidle'); + + // Verify the changes + await expect(page.getByText(updatedName)).toBeVisible(); + await expect(page.getByText(reportName)).not.toBeVisible(); +}); + +test('delete shared report', async ({ page }) => { + const projectName = 'Delete Project ' + Math.floor(Math.random() * 10000); + const reportName = 'Delete Report ' + Math.floor(Math.random() * 10000); + + // Create test data + await createTimeEntryWithProject(page, projectName, '1h'); + + // Create a report + await createReport(page, reportName, { projectFilter: projectName }); + + // Make the report public + await makeReportPublic(page, reportName); + + // Delete the report + const reportRow = page.locator('tr').filter({ hasText: reportName }); + await reportRow.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete Report' }).click(); + await page.waitForLoadState('networkidle'); + + // Verify the report is deleted + await expect(page.getByText(reportName)).not.toBeVisible(); +});