diff --git a/composer.json b/composer.json index 8d522391e..ddd220cb6 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "league/flysystem-bundle": "^3.3", "lexik/jwt-authentication-bundle": "^3.1", "nelmio/cors-bundle": "^2.5", + "openspout/openspout": "^5.8", "phpoffice/phpspreadsheet": "^4.3", "runtime/frankenphp-symfony": "^0.2.0", "symfony/asset": "7.2.*", diff --git a/composer.lock b/composer.lock index 5e72d2633..787e79ae5 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": "03322ffb12a7d01fd1fb39d799da708e", + "content-hash": "9e2fedeacecf6b30f74ff1bb2a8905c2", "packages": [ { "name": "composer/pcre", @@ -2798,6 +2798,99 @@ }, "time": "2025-10-23T06:57:22+00:00" }, + { + "name": "openspout/openspout", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/openspout/openspout.git", + "reference": "1e1aad228e3e289c7e11d97b07496f2569121afc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openspout/openspout/zipball/1e1aad228e3e289c7e11d97b07496f2569121afc", + "reference": "1e1aad228e3e289c7e11d97b07496f2569121afc", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-libxml": "*", + "ext-xmlreader": "*", + "ext-zip": "*", + "php": "~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ext-fileinfo": "*", + "ext-zlib": "*", + "friendsofphp/php-cs-fixer": "^3.95.14", + "infection/infection": "^0.34", + "phpbench/phpbench": "^1.7.0", + "phpstan/phpstan": "^2.2.5", + "phpstan/phpstan-phpunit": "^2.0.18", + "phpstan/phpstan-strict-rules": "^2.0.11", + "phpunit/phpunit": "^13.2.4" + }, + "suggest": { + "ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)", + "ext-mbstring": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenSpout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Adrien Loison", + "email": "adrien@box.com" + } + ], + "description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way", + "homepage": "https://github.com/openspout/openspout", + "keywords": [ + "OOXML", + "csv", + "excel", + "memory", + "odf", + "ods", + "office", + "open", + "php", + "read", + "scale", + "spreadsheet", + "stream", + "write", + "xlsx" + ], + "support": { + "issues": "https://github.com/openspout/openspout/issues", + "source": "https://github.com/openspout/openspout/tree/v5.8.0" + }, + "funding": [ + { + "url": "https://paypal.me/filippotessarotto", + "type": "custom" + }, + { + "url": "https://github.com/Slamdunk", + "type": "github" + } + ], + "time": "2026-07-15T07:34:50+00:00" + }, { "name": "phpoffice/phpspreadsheet", "version": "4.5.0", diff --git a/config/routes/admin/user.yaml b/config/routes/admin/user.yaml index ff8a5f1d7..55c40bb6a 100644 --- a/config/routes/admin/user.yaml +++ b/config/routes/admin/user.yaml @@ -3,6 +3,11 @@ list: controller: App\Controller\Web\Admin\UserAdminController::list methods: ['GET'] +export_links: + path: /exportar-vinculos + controller: App\Controller\Web\Admin\UserAdminController::exportLinks + methods: ['GET'] + timeline: path: /{id}/timeline controller: App\Controller\Web\Admin\UserAdminController::timeline @@ -32,4 +37,3 @@ edit_roles: path: /{id}/edit-user-roles controller: App\Controller\Web\Admin\UserAdminController::editUserRoles methods: ['POST'] - \ No newline at end of file diff --git a/src/Controller/Web/Admin/UserAdminController.php b/src/Controller/Web/Admin/UserAdminController.php index 72da199fa..1fc2b6f7f 100644 --- a/src/Controller/Web/Admin/UserAdminController.php +++ b/src/Controller/Web/Admin/UserAdminController.php @@ -14,10 +14,12 @@ use App\Security\PasswordHasher; use App\Service\Interface\AgentServiceInterface; use App\Service\Interface\UserServiceInterface; +use App\Service\UserExportService; use Exception; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -56,6 +58,15 @@ public function list(): Response ]); } + #[IsGranted(new Expression( + 'is_granted("'.UserRolesEnum::ROLE_ADMIN->value.'") or '. + 'is_granted("'.UserRolesEnum::ROLE_MANAGER->value.'")' + ), statusCode: self::ACCESS_DENIED_RESPONSE_CODE)] + public function exportLinks(UserExportService $service): BinaryFileResponse + { + return $service->export(); + } + #[IsGranted(new Expression( 'is_granted("'.UserRolesEnum::ROLE_ADMIN->value.'") or '. 'is_granted("'.UserRolesEnum::ROLE_MANAGER->value.'")' diff --git a/src/Repository/Interface/UserRepositoryInterface.php b/src/Repository/Interface/UserRepositoryInterface.php index 207de6b2b..99bcffb0c 100644 --- a/src/Repository/Interface/UserRepositoryInterface.php +++ b/src/Repository/Interface/UserRepositoryInterface.php @@ -6,6 +6,26 @@ use App\Entity\User; +/** + * @phpstan-type UserExportRow array{ + * user_id: string, + * user_name: string, + * social_name: ?string, + * email: string, + * status: string, + * roles: string, + * user_created_at: string, + * user_updated_at: ?string, + * agent_id: ?string, + * agent_name: ?string, + * agent_extra_fields: ?string, + * organization_id: ?string, + * organization_name: ?string, + * organization_type: ?string, + * organization_extra_fields: ?string, + * owner_id: ?string + * } + */ interface UserRepositoryInterface { public function save(User $user): User; @@ -18,5 +38,8 @@ public function rollback(): void; public function findOneByRole(string $role): ?User; - public function findAllForExport(): array; + /** + * @return iterable + */ + public function findAllForExport(): iterable; } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 1b4d15174..2b51aa812 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -8,6 +8,9 @@ use App\Repository\Interface\UserRepositoryInterface; use Doctrine\Persistence\ManagerRegistry; +/** + * @phpstan-import-type UserExportRow from UserRepositoryInterface + */ class UserRepository extends AbstractRepository implements UserRepositoryInterface { public function __construct(ManagerRegistry $registry) @@ -58,7 +61,10 @@ public function findOneByRole(string $role): ?User return $id ? $this->find($id) : null; } - public function findAllForExport(): array + /** + * @return iterable + */ + public function findAllForExport(): iterable { $sql = <<getEntityManager() + $result = $this->getEntityManager() ->getConnection() - ->executeQuery($sql) - ->fetchAllAssociative(); + ->executeQuery($sql); + + yield from $result->iterateAssociative(); } } diff --git a/src/Service/UserExportService.php b/src/Service/UserExportService.php new file mode 100644 index 000000000..9a38d2390 --- /dev/null +++ b/src/Service/UserExportService.php @@ -0,0 +1,265 @@ +createExportDirectory(); + + $filePath = tempnam($this->exportDirectory, 'usuarios_e_vinculos_'); + if (false === $filePath) { + throw new RuntimeException('Não foi possível criar o arquivo temporário da exportação.'); + } + + $writer = null; + + try { + $writer = new Writer(new Options(tempFolder: $this->exportDirectory)); + $writer->openToFile($filePath); + $writer->addRow($this->createRow(self::HEADERS)); + + foreach ($this->userRepository->findAllForExport() as $row) { + $writer->addRow($this->createRow($this->formatRow($row))); + } + + $writer->close(); + + $downloadName = sprintf( + 'usuarios_e_vinculos_%s.xlsx', + $this->clock->now()->format('Y-m-d_H-i-s') + ); + $response = new BinaryFileResponse($filePath, headers: ['Content-Type' => self::CONTENT_TYPE]); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $downloadName); + $response->deleteFileAfterSend(true); + + return $response; + } catch (Throwable $exception) { + try { + $writer?->close(); + } catch (Throwable) { + } + + if (is_file($filePath)) { + @unlink($filePath); + } + + throw $exception; + } + } + + private function createExportDirectory(): void + { + if (is_dir($this->exportDirectory)) { + return; + } + + if (!mkdir($this->exportDirectory, 0775, true) && !is_dir($this->exportDirectory)) { + throw new RuntimeException(sprintf( + 'Não foi possível criar o diretório de exportação "%s".', + $this->exportDirectory + )); + } + } + + /** + * @param array $row + * + * @return list + */ + private function formatRow(array $row): array + { + $agentExtraFields = $this->decodeJsonObject($row['agent_extra_fields'] ?? null); + $organizationExtraFields = $this->decodeJsonObject($row['organization_extra_fields'] ?? null); + $organizationId = $this->stringValue($row['organization_id'] ?? null); + $organizationName = $this->stringValue($row['organization_name'] ?? null); + $organizationType = $this->stringValue($row['organization_type'] ?? null); + + $municipality = $this->stringValue( + $organizationExtraFields['municipality'] ?? $organizationExtraFields['city_name'] ?? null + ); + if ( + '' !== $organizationId + && OrganizationTypeEnum::MUNICIPIO->value === $organizationType + && '' === $municipality + ) { + $municipality = $organizationName; + } + + return [ + $this->stringValue($row['user_id'] ?? null), + $this->stringValue($row['user_name'] ?? null), + $this->stringValue($row['social_name'] ?? null), + $this->stringValue($row['email'] ?? null), + $this->formatStatus($row['status'] ?? null), + $this->formatRoles($row['roles'] ?? null), + $this->formatDate($row['user_created_at'] ?? null), + $this->formatDate($row['user_updated_at'] ?? null), + $this->stringValue($row['agent_id'] ?? null), + $this->stringValue($row['agent_name'] ?? null), + $this->stringValue($agentExtraFields['cpf'] ?? null), + $this->stringValue($agentExtraFields['cargo'] ?? null), + $organizationId, + $organizationName, + $this->formatOrganizationType($organizationType), + $this->stringValue($organizationExtraFields['cnpj'] ?? null), + $municipality, + $this->stringValue($organizationExtraFields['uf'] ?? $organizationExtraFields['state'] ?? null), + $this->formatOwner($organizationId, $row['owner_id'] ?? null, $row['agent_id'] ?? null), + ]; + } + + /** + * @param list $values + */ + private function createRow(array $values): Row + { + return new Row(array_map( + static fn (string $value): StringCell => new StringCell($value), + $values + )); + } + + private function formatStatus(mixed $status): string + { + return match ($this->stringValue($status)) { + UserStatusEnum::ACTIVE->value => 'Ativo', + UserStatusEnum::BLOCKED->value => 'Bloqueado', + UserStatusEnum::AWAITING_CONFIRMATION->value => 'Aguardando confirmação', + default => '', + }; + } + + private function formatRoles(mixed $encodedRoles): string + { + $roles = []; + if (is_string($encodedRoles)) { + $decodedRoles = json_decode($encodedRoles, true); + if (is_array($decodedRoles)) { + foreach ($decodedRoles as $role) { + if (is_string($role) && !in_array($role, $roles, true)) { + $roles[] = $role; + } + } + } + } + + if (!in_array(UserRolesEnum::ROLE_USER->value, $roles, true)) { + $roles[] = UserRolesEnum::ROLE_USER->value; + } + + return implode('; ', array_map( + static fn (string $role): string => match ($role) { + UserRolesEnum::ROLE_ADMIN->value => 'Administrador Geral', + UserRolesEnum::ROLE_MANAGER->value => 'Gestor', + UserRolesEnum::ROLE_COMPANY->value => 'Administrador de Empresa', + UserRolesEnum::ROLE_MUNICIPALITY->value => 'Administrador de Município', + UserRolesEnum::ROLE_SUPPORT->value => 'Suporte', + UserRolesEnum::ROLE_USER->value => 'Usuário Padrão', + UserRolesEnum::ROLE_CAIXA->value => 'Visualizador Caixa', + default => $role, + }, + $roles + )); + } + + private function formatOrganizationType(string $type): string + { + return match ($type) { + OrganizationTypeEnum::MUNICIPIO->value => 'Município', + OrganizationTypeEnum::EMPRESA->value => 'Empresa', + OrganizationTypeEnum::ENTIDADE->value => 'Entidade', + OrganizationTypeEnum::OSC->value => 'OSC', + OrganizationTypeEnum::UNDEFINED->value => 'Indefinida', + default => '', + }; + } + + private function formatDate(mixed $date): string + { + $date = $this->stringValue($date); + + return '' === $date ? '' : (new DateTimeImmutable($date))->format('d/m/Y H:i:s'); + } + + private function formatOwner(string $organizationId, mixed $ownerId, mixed $agentId): string + { + if ('' === $organizationId) { + return ''; + } + + $agentId = $this->stringValue($agentId); + + return '' !== $agentId && $this->stringValue($ownerId) === $agentId ? 'Sim' : 'Não'; + } + + /** + * @return array + */ + private function decodeJsonObject(mixed $json): array + { + if (!is_string($json) || '' === $json) { + return []; + } + + $decoded = json_decode($json, true); + + return is_array($decoded) ? $decoded : []; + } + + private function stringValue(mixed $value): string + { + return is_scalar($value) || $value instanceof \Stringable ? (string) $value : ''; + } +} diff --git a/templates/_admin/user/list.html.twig b/templates/_admin/user/list.html.twig index 054078713..f6dfdd567 100644 --- a/templates/_admin/user/list.html.twig +++ b/templates/_admin/user/list.html.twig @@ -16,9 +16,14 @@

