Compare commits

..

5 Commits

Author SHA1 Message Date
Constantin Graf
c5bcf98b84 Delete running time entries of placeholder when merging members
The running time entries of the merged member could give the destination
member a second running time entry. The placeholder is deleted after the
merge, so its running time entries can not stay with it.
2026-10-13 18:04:11 +02:00
Constantin Graf
0bfeab156c Skip running time entries in solidtime import
Imported running time entries could give a user a second running time
entry, for example when an export is imported again while a timer runs.
2026-10-13 18:04:11 +02:00
Constantin Graf
332edcb047 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.
2026-10-13 17:08:25 +02:00
Constantin Graf
c791057f25 Allow only one running time entry per user across organizations
The running time entry check in the store endpoint was scoped to the
member, so a user could start a time entry in each of their
organizations. The active time entry endpoint and the database
consistency check expect only one running time entry per user.
2026-10-09 12:58:15 +02:00
Constantin Graf
4a4acb2214 Lock creation of running time entries to prevent race condition
Concurrent requests could both pass the running time entry check and
create more than one running time entry for the same member.
2026-10-09 12:58:06 +02:00
33 changed files with 371 additions and 464 deletions

View File

@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Correction;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
use Illuminate\Console\Command;
class CorrectionDeleteTimeEntriesWithEndBeforeStartCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'correction:delete-time-entries-with-end-before-start '.
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Deletes all time entries where the end is before the start';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Deletes all time entries where the end is before the start...');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
}
$timeEntries = TimeEntry::query()
->whereNotNull('end')
->whereColumn('end', '<', 'start')
->with(['project', 'task'])
->get();
foreach ($timeEntries as $timeEntry) {
/** @var TimeEntry $timeEntry */
if (! $dryRun) {
// Delete via Eloquent, so that the deletion is audited
$timeEntry->delete();
if ($timeEntry->project !== null) {
RecalculateSpentTimeForProject::dispatch($timeEntry->project);
}
if ($timeEntry->task !== null) {
RecalculateSpentTimeForTask::dispatch($timeEntry->task);
}
}
$this->line('Deleted time entry (id='.$timeEntry->getKey().', start='.$timeEntry->start->toIso8601ZuluString().', end='.$timeEntry->end?->toIso8601ZuluString().')');
}
return self::SUCCESS;
}
}

View File

@@ -59,14 +59,6 @@ class SelfHostDatabaseConsistency extends Command
->get(); ->get();
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem); $this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
// End of time entries can not be before the start
$problems = DB::table('time_entries')
->select(['id'])
->whereNotNull('end')
->whereColumn('end', '<', 'start')
->get();
$this->logProblems($problems, 'Time entries have an end that is before the start', $hadAProblem);
// Every user needs to be a member of at least one organization // Every user needs to be a member of at least one organization
$problems = DB::table('users') $problems = DB::table('users')
->select(['users.id as id']) ->select(['users.id as id'])

View File

@@ -0,0 +1,15 @@
<?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,6 +11,7 @@ use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException; use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException; use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
@@ -51,6 +52,7 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -613,7 +615,24 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:create:all'); $this->checkPermission($organization, 'time-entries:create:all');
} }
if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) { // 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()) {
throw new TimeEntryStillRunningApiException; throw new TimeEntryStillRunningApiException;
} }
@@ -650,7 +669,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. * 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 * @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException|TimeEntryMemberCanNotBeChangedWhileRunningApiException
* *
* @operationId updateTimeEntry * @operationId updateTimeEntry
*/ */
@@ -676,6 +695,11 @@ class TimeEntryController extends Controller
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end; $effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry); $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; $oldProject = $timeEntry->project;
$oldTask = $timeEntry->task; $oldTask = $timeEntry->task;
@@ -801,6 +825,12 @@ class TimeEntryController extends Controller
continue; 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; $oldProject = $timeEntry->project;
$oldTask = $timeEntry->task; $oldTask = $timeEntry->task;

View File

