Compare commits

..

6 Commits

Author SHA1 Message Date
Gregor Vostrak
b8b0c615de fix scramble diagnostics in goal resource and requests 2026-10-08 18:11:30 +02:00
Gregor Vostrak
de05e9da46 remove PaginatedResourceCollection from goal collection after scramble 0.13 update 2026-10-08 18:02:26 +02:00
Gregor Vostrak
0d76021f8a improve goal target formatting in superadmin interface 2026-10-08 17:58:49 +02:00
Gregor Vostrak
85d93c47ce fix broken loading spinner position on initial load 2026-10-08 17:58:49 +02:00
Gregor Vostrak
4b04a3b8de move goal progress calculation out of the resources 2026-10-08 17:58:49 +02:00
Gregor Vostrak
38e33b318e Add goals feature for personal goals with filters 2026-10-08 17:58:48 +02:00
20 changed files with 14 additions and 371 deletions

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class TimeEntryMemberCanNotBeChangedWhileRunningApiException extends ApiException
{
public const string KEY = 'time_entry_member_can_not_be_changed_while_running';
public function report(): bool
{
return true;
}
}

View File

@@ -11,7 +11,6 @@ use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
@@ -52,7 +51,6 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
@@ -615,24 +613,7 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:create:all');
}
// Lock the creation of running time entries per user, so that concurrent requests can not create more than one running time entry
$lock = $request->input('end') === null ? Cache::lock('time-entries:running:'.$member->user_id, 10) : null;
$lock?->block(5);
try {
return $this->storeTimeEntry($organization, $member, $request);
} finally {
$lock?->release();
}
}
/**
* @throws TimeEntryStillRunningApiException
*/
private function storeTimeEntry(Organization $organization, Member $member, TimeEntryStoreRequest $request): JsonResource
{
// A user can only have one running time entry, across all organizations
if ($request->input('end') === null && TimeEntry::query()->where('user_id', $member->user_id)->whereNull('end')->exists()) {
if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) {
throw new TimeEntryStillRunningApiException;
}
@@ -669,7 +650,7 @@ class TimeEntryController extends Controller
*
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
*
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException|TimeEntryMemberCanNotBeChangedWhileRunningApiException
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
*
* @operationId updateTimeEntry
*/
@@ -695,11 +676,6 @@ class TimeEntryController extends Controller
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
// The new member could already have a running time entry
if ($effectiveEnd === null && $newMember !== null && $newMember->getKey() !== $timeEntry->member_id) {
throw new TimeEntryMemberCanNotBeChangedWhileRunningApiException;
}
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
@@ -825,12 +801,6 @@ class TimeEntryController extends Controller
continue;
}
// The new member could already have a running time entry
if ($newMember !== null && $timeEntry->end === null && $newMember->getKey() !== $timeEntry->member_id) {
$error->push($id);
continue;
}
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;

View File

