From dd75a80df754a7c3ae112dd8b463525e50c999cd Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Mon, 2 Feb 2026 01:16:28 +0100 Subject: [PATCH] add no project, no task, no client, no task, no tag support to the API --- .../TimeEntryAggregateExportRequest.php | 53 +++-- .../TimeEntry/TimeEntryAggregateRequest.php | 53 +++-- .../TimeEntry/TimeEntryIndexExportRequest.php | 45 ++-- .../V1/TimeEntry/TimeEntryIndexRequest.php | 55 +++-- app/Service/TimeEntryFilter.php | 48 ++++- .../Endpoint/Api/V1/TimeEntryEndpointTest.php | 199 ++++++++++++++++++ tests/Unit/Service/TimeEntryFilterTest.php | 166 +++++++++++++++ 7 files changed, 550 insertions(+), 69 deletions(-) diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php index 35f84519..a356198c 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php @@ -16,6 +16,7 @@ use App\Models\Project; use App\Models\Tag; use App\Models\Task; use App\Models\User; +use App\Service\TimeEntryFilter; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; @@ -30,7 +31,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -94,10 +95,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest ], 'project_ids.*' => [ 'string', - ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by client IDs, client IDs are OR combined 'client_ids' => [ @@ -106,10 +112,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest ], 'client_ids.*' => [ 'string', - ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by tag IDs, tag IDs are OR combined 'tag_ids' => [ @@ -118,10 +129,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest ], 'tag_ids.*' => [ 'string', - ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ @@ -130,9 +146,14 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest ], 'task_ids.*' => [ 'string', - ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php index 39c9270e..92378f82 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -14,6 +14,7 @@ use App\Models\Project; use App\Models\Tag; use App\Models\Task; use App\Models\User; +use App\Service\TimeEntryFilter; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; @@ -28,7 +29,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -80,10 +81,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest ], 'project_ids.*' => [ 'string', - ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by client IDs, client IDs are OR combined 'client_ids' => [ @@ -92,10 +98,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest ], 'client_ids.*' => [ 'string', - ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by tag IDs, tag IDs are OR combined 'tag_ids' => [ @@ -104,10 +115,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest ], 'tag_ids.*' => [ 'string', - ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ @@ -116,9 +132,14 @@ class TimeEntryAggregateRequest extends BaseFormRequest ], 'task_ids.*' => [ 'string', - ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php index 6c3180b2..5daa61e1 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php @@ -11,6 +11,7 @@ use App\Models\Organization; use App\Models\Project; use App\Models\Tag; use App\Models\Task; +use App\Service\TimeEntryFilter; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; @@ -25,7 +26,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -64,11 +65,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest ], 'project_ids.*' => [ 'string', - 'uuid', - new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - }), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by tag IDs, tag IDs are OR combined 'tag_ids' => [ @@ -77,11 +82,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest ], 'tag_ids.*' => [ 'string', - 'uuid', - new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - }), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ @@ -90,11 +99,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest ], 'task_ids.*' => [ 'string', - 'uuid', - new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - }), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 2c6dd61e..230e5134 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -12,6 +12,7 @@ use App\Models\Organization; use App\Models\Project; use App\Models\Tag; use App\Models\Task; +use App\Service\TimeEntryFilter; use Illuminate\Contracts\Validation\Rule as RuleContract; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; @@ -26,7 +27,7 @@ class TimeEntryIndexRequest extends BaseFormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -58,10 +59,15 @@ class TimeEntryIndexRequest extends BaseFormRequest ], 'client_ids.*' => [ 'string', - ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by project IDs, project IDs are OR combined 'project_ids' => [ @@ -70,10 +76,15 @@ class TimeEntryIndexRequest extends BaseFormRequest ], 'project_ids.*' => [ 'string', - ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by tag IDs, tag IDs are OR combined 'tag_ids' => [ @@ -82,10 +93,15 @@ class TimeEntryIndexRequest extends BaseFormRequest ], 'tag_ids.*' => [ 'string', - ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter by task IDs, task IDs are OR combined 'task_ids' => [ @@ -94,10 +110,15 @@ class TimeEntryIndexRequest extends BaseFormRequest ], 'task_ids.*' => [ 'string', - ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereBelongsTo($this->organization, 'organization'); - })->uuid(), + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === TimeEntryFilter::NONE_VALUE) { + return; + } + ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + })->uuid()->validate($attribute, $value, $fail); + }, ], // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'start' => [ diff --git a/app/Service/TimeEntryFilter.php b/app/Service/TimeEntryFilter.php index 160adddc..02fbe689 100644 --- a/app/Service/TimeEntryFilter.php +++ b/app/Service/TimeEntryFilter.php @@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Log; class TimeEntryFilter { + public const string NONE_VALUE = 'none'; + /** * @var Builder */ @@ -149,7 +151,17 @@ class TimeEntryFilter if ($clientIds === null) { return $this; } - $this->builder->whereIn('client_id', $clientIds); + $includeNone = in_array(self::NONE_VALUE, $clientIds, true); + $clientIds = array_values(array_filter($clientIds, fn (string $id): bool => $id !== self::NONE_VALUE)); + + $this->builder->where(function (Builder $builder) use ($clientIds, $includeNone): void { + if (count($clientIds) > 0) { + $builder->whereIn('client_id', $clientIds); + } + if ($includeNone) { + $builder->orWhereNull('client_id'); + } + }); return $this; } @@ -162,7 +174,17 @@ class TimeEntryFilter if ($projectIds === null) { return $this; } - $this->builder->whereIn('project_id', $projectIds); + $includeNone = in_array(self::NONE_VALUE, $projectIds, true); + $projectIds = array_values(array_filter($projectIds, fn (string $id): bool => $id !== self::NONE_VALUE)); + + $this->builder->where(function (Builder $builder) use ($projectIds, $includeNone): void { + if (count($projectIds) > 0) { + $builder->whereIn('project_id', $projectIds); + } + if ($includeNone) { + $builder->orWhereNull('project_id'); + } + }); return $this; } @@ -175,10 +197,18 @@ class TimeEntryFilter if ($tagIds === null) { return $this; } - $this->builder->where(function (Builder $builder) use ($tagIds): void { + $includeNone = in_array(self::NONE_VALUE, $tagIds, true); + $tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE)); + + $this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void { foreach ($tagIds as $tagId) { $builder->orWhereJsonContains('tags', $tagId); } + if ($includeNone) { + $builder->orWhere(function (Builder $query): void { + $query->whereJsonLength('tags', 0)->orWhereNull('tags'); + }); + } }); return $this; @@ -192,7 +222,17 @@ class TimeEntryFilter if ($taskIds === null) { return $this; } - $this->builder->whereIn('task_id', $taskIds); + $includeNone = in_array(self::NONE_VALUE, $taskIds, true); + $taskIds = array_values(array_filter($taskIds, fn (string $id): bool => $id !== self::NONE_VALUE)); + + $this->builder->where(function (Builder $builder) use ($taskIds, $includeNone): void { + if (count($taskIds) > 0) { + $builder->whereIn('task_id', $taskIds); + } + if ($includeNone) { + $builder->orWhereNull('task_id'); + } + }); return $this; } diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index e2a08af2..4b65c4e9 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -20,6 +20,7 @@ use App\Models\Tag; use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; +use App\Service\TimeEntryFilter; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; @@ -4033,4 +4034,202 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract 'member_id' => $ownTimeEntry->member_id, ]); } + + public function test_index_endpoint_with_none_project_filter_returns_entries_without_project(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $timeEntryWithProject = TimeEntry::factory() + ->forOrganization($data->organization) + ->forProject($project) + ->forMember($data->member) + ->create([ + 'start' => Carbon::now()->subHour(), + ]); + $timeEntryWithoutProject = TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'project_id' => null, + 'start' => Carbon::now()->subHour(), + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'project_ids' => [TimeEntryFilter::NONE_VALUE], + 'start' => Carbon::now()->subDay()->toIso8601ZuluString(), + 'end' => Carbon::now()->addDay()->toIso8601ZuluString(), + ])); + + // Assert + $this->assertResponseCode($response, 200); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.id', $timeEntryWithoutProject->getKey()); + } + + public function test_index_endpoint_with_none_and_id_project_filter_returns_both(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $otherProject = Project::factory()->forOrganization($data->organization)->create(); + $timeEntryWithProject = TimeEntry::factory() + ->forOrganization($data->organization) + ->forProject($project) + ->forMember($data->member) + ->create([ + 'start' => Carbon::now()->subHour(), + ]); + $timeEntryWithoutProject = TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'project_id' => null, + 'start' => Carbon::now()->subHour(), + ]); + $timeEntryWithOtherProject = TimeEntry::factory() + ->forOrganization($data->organization) + ->forProject($otherProject) + ->forMember($data->member) + ->create([ + 'start' => Carbon::now()->subHour(), + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'project_ids' => [TimeEntryFilter::NONE_VALUE, $project->getKey()], + 'start' => Carbon::now()->subDay()->toIso8601ZuluString(), + 'end' => Carbon::now()->addDay()->toIso8601ZuluString(), + ])); + + // Assert + $this->assertResponseCode($response, 200); + $response->assertJsonCount(2, 'data'); + $ids = collect($response->json('data'))->pluck('id')->toArray(); + $this->assertContains($timeEntryWithProject->getKey(), $ids); + $this->assertContains($timeEntryWithoutProject->getKey(), $ids); + $this->assertNotContains($timeEntryWithOtherProject->getKey(), $ids); + } + + public function test_index_endpoint_with_none_task_filter_returns_entries_without_task(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create(); + $timeEntryWithTask = TimeEntry::factory() + ->forOrganization($data->organization) + ->forProject($project) + ->forTask($task) + ->forMember($data->member) + ->create([ + 'start' => Carbon::now()->subHour(), + ]); + $timeEntryWithoutTask = TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'task_id' => null, + 'start' => Carbon::now()->subHour(), + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'task_ids' => [TimeEntryFilter::NONE_VALUE], + 'start' => Carbon::now()->subDay()->toIso8601ZuluString(), + 'end' => Carbon::now()->addDay()->toIso8601ZuluString(), + ])); + + // Assert + $this->assertResponseCode($response, 200); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.id', $timeEntryWithoutTask->getKey()); + } + + public function test_index_endpoint_with_none_client_filter_returns_entries_without_client(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $client = Client::factory()->forOrganization($data->organization)->create(); + $timeEntryWithClient = TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'client_id' => $client->getKey(), + 'start' => Carbon::now()->subHour(), + ]); + $timeEntryWithoutClient = TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'client_id' => null, + 'start' => Carbon::now()->subHour(), + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'client_ids' => [TimeEntryFilter::NONE_VALUE], + 'start' => Carbon::now()->subDay()->toIso8601ZuluString(), + 'end' => Carbon::now()->addDay()->toIso8601ZuluString(), + ])); + + // Assert + $this->assertResponseCode($response, 200); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.id', $timeEntryWithoutClient->getKey()); + } + + public function test_index_endpoint_with_none_tag_filter_returns_entries_without_tags(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $tag = Tag::factory()->forOrganization($data->organization)->create(); + $timeEntryWithTag = TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::now()->subHour(), + 'tags' => [$tag->getKey()], + ]); + $timeEntryWithoutTag = TimeEntry::factory() + ->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::now()->subHour(), + 'tags' => [], + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'tag_ids' => [TimeEntryFilter::NONE_VALUE], + 'start' => Carbon::now()->subDay()->toIso8601ZuluString(), + 'end' => Carbon::now()->addDay()->toIso8601ZuluString(), + ])); + + // Assert + $this->assertResponseCode($response, 200); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.id', $timeEntryWithoutTag->getKey()); + } } diff --git a/tests/Unit/Service/TimeEntryFilterTest.php b/tests/Unit/Service/TimeEntryFilterTest.php index 1c390f7d..9e2312d2 100644 --- a/tests/Unit/Service/TimeEntryFilterTest.php +++ b/tests/Unit/Service/TimeEntryFilterTest.php @@ -4,7 +4,10 @@ declare(strict_types=1); namespace Tests\Unit\Service; +use App\Models\Client; +use App\Models\Project; use App\Models\Tag; +use App\Models\Task; use App\Models\TimeEntry; use App\Service\TimeEntryFilter; use PHPUnit\Framework\Attributes\CoversClass; @@ -39,4 +42,167 @@ class TimeEntryFilterTest extends TestCaseWithDatabase $this->assertCount(3, $timeEntries); } + + public function test_add_project_ids_filter_with_none_returns_entries_without_project(): void + { + // Arrange + $project = Project::factory()->create(); + $timeEntryWithProject = TimeEntry::factory()->create([ + 'project_id' => $project->getKey(), + 'organization_id' => $project->organization_id, + ]); + $timeEntryWithoutProject = TimeEntry::factory()->create([ + 'project_id' => null, + ]); + + $builder = TimeEntry::query(); + $filter = new TimeEntryFilter($builder); + + // Act + $filter->addProjectIdsFilter([TimeEntryFilter::NONE_VALUE]); + + // Assert + $timeEntries = $builder->get(); + $this->assertCount(1, $timeEntries); + $this->assertTrue($timeEntries->contains($timeEntryWithoutProject)); + $this->assertFalse($timeEntries->contains($timeEntryWithProject)); + } + + public function test_add_project_ids_filter_with_none_and_ids_returns_both(): void + { + // Arrange + $project = Project::factory()->create(); + $timeEntryWithProject = TimeEntry::factory()->create([ + 'project_id' => $project->getKey(), + 'organization_id' => $project->organization_id, + ]); + $timeEntryWithoutProject = TimeEntry::factory()->create([ + 'project_id' => null, + ]); + $otherProject = Project::factory()->create(); + $timeEntryWithOtherProject = TimeEntry::factory()->create([ + 'project_id' => $otherProject->getKey(), + 'organization_id' => $otherProject->organization_id, + ]); + + $builder = TimeEntry::query(); + $filter = new TimeEntryFilter($builder); + + // Act + $filter->addProjectIdsFilter([$project->getKey(), TimeEntryFilter::NONE_VALUE]); + + // Assert + $timeEntries = $builder->get(); + $this->assertCount(2, $timeEntries); + $this->assertTrue($timeEntries->contains($timeEntryWithProject)); + $this->assertTrue($timeEntries->contains($timeEntryWithoutProject)); + $this->assertFalse($timeEntries->contains($timeEntryWithOtherProject)); + } + + public function test_add_task_ids_filter_with_none_returns_entries_without_task(): void + { + // Arrange + $task = Task::factory()->create(); + $timeEntryWithTask = TimeEntry::factory()->create([ + 'task_id' => $task->getKey(), + 'organization_id' => $task->organization_id, + ]); + $timeEntryWithoutTask = TimeEntry::factory()->create([ + 'task_id' => null, + ]); + + $builder = TimeEntry::query(); + $filter = new TimeEntryFilter($builder); + + // Act + $filter->addTaskIdsFilter([TimeEntryFilter::NONE_VALUE]); + + // Assert + $timeEntries = $builder->get(); + $this->assertCount(1, $timeEntries); + $this->assertTrue($timeEntries->contains($timeEntryWithoutTask)); + $this->assertFalse($timeEntries->contains($timeEntryWithTask)); + } + + public function test_add_client_ids_filter_with_none_returns_entries_without_client(): void + { + // Arrange + $client = Client::factory()->create(); + $timeEntryWithClient = TimeEntry::factory()->create([ + 'client_id' => $client->getKey(), + 'organization_id' => $client->organization_id, + ]); + $timeEntryWithoutClient = TimeEntry::factory()->create([ + 'client_id' => null, + ]); + + $builder = TimeEntry::query(); + $filter = new TimeEntryFilter($builder); + + // Act + $filter->addClientIdsFilter([TimeEntryFilter::NONE_VALUE]); + + // Assert + $timeEntries = $builder->get(); + $this->assertCount(1, $timeEntries); + $this->assertTrue($timeEntries->contains($timeEntryWithoutClient)); + $this->assertFalse($timeEntries->contains($timeEntryWithClient)); + } + + public function test_add_tag_ids_filter_with_none_returns_entries_without_tags(): void + { + // Arrange + $tag = Tag::factory()->create(); + $timeEntryWithTag = TimeEntry::factory()->create([ + 'tags' => [$tag->getKey()], + ]); + $timeEntryWithEmptyTags = TimeEntry::factory()->create([ + 'tags' => [], + ]); + $timeEntryWithNullTags = TimeEntry::factory()->create([ + 'tags' => null, + ]); + + $builder = TimeEntry::query(); + $filter = new TimeEntryFilter($builder); + + // Act + $filter->addTagIdsFilter([TimeEntryFilter::NONE_VALUE]); + + // Assert + $timeEntries = $builder->get(); + $this->assertCount(2, $timeEntries); + $this->assertTrue($timeEntries->contains($timeEntryWithEmptyTags)); + $this->assertTrue($timeEntries->contains($timeEntryWithNullTags)); + $this->assertFalse($timeEntries->contains($timeEntryWithTag)); + } + + public function test_add_tag_ids_filter_with_none_and_ids_returns_both(): void + { + // Arrange + $tag1 = Tag::factory()->create(); + $tag2 = Tag::factory()->create(); + $timeEntryWithTag1 = TimeEntry::factory()->create([ + 'tags' => [$tag1->getKey()], + ]); + $timeEntryWithTag2 = TimeEntry::factory()->create([ + 'tags' => [$tag2->getKey()], + ]); + $timeEntryWithNoTags = TimeEntry::factory()->create([ + 'tags' => [], + ]); + + $builder = TimeEntry::query(); + $filter = new TimeEntryFilter($builder); + + // Act + $filter->addTagIdsFilter([$tag1->getKey(), TimeEntryFilter::NONE_VALUE]); + + // Assert + $timeEntries = $builder->get(); + $this->assertCount(2, $timeEntries); + $this->assertTrue($timeEntries->contains($timeEntryWithTag1)); + $this->assertTrue($timeEntries->contains($timeEntryWithNoTags)); + $this->assertFalse($timeEntries->contains($timeEntryWithTag2)); + } }