@@ -13,11 +13,9 @@ use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Carbon\Exceptions\InvalidFormatException;
use Closure; use Closure;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ConditionalRules; use Illuminate\Validation\ConditionalRules;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -92,32 +90,12 @@ class TimeEntryUpdateRequest extends BaseFormRequest
// Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") // Start of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
'start' => [ 'start' => [
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
function (string $attribute, mixed $value, Closure $fail) use ($timeEntry): void {
// If the payload does not contain an end, the start needs to be validated against the persisted end
if ($this->has('end') || $timeEntry?->end === null) {
return;
}
$start = $this->parseDate($value);
if ($start !== null && $start->gt($timeEntry->end)) {
$fail('The start field must be a date before or equal to end.');
}
},
], ],
// End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z") // End of time entry (Format: "Y-m-d\TH:i:s\Z", UTC timezone, Example: "2000-02-22T14:58:59Z")
'end' => [ 'end' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
'after_or_equal:start', 'after_or_equal:start',
function (string $attribute, mixed $value, Closure $fail) use ($timeEntry): void {
// If the payload does not contain a start, the end needs to be validated against the persisted start
if ($this->has('start') || $timeEntry === null) {
return;
}
$end = $this->parseDate($value);
if ($end !== null && $end->lt($timeEntry->start)) {
$fail('The end field must be a date after or equal to start.');
}
},
], ],
// Whether time entry is billable // Whether time entry is billable
'billable' => [ 'billable' => [
@@ -159,16 +137,4 @@ class TimeEntryUpdateRequest extends BaseFormRequest
], ],
]; ];
} }
private function parseDate(mixed $value): ?Carbon
{
if (! is_string($value)) {
return null;
}
try {
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $value, 'UTC');
} catch (InvalidFormatException) {
return null;
}
}
} }

View File