@@ -211,10 +211,6 @@ class SolidtimeImporter extends DefaultImporter
// Time entries
foreach ($timeEntriesReader as $timeEntryRow) {
// Running time entries are skipped, since the user could already have a running time entry
if ($timeEntryRow['end'] === '') {
continue;
}
$userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']);
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);
$member = $this->memberImportHelper->getModelById($memberId);
@@ -275,15 +271,19 @@ class SolidtimeImporter extends DefaultImporter
}
$timeEntry->start = $start->utc();
try {
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
if ($timeEntryRow['end'] !== '') {
try {
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
if ($end === null) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
$timeEntry->end = $end->utc();
} else {
$timeEntry->end = null;
}
if ($end === null) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
$timeEntry->end = $end->utc();
if ($timeEntryRow['still_active_email_sent_at'] !== '') {
try {

View File

@@ -159,13 +159,6 @@ class MemberService
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
{
// Running time entries are deleted instead of moved, since $toMember could already have a running time entry
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->whereBelongsTo($fromMember, 'member')
->whereNull('end')
->delete();
// Time entries
TimeEntry::query()
->whereBelongsTo($organization, 'organization')

View File

@@ -19,7 +19,6 @@ 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;
@@ -32,7 +31,6 @@ return [
TimeEntryStillRunningApiException::KEY => 'Time entry is still running',
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
TimeEntryMemberCanNotBeChangedWhileRunningApiException::KEY => 'The member of a running time entry can not be changed',
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization',
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',

View File

@@ -1,3 +0,0 @@
id,name,organization_id,archived_at,created_at,updated_at
b4187a44-41f4-46d7-8460-f15a25b3aad6,"Big Company",ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72,"Other Company (Archived)",ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id name organization_id archived_at created_at updated_at
2 b4187a44-41f4-46d7-8460-f15a25b3aad6 Big Company ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
3 e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72 Other Company (Archived) ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -1,2 +0,0 @@
id,user_id,name,email,organization_id,billable_rate,role,created_at,updated_at
06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,"Peter Tester",peter.test@email.test,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,admin,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id user_id name email organization_id billable_rate role created_at updated_at
2 06e6e605-86bd-417b-b75d-02f671e5d520 0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c Peter Tester peter.test@email.test ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 admin 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -1 +0,0 @@
{"id":"d6a324ee-58d5-4096-8069-c63bd55608f7","version":"1.0","organizations":["ee5a8cd6-312f-4ae6-b044-e2014f09ecc2"],"exported_at":"2024-08-26T18:21:59Z"}

View File

@@ -1 +0,0 @@
id,email,organization_id,role,created_at,updated_at
1 id email organization_id role created_at updated_at

View File

@@ -1,2 +0,0 @@
id,name,billable_rate,currency,created_at,updated_at
ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,"ACME Corp",,EUR,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id name billable_rate currency created_at updated_at
2 ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 ACME Corp EUR 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -1,2 +0,0 @@
id,billable_rate,project_id,user_id,member_id,created_at,updated_at
180a1a98-2f1c-4596-86e4-63a6be0d7b1d,10002,06e79ec4-33f8-4730-804c-d03c014991d1,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,06e6e605-86bd-417b-b75d-02f671e5d520,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id billable_rate project_id user_id member_id created_at updated_at
2 180a1a98-2f1c-4596-86e4-63a6be0d7b1d 10002 06e79ec4-33f8-4730-804c-d03c014991d1 0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c 06e6e605-86bd-417b-b75d-02f671e5d520 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -1,4 +0,0 @@
id,name,color,billable_rate,is_public,client_id,organization_id,is_billable,archived_at,created_at,updated_at
06e79ec4-33f8-4730-804c-d03c014991d1,"Project for Big Company",#ec407a,10001,false,b4187a44-41f4-46d7-8460-f15a25b3aad6,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,true,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
622c74a9-7e64-44c2-9426-2a37ac738206,"Project without Client",#ef5350,,false,,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
aa831162-dbb2-4cfe-bfe0-5e3a252c66f0,"Project (Archived)",#6a407f,,true,e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,true,2024-08-25T10:00:00Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id name color billable_rate is_public client_id organization_id is_billable archived_at created_at updated_at
2 06e79ec4-33f8-4730-804c-d03c014991d1 Project for Big Company #ec407a 10001 false b4187a44-41f4-46d7-8460-f15a25b3aad6 ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 true 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
3 622c74a9-7e64-44c2-9426-2a37ac738206 Project without Client #ef5350 false ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 false 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
4 aa831162-dbb2-4cfe-bfe0-5e3a252c66f0 Project (Archived) #6a407f true e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72 ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 true 2024-08-25T10:00:00Z 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -1,3 +0,0 @@
id,name,organization_id,created_at,updated_at
2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7,Development,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
bf6c0ac5-2587-474b-8983-40bb3ea8002f,Backend,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id name organization_id created_at updated_at
2 2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7 Development ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
3 bf6c0ac5-2587-474b-8983-40bb3ea8002f Backend ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -1,3 +0,0 @@
id,name,project_id,organization_id,done_at,created_at,updated_at
b49688a0-94f3-4cb3-9ca1-5003de955fb0,"Task 1",06e79ec4-33f8-4730-804c-d03c014991d1,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
b49688a0-94f3-4cb3-9ca1-5003de955fb0,"Task 2",06e79ec4-33f8-4730-804c-d03c014991d1,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-24T10:00:00Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id name project_id organization_id done_at created_at updated_at
2 b49688a0-94f3-4cb3-9ca1-5003de955fb0 Task 1 06e79ec4-33f8-4730-804c-d03c014991d1 ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
3 b49688a0-94f3-4cb3-9ca1-5003de955fb0 Task 2 06e79ec4-33f8-4730-804c-d03c014991d1 ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 2024-08-24T10:00:00Z 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -1,5 +0,0 @@
id,description,start,end,billable_rate,billable,type,member_id,user_id,organization_id,client_id,project_id,task_id,tags,is_imported,still_active_email_sent_at,created_at,updated_at
00aae3be-18fc-462d-bee4-350fb605b2f3,,2024-03-04T09:23:52Z,2024-03-04T09:23:52Z,,false,,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,"[""2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7"",""bf6c0ac5-2587-474b-8983-40bb3ea8002f""]",false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1c7a905d-aa12-4d08-bc41-7e92577e7cdf,"Working hard",2024-03-04T09:23:00Z,2024-03-04T10:23:01Z,,true,work,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,b4187a44-41f4-46d7-8460-f15a25b3aad6,06e79ec4-33f8-4730-804c-d03c014991d1,b49688a0-94f3-4cb3-9ca1-5003de955fb0,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
9f2f5b83-3a6b-4a9c-8d21-4f1e5cf1b7aa,"Lunch break",2024-03-04T12:00:00Z,2024-03-04T12:30:00Z,,false,break,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
5d0e8c4a-7b1f-4e2a-9c3d-8a6b2f1e4c7d,"Still running",2024-03-04T13:00:00Z,,,true,work,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id description start end billable_rate billable type member_id user_id organization_id client_id project_id task_id tags is_imported still_active_email_sent_at created_at updated_at
2 00aae3be-18fc-462d-bee4-350fb605b2f3 2024-03-04T09:23:52Z 2024-03-04T09:23:52Z false 06e6e605-86bd-417b-b75d-02f671e5d520 0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 ["2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7","bf6c0ac5-2587-474b-8983-40bb3ea8002f"] false 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
3 1c7a905d-aa12-4d08-bc41-7e92577e7cdf Working hard 2024-03-04T09:23:00Z 2024-03-04T10:23:01Z true work 06e6e605-86bd-417b-b75d-02f671e5d520 0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 b4187a44-41f4-46d7-8460-f15a25b3aad6 06e79ec4-33f8-4730-804c-d03c014991d1 b49688a0-94f3-4cb3-9ca1-5003de955fb0 [] false 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
4 9f2f5b83-3a6b-4a9c-8d21-4f1e5cf1b7aa Lunch break 2024-03-04T12:00:00Z 2024-03-04T12:30:00Z false break 06e6e605-86bd-417b-b75d-02f671e5d520 0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 [] false 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z
5 5d0e8c4a-7b1f-4e2a-9c3d-8a6b2f1e4c7d Still running 2024-03-04T13:00:00Z true work 06e6e605-86bd-417b-b75d-02f671e5d520 0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c ee5a8cd6-312f-4ae6-b044-e2014f09ecc2 [] false 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -390,40 +390,6 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_merge_into_deletes_running_time_entries_of_source_member(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:merge-into',
]);
$userSource = User::factory()->placeholder()->create();
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
$runningTimeEntrySource = TimeEntry::factory()->forOrganization($data->organization)->forMember($memberSource)->active()->create();
TimeEntry::factory()->forOrganization($data->organization)->forMember($memberSource)->createMany(2);
$userDestination = User::factory()->create();
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
$runningTimeEntryDestination = TimeEntry::factory()->forOrganization($data->organization)->forMember($memberDestination)->active()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
'member_id' => $memberDestination->getKey(),
]);
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Member::class, [
'id' => $memberSource->getKey(),
]);
$this->assertDatabaseMissing(TimeEntry::class, [
'id' => $runningTimeEntrySource->getKey(),
]);
$memberDestination->refresh();
$this->assertCount(3, $memberDestination->timeEntries);
$this->assertSame([$runningTimeEntryDestination->getKey()], TimeEntry::query()->whereBelongsTo($userDestination, 'user')->whereNull('end')->pluck('id')->all());
}
public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member_with_existing_destination_resources(): void
{
// Arrange

View File

@@ -24,9 +24,7 @@ use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -2446,77 +2444,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('error', true);
}
public function test_store_endpoint_fails_if_user_already_has_active_time_entry_in_another_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$otherOrganization = Organization::factory()->create();
$otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($data->user)->create();
TimeEntry::factory()->forOrganization($otherOrganization)->forMember($otherMember)->active()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => true,
'start' => Carbon::now()->toIso8601ZuluString(),
'end' => null,
'member_id' => $data->member->getKey(),
]);
// Assert
$response->assertStatus(400);
$response->assertJsonPath('key', 'time_entry_still_running');
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($data->organization, 'organization')->count());
}
public function test_store_endpoint_releases_running_time_entry_lock_after_request(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => true,
'start' => Carbon::now()->toIso8601ZuluString(),
'end' => null,
'member_id' => $data->member->getKey(),
]);
// Assert
$response->assertStatus(201);
$this->assertTrue(Cache::lock('time-entries:running:'.$data->user->getKey(), 10)->get());
}
public function test_store_endpoint_waits_for_running_time_entry_lock_and_fails_if_it_is_not_released(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
Cache::lock('time-entries:running:'.$data->user->getKey(), 10)->get();
$this->withoutExceptionHandling();
Passport::actingAs($data->user);
// Act
try {
$this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'billable' => true,
'start' => Carbon::now()->toIso8601ZuluString(),
'end' => null,
'member_id' => $data->member->getKey(),
]);
$this->fail('Expected LockTimeoutException');
} catch (LockTimeoutException) {
// Assert
$this->assertSame(0, TimeEntry::query()->count());
}
}
public function test_store_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void
{
// Arrange
@@ -3237,80 +3164,6 @@ 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
@@ -4178,51 +4031,6 @@ 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,7 +22,6 @@ 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;
@@ -54,7 +53,6 @@ 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];

