Reject changing the member of running time entries

Moving a running time entry to another member could give that member's
user a second running time entry. Stopping the time entry and changing
the member in the same request is still allowed.
This commit is contained in:
Constantin Graf
2026-10-13 17:08:25 +02:00
parent f77fe85ea0
commit cc44f5891b
5 changed files with 151 additions and 1 deletions

View File

@@ -3332,6 +3332,80 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_update_endpoint_fails_if_member_of_running_time_entry_is_changed(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:all',
]);
$otherMember = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
'member_id' => $otherMember->getKey(),
]);
// Assert
$response->assertStatus(400);
$response->assertJsonPath('key', 'time_entry_member_can_not_be_changed_while_running');
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry->getKey(),
'member_id' => $data->member->getKey(),
'user_id' => $data->user->getKey(),
]);
}
public function test_update_endpoint_can_update_running_time_entry_with_unchanged_member_id(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
'member_id' => $data->member->getKey(),
'description' => 'New description',
]);
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry->getKey(),
'description' => 'New description',
]);
}
public function test_update_endpoint_can_stop_running_time_entry_and_change_member_in_the_same_request(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:all',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
'member_id' => $otherMember->getKey(),
'end' => Carbon::now()->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry->getKey(),
'member_id' => $otherMember->getKey(),
'user_id' => $otherUser->getKey(),
]);
}
public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
{
// Arrange
@@ -4199,6 +4273,51 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_update_multiple_does_not_change_member_of_running_time_entries(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:all',
]);
$otherMember = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();
$runningTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
$runningTimeEntryOfOtherMember = TimeEntry::factory()->forOrganization($data->organization)->forMember($otherMember)->active()->create();
$finishedTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
Passport::actingAs($data->user);
// Act
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
'ids' => [
$runningTimeEntry->getKey(),
$runningTimeEntryOfOtherMember->getKey(),
$finishedTimeEntry->getKey(),
],
'changes' => [
'member_id' => $otherMember->getKey(),
],
]);
// Assert
$response->assertStatus(200);
$response->assertExactJson([
'success' => [
$runningTimeEntryOfOtherMember->getKey(),
$finishedTimeEntry->getKey(),
],
'error' => [
$runningTimeEntry->getKey(),
],
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $runningTimeEntry->getKey(),
'member_id' => $data->member->getKey(),
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $finishedTimeEntry->getKey(),
'member_id' => $otherMember->getKey(),
]);
}
public function test_update_multiple_updates_user_id_when_member_id_changes(): void
{
// Arrange

View File

@@ -22,6 +22,7 @@ use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
@@ -53,6 +54,7 @@ class ApiExceptionTest extends TestCase
yield OverlappingTimeEntryApiException::class => [new OverlappingTimeEntryApiException];
yield ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::class => [new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException];
yield TimeEntryCanNotBeRestartedApiException::class => [new TimeEntryCanNotBeRestartedApiException];
yield TimeEntryMemberCanNotBeChangedWhileRunningApiException::class => [new TimeEntryMemberCanNotBeChangedWhileRunningApiException];
yield TimeEntryStillRunningApiException::class => [new TimeEntryStillRunningApiException];
yield UserIsAlreadyMemberOfOrganizationApiException::class => [new UserIsAlreadyMemberOfOrganizationApiException];
yield UserIsAlreadyMemberOfProjectApiException::class => [new UserIsAlreadyMemberOfProjectApiException];