@@ -190,9 +190,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
if ($end === null) { if ($end === null) {
throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid'); throw new ImportException('End date ("'.$endDateStr.'") or time ("'.$endTimeStr.'") are invalid');
} }
if ($end->lt($start)) {
throw new ImportException('End ("'.$endStr.'") is before start ("'.$startStr.'")');
}
$timeEntry->end = $end->utc(); $timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(

View File

@@ -155,9 +155,6 @@ class GenericTimeEntriesImporter extends DefaultImporter
if ($end === null) { if ($end === null) {
throw new ImportException('Value of end ("'.$record['end'].'") is invalid'); throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
} }
if ($end->lt($start)) {
throw new ImportException('Value of end ("'.$record['end'].'") is before start ("'.$record['start'].'")');
}
$timeEntry->end = $end->utc(); $timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry, $timeEntry,

View File

@@ -136,9 +136,6 @@ class HarvestTimeEntriesImporter extends DefaultImporter
throw new ImportException('Hours ("'.$record['Hours'].'") is invalid'); throw new ImportException('Hours ("'.$record['Hours'].'") is invalid');
} }
$hours = (float) $hoursField; $hours = (float) $hoursField;
if ($hours < 0) {
throw new ImportException('Hours ("'.$record['Hours'].'") is negative');
}
$timeEntry->start = $date->copy()->startOfDay()->utc(); $timeEntry->start = $date->copy()->startOfDay()->utc();
$timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc(); $timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(

View File

@@ -211,6 +211,10 @@ class SolidtimeImporter extends DefaultImporter
// Time entries // Time entries
foreach ($timeEntriesReader as $timeEntryRow) { 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']); $userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']);
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']); $memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);
$member = $this->memberImportHelper->getModelById($memberId); $member = $this->memberImportHelper->getModelById($memberId);
@@ -271,22 +275,15 @@ class SolidtimeImporter extends DefaultImporter
} }
$timeEntry->start = $start->utc(); $timeEntry->start = $start->utc();
if ($timeEntryRow['end'] !== '') { try {
try { $end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC'); } catch (InvalidFormatException) {
} catch (InvalidFormatException) { throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
if ($end === null) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid');
}
if ($end->lt($start)) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is before start date ("'.$timeEntryRow['start'].'")');
}
$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'] !== '') { if ($timeEntryRow['still_active_email_sent_at'] !== '') {
try { try {

View File

@@ -139,9 +139,6 @@ class TogglTimeEntriesImporter extends DefaultImporter
if ($end === null) { if ($end === null) {
throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid'); throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid');
} }
if ($end->lt($start)) {
throw new ImportException('End ("'.$record['End date'].' '.$record['End time'].'") is before start ("'.$record['Start date'].' '.$record['Start time'].'")');
}
$timeEntry->end = $end->utc(); $timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry, $timeEntry,

View File

@@ -159,6 +159,13 @@ class MemberService
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void 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 // Time entries
TimeEntry::query() TimeEntry::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')

View File

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

View File

@@ -0,0 +1,3 @@
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

@@ -0,0 +1,2 @@
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

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

View File

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

View File

@@ -0,0 +1,2 @@
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

@@ -0,0 +1,2 @@
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

@@ -0,0 +1,4 @@
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

@@ -0,0 +1,3 @@
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

@@ -0,0 +1,3 @@
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

@@ -0,0 +1,5 @@
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

@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Console\Commands\Correction;
use App\Console\Commands\Correction\CorrectionDeleteTimeEntriesWithEndBeforeStartCommand;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\Audit;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(CorrectionDeleteTimeEntriesWithEndBeforeStartCommand::class)]
class CorrectionDeleteTimeEntriesWithEndBeforeStartCommandTest extends TestCaseWithDatabase
{
public function test_deletes_time_entries_with_end_before_start_and_audits_the_deletion(): void
{
// Arrange
Queue::fake([
RecalculateSpentTimeForProject::class,
RecalculateSpentTimeForTask::class,
]);
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$project = Project::factory()->forOrganization($organization)->create();
$task = Task::factory()->forOrganization($organization)->forProject($project)->create();
$invalidTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->forProject($project)->forTask($task)->create([
'start' => Carbon::parse('2026-08-01T03:00:00Z'),
'end' => Carbon::parse('2026-08-01T02:59:55Z'),
]);
$validTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->create([
'start' => Carbon::parse('2026-08-01T04:00:00Z'),
'end' => Carbon::parse('2026-08-01T04:00:00Z'),
]);
$runningTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->active()->create();
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('correction:delete-time-entries-with-end-before-start');
// Assert
$this->assertSame(Command::SUCCESS, $exitCode);
$output = Artisan::output();
$this->assertSame("Deletes all time entries where the end is before the start...\n".
'Deleted time entry (id='.$invalidTimeEntry->getKey().", start=2026-08-01T03:00:00Z, end=2026-08-01T02:59:55Z)\n", $output);
$this->assertDatabaseMissing(TimeEntry::class, ['id' => $invalidTimeEntry->getKey()]);
$this->assertDatabaseHas(TimeEntry::class, ['id' => $validTimeEntry->getKey()]);
$this->assertDatabaseHas(TimeEntry::class, ['id' => $runningTimeEntry->getKey()]);
$this->assertSame(1, Audit::query()
->where('auditable_type', $invalidTimeEntry->getMorphClass())
->where('auditable_id', $invalidTimeEntry->getKey())
->where('event', 'deleted')
->count());
Queue::assertPushed(RecalculateSpentTimeForProject::class, 1);
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
}
public function test_does_not_delete_time_entries_in_dry_run(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
$invalidTimeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->create([
'start' => Carbon::parse('2026-08-01T03:00:00Z'),
'end' => Carbon::parse('2026-08-01T02:59:55Z'),
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('correction:delete-time-entries-with-end-before-start --dry-run');
// Assert
$this->assertSame(Command::SUCCESS, $exitCode);
$output = Artisan::output();
$this->assertSame("Deletes all time entries where the end is before the start...\n".
"Running in dry-run mode. Nothing will be saved to the database.\n".
'Deleted time entry (id='.$invalidTimeEntry->getKey().", start=2026-08-01T03:00:00Z, end=2026-08-01T02:59:55Z)\n", $output);
$this->assertDatabaseHas(TimeEntry::class, ['id' => $invalidTimeEntry->getKey()]);
}
}

View File

@@ -13,7 +13,6 @@ use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase; use Tests\TestCaseWithDatabase;
@@ -159,26 +158,4 @@ class SelfHostDatabaseConsistencyCommandTest extends TestCaseWithDatabase
$output = Artisan::output(); $output = Artisan::output();
$this->assertSame("Consistency problem: Users have a current organization that they are not a member of\n - ".$user1->user->getKey()."\n", $output); $this->assertSame("Consistency problem: Users have a current organization that they are not a member of\n - ".$user1->user->getKey()."\n", $output);
} }
public function test_checks_that_end_of_time_entries_is_not_before_start(): void
{
// Arrange
$user = $this->createUserWithRole(Role::Owner);
$timeEntry = TimeEntry::factory()->forMember($user->member)->create([
'start' => Carbon::parse('2026-08-01T03:00:00Z'),
'end' => Carbon::parse('2026-08-01T02:59:55Z'),
]);
TimeEntry::factory()->forMember($user->member)->create([
'start' => Carbon::parse('2026-08-01T04:00:00Z'),
'end' => Carbon::parse('2026-08-01T04:00:00Z'),
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('self-host:database-consistency');
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertSame("Consistency problem: Time entries have an end that is before the start\n - ".$timeEntry->getKey()."\n", $output);
}
} }

View File

@@ -390,6 +390,40 @@ 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 public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member_with_existing_destination_resources(): void
{ {
// Arrange // Arrange

View File

@@ -24,7 +24,9 @@ use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use App\Service\TimeEntryFilter; use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -2444,6 +2446,77 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('error', true); $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 public function test_store_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void
{ {
// Arrange // Arrange
@@ -2961,101 +3034,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_update_endpoint_validation_fails_if_only_end_is_sent_and_it_is_before_the_persisted_start(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([
'start' => Carbon::parse('2026-08-01T03:00:00Z'),
'end' => Carbon::parse('2026-08-01T03:01:00Z'),
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
'end' => '2026-08-01T02:59:55Z',
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'end' => 'The end field must be a date after or equal to start.',
]);
$timeEntry->refresh();
$this->assertSame('2026-08-01T03:01:00Z', $timeEntry->end->toIso8601ZuluString());
}
public function test_update_endpoint_validation_fails_if_only_start_is_sent_and_it_is_after_the_persisted_end(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([
'start' => Carbon::parse('2026-08-01T03:00:00Z'),
'end' => Carbon::parse('2026-08-01T03:01:00Z'),
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
'start' => '2026-08-01T03:01:05Z',
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'start' => 'The start field must be a date before or equal to end.',
]);
$timeEntry->refresh();
$this->assertSame('2026-08-01T03:00:00Z', $timeEntry->start->toIso8601ZuluString());
}
public function test_update_endpoint_allows_updating_only_start_of_running_time_entry(): 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()]), [
'start' => '2026-08-01T03:00:00Z',
]);
// Assert
$response->assertStatus(200);
$timeEntry->refresh();
$this->assertSame('2026-08-01T03:00:00Z', $timeEntry->start->toIso8601ZuluString());
}
public function test_update_endpoint_allows_updating_only_end_if_it_is_after_the_persisted_start(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([
'start' => Carbon::parse('2026-08-01T03:00:00Z'),
'end' => Carbon::parse('2026-08-01T03:01:00Z'),
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
'end' => '2026-08-01T03:00:00Z',
]);
// Assert
$response->assertStatus(200);
$timeEntry->refresh();
$this->assertSame('2026-08-01T03:00:00Z', $timeEntry->end->toIso8601ZuluString());
}
public function test_update_endpoint_validation_fails_if_project_id_is_missing_but_request_has_task_id(): void public function test_update_endpoint_validation_fails_if_project_id_is_missing_but_request_has_task_id(): void
{ {
// Arrange // Arrange
@@ -3259,6 +3237,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 public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
{ {
// Arrange // Arrange
@@ -4126,6 +4178,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 public function test_update_multiple_updates_user_id_when_member_id_changes(): void
{ {
// Arrange // Arrange

View File

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

View File

@@ -231,29 +231,4 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, Tag::query()->count()); $this->assertSame(0, Tag::query()->count());
$this->assertSame(0, Client::query()->count()); $this->assertSame(0, Client::query()->count());
} }
public function test_import_fails_if_end_is_before_start(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
$data = <<<'CSV'
"Project","Client","Description","Task","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
"Project","Client","Working hard","","Peter Tester","","peter.test@email.test","","Yes","03/04/2024","10:30:00 AM","03/04/2024","10:00:00 AM","00:30:00","0.50","0.00","0.00"
CSV;
// Act
try {
$importer->importData($data, $timezone);
} catch (ImportException $e) {
// Assert
$this->assertSame('End ("03/04/2024 10:00:00 AM") is before start ("03/04/2024 10:30:00 AM")', $e->getMessage());
$this->assertSame(0, TimeEntry::query()->count());
return;
}
$this->fail();
}
} }

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers; namespace Tests\Unit\Service\Import\Importers;
use App\Models\Organization; use App\Models\Organization;
use App\Models\TimeEntry;
use App\Service\Import\Importers\DefaultImporter; use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\GenericTimeEntriesImporter; use App\Service\Import\Importers\GenericTimeEntriesImporter;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
@@ -90,27 +89,4 @@ class GenericTimeEntriesImporterTest extends ImporterTestAbstract
} }
$this->fail(); $this->fail();
} }
public function test_import_fails_if_end_is_before_start(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new GenericTimeEntriesImporter;
$importer->init($organization);
$data = "description,billable,client,project,tags,start,end,task,user_name,user_email\n".
'"Working hard","true","Big Company","Project for Big Company","","2024-03-04T10:23:00Z","2024-03-04T09:23:00Z","","Peter Tester","peter.test@email.test"';
// Act
try {
$importer->importData($data, $timezone);
} catch (ImportException $e) {
// Assert
$this->assertSame('Value of end ("2024-03-04T09:23:00Z") is before start ("2024-03-04T10:23:00Z")', $e->getMessage());
$this->assertSame(0, TimeEntry::query()->count());
return;
}
$this->fail();
}
} }

