Allow NONE filter value to shared reports and add shared-report tests

This commit is contained in:
Gregor Vostrak
2026-02-02 20:42:07 +01:00
parent 18989a9a8e
commit 09c3205680
7 changed files with 651 additions and 10 deletions
@@ -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<string, array<string|ValidationRule|LegacyValidationRule>>
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
*/
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',
+2 -1
View File
@@ -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);
+258
View File
@@ -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();
});
+28
View File
@@ -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 };
}
@@ -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,
@@ -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,
],
]);
}
}
@@ -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