diff --git a/app/Actions/Jetstream/AddOrganizationMember.php b/app/Actions/Jetstream/AddOrganizationMember.php index 7a84d53f..dd43ee5d 100644 --- a/app/Actions/Jetstream/AddOrganizationMember.php +++ b/app/Actions/Jetstream/AddOrganizationMember.php @@ -8,6 +8,7 @@ use App\Models\Organization; use App\Models\User; use Closure; use Illuminate\Contracts\Validation\Rule; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; @@ -59,7 +60,7 @@ class AddOrganizationMember implements AddsTeamMembers /** * Get the validation rules for adding a team member. * - * @return array> + * @return array> */ protected function rules(): array { diff --git a/app/Actions/Jetstream/InviteOrganizationMember.php b/app/Actions/Jetstream/InviteOrganizationMember.php index 686a9978..a73ebac2 100644 --- a/app/Actions/Jetstream/InviteOrganizationMember.php +++ b/app/Actions/Jetstream/InviteOrganizationMember.php @@ -34,7 +34,7 @@ class InviteOrganizationMember implements InvitesTeamMembers InvitingTeamMember::dispatch($organization, $email, $role); - /** @var TeamInvitation $invitation */ + /** @var OrganizationInvitation $invitation */ $invitation = $organization->teamInvitations()->create([ 'email' => $email, 'role' => $role, diff --git a/app/Exceptions/Api/ApiException.php b/app/Exceptions/Api/ApiException.php index bcd5ae01..e68bcc50 100644 --- a/app/Exceptions/Api/ApiException.php +++ b/app/Exceptions/Api/ApiException.php @@ -11,6 +11,8 @@ use LogicException; abstract class ApiException extends Exception { + public const string KEY = 'api_exception'; + /** * Render the exception into an HTTP response. */ @@ -29,11 +31,13 @@ abstract class ApiException extends Exception */ public function getKey(): string { - if (defined(static::class.'::KEY')) { - return static::KEY; + $key = static::KEY; + + if ($key === ApiException::KEY) { + throw new LogicException('API exceptions need the KEY constant defined.'); } - throw new LogicException('API exceptions need the KEY constant defined.'); + return $key; } /** diff --git a/app/Exceptions/Api/TimeEntryStillRunningApiException.php b/app/Exceptions/Api/TimeEntryStillRunningApiException.php index e110a9ae..c153534c 100644 --- a/app/Exceptions/Api/TimeEntryStillRunningApiException.php +++ b/app/Exceptions/Api/TimeEntryStillRunningApiException.php @@ -6,5 +6,5 @@ namespace App\Exceptions\Api; class TimeEntryStillRunningApiException extends ApiException { - const string KEY = 'time_entry_still_running'; + public const string KEY = 'time_entry_still_running'; } diff --git a/app/Exceptions/Api/UserNotPlaceholderApiException.php b/app/Exceptions/Api/UserNotPlaceholderApiException.php index 2aae88c9..92af4739 100644 --- a/app/Exceptions/Api/UserNotPlaceholderApiException.php +++ b/app/Exceptions/Api/UserNotPlaceholderApiException.php @@ -6,5 +6,5 @@ namespace App\Exceptions\Api; class UserNotPlaceholderApiException extends ApiException { - const string KEY = 'user_not_placeholder'; + public const string KEY = 'user_not_placeholder'; } diff --git a/app/Filament/Resources/OrganizationResource.php b/app/Filament/Resources/OrganizationResource.php index 3c14a313..eb2a9e0d 100644 --- a/app/Filament/Resources/OrganizationResource.php +++ b/app/Filament/Resources/OrganizationResource.php @@ -74,7 +74,7 @@ class OrganizationResource extends Resource // TODO: different disk! try { /** @var ReportDto $report */ - $report = app(ImportService::class)->import($record, $data['type'], Storage::disk('public')->get($data['file']), []); + $report = app(ImportService::class)->import($record, $data['type'], Storage::disk('public')->get($data['file'])); Notification::make() ->title('Import successful') ->success() @@ -98,9 +98,10 @@ class OrganizationResource extends Resource ->send(); } }) - ->tooltip(fn (Organization $record): string => "Import into {$record->name}") + ->tooltip(fn (Organization $record): string => 'Import into '.$record->name) ->form([ Forms\Components\FileUpload::make('file') + // TODO: disk! ->label('File') ->required(), Select::make('type') diff --git a/app/Filament/Resources/TaskResource.php b/app/Filament/Resources/TaskResource.php index 0ed06bd6..d4be3651 100644 --- a/app/Filament/Resources/TaskResource.php +++ b/app/Filament/Resources/TaskResource.php @@ -6,9 +6,12 @@ namespace App\Filament\Resources; use App\Filament\Resources\TaskResource\Pages; use App\Models\Task; +use Filament\Forms; +use Filament\Forms\Components\Select; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; class TaskResource extends Resource @@ -25,7 +28,18 @@ class TaskResource extends Resource { return $form ->schema([ - // + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255), + Select::make('project_id') + ->relationship(name: 'project', titleAttribute: 'name') + ->searchable(['name']) + ->required(), + Select::make('organization_id') + ->relationship(name: 'organization', titleAttribute: 'name') + ->searchable(['name']) + ->required(), ]); } @@ -46,7 +60,9 @@ class TaskResource extends Resource ->sortable(), ]) ->filters([ - // + SelectFilter::make('organization') + ->relationship('organization', 'name') + ->searchable(), ]) ->defaultSort('created_at', 'desc') ->actions([ diff --git a/app/Filament/Resources/TimeEntryResource.php b/app/Filament/Resources/TimeEntryResource.php index ca70c0db..56ac3898 100644 --- a/app/Filament/Resources/TimeEntryResource.php +++ b/app/Filament/Resources/TimeEntryResource.php @@ -14,6 +14,7 @@ use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; class TimeEntryResource extends Resource @@ -67,6 +68,7 @@ class TimeEntryResource extends Resource return $table ->columns([ TextColumn::make('description') + ->searchable() ->label('Description'), TextColumn::make('user.email') ->label('User'), @@ -89,7 +91,9 @@ class TimeEntryResource extends Resource ->sortable(), ]) ->filters([ - // + SelectFilter::make('organization') + ->relationship('organization', 'name') + ->searchable(), ]) ->defaultSort('created_at', 'desc') ->actions([ diff --git a/app/Http/Controllers/Api/V1/ImportController.php b/app/Http/Controllers/Api/V1/ImportController.php index 877c8c61..a3bd4040 100644 --- a/app/Http/Controllers/Api/V1/ImportController.php +++ b/app/Http/Controllers/Api/V1/ImportController.php @@ -26,8 +26,7 @@ class ImportController extends Controller $report = $importService->import( $organization, $request->input('type'), - $request->input('data'), - $request->input('options') + $request->input('data') ); return new JsonResponse([ diff --git a/app/Http/Requests/V1/Project/ProjectStoreRequest.php b/app/Http/Requests/V1/Project/ProjectStoreRequest.php index 09bf72dc..ca57a537 100644 --- a/app/Http/Requests/V1/Project/ProjectStoreRequest.php +++ b/app/Http/Requests/V1/Project/ProjectStoreRequest.php @@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Project; use App\Models\Client; use App\Models\Organization; +use App\Rules\ColorRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; @@ -35,6 +36,7 @@ class ProjectStoreRequest extends FormRequest 'required', 'string', 'max:255', + new ColorRule(), ], 'client_id' => [ 'nullable', diff --git a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php index 28ed09ad..82259815 100644 --- a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php +++ b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php @@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Project; use App\Models\Client; use App\Models\Organization; +use App\Rules\ColorRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; @@ -34,6 +35,7 @@ class ProjectUpdateRequest extends FormRequest 'required', 'string', 'max:255', + new ColorRule(), ], 'client_id' => [ 'nullable', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 0d3bb8be..92d9a591 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -30,10 +30,7 @@ class TimeEntryIndexRequest extends FormRequest 'uuid', new ExistsEloquent(User::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ - return $builder->whereHas('organizations', function (Builder $builder) { - /** @var Builder $builder */ - return $builder->whereKey($this->organization->getKey()); - }); + return $builder->belongsToOrganization($this->organization); }), ], // Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31) diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php index b2441819..8cbfd2fb 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php @@ -33,10 +33,7 @@ class TimeEntryStoreRequest extends FormRequest 'uuid', new ExistsEloquent(User::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ - return $builder->whereHas('organizations', function (Builder $builder) { - /** @var Builder $builder */ - return $builder->whereKey($this->organization->getKey()); - }); + return $builder->belongsToOrganization($this->organization); }), ], // ID of the task that the time entry should belong to diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 284675d4..5e6a99c3 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Laravel\Jetstream\Events\TeamCreated; use Laravel\Jetstream\Events\TeamDeleted; use Laravel\Jetstream\Events\TeamUpdated; +use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Team as JetstreamTeam; /** @@ -21,6 +22,7 @@ use Laravel\Jetstream\Team as JetstreamTeam; * @property bool $personal_team * @property User $owner * @property Collection $users + * @property Collection $realUsers * * @method HasMany teamInvitations() * @method static OrganizationFactory factory() @@ -64,7 +66,7 @@ class Organization extends JetstreamTeam /** * Get all the non-placeholder users of the organization including its owner. * - * @return Collection + * @return Collection */ public function allRealUsers(): Collection { @@ -78,6 +80,19 @@ class Organization extends JetstreamTeam }); } + /** + * Get all the users that belong to the team. + * + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(Jetstream::userModel(), Jetstream::membershipModel()) + ->withPivot('role') + ->withTimestamps() + ->as('membership'); + } + /** * @return BelongsToMany */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1aa92645..2674c1bf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -46,6 +46,7 @@ class AppServiceProvider extends ServiceProvider Model::preventLazyLoading(! $this->app->isProduction()); Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction()); + Model::preventAccessingMissingAttributes(! $this->app->isProduction()); Relation::enforceMorphMap([ 'membership' => Membership::class, 'organization' => Organization::class, diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index e3757c7c..414fe1b5 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -109,5 +109,8 @@ class JetstreamServiceProvider extends ServiceProvider 'time-entries:delete:own', 'organizations:view', ])->description('Editor users have the ability to read, create, and update.'); + + Jetstream::role('placeholder', 'Placeholder', [ + ])->description('Placeholders are used for importing data. They cannot log in and have no permissions.'); } } diff --git a/app/Rules/ColorRule.php b/app/Rules/ColorRule.php new file mode 100644 index 00000000..68dda354 --- /dev/null +++ b/app/Rules/ColorRule.php @@ -0,0 +1,32 @@ +isValid($value)) { + $fail(__('validation.color')); + + return; + } + } +} diff --git a/app/Service/ColorService.php b/app/Service/ColorService.php index a58e459b..e9ff97c0 100644 --- a/app/Service/ColorService.php +++ b/app/Service/ColorService.php @@ -31,8 +31,15 @@ class ColorService '#78909c', ]; + private const string VALID_REGEX = '/^#[0-9a-f]{6}$/'; + public function getRandomColor(): string { return self::COLORS[array_rand(self::COLORS)]; } + + public function isValid(string $color): bool + { + return preg_match(self::VALID_REGEX, $color) === 1; + } } diff --git a/app/Service/Import/ImportDatabaseHelper.php b/app/Service/Import/ImportDatabaseHelper.php index fa5551ae..4f169614 100644 --- a/app/Service/Import/ImportDatabaseHelper.php +++ b/app/Service/Import/ImportDatabaseHelper.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace App\Service\Import; +use App\Service\Import\Importers\ImportException; use Closure; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Log; /** * @template TModel of Model @@ -23,9 +25,15 @@ class ImportDatabaseHelper */ private array $identifiers; + /** + * @var array|null + */ private ?array $mapIdentifierToKey = null; - private array $mapNewAttach = []; + /** + * @var array + */ + private array $mapExternalIdentifierToInternalIdentifier = []; private bool $attachToExisting; @@ -57,7 +65,11 @@ class ImportDatabaseHelper return (new $this->model)->query(); } - private function createEntity(array $identifierData, array $createValues): string + /** + * @param array $identifierData + * @param array $createValues + */ + private function createEntity(array $identifierData, array $createValues, ?string $externalIdentifier): string { $model = new $this->model(); foreach ($identifierData as $identifier => $identifierValue) { @@ -72,34 +84,97 @@ class ImportDatabaseHelper ($this->afterCreate)($model); } - $this->mapIdentifierToKey[$this->getHash($identifierData)] = $model->getKey(); + $hash = $this->getHash($identifierData); + $this->mapIdentifierToKey[$hash] = $model->getKey(); $this->createdCount++; + if ($externalIdentifier !== null) { + $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash; + } + return $model->getKey(); } + /** + * @param array $data + */ private function getHash(array $data): string { - return md5(json_encode($data)); + $jsonData = json_encode($data); + if ($jsonData === false) { + throw new \RuntimeException('Failed to encode data to JSON'); + } + + return md5($jsonData); } - public function getKey(array $identifierData, array $createValues = []): string + /** + * @param array $identifierData + * @param array $createValues + * + * @throws ImportException + */ + public function getKey(array $identifierData, array $createValues = [], ?string $externalIdentifier = null): string { $this->checkMap(); + $this->validateIdentifierData($identifierData); + $hash = $this->getHash($identifierData); if ($this->attachToExisting) { $key = $this->mapIdentifierToKey[$hash] ?? null; if ($key !== null) { + if ($externalIdentifier !== null) { + $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash; + } + Log::debug('HIT', [ + 'class' => $this->model, + ]); + return $key; } - return $this->createEntity($identifierData, $createValues); + Log::debug('MISS', [ + 'class' => $this->model, + ]); + + return $this->createEntity($identifierData, $createValues, $externalIdentifier); } else { throw new \RuntimeException('Not implemented'); } } + /** + * @param array $identifierData + * + * @throws ImportException + */ + private function validateIdentifierData(array $identifierData): void + { + if (array_keys($identifierData) !== $this->identifiers) { + throw new ImportException('Invalid identifier data'); + } + } + + public function getKeyByExternalIdentifier(string $externalIdentifier): ?string + { + $hash = $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] ?? null; + if ($hash === null) { + return null; + } + + return $this->mapIdentifierToKey[$hash] ?? null; + } + + /** + * @return array + */ + public function getExternalIds(): array + { + // Note: Otherwise the external ids are integers + return array_map(fn ($value) => (string) $value, array_keys($this->mapExternalIdentifierToInternalIdentifier)); + } + private function checkMap(): void { if ($this->mapIdentifierToKey === null) { diff --git a/app/Service/Import/ImportService.php b/app/Service/Import/ImportService.php index 0c442c15..8f42a756 100644 --- a/app/Service/Import/ImportService.php +++ b/app/Service/Import/ImportService.php @@ -16,13 +16,13 @@ class ImportService /** * @throws ImportException */ - public function import(Organization $organization, string $importerType, string $data, array $options): ReportDto + public function import(Organization $organization, string $importerType, string $data): ReportDto { /** @var ImporterContract $importer */ $importer = app(ImporterProvider::class)->getImporter($importerType); $importer->init($organization); - DB::transaction(function () use (&$importer, &$data, &$options, &$organization) { - $importer->importData($data, $options); + DB::transaction(function () use (&$importer, &$data) { + $importer->importData($data); }); return $importer->getReport(); diff --git a/app/Service/Import/Importers/ClockifyProjectsImporter.php b/app/Service/Import/Importers/ClockifyProjectsImporter.php new file mode 100644 index 00000000..5debbf91 --- /dev/null +++ b/app/Service/Import/Importers/ClockifyProjectsImporter.php @@ -0,0 +1,143 @@ + + */ + private ImportDatabaseHelper $projectImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $clientImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $taskImportHelper; + + #[\Override] + public function init(Organization $organization): void + { + $this->organization = $organization; + $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + } + + /** + * @throws ImportException + */ + #[\Override] + public function importData(string $data): void + { + try { + $colorService = app(ColorService::class); + $reader = Reader::createFromString($data); + $reader->setHeaderOffset(0); + $reader->setDelimiter(','); + $header = $reader->getHeader(); + $this->validateHeader($header); + $records = $reader->getRecords(); + foreach ($records as $record) { + $clientId = null; + if ($record['Client'] !== '') { + $clientId = $this->clientImportHelper->getKey([ + 'name' => $record['Client'], + 'organization_id' => $this->organization->id, + ]); + } + $projectId = null; + if ($record['Name'] !== '') { + $projectId = $this->projectImportHelper->getKey([ + 'name' => $record['Name'], + 'organization_id' => $this->organization->id, + ], [ + 'client_id' => $clientId, + 'color' => $colorService->getRandomColor(), + ]); + } + + if ($record['Tasks'] !== '') { + $tasks = explode(', ', $record['Tasks']); + foreach ($tasks as $task) { + if (strlen($task) > 255) { + throw new ImportException('Task is too long'); + } + $taskId = $this->taskImportHelper->getKey([ + 'name' => $task, + 'project_id' => $projectId, + 'organization_id' => $this->organization->id, + ]); + } + } + } + } catch (ImportException $exception) { + throw $exception; + } catch (CsvException $exception) { + throw new ImportException('Invalid CSV data'); + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); + } + } + + /** + * @param array $header + * + * @throws ImportException + */ + private function validateHeader(array $header): void + { + $requiredFields = [ + 'Name', + 'Client', + 'Status', + 'Visibility', + 'Billability', + 'Tasks', + ]; + foreach ($requiredFields as $requiredField) { + if (! in_array($requiredField, $header, true)) { + throw new ImportException('Invalid CSV header, missing field: '.$requiredField); + } + } + } + + #[\Override] + public function getReport(): ReportDto + { + return new ReportDto( + clientsCreated: $this->clientImportHelper->getCreatedCount(), + projectsCreated: $this->projectImportHelper->getCreatedCount(), + tasksCreated: $this->taskImportHelper->getCreatedCount(), + timeEntriesCreated: 0, + tagsCreated: 0, + usersCreated: 0, + ); + } +} diff --git a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php new file mode 100644 index 00000000..bb7075cc --- /dev/null +++ b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php @@ -0,0 +1,227 @@ + + */ + private ImportDatabaseHelper $userImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $projectImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $tagImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $clientImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $taskImportHelper; + + private int $timeEntriesCreated; + + #[\Override] + public function init(Organization $organization): void + { + $this->organization = $organization; + $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { + /** @var Builder $builder */ + return $builder->belongsToOrganization($this->organization); + }, function (User $user) { + $user->organizations()->attach($this->organization, [ + 'role' => 'placeholder', + ]); + }); + $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->timeEntriesCreated = 0; + } + + /** + * @return array + * + * @throws ImportException + */ + private function getTags(string $tags): array + { + if (trim($tags) === '') { + return []; + } + $tagsParsed = explode(', ', $tags); + $tagIds = []; + foreach ($tagsParsed as $tagParsed) { + if (strlen($tagParsed) > 255) { + throw new ImportException('Tag is too long'); + } + $tagId = $this->tagImportHelper->getKey([ + 'name' => $tagParsed, + 'organization_id' => $this->organization->id, + ]); + $tagIds[] = $tagId; + } + + return $tagIds; + } + + /** + * @throws ImportException + */ + #[\Override] + public function importData(string $data): void + { + try { + $colorService = app(ColorService::class); + $reader = Reader::createFromString($data); + $reader->setHeaderOffset(0); + $reader->setDelimiter(','); + $header = $reader->getHeader(); + $this->validateHeader($header); + $records = $reader->getRecords(); + foreach ($records as $record) { + $userId = $this->userImportHelper->getKey([ + 'email' => $record['Email'], + ], [ + 'name' => $record['User'], + 'is_placeholder' => true, + ]); + $clientId = null; + if ($record['Client'] !== '') { + $clientId = $this->clientImportHelper->getKey([ + 'name' => $record['Client'], + 'organization_id' => $this->organization->id, + ]); + } + $projectId = null; + if ($record['Project'] !== '') { + $projectId = $this->projectImportHelper->getKey([ + 'name' => $record['Project'], + 'organization_id' => $this->organization->id, + ], [ + 'client_id' => $clientId, + 'color' => $colorService->getRandomColor(), + ]); + } + $taskId = null; + if ($record['Task'] !== '') { + $taskId = $this->taskImportHelper->getKey([ + 'name' => $record['Task'], + 'project_id' => $projectId, + 'organization_id' => $this->organization->id, + ]); + } + $timeEntry = new TimeEntry(); + $timeEntry->user_id = $userId; + $timeEntry->task_id = $taskId; + $timeEntry->project_id = $projectId; + $timeEntry->organization_id = $this->organization->id; + $timeEntry->description = $record['Description']; + if (! in_array($record['Billable'], ['Yes', 'No'], true)) { + throw new ImportException('Invalid billable value'); + } + $timeEntry->billable = $record['Billable'] === 'Yes'; + $timeEntry->tags = $this->getTags($record['Tags']); + $start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], 'UTC'); + if ($start === false) { + throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid'); + } + $timeEntry->start = $start; + $end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], 'UTC'); + if ($end === false) { + throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid'); + } + $timeEntry->end = $end; + $timeEntry->save(); + $this->timeEntriesCreated++; + } + } catch (ImportException $exception) { + throw $exception; + } catch (CsvException $exception) { + throw new ImportException('Invalid CSV data'); + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); + } + } + + /** + * @param array $header + * + * @throws ImportException + */ + private function validateHeader(array $header): void + { + $requiredFields = [ + 'Project', + 'Client', + 'Description', + 'Task', + 'User', + 'Group', + 'Email', + 'Tags', + 'Billable', + 'Start Date', + 'Start Time', + 'End Date', + 'End Time', + ]; + foreach ($requiredFields as $requiredField) { + if (! in_array($requiredField, $header, true)) { + throw new ImportException('Invalid CSV header, missing field: '.$requiredField); + } + } + } + + #[\Override] + public function getReport(): ReportDto + { + return new ReportDto( + clientsCreated: $this->clientImportHelper->getCreatedCount(), + projectsCreated: $this->projectImportHelper->getCreatedCount(), + tasksCreated: $this->taskImportHelper->getCreatedCount(), + timeEntriesCreated: $this->timeEntriesCreated, + tagsCreated: $this->tagImportHelper->getCreatedCount(), + usersCreated: $this->userImportHelper->getCreatedCount(), + ); + } +} diff --git a/app/Service/Import/Importers/ImporterContract.php b/app/Service/Import/Importers/ImporterContract.php index 65ca1bb6..a4846167 100644 --- a/app/Service/Import/Importers/ImporterContract.php +++ b/app/Service/Import/Importers/ImporterContract.php @@ -10,7 +10,7 @@ interface ImporterContract { public function init(Organization $organization): void; - public function importData(string $data, array $options): void; + public function importData(string $data): void; public function getReport(): ReportDto; } diff --git a/app/Service/Import/Importers/ImporterProvider.php b/app/Service/Import/Importers/ImporterProvider.php index ed413566..56eacf08 100644 --- a/app/Service/Import/Importers/ImporterProvider.php +++ b/app/Service/Import/Importers/ImporterProvider.php @@ -11,6 +11,9 @@ class ImporterProvider */ private array $importers = [ 'toggl_time_entries' => TogglTimeEntriesImporter::class, + 'toggl_data_importer' => TogglDataImporter::class, + 'clockify_time_entries' => ClockifyTimeEntriesImporter::class, + 'clockify_projects' => ClockifyProjectsImporter::class, ]; /** diff --git a/app/Service/Import/Importers/TogglDataImporter.php b/app/Service/Import/Importers/TogglDataImporter.php new file mode 100644 index 00000000..2183b2d9 --- /dev/null +++ b/app/Service/Import/Importers/TogglDataImporter.php @@ -0,0 +1,194 @@ + + */ + private ImportDatabaseHelper $userImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $projectImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $tagImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $clientImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $taskImportHelper; + + private ColorService $colorService; + + #[\Override] + public function init(Organization $organization): void + { + $this->organization = $organization; + $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { + /** @var Builder $builder */ + return $builder->belongsToOrganization($this->organization); + }, function (User $user) { + $user->organizations()->attach($this->organization, [ + 'role' => 'placeholder', + ]); + }); + $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->colorService = app(ColorService::class); + } + + /** + * @throws ImportException + */ + #[\Override] + public function importData(string $data): void + { + try { + $zip = new ZipArchive(); + $temporaryDirectory = TemporaryDirectory::make(); + file_put_contents($temporaryDirectory->path('import.zip'), $data); + $zip->open($temporaryDirectory->path('import.zip'), ZipArchive::RDONLY); + $temporaryDirectory = TemporaryDirectory::make(); + $zip->extractTo($temporaryDirectory->path()); + $zip->close(); + $clientsFileContent = file_get_contents($temporaryDirectory->path('clients.json')); + if ($clientsFileContent === false) { + throw new ImportException('File clients.json missing in ZIP'); + } + $clients = json_decode($clientsFileContent); + $projectsFileContent = file_get_contents($temporaryDirectory->path('projects.json')); + if ($projectsFileContent === false) { + throw new ImportException('File projects.json missing in ZIP'); + } + $projects = json_decode($projectsFileContent); + $tagsFileContent = file_get_contents($temporaryDirectory->path('tags.json')); + if ($tagsFileContent === false) { + throw new ImportException('File tags.json missing in ZIP'); + } + $tags = json_decode($tagsFileContent); + $workspaceUsersFileContent = file_get_contents($temporaryDirectory->path('workspace_users.json')); + if ($workspaceUsersFileContent === false) { + throw new ImportException('File workspace_users.json missing in ZIP'); + } + $workspaceUsers = json_decode($workspaceUsersFileContent); + foreach ($clients as $client) { + $this->clientImportHelper->getKey([ + 'name' => $client->name, + 'organization_id' => $this->organization->id, + ], [], (string) $client->id); + } + foreach ($tags as $tag) { + $this->tagImportHelper->getKey([ + 'name' => $tag->name, + 'organization_id' => $this->organization->id, + ], [], (string) $tag->id); + } + + foreach ($projects as $project) { + $clientId = null; + if ($project->client_id !== null) { + $clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id); + if ($clientId === null) { + throw new Exception('Client does not exist'); + } + } + + if (! $this->colorService->isValid($project->color)) { + throw new ImportException('Invalid color'); + } + + $this->projectImportHelper->getKey([ + 'name' => $project->name, + 'organization_id' => $this->organization->getKey(), + ], [ + 'client_id' => $clientId, + 'color' => $project->color, + ], (string) $project->id); + } + foreach ($workspaceUsers as $workspaceUser) { + $this->userImportHelper->getKey([ + 'email' => $workspaceUser->email, + ], [ + 'name' => $workspaceUser->name, + 'is_placeholder' => true, + ], (string) $workspaceUser->id); + } + $projectIds = $this->projectImportHelper->getExternalIds(); + foreach ($projectIds as $projectIdExternal) { + $tasksFileContent = file_get_contents($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json')); + if ($tasksFileContent === false) { + throw new ImportException('File tasks/'.$projectIdExternal.'.json missing in ZIP'); + } + $tasks = json_decode($tasksFileContent); + foreach ($tasks as $task) { + $projectId = $this->projectImportHelper->getKeyByExternalIdentifier((string) $projectIdExternal); + + if ($projectId === null) { + throw new Exception('Project does not exist'); + } + $this->taskImportHelper->getKey([ + 'name' => $task->name, + 'project_id' => $projectId, + 'organization_id' => $this->organization->getKey(), + ], [], (string) $task->id); + } + } + } catch (ImportException $exception) { + throw $exception; + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); + } + } + + #[\Override] + public function getReport(): ReportDto + { + return new ReportDto( + clientsCreated: $this->clientImportHelper->getCreatedCount(), + projectsCreated: $this->projectImportHelper->getCreatedCount(), + tasksCreated: $this->taskImportHelper->getCreatedCount(), + timeEntriesCreated: 0, + tagsCreated: $this->tagImportHelper->getCreatedCount(), + usersCreated: $this->userImportHelper->getCreatedCount(), + ); + } +} diff --git a/app/Service/Import/Importers/TogglTimeEntriesImporter.php b/app/Service/Import/Importers/TogglTimeEntriesImporter.php index 0021bfb6..74740496 100644 --- a/app/Service/Import/Importers/TogglTimeEntriesImporter.php +++ b/app/Service/Import/Importers/TogglTimeEntriesImporter.php @@ -13,6 +13,7 @@ use App\Models\TimeEntry; use App\Models\User; use App\Service\ColorService; use App\Service\Import\ImportDatabaseHelper; +use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use League\Csv\Exception as CsvException; @@ -22,14 +23,29 @@ class TogglTimeEntriesImporter implements ImporterContract { private Organization $organization; + /** + * @var ImportDatabaseHelper + */ private ImportDatabaseHelper $userImportHelper; + /** + * @var ImportDatabaseHelper + */ private ImportDatabaseHelper $projectImportHelper; + /** + * @var ImportDatabaseHelper + */ private ImportDatabaseHelper $tagImportHelper; + /** + * @var ImportDatabaseHelper + */ private ImportDatabaseHelper $clientImportHelper; + /** + * @var ImportDatabaseHelper + */ private ImportDatabaseHelper $taskImportHelper; private int $timeEntriesCreated; @@ -39,14 +55,13 @@ class TogglTimeEntriesImporter implements ImporterContract { $this->organization = $organization; $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { - return $builder->whereHas('organizations', function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereKey($this->organization->getKey()); - }); + /** @var Builder $builder */ + return $builder->belongsToOrganization($this->organization); }, function (User $user) { - $user->organizations()->attach([$this->organization->id]); + $user->organizations()->attach($this->organization, [ + 'role' => 'placeholder', + ]); }); - // TODO: user special after import $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { return $builder->where('organization_id', $this->organization->id); }); @@ -62,6 +77,11 @@ class TogglTimeEntriesImporter implements ImporterContract $this->timeEntriesCreated = 0; } + /** + * @return array + * + * @throws ImportException + */ private function getTags(string $tags): array { if (trim($tags) === '') { @@ -87,7 +107,7 @@ class TogglTimeEntriesImporter implements ImporterContract * @throws ImportException */ #[\Override] - public function importData(string $data, array $options): void + public function importData(string $data): void { try { $colorService = app(ColorService::class); @@ -140,17 +160,34 @@ class TogglTimeEntriesImporter implements ImporterContract } $timeEntry->billable = $record['Billable'] === 'Yes'; $timeEntry->tags = $this->getTags($record['Tags']); - $timeEntry->start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], 'UTC'); - $timeEntry->end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], 'UTC'); + $start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], 'UTC'); + if ($start === false) { + throw new ImportException('Start date ("'.$record['Start date'].'") or time ("'.$record['Start time'].'") are invalid'); + } + $timeEntry->start = $start; + $end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], 'UTC'); + if ($end === false) { + throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid'); + } + $timeEntry->end = $end; $timeEntry->save(); $this->timeEntriesCreated++; } + } catch (ImportException $exception) { + throw $exception; } catch (CsvException $exception) { throw new ImportException('Invalid CSV data'); + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); } - } + /** + * @param array $header + * + * @throws ImportException + */ private function validateHeader(array $header): void { $requiredFields = [ diff --git a/app/Service/UserService.php b/app/Service/UserService.php index e554e078..87be9978 100644 --- a/app/Service/UserService.php +++ b/app/Service/UserService.php @@ -13,11 +13,11 @@ class UserService public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void { // Time entries - dump(TimeEntry::query() + TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->whereBelongsTo($fromUser, 'user') ->update([ 'user_id' => $toUser->getKey(), - ])); + ]); } } diff --git a/composer.json b/composer.json index 8b895a38..8b84fdf8 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,7 @@ "license": "AGPL-3.0-or-later", "require": { "php": "8.3.*", + "ext-zip": "*", "dedoc/scramble": "^0.8.5", "filament/filament": "^3.2", "guzzlehttp/guzzle": "^7.2", @@ -16,6 +17,7 @@ "laravel/passport": "^11.10.2", "laravel/tinker": "^2.8", "pxlrbt/filament-environment-indicator": "^2.0", + "spatie/temporary-directory": "^2.2", "tightenco/ziggy": "^1.0", "tpetry/laravel-postgresql-enhanced": "^0.33.0" }, diff --git a/composer.lock b/composer.lock index d39541ae..f3065194 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e83929e68d256367652d91e43a79288e", + "content-hash": "9e9c41ae5787e1aa711b04cc019cb7e7", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -6542,6 +6542,67 @@ ], "time": "2024-01-11T08:43:00+00:00" }, + { + "name": "spatie/temporary-directory", + "version": "2.2.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/temporary-directory.git", + "reference": "76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a", + "reference": "76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\TemporaryDirectory\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily create, use and destroy temporary directories", + "homepage": "https://github.com/spatie/temporary-directory", + "keywords": [ + "php", + "spatie", + "temporary-directory" + ], + "support": { + "issues": "https://github.com/spatie/temporary-directory/issues", + "source": "https://github.com/spatie/temporary-directory/tree/2.2.1" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2023-12-25T11:46:58+00:00" + }, { "name": "symfony/console", "version": "v6.4.4", @@ -12424,7 +12485,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "8.3.*" + "php": "8.3.*", + "ext-zip": "*" }, "platform-dev": [], "plugin-api-version": "2.6.0" diff --git a/config/filesystems.php b/config/filesystems.php index d307268d..508cc76c 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -58,6 +58,12 @@ return [ 'throw' => false, ], + 'testfiles' => [ + 'driver' => 'local', + 'root' => storage_path('tests'), + 'throw' => false, + ], + ], /* diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php index c05a79ff..f8654731 100644 --- a/database/factories/ProjectFactory.php +++ b/database/factories/ProjectFactory.php @@ -7,6 +7,7 @@ namespace Database\Factories; use App\Models\Client; use App\Models\Organization; use App\Models\Project; +use App\Service\ColorService; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -23,7 +24,7 @@ class ProjectFactory extends Factory { return [ 'name' => $this->faker->company(), - 'color' => $this->faker->hexColor(), + 'color' => app(ColorService::class)->getRandomColor(), 'organization_id' => Organization::factory(), 'client_id' => null, ]; diff --git a/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 00000000..e2de2aca --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,22 @@ + 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + +]; diff --git a/lang/en/pagination.php b/lang/en/pagination.php new file mode 100644 index 00000000..f03c42c6 --- /dev/null +++ b/lang/en/pagination.php @@ -0,0 +1,21 @@ + '« Previous', + 'next' => 'Next »', + +]; diff --git a/lang/en/passwords.php b/lang/en/passwords.php new file mode 100644 index 00000000..43092321 --- /dev/null +++ b/lang/en/passwords.php @@ -0,0 +1,24 @@ + 'Your password has been reset.', + 'sent' => 'We have emailed your password reset link.', + 'throttled' => 'Please wait before retrying.', + 'token' => 'This password reset token is invalid.', + 'user' => "We can't find a user with that email address.", + +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 00000000..9813d38a --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,199 @@ + 'The :attribute field must be accepted.', + 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', + 'active_url' => 'The :attribute field must be a valid URL.', + 'after' => 'The :attribute field must be a date after :date.', + 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.', + 'alpha' => 'The :attribute field must only contain letters.', + 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.', + 'alpha_num' => 'The :attribute field must only contain letters and numbers.', + 'array' => 'The :attribute field must be an array.', + 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', + 'before' => 'The :attribute field must be a date before :date.', + 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.', + 'between' => [ + 'array' => 'The :attribute field must have between :min and :max items.', + 'file' => 'The :attribute field must be between :min and :max kilobytes.', + 'numeric' => 'The :attribute field must be between :min and :max.', + 'string' => 'The :attribute field must be between :min and :max characters.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'can' => 'The :attribute field contains an unauthorized value.', + 'confirmed' => 'The :attribute field confirmation does not match.', + 'current_password' => 'The password is incorrect.', + 'date' => 'The :attribute field must be a valid date.', + 'date_equals' => 'The :attribute field must be a date equal to :date.', + 'date_format' => 'The :attribute field must match the format :format.', + 'decimal' => 'The :attribute field must have :decimal decimal places.', + 'declined' => 'The :attribute field must be declined.', + 'declined_if' => 'The :attribute field must be declined when :other is :value.', + 'different' => 'The :attribute field and :other must be different.', + 'digits' => 'The :attribute field must be :digits digits.', + 'digits_between' => 'The :attribute field must be between :min and :max digits.', + 'dimensions' => 'The :attribute field has invalid image dimensions.', + 'distinct' => 'The :attribute field has a duplicate value.', + 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', + 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', + 'email' => 'The :attribute field must be a valid email address.', + 'ends_with' => 'The :attribute field must end with one of the following: :values.', + 'enum' => 'The selected :attribute is invalid.', + 'exists' => 'The selected :attribute is invalid.', + 'extensions' => 'The :attribute field must have one of the following extensions: :values.', + 'file' => 'The :attribute field must be a file.', + 'filled' => 'The :attribute field must have a value.', + 'gt' => [ + 'array' => 'The :attribute field must have more than :value items.', + 'file' => 'The :attribute field must be greater than :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than :value.', + 'string' => 'The :attribute field must be greater than :value characters.', + ], + 'gte' => [ + 'array' => 'The :attribute field must have :value items or more.', + 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than or equal to :value.', + 'string' => 'The :attribute field must be greater than or equal to :value characters.', + ], + 'hex_color' => 'The :attribute field must be a valid hexadecimal color.', + 'image' => 'The :attribute field must be an image.', + 'in' => 'The selected :attribute is invalid.', + 'in_array' => 'The :attribute field must exist in :other.', + 'integer' => 'The :attribute field must be an integer.', + 'ip' => 'The :attribute field must be a valid IP address.', + 'ipv4' => 'The :attribute field must be a valid IPv4 address.', + 'ipv6' => 'The :attribute field must be a valid IPv6 address.', + 'json' => 'The :attribute field must be a valid JSON string.', + 'lowercase' => 'The :attribute field must be lowercase.', + 'lt' => [ + 'array' => 'The :attribute field must have less than :value items.', + 'file' => 'The :attribute field must be less than :value kilobytes.', + 'numeric' => 'The :attribute field must be less than :value.', + 'string' => 'The :attribute field must be less than :value characters.', + ], + 'lte' => [ + 'array' => 'The :attribute field must not have more than :value items.', + 'file' => 'The :attribute field must be less than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be less than or equal to :value.', + 'string' => 'The :attribute field must be less than or equal to :value characters.', + ], + 'mac_address' => 'The :attribute field must be a valid MAC address.', + 'max' => [ + 'array' => 'The :attribute field must not have more than :max items.', + 'file' => 'The :attribute field must not be greater than :max kilobytes.', + 'numeric' => 'The :attribute field must not be greater than :max.', + 'string' => 'The :attribute field must not be greater than :max characters.', + ], + 'max_digits' => 'The :attribute field must not have more than :max digits.', + 'mimes' => 'The :attribute field must be a file of type: :values.', + 'mimetypes' => 'The :attribute field must be a file of type: :values.', + 'min' => [ + 'array' => 'The :attribute field must have at least :min items.', + 'file' => 'The :attribute field must be at least :min kilobytes.', + 'numeric' => 'The :attribute field must be at least :min.', + 'string' => 'The :attribute field must be at least :min characters.', + ], + 'min_digits' => 'The :attribute field must have at least :min digits.', + 'missing' => 'The :attribute field must be missing.', + 'missing_if' => 'The :attribute field must be missing when :other is :value.', + 'missing_unless' => 'The :attribute field must be missing unless :other is :value.', + 'missing_with' => 'The :attribute field must be missing when :values is present.', + 'missing_with_all' => 'The :attribute field must be missing when :values are present.', + 'multiple_of' => 'The :attribute field must be a multiple of :value.', + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute field format is invalid.', + 'numeric' => 'The :attribute field must be a number.', + 'password' => [ + 'letters' => 'The :attribute field must contain at least one letter.', + 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.', + 'numbers' => 'The :attribute field must contain at least one number.', + 'symbols' => 'The :attribute field must contain at least one symbol.', + 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', + ], + 'present' => 'The :attribute field must be present.', + 'present_if' => 'The :attribute field must be present when :other is :value.', + 'present_unless' => 'The :attribute field must be present unless :other is :value.', + 'present_with' => 'The :attribute field must be present when :values is present.', + 'present_with_all' => 'The :attribute field must be present when :values are present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', + 'regex' => 'The :attribute field format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', + 'required_unless' => 'The :attribute field is required unless :other is in :values.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values are present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute field must match :other.', + 'size' => [ + 'array' => 'The :attribute field must contain :size items.', + 'file' => 'The :attribute field must be :size kilobytes.', + 'numeric' => 'The :attribute field must be :size.', + 'string' => 'The :attribute field must be :size characters.', + ], + 'starts_with' => 'The :attribute field must start with one of the following: :values.', + 'string' => 'The :attribute field must be a string.', + 'timezone' => 'The :attribute field must be a valid timezone.', + 'unique' => 'The :attribute has already been taken.', + 'uploaded' => 'The :attribute failed to upload.', + 'uppercase' => 'The :attribute field must be uppercase.', + 'url' => 'The :attribute field must be a valid URL.', + 'ulid' => 'The :attribute field must be a valid ULID.', + 'uuid' => 'The :attribute field must be a valid UUID.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [], + + /* + * Custom validation rules + */ + + 'color' => 'The :attribute field must be a valid color.', + +]; diff --git a/storage/tests/clockify_import_test_1.csv b/storage/tests/clockify_import_test_1.csv deleted file mode 100644 index 66b59c18..00000000 --- a/storage/tests/clockify_import_test_1.csv +++ /dev/null @@ -1 +0,0 @@ -"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)" diff --git a/storage/tests/clockify_projects_import_test_1.csv b/storage/tests/clockify_projects_import_test_1.csv new file mode 100644 index 00000000..d8c018fb --- /dev/null +++ b/storage/tests/clockify_projects_import_test_1.csv @@ -0,0 +1,3 @@ +"Name","Client","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Project members","Project manager","Note" +"Project for Big Company","Big Company","Active","Public","Yes","Task 1, Task 2, Task 3","0.00","","","","","0.00","0.00","","0.00","Constantin Graf","","" +"Project without Client","","Active","Public","Yes","","0.00","","","","","0.00","0.00","","0.00","Constantin Graf","","" diff --git a/storage/tests/clockify_time_entries_import_test_1.csv b/storage/tests/clockify_time_entries_import_test_1.csv new file mode 100644 index 00000000..b7b1f1ce --- /dev/null +++ b/storage/tests/clockify_time_entries_import_test_1.csv @@ -0,0 +1,3 @@ +"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 without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","No","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00" +"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","Yes","03/04/2024","10:23:00 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00" diff --git a/storage/tests/toggl_data_import_test_1/clients.json b/storage/tests/toggl_data_import_test_1/clients.json new file mode 100644 index 00000000..9291eaef --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/clients.json @@ -0,0 +1,9 @@ +[ + { + "archived": false, + "creator_id": 201, + "id": 301, + "name": "Big Company", + "wid": 0 + } +] diff --git a/storage/tests/toggl_data_import_test_1/projects.json b/storage/tests/toggl_data_import_test_1/projects.json new file mode 100644 index 00000000..7c28f2d7 --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/projects.json @@ -0,0 +1,58 @@ +[ + { + "active": true, + "actual_hours": null, + "actual_seconds": null, + "auto_estimates": false, + "billable": true, + "cid": null, + "client_id": null, + "color": "#ef5350", + "currency": "EUR", + "estimated_hours": null, + "estimated_seconds": null, + "fixed_fee": null, + "guid": "", + "id": 401, + "is_private": true, + "name": "Project without Client", + "rate": null, + "rate_last_updated": null, + "recurring": false, + "recurring_parameters": null, + "start_date": "2020-01-01", + "status": "active", + "template": false, + "template_id": null, + "wid": 0, + "workspace_id": 0 + }, + { + "active": true, + "actual_hours": null, + "actual_seconds": null, + "auto_estimates": false, + "billable": false, + "cid": 301, + "client_id": 301, + "color": "#ec407a", + "currency": null, + "estimated_hours": null, + "estimated_seconds": null, + "fixed_fee": null, + "guid": "", + "id": 402, + "is_private": true, + "name": "Project for Big Company", + "rate": null, + "rate_last_updated": null, + "recurring": false, + "recurring_parameters": null, + "start_date": "2020-01-01", + "status": "active", + "template": false, + "template_id": null, + "wid": 0, + "workspace_id": 0 + } +] diff --git a/storage/tests/toggl_data_import_test_1/tags.json b/storage/tests/toggl_data_import_test_1/tags.json new file mode 100644 index 00000000..4bc90b57 --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/tags.json @@ -0,0 +1,14 @@ +[ + { + "creator_id": 0, + "id": 501, + "name": "Development", + "workspace_id": 0 + }, + { + "creator_id": 0, + "id": 502, + "name": "Backend", + "workspace_id": 0 + } +] diff --git a/storage/tests/toggl_data_import_test_1/tasks/401.json b/storage/tests/toggl_data_import_test_1/tasks/401.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/tasks/401.json @@ -0,0 +1 @@ +[] diff --git a/storage/tests/toggl_data_import_test_1/tasks/402.json b/storage/tests/toggl_data_import_test_1/tasks/402.json new file mode 100644 index 00000000..fc7d6934 --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/tasks/402.json @@ -0,0 +1,13 @@ +[ + { + "active": true, + "estimated_seconds": 0, + "id": 601, + "name": "Task 1", + "project_id": 402, + "recurring": false, + "tracked_seconds": 0, + "user_id": null, + "workspace_id": 0 + } +] diff --git a/storage/tests/toggl_data_import_test_1/workspace_users.json b/storage/tests/toggl_data_import_test_1/workspace_users.json new file mode 100644 index 00000000..356daa1f --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/workspace_users.json @@ -0,0 +1,19 @@ +[ + { + "active": true, + "admin": true, + "email": "peter.test@email.test", + "group_ids": [], + "id": 201, + "inactive": false, + "labour_cost": null, + "name": "Peter Tester", + "rate": null, + "rate_last_updated": null, + "role": "admin", + "timezone": "Europe/Vienna", + "uid": 0, + "wid": 0, + "working_hours_in_minutes": null + } +] diff --git a/storage/tests/toggl_import_test_1.csv b/storage/tests/toggl_time_entries_import_test_1.csv similarity index 81% rename from storage/tests/toggl_import_test_1.csv rename to storage/tests/toggl_time_entries_import_test_1.csv index 1fdfc8d6..effd1dfb 100644 --- a/storage/tests/toggl_import_test_1.csv +++ b/storage/tests/toggl_time_entries_import_test_1.csv @@ -1,3 +1,3 @@ User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Duration,Tags,Amount (EUR) -Peter Tester,peter.test@email.test,,Project without Client,,"",No,2024-03-04,10:23:52,2024-03-04,10:23:52,00:00:00,Development, +Peter Tester,peter.test@email.test,,Project without Client,,"",No,2024-03-04,10:23:52,2024-03-04,10:23:52,00:00:00,"Development, Backend", Peter Tester,peter.test@email.test,Big Company,Project for Big Company,Task 1,Working hard,Yes,2024-03-04,10:23:00,2024-03-04,11:23:01,01:00:01,,111.11 diff --git a/tests/Unit/Rules/ColorRuleTest.php b/tests/Unit/Rules/ColorRuleTest.php new file mode 100644 index 00000000..3df5ee22 --- /dev/null +++ b/tests/Unit/Rules/ColorRuleTest.php @@ -0,0 +1,66 @@ + '#ef5350', + ], [ + 'color' => [new ColorRule()], + ]); + + // Act + $isValid = $validator->passes(); + $messages = $validator->messages()->toArray(); + + // Assert + $this->assertTrue($isValid); + $this->assertArrayNotHasKey('color', $messages); + } + + public function test_validation_fails_if_value_is_not_a_string(): void + { + // Arrange + $validator = Validator::make([ + 'color' => true, + ], [ + 'color' => [new ColorRule()], + ]); + + // Act + $isValid = $validator->passes(); + $messages = $validator->messages()->toArray(); + + // Assert + $this->assertFalse($isValid); + $this->assertEquals('The color field must be a string.', $messages['color'][0]); + } + + public function test_validation_fails_if_value_is_not_a_valid_color(): void + { + // Arrange + $validator = Validator::make([ + 'color' => 'rgb(0,0,0)', + ], [ + 'color' => [new ColorRule()], + ]); + + // Act + $isValid = $validator->passes(); + $messages = $validator->messages()->toArray(); + + // Assert + $this->assertFalse($isValid); + $this->assertEquals('The color field must be a valid color.', $messages['color'][0]); + } +} diff --git a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php index d1d156f2..9337b724 100644 --- a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php +++ b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Service\Import; +use App\Models\Organization; use App\Models\Project; use App\Models\User; use App\Service\Import\ImportDatabaseHelper; @@ -74,4 +75,64 @@ class ImportDatabaseHelperTest extends TestCase // Assert $this->fail(); } + + public function test_get_key_by_external_identifier_returns_key_for_external_identifier(): void + { + // Arrange + $organization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($organization)->create(); + $externalIdentifier1 = '12345'; + $externalIdentifier2 = '54321'; + $helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true); + $helper->getKey([ + 'name' => $project->name, + 'organization_id' => $organization->getKey(), + ], [ + 'color' => '#000000', + ], $externalIdentifier1); + $helper->getKey([ + 'name' => 'Not existing project', + 'organization_id' => $organization->getKey(), + ], [ + 'color' => '#000000', + ], $externalIdentifier2); + + // Act + $key1 = $helper->getKeyByExternalIdentifier($externalIdentifier1); + $key2 = $helper->getKeyByExternalIdentifier($externalIdentifier2); + + // Assert + $this->assertSame($project->getKey(), $key1); + $this->assertSame(Project::where('name', '=', 'Not existing project')->first()->getKey(), $key2); + } + + public function test_get_external_ids_returns_all_external_ids_that_were_temporary_stored_via_get_key(): void + { + // Arrange + $organization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($organization)->create(); + $externalIdentifier1 = '12345'; + $externalIdentifier2 = '54321'; + $helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true); + $helper->getKey([ + 'name' => $project->name, + 'organization_id' => $organization->getKey(), + ], [ + 'color' => '#000000', + ], $externalIdentifier1); + $helper->getKey([ + 'name' => 'Not existing project', + 'organization_id' => $organization->getKey(), + ], [ + 'color' => '#000000', + ], $externalIdentifier2); + + // Act + $externalKeys = $helper->getExternalIds(); + + // Assert + $this->assertCount(2, $externalKeys); + $this->assertContains($externalIdentifier1, $externalKeys); + $this->assertContains($externalIdentifier2, $externalKeys); + } } diff --git a/tests/Unit/Service/Import/Importer/ClockifyProjectsImporterTest.php b/tests/Unit/Service/Import/Importer/ClockifyProjectsImporterTest.php new file mode 100644 index 00000000..598a7de7 --- /dev/null +++ b/tests/Unit/Service/Import/Importer/ClockifyProjectsImporterTest.php @@ -0,0 +1,44 @@ +create(); + $importer = new ClockifyProjectsImporter(); + $importer->init($organization); + $data = file_get_contents(storage_path('tests/clockify_projects_import_test_1.csv')); + + // Act + $importer->importData($data, []); + + // Assert + $this->checkTestScenarioProjectsOnlyAfterImport(); + } + + public function test_import_of_test_file_twice_succeeds(): void + { + // Arrange + $organization = Organization::factory()->create(); + $importer = new ClockifyProjectsImporter(); + $importer->init($organization); + $data = file_get_contents(storage_path('tests/clockify_projects_import_test_1.csv')); + $importer->importData($data, []); + $importer = new ClockifyProjectsImporter(); + $importer->init($organization); + + // Act + $importer->importData($data, []); + + // Assert + $this->checkTestScenarioProjectsOnlyAfterImport(); + } +} diff --git a/tests/Unit/Service/Import/Importer/ClockifyTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importer/ClockifyTimeEntriesImporterTest.php new file mode 100644 index 00000000..837646dc --- /dev/null +++ b/tests/Unit/Service/Import/Importer/ClockifyTimeEntriesImporterTest.php @@ -0,0 +1,77 @@ +create(); + $importer = new ClockifyTimeEntriesImporter(); + $importer->init($organization); + $data = file_get_contents(storage_path('tests/clockify_time_entries_import_test_1.csv')); + + // Act + $importer->importData($data, []); + + // Assert + $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $timeEntries = TimeEntry::all(); + $this->assertCount(2, $timeEntries); + $timeEntry1 = $timeEntries->firstWhere('description', ''); + $this->assertNotNull($timeEntry1); + $this->assertSame('', $timeEntry1->description); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString()); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString()); + $this->assertFalse($timeEntry1->billable); + $this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags); + $timeEntry2 = $timeEntries->firstWhere('description', 'Working hard'); + $this->assertNotNull($timeEntry2); + $this->assertSame('Working hard', $timeEntry2->description); + $this->assertSame('2024-03-04 10:23:00', $timeEntry2->start->toDateTimeString()); + $this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString()); + $this->assertTrue($timeEntry2->billable); + $this->assertSame([], $timeEntry2->tags); + } + + public function test_import_of_test_file_twice_succeeds(): void + { + // Arrange + $organization = Organization::factory()->create(); + $importer = new ClockifyTimeEntriesImporter(); + $importer->init($organization); + $data = file_get_contents(storage_path('tests/clockify_time_entries_import_test_1.csv')); + $importer->importData($data, []); + $importer = new ClockifyTimeEntriesImporter(); + $importer->init($organization); + + // Act + $importer->importData($data, []); + + // Assert + $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $timeEntries = TimeEntry::all(); + $this->assertCount(4, $timeEntries); + $timeEntry1 = $timeEntries->firstWhere('description', ''); + $this->assertNotNull($timeEntry1); + $this->assertSame('', $timeEntry1->description); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString()); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString()); + $this->assertFalse($timeEntry1->billable); + $this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags); + $timeEntry2 = $timeEntries->firstWhere('description', 'Working hard'); + $this->assertNotNull($timeEntry2); + $this->assertSame('Working hard', $timeEntry2->description); + $this->assertSame('2024-03-04 10:23:00', $timeEntry2->start->toDateTimeString()); + $this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString()); + $this->assertTrue($timeEntry2->billable); + $this->assertSame([], $timeEntry2->tags); + } +} diff --git a/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php b/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php index 7b9c44ab..e22ed3a9 100644 --- a/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php +++ b/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Service\Import\Importer; +use App\Models\Client; use App\Models\Project; use App\Models\Tag; use App\Models\Task; @@ -16,7 +17,7 @@ class ImporterTestAbstract extends TestCase use RefreshDatabase; /** - * @return object{user1: User, project1: Project, project2: Project, tag1: Tag} + * @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} */ protected function checkTestScenarioAfterImportExcludingTimeEntries(): object { @@ -27,20 +28,27 @@ class ImporterTestAbstract extends TestCase $this->assertSame(null, $user1->password); $this->assertSame('Peter Tester', $user1->name); $this->assertSame('peter.test@email.test', $user1->email); + $clients = Client::all(); + $this->assertCount(1, $clients); + $client1 = $clients->firstWhere('name', 'Big Company'); + $this->assertNotNull($client1); $projects = Project::all(); $this->assertCount(2, $projects); $project1 = $projects->firstWhere('name', 'Project without Client'); $this->assertNotNull($project1); + $this->assertNull($project1->client_id); $project2 = $projects->firstWhere('name', 'Project for Big Company'); $this->assertNotNull($project2); + $this->assertSame($client1->getKey(), $project2->client_id); $tasks = Task::all(); $this->assertCount(1, $tasks); $task1 = $tasks->firstWhere('name', 'Task 1'); $this->assertNotNull($task1); $this->assertSame($project2->getKey(), $task1->project_id); $tags = Tag::all(); - $this->assertCount(1, $tags); + $this->assertCount(2, $tags); $tag1 = $tags->firstWhere('name', 'Development'); + $tag2 = $tags->firstWhere('name', 'Backend'); $this->assertNotNull($tag1); return (object) [ @@ -48,6 +56,44 @@ class ImporterTestAbstract extends TestCase 'project1' => $project1, 'project2' => $project2, 'tag1' => $tag1, + 'tag2' => $tag2, + ]; + } + + /** + * @return object{client1: Client, project1: Project, project2: Project, task1: Task} + */ + protected function checkTestScenarioProjectsOnlyAfterImport(): object + { + $clients = Client::all(); + $this->assertCount(1, $clients); + $client1 = $clients->firstWhere('name', 'Big Company'); + $this->assertNotNull($client1); + $projects = Project::all(); + $this->assertCount(2, $projects); + $project1 = $projects->firstWhere('name', 'Project without Client'); + $this->assertNotNull($project1); + $this->assertNull($project1->client_id); + $project2 = $projects->firstWhere('name', 'Project for Big Company'); + $this->assertNotNull($project2); + $this->assertSame($client1->getKey(), $project2->client_id); + $tasks = Task::all(); + $this->assertCount(3, $tasks); + $task1 = $tasks->firstWhere('name', 'Task 1'); + $this->assertNotNull($task1); + $this->assertSame($project2->getKey(), $task1->project_id); + $task2 = $tasks->firstWhere('name', 'Task 2'); + $this->assertNotNull($task2); + $this->assertSame($project2->getKey(), $task2->project_id); + $task3 = $tasks->firstWhere('name', 'Task 3'); + $this->assertNotNull($task3); + $this->assertSame($project2->getKey(), $task3->project_id); + + return (object) [ + 'client1' => $client1, + 'project1' => $project1, + 'project2' => $project2, + 'task1' => $task1, ]; } } diff --git a/tests/Unit/Service/Import/Importer/TogglDataImporterTest.php b/tests/Unit/Service/Import/Importer/TogglDataImporterTest.php new file mode 100644 index 00000000..a57c72b5 --- /dev/null +++ b/tests/Unit/Service/Import/Importer/TogglDataImporterTest.php @@ -0,0 +1,64 @@ +path('test.zip'); + $zip = new ZipArchive(); + $zip->open($zipPath, ZipArchive::CREATE); + foreach (Storage::disk('testfiles')->allFiles($folder) as $file) { + $zip->addFile(Storage::disk('testfiles')->path($file), Str::of($file)->after($folder.'/')->value()); + } + $zip->close(); + + return $zipPath; + } + + public function test_import_of_test_file_succeeds(): void + { + // Arrange + $zipPath = $this->createTestZip('toggl_data_import_test_1'); + $organization = Organization::factory()->create(); + $importer = new TogglDataImporter(); + $importer->init($organization); + $data = file_get_contents($zipPath); + + // Act + $importer->importData($data); + + // Assert + $this->checkTestScenarioAfterImportExcludingTimeEntries(); + } + + public function test_import_of_test_file_twice_succeeds(): void + { + // Arrange + $zipPath = $this->createTestZip('toggl_data_import_test_1'); + $organization = Organization::factory()->create(); + $importer = new TogglDataImporter(); + $importer->init($organization); + $data = file_get_contents($zipPath); + $importer->importData($data); + $importer = new TogglDataImporter(); + $importer->init($organization); + + // Act + $importer->importData($data); + + // Assert + $this->checkTestScenarioAfterImportExcludingTimeEntries(); + } +} diff --git a/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php index 0e0f9ccf..28cca34b 100644 --- a/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php +++ b/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php @@ -16,13 +16,29 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract $organization = Organization::factory()->create(); $importer = new TogglTimeEntriesImporter(); $importer->init($organization); - $data = file_get_contents(storage_path('tests/toggl_import_test_1.csv')); + $data = file_get_contents(storage_path('tests/toggl_time_entries_import_test_1.csv')); // Act $importer->importData($data, []); // Assert - $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $timeEntries = TimeEntry::all(); + $this->assertCount(2, $timeEntries); + $timeEntry1 = $timeEntries->firstWhere('description', ''); + $this->assertNotNull($timeEntry1); + $this->assertSame('', $timeEntry1->description); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString()); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString()); + $this->assertFalse($timeEntry1->billable); + $this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags); + $timeEntry2 = $timeEntries->firstWhere('description', 'Working hard'); + $this->assertNotNull($timeEntry2); + $this->assertSame('Working hard', $timeEntry2->description); + $this->assertSame('2024-03-04 10:23:00', $timeEntry2->start->toDateTimeString()); + $this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString()); + $this->assertTrue($timeEntry2->billable); + $this->assertSame([], $timeEntry2->tags); } public function test_import_of_test_file_twice_succeeds(): void @@ -31,7 +47,7 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract $organization = Organization::factory()->create(); $importer = new TogglTimeEntriesImporter(); $importer->init($organization); - $data = file_get_contents(storage_path('tests/toggl_import_test_1.csv')); + $data = file_get_contents(storage_path('tests/toggl_time_entries_import_test_1.csv')); $importer->importData($data, []); $importer = new TogglTimeEntriesImporter(); $importer->init($organization); @@ -49,7 +65,7 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract $this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString()); $this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString()); $this->assertFalse($timeEntry1->billable); - $this->assertSame([$testScenario->tag1->getKey()], $timeEntry1->tags); + $this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags); $timeEntry2 = $timeEntries->firstWhere('description', 'Working hard'); $this->assertNotNull($timeEntry2); $this->assertSame('Working hard', $timeEntry2->description);