From d5bbba2c2f27451fbe43e7d08b88008ee16de4f9 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Mon, 6 May 2024 18:38:55 +0200 Subject: [PATCH] Added aggregate time entries endpoint --- .../Controllers/Api/V1/TaskController.php | 7 +- .../Api/V1/TimeEntryController.php | 181 ++++++++++++++++-- .../TimeEntry/TimeEntryAggregateRequest.php | 113 +++++++++++ .../V1/TimeEntry/TimeEntryIndexRequest.php | 50 ++++- app/Models/Task.php | 8 + app/Service/TimeEntryFilter.php | 147 ++++++++++++++ routes/api.php | 1 + .../Endpoint/Api/V1/TimeEntryEndpointTest.php | 87 +++++++++ tests/Unit/Model/TaskModelTest.php | 30 +++ 9 files changed, 597 insertions(+), 27 deletions(-) create mode 100644 app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php create mode 100644 app/Service/TimeEntryFilter.php diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index 0cb0e947..f1b6393e 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -11,11 +11,9 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest; use App\Http\Resources\V1\Task\TaskCollection; use App\Http\Resources\V1\Task\TaskResource; use App\Models\Organization; -use App\Models\Project; use App\Models\Task; use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\Auth; @@ -56,10 +54,7 @@ class TaskController extends Controller } if (! $canViewAllTasks) { - $query->whereHas('project', function (Builder $builder) use ($user): void { - /** @var Builder $builder */ - $builder->visibleByUser($user); - }); + $query->visibleByUser($user); } $tasks = $query->paginate(config('app.pagination_per_page_default')); diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 8f2fbcec..839deef6 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Enums\Weekday; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException; +use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest; @@ -13,13 +15,16 @@ use App\Http\Resources\V1\TimeEntry\TimeEntryCollection; use App\Http\Resources\V1\TimeEntry\TimeEntryResource; use App\Models\Organization; use App\Models\TimeEntry; +use App\Service\TimeEntryFilter; use App\Service\TimezoneService; +use Carbon\CarbonTimeZone; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; +use Ramsey\Uuid\Type\Time; class TimeEntryController extends Controller { @@ -53,26 +58,16 @@ class TimeEntryController extends Controller ->whereBelongsTo($organization, 'organization') ->orderBy('start', 'desc'); - if ($request->has('before')) { - $timeEntriesQuery->where('start', '<', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $request->input('before'), 'UTC')); - } - - if ($request->has('after')) { - $timeEntriesQuery->where('start', '>', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $request->input('after'), 'UTC')); - } - - if ($request->has('active')) { - if ($request->get('active') === 'true') { - $timeEntriesQuery->whereNull('end'); - } - if ($request->get('active') === 'false') { - $timeEntriesQuery->whereNotNull('end'); - } - } - - if ($request->has('user_id')) { - $timeEntriesQuery->where('user_id', $request->input('user_id')); - } + $filter = new TimeEntryFilter($timeEntriesQuery); + $filter->addBeforeFilter($request->input('before')); + $filter->addAfterFilter($request->input('after')); + $filter->addActiveFilter($request->input('active')); + $filter->addUserIdFilter($request->input('user_id')); + $filter->addProjectIdsFilter($request->input('project_ids')); + $filter->addTagIdsFilter($request->input('tag_ids')); + $filter->addTaskIdsFilter($request->input('task_ids')); + $filter->addClientIdsFilter($request->input('client_ids')); + $filter->addBillableFilter($request->input('billable')); $limit = $request->has('limit') ? (int) $request->get('limit', 100) : 100; if ($limit > 1000) { @@ -115,6 +110,152 @@ class TimeEntryController extends Controller return new TimeEntryCollection($timeEntries); } + /** + * Get aggregated time entries in organization + * + * @throws AuthorizationException + */ + public function aggregate(Organization $organization, TimeEntryAggregateRequest $request): array + { + if ($request->has('user_id') && $request->get('user_id') === Auth::id()) { + $this->checkPermission($organization, 'time-entries:view:own'); + } else { + $this->checkPermission($organization, 'time-entries:view:all'); + } + + $timeEntriesQuery = TimeEntry::query() + ->whereBelongsTo($organization, 'organization'); + + $filter = new TimeEntryFilter($timeEntriesQuery); + $filter->addBeforeFilter($request->input('before')); + $filter->addAfterFilter($request->input('after')); + $filter->addActiveFilter($request->input('active')); + $filter->addUserIdFilter($request->input('user_id')); + $filter->addProjectIdsFilter($request->input('project_ids')); + $filter->addTagIdsFilter($request->input('tag_ids')); + $filter->addTaskIdsFilter($request->input('task_ids')); + $filter->addClientIdsFilter($request->input('client_ids')); + $filter->addBillableFilter($request->input('billable')); + $timeEntriesQuery = $filter->get(); + + $user = Auth::user(); + + $group1Type = $request->get('group_1'); + $group2Type = $request->get('group_2'); + + $group1Select = null; + $group2Select = null; + $groupBy = null; + if ($group1Type !== null) { + $group1Select = $this->getGroupByQuery($group1Type, $user->timezone, $user->week_start); + $groupBy = ['group_1']; + if ($group2Type !== null) { + $group2Select = $this->getGroupByQuery($group2Type, $user->timezone, $user->week_start); + $groupBy = ['group_1', 'group_2']; + } + } + + $timeEntriesQuery->selectRaw( + ($group1Select !== null ? $group1Select.' as group_1,' : ''). + ($group2Select !== null ? $group2Select.' as group_2,' : ''). + ' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'. + ' round( + sum( + extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60) + ) + ) as cost' + ); + if ($groupBy !== null) { + $timeEntriesQuery->groupBy($groupBy); + } + + $timeEntriesAggregates = $timeEntriesQuery->get(); + + if ($group1Select !== null) { + $groupedAggregates = $timeEntriesAggregates->groupBy($group2Select !== null ? ['group_1', 'group_2'] : ['group_1']); + + $group1Response = []; + $group1ResponseSum = 0; + $group1ResponseCost = 0; + foreach ($groupedAggregates as $group1 => $group1Aggregates) { + $group2Response = []; + if ($group2Select !== null) { + $group2ResponseSum = 0; + $group2ResponseCost = 0; + foreach ($group1Aggregates as $group2 => $aggregate) { + $group2Response[] = [ + 'type' => $group2Type, + 'value' => $group2 === '' ? null : $group2, + 'aggregate' => (int) $aggregate->get(0)->aggregate, + 'cost' => (int) $aggregate->get(0)->cost, + ]; + $group2ResponseSum += (int) $aggregate->get(0)->aggregate; + $group2ResponseCost += (int) $aggregate->get(0)->cost; + } + } else { + $group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate; + $group2ResponseCost = (int) $group1Aggregates->get(0)->cost; + $group2Response = null; + } + + $group1Response[] = [ + 'type' => $group1Type, + 'value' => $group1 === '' ? null : $group1, + 'aggregate' => $group2ResponseSum, + 'grouped_data' => $group2Response, + ]; + $group1ResponseSum += $group2ResponseSum; + $group1ResponseCost += $group2ResponseCost; + } + } else { + $group1Response = null; + $group1ResponseSum = (int) $timeEntriesAggregates->get(0)->aggregate; + $group1ResponseCost = (int) $timeEntriesAggregates->get(0)->cost; + } + + return [ + 'data' => [ + 'grouped_data' => $group1Response, + 'aggregate' => $group1ResponseSum, + 'cost' => $group1ResponseCost, + ], + ]; + } + + private function getGroupByQuery(string $group, string $timezone, Weekday $startOfWeek): string + { + $timezoneShift = app(TimezoneService::class)->getShiftFromUtc(new CarbonTimeZone($timezone)); + if ($timezoneShift > 0) { + $dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\''; + } elseif ($timezoneShift < 0) { + $dateWithTimeZone = 'start - INTERVAL \''.abs($timezoneShift).' second\''; + } else { + $dateWithTimeZone = 'start'; + } + $startOfWeek = Carbon::now()->setTimezone($timezone)->startOfWeek($startOfWeek->carbonWeekDay())->utc()->toDateTimeString(); + if ($group === 'day') { + return 'date('.$dateWithTimeZone.')'; + } elseif ($group === 'week') { + return "to_char(date_bin('7 days', ".$dateWithTimeZone.", timestamp '".$startOfWeek."'), 'YYYY-MM-DD HH24:MI:SS')"; + } elseif ($group === 'month') { + return 'to_char('.$dateWithTimeZone.', \'YYYY-MM\')'; + } elseif ($group === 'year') { + return 'to_char('.$dateWithTimeZone.', \'YYYY\')'; + } elseif ($group === 'user') { + return 'user_id'; + } elseif ($group === 'project') { + return 'project_id'; + } elseif ($group === 'task') { + return 'task_id'; + } elseif ($group === 'client') { + return 'client_id'; + } elseif ($group === 'billable') { + return 'billable'; + } + + throw new \LogicException('Invalid group'); + } + /** * Create time entry * diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php new file mode 100644 index 00000000..a8a5d274 --- /dev/null +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -0,0 +1,113 @@ +> + */ + public function rules(): array + { + return [ + 'group_1' => [ + 'required_with:group_2', + 'in:day,week,month,year,user,project,task,client,billable', + ], + + 'group_2' => [ + 'in:day,week,month,year,user,project,task,client,billable', + ], + + // Filter by user ID + 'user_id' => [ + 'string', + 'uuid', + new ExistsEloquent(User::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->belongsToOrganization($this->organization); + }), + ], + // Filter by project IDs, project IDs are OR combined + 'project_ids' => [ + 'array', + 'min:1', + ], + 'project_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->visibleByUser(Auth::user()); + }), + ], + // Filter by tag IDs, tag IDs are AND combined + 'tag_ids' => [ + 'array', + 'min:1', + ], + 'tag_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // Filter by task IDs, task IDs are OR combined + 'task_ids' => [ + 'array', + 'min:1', + ], + 'task_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->visibleByUser(Auth::user()); + }), + ], + // Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31) + 'before' => [ + 'nullable', + 'string', + 'date_format:Y-m-d\TH:i:s\Z', + 'before:after', + ], + // Filter only time entries that have a start date after (not including) the given date (example: 2021-12-31) + 'after' => [ + 'nullable', + 'string', + 'date_format:Y-m-d\TH:i:s\Z', + ], + // Filter by active status (active means has no end date, is still running) + 'active' => [ + 'string', + 'in:true,false', + ], + // Filter by billable status + 'billable' => [ + 'string', + 'in:true,false', + ], + ]; + } +} diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 09cf44c1..3054080f 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -5,10 +5,14 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; use App\Models\Organization; +use App\Models\Project; +use App\Models\Tag; +use App\Models\Task; use App\Models\User; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Auth; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; /** @@ -33,6 +37,45 @@ class TimeEntryIndexRequest extends FormRequest return $builder->belongsToOrganization($this->organization); }), ], + // Filter by project IDs, project IDs are OR combined + 'project_ids' => [ + 'array', + 'min:1', + ], + 'project_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->visibleByUser(Auth::user()); + }), + ], + // Filter by tag IDs, tag IDs are AND combined + 'tag_ids' => [ + 'array', + 'min:1', + ], + 'tag_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // Filter by task IDs, task IDs are OR combined + 'task_ids' => [ + 'array', + 'min:1', + ], + 'task_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->visibleByUser(Auth::user()); + }), + ], // Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31) 'before' => [ 'nullable', @@ -46,11 +89,16 @@ class TimeEntryIndexRequest extends FormRequest 'string', 'date_format:Y-m-d\TH:i:s\Z', ], - // Filter only time entries that are active (have no end date, are still running) + // Filter by active status (active means has no end date, is still running) 'active' => [ 'string', 'in:true,false', ], + // Filter by billable status + 'billable' => [ + 'string', + 'in:true,false', + ], // Limit the number of returned time entries (default: 150) 'limit' => [ 'integer', diff --git a/app/Models/Task.php b/app/Models/Task.php index e868c93b..597664d2 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Models; use Database\Factories\TaskFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -63,4 +64,11 @@ class Task extends Model { return $this->hasMany(TimeEntry::class, 'task_id'); } + + public function scopeVisibleByUser(Builder $builder, User $user): Builder + { + return $builder->whereHas('project', function (Builder $builder) use ($user): Builder { + return $builder->visibleByUser($user); + }); + } } diff --git a/app/Service/TimeEntryFilter.php b/app/Service/TimeEntryFilter.php new file mode 100644 index 00000000..c6570f7b --- /dev/null +++ b/app/Service/TimeEntryFilter.php @@ -0,0 +1,147 @@ + + */ + private Builder $builder; + + /** + * @param Builder $builder + */ + public function __construct(Builder $builder) + { + $this->builder = $builder; + } + + public function addBeforeFilter(?string $dateTime): self + { + if ($dateTime === null) { + return $this; + } + $this->builder->where('start', '<', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC')); + + return $this; + } + + public function addAfterFilter(?string $dateTime): self + { + if ($dateTime === null) { + return $this; + } + $this->builder->where('start', '>', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC')); + + return $this; + } + + public function addActiveFilter(?string $active): self + { + if ($active === null) { + return $this; + } + if ($active === 'true') { + $this->builder->whereNull('end'); + } + if ($active === 'false') { + $this->builder->whereNotNull('end'); + } + + return $this; + } + + public function addUserIdFilter(?string $userId): self + { + if ($userId === null) { + return $this; + } + $this->builder->where('user_id', $userId); + + return $this; + } + + public function addBillableFilter(?string $billable): self + { + if ($billable === null) { + return $this; + } + if ($billable === 'true') { + $this->builder->where('billable', '=', true); + } elseif ($billable === 'false') { + $this->builder->where('billable', '=', false); + } else { + Log::warning('Invalid billable filter value', ['value' => $billable]); + } + + return $this; + } + + /** + * @param array|null $clientIds + */ + public function addClientIdsFilter(?array $clientIds): self + { + if ($clientIds === null) { + return $this; + } + $this->builder->whereIn('client_id', $clientIds); + + return $this; + } + + /** + * @param array|null $projectIds + */ + public function addProjectIdsFilter(?array $projectIds): self + { + if ($projectIds === null) { + return $this; + } + $this->builder->whereIn('project_id', $projectIds); + + return $this; + } + + /** + * @param array|null $tagIds + */ + public function addTagIdsFilter(?array $tagIds): self + { + if ($tagIds === null) { + return $this; + } + $this->builder->whereJsonContains('tags', $tagIds); + + return $this; + } + + /** + * @param array|null $taskIds + */ + public function addTaskIdsFilter(?array $taskIds): self + { + if ($taskIds === null) { + return $this; + } + $this->builder->whereIn('task_id', $taskIds); + + return $this; + } + + /** + * @return Builder + */ + public function get(): Builder + { + return $this->builder; + } +} diff --git a/routes/api.php b/routes/api.php index 0c9494ba..a7520fff 100644 --- a/routes/api.php +++ b/routes/api.php @@ -73,6 +73,7 @@ Route::middleware([ // Time entry routes Route::name('time-entries.')->group(static function () { Route::get('/organizations/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index'); + Route::get('/organizations/{organization}/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate'); Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store'); Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update'); Route::delete('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy'); diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index 91b55b8a..c4996ab7 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1; +use App\Models\Project; use App\Models\TimeEntry; use App\Models\User; use Carbon\Carbon; @@ -384,6 +385,92 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract ); } + public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [$data->organization->getKey()])); + + // Assert + $response->assertForbidden(); + } + + public function test_aggregate_endpoint_groups_by_two_groups(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3); + $project = Project::factory()->forOrganization($data->organization)->create(); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->forProject($project)->createMany(3); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->state([ + 'start' => $timeEntries->get(0)->start, + ])->createMany(3); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + 'group_1' => 'day', + 'group_2' => 'project', + ])); + + // Assert + $response->assertSuccessful(); + } + + public function test_aggregate_endpoint_groups_by_one_group(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3); + $project = Project::factory()->forOrganization($data->organization)->create(); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->forProject($project)->createMany(3); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->state([ + 'start' => $timeEntries->get(0)->start, + ])->createMany(3); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + 'group_1' => 'week', + ])); + + // Assert + $response->assertSuccessful(); + } + + public function test_aggregate_endpoint_with_no_group(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3); + $project = Project::factory()->forOrganization($data->organization)->create(); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->forProject($project)->createMany(3); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->state([ + 'start' => $timeEntries->get(0)->start, + ])->createMany(3); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + ])); + + // Assert + $response->assertSuccessful(); + } + public function test_store_endpoint_fails_if_user_has_no_permission_to_create_time_entries(): void { // Arrange diff --git a/tests/Unit/Model/TaskModelTest.php b/tests/Unit/Model/TaskModelTest.php index b65fd667..6058e877 100644 --- a/tests/Unit/Model/TaskModelTest.php +++ b/tests/Unit/Model/TaskModelTest.php @@ -6,8 +6,10 @@ namespace Tests\Unit\Model; use App\Models\Organization; use App\Models\Project; +use App\Models\ProjectMember; use App\Models\Task; use App\Models\TimeEntry; +use App\Models\User; class TaskModelTest extends ModelTestAbstract { @@ -56,4 +58,32 @@ class TaskModelTest extends ModelTestAbstract // Assert $this->assertCount(3, $timeEntries); } + + public function test_scope_visible_by_user_filters_so_that_only_tasks_of_public_projects_or_projects_where_the_user_is_member_are_shown(): void + { + // Arrange + $user = User::factory()->create(); + $projectPrivate = Project::factory()->isPrivate()->create(); + $projectPublic = Project::factory()->isPublic()->create(); + $projectPrivateButMember = Project::factory()->isPrivate()->create(); + ProjectMember::factory()->forProject($projectPrivateButMember)->forUser($user)->create(); + $taskPrivate = Task::factory()->forProject($projectPrivate)->create(); + $taskPublic = Task::factory()->forProject($projectPublic)->create(); + $taskPrivateButMember = Task::factory()->forProject($projectPrivateButMember)->create(); + + // Act + $tasksVisible = Task::query()->visibleByUser($user)->get(); + $allTasks = Task::query()->get(); + + // Assert + $this->assertEqualsIdsOfEloquentCollection([ + $taskPublic->getKey(), + $taskPrivateButMember->getKey(), + ], $tasksVisible); + $this->assertEqualsIdsOfEloquentCollection([ + $taskPrivate->getKey(), + $taskPublic->getKey(), + $taskPrivateButMember->getKey(), + ], $allTasks); + } }