diff --git a/docs/developers/README.md b/docs/developers/README.md index 6df86be1..ba9856b5 100644 --- a/docs/developers/README.md +++ b/docs/developers/README.md @@ -12,6 +12,7 @@ Dedicated to the backend: - [Sorting the entities](/docs/developers/sorters.md) - [How to paginate the entities](/docs/developers/pagination.md) - [Working with the tickets' events](/docs/developers/tickets-events.md) +- [Importing data](/docs/developers/import-data.md) Dedicated to the frontend: diff --git a/docs/developers/import-data.md b/docs/developers/import-data.md new file mode 100644 index 00000000..83564691 --- /dev/null +++ b/docs/developers/import-data.md @@ -0,0 +1,130 @@ +# Importing data + +Bileto allows to import data from a ZIP archive. +This allows in particular to import data from GLPI. + +## The DataImporter service + +The class responsible for the import is the [`DataImporter` service](/src/Service/DataImporter/DataImporter.php). +The file is already extensively commented, so we don't say more here about it. + +## Specifications + +### The command + +For now, we only provide a command to execute in a console. This allows to simplify the implementation as we don't need to handle potential timeouts. +The command is named `app:data:import` and takes a path to a ZIP file: + +```console +$ php bin/console app:data:import /path/to/archive.zip +``` + +The command extracts the archive and check every file in it. If at least one file is invalid, it fails. + +### Checking the data + +Before importing the data into the database, the command performs some checks to verify the integrity and validity of the files. + +Constraints can be associated to fields as expressed in the next section. Most of them are already declared in the code of the Symfony entities, with assertions (c.f. `Assert\*`). Unfortunately, some assertions cannot be performed before the data is stored into the database. For instance: + +- organizations' max length can be checked easily + ```php + $organization = new Organization(); + $organization->setName($name); + $errors = $validator->validate($organization); + ``` +- but uniqueness of users' emails cannot be checked unless the data is imported into the database. + +These checks are handled in different ways: + +- uniqueness of ids: as these ids are not imported into the database, they are checked from outside of the Symfony entities. +- references to other elements: same reasoning, the ids must refer to ids from the file, but not to the ids from the database. +- uniqueness of properties (e.g. organizations' names): the command fails if the data is duplicated within the file, but will reuse the data already present in the database. For instance, if a user with the email `example@example.com` is twice in the file, the command will fail. However, if the email is in the database, the command will load it and ignore the data from the file. +- the few last checks are handled with custom logic. + +### The data + +The data is stored in a ZIP archive. It contains several files: + +- `organizations.json` an array of organizations defined as: + - id: string (unique) + - name: string (unique, not empty, max 255 chars) +- `roles.json` an array of roles defined as: + - id: string (unique) + - name: string (unique, not empty, max 50 chars) + - description: string (not empty, max 255 chars) + - type: string (must be `super`, `admin`, `agent`, or `user`, `super` must be unique) + - permissions: array of string, optional (see `Role::PERMISSIONS` for the list of valid strings) +- `users.json` an array of users defined as: + - id: string (unique) + - email: string (unique, not empty, valid email) + - locale: string, optional (must be `en_GB`, or `fr_FR`) + - name: string, optional (not empty, max 100 chars) + - ldapIdentifier: string or null, optional + - organizationId: string or null, optional (reference to an organization) + - authorizations: array of, optional: + - roleId: string (reference to a role) + - organizationId: string or null (reference to an organization) +- `contracts.json` an array of contracts defined as: + - id: string (unique) + - name: string (max 255 chars, not empty) + - startAt: datetime + - endAt: datetime (greater than startAt) + - maxHours: integer (number of minutes, greater than 0) + - notes: string, optional + - organizationId: string (reference to an organization) + - timeAccountingUnit: integer, optional (number of minutes, greater than or equal 0) + - hoursAlert: integer, optional (percent, greater than or equal 0) + - dateAlert: integer, optional (number of days, greater than or equal 0) + +It also contains a `tickets/` folder where each file corresponds to a ticket. For clarity reasons, the files can be put in sub-folders. Sub-folders have no meaning to the command, but can help to group tickets by organizations for instance. The name of the files doesn't matter, but they have to contain JSON objects: + +- id: string (unique) +- createdAt: datetime +- createdById: string (reference to a user) +- type: string, optional (must be `request`, or `incident`) +- status: string, optional (must be `new`, `in_progress`, `planned`, `pending`, `resolved`, or `closed`) +- title: string (max 255 chars, not empty) +- urgency: string, optional (must be `low`, `medium`, or `high`) +- impact: string, optional (must be `low`, `medium`, or `high`) +- priority: string, optional (must be `low`, `medium`, or `high`) +- requesterId: string (reference to a user) +- assigneeId: string or null, optional (reference to a user) +- organizationId: string (reference to an organization) +- solutionId: string or null, optional (reference to a message, included in ticket.messages) +- contractIds: array of string, optional (references to contracts) +- timeSpents: array of, optional: + - createdAt: datetime + - createdById: string (reference to a user) + - time: integer (number of minutes, greater than 0) + - realTime: integer (number of minutes, greater than 0) + - contractId: string or null, optional (reference to a contract, included in ticket.contracts) +- messages: array of, optional: + - id: string (unique) + - createdAt: datetime + - createdById: string (reference to a user) + - isConfidential: boolean, optional + - via: string, optional (must be `webapp`, or `email`) + - content: string (not empty, HTML, will be sanitized) + - messageDocuments: array of, optional: + - name: string (not empty) + - filepath: string (not empty, exists under the `documents/` folder) + +The ids are not imported, but are used to link elements between each other during the importation process. + +Datetimes must be expressed with [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339), e.g. `2024-02-13T10:00:00+02:00` (see also PHP [`DateTimeInterface::RFC3339`](https://www.php.net/manual/fr/class.datetimeinterface.php)). + +A last folder named `documents/` contains the list of documents to import. + +A file (or folder) can be missing. In this case, it is considered that there is no corresponding data to import. Be careful though as the references cannot be broken (e.g. if a ticket refers to a user id, the user must exist in the file `users.json`, even though the email already exists in the database). + +### Handling existing data + +When importing the data, some elements may already exist in the database (e.g. users have been imported from LDAP, or the command failed the first time after importing part of the data). + +We can easily detect existing data for organizations, roles and users. Indeed, these entities require the uniqueness of a field (name or email). Thus, if we detect that a corresponding entity already exists (using the unique field), we can load the entity from the database to reuse it. + +Contracts and tickets are harder to handle as there is no unique field that could help us to detect existing data. Custom logic can be used though: + +- contracts: same name, startAt, endAt and organization +- tickets: same name, createdAt and organization diff --git a/src/Command/Data/ImportCommand.php b/src/Command/Data/ImportCommand.php new file mode 100644 index 00000000..9f2605db --- /dev/null +++ b/src/Command/Data/ImportCommand.php @@ -0,0 +1,57 @@ +addArgument('file', InputArgument::REQUIRED, 'The ZIP archive file to import'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $filename = $input->getArgument('file'); + $filepathname = getcwd() . '/' . $filename; + + $output->writeln("Starting to import {$filename}…"); + + try { + $progress = $this->dataImporter->importFile($filepathname); + + foreach ($progress as $log) { + $output->write($log); + } + + $output->writeln("File {$filename} imported successfully."); + + return Command::SUCCESS; + } catch (\Exception $e) { + $output->writeln('ERROR'); + $output->writeln($e->getMessage()); + + return Command::FAILURE; + } + } +} diff --git a/src/Entity/Contract.php b/src/Entity/Contract.php index 8eae22c8..223a1e3a 100644 --- a/src/Entity/Contract.php +++ b/src/Entity/Contract.php @@ -463,4 +463,24 @@ public function getRenewed(): Contract return $contract; } + + public function getUniqueKey(): string + { + $startAt = ''; + if ($this->startAt) { + $startAt = $this->startAt->getTimestamp(); + } + + $endAt = ''; + if ($this->endAt) { + $endAt = $this->endAt->getTimestamp(); + } + + $organization = ''; + if ($this->organization) { + $organization = $this->organization->getName(); + } + + return md5("{$this->name}-{$organization}-{$startAt}-{$endAt}"); + } } diff --git a/src/Entity/Ticket.php b/src/Entity/Ticket.php index 0c6de416..da191209 100644 --- a/src/Entity/Ticket.php +++ b/src/Entity/Ticket.php @@ -121,7 +121,12 @@ class Ticket implements MonitorableEntityInterface, UidEntityInterface private ?Organization $organization = null; /** @var Collection $messages */ - #[ORM\OneToMany(mappedBy: 'ticket', targetEntity: Message::class, orphanRemoval: true)] + #[ORM\OneToMany( + mappedBy: 'ticket', + targetEntity: Message::class, + orphanRemoval: true, + cascade: ['persist'], + )] private Collection $messages; #[ORM\OneToOne(cascade: ['persist'])] @@ -132,7 +137,11 @@ class Ticket implements MonitorableEntityInterface, UidEntityInterface private Collection $contracts; /** @var Collection $timeSpents */ - #[ORM\OneToMany(mappedBy: 'ticket', targetEntity: TimeSpent::class)] + #[ORM\OneToMany( + mappedBy: 'ticket', + targetEntity: TimeSpent::class, + cascade: ['persist'], + )] private Collection $timeSpents; public function __construct() @@ -420,6 +429,16 @@ public function getMessagesWithoutConfidential(): Collection return $messages->matching($criteria); } + public function addMessage(Message $message): static + { + if (!$this->messages->contains($message)) { + $this->messages->add($message); + $message->setTicket($this); + } + + return $this; + } + public function getSolution(): ?Message { return $this->solution; @@ -519,4 +538,19 @@ public function removeTimeSpent(TimeSpent $timeSpent): static return $this; } + + public function getUniqueKey(): string + { + $createdAt = ''; + if ($this->createdAt) { + $createdAt = $this->createdAt->getTimestamp(); + } + + $organization = ''; + if ($this->organization) { + $organization = $this->organization->getName(); + } + + return md5("{$this->title}-{$organization}-{$createdAt}"); + } } diff --git a/src/Entity/User.php b/src/Entity/User.php index 7d1efe0b..d7787f84 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -97,7 +97,12 @@ class User implements private ?string $name = null; /** @var Collection $authorizations */ - #[ORM\OneToMany(mappedBy: 'holder', targetEntity: Authorization::class, orphanRemoval: true)] + #[ORM\OneToMany( + mappedBy: 'holder', + targetEntity: Authorization::class, + orphanRemoval: true, + cascade: ['persist'], + )] private Collection $authorizations; #[ORM\Column] @@ -231,6 +236,16 @@ public function getAuthorizations(): Collection return $this->authorizations; } + public function addAuthorization(Authorization $authorization): static + { + if (!$this->authorizations->contains($authorization)) { + $this->authorizations->add($authorization); + $authorization->setHolder($this); + } + + return $this; + } + public function areEventsHidden(): ?bool { return $this->hideEvents; diff --git a/src/Service/DataImporter/DataImporter.php b/src/Service/DataImporter/DataImporter.php new file mode 100644 index 00000000..585a2e4c --- /dev/null +++ b/src/Service/DataImporter/DataImporter.php @@ -0,0 +1,1262 @@ + */ + private Index $indexOrganizations; + + /** @var Index */ + private Index $indexRoles; + + /** @var Index */ + private Index $indexUsers; + + /** @var Index */ + private Index $indexContracts; + + /** @var Index */ + private Index $indexTickets; + + /** @var Index */ + private Index $indexMessages; + + /** + * @var Index> + */ + private Index $indexMessageToDocuments; + + public function __construct( + private ContractRepository $contractRepository, + private OrganizationRepository $organizationRepository, + private RoleRepository $roleRepository, + private TicketRepository $ticketRepository, + private UserRepository $userRepository, + private EntityManagerInterface $entityManager, + private ValidatorInterface $validator, + private HtmlSanitizerInterface $appMessageSanitizer, + private MessageDocumentStorage $messageDocumentStorage, + ) { + } + + /** + * @return \Generator + */ + public function importFile(string $filepathname): \Generator + { + $zipArchive = new \ZipArchive(); + $result = $zipArchive->open($filepathname, \ZipArchive::RDONLY); + + if ($result === \ZipArchive::ER_NOENT || $result === \ZipArchive::ER_OPEN) { + throw new DataImporterError('The file does not exist or cannot be read.'); + } elseif ($result !== true) { + throw new DataImporterError('The file is not a valid ZIP archive.'); + } + + $now = Time::now(); + $tmpPath = sys_get_temp_dir(); + $tmpPath = $tmpPath . "/BiletoDataImport_{$now->format('Y-m-d\TH:i:s')}"; + + yield "Extracting the archive to {$tmpPath}… "; + + $zipArchive->extractTo($tmpPath); + $zipArchive->close(); + + yield "ok\n"; + + $organizations = []; + if (file_exists("{$tmpPath}/organizations.json")) { + $organizations = FSHelper::readJson("{$tmpPath}/organizations.json"); + } else { + yield "The file organizations.json is missing, so ignoring organizations.\n"; + } + + $roles = []; + if (file_exists("{$tmpPath}/roles.json")) { + $roles = FSHelper::readJson("{$tmpPath}/roles.json"); + } else { + yield "The file roles.json is missing, so ignoring roles.\n"; + } + + $users = []; + if (file_exists("{$tmpPath}/users.json")) { + $users = FSHelper::readJson("{$tmpPath}/users.json"); + } else { + yield "The file users.json is missing, so ignoring users.\n"; + } + + $contracts = []; + if (file_exists("{$tmpPath}/contracts.json")) { + $contracts = FSHelper::readJson("{$tmpPath}/contracts.json"); + } else { + yield "The file contracts.json is missing, so ignoring contracts.\n"; + } + + $tickets = []; + foreach (FSHelper::recursiveScandir("{$tmpPath}/tickets/") as $ticketFilepath) { + $tickets[] = FSHelper::readJson($ticketFilepath); + } + + $countTickets = count($tickets); + if (count($tickets) === 0) { + yield "No ticket files found, so ignoring tickets.\n"; + } + + $documentsPath = "{$tmpPath}/documents"; + if (!is_dir($documentsPath)) { + $documentsPath = ''; + yield "The documents/ directory does not exist, not importing the documents.\n"; + } + + $error = null; + try { + yield from $this->import( + organizations: $organizations, + roles: $roles, + users: $users, + contracts: $contracts, + tickets: $tickets, + documentsPath: $documentsPath, + ); + } catch (DataImporterError $e) { + $error = $e; + yield "Errors detected!\n"; + } + + yield "Removing the extracted files at {$tmpPath}… "; + FSHelper::recursiveUnlink($tmpPath); + + if ($error) { + throw $error; + } else { + yield "ok\n"; + } + } + + /** + * @param mixed[] $organizations + * @param mixed[] $roles + * @param mixed[] $users + * @param mixed[] $contracts + * @param mixed[] $tickets + * @param string $documentsPath + * + * @return \Generator + */ + public function import( + array $organizations = [], + array $roles = [], + array $users = [], + array $contracts = [], + array $tickets = [], + string $documentsPath = '', + ): \Generator { + $this->errors = []; + + $this->documentsPath = $documentsPath; + + $this->indexOrganizations = new Index(); + $this->indexRoles = new Index(); + $this->indexUsers = new Index(); + $this->indexContracts = new Index(); + $this->indexTickets = new Index(); + $this->indexMessages = new Index(); + $this->indexMessageToDocuments = new Index(); + + yield from $this->processOrganizations($organizations); + yield from $this->processRoles($roles); + yield from $this->processUsers($users); + yield from $this->processContracts($contracts); + yield from $this->processTickets($tickets); + + if ($this->errors) { + throw new DataImporterError(implode("\n", $this->errors)); + } + + yield from $this->saveEntities($this->indexOrganizations->list()); + yield from $this->saveEntities($this->indexRoles->list()); + yield from $this->saveEntities($this->indexUsers->list()); + yield from $this->saveEntities($this->indexContracts->list()); + yield from $this->saveEntities($this->indexTickets->list()); + yield from $this->saveMessageDocuments(); + } + + /** + * @phpstan-impure + * + * @param mixed[] $json + * + * @return \Generator + */ + private function processOrganizations(array $json): \Generator + { + yield 'Processing organizations… '; + + $requiredFields = [ + 'id', + 'name' + ]; + + foreach ($json as $jsonOrganization) { + // Check the structure of the organization + $error = self::checkStructure($jsonOrganization, required: $requiredFields); + if ($error) { + $this->errors[] = "Organizations file contains invalid data: {$error}"; + continue; + } + + $id = strval($jsonOrganization['id']); + $name = strval($jsonOrganization['name']); + + // Build the organization + $organization = new Organization(); + $organization->setName($name); + + // Add the organization to the index + try { + $this->indexOrganizations->add($id, $organization, uniqueKey: $name); + } catch (IndexError $e) { + $this->errors[] = "Organization {$id} error: {$e->getMessage()}"; + } + } + + // Load existing values from the database and update the indexes + $existingOrganizations = $this->organizationRepository->findAll(); + foreach ($existingOrganizations as $organization) { + $name = $organization->getName(); + $this->indexOrganizations->refreshUnique($organization, uniqueKey: $name); + } + + // Validate the organizations + foreach ($this->indexOrganizations->list() as $id => $organization) { + $error = $this->validate($organization); + if ($error) { + $this->errors[] = "Organization {$id} error: {$error}"; + } + } + + yield "ok\n"; + } + + /** + * @phpstan-impure + * + * @param mixed[] $json + * + * @return \Generator + */ + private function processRoles(array $json): \Generator + { + yield 'Processing roles… '; + + $requiredFields = [ + 'id', + 'name', + 'description', + 'type', + ]; + + $superUniqueKey = '@super'; + + foreach ($json as $jsonRole) { + // Check the structure of the role + $error = self::checkStructure($jsonRole, required: $requiredFields); + if ($error) { + $this->errors[] = "Roles file contains invalid data: {$error}"; + continue; + } + + $id = strval($jsonRole['id']); + $name = strval($jsonRole['name']); + $description = strval($jsonRole['description']); + $type = strval($jsonRole['type']); + + $permissions = []; + if (isset($jsonRole['permissions'])) { + $permissions = $jsonRole['permissions']; + } + + // Build the role + $role = new Role(); + $role->setName($name); + $role->setDescription($description); + $role->setType($type); + + if (is_array($permissions)) { + $role->setPermissions($permissions); + } else { + $this->errors[] = "Role {$id} error: permissions: not an array."; + } + + // Add the role to the index + try { + $this->indexRoles->add($id, $role, uniqueKey: $name); + + if ($type === 'super') { + $this->indexRoles->addUniqueAlias($id, uniqueKey: $superUniqueKey); + } + } catch (IndexError $e) { + $this->errors[] = "Role {$id} error: {$e->getMessage()}"; + } + } + + // Load existing values from the database and update the indexes + $existingRoles = $this->roleRepository->findAll(); + foreach ($existingRoles as $role) { + $name = $role->getName(); + $this->indexRoles->refreshUnique($role, uniqueKey: $name); + + if ($role->getType() === 'super') { + $this->indexRoles->refreshUnique($role, uniqueKey: $superUniqueKey); + } + } + + // Validate the roles + foreach ($this->indexRoles->list() as $id => $role) { + $error = $this->validate($role); + if ($error) { + $this->errors[] = "Role {$id} error: {$error}"; + } + } + + yield "ok\n"; + } + + /** + * @phpstan-impure + * + * @param mixed[] $json + * + * @return \Generator + */ + private function processUsers(array $json): \Generator + { + yield 'Processing users… '; + + $requiredFields = [ + 'id', + 'email', + ]; + + foreach ($json as $jsonUser) { + // Check the structure of the role + $error = self::checkStructure($jsonUser, required: $requiredFields); + if ($error) { + $this->errors[] = "Users file contains invalid data: {$error}"; + continue; + } + + $id = strval($jsonUser['id']); + $email = strval($jsonUser['email']); + $name = null; + if (isset($jsonUser['name'])) { + $name = strval($jsonUser['name']); + } + $locale = null; + if (isset($jsonUser['locale'])) { + $locale = strval($jsonUser['locale']); + } + $ldapIdentifier = null; + if (isset($jsonUser['ldapIdentifier'])) { + $ldapIdentifier = strval($jsonUser['ldapIdentifier']); + } + $organizationId = null; + if (isset($jsonUser['organizationId'])) { + $organizationId = strval($jsonUser['organizationId']); + } + + $authorizations = []; + if (isset($jsonUser['authorizations'])) { + $authorizations = $jsonUser['authorizations']; + } + + // Build the user + $user = new User(); + $user->setEmail($email); + if ($name) { + $user->setName($name); + } + if ($locale) { + $user->setLocale($locale); + } + if ($ldapIdentifier) { + $user->setLdapIdentifier($ldapIdentifier); + } + + // Check and set references + if ($organizationId) { + $organization = $this->indexOrganizations->get($organizationId); + + if ($organization) { + $user->setOrganization($organization); + } else { + $this->errors[] = "User {$id} error: references an unknown organization {$organizationId}."; + } + } + + if (is_array($authorizations)) { + $this->processUserAuthorizations($id, $user, $authorizations); + } else { + $this->errors[] = "User {$id} error: authorizations: not an array."; + } + + // Add the user to the index + try { + $this->indexUsers->add($id, $user, uniqueKey: $email); + } catch (IndexError $e) { + $this->errors[] = "User {$id} error: {$e->getMessage()}"; + } + } + + // Load existing values from the database and update the indexes + $existingUsers = $this->userRepository->findAll(); + foreach ($existingUsers as $user) { + $email = $user->getEmail(); + $this->indexUsers->refreshUnique($user, uniqueKey: $email); + } + + // Validate the users + foreach ($this->indexUsers->list() as $id => $user) { + $error = $this->validate($user); + if ($error) { + $this->errors[] = "User {$id} error: {$error}"; + } + } + + yield "ok\n"; + } + + /** + * @phpstan-impure + * + * @param mixed[] $json + */ + private function processUserAuthorizations(string $userId, User $user, array $json): void + { + $requiredFields = [ + 'roleId', + ]; + + foreach ($json as $jsonAuthorization) { + // Check the structure of the authorization + $error = self::checkStructure($jsonAuthorization, required: $requiredFields); + if ($error) { + $this->errors[] = "User {$userId} error: authorizations: {$error}"; + continue; + } + + $roleId = strval($jsonAuthorization['roleId']); + + $organizationId = null; + if (isset($jsonAuthorization['organizationId'])) { + $organizationId = strval($jsonAuthorization['organizationId']); + } + + // Build the authorization + $authorization = new Authorization(); + + // Check and set references + $role = $this->indexRoles->get($roleId); + + if ($role) { + $authorization->setRole($role); + } else { + $this->errors[] = "User {$userId} error: authorizations: " + . "references an unknown role {$roleId}"; + } + + if ($organizationId) { + $organization = $this->indexOrganizations->get($organizationId); + + if ($organization) { + $authorization->setOrganization($organization); + } else { + $this->errors[] = "User {$userId} error: authorizations: " + . "references an unknown organization {$organizationId}"; + } + } + + // Add the authorization to the user + $user->addAuthorization($authorization); + } + } + + /** + * @phpstan-impure + * + * @param mixed[] $json + * + * @return \Generator + */ + private function processContracts(array $json): \Generator + { + yield 'Processing contracts… '; + + $requiredFields = [ + 'id', + 'name', + 'startAt', + 'endAt', + 'maxHours', + 'organizationId', + ]; + + foreach ($json as $jsonContract) { + // Check the structure of the contract + $error = self::checkStructure($jsonContract, required: $requiredFields); + if ($error) { + $this->errors[] = "Contracts file contains invalid data: {$error}"; + continue; + } + + $id = strval($jsonContract['id']); + $name = strval($jsonContract['name']); + $startAt = self::parseDatetime($jsonContract['startAt']); + $endAt = self::parseDatetime($jsonContract['endAt']); + $maxHours = intval($jsonContract['maxHours']); + $organizationId = strval($jsonContract['organizationId']); + + $notes = null; + if (isset($jsonContract['notes'])) { + $notes = strval($jsonContract['notes']); + } + $timeAccountingUnit = null; + if (isset($jsonContract['timeAccountingUnit'])) { + $timeAccountingUnit = intval($jsonContract['timeAccountingUnit']); + } + $hoursAlert = null; + if (isset($jsonContract['hoursAlert'])) { + $hoursAlert = intval($jsonContract['hoursAlert']); + } + $dateAlert = null; + if (isset($jsonContract['dateAlert'])) { + $dateAlert = intval($jsonContract['dateAlert']); + } + + // Build the contract + $contract = new Contract(); + $contract->setName($name); + $contract->setMaxHours($maxHours); + + if ($startAt !== null) { + $contract->setStartAt($startAt); + } else { + $this->errors[] = "Contract {$id} error: invalid startAt datetime"; + } + + if ($endAt !== null) { + $contract->setEndAt($endAt); + } else { + $this->errors[] = "Contract {$id} error: invalid endAt datetime"; + } + + if ($notes) { + $contract->setNotes($notes); + } + + if ($timeAccountingUnit) { + $contract->setTimeAccountingUnit($timeAccountingUnit); + } + + if ($hoursAlert) { + $contract->setHoursAlert($hoursAlert); + } + + if ($dateAlert) { + $contract->setDateAlert($dateAlert); + } + + // Check and set references + $organization = $this->indexOrganizations->get($organizationId); + + if ($organization) { + $contract->setOrganization($organization); + } else { + $this->errors[] = "Contract {$id} error: references an unknown organization {$organizationId}."; + } + + // Add the contract to the index + try { + $uniqueKey = $contract->getUniqueKey(); + $this->indexContracts->add($id, $contract, uniqueKey: $uniqueKey); + } catch (IndexError $e) { + $this->errors[] = "Contract {$id} error: {$e->getMessage()}"; + } + } + + // Load existing values from the database and update the indexes + $existingContracts = $this->contractRepository->findAll(); + foreach ($existingContracts as $contract) { + $uniqueKey = $contract->getUniqueKey(); + $this->indexContracts->refreshUnique($contract, uniqueKey: $uniqueKey); + } + + // Validate the contracts + foreach ($this->indexContracts->list() as $id => $contract) { + $error = $this->validate($contract); + if ($error) { + $this->errors[] = "Contract {$id} error: {$error}"; + } + } + + yield "ok\n"; + } + + /** + * @phpstan-impure + * + * @param mixed[] $json + * + * @return \Generator + */ + private function processTickets(array $json): \Generator + { + yield 'Processing tickets… '; + + $requiredFields = [ + 'id', + 'createdAt', + 'createdById', + 'title', + 'requesterId', + 'organizationId', + ]; + + foreach ($json as $jsonTicket) { + // Check the structure of the ticket + $error = self::checkStructure($jsonTicket, required: $requiredFields); + if ($error) { + $this->errors[] = "Tickets file contains invalid data: {$error}"; + continue; + } + + $id = strval($jsonTicket['id']); + $createdAt = self::parseDatetime($jsonTicket['createdAt']); + $createdById = strval($jsonTicket['createdById']); + $title = strval($jsonTicket['title']); + + $type = null; + if (isset($jsonTicket['type'])) { + $type = strval($jsonTicket['type']); + } + + $status = null; + if (isset($jsonTicket['status'])) { + $status = strval($jsonTicket['status']); + } + + $urgency = null; + if (isset($jsonTicket['urgency'])) { + $urgency = strval($jsonTicket['urgency']); + } + + $impact = null; + if (isset($jsonTicket['impact'])) { + $impact = strval($jsonTicket['impact']); + } + + $priority = null; + if (isset($jsonTicket['priority'])) { + $priority = strval($jsonTicket['priority']); + } + + $requesterId = strval($jsonTicket['requesterId']); + + $assigneeId = null; + if (isset($jsonTicket['assigneeId'])) { + $assigneeId = strval($jsonTicket['assigneeId']); + } + + $organizationId = strval($jsonTicket['organizationId']); + + $solutionId = null; + if (isset($jsonTicket['solutionId'])) { + $solutionId = strval($jsonTicket['solutionId']); + } + + $contractIds = []; + if (isset($jsonTicket['contractIds'])) { + $contractIds = $jsonTicket['contractIds']; + } + + $timeSpents = []; + if (isset($jsonTicket['timeSpents'])) { + $timeSpents = $jsonTicket['timeSpents']; + } + + $messages = []; + if (isset($jsonTicket['messages'])) { + $messages = $jsonTicket['messages']; + } + + // Build the ticket + $ticket = new Ticket(); + $ticket->setCreatedAt($createdAt); + $ticket->setTitle($title); + + if ($type !== null) { + $ticket->setType($type); + } + + if ($status !== null) { + $ticket->setStatus($status); + } + + if ($urgency !== null) { + $ticket->setUrgency($urgency); + } + + if ($impact !== null) { + $ticket->setImpact($impact); + } + + if ($priority !== null) { + $ticket->setPriority($priority); + } + + // Check and set references + $createdBy = $this->indexUsers->get($createdById); + + if ($createdBy) { + $ticket->setCreatedBy($createdBy); + $ticket->setUpdatedBy($createdBy); + } else { + $this->errors[] = "Ticket {$id} error: references an unknown createdBy user {$createdById}."; + } + + $organization = $this->indexOrganizations->get($organizationId); + + if ($organization) { + $ticket->setOrganization($organization); + } else { + $this->errors[] = "Ticket {$id} error: references an unknown organization {$organizationId}."; + } + + $requester = $this->indexUsers->get($requesterId); + + if ($requester) { + $ticket->setRequester($requester); + } else { + $this->errors[] = "Ticket {$id} error: references an unknown requester user {$requesterId}."; + } + + if ($assigneeId !== null) { + $assignee = $this->indexUsers->get($assigneeId); + + if ($assignee) { + $ticket->setAssignee($assignee); + } else { + $this->errors[] = "Ticket {$id} error: references an unknown assignee user {$assigneeId}."; + } + } + + if (is_array($contractIds)) { + foreach ($contractIds as $contractId) { + $contract = $this->indexContracts->get($contractId); + + if ($contract) { + $ticket->addContract($contract); + } else { + $this->errors[] = "Ticket {$id} error: references an unknown contract {$contractId}."; + } + } + } else { + $this->errors[] = "Ticket {$id} error: contractIds: not an array."; + } + + if (is_array($timeSpents)) { + $this->processTicketTimeSpents($id, $ticket, $timeSpents); + } else { + $this->errors[] = "Ticket {$id} error: timeSpents: not an array."; + } + + if (is_array($messages)) { + $this->processTicketMessages($id, $ticket, $messages, $solutionId); + } else { + $this->errors[] = "Ticket {$id} error: messages: not an array."; + } + + if ($solutionId !== null && $ticket->getSolution() === null) { + $this->errors[] = "Ticket {$id} error: references an unknown solution {$solutionId}."; + } + + // Add the ticket to the index + try { + $uniqueKey = $ticket->getUniqueKey(); + $this->indexTickets->add($id, $ticket, uniqueKey: $uniqueKey); + } catch (IndexError $e) { + $this->errors[] = "Ticket {$id} error: {$e->getMessage()}"; + } + } + + // Load existing values from the database and update the indexes + $existingTickets = $this->ticketRepository->findAll(); + foreach ($existingTickets as $ticket) { + $uniqueKey = $ticket->getUniqueKey(); + $this->indexTickets->refreshUnique($ticket, uniqueKey: $uniqueKey); + } + + // Validate the tickets + foreach ($this->indexTickets->list() as $id => $ticket) { + $error = $this->validate($ticket); + if ($error) { + $this->errors[] = "Ticket {$id} error: {$error}"; + } + } + + yield "ok\n"; + } + + /** + * @phpstan-impure + * + * @param mixed[] $json + */ + private function processTicketTimeSpents(string $ticketId, Ticket $ticket, array $json): void + { + $requiredFields = [ + 'createdAt', + 'createdById', + 'time', + 'realTime', + ]; + + foreach ($json as $jsonTimeSpent) { + // Check the structure of the time spent + $error = self::checkStructure($jsonTimeSpent, required: $requiredFields); + if ($error) { + $this->errors[] = "Ticket {$ticketId} error: timeSpents: {$error}"; + continue; + } + + $createdAt = self::parseDatetime($jsonTimeSpent['createdAt']); + $createdById = strval($jsonTimeSpent['createdById']); + $time = intval($jsonTimeSpent['time']); + $realTime = intval($jsonTimeSpent['realTime']); + + $contractId = null; + if (isset($jsonTimeSpent['contractId'])) { + $contractId = strval($jsonTimeSpent['contractId']); + } + + // Build the time spent + $timeSpent = new TimeSpent(); + $timeSpent->setCreatedAt($createdAt); + $timeSpent->setTime($time); + $timeSpent->setRealTime($realTime); + + // Check and set references + $createdBy = $this->indexUsers->get($createdById); + + if ($createdBy) { + $timeSpent->setCreatedBy($createdBy); + $timeSpent->setUpdatedBy($createdBy); + } else { + $this->errors[] = "Ticket {$ticketId} error: timeSpents: " + . "references an unknown user {$createdById}"; + } + + if ($contractId !== null) { + $contract = $this->indexContracts->get($contractId); + + if ($contract) { + $timeSpent->setContract($contract); + } else { + $this->errors[] = "Ticket {$ticketId} error: timeSpents: " + . "references an unknown contract {$contractId}"; + } + } + + // Validate the time spent + $error = $this->validate($timeSpent); + if ($error) { + $this->errors[] = "Ticket {$ticketId} error: timeSpents: {$error}"; + } + + // Add the time spent to the ticket + $ticket->addTimeSpent($timeSpent); + } + } + + /** + * @phpstan-impure + * + * @param mixed[] $json + */ + private function processTicketMessages(string $ticketId, Ticket $ticket, array $json, ?string $solutionId): void + { + $requiredFields = [ + 'id', + 'createdAt', + 'createdById', + 'content', + ]; + + foreach ($json as $jsonMessage) { + // Check the structure of the message + $error = self::checkStructure($jsonMessage, required: $requiredFields); + if ($error) { + $this->errors[] = "Ticket {$ticketId} error: messages: {$error}"; + continue; + } + + $id = strval($jsonMessage['id']); + $createdAt = self::parseDatetime($jsonMessage['createdAt']); + $createdById = strval($jsonMessage['createdById']); + + $content = strval($jsonMessage['content']); + $content = $this->appMessageSanitizer->sanitize($content); + + $isConfidential = null; + if (isset($jsonMessage['isConfidential'])) { + $isConfidential = boolval($jsonMessage['isConfidential']); + } + + $via = null; + if (isset($jsonMessage['via'])) { + $via = strval($jsonMessage['via']); + } + + $messageDocuments = []; + if (isset($jsonMessage['messageDocuments'])) { + $messageDocuments = $jsonMessage['messageDocuments']; + } + + // Build the message + $message = new Message(); + $message->setCreatedAt($createdAt); + $message->setContent($content); + if ($isConfidential !== null) { + $message->setIsConfidential($isConfidential); + } + if ($via !== null) { + $message->setVia($via); + } + + if ($solutionId === $id) { + $ticket->setSolution($message); + } + + // Check and set references + $createdBy = $this->indexUsers->get($createdById); + + if ($createdBy) { + $message->setCreatedBy($createdBy); + $message->setUpdatedBy($createdBy); + } else { + $this->errors[] = "Ticket {$ticketId} error: message {$id}: " + . "references an unknown user {$createdById}"; + } + + if ($this->documentsPath !== '') { + // Process (and import) the message documents only if the + // documentsPath is set. + if (is_array($messageDocuments)) { + $this->processMessageDocuments($id, $messageDocuments); + } else { + $this->errors[] = "Ticket {$ticketId} error: message {$id}: " + . "messageDocuments: not an array."; + } + } + + // Validate the message + $error = $this->validate($message); + if ($error) { + $this->errors[] = "Ticket {$ticketId} error: message {$id}: {$error}"; + } + + // Add the message to the index and to the ticket + try { + $this->indexMessages->add($id, $message); + } catch (IndexError $e) { + $this->errors[] = "Ticket {$ticketId} error: message {$id}: {$e->getMessage()}"; + } + + $ticket->addMessage($message); + } + } + + /** + * @phpstan-impure + * + * @param mixed[] $json + */ + private function processMessageDocuments(string $messageId, array $json): void + { + if ($this->documentsPath === '') { + return; + } + + $requiredFields = [ + 'name', + 'filepath', + ]; + + $messageDocuments = []; + + foreach ($json as $jsonMessageDocument) { + // Check the structure of the messageDocument + $error = self::checkStructure($jsonMessageDocument, required: $requiredFields); + if ($error) { + $this->errors[] = "Message {$messageId} error: document: {$error}"; + continue; + } + + $name = strval($jsonMessageDocument['name']); + $filepath = strval($jsonMessageDocument['filepath']); + + $fullFilepath = "{$this->documentsPath}/{$filepath}"; + if (!file_exists($fullFilepath)) { + $this->errors[] = "Message {$messageId} error: document {$name}: " + . "references an unknown document {$filepath}"; + } + + $messageDocuments[] = [ + 'name' => $name, + 'filepath' => $filepath, + ]; + } + + // Add the message documents to the index + try { + $this->indexMessageToDocuments->add($messageId, $messageDocuments); + } catch (IndexError $e) { + // Ignore on purpose as this same error will be catch when adding + // the message to its own index. + } + } + + /** + * Check that the specified data is an array and contains the given + * required fields. + * + * @param string[] $required + */ + private static function checkStructure(mixed $data, array $required = []): string + { + if (!is_array($data)) { + return 'not an array'; + } + + foreach ($required as $requiredField) { + if (!isset($data[$requiredField])) { + return "missing required field {$requiredField}"; + } + } + + return ''; + } + + private static function parseDatetime(mixed $value): ?\DateTimeImmutable + { + $value = strval($value); + + $datetime = \DateTimeImmutable::createFromFormat(\DateTimeInterface::RFC3339, $value); + + if ($datetime === false) { + return null; + } + + return $datetime; + } + + /** + * @template T of UidEntityInterface + * + * @param T[] $entities + * + * @return \Generator + */ + private function saveEntities(array $entities): \Generator + { + if (empty($entities)) { + return; + } + + $entities = array_values($entities); + $entityClass = $entities[0]::class; + $repository = $this->entityManager->getRepository($entityClass); + + $entitiesToSave = []; + foreach ($entities as $entity) { + if ($entity->getUid() === null) { + $entitiesToSave[] = $entity; + } + } + + if (!is_callable([$repository, 'save'])) { + throw new \BadMethodCallException('The method save() cannot be called on ' . $repository::class); + } + + $count = count($entitiesToSave); + yield "Saving {$count} {$entityClass}… "; + + $repository->save($entitiesToSave, true); + + yield "ok\n"; + } + + /** + * @return \Generator + */ + private function saveMessageDocuments(): \Generator + { + if ($this->documentsPath === '') { + return; + } + + $messageDocuments = []; + + $count = $this->indexMessageToDocuments->count(); + yield "Processing documents of {$count} messages… "; + + $hasErrors = false; + + foreach ($this->indexMessageToDocuments->list() as $messageId => $jsonMessageDocuments) { + $message = $this->indexMessages->get($messageId); + + if ($message->getUid() === null) { + // Ignore this Message as it is not saved in database. It + // happens when the ticket is duplicated in the database: the + // related messages are not saved as they should already exist + // in the database as well. The index of messages is not + // updated as for the other entities in order to improve the + // performance. + continue; + } + + foreach ($jsonMessageDocuments as $jsonMessageDocument) { + $filename = $jsonMessageDocument['name']; + $filepath = "{$this->documentsPath}/{$jsonMessageDocument['filepath']}"; + $file = new File($filepath, false); + + try { + $messageDocument = $this->messageDocumentStorage->store($file, $filename); + } catch (MessageDocumentStorageError $e) { + $hasErrors = true; + yield "Cannot store the file {$filename}: {$e->getMessage()}\n"; + continue; + } + + $messageDocument->setCreatedBy($message->getCreatedBy()); + $messageDocument->setUpdatedBy($message->getCreatedBy()); + $messageDocument->setMessage($message); + $messageDocuments[] = $messageDocument; + } + } + + if ($hasErrors) { + yield "Errors occurred when storing files, so stop there.\n"; + return; + } + + yield "ok\n"; + + yield from $this->saveEntities($messageDocuments); + } + + private function validate(object $object): string + { + $rawErrors = $this->validator->validate($object); + if (count($rawErrors) === 0) { + return ''; + } + + $formattedErrors = ConstraintErrorsFormatter::format($rawErrors); + return implode(' ', $formattedErrors); + } +} diff --git a/src/Service/DataImporter/DataImporterError.php b/src/Service/DataImporter/DataImporterError.php new file mode 100644 index 00000000..1ba5044c --- /dev/null +++ b/src/Service/DataImporter/DataImporterError.php @@ -0,0 +1,11 @@ + */ + private array $index = []; + + /** @var array */ + private array $uniqueIndex = []; + + /** + * @return array + */ + public function list(): array + { + return $this->index; + } + + /** + * @return ?T + */ + public function get(string $id): mixed + { + return $this->index[$id] ?? null; + } + + public function has(string $id): bool + { + return isset($this->index[$id]); + } + + public function count(): int + { + return count($this->index); + } + + /** + * @param T $object + */ + public function add(string $id, mixed $object, ?string $uniqueKey = null): void + { + if (isset($this->index[$id])) { + throw new IndexError('id is duplicated'); + } + + $this->index[$id] = $object; + + if ($uniqueKey) { + $this->addUniqueAlias($id, $uniqueKey); + } + } + + public function addUniqueAlias(string $id, string $uniqueKey): void + { + if (isset($this->uniqueIndex[$uniqueKey])) { + $duplicatedId = $this->uniqueIndex[$uniqueKey]; + throw new IndexError("duplicates id {$duplicatedId}"); + } + + $this->uniqueIndex[$uniqueKey] = $id; + } + + /** + * @param T $object + */ + public function refreshUnique(mixed $object, string $uniqueKey): void + { + if (isset($this->uniqueIndex[$uniqueKey])) { + $id = $this->uniqueIndex[$uniqueKey]; + $this->index[$id] = $object; + } + } +} diff --git a/src/Service/DataImporter/IndexError.php b/src/Service/DataImporter/IndexError.php new file mode 100644 index 00000000..b9b4f35d --- /dev/null +++ b/src/Service/DataImporter/IndexError.php @@ -0,0 +1,11 @@ +get(DataImporter::class); + $this->dataImporter = $dataImporter; + } + + /** + * @param \Generator $generator + * + * @return string[] + */ + private function processGenerator(\Generator $generator): array + { + return iterator_to_array($generator); + } + + public function testImportOrganizations(): void + { + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + + $this->processGenerator($this->dataImporter->import(organizations: $organizations)); + + $this->assertSame(1, OrganizationFactory::count()); + $organization = OrganizationFactory::last(); + $this->assertSame('Foo', $organization->getName()); + } + + public function testImportOrganizationsKeepsExistingOrganizationsInDatabase(): void + { + $existingOrganization = OrganizationFactory::createOne([ + 'name' => 'Foo', + ]); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + + $this->processGenerator($this->dataImporter->import(organizations: $organizations)); + + $this->assertSame(1, OrganizationFactory::count()); + $organization = OrganizationFactory::last(); + $this->assertSame($existingOrganization->getUid(), $organization->getUid()); + } + + public function testImportOrganizationsFailsIfIdIsDuplicatedInFile(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Organization 1 error: id is duplicated'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + [ + 'id' => '1', + 'name' => 'Bar', + ], + ]; + + $this->processGenerator($this->dataImporter->import(organizations: $organizations)); + + $this->assertSame(0, OrganizationFactory::count()); + } + + public function testImportOrganizationsFailsIfNameIsDuplicatedInFile(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Organization 2 error: duplicates id 1'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + [ + 'id' => '2', + 'name' => 'Foo', + ], + ]; + + $this->processGenerator($this->dataImporter->import(organizations: $organizations)); + + $this->assertSame(0, OrganizationFactory::count()); + } + + public function testImportOrganizationsFailsIfOrganizationIsInvalid(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Organization 1 error: Enter a name.'); + + $organizations = [ + [ + 'id' => '1', + 'name' => '', + ], + ]; + + $this->processGenerator($this->dataImporter->import(organizations: $organizations)); + + $this->assertSame(0, OrganizationFactory::count()); + } + + public function testImportRoles(): void + { + $roles = [ + [ + 'id' => '1', + 'name' => 'Foo', + 'description' => 'Foo description', + 'type' => 'user', + 'permissions' => [ + 'orga:create:tickets', + 'orga:see', + 'admin:see', + ], + ], + ]; + + $this->processGenerator($this->dataImporter->import(roles: $roles)); + + $this->assertSame(1, RoleFactory::count()); + $role = RoleFactory::last(); + $this->assertSame('Foo', $role->getName()); + $this->assertSame('Foo description', $role->getDescription()); + $this->assertSame('user', $role->getType()); + $this->assertSame([ + 'orga:create:tickets', + 'orga:see', + ], $role->getPermissions()); + } + + public function testImportRolesKeepsExistingRolesInDatabase(): void + { + $exisitingRole = RoleFactory::createOne([ + 'name' => 'Foo', + ]); + + $roles = [ + [ + 'id' => '1', + 'name' => 'Foo', + 'description' => 'Foo description', + 'type' => 'user', + 'permissions' => [ + 'orga:create:tickets', + 'orga:see', + ], + ], + ]; + + $this->processGenerator($this->dataImporter->import(roles: $roles)); + + $this->assertSame(1, RoleFactory::count()); + $role = RoleFactory::last(); + $this->assertSame($exisitingRole->getUid(), $role->getUid()); + } + + public function testImportRolesKeepsSuperRoleInDatabase(): void + { + /** @var \App\Repository\RoleRepository */ + $roleRepository = RoleFactory::repository(); + $superRole = $roleRepository->findOrCreateSuperRole(); + + $roles = [ + [ + 'id' => '1', + 'name' => 'Foo', + 'description' => 'Foo description', + 'type' => 'super', + 'permissions' => ['admin:*'], + ], + ]; + + $this->processGenerator($this->dataImporter->import(roles: $roles)); + + $this->assertSame(1, RoleFactory::count()); + $role = RoleFactory::last(); + $this->assertSame($superRole->getUid(), $role->getUid()); + } + + public function testImportRolesFailsIfIdIsDuplicatedInFile(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Role 1 error: id is duplicated'); + + $roles = [ + [ + 'id' => '1', + 'name' => 'Foo', + 'description' => 'Foo description', + 'type' => 'user', + ], + [ + 'id' => '1', + 'name' => 'Bar', + 'description' => 'Bar description', + 'type' => 'user', + ], + ]; + + $this->processGenerator($this->dataImporter->import(roles: $roles)); + + $this->assertSame(0, RoleFactory::count()); + } + + public function testImportRolesFailsIfNameIsDuplicatedInFile(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Role 2 error: duplicates id 1'); + + $roles = [ + [ + 'id' => '1', + 'name' => 'Foo', + 'description' => 'Foo description', + 'type' => 'user', + ], + [ + 'id' => '2', + 'name' => 'Foo', + 'description' => 'Foo description', + 'type' => 'agent', + ], + ]; + + $this->processGenerator($this->dataImporter->import(roles: $roles)); + + $this->assertSame(0, RoleFactory::count()); + } + + public function testImportRolesFailsIfTypeSuperIsDuplicatedInFile(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Role 2 error: duplicates id 1'); + + $roles = [ + [ + 'id' => '1', + 'name' => 'Foo', + 'description' => 'Foo description', + 'type' => 'super', + ], + [ + 'id' => '2', + 'name' => 'Bar', + 'description' => 'Bar description', + 'type' => 'super', + ], + ]; + + $this->processGenerator($this->dataImporter->import(roles: $roles)); + + $this->assertSame(0, RoleFactory::count()); + } + + public function testImportRolesFailsIfRoleIsInvalid(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Role 1 error: The value you selected is not a valid choice.'); + + $roles = [ + [ + 'id' => '1', + 'name' => 'Foo', + 'description' => 'Foo description', + 'type' => 'foo', + ], + ]; + + $this->processGenerator($this->dataImporter->import(roles: $roles)); + + $this->assertSame(0, RoleFactory::count()); + } + + public function testImportUsers(): void + { + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $roles = [ + [ + 'id' => '1', + 'name' => 'Foo', + 'description' => 'Foo description', + 'type' => 'user', + ], + ]; + $users = [ + [ + 'id' => '1', + 'name' => 'Alix Hambourg', + 'email' => 'alix@example.com', + 'locale' => 'fr_FR', + 'ldapIdentifier' => 'alix.hambourg', + 'organizationId' => '1', + 'authorizations' => [ + [ + 'roleId' => '1', + 'organizationId' => '1', + ], + ], + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + roles: $roles, + users: $users, + )); + + $this->assertSame(1, UserFactory::count()); + $user = UserFactory::last(); + $user->refresh(); + $this->assertSame('Alix Hambourg', $user->getName()); + $this->assertSame('alix@example.com', $user->getEmail()); + $this->assertSame('fr_FR', $user->getLocale()); + $this->assertSame('alix.hambourg', $user->getLdapIdentifier()); + $organization = $user->getOrganization(); + $this->assertNotNull($organization); + $this->assertSame('Foo', $organization->getName()); + $authorizations = $user->getAuthorizations(); + $this->assertSame(1, count($authorizations)); + $authOrganization = $authorizations[0]->getOrganization(); + $authRole = $authorizations[0]->getRole(); + $this->assertNotNull($authOrganization); + $this->assertSame('Foo', $authOrganization->getName()); + $this->assertSame('Foo', $authRole->getName()); + } + + public function testImportUsersKeepsExistingUsersInDatabase(): void + { + $existingUser = UserFactory::createOne([ + 'email' => 'alix@example.com', + ]); + + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + + $this->processGenerator($this->dataImporter->import(users: $users)); + + $this->assertSame(1, UserFactory::count()); + $user = UserFactory::last(); + $this->assertSame($existingUser->getUid(), $user->getUid()); + } + + public function testImportUsersFailsIfIdIsDuplicatedInFile(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('User 1 error: id is duplicated'); + + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + [ + 'id' => '1', + 'email' => 'benedict@example.com', + ], + ]; + + $this->processGenerator($this->dataImporter->import(users: $users)); + + $this->assertSame(0, UserFactory::count()); + } + + public function testImportUsersFailsIfEmailIsDuplicatedInFile(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('User 2 error: duplicates id 1'); + + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + [ + 'id' => '2', + 'email' => 'alix@example.com', + ], + ]; + + $this->processGenerator($this->dataImporter->import(users: $users)); + + $this->assertSame(0, UserFactory::count()); + } + + public function testImportUsersFailsIfUserIsInvalid(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('User 1 error: Enter a valid email address'); + + $users = [ + [ + 'id' => '1', + 'email' => 'not an email', + ], + ]; + + $this->processGenerator($this->dataImporter->import(users: $users)); + + $this->assertSame(0, UserFactory::count()); + } + + public function testImportUsersFailsIfOrganizationRefersToUnknownId(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('User 1 error: references an unknown organization 1.'); + + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + 'organizationId' => '1', + ], + ]; + + $this->processGenerator($this->dataImporter->import(users: $users)); + + $this->assertSame(0, UserFactory::count()); + } + + public function testImportUsersFailsIfAuthorizationRefersToUnknownRole(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('User 1 error: authorizations: references an unknown role 1'); + + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + 'authorizations' => [ + [ + 'roleId' => '1', + 'organizationId' => null, + ], + ], + ], + ]; + + $this->processGenerator($this->dataImporter->import(users: $users)); + + $this->assertSame(0, UserFactory::count()); + } + + public function testImportUsersFailsIfAuthorizationRefersToUnknownOrganization(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('User 1 error: authorizations: references an unknown organization 1'); + + $roles = [ + [ + 'id' => '1', + 'name' => 'Foo', + 'description' => 'Foo description', + 'type' => 'user', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + 'authorizations' => [ + [ + 'roleId' => '1', + 'organizationId' => '1', + ], + ], + ], + ]; + + $this->processGenerator($this->dataImporter->import( + roles: $roles, + users: $users, + )); + + $this->assertSame(0, UserFactory::count()); + } + + public function testImportContracts(): void + { + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $contracts = [ + [ + 'id' => '1', + 'name' => 'My contract', + 'startAt' => '2024-01-01T00:00:00+00:00', + 'endAt' => '2024-12-31T23:59:59+00:00', + 'maxHours' => 42, + 'notes' => 'My notes', + 'organizationId' => '1', + 'timeAccountingUnit' => 30, + 'hoursAlert' => 80, + 'dateAlert' => 60, + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + contracts: $contracts, + )); + + $this->assertSame(1, ContractFactory::count()); + $contract = ContractFactory::last(); + $this->assertSame('My contract', $contract->getName()); + $this->assertSame(1704067200, $contract->getStartAt()->getTimestamp()); + $this->assertSame(1735689599, $contract->getEndAt()->getTimestamp()); + $this->assertSame(42, $contract->getMaxHours()); + $this->assertSame('My notes', $contract->getNotes()); + $this->assertSame(30, $contract->getTimeAccountingUnit()); + $this->assertSame(80, $contract->getHoursAlert()); + $this->assertSame(60, $contract->getDateAlert()); + $organization = $contract->getOrganization(); + $this->assertNotNull($organization); + $this->assertSame('Foo', $organization->getName()); + } + + public function testImportContractsKeepsExistingContractsInDatabase(): void + { + $existingOrganization = OrganizationFactory::createOne([ + 'name' => 'Foo', + ]); + $existingContract = ContractFactory::createOne([ + 'name' => 'My contract', + 'organization' => $existingOrganization, + 'startAt' => new \DateTimeImmutable('2024-01-01T00:00:00+00:00'), + 'endAt' => new \DateTimeImmutable('2024-12-31T23:59:59+00:00'), + ]); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $contracts = [ + [ + 'id' => '1', + 'name' => 'My contract', + 'startAt' => '2024-01-01T00:00:00+00:00', + 'endAt' => '2024-12-31T23:59:59+00:00', + 'maxHours' => 42, + 'organizationId' => '1', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + contracts: $contracts, + )); + + $this->assertSame(1, ContractFactory::count()); + $contract = ContractFactory::last(); + $this->assertSame($existingContract->getUid(), $contract->getUid()); + } + + public function testImportContractsFailsIfIdIsDuplicatedInFile(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Contract 1 error: id is duplicated'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $contracts = [ + [ + 'id' => '1', + 'name' => 'My contract', + 'startAt' => '2024-01-01T00:00:00+00:00', + 'endAt' => '2024-12-31T23:59:59+00:00', + 'maxHours' => 42, + 'organizationId' => '1', + ], + [ + 'id' => '1', + 'name' => 'My contract 2', + 'startAt' => '2025-01-01T00:00:00+00:00', + 'endAt' => '2025-12-31T23:59:59+00:00', + 'maxHours' => 42, + 'organizationId' => '1', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + contracts: $contracts, + )); + + $this->assertSame(0, ContractFactory::count()); + } + + public function testImportContractsFailsIfFieldsAreDuplicatedInFile(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Contract 2 error: duplicates id 1'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + // A contract is duplicated if its name + organization + startAt + + // endAt are identical. + $contracts = [ + [ + 'id' => '1', + 'name' => 'My contract', + 'startAt' => '2024-01-01T00:00:00+00:00', + 'endAt' => '2024-12-31T23:59:59+00:00', + 'maxHours' => 42, + 'notes' => 'My notes', + 'organizationId' => '1', + 'timeAccountingUnit' => 30, + 'hoursAlert' => 80, + 'dateAlert' => 60, + ], + [ + 'id' => '2', + 'name' => 'My contract', + 'startAt' => '2024-01-01T00:00:00+00:00', + 'endAt' => '2024-12-31T23:59:59+00:00', + 'maxHours' => 45, + 'notes' => '', + 'organizationId' => '1', + 'timeAccountingUnit' => 20, + 'hoursAlert' => 50, + 'dateAlert' => 50, + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + contracts: $contracts, + )); + + $this->assertSame(0, ContractFactory::count()); + } + + public function testImportContractsFailsIfContractIsInvalid(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Contract 1 error: Enter a date greater than the start date.'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $contracts = [ + [ + 'id' => '1', + 'name' => 'My contract', + 'startAt' => '2025-01-01T00:00:00+00:00', + 'endAt' => '2024-12-31T23:59:59+00:00', + 'maxHours' => 42, + 'organizationId' => '1', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + contracts: $contracts, + )); + + $this->assertSame(0, ContractFactory::count()); + } + + public function testImportContractsFailsIfOrganizationRefersToUnknownId(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Contract 1 error: references an unknown organization 1.'); + + $contracts = [ + [ + 'id' => '1', + 'name' => 'My contract', + 'startAt' => '2024-01-01T00:00:00+00:00', + 'endAt' => '2024-12-31T23:59:59+00:00', + 'maxHours' => 42, + 'organizationId' => '1', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + contracts: $contracts, + )); + + $this->assertSame(0, ContractFactory::count()); + } + + public function testImportTickets(): void + { + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + [ + 'id' => '2', + 'email' => 'benedict@example.com', + ], + ]; + $contracts = [ + [ + 'id' => '1', + 'name' => 'My contract', + 'startAt' => '2024-01-01T00:00:00+00:00', + 'endAt' => '2024-12-31T23:59:59+00:00', + 'maxHours' => 42, + 'organizationId' => '1', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'type' => 'incident', + 'status' => 'resolved', + 'title' => 'It does not work', + 'urgency' => 'low', + 'impact' => 'high', + 'priority' => 'medium', + 'requesterId' => '1', + 'assigneeId' => '2', + 'organizationId' => '1', + 'solutionId' => '2', + 'contractIds' => ['1'], + 'timeSpents' => [ + [ + 'createdAt' => '2024-04-25T18:00:00+00:00', + 'createdById' => '2', + 'time' => 30, + 'realTime' => 15, + 'contractId' => '1', + ], + ], + 'messages' => [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'isConfidential' => false, + 'via' => 'email', + 'content' => '

This is not working!

', + ], + [ + 'id' => '2', + 'createdAt' => '2024-04-25T18:00:00+00:00', + 'createdById' => '2', + 'isConfidential' => true, + 'via' => 'webapp', + 'content' => '

Indeed, it does not work.

', + ], + ], + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + contracts: $contracts, + tickets: $tickets, + )); + + $this->assertSame(1, TicketFactory::count()); + $ticket = TicketFactory::last(); + $ticket->refresh(); + $this->assertSame('It does not work', $ticket->getTitle()); + $this->assertSame(1714066680, $ticket->getCreatedAt()->getTimestamp()); + $this->assertSame('incident', $ticket->getType()); + $this->assertSame('resolved', $ticket->getStatus()); + $this->assertSame('low', $ticket->getUrgency()); + $this->assertSame('high', $ticket->getImpact()); + $this->assertSame('medium', $ticket->getPriority()); + $createdBy = $ticket->getCreatedBy(); + $this->assertSame('alix@example.com', $createdBy->getEmail()); + $organization = $ticket->getOrganization(); + $this->assertSame('Foo', $organization->getName()); + $requester = $ticket->getRequester(); + $this->assertSame('alix@example.com', $requester->getEmail()); + $assignee = $ticket->getAssignee(); + $this->assertSame('benedict@example.com', $assignee->getEmail()); + $contracts = $ticket->getContracts(); + $this->assertSame(1, count($contracts)); + $this->assertSame('My contract', $contracts[0]->getName()); + $timeSpents = $ticket->getTimeSpents(); + $this->assertSame(1, count($timeSpents)); + $this->assertSame(1714068000, $timeSpents[0]->getCreatedAt()->getTimestamp()); + $this->assertSame($assignee->getUid(), $timeSpents[0]->getCreatedBy()->getUid()); + $this->assertSame(30, $timeSpents[0]->getTime()); + $this->assertSame(15, $timeSpents[0]->getRealTime()); + $this->assertSame($contracts[0]->getUid(), $timeSpents[0]->getContract()->getUid()); + $messages = $ticket->getMessages(); + $this->assertSame(2, count($messages)); + $this->assertSame(1714066680, $messages[0]->getCreatedAt()->getTimestamp()); + $this->assertSame($requester->getUid(), $messages[0]->getCreatedBy()->getUid()); + $this->assertFalse($messages[0]->isConfidential()); + $this->assertSame('email', $messages[0]->getVia()); + $this->assertSame('

This is not working!

', $messages[0]->getContent()); + $this->assertSame(1714068000, $messages[1]->getCreatedAt()->getTimestamp()); + $this->assertSame($assignee->getUid(), $messages[1]->getCreatedBy()->getUid()); + $this->assertTrue($messages[1]->isConfidential()); + $this->assertSame('webapp', $messages[1]->getVia()); + $this->assertSame('

Indeed, it does not work.

', $messages[1]->getContent()); + $solution = $ticket->getSolution(); + $this->assertNotNull($solution); + $this->assertSame($messages[1]->getUid(), $solution->getUid()); + } + + public function testImportTicketsKeepsExistingTicketsInDatabase(): void + { + $existingOrganization = OrganizationFactory::createOne([ + 'name' => 'Foo', + ]); + $existingTicket = TicketFactory::createOne([ + 'title' => 'It does not work', + 'createdAt' => new \DateTimeImmutable('2024-04-25T17:38:00+00:00'), + 'organization' => $existingOrganization, + ]); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(1, TicketFactory::count()); + $ticket = TicketFactory::last(); + $this->assertSame($existingTicket->getUid(), $ticket->getUid()); + } + + public function testImportTicketsImportsDocumentsAsWell(): void + { + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + 'messages' => [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'isConfidential' => false, + 'via' => 'email', + 'content' => '

This is not working!

', + 'messageDocuments' => [ + [ + 'name' => 'The bug', + 'filepath' => 'bug.txt', + ] + ], + ], + ], + ], + ]; + $documentsPath = sys_get_temp_dir() . '/documents'; + @mkdir($documentsPath); + $content = 'My bug'; + $hash = hash('sha256', $content); + file_put_contents($documentsPath . '/bug.txt', $content); + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + documentsPath: $documentsPath, + )); + + $this->assertSame(1, MessageFactory::count()); + $message = MessageFactory::last(); + $this->assertSame(1, MessageDocumentFactory::count()); + $messageDocument = MessageDocumentFactory::first(); + $this->assertSame('The bug', $messageDocument->getName()); + $this->assertSame($hash . '.txt', $messageDocument->getFilename()); + $this->assertSame('text/plain', $messageDocument->getMimetype()); + $this->assertSame('sha256:' . $hash, $messageDocument->getHash()); + $this->assertNotNull($messageDocument->getMessage()); + $this->assertSame($message->getUid(), $messageDocument->getMessage()->getUid()); + } + + public function testImportTicketsFailsIfIdIsDuplicatedInFile(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: id is duplicated'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + ], + [ + 'id' => '1', + 'createdAt' => '2025-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'Please help', + 'requesterId' => '1', + 'organizationId' => '1', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfFieldsAreDuplicatedInFile(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 2 error: duplicates id 1'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + // A ticket is duplicated if its title + organization + createdAt are + // identical. + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + ], + [ + 'id' => '2', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfTicketIsInvalid(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: Enter a title.'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => '', + 'requesterId' => '1', + 'organizationId' => '1', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfCreatedByRefersToUnknownId(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: references an unknown createdBy user 2'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '2', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfOrganizationRefersToUnknownId(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: references an unknown organization 2'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '2', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfRequesterRefersToUnknownId(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: references an unknown requester user 2'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '2', + 'organizationId' => '1', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfAssigneeRefersToUnknownId(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: references an unknown assignee user 2'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'assigneeId' => '2', + 'organizationId' => '1', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfContractsRefersToUnknownId(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: references an unknown contract 2'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + 'contractIds' => ['2'], + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfSolutionRefersToUnknownId(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: references an unknown solution 2.'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + 'solutionId' => '2', + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfSpentTimeIsInvalid(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: timeSpents: Enter a number greater than zero.'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + 'timeSpents' => [ + [ + 'createdAt' => '2024-04-25T18:00:00+00:00', + 'createdById' => '1', + 'time' => 0, + 'realTime' => 15, + ], + ], + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfSpentTimeCreatedByRefersToUnknownId(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: timeSpents: references an unknown user 2'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + 'timeSpents' => [ + [ + 'createdAt' => '2024-04-25T18:00:00+00:00', + 'createdById' => '2', + 'time' => 30, + 'realTime' => 15, + ], + ], + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfSpentTimeContractRefersToUnknownId(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: timeSpents: references an unknown contract 2'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + 'timeSpents' => [ + [ + 'createdAt' => '2024-04-25T18:00:00+00:00', + 'createdById' => '1', + 'time' => 30, + 'realTime' => 15, + 'contractId' => '2', + ], + ], + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfMessageIsInvalid(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: message 1: Enter a message.'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + 'messages' => [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'content' => '', + ], + ], + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfMessageCreatedByRefersToUnknownId(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Ticket 1 error: message 1: references an unknown user 2'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + 'messages' => [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '2', + 'content' => '

This is not working!

', + ], + ], + ], + ]; + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + )); + + $this->assertSame(0, TicketFactory::count()); + } + + public function testImportTicketsFailsIfDocumentIsMissing(): void + { + $this->expectException(DataImporterError::class); + $this->expectExceptionMessage('Message 1 error: document The bug: references an unknown document bug.txt'); + + $organizations = [ + [ + 'id' => '1', + 'name' => 'Foo', + ], + ]; + $users = [ + [ + 'id' => '1', + 'email' => 'alix@example.com', + ], + ]; + $tickets = [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'title' => 'It does not work', + 'requesterId' => '1', + 'organizationId' => '1', + 'messages' => [ + [ + 'id' => '1', + 'createdAt' => '2024-04-25T17:38:00+00:00', + 'createdById' => '1', + 'isConfidential' => false, + 'via' => 'email', + 'content' => '

This is not working!

', + 'messageDocuments' => [ + [ + 'name' => 'The bug', + 'filepath' => 'bug.txt', + ] + ], + ], + ], + ], + ]; + $documentsPath = sys_get_temp_dir() . '/documents'; + @mkdir($documentsPath); + @unlink($documentsPath . '/bug.txt'); + + $this->processGenerator($this->dataImporter->import( + organizations: $organizations, + users: $users, + tickets: $tickets, + documentsPath: $documentsPath, + )); + + $this->assertSame(0, MessageFactory::count()); + $this->assertSame(0, MessageDocumentFactory::count()); + } +}