diff --git a/tests/Functional/Controller/Web/Admin/UserExportAdminWebControllerTest.php b/tests/Functional/Controller/Web/Admin/UserExportAdminWebControllerTest.php new file mode 100644 index 000000000..1f4476911 --- /dev/null +++ b/tests/Functional/Controller/Web/Admin/UserExportAdminWebControllerTest.php @@ -0,0 +1,117 @@ +entityManager = static::getContainer()->get(EntityManagerInterface::class); + $this->client->loginUser($this->createActiveUser(UserRolesEnum::ROLE_ADMIN), 'web'); + } + + public function testAdminCanExportUserLinksAsXlsx(): void + { + $this->client->request( + Request::METHOD_GET, + $this->router->generate('admin_user_export_links') + ); + + $response = $this->client->getResponse(); + + self::assertInstanceOf(BinaryFileResponse::class, $response); + self::assertResponseIsSuccessful(); + self::assertSame( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + $response->headers->get('Content-Type') + ); + self::assertMatchesRegularExpression( + '/^attachment; filename=usuarios_e_vinculos_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.xlsx$/', + (string) $response->headers->get('Content-Disposition') + ); + self::assertFileDoesNotExist($response->getFile()->getPathname()); + } + + public function testStandardUserReceivesSameDenialAsUserList(): void + { + $this->client->loginUser($this->createActiveUser(UserRolesEnum::ROLE_USER), 'web'); + + $this->client->request( + Request::METHOD_GET, + $this->router->generate('admin_user_list') + ); + $listStatusCode = $this->client->getResponse()->getStatusCode(); + + $this->client->request( + Request::METHOD_GET, + $this->router->generate('admin_user_export_links') + ); + + $response = $this->client->getResponse(); + + self::assertSame($listStatusCode, $response->getStatusCode()); + self::assertFalse($response->isSuccessful()); + } + + public function testManagerCanExportUserLinks(): void + { + $this->client->loginUser($this->createActiveUser(UserRolesEnum::ROLE_MANAGER), 'web'); + + $this->client->request( + Request::METHOD_GET, + $this->router->generate('admin_user_export_links') + ); + + $response = $this->client->getResponse(); + + self::assertInstanceOf(BinaryFileResponse::class, $response); + self::assertResponseIsSuccessful(); + } + + public function testUsersListShowsExportUsersAndLinksButton(): void + { + $this->client->request( + Request::METHOD_GET, + $this->router->generate('admin_user_list') + ); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextSame( + sprintf('a[href="%s"]', $this->router->generate('admin_user_export_links')), + 'Exportar usuários e vínculos' + ); + } + + private function createActiveUser(UserRolesEnum $role): User + { + $id = Uuid::v4(); + $user = new User(); + $user->setId($id); + $user->setFirstname('Usuário'); + $user->setLastname('Exportação'); + $user->setEmail(sprintf('export-web-%s@example.test', $id->toRfc4122())); + $user->setPassword('not-used'); + $user->setStatus(UserStatusEnum::ACTIVE->value); + $user->setRoles([$role->value]); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } +} diff --git a/tests/Functional/Repositories/UserRepositoryTest.php b/tests/Functional/Repositories/UserRepositoryTest.php index 9b53f3303..456943af9 100644 --- a/tests/Functional/Repositories/UserRepositoryTest.php +++ b/tests/Functional/Repositories/UserRepositoryTest.php @@ -11,6 +11,7 @@ use DateTime; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; +use Generator; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Uid\Uuid; @@ -35,7 +36,11 @@ public function testFindAllForExportReturnsOneRowForEachActiveOrganization(): vo $organizationA = $this->createOrganization($agent, 'Organização A', ['cnpj' => '11']); $this->flush(); - $rows = $this->rowsForUser($user); + $result = $this->repository->findAllForExport(); + + self::assertInstanceOf(Generator::class, $result); + + $rows = $this->rowsForUser($user, $result); self::assertCount(2, $rows); self::assertSame( @@ -223,10 +228,10 @@ private function flush(): void /** * @return array> */ - private function rowsForUser(User $user): array + private function rowsForUser(User $user, ?iterable $result = null): array { return array_values(array_filter( - $this->repository->findAllForExport(), + iterator_to_array($result ?? $this->repository->findAllForExport(), false), static fn (array $row): bool => $row['user_id'] === $user->getId()->toRfc4122() )); } diff --git a/tests/Unit/Service/UserExportServiceTest.php b/tests/Unit/Service/UserExportServiceTest.php new file mode 100644 index 000000000..106854fa6 --- /dev/null +++ b/tests/Unit/Service/UserExportServiceTest.php @@ -0,0 +1,279 @@ +repository = $this->createMock(UserRepositoryInterface::class); + $this->exportDirectory = sprintf( + '%s/pvr-user-export-%s', + sys_get_temp_dir(), + bin2hex(random_bytes(8)) + ); + } + + protected function tearDown(): void + { + if (!is_dir($this->exportDirectory)) { + return; + } + + $files = scandir($this->exportDirectory); + if (false !== $files) { + foreach ($files as $file) { + if ('.' === $file || '..' === $file) { + continue; + } + + $path = $this->exportDirectory.'/'.$file; + if (is_file($path)) { + unlink($path); + } + } + } + + rmdir($this->exportDirectory); + } + + public function testExportsCompleteRowAndHttpMetadata(): void + { + $this->repository->expects(self::once()) + ->method('findAllForExport') + ->willReturn([[ + 'user_id' => 'user-1', + 'user_name' => 'Ana Maria', + 'social_name' => 'Ana', + 'email' => 'ana@example.com', + 'status' => 'Active', + 'roles' => '["ROLE_ADMIN"]', + 'user_created_at' => '2025-01-02 03:04:05', + 'user_updated_at' => '2025-06-07 08:09:10', + 'agent_id' => 'agent-1', + 'agent_name' => 'Ana Agente', + 'agent_extra_fields' => '{"cpf":"012.345.678-90","cargo":"Coordenadora"}', + 'organization_id' => 'org-1', + 'organization_name' => 'Prefeitura de Exemplo', + 'organization_type' => 'Municipio', + 'organization_extra_fields' => '{"cnpj":"12.345.678/0001-90","city_name":"Exemplo","state":"SP"}', + 'owner_id' => 'agent-1', + ]]); + + $response = $this->createService()->export(); + $file = $response->getFile()->getPathname(); + $sheet = $this->loadSheet($file); + + self::assertSame( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + $response->headers->get('Content-Type') + ); + self::assertSame( + 'attachment; filename=usuarios_e_vinculos_2026-07-23_14-05-06.xlsx', + $response->headers->get('Content-Disposition') + ); + self::assertSame(UserExportService::HEADERS, $sheet->rangeToArray('A1:S1')[0]); + self::assertSame([ + 'user-1', + 'Ana Maria', + 'Ana', + 'ana@example.com', + 'Ativo', + 'Administrador Geral; Usuário Padrão', + '02/01/2025 03:04:05', + '07/06/2025 08:09:10', + 'agent-1', + 'Ana Agente', + '012.345.678-90', + 'Coordenadora', + 'org-1', + 'Prefeitura de Exemplo', + 'Município', + '12.345.678/0001-90', + 'Exemplo', + 'SP', + 'Sim', + ], $sheet->rangeToArray('A2:S2')[0]); + + foreach ($sheet->getCellCollection()->getCoordinates() as $coordinate) { + self::assertSame('inlineStr', $sheet->getCell($coordinate)->getDataType()); + } + + ob_start(); + $response->sendContent(); + ob_end_clean(); + self::assertFileDoesNotExist($file); + } + + public function testExportsOnlyHeadersWhenThereAreNoUsers(): void + { + $this->repository->method('findAllForExport')->willReturn([]); + + $response = $this->createService()->export(); + $sheet = $this->loadSheet($response->getFile()->getPathname()); + + self::assertSame(UserExportService::HEADERS, $sheet->rangeToArray('A1:S1')[0]); + self::assertSame(1, $sheet->getHighestDataRow()); + } + + public function testPreservesAgentWithoutOrganizationAndLeavesOrganizationColumnsEmpty(): void + { + $this->repository->method('findAllForExport')->willReturn([[ + 'user_id' => 'user-2', + 'user_name' => 'Bruno', + 'social_name' => null, + 'email' => 'bruno@example.com', + 'status' => 'Blocked', + 'roles' => '[]', + 'user_created_at' => '2025-02-03 10:20:30', + 'user_updated_at' => null, + 'agent_id' => 'agent-2', + 'agent_name' => 'Bruno Agente', + 'agent_extra_fields' => '{"cpf":"987.654.321-00","cargo":"Analista"}', + 'organization_id' => null, + 'organization_name' => null, + 'organization_type' => null, + 'organization_extra_fields' => null, + 'owner_id' => null, + ]]); + + $response = $this->createService()->export(); + $row = $this->loadSheet($response->getFile()->getPathname())->rangeToArray('A2:S2', '')[0]; + + self::assertSame('Bloqueado', $row[4]); + self::assertSame('Usuário Padrão', $row[5]); + self::assertSame(['agent-2', 'Bruno Agente', '987.654.321-00', 'Analista'], array_slice($row, 8, 4)); + self::assertSame(array_fill(0, 7, ''), array_slice($row, 12, 7)); + } + + public function testWritesFormulaLikeOrganizationNameAsLiteralString(): void + { + $row = $this->minimalRow(); + $row['organization_id'] = 'org-danger'; + $row['organization_name'] = '=HYPERLINK("https://example.com","click")'; + $row['organization_type'] = 'Empresa'; + $row['organization_extra_fields'] = '{"cnpj":"00.000.000/0001-00","municipality":"Recife","uf":"PE"}'; + $row['owner_id'] = 'another-agent'; + $this->repository->method('findAllForExport')->willReturn([$row]); + + $response = $this->createService()->export(); + $cell = $this->loadSheet($response->getFile()->getPathname())->getCell('N2'); + $value = $cell->getValue(); + if ($value instanceof RichText) { + $value = $value->getPlainText(); + } + + self::assertSame('=HYPERLINK("https://example.com","click")', $value); + self::assertSame('inlineStr', $cell->getDataType()); + } + + public function testConsumesRepositoryGeneratorOnlyOnce(): void + { + $iterations = 0; + $rows = (function () use (&$iterations): iterable { + ++$iterations; + yield $this->minimalRow(); + })(); + $this->repository->expects(self::once())->method('findAllForExport')->willReturn($rows); + + $response = $this->createService()->export(); + $sheet = $this->loadSheet($response->getFile()->getPathname()); + + self::assertSame(1, $iterations); + self::assertSame(2, $sheet->getHighestDataRow()); + } + + public function testRemovesTemporaryFileWhenGeneratorThrowsAfterYieldingARow(): void + { + $rows = (function (): iterable { + yield $this->minimalRow(); + + throw new \RuntimeException('Falha durante a exportação.'); + })(); + $this->repository->method('findAllForExport')->willReturn($rows); + + try { + $this->createService()->export(); + self::fail('A exceção do generator deveria ter sido propagada.'); + } catch (\RuntimeException $exception) { + self::assertSame('Falha durante a exportação.', $exception->getMessage()); + } + + self::assertSame(['.', '..'], scandir($this->exportDirectory)); + } + + public function testDoesNotDependOnSpreadsheetCachePool(): void + { + $parameters = (new \ReflectionMethod(UserExportService::class, '__construct'))->getParameters(); + + self::assertCount(3, $parameters); + foreach ($parameters as $parameter) { + self::assertNotSame( + 'Psr\Cache\CacheItemPoolInterface', + $parameter->getType() instanceof \ReflectionNamedType ? $parameter->getType()->getName() : null + ); + } + } + + public function testProductionServiceDoesNotUsePhpSpreadsheet(): void + { + $serviceFile = (new \ReflectionClass(UserExportService::class))->getFileName(); + + self::assertIsString($serviceFile); + self::assertStringNotContainsString('PhpOffice\\PhpSpreadsheet', file_get_contents($serviceFile)); + } + + private function createService(): UserExportService + { + return new UserExportService( + $this->repository, + new MockClock(new DateTimeImmutable('2026-07-23 14:05:06 America/Sao_Paulo')), + $this->exportDirectory + ); + } + + private function loadSheet(string $path): Worksheet + { + return IOFactory::load($path)->getActiveSheet(); + } + + /** + * @return array + */ + private function minimalRow(): array + { + return [ + 'user_id' => 'user-minimal', + 'user_name' => 'Usuário', + 'social_name' => null, + 'email' => 'user@example.com', + 'status' => 'AwaitingConfirmation', + 'roles' => '["ROLE_USER"]', + 'user_created_at' => '2025-03-04 05:06:07', + 'user_updated_at' => null, + 'agent_id' => null, + 'agent_name' => null, + 'agent_extra_fields' => null, + 'organization_id' => null, + 'organization_name' => null, + 'organization_type' => null, + 'organization_extra_fields' => null, + 'owner_id' => null, + ]; + } +} diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 29ce0316d..50290c872 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -423,6 +423,7 @@ total_value: Total Value user: User user_name: User Name users: Users +export_users_and_links: Export users and links updated_at: Updated at updated_success: Updated successfully. diff --git a/translations/messages.es.yaml b/translations/messages.es.yaml index 48a610b9a..5b89de39d 100644 --- a/translations/messages.es.yaml +++ b/translations/messages.es.yaml @@ -421,6 +421,7 @@ total_value: Valor total user: Usuario user_name: Nombre de usuario users: Usuarios +export_users_and_links: Exportar usuarios y vínculos updated_at: Actualizado en updated_success: Updated successfully diff --git a/translations/messages.pt-br.yaml b/translations/messages.pt-br.yaml index fb20d5464..7f7470fd7 100644 --- a/translations/messages.pt-br.yaml +++ b/translations/messages.pt-br.yaml @@ -422,6 +422,7 @@ total_value: Valor total user: Usuário user_name: Nome do Usuário users: Usuários +export_users_and_links: Exportar usuários e vínculos updated_at: Atualizado em updated_success: Atualizado com sucesso. diff --git a/translations/messages.regmel.yaml b/translations/messages.regmel.yaml index 7f027d3b1..ed1f987ef 100644 --- a/translations/messages.regmel.yaml +++ b/translations/messages.regmel.yaml @@ -168,6 +168,7 @@ documents: Termos de Adesão does_not_expire: não expira download_all_terms: Baixar todos os termos export_users: Exportar usuários +export_users_and_links: Exportar usuários e vínculos downloadable-files: Arquivos para download download_archive: Baixar arquivo KML organization_duplicate: Este município já foi credenciado. Entre em contato com %s para mais informações.