From 28fc324c6adcadd388ee9a7645482aead3ae60b6 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Mon, 2 Feb 2026 20:42:07 +0100 Subject: [PATCH] Allow NONE filter value to shared reports and add shared-report tests --- .../Requests/V1/Report/ReportStoreRequest.php | 40 ++- app/Service/Dto/ReportPropertiesDto.php | 3 +- e2e/shared-reports.spec.ts | 258 ++++++++++++++++++ e2e/utils/reporting.ts | 28 ++ .../packages/api/src/openapi.json.client.ts | 8 +- .../V1/Public/PublicReportEndpointTest.php | 241 ++++++++++++++++ .../Endpoint/Api/V1/ReportEndpointTest.php | 83 ++++++ 7 files changed, 651 insertions(+), 10 deletions(-) create mode 100644 e2e/shared-reports.spec.ts diff --git a/app/Http/Requests/V1/Report/ReportStoreRequest.php b/app/Http/Requests/V1/Report/ReportStoreRequest.php index da609ba1..443bf01c 100644 --- a/app/Http/Requests/V1/Report/ReportStoreRequest.php +++ b/app/Http/Requests/V1/Report/ReportStoreRequest.php @@ -10,9 +10,11 @@ use App\Enums\TimeEntryRoundingType; use App\Enums\Weekday; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Organization; +use App\Service\TimeEntryFilter; use Illuminate\Contracts\Validation\Rule as LegacyValidationRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Carbon; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; /** @@ -23,7 +25,7 @@ class ReportStoreRequest extends BaseFormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -81,7 +83,14 @@ class ReportStoreRequest extends BaseFormRequest ], 'properties.client_ids.*' => [ 'string', - 'uuid', + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + if (! Str::isUuid($value)) { + $fail('The '.$attribute.' must be a valid UUID.'); + } + }, ], // Filter by project IDs, project IDs are OR combined 'properties.project_ids' => [ @@ -90,7 +99,14 @@ class ReportStoreRequest extends BaseFormRequest ], 'properties.project_ids.*' => [ 'string', - 'uuid', + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + if (! Str::isUuid($value)) { + $fail('The '.$attribute.' must be a valid UUID.'); + } + }, ], // Filter by tag IDs, tag IDs are OR combined 'properties.tag_ids' => [ @@ -99,7 +115,14 @@ class ReportStoreRequest extends BaseFormRequest ], 'properties.tag_ids.*' => [ 'string', - 'uuid', + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + if (! Str::isUuid($value)) { + $fail('The '.$attribute.' must be a valid UUID.'); + } + }, ], 'properties.task_ids' => [ 'nullable', @@ -107,7 +130,14 @@ class ReportStoreRequest extends BaseFormRequest ], 'properties.task_ids.*' => [ 'string', - 'uuid', + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + if (! Str::isUuid($value)) { + $fail('The '.$attribute.' must be a valid UUID.'); + } + }, ], 'properties.group' => [ 'required', diff --git a/app/Service/Dto/ReportPropertiesDto.php b/app/Service/Dto/ReportPropertiesDto.php index ac056d0f..a3ff85db 100644 --- a/app/Service/Dto/ReportPropertiesDto.php +++ b/app/Service/Dto/ReportPropertiesDto.php @@ -8,6 +8,7 @@ use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryRoundingType; use App\Enums\Weekday; +use App\Service\TimeEntryFilter; use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; @@ -174,7 +175,7 @@ class ReportPropertiesDto implements Castable if (! is_string($id)) { throw new \InvalidArgumentException('The given ID is not a string'); } - if (! Str::isUuid($id)) { + if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) { throw new \InvalidArgumentException('The given ID is not a valid UUID'); } $collection->push($id); diff --git a/e2e/shared-reports.spec.ts b/e2e/shared-reports.spec.ts new file mode 100644 index 00000000..db82235d --- /dev/null +++ b/e2e/shared-reports.spec.ts @@ -0,0 +1,258 @@ +import { expect } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; +import { + goToReporting, + goToReportingShared, + createProject, + createClient, + createProjectWithClient, + createTask, + createTimeEntryWithProject, + createTimeEntryWithProjectAndTask, + createTimeEntryWithTag, + createBareTimeEntry, + waitForReportingUpdate, + saveAsSharedReport, +} from './utils/reporting'; + +// Each test registers a new user and creates test data, which needs more time +test.describe.configure({ timeout: 60000 }); + +// ────────────────────────────────────────────────── +// Shared Report Lifecycle Tests +// ────────────────────────────────────────────────── + +test('test that saving a report creates a shared report and its shareable link shows correct data', async ({ + page, +}) => { + const projectName = 'SharedProject ' + Math.floor(Math.random() * 10000); + const reportName = 'SharedReport ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + + await goToReporting(page); + await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); + + const { shareableLink } = await saveAsSharedReport(page, reportName); + + // Verify report appears on shared tab + await goToReportingShared(page); + await expect(page.getByTestId('report_table')).toBeVisible(); + await expect(page.getByText(reportName)).toBeVisible(); + await expect(page.getByText('Public', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Copy URL' })).toBeVisible(); + + // Navigate to shareable link and verify report data + await page.goto(shareableLink); + await expect(page.getByText('Reporting')).toBeVisible(); + await expect(page.getByText(projectName)).toBeVisible(); + await expect(page.getByText('Total')).toBeVisible(); +}); + +test('test that shared report with invalid secret shows no data', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value'); + await expect(page.getByText('No time entries found').first()).toBeVisible(); +}); + +test('test that a shared report can be edited to toggle public/private and then deleted', async ({ + page, +}) => { + const projectName = 'EditDelProject ' + Math.floor(Math.random() * 10000); + const reportName = 'EditDelReport ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + + await goToReporting(page); + await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); + + await saveAsSharedReport(page, reportName); + + await goToReportingShared(page); + await expect(page.getByText(reportName)).toBeVisible(); + await expect(page.getByText('Public', { exact: true })).toBeVisible(); + + // Click more options and edit + await page + .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) }) + .click(); + await page.getByRole('menuitem', { name: /^Edit Report/ }).click(); + + // Uncheck public and save + await page.getByLabel('Public').click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/reports/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Report' }).click(), + ]); + + // Verify status changed to private + await expect(page.getByText('Private')).toBeVisible(); + await expect(page.getByText('--')).toBeVisible(); + + // Delete the report + await page + .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) }) + .click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/reports/') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + page.getByRole('menuitem', { name: /^Delete Report/ }).click(), + ]); + + await expect(page.getByText('No shared reports found')).toBeVisible(); +}); + +// ────────────────────────────────────────────────── +// Shared Report Filter Tests +// ────────────────────────────────────────────────── + +test('test that shared report respects project filter', async ({ page }) => { + const projectA = 'FilterProjA ' + Math.floor(Math.random() * 10000); + const projectB = 'FilterProjB ' + Math.floor(Math.random() * 10000); + const reportName = 'FilterProjReport ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectA); + await createProject(page, projectB); + await createTimeEntryWithProject(page, projectA, '1h'); + await createTimeEntryWithProject(page, projectB, '2h'); + + await goToReporting(page); + await expect(page.getByTestId('reporting_view').getByText(projectA)).toBeVisible(); + + // Filter by project A + await page.getByRole('button', { name: 'Projects' }).first().click(); + await page.getByRole('option').filter({ hasText: projectA }).click(); + await page.keyboard.press('Escape'); + await waitForReportingUpdate(page); + + const { shareableLink } = await saveAsSharedReport(page, reportName); + + // View the shared report + await page.goto(shareableLink); + await expect(page.getByText('Reporting')).toBeVisible(); + await expect(page.getByText(projectA)).toBeVisible(); + await expect(page.getByText(projectB)).not.toBeVisible(); +}); + +test('test that shared report with No Project filter shows entries without a project', async ({ + page, +}) => { + const projectName = 'NoProjFilter ' + Math.floor(Math.random() * 10000); + const reportName = 'NoProjReport ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + await createBareTimeEntry(page, 'Bare entry no project', '2h'); + + await goToReporting(page); + await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); + + // Filter by "No Project" + await page.getByRole('button', { name: 'Projects' }).first().click(); + await page.getByRole('option').filter({ hasText: 'No Project' }).click(); + await page.keyboard.press('Escape'); + await waitForReportingUpdate(page); + + const { shareableLink } = await saveAsSharedReport(page, reportName); + + // View the shared report + await page.goto(shareableLink); + await expect(page.getByText('Reporting')).toBeVisible(); + // The "No Project" group should show, but the project name should not appear as a group + await expect(page.getByText('Total')).toBeVisible(); + await expect(page.getByText(projectName)).not.toBeVisible(); +}); + +test('test that shared report with No Task filter shows entries without a task', async ({ + page, +}) => { + const projectName = 'NoTaskProj ' + Math.floor(Math.random() * 10000); + const taskName = 'NoTaskFilter ' + Math.floor(Math.random() * 10000); + const reportName = 'NoTaskReport ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTask(page, projectName, taskName); + await createTimeEntryWithProjectAndTask(page, projectName, taskName, '1h'); + await createTimeEntryWithProject(page, projectName, '2h'); + + await goToReporting(page); + await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); + + // Filter by "No Task" + await page.getByRole('button', { name: 'Tasks' }).first().click(); + await page.getByRole('option').filter({ hasText: 'No Task' }).click(); + await page.keyboard.press('Escape'); + await waitForReportingUpdate(page); + + const { shareableLink } = await saveAsSharedReport(page, reportName); + + // View the shared report + await page.goto(shareableLink); + await expect(page.getByText('Reporting')).toBeVisible(); + await expect(page.getByText('Total')).toBeVisible(); +}); + +test('test that shared report with No Client filter shows entries without a client', async ({ + page, +}) => { + const clientName = 'NoClientCli ' + Math.floor(Math.random() * 10000); + const projectName = 'NoClientProj ' + Math.floor(Math.random() * 10000); + const reportName = 'NoClientReport ' + Math.floor(Math.random() * 10000); + + await createClient(page, clientName); + await createProjectWithClient(page, projectName, clientName); + await createTimeEntryWithProject(page, projectName, '1h'); + await createBareTimeEntry(page, 'Entry without client', '2h'); + + await goToReporting(page); + await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); + + // Filter by "No Client" + await page.getByRole('button', { name: 'Clients' }).first().click(); + await page.getByRole('option').filter({ hasText: 'No Client' }).click(); + await page.keyboard.press('Escape'); + await waitForReportingUpdate(page); + + const { shareableLink } = await saveAsSharedReport(page, reportName); + + // View the shared report + await page.goto(shareableLink); + await expect(page.getByText('Reporting')).toBeVisible(); + await expect(page.getByText('Total')).toBeVisible(); + await expect(page.getByText(projectName)).not.toBeVisible(); +}); + +test('test that shared report with No Tag filter shows entries without tags', async ({ page }) => { + const tagName = 'NoTagFilter ' + Math.floor(Math.random() * 10000); + const reportName = 'NoTagReport ' + Math.floor(Math.random() * 10000); + + await createTimeEntryWithTag(page, tagName, '1h'); + await createBareTimeEntry(page, 'Entry without tags', '2h'); + + await goToReporting(page); + await expect(page.getByText('Total')).toBeVisible(); + + // Filter by "No Tag" + await page.getByRole('button', { name: 'Tags' }).first().click(); + await page.getByRole('option').filter({ hasText: 'No Tag' }).click(); + await page.keyboard.press('Escape'); + await waitForReportingUpdate(page); + + const { shareableLink } = await saveAsSharedReport(page, reportName); + + // View the shared report + await page.goto(shareableLink); + await expect(page.getByText('Reporting')).toBeVisible(); + await expect(page.getByText('Total')).toBeVisible(); +}); diff --git a/e2e/utils/reporting.ts b/e2e/utils/reporting.ts index d48c793c..25bf4cc9 100644 --- a/e2e/utils/reporting.ts +++ b/e2e/utils/reporting.ts @@ -269,3 +269,31 @@ export async function waitForDetailedReportingUpdate(page: Page) { response.status() === 200 ); } + +// ────────────────────────────────────────────────── +// Shared report helpers +// ────────────────────────────────────────────────── + +export async function goToReportingShared(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared'); +} + +export async function saveAsSharedReport( + page: Page, + reportName: string +): Promise<{ shareableLink: string }> { + await page.getByRole('button', { name: 'Save Report' }).click(); + await page.getByLabel('Name').fill(reportName); + // "Public" checkbox is checked by default + const [response] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/reports') && + response.request().method() === 'POST' && + response.status() === 201 + ), + page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(), + ]); + const responseBody = await response.json(); + return { shareableLink: responseBody.data.shareable_link }; +} diff --git a/resources/js/packages/api/src/openapi.json.client.ts b/resources/js/packages/api/src/openapi.json.client.ts index 4bbcd19a..ae8c288c 100644 --- a/resources/js/packages/api/src/openapi.json.client.ts +++ b/resources/js/packages/api/src/openapi.json.client.ts @@ -444,10 +444,10 @@ const ReportStoreRequest = z active: z.union([z.boolean(), z.null()]).optional(), member_ids: z.union([z.array(z.string().uuid()), z.null()]).optional(), billable: z.union([z.boolean(), z.null()]).optional(), - client_ids: z.union([z.array(z.string().uuid()), z.null()]).optional(), - project_ids: z.union([z.array(z.string().uuid()), z.null()]).optional(), - tag_ids: z.union([z.array(z.string().uuid()), z.null()]).optional(), - task_ids: z.union([z.array(z.string().uuid()), z.null()]).optional(), + client_ids: z.union([z.array(z.string()), z.null()]).optional(), + project_ids: z.union([z.array(z.string()), z.null()]).optional(), + tag_ids: z.union([z.array(z.string()), z.null()]).optional(), + task_ids: z.union([z.array(z.string()), z.null()]).optional(), group: TimeEntryAggregationType, sub_group: TimeEntryAggregationType, history_group: TimeEntryAggregationTypeInterval, diff --git a/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php index 7345792e..4a8db48b 100644 --- a/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php @@ -16,6 +16,7 @@ use App\Models\Task; use App\Models\TimeEntry; use App\Service\CurrencyService; use App\Service\Dto\ReportPropertiesDto; +use App\Service\TimeEntryFilter; use Illuminate\Support\Str; use Tests\Unit\Endpoint\Api\V1\ApiEndpointTestAbstract; @@ -423,4 +424,244 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract ], ]); } + + public function test_show_returns_only_entries_without_project_when_none_project_filter_is_set(): void + { + // Arrange + $organization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($organization)->create(); + + // Entry with project (should be excluded) + TimeEntry::factory()->forOrganization($organization) + ->forProject($project) + ->startWithDuration(now()->subDay(), 100) + ->create(); + // Entry without project (should be included) + TimeEntry::factory()->forOrganization($organization) + ->startWithDuration(now()->subDay(), 200) + ->create(); + + $reportDto = new ReportPropertiesDto; + $reportDto->start = now()->subDays(2); + $reportDto->end = now(); + $reportDto->group = TimeEntryAggregationType::Project; + $reportDto->subGroup = TimeEntryAggregationType::Task; + $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; + $reportDto->weekStart = Weekday::Monday; + $reportDto->timezone = 'Europe/Vienna'; + $reportDto->setProjectIds([TimeEntryFilter::NONE_VALUE]); + $report = Report::factory()->forOrganization($organization)->public()->create([ + 'public_until' => null, + 'properties' => $reportDto, + ]); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => $report->share_secret, + ]); + + // Assert + $response->assertOk(); + $response->assertJson([ + 'data' => [ + 'seconds' => 200, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationType::Project->value, + ], + ]); + } + + public function test_show_returns_entries_with_and_without_project_when_none_and_real_id_combined(): void + { + // Arrange + $organization = Organization::factory()->create(); + $projectA = Project::factory()->forOrganization($organization)->create(); + $projectB = Project::factory()->forOrganization($organization)->create(); + + // Entry with project A (should be included) + TimeEntry::factory()->forOrganization($organization) + ->forProject($projectA) + ->startWithDuration(now()->subDay(), 100) + ->create(); + // Entry with project B (should be excluded) + TimeEntry::factory()->forOrganization($organization) + ->forProject($projectB) + ->startWithDuration(now()->subDay(), 100) + ->create(); + // Entry without project (should be included) + TimeEntry::factory()->forOrganization($organization) + ->startWithDuration(now()->subDay(), 200) + ->create(); + + $reportDto = new ReportPropertiesDto; + $reportDto->start = now()->subDays(2); + $reportDto->end = now(); + $reportDto->group = TimeEntryAggregationType::Project; + $reportDto->subGroup = TimeEntryAggregationType::Task; + $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; + $reportDto->weekStart = Weekday::Monday; + $reportDto->timezone = 'Europe/Vienna'; + $reportDto->setProjectIds([$projectA->getKey(), TimeEntryFilter::NONE_VALUE]); + $report = Report::factory()->forOrganization($organization)->public()->create([ + 'public_until' => null, + 'properties' => $reportDto, + ]); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => $report->share_secret, + ]); + + // Assert + $response->assertOk(); + $response->assertJson([ + 'data' => [ + 'seconds' => 300, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationType::Project->value, + ], + ]); + } + + public function test_show_returns_only_entries_without_task_when_none_task_filter_is_set(): void + { + // Arrange + $organization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($organization)->create(); + $task = Task::factory()->forOrganization($organization)->forProject($project)->create(); + + // Entry with task (should be excluded) + TimeEntry::factory()->forOrganization($organization) + ->forTask($task) + ->startWithDuration(now()->subDay(), 100) + ->create(); + // Entry without task (should be included) + TimeEntry::factory()->forOrganization($organization) + ->forProject($project) + ->startWithDuration(now()->subDay(), 200) + ->create(); + + $reportDto = new ReportPropertiesDto; + $reportDto->start = now()->subDays(2); + $reportDto->end = now(); + $reportDto->group = TimeEntryAggregationType::Project; + $reportDto->subGroup = TimeEntryAggregationType::Task; + $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; + $reportDto->weekStart = Weekday::Monday; + $reportDto->timezone = 'Europe/Vienna'; + $reportDto->setTaskIds([TimeEntryFilter::NONE_VALUE]); + $report = Report::factory()->forOrganization($organization)->public()->create([ + 'public_until' => null, + 'properties' => $reportDto, + ]); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => $report->share_secret, + ]); + + // Assert + $response->assertOk(); + $response->assertJson([ + 'data' => [ + 'seconds' => 200, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationType::Project->value, + ], + ]); + } + + public function test_show_returns_only_entries_without_client_when_none_client_filter_is_set(): void + { + // Arrange + $organization = Organization::factory()->create(); + $client = Client::factory()->forOrganization($organization)->create(); + $projectWithClient = Project::factory()->forClient($client)->forOrganization($organization)->create(); + + // Entry with client (should be excluded) + TimeEntry::factory()->forOrganization($organization) + ->forProject($projectWithClient) + ->startWithDuration(now()->subDay(), 100) + ->create(); + // Entry without client (should be included) + TimeEntry::factory()->forOrganization($organization) + ->startWithDuration(now()->subDay(), 200) + ->create(); + + $reportDto = new ReportPropertiesDto; + $reportDto->start = now()->subDays(2); + $reportDto->end = now(); + $reportDto->group = TimeEntryAggregationType::Project; + $reportDto->subGroup = TimeEntryAggregationType::Task; + $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; + $reportDto->weekStart = Weekday::Monday; + $reportDto->timezone = 'Europe/Vienna'; + $reportDto->setClientIds([TimeEntryFilter::NONE_VALUE]); + $report = Report::factory()->forOrganization($organization)->public()->create([ + 'public_until' => null, + 'properties' => $reportDto, + ]); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => $report->share_secret, + ]); + + // Assert + $response->assertOk(); + $response->assertJson([ + 'data' => [ + 'seconds' => 200, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationType::Project->value, + ], + ]); + } + + public function test_show_returns_only_entries_without_tags_when_none_tag_filter_is_set(): void + { + // Arrange + $organization = Organization::factory()->create(); + $tag = Tag::factory()->forOrganization($organization)->create(); + + // Entry with tag (should be excluded) + TimeEntry::factory()->forOrganization($organization) + ->startWithDuration(now()->subDay(), 100) + ->create([ + 'tags' => [$tag->getKey()], + ]); + // Entry without tags (should be included) + TimeEntry::factory()->forOrganization($organization) + ->startWithDuration(now()->subDay(), 200) + ->create(); + + $reportDto = new ReportPropertiesDto; + $reportDto->start = now()->subDays(2); + $reportDto->end = now(); + $reportDto->group = TimeEntryAggregationType::Project; + $reportDto->subGroup = TimeEntryAggregationType::Task; + $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; + $reportDto->weekStart = Weekday::Monday; + $reportDto->timezone = 'Europe/Vienna'; + $reportDto->setTagIds([TimeEntryFilter::NONE_VALUE]); + $report = Report::factory()->forOrganization($organization)->public()->create([ + 'public_until' => null, + 'properties' => $reportDto, + ]); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => $report->share_secret, + ]); + + // Assert + $response->assertOk(); + $response->assertJson([ + 'data' => [ + 'seconds' => 200, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationType::Project->value, + ], + ]); + } } diff --git a/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php index ff821777..d8e7a563 100644 --- a/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php @@ -8,7 +8,12 @@ use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryRoundingType; use App\Enums\Weekday; use App\Http\Controllers\Api\V1\ReportController; +use App\Models\Client; +use App\Models\Project; use App\Models\Report; +use App\Models\Tag; +use App\Models\Task; +use App\Service\TimeEntryFilter; use Illuminate\Support\Carbon; use Illuminate\Testing\Fluent\AssertableJson; use Laravel\Passport\Passport; @@ -490,6 +495,84 @@ class ReportEndpointTest extends ApiEndpointTestAbstract ); } + public function test_store_endpoint_creates_report_with_none_filter_values(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:create', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [ + 'name' => 'Test Report with None Filters', + 'is_public' => false, + 'properties' => [ + 'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(), + 'end' => Carbon::now()->toIso8601ZuluString(), + 'group' => TimeEntryAggregationType::Project->value, + 'sub_group' => TimeEntryAggregationType::Task->value, + 'history_group' => TimeEntryAggregationType::Day->value, + 'project_ids' => [TimeEntryFilter::NONE_VALUE], + 'client_ids' => [TimeEntryFilter::NONE_VALUE], + 'tag_ids' => [TimeEntryFilter::NONE_VALUE], + 'task_ids' => [TimeEntryFilter::NONE_VALUE], + ], + ]); + + // Assert + $response->assertStatus(201); + /** @var Report $report */ + $report = Report::query()->findOrFail($response->json('data.id')); + $this->assertTrue($report->properties->projectIds->contains(TimeEntryFilter::NONE_VALUE)); + $this->assertTrue($report->properties->clientIds->contains(TimeEntryFilter::NONE_VALUE)); + $this->assertTrue($report->properties->tagIds->contains(TimeEntryFilter::NONE_VALUE)); + $this->assertTrue($report->properties->taskIds->contains(TimeEntryFilter::NONE_VALUE)); + } + + public function test_store_endpoint_creates_report_with_none_combined_with_real_ids(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'reports:create', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $client = Client::factory()->forOrganization($data->organization)->create(); + $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create(); + $tag = Tag::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [ + 'name' => 'Test Report with Combined Filters', + 'is_public' => false, + 'properties' => [ + 'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(), + 'end' => Carbon::now()->toIso8601ZuluString(), + 'group' => TimeEntryAggregationType::Project->value, + 'sub_group' => TimeEntryAggregationType::Task->value, + 'history_group' => TimeEntryAggregationType::Day->value, + 'project_ids' => [$project->getKey(), TimeEntryFilter::NONE_VALUE], + 'client_ids' => [$client->getKey(), TimeEntryFilter::NONE_VALUE], + 'tag_ids' => [$tag->getKey(), TimeEntryFilter::NONE_VALUE], + 'task_ids' => [$task->getKey(), TimeEntryFilter::NONE_VALUE], + ], + ]); + + // Assert + $response->assertStatus(201); + /** @var Report $report */ + $report = Report::query()->findOrFail($response->json('data.id')); + $this->assertTrue($report->properties->projectIds->contains($project->getKey())); + $this->assertTrue($report->properties->projectIds->contains(TimeEntryFilter::NONE_VALUE)); + $this->assertTrue($report->properties->clientIds->contains($client->getKey())); + $this->assertTrue($report->properties->clientIds->contains(TimeEntryFilter::NONE_VALUE)); + $this->assertTrue($report->properties->tagIds->contains($tag->getKey())); + $this->assertTrue($report->properties->tagIds->contains(TimeEntryFilter::NONE_VALUE)); + $this->assertTrue($report->properties->taskIds->contains($task->getKey())); + $this->assertTrue($report->properties->taskIds->contains(TimeEntryFilter::NONE_VALUE)); + } + public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_report(): void { // Arrange