From eed638d0aa97ac90aaa59b4a59bf360056b89e96 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 18 Feb 2026 19:16:14 +0100 Subject: [PATCH] add default sorting to task, project, member, invitation, api token endpoints --- .../Controllers/Api/V1/ApiTokenController.php | 1 + .../Api/V1/InvitationController.php | 1 + .../Controllers/Api/V1/MemberController.php | 1 + .../Controllers/Api/V1/ProjectController.php | 4 ++- .../Api/V1/ProjectMemberController.php | 1 + .../Controllers/Api/V1/TaskController.php | 4 ++- .../Endpoint/Api/V1/ApiTokenEndpointTest.php | 25 +++++++++++++ .../Api/V1/InvitationEndpointTest.php | 26 ++++++++++++++ .../Endpoint/Api/V1/MemberEndpointTest.php | 32 +++++++++++++++++ .../Endpoint/Api/V1/ProjectEndpointTest.php | 35 ++++++++++++++++--- .../Api/V1/ProjectMemberEndpointTest.php | 30 ++++++++++++++++ .../Unit/Endpoint/Api/V1/TaskEndpointTest.php | 27 ++++++++++++++ 12 files changed, 181 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/V1/ApiTokenController.php b/app/Http/Controllers/Api/V1/ApiTokenController.php index 2dea2694..1fbf12d8 100644 --- a/app/Http/Controllers/Api/V1/ApiTokenController.php +++ b/app/Http/Controllers/Api/V1/ApiTokenController.php @@ -35,6 +35,7 @@ class ApiTokenController extends Controller /** @var Builder $query */ $query->whereJsonContains('grant_types', 'personal_access'); }) + ->orderBy('created_at', 'desc') ->get(); return new ApiTokenCollection($tokens); diff --git a/app/Http/Controllers/Api/V1/InvitationController.php b/app/Http/Controllers/Api/V1/InvitationController.php index 426a4c59..4e7a0dcd 100644 --- a/app/Http/Controllers/Api/V1/InvitationController.php +++ b/app/Http/Controllers/Api/V1/InvitationController.php @@ -41,6 +41,7 @@ class InvitationController extends Controller $this->checkPermission($organization, 'invitations:view'); $invitations = $organization->teamInvitations() + ->orderBy('created_at', 'desc') ->paginate(config('app.pagination_per_page_default')); return InvitationCollection::make($invitations); diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index c4d55a50..6dde1b6e 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -60,6 +60,7 @@ class MemberController extends Controller $members = Member::query() ->whereBelongsTo($organization, 'organization') ->with(['user']) + ->orderBy('created_at', 'desc') ->paginate(config('app.pagination_per_page_default')); return MemberCollection::make($members); diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 72ee24f7..060b836f 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -60,7 +60,9 @@ class ProjectController extends Controller $projectsQuery->whereNull('archived_at'); } - $projects = $projectsQuery->paginate(config('app.pagination_per_page_default')); + $projects = $projectsQuery + ->orderBy('created_at', 'desc') + ->paginate(config('app.pagination_per_page_default')); $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; diff --git a/app/Http/Controllers/Api/V1/ProjectMemberController.php b/app/Http/Controllers/Api/V1/ProjectMemberController.php index dfad9137..ff6c03c2 100644 --- a/app/Http/Controllers/Api/V1/ProjectMemberController.php +++ b/app/Http/Controllers/Api/V1/ProjectMemberController.php @@ -47,6 +47,7 @@ class ProjectMemberController extends Controller $projectMembers = ProjectMember::query() ->whereBelongsTo($project, 'project') + ->orderBy('created_at', 'desc') ->paginate(config('app.pagination_per_page_default')); return new ProjectMemberCollection($projectMembers); diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index 68a371ed..fe554110 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -82,7 +82,9 @@ class TaskController extends Controller $query->whereNull('done_at'); } - $tasks = $query->paginate(config('app.pagination_per_page_default')); + $tasks = $query + ->orderBy('created_at', 'desc') + ->paginate(config('app.pagination_per_page_default')); return new TaskCollection($tasks); } diff --git a/tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php index 9f9b177a..f23b44e6 100644 --- a/tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php @@ -46,6 +46,31 @@ class ApiTokenEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_index_endpoint_returns_api_tokens_ordered_by_created_at_descending(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + $personalAccessClient = $this->createPersonalAccessClient(); + $tokenOldest = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create([ + 'created_at' => now()->subDays(3), + ]); + $tokenNewest = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create([ + 'created_at' => now()->subDay(), + ]); + $tokenMiddle = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create([ + 'created_at' => now()->subDays(2), + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.api-tokens.index')); + + // Assert + $this->assertResponseCode($response, 200); + $ids = collect($response->json('data'))->pluck('id')->values()->toArray(); + $this->assertSame([$tokenNewest->id, $tokenMiddle->id, $tokenOldest->id], $ids); + } + public function test_store_endpoint_creates_new_api_token(): void { // Arrange diff --git a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php index 0aaa8e57..d1752a14 100644 --- a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php @@ -55,6 +55,32 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_index_returns_invitations_ordered_by_created_at_descending(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'invitations:view', + ]); + $invitationOldest = OrganizationInvitation::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDays(3), + ]); + $invitationNewest = OrganizationInvitation::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDay(), + ]); + $invitationMiddle = OrganizationInvitation::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDays(2), + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.invitations.index', $data->organization->getKey())); + + // Assert + $response->assertStatus(200); + $ids = collect($response->json('data'))->pluck('id')->values()->toArray(); + $this->assertSame([$invitationNewest->getKey(), $invitationMiddle->getKey(), $invitationOldest->getKey()], $ids); + } + public function test_store_fails_if_user_has_no_permission_to_create_invitations(): void { // Arrange diff --git a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php index 7981dbc8..7ee0bfec 100644 --- a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php @@ -52,6 +52,38 @@ class MemberEndpointTest extends ApiEndpointTestAbstract $response->assertStatus(200); } + public function test_index_returns_members_ordered_by_created_at_descending(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'members:view', + ]); + $memberOldest = Member::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDays(3), + ]); + $memberNewest = Member::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDay(), + ]); + $memberMiddle = Member::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDays(2), + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.members.index', $data->organization->getKey())); + + // Assert + $response->assertStatus(200); + $ids = collect($response->json('data'))->pluck('id')->values()->toArray(); + // Verify that the three explicitly created members appear in newest-first order + $createdMemberIds = array_values(array_filter($ids, fn ($id) => in_array($id, [ + $memberOldest->getKey(), + $memberNewest->getKey(), + $memberMiddle->getKey(), + ], true))); + $this->assertSame([$memberNewest->getKey(), $memberMiddle->getKey(), $memberOldest->getKey()], $createdMemberIds); + } + public function test_update_member_fails_if_user_has_no_permission_to_update_members(): void { // Arrange diff --git a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php index c606ed1f..d9312f8e 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php @@ -54,6 +54,33 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $response->assertJsonCount(4, 'data'); } + public function test_index_endpoint_returns_projects_ordered_by_created_at_descending(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:view', + 'projects:view:all', + ]); + $projectOldest = Project::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDays(3), + ]); + $projectNewest = Project::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDay(), + ]); + $projectMiddle = Project::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDays(2), + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()])); + + // Assert + $response->assertStatus(200); + $ids = collect($response->json('data'))->pluck('id')->values()->toArray(); + $this->assertSame([$projectNewest->getKey(), $projectMiddle->getKey(), $projectOldest->getKey()], $ids); + } + public function test_index_endpoint_without_filter_archived_returns_only_non_archived_projects(): void { // Arrange @@ -211,10 +238,10 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract ->has('data') ->has('links') ->has('meta') - ->where('data.0.billable_rate', 112) - ->where('data.1.billable_rate', 112) - ->where('data.2.billable_rate', 113) - ->where('data.3.billable_rate', 113) + ->where('data.0.billable_rate', 113) + ->where('data.1.billable_rate', 113) + ->where('data.2.billable_rate', 112) + ->where('data.3.billable_rate', 112) ); } diff --git a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php index 48c14c0d..7a0de6a2 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php @@ -80,6 +80,36 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract $response->assertJsonCount(4, 'data'); } + public function test_index_endpoint_returns_project_members_ordered_by_created_at_descending(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'project-members:view', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $pmOldest = ProjectMember::factory()->forProject($project)->create([ + 'created_at' => now()->subDays(3), + ]); + $pmNewest = ProjectMember::factory()->forProject($project)->create([ + 'created_at' => now()->subDay(), + ]); + $pmMiddle = ProjectMember::factory()->forProject($project)->create([ + 'created_at' => now()->subDays(2), + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.project-members.index', [ + $data->organization->getKey(), + $project->getKey(), + ])); + + // Assert + $response->assertStatus(200); + $ids = collect($response->json('data'))->pluck('id')->values()->toArray(); + $this->assertSame([$pmNewest->getKey(), $pmMiddle->getKey(), $pmOldest->getKey()], $ids); + } + public function test_store_endpoint_fails_if_user_has_no_permission_to_add_members_to_project(): void { // Arrange diff --git a/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php index 82e7b289..0c4c6054 100644 --- a/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php @@ -78,6 +78,33 @@ class TaskEndpointTest extends ApiEndpointTestAbstract $response->assertJsonCount(4, 'data'); } + public function test_index_endpoint_returns_tasks_ordered_by_created_at_descending(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:view', + 'tasks:view:all', + ]); + $taskOldest = Task::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDays(3), + ]); + $taskNewest = Task::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDay(), + ]); + $taskMiddle = Task::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDays(2), + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey(), 'done' => 'all'])); + + // Assert + $response->assertStatus(200); + $ids = collect($response->json('data'))->pluck('id')->values()->toArray(); + $this->assertSame([$taskNewest->getKey(), $taskMiddle->getKey(), $taskOldest->getKey()], $ids); + } + public function test_index_endpoint_without_filter_done_returns_list_of_all_tasks_of_organization(): void { // Arrange