Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
23d64b6d7a Add core support for extendable authentication
add support for passwordless user creation; add filament loading support
for new laravel modules namespacing;
add support for pluggable password reset and login rules
2026-10-13 17:28:53 +02:00
57 changed files with 381 additions and 998 deletions

View File

@@ -5,8 +5,12 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\User; use App\Models\User;
use App\Providers\FortifyServiceProvider;
use Illuminate\Auth\Passwords\PasswordBroker;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords; use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords class ResetUserPassword implements ResetsUserPasswords
@@ -20,6 +24,16 @@ class ResetUserPassword implements ResetsUserPasswords
*/ */
public function reset(User $user, array $input): void public function reset(User $user, array $input): void
{ {
if (! FortifyServiceProvider::canResetPassword($user, $input)) {
/** @var PasswordBroker $broker */
$broker = Password::broker(config('fortify.passwords'));
$broker->deleteToken($user);
throw ValidationException::withMessages([
'email' => [__('This password reset link is invalid.')],
]);
}
Validator::make($input, [ Validator::make($input, [
'password' => $this->passwordRules(), 'password' => $this->passwordRules(),
])->validate(); ])->validate();

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

@@ -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

@@ -183,7 +183,7 @@ class OrganizationResource extends Resource
->action(function (Organization $record, array $data): void { ->action(function (Organization $record, array $data): void {
try { try {
try { try {
$file = Storage::disk(config('filesystems.private'))->get($data['file']); $file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
} catch (UnableToReadFile) { } catch (UnableToReadFile) {
$file = null; $file = null;
} }
@@ -220,17 +220,12 @@ class OrganizationResource extends Resource
->body('Message: '.$exception->getMessage()) ->body('Message: '.$exception->getMessage())
->persistent() ->persistent()
->send(); ->send();
} finally {
Storage::disk(config('filesystems.private'))->delete($data['file']);
} }
}) })
->tooltip(fn (Organization $record): string => 'Import into '.$record->name) ->tooltip(fn (Organization $record): string => 'Import into '.$record->name)
->schema([ ->schema([
FileUpload::make('file') FileUpload::make('file')
->label('File') ->label('File')
->disk(config('filesystems.private'))
->directory('admin-import-uploads')
->visibility('private')
->required(), ->required(),
Select::make('type') Select::make('type')
->required() ->required()

View File

@@ -11,7 +11,6 @@ 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;
@@ -52,7 +51,6 @@ 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;
@@ -615,24 +613,7 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:create:all'); $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 if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) {
$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;
} }
@@ -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. * 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 * @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; $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;
@@ -825,12 +801,6 @@ 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

@@ -160,13 +160,23 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF'; return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
} }
public function canAccessPanel(Panel $panel): bool public function isSuperAdmin(): bool
{ {
return $this->is_placeholder === false return $this->is_placeholder === false
&& in_array($this->email, config('auth.super_admins', []), true) && in_array($this->email, config('auth.super_admins', []), true)
&& $this->hasVerifiedEmail(); && $this->hasVerifiedEmail();
} }
public function hasLocalPassword(): bool
{
return is_string($this->password) && $this->password !== '';
}
public function canAccessPanel(Panel $panel): bool
{
return $this->isSuperAdmin();
}
public function isMemberOfOrganization(Organization $organization): bool public function isMemberOfOrganization(Organization $organization): bool
{ {
if ($this->relationLoaded('organizations')) { if ($this->relationLoaded('organizations')) {

View File

@@ -26,6 +26,7 @@ use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\View\Middleware\ShareErrorsFromSession; use Illuminate\View\Middleware\ShareErrorsFromSession;
use Nwidart\Modules\Facades\Module; use Nwidart\Modules\Facades\Module;
use Nwidart\Modules\Laravel\Module as LaravelModule;
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin; use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
class AdminPanelProvider extends PanelProvider class AdminPanelProvider extends PanelProvider
@@ -93,22 +94,77 @@ class AdminPanelProvider extends PanelProvider
$modules = Module::allEnabled(); $modules = Module::allEnabled();
foreach ($modules as $module) { foreach ($modules as $module) {
$moduleNamespace = $this->getModuleAppNamespace($module);
$panel->discoverResources( $panel->discoverResources(
in: module_path($module->getName(), 'app/Filament/Resources'), in: module_path($module->getName(), 'app/Filament/Resources'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources' for: $moduleNamespace.'\\Filament\\Resources'
); );
$panel->discoverPages( $panel->discoverPages(
in: module_path($module->getName(), 'app/Filament/Pages'), in: module_path($module->getName(), 'app/Filament/Pages'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages' for: $moduleNamespace.'\\Filament\\Pages'
); );
$panel->discoverWidgets( $panel->discoverWidgets(
in: module_path($module->getName(), 'app/Filament/Widgets'), in: module_path($module->getName(), 'app/Filament/Widgets'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets' for: $moduleNamespace.'\\Filament\\Widgets'
); );
} }
return $panel; return $panel;
} }
/** @var array<string, string> Cache of module name => resolved app namespace. */
private static array $moduleAppNamespaces = [];
private function getModuleAppNamespace(LaravelModule $module): string
{
return self::$moduleAppNamespaces[$module->getName()] ??= $this->resolveModuleAppNamespace($module);
}
/**
* Resolve the PHP namespace mapped to a module's app/ directory so the
* Filament panel can discover its Resources/Pages/Widgets under the right
* namespace.
*
* Two module layouts currently coexist in this repo:
* - laravel-modules v12 (app_folder enabled): a bare namespace maps to
* app/ — e.g. "Extensions\SSO\" => app/, so classes are
* Extensions\SSO\Filament\... (this is the current convention).
* - the older layout: an "...\App" namespace maps to app/ — e.g.
* "Extensions\Billing\App\" => app/, so classes are
* Extensions\Billing\App\Filament\...
*
* The package's own namespace derivation assumes the v12 (bare) layout and
* would mis-resolve the legacy modules, so we read each module's composer
* PSR-4 map and use whichever namespace actually points at app/. The legacy
* "...\App" shape is only a fallback for when composer is missing/unreadable.
* Once every module adopts the bare layout this collapses to
* config('modules.namespace').'\\'.$module->getName().
*/
private function resolveModuleAppNamespace(LaravelModule $module): string
{
$fallback = 'Extensions\\'.$module->getName().'\\App';
$composerPath = module_path($module->getName(), 'composer.json');
$psr4 = [];
if (is_file($composerPath)) {
$composer = json_decode((string) file_get_contents($composerPath), true);
$psr4 = is_array($composer) ? ($composer['autoload']['psr-4'] ?? []) : [];
}
foreach ((array) $psr4 as $namespace => $path) {
if (is_string($namespace) && $this->normalizeComposerPath($path) === 'app') {
return rtrim($namespace, '\\');
}
}
return $fallback;
}
private function normalizeComposerPath(mixed $path): string
{
return trim(str_replace('\\', '/', (string) $path), '/');
}
} }

View File

@@ -25,6 +25,73 @@ use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider class FortifyServiceProvider extends ServiceProvider
{ {
/**
* Dummy bcrypt hash compared against when no user matches the submitted
* email. Hash::check is run against it so login takes the same time whether
* or not the email exists — otherwise an unknown email would skip the
* (deliberately slow) hash and return faster, letting an attacker enumerate
* registered accounts by timing the response. The plaintext is irrelevant:
* it is only ever checked against attacker-supplied input and never matches.
*/
private const ABSENT_USER_PASSWORD_HASH = '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi';
/**
* Authorization rules applied AFTER the password is verified. Each rule
* receives the authenticated user + request and returns whether the login
* may proceed; any rule returning false denies it. This is an extension
* point: modules (e.g. SSO enforcement) add a rule to veto a password login
* instead of replacing this credential check — which would silently drift
* from the host logic the next time it changes.
*
* @var array<int, \Closure(User, Request): bool>
*/
protected static array $loginRules = [];
/**
* Authorization rules applied before a password reset is completed. Rules
* receive the user being reset + submitted input and return whether the
* local reset flow may set a new password for that account.
*
* @var array<int, \Closure(User, array<string, mixed>): bool>
*/
protected static array $passwordResetRules = [];
/**
* Register an additional rule that gates password login (see $loginRules).
*
* @param \Closure(User, Request): bool $rule
*/
public static function authenticateUsingRule(\Closure $rule): void
{
static::$loginRules[] = $rule;
}
/**
* Register an additional rule that gates password reset completion.
*
* @param \Closure(User, array<string, mixed>): bool $rule
*/
public static function resetPasswordUsingRule(\Closure $rule): void
{
static::$passwordResetRules[] = $rule;
}
/**
* Check whether the given user may complete the local password reset flow.
*
* @param array<string, mixed> $input
*/
public static function canResetPassword(User $user, array $input = []): bool
{
foreach (static::$passwordResetRules as $rule) {
if (! $rule($user, $input)) {
return false;
}
}
return true;
}
/** /**
* Register any application services. * Register any application services.
*/ */
@@ -93,7 +160,23 @@ class FortifyServiceProvider extends ServiceProvider
->where('is_placeholder', '=', false) ->where('is_placeholder', '=', false)
->first(); ->first();
if ($user !== null && Hash::check($request->password, $user->password)) { // Always run the hash check — against the real hash, or a dummy when
// there is no user — so login timing is identical either way (see
// ABSENT_USER_PASSWORD_HASH). Passwordless accounts (SSO-only users
// have password = null) fail here, so they cannot password-login.
$existingPasswordHash = $user->password ?? self::ABSENT_USER_PASSWORD_HASH;
$passwordIsValid = Hash::check((string) $request->password, $existingPasswordHash);
if ($user !== null && $passwordIsValid) {
// Credentials are valid; now apply any registered authorization
// rules (e.g. SSO enforcement may still block password login).
foreach (static::$loginRules as $rule) {
if (! $rule($user, $request)) {
return null;
}
}
return $user; return $user;
} }

View File

@@ -9,11 +9,8 @@ use App\Service\Import\Importers\ImporterContract;
use App\Service\Import\Importers\ImporterProvider; use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto; use App\Service\Import\Importers\ReportDto;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImportService class ImportService
{ {
@@ -25,8 +22,6 @@ class ImportService
/** @var ImporterContract $importer */ /** @var ImporterContract $importer */
$importer = app(ImporterProvider::class)->getImporter($importerType); $importer = app(ImporterProvider::class)->getImporter($importerType);
$importer->init($organization); $importer->init($organization);
Storage::disk(config('filesystems.private'))
->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1); $lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);

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,34 +211,18 @@ 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']);
if ($userId === null) {
throw new ImportException('Time entry references a user that does not exist in the import');
}
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']); $memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']);
if ($memberId === null) {
throw new ImportException('Time entry references a member that does not exist in the import');
}
$member = $this->memberImportHelper->getModelById($memberId); $member = $this->memberImportHelper->getModelById($memberId);
$clientId = null; $clientId = null;
if ($timeEntryRow['client_id'] !== '') { if ($timeEntryRow['client_id'] !== '') {
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier($timeEntryRow['client_id']); $clientId = $this->clientImportHelper->getKeyByExternalIdentifier($timeEntryRow['client_id']);
if ($clientId === null) {
throw new ImportException('Time entry references a client that does not exist in the import');
}
} }
$project = null; $project = null;
$projectId = null; $projectId = null;
$projectMember = null; $projectMember = null;
if ($timeEntryRow['project_id'] !== '') { if ($timeEntryRow['project_id'] !== '') {
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier($timeEntryRow['project_id']); $projectId = $this->projectImportHelper->getKeyByExternalIdentifier($timeEntryRow['project_id']);
if ($projectId === null) {
throw new ImportException('Time entry references a project that does not exist in the import');
}
$project = $this->projectImportHelper->getModelById($projectId); $project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([ $projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId, 'project_id' => $projectId,
@@ -248,9 +232,6 @@ class SolidtimeImporter extends DefaultImporter
$taskId = null; $taskId = null;
if ($timeEntryRow['task_id'] !== '') { if ($timeEntryRow['task_id'] !== '') {
$taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']); $taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']);
if ($taskId === null) {
throw new ImportException('Time entry references a task that does not exist in the import');
}
$this->taskImportHelper->getModelById($taskId); $this->taskImportHelper->getModelById($taskId);
} }
$timeEntry = new TimeEntry; $timeEntry = new TimeEntry;
@@ -290,18 +271,19 @@ class SolidtimeImporter extends DefaultImporter
} }
$timeEntry->start = $start->utc(); $timeEntry->start = $start->utc();
try { if ($timeEntryRow['end'] !== '') {
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC'); try {
} catch (InvalidFormatException) { $end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC');
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid'); } 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');
}
if ($end->lt($start)) {
throw new ImportException('End date ("'.$timeEntryRow['end'].'") is before start date ("'.$timeEntryRow['start'].'")');
}
$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,13 +159,6 @@ 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

@@ -49,6 +49,56 @@ class UserService
} }
$user->save(); $user->save();
$this->createDefaultOrganizationForUser(
$user,
$currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat,
);
return $user;
}
/**
* Create a user without a password (e.g. provisioned via SSO). Such users
* can only authenticate through a linked identity provider.
*/
public function createPasswordlessUser(
string $name,
string $email,
string $timezone,
Weekday $weekStart,
?string $currency,
bool $verifyEmail = false
): User {
$user = new User;
$user->name = $name;
$user->email = strtolower($email);
$user->password = null;
$user->timezone = $timezone;
$user->week_start = $weekStart;
if ($verifyEmail) {
$user->email_verified_at = Carbon::now();
}
$user->save();
$this->createDefaultOrganizationForUser($user, $currency);
return $user;
}
private function createDefaultOrganizationForUser(
User $user,
?string $currency,
?NumberFormat $numberFormat = null,
?CurrencyFormat $currencyFormat = null,
?DateFormat $dateFormat = null,
?IntervalFormat $intervalFormat = null,
?TimeFormat $timeFormat = null,
): void {
$organizations = app(InvitationService::class)->processAcceptedInvitations($user); $organizations = app(InvitationService::class)->processAcceptedInvitations($user);
if ($organizations->isEmpty()) { if ($organizations->isEmpty()) {
@@ -65,8 +115,6 @@ class UserService
); );
$this->switchCurrentOrganization($user, $organization); $this->switchCurrentOrganization($user, $organization);
} }
return $user;
} }
/** /**

View File

@@ -27,7 +27,7 @@ return [
| |
*/ */
'zip_max_files' => (int) (env('IMPORT_ZIP_MAX_FILES') ?: 10000), 'zip_max_files' => (int) (env('IMPORT_ZIP_MAX_FILES') ?: 100),
'zip_max_uncompressed_size' => (int) (env('IMPORT_ZIP_MAX_UNCOMPRESSED_SIZE') ?: 500 * 1024 * 1024), 'zip_max_uncompressed_size' => (int) (env('IMPORT_ZIP_MAX_UNCOMPRESSED_SIZE') ?: 500 * 1024 * 1024),