View File

@@ -105,27 +105,4 @@ class HarvestTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(2, $report->projectsCreated); $this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated); $this->assertSame(1, $report->clientsCreated);
} }
public function test_import_fails_if_hours_are_negative(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new HarvestTimeEntriesImporter;
$importer->init($organization);
$data = "Date,Client,Project,Project Code,Task,Notes,Hours,Billable?,Invoiced?,Approved?,First Name,Last Name,Roles,Employee?,Billable Rate,Billable Amount,Cost Rate,Cost Amount,Currency,External Reference URL\n".
'2024-03-04,,Project without Client,,,"","-2,0",No,No,No,Peter,Tester,,Yes,"100,0","2.000,0","0,0","0,0",Euro - EUR,';
// Act
try {
$importer->importData($data, $timezone);
} catch (ImportException $e) {
// Assert
$this->assertSame('Hours ("-2,0") is negative', $e->getMessage());
$this->assertSame(0, TimeEntry::query()->count());
return;
}
$this->fail();
}
} }

View File

@@ -15,9 +15,7 @@ use App\Service\Import\Importers\SolidtimeImporter;
use Exception; use Exception;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use ZipArchive;
#[CoversClass(SolidtimeImporter::class)] #[CoversClass(SolidtimeImporter::class)]
#[CoversClass(ImportException::class)] #[CoversClass(ImportException::class)]
@@ -142,6 +140,31 @@ class SolidtimeImporterTest extends ImporterTestAbstract
$this->assertSame([], $breakEntry->tags); $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 public function test_import_of_test_file_twice_succeeds(): void
{ {
// Arrange // Arrange
@@ -179,32 +202,4 @@ class SolidtimeImporterTest extends ImporterTestAbstract
Queue::assertPushed(RecalculateSpentTimeForProject::class, 1); Queue::assertPushed(RecalculateSpentTimeForProject::class, 1);
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1); Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
} }
public function test_import_fails_if_end_of_time_entry_is_before_start(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$zipPath = $this->createTestZip('solidtime_import_test_1');
$zip = new ZipArchive;
$zip->open($zipPath);
$timeEntries = $zip->getFromName('time_entries.csv');
$timeEntries = Str::replaceFirst(',2024-03-04T09:23:52Z,2024-03-04T09:23:52Z,', ',2024-03-04T09:23:52Z,2024-03-04T08:23:52Z,', $timeEntries);
$zip->addFromString('time_entries.csv', $timeEntries);
$zip->close();
$importer = new SolidtimeImporter;
$importer->init($organization);
$data = file_get_contents($zipPath);
// Act
try {
$importer->importData($data, $timezone);
} catch (ImportException $e) {
// Assert
$this->assertSame('End date ("2024-03-04T08:23:52Z") is before start date ("2024-03-04T09:23:52Z")', $e->getMessage());
return;
}
$this->fail();
}
} }

View File

@@ -120,27 +120,4 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
Queue::assertPushed(RecalculateSpentTimeForProject::class, 2); Queue::assertPushed(RecalculateSpentTimeForProject::class, 2);
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1); Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
} }
public function test_import_fails_if_end_is_before_start(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new TogglTimeEntriesImporter;
$importer->init($organization);
$data = "User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Duration,Tags,Amount (EUR)\n".
'Peter Tester,peter.test@email.test,,Project without Client,,"",No,2024-03-04,10:23:52,2024-03-04,09:23:52,-01:00:00,"",';
// Act
try {
$importer->importData($data, $timezone);
} catch (ImportException $e) {
// Assert
$this->assertSame('End ("2024-03-04 09:23:52") is before start ("2024-03-04 10:23:52")', $e->getMessage());
$this->assertSame(0, TimeEntry::query()->count());
return;
}
$this->fail();
}
} }

View File

@@ -228,6 +228,30 @@ class MemberServiceTest extends TestCaseWithDatabase
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count()); $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 public function test_assign_organization_entities_to_different_member_moves_all_goals_of_the_member(): void
{ {
// Arrange // Arrange