View File

@@ -140,31 +140,6 @@ class SolidtimeImporterTest extends ImporterTestAbstract
$this->assertSame([], $breakEntry->tags);
}
public function test_import_skips_running_time_entries(): void
{
// Arrange
$zipPath = $this->createTestZip('solidtime_import_test_3');
$timezone = 'Europe/Vienna';
$organization = Organization::factory()->create();
$importer = new SolidtimeImporter;
$importer->init($organization);
$data = file_get_contents($zipPath);
Queue::fake([
RecalculateSpentTimeForProject::class,
RecalculateSpentTimeForTask::class,
]);
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$this->assertSame(3, $report->timeEntriesCreated);
$this->assertSame(3, TimeEntry::query()->count());
$this->assertSame(0, TimeEntry::query()->whereNull('end')->count());
$this->assertNull(TimeEntry::query()->where('description', 'Still running')->first());
}
public function test_import_of_test_file_twice_succeeds(): void
{
// Arrange

View File

@@ -228,30 +228,6 @@ class MemberServiceTest extends TestCaseWithDatabase
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count());
}
public function test_assign_organization_entities_to_different_member_deletes_running_time_entries_of_the_member(): void
{
// Arrange
$organization = Organization::factory()->create();
$fromUser = User::factory()->create();
$toUser = User::factory()->create();
$fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();
$toUserMember = Member::factory()->forOrganization($organization)->forUser($toUser)->create();
$runningTimeEntryOfFromMember = TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->active()->create();
TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->createMany(2);
$runningTimeEntryOfToMember = TimeEntry::factory()->forOrganization($organization)->forMember($toUserMember)->active()->create();
// Act
$this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromUserMember, $toUserMember);
// Assert
$this->assertDatabaseMissing(TimeEntry::class, [
'id' => $runningTimeEntryOfFromMember->getKey(),
]);
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUserMember, 'member')->count());
$this->assertSame([$runningTimeEntryOfToMember->getKey()], TimeEntry::query()->whereBelongsTo($toUser, 'user')->whereNull('end')->pluck('id')->all());
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUserMember, 'member')->count());
}
public function test_assign_organization_entities_to_different_member_moves_all_goals_of_the_member(): void
{
// Arrange