View File

@@ -19,7 +19,6 @@ 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;
@@ -32,7 +31,6 @@ 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

@@ -7,10 +7,16 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue'; import TextInput from '@/packages/ui/src/Input/TextInput.vue';
defineProps({ withDefaults(
canResetPassword: Boolean, defineProps<{
status: String, canResetPassword?: boolean;
}); status?: string;
}>(),
{
canResetPassword: false,
status: '',
}
);
const form = useForm({ const form = useForm({
email: '', email: '',
@@ -28,8 +34,8 @@ const submit = () => {
}; };
const page = usePage<{ const page = usePage<{
flash: { flash?: {
message: string; message?: string;
}; };
}>(); }>();
</script> </script>
@@ -61,6 +67,9 @@ const page = usePage<{
{{ page.props.flash?.message }} {{ page.props.flash?.message }}
</div> </div>
<!-- Extension seam: alternative-auth errors (e.g. SSO callback failures) -->
<slot name="error" />
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<Field> <Field>
<FieldLabel for="email">Email</FieldLabel> <FieldLabel for="email">Email</FieldLabel>
@@ -103,5 +112,8 @@ const page = usePage<{
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>
<!-- Extension seam: alternative auth methods (e.g. SSO providers) -->
<slot name="alternatives" />
</AuthenticationCard> </AuthenticationCard>
</template> </template>

View File

@@ -10,35 +10,73 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
import { type DefineComponent } from 'vue'; import { type DefineComponent } from 'vue';
import { setupPrefetching } from '@/utils/prefetch'; import { setupPrefetching } from '@/utils/prefetch';
interface ExtensionManifest {
name?: string;
alias?: string;
}
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const pinia = createPinia(); const pinia = createPinia();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const extensionManifests = import.meta.glob('../../extensions/**/module.json', {
eager: true,
import: 'default',
}) as Record<string, ExtensionManifest>;
// BillingPortal is a Vue 2 component and must not be bundled into the Vue 3 app.
const extensionPages = import.meta.glob<DefineComponent>([
'../../extensions/**/resources/js/Pages/**/*.vue',
'!**/BillingPortal.vue',
]);
const extensionDirectories = Object.entries(extensionManifests).reduce<Record<string, string>>(
(directories, [path, manifest]) => {
const match = path.match(/^\.\.\/\.\.\/extensions\/([^/]+)\/module\.json$/);
const extensionDirectory = match?.[1];
if (extensionDirectory === undefined) {
return directories;
}
for (const key of [manifest.name, manifest.alias, extensionDirectory]) {
if (typeof key !== 'string' || key === '') {
continue;
}
directories[key] = extensionDirectory;
directories[key.toLowerCase()] = extensionDirectory;
}
return directories;
},
{}
);
function resolveExtensionDirectory(moduleName: string): string {
return (
extensionDirectories[moduleName] ??
extensionDirectories[moduleName.toLowerCase()] ??
moduleName
);
}
createInertiaApp({ createInertiaApp({
title: (title) => `${title} - ${appName}`, title: (title) => `${title} - ${appName}`,
resolve: (name) => { resolve: (name) => {
if (name.includes('Invoicing::')) { // "Module::Page" (both halves present) resolves to that extension's page
const [module, page] = name.split('::'); // directory; everything else is a host page under resources/js/Pages.
const [module, ...pageSegments] = name.split('::');
const page = pageSegments.join('::');
const pagePath = module if (module && page) {
? `../../extensions/${module}/resources/js/Pages/${page}.vue` const extensionDirectory = resolveExtensionDirectory(module);
: `./Pages/${page}.vue`; const pagePath = `../../extensions/${extensionDirectory}/resources/js/Pages/${page}.vue`;
// BillingPortal is a Vue 2 Component and therefore should not be imported return resolvePageComponent(pagePath, extensionPages);
const pages = module
? import.meta.glob<DefineComponent>([
'../../extensions/**/resources/js/Pages/*.vue',
'!**/BillingPortal.vue',
])
: import.meta.glob<DefineComponent>('./Pages/**/*.vue');
return resolvePageComponent(pagePath, pages);
} else {
return resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
);
} }
return resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
);
}, },
setup({ el, App, props, plugin }) { setup({ el, App, props, plugin }) {
const app = createApp({ render: () => h(App, props) }); const app = createApp({ render: () => h(App, props) });

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

@@ -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,3 +0,0 @@
id,description,start,end,billable_rate,billable,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,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,9f0c1b2a-0000-4000-8000-000000000000,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 id description start end billable_rate billable 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 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 9f0c1b2a-0000-4000-8000-000000000000 [] 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,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 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,9 +24,7 @@ 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;
@@ -2446,77 +2444,6 @@ 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
@@ -3034,101 +2961,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
@@ -3332,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 public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
{ {
// Arrange // Arrange
@@ -4273,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 public function test_update_multiple_updates_user_id_when_member_id_changes(): void
{ {
// Arrange // Arrange

View File

@@ -22,7 +22,6 @@ 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;
@@ -54,7 +53,6 @@ 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

@@ -21,8 +21,6 @@ use App\Service\Import\Importers\ReportDto;
use App\Service\Import\ImportService; use App\Service\Import\ImportService;
use Exception; use Exception;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
@@ -171,7 +169,8 @@ class OrganizationResourceTest extends FilamentTestCase
public function test_can_import_into_an_organization(): void public function test_can_import_into_an_organization(): void
{ {
// Arrange // Arrange
Storage::fake(config('filesystems.private')); Storage::fake(config('filament.default_filesystem_disk'));
Storage::disk(config('filament.default_filesystem_disk'))->put('imports/import.csv', 'csv-content');
$organization = Organization::factory()->create(); $organization = Organization::factory()->create();
$this->mock(ImportService::class, function (MockInterface $mock) use ($organization): void { $this->mock(ImportService::class, function (MockInterface $mock) use ($organization): void {
$mock->shouldReceive('import') $mock->shouldReceive('import')
@@ -195,7 +194,7 @@ class OrganizationResourceTest extends FilamentTestCase
// Act // Act
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class) $response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
->callTableAction('Import', $organization, data: [ ->callTableAction('Import', $organization, data: [
'file' => UploadedFile::fake()->createWithContent('import.csv', 'csv-content'), 'file' => [(string) Str::uuid() => 'imports/import.csv'],
'type' => 'toggl_time_entries', 'type' => 'toggl_time_entries',
'timezone' => 'Europe/Vienna', 'timezone' => 'Europe/Vienna',
]); ]);
@@ -217,41 +216,13 @@ class OrganizationResourceTest extends FilamentTestCase
) )
->persistent() ->persistent()
); );
Storage::disk(config('filesystems.private'))->assertDirectoryEmpty('admin-import-uploads');
}
public function test_import_into_an_organization_stores_copy_of_import_data_for_debugging_and_deletes_upload(): void
{
// Arrange
Storage::fake(config('filesystems.private'));
$data = Storage::disk('testfiles')->get('toggl_time_entries_import_test_1.csv');
$organization = Organization::factory()->create();
// Act
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
->callTableAction('Import', $organization, data: [
'file' => UploadedFile::fake()->createWithContent('import.csv', $data),
'type' => 'toggl_time_entries',
'timezone' => 'Europe/Vienna',
]);
// Assert
$response->assertHasNoTableActionErrors();
$response->assertSuccessful();
Storage::disk(config('filesystems.private'))->assertDirectoryEmpty('admin-import-uploads');
$storedFiles = Storage::disk(config('filesystems.private'))->files('import');
$this->assertCount(1, $storedFiles);
$this->assertMatchesRegularExpression(
'/^import\/'.preg_quote(Carbon::now()->toDateString(), '/').'-'.preg_quote($organization->getKey(), '/').'-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/',
$storedFiles[0]
);
$this->assertSame($data, Storage::disk(config('filesystems.private'))->get($storedFiles[0]));
} }
public function test_import_into_organization_shows_error_notification_on_import_exception(): void public function test_import_into_organization_shows_error_notification_on_import_exception(): void
{ {
// Arrange // Arrange
Storage::fake(config('filesystems.private')); Storage::fake(config('filament.default_filesystem_disk'));
Storage::disk(config('filament.default_filesystem_disk'))->put('imports/import.csv', 'csv-content');
$organization = Organization::factory()->create(); $organization = Organization::factory()->create();
$this->mock(ImportService::class, function (MockInterface $mock): void { $this->mock(ImportService::class, function (MockInterface $mock): void {
$mock->shouldReceive('import') $mock->shouldReceive('import')
@@ -262,7 +233,7 @@ class OrganizationResourceTest extends FilamentTestCase
// Act // Act
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class) $response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
->callTableAction('Import', $organization, data: [ ->callTableAction('Import', $organization, data: [
'file' => UploadedFile::fake()->createWithContent('import.csv', 'csv-content'), 'file' => [(string) Str::uuid() => 'imports/import.csv'],
'type' => 'toggl_time_entries', 'type' => 'toggl_time_entries',
'timezone' => 'Europe/Vienna', 'timezone' => 'Europe/Vienna',
]); ]);
@@ -276,13 +247,12 @@ class OrganizationResourceTest extends FilamentTestCase
->body('Message: Invalid CSV') ->body('Message: Invalid CSV')
->persistent() ->persistent()
); );
Storage::disk(config('filesystems.private'))->assertDirectoryEmpty('admin-import-uploads');
} }
public function test_import_into_organization_shows_error_notification_if_file_is_missing(): void public function test_import_into_organization_shows_error_notification_if_file_is_missing(): void
{ {
// Arrange // Arrange
Storage::fake(config('filesystems.private')); Storage::fake(config('filament.default_filesystem_disk'));
$organization = Organization::factory()->create(); $organization = Organization::factory()->create();
$this->mock(ImportService::class, function (MockInterface $mock): void { $this->mock(ImportService::class, function (MockInterface $mock): void {
$mock->shouldNotReceive('import'); $mock->shouldNotReceive('import');
@@ -291,7 +261,7 @@ class OrganizationResourceTest extends FilamentTestCase
// Act // Act
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class) $response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
->callTableAction('Import', $organization, data: [ ->callTableAction('Import', $organization, data: [
'file' => [(string) Str::uuid() => 'admin-import-uploads/does-not-exist.csv'], 'file' => [(string) Str::uuid() => 'imports/does-not-exist.csv'],
'type' => 'toggl_time_entries', 'type' => 'toggl_time_entries',
'timezone' => 'Europe/Vienna', 'timezone' => 'Europe/Vienna',
]); ]);
@@ -310,7 +280,7 @@ class OrganizationResourceTest extends FilamentTestCase
public function test_import_into_organization_shows_error_notification_if_file_is_missing_on_non_throwing_disk(): void public function test_import_into_organization_shows_error_notification_if_file_is_missing_on_non_throwing_disk(): void
{ {
// Arrange // Arrange
Storage::fake(config('filesystems.private'), ['throw' => false]); Storage::fake(config('filament.default_filesystem_disk'), ['throw' => false]);
$organization = Organization::factory()->create(); $organization = Organization::factory()->create();
$this->mock(ImportService::class, function (MockInterface $mock): void { $this->mock(ImportService::class, function (MockInterface $mock): void {
$mock->shouldNotReceive('import'); $mock->shouldNotReceive('import');
@@ -319,7 +289,7 @@ class OrganizationResourceTest extends FilamentTestCase
// Act // Act
$response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class) $response = Livewire::test(OrganizationResource\Pages\ListOrganizations::class)
->callTableAction('Import', $organization, data: [ ->callTableAction('Import', $organization, data: [
'file' => [(string) Str::uuid() => 'admin-import-uploads/does-not-exist.csv'], 'file' => [(string) Str::uuid() => 'imports/does-not-exist.csv'],
'type' => 'toggl_time_entries', 'type' => 'toggl_time_entries',
'timezone' => 'Europe/Vienna', 'timezone' => 'Europe/Vienna',
]); ]);

View File

@@ -9,7 +9,6 @@ use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
use App\Service\Import\ImportService; use App\Service\Import\ImportService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
@@ -24,7 +23,7 @@ class ImportServiceTest extends TestCase
public function test_import_gets_importer_from_provider_runs_importer_and_returns_report(): void public function test_import_gets_importer_from_provider_runs_importer_and_returns_report(): void
{ {
// Arrange // Arrange
Storage::fake(config('filesystems.private')); Storage::fake(config('filesystems.default'));
$organization = Organization::factory()->create(); $organization = Organization::factory()->create();
$timezone = 'Europe/Vienna'; $timezone = 'Europe/Vienna';
$data = Storage::disk('testfiles')->get('toggl_time_entries_import_test_1.csv'); $data = Storage::disk('testfiles')->get('toggl_time_entries_import_test_1.csv');
@@ -42,16 +41,13 @@ class ImportServiceTest extends TestCase
$this->assertSame(1, $report->usersCreated); $this->assertSame(1, $report->usersCreated);
$this->assertSame(2, $report->projectsCreated); $this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated); $this->assertSame(1, $report->clientsCreated);
$storedFiles = Storage::disk(config('filesystems.private'))->files('import'); Storage::disk(config('filesystems.default'))->assertDirectoryEmpty('import');
$this->assertCount(1, $storedFiles);
$this->assertStringStartsWith('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-', $storedFiles[0]);
$this->assertSame($data, Storage::disk(config('filesystems.private'))->get($storedFiles[0]));
} }
public function test_import_releases_lock_if_an_exception_happens_during_the_import(): void public function test_import_releases_lock_if_an_exception_happens_during_the_import(): void
{ {
// Arrange // Arrange
Storage::fake(config('filesystems.private')); Storage::fake(config('filesystems.default'));
$organization = Organization::factory()->create(); $organization = Organization::factory()->create();
$timezone = 'Europe/Vienna'; $timezone = 'Europe/Vienna';
$data = 'Invalid CSV data'; $data = 'Invalid CSV data';
@@ -70,7 +66,7 @@ class ImportServiceTest extends TestCase
public function test_import_throws_exception_if_import_is_already_in_progress(): void public function test_import_throws_exception_if_import_is_already_in_progress(): void
{ {
// Arrange // Arrange
Storage::fake(config('filesystems.private')); Storage::fake(config('filesystems.default'));
$organization = Organization::factory()->create(); $organization = Organization::factory()->create();
$timezone = 'Europe/Vienna'; $timezone = 'Europe/Vienna';
$data = Storage::disk('testfiles')->get('toggl_time_entries_import_test_1.csv'); $data = Storage::disk('testfiles')->get('toggl_time_entries_import_test_1.csv');

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,53 +140,6 @@ class SolidtimeImporterTest extends ImporterTestAbstract
$this->assertSame([], $breakEntry->tags); $this->assertSame([], $breakEntry->tags);
} }
public function test_import_throws_exception_if_time_entry_references_task_that_does_not_exist(): void
{
// Arrange
$zipPath = $this->createTestZip('solidtime_import_test_missing_task');
$timezone = 'Europe/Vienna';
$organization = Organization::factory()->create();
$importer = new SolidtimeImporter;
$importer->init($organization);
$data = file_get_contents($zipPath);
// Act
try {
$importer->importData($data, $timezone);
} catch (Exception $e) {
$this->assertInstanceOf(ImportException::class, $e);
$this->assertSame('Time entry references a task that does not exist in the import', $e->getMessage());
return;
}
$this->fail();
}
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
@@ -226,32 +177,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

@@ -88,30 +88,6 @@ class ZipImportHelperTest extends TestCase
$this->fail(); $this->fail();
} }
public function test_extract_with_default_limit_allows_toggl_export_with_many_projects(): void
{
// Arrange
// Toggl exports contain one projects_users and one tasks file per project
$files = [
'clients.json' => '[]',
'projects.json' => '[]',
'tags.json' => '[]',
'workspace_users.json' => '[]',
];
for ($project = 1; $project <= 2000; $project++) {
$files['projects_users/'.$project.'.json'] = '[]';
$files['tasks/'.$project.'.json'] = '[]';
}
$zipPath = $this->createZip($files);
// Act
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
// Assert
$this->assertSame('[]', file_get_contents($this->targetDirectory->path('projects_users/2000.json')));
$this->assertSame('[]', file_get_contents($this->targetDirectory->path('tasks/2000.json')));
}
public function test_extract_throws_exception_if_zip_contains_too_many_files(): void public function test_extract_throws_exception_if_zip_contains_too_many_files(): void
{ {
// Arrange // Arrange

View File

@@ -228,30 +228,6 @@ 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

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace Tests\Unit\Service; namespace Tests\Unit\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry; use App\Models\TimeEntry;
@@ -135,4 +137,60 @@ class UserServiceTest extends TestCase
$this->assertSame(Role::Owner->value, $newMember->role); $this->assertSame(Role::Owner->value, $newMember->role);
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey()); $this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());
} }
public function test_create_passwordless_user_joins_accepted_invitation_organization_instead_of_creating_personal_one(): void
{
// Arrange — an accepted invitation exists for the email (e.g. the user
// followed the invite link, then signs up via SSO). Casing differs to
// prove the email is normalised before the invitation is matched.
$organization = Organization::factory()->create();
OrganizationInvitation::factory()
->forOrganization($organization)
->role(Role::Employee)
->accepted()
->create([
'email' => 'invitee@example.com',
]);
// Act
$user = $this->userService->createPasswordlessUser(
'Invitee',
'Invitee@Example.com',
'UTC',
Weekday::Monday,
null,
);
// Assert — invitation is materialised, no personal organization is created
$this->assertNull($user->password);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'email' => 'invitee@example.com',
]);
$user->refresh();
$this->assertSame(1, $user->organizations()->count());
$this->assertSame($organization->getKey(), $user->organizations()->first()->getKey());
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
$this->assertSame(Role::Employee->value, $member->role);
}
public function test_create_passwordless_user_creates_personal_organization_when_no_invitation_exists(): void
{
// Act
$user = $this->userService->createPasswordlessUser(
'Solo User',
'solo@example.com',
'UTC',
Weekday::Monday,
null,
);
// Assert — a personal organization is created, owned by the user and set current
$user->refresh();
$this->assertNull($user->password);
$this->assertSame(1, $user->organizations()->count());
$organization = $user->organizations()->first();
$this->assertTrue($organization->personal_team);
$this->assertSame($user->getKey(), $organization->user_id);
$this->assertSame($organization->getKey(), $user->currentOrganization->getKey());
}
} }