From a28fa002e35887bec23b1a3ffddf6364bf30063a Mon Sep 17 00:00:00 2001 From: Carlos C Soto Date: Fri, 18 Oct 2024 13:37:51 -0600 Subject: [PATCH] Initial release --- .dockerignore | 9 + .editorconfig | 20 ++ .gitattributes | 24 ++ .github/CODEOWNERS | 2 + .github/workflows/build.yml | 124 +++++++ .github/workflows/docker.yml | 35 ++ .github/workflows/phar.yml | 47 +++ .gitignore | 4 + .phive/phars.xml | 9 + .php-cs-fixer.dist.php | 52 +++ CODE_OF_CONDUCT.md | 84 +++++ CONTRIBUTING.md | 100 ++++++ Docker.README.md | 29 ++ Dockerfile | 15 + LICENSE | 21 ++ README.md | 332 ++++++++++++++++++ bin/descarga-masiva.php | 27 ++ box.json.dist | 7 + build/.gitignore | 2 + composer.json | 83 +++++ docs/CHANGELOG.md | 23 ++ docs/TODO.md | 15 + phpcs.xml.dist | 31 ++ phpstan.neon.dist | 6 + phpunit.xml.dist | 27 ++ rector.php | 21 ++ sonar-project.properties | 10 + src/Commands/Common/LabelMethodsTrait.php | 110 ++++++ src/Commands/DownloadCommand.php | 122 +++++++ .../Exceptions/ExecutionException.php | 16 + src/Commands/Exceptions/InputException.php | 24 ++ src/Commands/ListComplementsCommand.php | 61 ++++ src/Commands/QueryCommand.php | 114 ++++++ src/Commands/QueryCommand/QueryBuilder.php | 212 +++++++++++ src/Commands/VerifyCommand.php | 103 ++++++ src/Commands/WithFielAbstractCommand.php | 21 ++ src/Commands/ZipExportMetadataCommand.php | 120 +++++++ .../MetadataProviderIterator.php | 33 ++ src/Commands/ZipExportXmlCommand.php | 93 +++++ src/Internal/Filesystem.php | 85 +++++ src/Service/ConfigValues.php | 21 ++ src/Service/GuzzleWebClientWithLogger.php | 67 ++++ src/Service/ServiceBuilder.php | 197 +++++++++++ src/Service/ServiceWithStorageToken.php | 42 +++ src/Service/StorageToken.php | 122 +++++++ tests/Helpers/ExceptionCatcherTrait.php | 22 ++ tests/Helpers/TemporaryFile.php | 55 +++ tests/TestCase.php | 58 +++ tests/Unit/Commands/DownloadCommandTest.php | 125 +++++++ .../Commands/ListComplementsCommandTest.php | 50 +++ .../QueryCommand/QueryBuilderTest.php | 307 ++++++++++++++++ tests/Unit/Commands/QueryCommandTest.php | 193 ++++++++++ tests/Unit/Commands/VerifyCommandTest.php | 174 +++++++++ .../Commands/WithfieldAbstractCommandTest.php | 161 +++++++++ .../Commands/ZipExportMetadataCommandTest.php | 122 +++++++ .../Unit/Commands/ZipExportXmlCommandTest.php | 131 +++++++ tests/Unit/Internal/FileSystemTest.php | 72 ++++ .../Service/ServiceWithStorageTokenTest.php | 63 ++++ tests/Unit/Service/StorageTokenTest.php | 165 +++++++++ tests/_files/cfdi.zip | Bin 0 -> 2727 bytes .../aaaaaaaa-bbbb-cccc-dddd-000000000001.xml | 41 +++ .../aaaaaaaa-bbbb-cccc-dddd-000000000002.xml | 41 +++ tests/_files/fake-fiel/.gitignore | 2 + .../_files/fake-fiel/EKU9003173C9-efirma.json | 6 + .../fake-fiel/EKU9003173C9-password.txt | 1 + tests/_files/fake-fiel/EKU9003173C9.cer | Bin 0 -> 1544 bytes tests/_files/fake-fiel/EKU9003173C9.key | Bin 0 -> 1298 bytes tests/_files/fake-fiel/README.md | 5 + tests/_files/metadata.zip | Bin 0 -> 1542 bytes tests/bootstrap.php | 7 + 70 files changed, 4523 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/phar.yml create mode 100644 .gitignore create mode 100644 .phive/phars.xml create mode 100644 .php-cs-fixer.dist.php create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Docker.README.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bin/descarga-masiva.php create mode 100644 box.json.dist create mode 100644 build/.gitignore create mode 100644 composer.json create mode 100644 docs/CHANGELOG.md create mode 100644 docs/TODO.md create mode 100644 phpcs.xml.dist create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 rector.php create mode 100644 sonar-project.properties create mode 100644 src/Commands/Common/LabelMethodsTrait.php create mode 100644 src/Commands/DownloadCommand.php create mode 100644 src/Commands/Exceptions/ExecutionException.php create mode 100644 src/Commands/Exceptions/InputException.php create mode 100644 src/Commands/ListComplementsCommand.php create mode 100644 src/Commands/QueryCommand.php create mode 100644 src/Commands/QueryCommand/QueryBuilder.php create mode 100644 src/Commands/VerifyCommand.php create mode 100644 src/Commands/WithFielAbstractCommand.php create mode 100644 src/Commands/ZipExportMetadataCommand.php create mode 100644 src/Commands/ZipExportMetadataCommand/MetadataProviderIterator.php create mode 100644 src/Commands/ZipExportXmlCommand.php create mode 100644 src/Internal/Filesystem.php create mode 100644 src/Service/ConfigValues.php create mode 100644 src/Service/GuzzleWebClientWithLogger.php create mode 100644 src/Service/ServiceBuilder.php create mode 100644 src/Service/ServiceWithStorageToken.php create mode 100644 src/Service/StorageToken.php create mode 100644 tests/Helpers/ExceptionCatcherTrait.php create mode 100644 tests/Helpers/TemporaryFile.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/Commands/DownloadCommandTest.php create mode 100644 tests/Unit/Commands/ListComplementsCommandTest.php create mode 100644 tests/Unit/Commands/QueryCommand/QueryBuilderTest.php create mode 100644 tests/Unit/Commands/QueryCommandTest.php create mode 100644 tests/Unit/Commands/VerifyCommandTest.php create mode 100644 tests/Unit/Commands/WithfieldAbstractCommandTest.php create mode 100644 tests/Unit/Commands/ZipExportMetadataCommandTest.php create mode 100644 tests/Unit/Commands/ZipExportXmlCommandTest.php create mode 100644 tests/Unit/Internal/FileSystemTest.php create mode 100644 tests/Unit/Service/ServiceWithStorageTokenTest.php create mode 100644 tests/Unit/Service/StorageTokenTest.php create mode 100644 tests/_files/cfdi.zip create mode 100644 tests/_files/cfdi/aaaaaaaa-bbbb-cccc-dddd-000000000001.xml create mode 100644 tests/_files/cfdi/aaaaaaaa-bbbb-cccc-dddd-000000000002.xml create mode 100644 tests/_files/fake-fiel/.gitignore create mode 100644 tests/_files/fake-fiel/EKU9003173C9-efirma.json create mode 100644 tests/_files/fake-fiel/EKU9003173C9-password.txt create mode 100644 tests/_files/fake-fiel/EKU9003173C9.cer create mode 100644 tests/_files/fake-fiel/EKU9003173C9.key create mode 100644 tests/_files/fake-fiel/README.md create mode 100644 tests/_files/metadata.zip create mode 100644 tests/bootstrap.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..68c2af8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +* +!bin/ +!docs/ +!src/ +!composer.json +!Dockerfile +!Docker.README.md +!LICENSE +!README.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d874bfe --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf + +[**.{yml,yml.dist,neon,neon.dist}] +indent_size = 2 + +[**.{php,xml,yml,json,dist}] +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[**.md] +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..57ad2fd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,24 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Do not put this files on a distribution package (by .gitignore) +/tools/ export-ignore +/vendor/ export-ignore +/composer.lock export-ignore + +# Do not put this files on a distribution package +/.github/ export-ignore +/.phive/ export-ignore +/build/ export-ignore +/tests/ export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/box.json.dist export-ignore +/phpcs.xml.dist export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/rector.php export-ignore + +# Do not count these files on github code language +/tests/_files/** linguist-detectable=false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..91f55ca --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# see https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners +/.github/* @phpcfdi/core-maintainers diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..75df0bd --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,124 @@ +name: build +on: + workflow_dispatch: + pull_request: + branches: [ "main" ] + push: + branches: [ "main" ] + schedule: + - cron: '0 16 * * 0' # sunday 16:00 + +# Actions +# shivammathur/setup-php@v2 https://github.com/marketplace/actions/setup-php-action + +jobs: + + composer-normalize: + name: Composer normalization + runs-on: "ubuntu-latest" + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: composer-normalize + env: + fail-fast: true + - name: Composer normalize + run: composer-normalize + + phpcs: + name: Code style (phpcs) + runs-on: "ubuntu-latest" + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: cs2pr, phpcs + env: + fail-fast: true + - name: Code style (phpcs) + run: phpcs -q --report=checkstyle | cs2pr + + php-cs-fixer: + name: Code style (php-cs-fixer) + runs-on: "ubuntu-latest" + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: cs2pr, php-cs-fixer + env: + fail-fast: true + - name: Code style (php-cs-fixer) + run: php-cs-fixer fix --dry-run --format=checkstyle | cs2pr + + phpstan: + name: Code analysis (phpstan) + runs-on: "ubuntu-latest" + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: composer:v2, phpstan + env: + fail-fast: true + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: "${{ steps.composer-cache.outputs.dir }}" + key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}" + restore-keys: "${{ runner.os }}-composer-" + - name: Install project dependencies + run: composer upgrade --no-interaction --no-progress --prefer-dist + - name: PHPStan + run: phpstan analyse --no-progress --verbose + + tests: + name: Tests on PHP ${{ matrix.php-version }} + runs-on: "ubuntu-latest" + strategy: + matrix: + php-version: ['8.2', '8.3'] + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + tools: composer:v2 + env: + fail-fast: true + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: "${{ steps.composer-cache.outputs.dir }}" + key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}" + restore-keys: "${{ runner.os }}-composer-" + - name: Install project dependencies + run: composer upgrade --no-interaction --no-progress --prefer-dist + - name: Tests (phpunit) + run: vendor/bin/phpunit --testdox diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..7bb5115 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,35 @@ +name: docker +on: + workflow_dispatch: + release: + types: [ "published" ] + +jobs: + docker: + name: Docker image + runs-on: "ubuntu-latest" + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set application version + run: sed -i "s#@box_git_version@#${{ github.ref_name }}#" bin/*.php + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: "${{ github.repository_owner }}/descarga-masiva" + tags: "type=semver,pattern={{version}}" + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/phar.yml b/.github/workflows/phar.yml new file mode 100644 index 0000000..d4fcc17 --- /dev/null +++ b/.github/workflows/phar.yml @@ -0,0 +1,47 @@ +name: phar +on: + workflow_dispatch: + release: + types: [ "published" ] + +# Actions +# shivammathur/setup-php@v2 https://github.com/marketplace/actions/setup-php-action +# softprops/action-gh-release@v2 https://github.com/softprops/action-gh-release + +jobs: + phar: + name: Create PHAR + runs-on: "ubuntu-latest" + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' # use lower compatible version + coverage: none + tools: composer:v2, box + env: + fail-fast: true + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: "${{ steps.composer-cache.outputs.dir }}" + key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}" + restore-keys: "${{ runner.os }}-composer-" + - name: Install project dependencies + run: composer upgrade --no-interaction --no-progress --prefer-dist --no-dev + - name: Compile PHAR + run: box compile --verbose + - name: Show PHAR information + run: box info build/descarga-masiva.phar --list + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.version }} + files: build/descarga-masiva.phar diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b127cf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# do not include this files on git +/tools/ +/vendor/ +/composer.lock diff --git a/.phive/phars.xml b/.phive/phars.xml new file mode 100644 index 0000000..5790491 --- /dev/null +++ b/.phive/phars.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..5bb85a0 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,52 @@ +setRiskyAllowed(true) + ->setCacheFile(__DIR__ . '/build/php-cs-fixer.cache') + ->setRules([ + '@PSR12' => true, + '@PSR12:risky' => true, + '@PHP71Migration:risky' => true, + '@PHP73Migration' => true, + // symfony + 'class_attributes_separation' => true, + 'whitespace_after_comma_in_array' => true, + 'no_empty_statement' => true, + 'no_extra_blank_lines' => true, + 'type_declaration_spaces' => true, + 'trailing_comma_in_multiline' => ['after_heredoc' => true, 'elements' => ['arrays']], + 'no_blank_lines_after_phpdoc' => true, + 'object_operator_without_whitespace' => true, + 'binary_operator_spaces' => true, + 'phpdoc_scalar' => true, + 'no_trailing_comma_in_singleline' => true, + 'single_quote' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'no_unused_imports' => true, + 'yoda_style' => ['equal' => true, 'identical' => true, 'less_and_greater' => null], + 'standardize_not_equals' => true, + 'concat_space' => ['spacing' => 'one'], + 'linebreak_after_opening_tag' => true, + // symfony:risky + 'no_alias_functions' => true, + 'self_accessor' => true, + // contrib + 'not_operator_with_successor_space' => true, + 'ordered_imports' => ['imports_order' => ['class', 'function', 'const']], // @PSR12 sort_algorithm: none + ]) + ->setFinder( + PhpCsFixer\Finder::create() + ->in(__DIR__) + ->append([__FILE__]) + ->exclude(['tools', 'vendor', 'build', 'tests/_files']) + ) +; diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b11e5da --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,84 @@ +# Código de Conducta Convenido para Contribuyentes + +## Nuestro compromiso + +Nosotros, como miembros, contribuyentes y administradores nos comprometemos a hacer de la participación en nuestra comunidad una experiencia libre de acoso para todo el mundo, independientemente de la edad, dimensión corporal, minusvalía visible o invisible, etnicidad, características sexuales, identidad y expresión de género, nivel de experiencia, educación, nivel socio-económico, nacionalidad, apariencia personal, raza, religión, o identidad u orientación sexual. + +Nos comprometemos a actuar e interactuar de maneras que contribuyan a una comunidad abierta, acogedora, diversa, inclusiva y sana. + +## Nuestros estándares + +Ejemplos de comportamiento que contribuyen a crear un ambiente positivo para nuestra comunidad: + +* Demostrar empatía y amabilidad ante otras personas +* Respeto a diferentes opiniones, puntos de vista y experiencias +* Dar y aceptar adecuadamente retroalimentación constructiva +* Aceptar la responsabilidad y disculparse ante quienes se vean afectados por nuestros errores, aprendiendo de la experiencia +* Centrarse en lo que sea mejor no sólo para nosotros como individuos, sino para la comunidad en general + +Ejemplos de comportamiento inaceptable: + +* El uso de lenguaje o imágenes sexualizadas, y aproximaciones o + atenciones sexuales de cualquier tipo +* Comentarios despectivos (_trolling_), insultantes o derogatorios, y ataques personales o políticos +* El acoso en público o privado +* Publicar información privada de otras personas, tales como direcciones físicas o de correo + electrónico, sin su permiso explícito +* Otras conductas que puedan ser razonablemente consideradas como inapropiadas en un + entorno profesional + +## Aplicación de las responsabilidades + +Los administradores de la comunidad son responsables de aclarar y hacer cumplir nuestros estándares de comportamiento aceptable y tomarán acciones apropiadas y correctivas de forma justa en respuesta a cualquier comportamiento que consideren inapropiado, amenazante, ofensivo o dañino. + +Los administradores de la comunidad tendrán el derecho y la responsabilidad de eliminar, editar o rechazar comentarios, _commits_, código, ediciones de páginas de wiki, _issues_ y otras contribuciones que no se alineen con este Código de Conducta, y comunicarán las razones para sus decisiones de moderación cuando sea apropiado. + +## Alcance + +Este código de conducta aplica tanto a espacios del proyecto como a espacios públicos donde un individuo esté en representación del proyecto o comunidad. Ejemplos de esto incluyen el uso de la cuenta oficial de correo electrónico, publicaciones a través de las redes sociales oficiales, o presentaciones con personas designadas en eventos en línea o no. + +## Aplicación + +Instancias de comportamiento abusivo, acosador o inaceptable de otro modo podrán ser reportadas a los administradores de la comunidad responsables del cumplimiento a través de [coc@phpcfdi.com](). Todas las quejas serán evaluadas e investigadas de una manera puntual y justa. + +Todos los administradores de la comunidad están obligados a respetar la privacidad y la seguridad de quienes reporten incidentes. + +## Guías de Aplicación + +Los administradores de la comunidad seguirán estas Guías de Impacto en la Comunidad para determinar las consecuencias de cualquier acción que juzguen como un incumplimiento de este Código de Conducta: + +### 1. Corrección + +**Impacto en la Comunidad**: El uso de lenguaje inapropiado u otro comportamiento considerado no profesional o no acogedor en la comunidad. + +**Consecuencia**: Un aviso escrito y privado por parte de los administradores de la comunidad, proporcionando claridad alrededor de la naturaleza de este incumplimiento y una explicación de por qué el comportamiento es inaceptable. Una disculpa pública podría ser solicitada. + +### 2. Aviso + +**Impacto en la Comunidad**: Un incumplimiento causado por un único incidente o por una cadena de acciones. + +**Consecuencia**: Un aviso con consecuencias por comportamiento prolongado. No se interactúa con las personas involucradas, incluyendo interacción no solicitada con quienes se encuentran aplicando el Código de Conducta, por un periodo especificado de tiempo. Esto incluye evitar las interacciones en espacios de la comunidad, así como a través de canales externos como las redes sociales. Incumplir estos términos puede conducir a una expulsión temporal o permanente. + +### 3. Expulsión temporal + +**Impacto en la Comunidad**: Una serie de incumplimientos de los estándares de la comunidad, incluyendo comportamiento inapropiado continuo. + +**Consecuencia**: Una expulsión temporal de cualquier forma de interacción o comunicación pública con la comunidad durante un intervalo de tiempo especificado. No se permite interactuar de manera pública o privada con las personas involucradas, incluyendo interacciones no solicitadas con quienes se encuentran aplicando el Código de Conducta, durante este periodo. Incumplir estos términos puede conducir a una expulsión permanente. + +### 4. Expulsión permanente + +**Impacto en la Comunidad**: Demostrar un patrón sistemático de incumplimientos de los estándares de la comunidad, incluyendo conductas inapropiadas prolongadas en el tiempo, acoso de individuos, o agresiones o menosprecio a grupos de individuos. + +**Consecuencia**: Una expulsión permanente de cualquier tipo de interacción pública con la comunidad del proyecto. + +## Atribución + +Este Código de Conducta es una adaptación del [Contributor Covenant][homepage], versión 2.0, +disponible en https://www.contributor-covenant.org/es/version/2/0/code_of_conduct.html + +Las Guías de Impacto en la Comunidad están inspiradas en la [escalera de aplicación del código de conducta de Mozilla](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +Para respuestas a las preguntas frecuentes de este código de conducta, consulta las FAQ en +https://www.contributor-covenant.org/faq. Hay traducciones disponibles en https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..25f28af --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,100 @@ +# Contribuciones + +Las contribuciones son bienvenidas. Aceptamos *Pull Requests* en el [repositorio GitHub][project]. + +Este proyecto se apega al siguiente [Código de Conducta][coc]. +Al participar en este proyecto y en su comunidad, deberás seguir este código. + +## Miembros del equipo + +* [phpCfdi][] - Organización que mantiene el proyecto. +* [Contribuidores][contributors]. + +## Canales de comunicación + +Puedes encontrar ayuda y comentar asuntos relacionados con este proyecto en estos lugares: + +* Comunidad Discord: +* GitHub Issues: + +## Reportar Bugs + +Publica los *Bugs* en la sección [GitHub Issues][issues] del proyecto. + +Sigue las recomendaciones generales de [phpCfdi][] para reportar problemas +. + +Cuando se reporte un *Bug*, por favor incluye la mayor información posible para reproducir el problema, preferentemente +con ejemplos de código o cualquier otra información técnica que nos pueda ayudar a identificar el caso. + +**Recuerda no incluir contraseñas, información personal o confidencial.** + +## Corrección de Bugs + +Apreciamos mucho los *Pull Request* para corregir Bugs. + +Si encuentras un reporte de Bug y te gustaría solucionarlo siéntete libre de hacerlo. +Sigue las directrices de "Agregar nuevas funcionalidades" a continuación. + +## Agregar nuevas funcionalidades + +Si tienes una idea para una nueva funcionalidad revisa primero que existan discusiones o *Pull Requests* +en donde ya se esté trabajando en la funcionalidad. + +Antes de trabajar en la nueva característica, utiliza los "Canales de comunicación" mencionados +anteriormente para platicar acerca de tu idea. Si dialogas tus ideas con la comunidad y los +mantenedores del proyecto, podrás ahorrar mucho esfuerzo de desarrollo y prevenir que tu +*Pull Request* sea rechazado. No nos gusta rechazar contribuciones, pero algunas características +o la forma de desarrollarlas puede que no estén alineadas con el proyecto. + +Considera las siguientes directrices: + +* Usa una rama única que se desprenda de la rama principal. + No mezcles dos diferentes funcionalidades en una misma rama o *Pull Request*. +* Describe claramente y en detalle los cambios que hiciste. +* **Escribe pruebas** para la funcionalidad que deseas agregar. +* **Asegúrate que las pruebas pasan** antes de enviar tu contribución. + Usamos integración continua donde se hace esta verificación, pero es mucho mejor si lo pruebas localmente. +* Intenta enviar una historia coherente, entenderemos cómo cambia el código si los *commits* tienen significado. +* La documentación es parte del proyecto. + Realiza los cambios en los archivos de ayuda para que reflejen los cambios en el código. + +## Proceso de construcción + +```shell +# Actualiza tus dependencias +composer update +phive update --force-accept-unsigned + +# Verificación de estilo de código +composer dev:check-style + +# Corrección de estilo de código +composer dev:fix-style + +# Ejecución de pruebas +composer dev:test + +# Ejecución todo en uno: corregir estilo, verificar estilo y correr pruebas +composer dev:build +``` + +## Construcción del archivo phar + +Se utiliza + +## Ejecutar GitHub Actions localmente + +Puedes utilizar la herramienta [`act`](https://github.com/nektos/act) para ejecutar las GitHub Actions localmente. +Según [`actions/setup-php-action`](https://github.com/marketplace/actions/setup-php-action#local-testing-setup) +puedes ejecutar el siguiente comando para revisar los flujos de trabajo localmente: + +```shell +act -P ubuntu-latest=shivammathur/node:latest +``` + +[phpCfdi]: https://github.com/phpcfdi/ +[project]: https://github.com/phpcfdi/sat-ws-descarga-masiva-cli +[contributors]: https://github.com/phpcfdi/sat-ws-descarga-masiva-cli/graphs/contributors +[coc]: https://github.com/phpcfdi/sat-ws-descarga-masiva-cli/blob/main/CODE_OF_CONDUCT.md +[issues]: https://github.com/phpcfdi/sat-ws-descarga-masiva-cli/issues diff --git a/Docker.README.md b/Docker.README.md new file mode 100644 index 0000000..4290db8 --- /dev/null +++ b/Docker.README.md @@ -0,0 +1,29 @@ +# phpcfdi/sat-ws-descarga-masiva-cli dockerfile helper + +```shell script +# get the project repository on folder "sat-ws-descarga-masiva-cli" +git clone https://github.com/phpcfdi/sat-ws-descarga-masiva-cli.git sat-ws-descarga-masiva-cli + +# build the image "descarga-masiva" from folder "sat-ws-descarga-masiva-cli/" +docker build --tag descarga-masiva sat-ws-descarga-masiva-cli/ + +# remove image sat-ws-descarga-masiva-cli +docker rmi descarga-masiva +``` + +## Run command + +The project is installed on `/opt/source/` and the entry point is the command +`/usr/local/bin/php /opt/source/bin/descarga-masiva.php`. + +```shell +# show help +docker run -it --rm --user="$(id -u):$(id -g)" descarga-masiva --help + +# show list of commands +docker run -it --rm --user="$(id -u):$(id -g)" descarga-masiva list + +# montar un volumen para ejecutar una verificación +docker run -it --rm --user="$(id -u):$(id -g)" --volume="${PWD}:/local" \ + descarga-masiva ws:verifica --efirma /local/efirmas/COSC8001137NA.json a78e09d1-bc39-4c95-bb47-ae59a64bf802 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7d59b7a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM php:8.3-cli-alpine + +COPY . /opt/source +COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer + +# install dependencies for php modules +RUN apk add icu-dev libzip-dev git && \ + docker-php-ext-install zip intl bcmath + +# build project +RUN cd /opt/source && \ + rm -r -f composer.lock vendor && \ + composer update --no-dev + +ENTRYPOINT ["/usr/local/bin/php", "/opt/source/bin/descarga-masiva.php"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d512e45 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 - 2024 PhpCfdi https://www.phpcfdi.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9a3fbb --- /dev/null +++ b/README.md @@ -0,0 +1,332 @@ +# phpcfdi/sat-ws-descarga-masiva-cli + +[![Source Code][badge-source]][source] +[![Packagist PHP Version Support][badge-php-version]][php-version] +[![Discord][badge-discord]][discord] +[![Latest Version][badge-release]][release] +[![Software License][badge-license]][license] +[![Build Status][badge-build]][build] +[![Reliability][badge-reliability]][reliability] +[![Maintainability][badge-maintainability]][maintainability] +[![Code Coverage][badge-coverage]][coverage] +[![Violations][badge-violations]][violations] +[![Total Downloads][badge-downloads]][downloads] +[![Docker Downloads][badge-docker]][docker] + + +> Consumo del web service de descarga masiva del SAT por línea de comandos + +:us: The documentation of this project is in spanish as this is the natural language for intented audience. + +:mexico: La documentación del proyecto está en español porque ese es el lenguaje principal de los usuarios. +También te esperamos en [el canal #phpcfdi de discord](https://discord.gg/aFGYXvX) + +Esta librería contiene un cliente (consumidor) del servicio del SAT de +**Servicio Web de Descarga Masiva de CFDI y Retenciones**. + +## Requerimientos + +Esta herramienta usa **PHP versión 8.2** o superior con las extensiones `xml`, `openssl`, `zip`, `curl`, `intl` y `bcmath`. + +## Instalación + +### Ejecutable + +Puedes descargar el archivo PHAR desde la dirección +. + +```shell +wget https://github.com/phpcfdi/sat-ws-descarga-masiva-cli/releases/latest/download/descarga-masiva.phar -O descarga-masiva.phar +php descarga-masiva.phar --version +``` + +### Phive + +Pendiente. + +### Docker + +```shell +docker pull phpcfdi/descarga-masiva +docker run --rm -it phpcfdi/descarga-masiva --version +``` + +### Composer + +Puedes instalar el proyecto en una carpeta especial y usar la herramienta o como dependencia de tu proyecto. +Personalmente, no recomiendo instalarla como una dependencia de algún proyecto, dado que se trata de +una herramienta y no de un componente o libería. + +```shell +# instalar la herramienta +composer require phpcfdi/sat-ws-descarga-masiva-cli +# ejecutar el script +php vendor/phpcfdi/sat-ws-descarga-masiva-cli/bin/descarga-masiva.php --version +``` + +Suponiendo que la herramienta se instaló en `~/projects/sat-ws-descarga-masiva-cli`, entonces podrías poner después +un script de ejecución como el siguiente en `/usr/local/bin/descarga-masiva` o en `~/.local/bin/descarga-masiva`: + +```bash +!#/usr/bin/env bash -e +php ~/projects/sat-ws-descarga-masiva-cli/bin/descarga-masiva.php "${@}" +``` + +### Instalación desde el repositorio Git + +Puedes decargar el proyecto de github y ejecutar el archivo `bin/descarga-masiva.php`. +Esta opción la recomiendo aún menos, dado que no es fácil mantener la herramienta desde Git. + +```shell +# descargar el proyecto +git clone https://github.com/phpcfdi/sat-ws-descarga-masiva-cli /opt/descarga-masiva +# instalar dependencias +composer --working-dir=/opt/descarga-masiva update --no-dev +# ejecución del proyecto +php /opt/descarga-masiva/bin/descarga-masiva.php --version +``` + +## Ejemplos de uso + +Para entender plenamente el uso del servicio web y los códigos de respuesta consulta la documentación de la librería +[`phpcfdi/sat-ws-descarga-masiva`](https://github.com/phpcfdi/sat-ws-descarga-masiva). + +La aplicación cuenta con dos tipos de comandos: `ws` para trabajar con el servicio y `zip` para trabajar con los paquetes. + +Para obtener la lista de comandos disponibles usa el comando `list`. + +Para obtener ayuda de la aplicación o cualquier comando agrega el parámetro `--help`. + +### Comando `ws:consulta` + +El comando `ws:consulta` presenta una consulta con los parámetros establecidos. + +El siguiente comando presenta una consulta de CFDI de metadata de comprobantes emitidos en el periodo +`2023-01-01 00:00:00` al `2023-12-31 23:59:59` con los datos de la FIEL del RFC `EKU9003173C9`. + +```shell +php bin/descarga-masiva ws:consulta \ + --certificado fiel/EKU9003173C9.cer --llave fiel/EKU9003173C9.key --password=12345679a \ + --desde "2023-01-01 00:00:00" --hasta "2023-12-31 23:59:59" +``` + +Con lo que puede entregar el siguiente resultado: + +```text +Consulta: + Servicio: cfdi + Paquete: Metadata + RFC: EKU9003173C9 + Desde: 2024-01-01T00:00:00.000UTC + Hasta: 2024-12-31T23:59:59.000UTC + Tipo: Emitidos + RFC de/para: (cualquiera) + Documentos: (cualquiera) + Complemento: (cualquiera) + Estado: (cualquiera) + Tercero: (cualquiera) +Resultado: + Consulta: 5000 - Solicitud Aceptada + Identificador de solicitud: ba31f7fa-3713-4395-8e1f-39a79f02f5cc +``` + +Los parámetros `--efirma`, `--certificado`, `--llave`, `--password`, `--token` son de autenticación +y se documentan más adelante. + +Adicionalmente, se pueden especificar los siguientes parámetros: + +- `--servicio`: Si se consultarán los CFDI regulares (`cfdi`) o CFDI de Retención e información de pagos (`retenciones`). + Por omisión: `cfdi`. +- `--tipo`: Si se consultarán los comprobantes emitidos (`emitidos`) o recibidos (`recibidos`). Por omisión: `emitidos`. +- `--paquete`: Si se solicita un paquete de Metadatos (`metadata`) o de XML (`xml`). Por omisión: `metadata`. + +Y los siguientes filtros, que son opcionales: + +- `--estado`: Filtra por el estado se encuentra el comprobante: + Vigentes `vigentes` o canceladas `canceladas`. +- `--rfc`: Filtra la información por RFC, si se solicitan emitidos entonces es el RFC receptor, + si se solicitan recibidos entonces es el RFC emisor. +- `--documento`: Filtra por el tipo de documento: + Ingreso (`ingreso`), egreso (`egreso`), traslado (`traslado`), pago (`pago`) o nómina (`nomina`). +- `--complemento`: Filtra por el tipo de complemento, ver el comando `info:complementos`. +- `--tercero`: Filtra por el RFC a cuenta de terceros. + +También se pueden hacer consultas por UUID con el paámetro `--uuid`. En caso de usar el filtro de UUID entonces +no se toman en cuenta los parámetros `--desde`, `--hasta` o cualquiera de los filtros antes mencionados. + +En la respuesta, entrega el resultado de la operación y el identificador de la solicitud, +que puede ser usado después en el comando `ws:verifica`. + +### Comando `ws:verifica` + +El comando `ws:verifica` verifica una consulta previamente presentada con los parámetros establecidos. + +El siguiente comando verifica una consulta de CFDI con el identificador `ba31f7fa-3713-4395-8e1f-39a79f02f5cc`. + +```shell +php bin/descarga-masiva ws:verifica \ + --certificado fiel/EKU9003173C9.cer --llave fiel/EKU9003173C9.key --password=12345679a \ + ba31f7fa-3713-4395-8e1f-39a79f02f5cc +``` + +En la respuesta, entrega el resultado de la operación y el identificador de uno o más paquetes para descarga, +que pueden ser usados después en el comando `ws:descarga`. + +```text +Verificación: + RFC: EKU9003173C9 + Identificador de la solicitud: ba31f7fa-3713-4395-8e1f-39a79f02f5cc +Resultado: + Verificación: 5000 - Solicitud Aceptada + Estado de la solicitud: 3 - Terminada + Estado de la descarga: 5000 - Solicitud recibida con éxito + Número de CFDI: 572 + Paquetes: BA31F7FA-3713-4395-8E1F-39A79F02F5CC_01 +``` + +Los parámetros `--efirma`, `--certificado`, `--llave`, `--password`, `--token` son de autenticación +y se documentan más adelante. + +Adicionalmente, se pueden especificar los siguientes parámetros: + +- `--servicio`: Si se verificará la consulta en el servicio web de CFDI regulares (`cfdi`) o + de CFDI de Retención e información de pagos (`retenciones`). Por omisión: `cfdi`. + +### Comando `ws:descarga` + +El comando `ws:descarga` descarga un paquete de una consulta previamente verificada. + +El siguiente comando descarga un paquete de CFDI con el identificador `BA31F7FA-3713-4395-8E1F-39A79F02F5CC_01` +en el directorio de destino `storage/paquetes`. + +```shell +php bin/descarga-masiva ws:descarga \ + --certificado fiel/EKU9003173C9.cer --llave fiel/EKU9003173C9.key --password=12345679a \ + --destino storage/paquetes BA31F7FA-3713-4395-8E1F-39A79F02F5CC_01 +``` + +En la respuesta, entrega el resultado de la operación y el identificador de uno o más paquetes para descarga, +que pueden ser usados después en el comando `ws:descarga`. + +```text +Descarga: + RFC: DIM8701081LA + Identificador del paquete: BA31F7FA-3713-4395-8E1F-39A79F02F5CC_01 + Destino: storage/paquetes/ba31f7fa-3713-4395-8e1f-39a79f02f5cc_01.zip +Resultado: + Descarga: 5000 - Solicitud Aceptada + Tamaño: 216126 +``` + +Los parámetros `--efirma`, `--certificado`, `--llave`, `--password`, `--token` son de autenticación +y se documentan más adelante. + +Adicionalmente, se pueden especificar los siguientes parámetros: + +- `--servicio`: Si se descargará el paquete en el servicio web de CFDI regulares (`cfdi`) o + de CFDI de Retención e información de pagos (`retenciones`). Por omisión: `cfdi`. +- `--destino`: Si se establece, determina en qué carpeta se descargará el paquete, + en caso de no usarse se utiliza el directorio actual. + +### Parámetros de autenticación + +Los parámetros `--efirma`, `--certificado`, `--llave`, `--password` y `--token` son de autenticación +y se utilizan en los comandos `ws:consulta`, `ws:descarga` y `ws:verifica`. + +- `--efirma`: Ruta absoluta o relativa al archivo de especificación de eFirma. +- `--certificado`: Ruta absoluta o relativa al archivo de certificado. +- `--llave`: Ruta absoluta o relativa al archivo de llave privada. +- `--password`: Contraseña de la llave privada, si no se especifica entonces usa el valor de la + variable de entorno `EFIRMA_PASSPHRASE`. +- `--token`: Ruta absoluta o relativa a un archivo *Token* (que genera esta aplicación). + +Es recomendado establecer la ruta del *token*, es en donde se almacena la autenticación con el servicio web +del SAT y se intenta reutilizar para no realizar más peticiones de autenticación de las necesarias. + +#### Archivos de eFirma + +Para no tener que especificar los parámetros `--certificado`, `--llave`, `--password` y `--token`, se puede +especificar el parámetro `--efirma` que espera la ubicación a un archivo JSON con la siguiente estructura: + +- `certificateFile`: Ruta absoluta o relativa al archivo de certificado CER. +- `privateKeyFile`: Ruta absoluta o relativa al archivo de llave privada KEY. +- `passPhrase`: Contraseña de la llave privada. +- `tokenFile`: Ruta absoluta o relativa a un archivo *Token* (que genera esta aplicación). + +### Comando `info:complementos` + +El comando `info:complementos` muestra la información de los complementos registrados para usarse en una consulta. + +Adicionalmente, se pueden especificar los siguientes parámetros: + +- `--servicio`: Si se mostrarán los complementos del servicio web de CFDI regulares (`cfdi`) o + de CFDI de Retención e información de pagos (`retenciones`). Por omisión: `cfdi`. + +### Comando `zip:metadata` + +El comando `zip:metadata` lee un paquete de metadatos desde `paquetes/ba31f7fa-3713-4395-8e1f-39a79f02f5cc_01.zip` +y exporta su información a un archivo de Excel en `archivos/listado.xlsx`. + +```shell +php bin/descarga-masiva zip:metadata paquetes/ba31f7fa-3713-4395-8e1f-39a79f02f5cc_01.zip archivos/listado.xlsx +``` + +### Comando `zip:xml` + +El comando `zip:xml` lee un paquete de XML y exporta los comprobantes a un directorio, el nombre de cada +comprobante es el UUID con la extensión `.xml`. + +El siguiente comando lee un paquete de XML desde `paquetes/ba31f7fa-3713-4395-8e1f-39a79f02f5cc_01.zip` +y exporta todos los archivos de comprobantes en el directorio `archivos/xml/`. + +```shell +php bin/descarga-masiva zip:metadata paquetes/ba31f7fa-3713-4395-8e1f-39a79f02f5cc_01.zip archivos/xml/ +``` + +## Compatibilidad + +Esta librería se mantendrá compatible con al menos la versión con +[soporte activo de PHP](https://www.php.net/supported-versions.php) más reciente. + +También utilizamos [Versionado Semántico 2.0.0](https://semver.org/lang/es/) +por lo que puedes usar esta librería sin temor a romper tu aplicación. + +## Contribuciones + +Las contribuciones con bienvenidas. Por favor lee [CONTRIBUTING][] para más detalles +y recuerda revisar el archivo de tareas pendientes [TODO][] y el archivo [CHANGELOG][]. + +## Copyright and License + +The `phpcfdi/sat-ws-descarga-masiva-cli` project is copyright © [PhpCfdi](https://www.phpcfdi.com) +and licensed for use under the MIT License (MIT). Please see [LICENSE][] for more information. + +[contributing]: https://github.com/phpcfdi/sat-ws-descarga-masiva-cli/blob/main/CONTRIBUTING.md +[changelog]: https://github.com/phpcfdi/sat-ws-descarga-masiva-cli/blob/main/docs/CHANGELOG.md +[todo]: https://github.com/phpcfdi/sat-ws-descarga-masiva-cli/blob/main/docs/TODO.md + +[source]: https://github.com/phpcfdi/sat-ws-descarga-masiva-cli +[php-version]: https://packagist.org/packages/phpcfdi/sat-ws-descarga-masiva-cli +[discord]: https://discord.gg/aFGYXvX +[release]: https://github.com/phpcfdi/sat-ws-descarga-masiva-cli/releases +[license]: https://github.com/phpcfdi/sat-ws-descarga-masiva-cli/blob/main/LICENSE +[build]: https://github.com/phpcfdi/sat-ws-descarga-masiva-cli/actions/workflows/build.yml?query=branch:main +[reliability]:https://sonarcloud.io/component_measures?id=phpcfdi_sat-ws-descarga-masiva-cli&metric=Reliability +[maintainability]: https://sonarcloud.io/component_measures?id=phpcfdi_sat-ws-descarga-masiva-cli&metric=Maintainability +[coverage]: https://sonarcloud.io/component_measures?id=phpcfdi_sat-ws-descarga-masiva-cli&metric=Coverage +[violations]: https://sonarcloud.io/project/issues?id=phpcfdi_sat-ws-descarga-masiva-cli&resolved=false +[downloads]: https://packagist.org/packages/phpcfdi/sat-ws-descarga-masiva-cli +[docker]: https://hub.docker.com/repository/docker/phpcfdi/descarga-masiva + +[badge-source]: https://img.shields.io/badge/source-phpcfdi/sat--ws--descarga--masiva--cli-blue?logo=github +[badge-discord]: https://img.shields.io/discord/459860554090283019?logo=discord +[badge-php-version]: https://img.shields.io/packagist/php-v/phpcfdi/sat-ws-descarga-masiva-cli?logo=php +[badge-release]: https://img.shields.io/github/release/phpcfdi/sat-ws-descarga-masiva-cli?logo=git +[badge-license]: https://img.shields.io/github/license/phpcfdi/sat-ws-descarga-masiva-cli?logo=open-source-initiative +[badge-build]: https://img.shields.io/github/actions/workflow/status/phpcfdi/sat-ws-descarga-masiva-cli/build.yml?branch=main&logo=github-actions +[badge-reliability]: https://sonarcloud.io/api/project_badges/measure?project=phpcfdi_sat-ws-descarga-masiva-cli&metric=reliability_rating +[badge-maintainability]: https://sonarcloud.io/api/project_badges/measure?project=phpcfdi_sat-ws-descarga-masiva-cli&metric=sqale_rating +[badge-coverage]: https://img.shields.io/sonar/coverage/phpcfdi_sat-ws-descarga-masiva-cli/main?logo=sonarcloud&server=https%3A%2F%2Fsonarcloud.io +[badge-violations]: https://img.shields.io/sonar/violations/phpcfdi_sat-ws-descarga-masiva-cli/main?format=long&logo=sonarcloud&server=https%3A%2F%2Fsonarcloud.io +[badge-downloads]: https://img.shields.io/packagist/dt/phpcfdi/sat-ws-descarga-masiva-cli?logo=packagist +[badge-docker]: https://img.shields.io/docker/pulls/phpcfdi/descarga-masiva?logo=docker diff --git a/bin/descarga-masiva.php b/bin/descarga-masiva.php new file mode 100644 index 0000000..673ee2d --- /dev/null +++ b/bin/descarga-masiva.php @@ -0,0 +1,27 @@ +setCatchExceptions(true); + +// ... register commands +$application->add(new QueryCommand()); +$application->add(new VerifyCommand()); +$application->add(new DownloadCommand()); +$application->add(new ListComplementsCommand()); +$application->add(new ZipExportMetadataCommand()); +$application->add(new ZipExportXmlCommand()); + +/** @noinspection PhpUnhandledExceptionInspection */ +exit($application->run()); diff --git a/box.json.dist b/box.json.dist new file mode 100644 index 0000000..379c3f0 --- /dev/null +++ b/box.json.dist @@ -0,0 +1,7 @@ +{ + "main": "bin/descarga-masiva.php", + "output": "build/descarga-masiva.phar", + "chmod": "0700", + "compression": "GZ", + "git": "box_git_version" +} diff --git a/build/.gitignore b/build/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/build/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7f36ea0 --- /dev/null +++ b/composer.json @@ -0,0 +1,83 @@ +{ + "name": "phpcfdi/sat-ws-descarga-masiva-cli", + "description": "Consumo del web service de descarga masiva del SAT por línea de comandos", + "license": "MIT", + "keywords": [ + "sat", + "cfdi", + "download", + "descarga", + "webservice" + ], + "authors": [ + { + "name": "Carlos C Soto", + "email": "eclipxe13@gmail.com" + } + ], + "homepage": "https://github.com/phpcfdi/sat-ws-descarga-masiva-cli", + "support": { + "issues": "https://github.com/phpcfdi/sat-ws-descarga-masiva-cli/issues", + "chat": "https://discord.gg/aFGYXvX", + "source": "https://github.com/phpcfdi/sat-ws-descarga-masiva-cli" + }, + "require": { + "php": "^8.2", + "ext-json": "*", + "azjezz/psl": "^3.0.2", + "eclipxe/enum": "^0.2.6", + "eclipxe/xlsxexporter": "^2.0.0", + "guzzlehttp/guzzle": "^7.8.1", + "phpcfdi/sat-ws-descarga-masiva": "^0.5.4", + "psr/log": "^3.0", + "symfony/console": "^7.1.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.2.6" + }, + "autoload": { + "psr-4": { + "PhpCfdi\\SatWsDescargaMasiva\\CLI\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PhpCfdi\\SatWsDescargaMasiva\\CLI\\Tests\\": "tests/" + } + }, + "scripts": { + "dev:build": [ + "@dev:fix-style", + "@dev:test" + ], + "dev:check-style": [ + "@php tools/composer-normalize normalize --dry-run", + "@php tools/php-cs-fixer fix --dry-run --verbose", + "@php tools/phpcs --colors -sp" + ], + "dev:coverage": [ + "@php -dzend_extension=xdebug.so -dxdebug.mode=coverage vendor/bin/phpunit --coverage-html build/coverage/html/" + ], + "dev:fix-style": [ + "@php tools/composer-normalize normalize", + "@php tools/php-cs-fixer fix --verbose", + "@php tools/phpcbf --colors -sp" + ], + "dev:phar": [ + "@php tools/box compile --verbose" + ], + "dev:test": [ + "@dev:check-style", + "@php tools/phpstan analyse --no-interaction --no-progress", + "@php vendor/bin/phpunit --testdox --stop-on-failure --exclude-group integration" + ] + }, + "scripts-descriptions": { + "dev:build": "DEV: run dev:fix-style and dev:tests, run before pull request", + "dev:check-style": "DEV: search for code style errors using composer-normalize, php-cs-fixer and phpcs", + "dev:coverage": "DEV: run phpunit with xdebug and storage coverage in build/coverage/html/", + "dev:fix-style": "DEV: fix code style errors using composer-normalize, php-cs-fixer and phpcbf", + "dev:phar": "DEV: build phar file", + "dev:test": "DEV: run @dev:check-style, phpstan and phpunit" + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..6997eb8 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,23 @@ +CHANGELOG + +## Acerca de los números de versiones + +Respetamos el estándar [Versionado Semántico 2.0.0](https://semver.org/lang/es/). + +En resumen, [SemVer](https://semver.org/) es un sistema de versiones de tres componentes `X.Y.Z` +que nombraremos así: ` Breaking . Feature . Fix `, donde: + +- `Breaking`: Rompe la compatibilidad de código con versiones anteriores. +- `Feature`: Agrega una nueva característica que es compatible con lo anterior. +- `Fix`: Incluye algún cambio (generalmente correcciones) que no agregan nueva funcionalidad. + +**Importante:** Las reglas de SEMVER no aplican si estás usando una rama (por ejemplo `main-dev`) +o estás usando una versión cero (por ejemplo `0.18.4`). + +## Versión 1.0.0 2024-10-18 + +- Primera versión pública. + +## Versión 0.1.0 2020-01-01 + +- Trabajo inicial para hacer pruebas con amigos. diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 0000000..6188a2c --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,15 @@ +# phpcfdi/sat-ws-descarga-masiva-cli To Do List + +## Tareas pendientes + +### Salida de comandos en formato JSON + +Si la salida de comandos fuera JSON, sería más fácil poder integrar la herramienta en otros entornos de ejecución. + +## Posibles ideas + +Ninguna. + +## Tareas resueltas + +Ninguna. diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..2792d14 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,31 @@ + + + The EngineWorks (PSR-12 based) coding standard. + + bin + src + tests + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..6a96904 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,6 @@ +parameters: + level: max + paths: + - bin/ + - src/ + - tests/ \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..214e15c --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + + src + + + + + + tests + + + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..463943e --- /dev/null +++ b/rector.php @@ -0,0 +1,21 @@ +withPaths([ + __DIR__ . '/bin', + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->withPhpSets() + ->withSets([ + PHPUnitSetList::PHPUNIT_100, + ]) + ->withRules([ + AddVoidReturnTypeWhereNoReturnRector::class, + ]); diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..a8c47bf --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,10 @@ +sonar.organization=phpcfdi +sonar.projectKey=phpcfdi_sat-ws-descarga-masiva-cli +sonar.sourceEncoding=UTF-8 +sonar.language=php +sonar.sources=src +sonar.tests=tests +sonar.test.exclusions=tests/_files/**/* +sonar.working.directory=build/.scannerwork +sonar.php.tests.reportPath=build/sonar-junit.xml +sonar.php.coverage.reportPaths=build/sonar-coverage.xml \ No newline at end of file diff --git a/src/Commands/Common/LabelMethodsTrait.php b/src/Commands/Common/LabelMethodsTrait.php new file mode 100644 index 0000000..4cadb74 --- /dev/null +++ b/src/Commands/Common/LabelMethodsTrait.php @@ -0,0 +1,110 @@ +isIssued() => 'Emitidos', + $downloadType->isReceived() => 'Recibidos', + default => throw new LogicException( + sprintf("Don't know the label for DownloadType %s", $downloadType->value()) + ) + }; + } + + public function getServiceTypeLabel(ServiceType $serviceType): string + { + return match (true) { + $serviceType->isCfdi() => 'Cfdi', + $serviceType->isRetenciones() => 'Retenciones', + default => throw new LogicException( + sprintf("Don't know the label for ServiceType %s", $serviceType->value()) + ) + }; + } + + public function getRequestTypeLabel(RequestType $requestType): string + { + return match (true) { + $requestType->isMetadata() => 'Metadata', + $requestType->isXml() => 'XML', + default => throw new LogicException( + sprintf("Don't know the label for RequestType %s", $requestType->value()) + ) + }; + } + + public function getRfcMatchLabel(RfcMatch $rfcMatch): string + { + if ($rfcMatch->isEmpty()) { + return '(cualquiera)'; + } + + return $rfcMatch->getValue(); + } + + public function getDocumentTypeLabel(DocumentType $documentType): string + { + return match (true) { + $documentType->isIngreso() => 'Ingreso', + $documentType->isEgreso() => 'Egreso', + $documentType->isNomina() => 'Nómina', + $documentType->isPago() => 'Pago', + $documentType->isTraslado() => 'Traslado', + $documentType->isUndefined() => '(cualquiera)', + default => throw new LogicException( + sprintf("Don't know the label for DocumentType %s", $documentType->value()) + ) + }; + } + + public function getComplementLabel(ComplementoInterface $complement): string + { + if (! $complement->value()) { + return '(cualquiera)'; + } + return sprintf('(%s) %s', $complement->value(), $complement->label()); + } + + public function getDocumentStatusLabel(DocumentStatus $documentStatus): string + { + return match (true) { + $documentStatus->isActive() => 'Vigentes', + $documentStatus->isCancelled() => 'Canceladas', + default => '(cualquiera)' + }; + } + + public function getOnBehalfLabel(RfcOnBehalf $rfcOnBehalf): string + { + if ($rfcOnBehalf->isEmpty()) { + return '(cualquiera)'; + } + + return $rfcOnBehalf->getValue(); + } + + public function getUuidLabel(Uuid $uuid): string + { + if ($uuid->isEmpty()) { + return '(cualquiera)'; + } + return $uuid->getValue(); + } +} diff --git a/src/Commands/DownloadCommand.php b/src/Commands/DownloadCommand.php new file mode 100644 index 0000000..b8effb0 --- /dev/null +++ b/src/Commands/DownloadCommand.php @@ -0,0 +1,122 @@ +setDescription('Descarga un paquete'); + // TODO: poner una descripción más larga + $this->setHelp('Descarga un paquete previamente confirmado'); + + $this->addOption('destino', '', InputOption::VALUE_REQUIRED, 'Carpeta de destino', ''); + + $this->addArgument('paquete', InputArgument::REQUIRED, 'Identificador del paquete'); + } + + private function buildPackageIdFromInput(InputInterface $input): string + { + /** @var string $packageId */ + $packageId = $input->getArgument('paquete'); + if (! is_string($packageId)) { + throw new InputException('El argumento "paquete" no es válido', 'paquete'); + } + + return $packageId; + } + + private function buildDestinationFromInput(InputInterface $input): string + { + /** @var string $destinationFolder */ + $destinationFolder = $input->getOption('destino'); + if ('' === $destinationFolder) { + $destinationFolder = '.'; + } + + $fs = new Filesystem(); + if (! $fs->isDirectory($destinationFolder)) { + throw new InputException('La opción "destino" no es una carpeta', 'destino'); + } + if (! $fs->isWritable($destinationFolder)) { + throw new InputException('La opción "destino" no tiene los permisos de escritura', 'destino'); + } + + return $destinationFolder; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $serviceBuilder = new ServiceBuilder($input, $output); + $service = $serviceBuilder->obtainService(); + + $packageId = $this->buildPackageIdFromInput($input); + $destinationFolder = $this->buildDestinationFromInput($input); + $destinationFile = sprintf( + '%s%s%s.zip', + rtrim($destinationFolder, DIRECTORY_SEPARATOR), + DIRECTORY_SEPARATOR, + strtolower($packageId) + ); + + $output->writeln([ + 'Descarga:', + sprintf(' RFC: %s', $serviceBuilder->obtainRfc()), + sprintf(' Identificador del paquete: %s', $packageId), + sprintf(' Destino: %s', $destinationFile), + ]); + + $downloadResult = $service->download($packageId); + $downloadStatus = $downloadResult->getStatus(); + + $output->writeln([ + 'Resultado:', + sprintf(' Descarga: %d - %s', $downloadStatus->getCode(), $downloadStatus->getMessage()), + sprintf(' Tamaño: %d', $downloadResult->getPackageSize()), + ]); + + return $this->processResult($downloadResult, $destinationFile); + } + + public function processResult(DownloadResult $downloadResult, string $destinationFile): int + { + $status = $downloadResult->getStatus(); + if (! $status->isAccepted()) { + throw ExecutionException::make( + sprintf('La petición no fue aceptada: %s - %s', $status->getCode(), $status->getMessage()) + ); + } + + try { + $fs = new Filesystem(); + $fs->write($destinationFile, $downloadResult->getPackageContent()); + } catch (RuntimeException $exception) { + throw ExecutionException::make( + sprintf('No se ha podido escribir el archivo %s', $destinationFile), + $exception + ); + } + + return self::SUCCESS; + } +} diff --git a/src/Commands/Exceptions/ExecutionException.php b/src/Commands/Exceptions/ExecutionException.php new file mode 100644 index 0000000..5c9707e --- /dev/null +++ b/src/Commands/Exceptions/ExecutionException.php @@ -0,0 +1,16 @@ +argumentName; + } +} diff --git a/src/Commands/ListComplementsCommand.php b/src/Commands/ListComplementsCommand.php new file mode 100644 index 0000000..4651307 --- /dev/null +++ b/src/Commands/ListComplementsCommand.php @@ -0,0 +1,61 @@ +setDescription('Muestra el listado de complementos de CFDI o Retenciones'); + + $this->addOption('servicio', '', InputOption::VALUE_REQUIRED, 'Cfdi o Retenciones', 'Cfdi'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $labels = $this->buildComplementoLabels($input); + $labels = array_filter($labels, 'boolval', ARRAY_FILTER_USE_KEY); // remove "undefined" + + $table = new Table($output); + $table->setHeaders(['Código', 'Descripción']); + foreach ($labels as $code => $label) { + $table->addRow([$code, $label]); + } + $table->render(); + + return self::SUCCESS; + } + + /** @return array */ + private function buildComplementoLabels(InputInterface $input): array + { + /** @var string $serviceInput */ + $serviceInput = $input->getOption('servicio'); + $serviceInput = strtolower($serviceInput); + return match ($serviceInput) { + 'cfdi' => ComplementoCfdi::getLabels(), + 'retenciones' => ComplementoRetenciones::getLabels(), + default => throw new Exceptions\InputException( + 'La opción "servicio" no es válida, debe ser "cfdi" o "retenciones"', + 'servicio' + ), + }; + } +} diff --git a/src/Commands/QueryCommand.php b/src/Commands/QueryCommand.php new file mode 100644 index 0000000..0aea44a --- /dev/null +++ b/src/Commands/QueryCommand.php @@ -0,0 +1,114 @@ +setDescription('Genera una consulta y devuelve el número de solicitud'); + // TODO: poner una descripción más larga + $this->setHelp('Genera una consulta y devuelve el número de solicitud'); + + $this->addOption('desde', '', InputOption::VALUE_REQUIRED, 'Inicio del periodo de consulta'); + $this->addOption('hasta', '', InputOption::VALUE_REQUIRED, 'Fin del periodo de consulta'); + $this->addOption('tipo', '', InputOption::VALUE_REQUIRED, 'Recibidos o emitidos', 'emitidos'); + $this->addOption('rfc', '', InputOption::VALUE_REQUIRED, 'Filtra por el RFC de contraparte', ''); + $this->addOption('paquete', '', InputOption::VALUE_REQUIRED, 'Xml o metadata', 'metadata'); + $this->addOption('estado', '', InputOption::VALUE_REQUIRED, 'Indefinido, vigentes o canceladas', ''); + $this->addOption( + 'documento', + '', + InputOption::VALUE_REQUIRED, + 'Indefinido, ingreso, egreso, traslado, pago o nómina', + '' + ); + $this->addOption('complemento', '', InputOption::VALUE_REQUIRED, 'Filtra por el tipo de complemento', ''); + $this->addOption('tercero', '', InputOption::VALUE_REQUIRED, 'Filtra por el RFC a cuenta de terceros', ''); + $this->addOption('uuid', '', InputOption::VALUE_REQUIRED, 'Filtra por el UUID especificado', ''); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $serviceBuilder = new ServiceBuilder($input, $output); + $service = $serviceBuilder->obtainService(); + + $serviceType = $serviceBuilder->obtainServiceEndPoints()->getServiceType(); + $queryParameters = $this->buildQueryParametersFromInput($input, $serviceType); + + $output->writeln([ + 'Consulta:', + sprintf(' Servicio: %s', $this->getServiceTypeLabel($serviceType)), + sprintf(' Paquete: %s', $this->getRequestTypeLabel($queryParameters->getRequestType())), + sprintf(' RFC: %s', $serviceBuilder->obtainRfc()), + ]); + + if ($queryParameters->getUuid()->isEmpty()) { + $output->writeln([ + sprintf(' Desde: %s', $queryParameters->getPeriod()->getStart()->format('Y-m-d H:i:s')), + sprintf(' Hasta: %s', $queryParameters->getPeriod()->getEnd()->format('Y-m-d H:i:s')), + sprintf(' Tipo: %s', $this->getDownloadTypeLabel($queryParameters->getDownloadType())), + sprintf(' RFC de/para: %s', $this->getRfcMatchLabel($queryParameters->getRfcMatch())), + sprintf(' Documentos: %s', $this->getDocumentTypeLabel($queryParameters->getDocumentType())), + sprintf(' Complemento: %s', $this->getComplementLabel($queryParameters->getComplement())), + sprintf(' Estado: %s', $this->getDocumentStatusLabel($queryParameters->getDocumentStatus())), + sprintf(' Tercero: %s', $this->getOnBehalfLabel($queryParameters->getRfcOnBehalf())), + ]); + } else { + $output->writeln([ + sprintf(' UUID: %s', $this->getUuidLabel($queryParameters->getUuid())), + ]); + } + + $queryResult = $service->query($queryParameters); + $queryStatus = $queryResult->getStatus(); + + $output->writeln([ + 'Resultado:', + sprintf(' Consulta: %d - %s', $queryStatus->getCode(), $queryStatus->getMessage()), + sprintf(' Identificador de solicitud: %s', $queryResult->getRequestId() ?: '(ninguno)'), + ]); + + return $this->processResult($queryResult); + } + + public function buildQueryParametersFromInput(InputInterface $input, ServiceType $serviceType): QueryParameters + { + $builder = new QueryBuilder($input, $serviceType); + return $builder->build(); + } + + public function processResult(QueryResult $queryResult): int + { + $status = $queryResult->getStatus(); + if (! $status->isAccepted()) { + throw ExecutionException::make( + sprintf('La petición no fue aceptada: %s - %s', $status->getCode(), $status->getMessage()) + ); + } + + return self::SUCCESS; + } +} diff --git a/src/Commands/QueryCommand/QueryBuilder.php b/src/Commands/QueryCommand/QueryBuilder.php new file mode 100644 index 0000000..4d98715 --- /dev/null +++ b/src/Commands/QueryCommand/QueryBuilder.php @@ -0,0 +1,212 @@ +buildPeriod(); + $requestType = $this->buildRequestType(); + $downloadType = $this->buildDownloadType(); + $rfcMatch = $this->buildRfcMatch(); + $documentStatus = $this->buildDocumentStatus(); + $documentType = $this->buildDocumentType(); + $uuid = $this->buildUuid(); + $complement = $this->buildComplement(); + $rfcOnBehalf = $this->buildRfcOnBehalf(); + + return QueryParameters::create($period) + ->withRequestType($requestType) + ->withDownloadType($downloadType) + ->withRfcMatch($rfcMatch) + ->withDocumentStatus($documentStatus) + ->withDocumentType($documentType) + ->withComplement($complement) + ->withUuid($uuid) + ->withRfcOnBehalf($rfcOnBehalf) + ; + } + + public function buildPeriod(): DateTimePeriod + { + $since = $this->buildSince(); + $until = $this->buildUntil(); + try { + return DateTimePeriod::create($since, $until); + } catch (Throwable $exception) { + throw new InputException('El periodo de fechas desde y hasta no es válido', 'hasta', $exception); + } + } + + public function buildSince(): DateTime + { + return $this->buildDateTime('desde'); + } + + public function buildUntil(): DateTime + { + return $this->buildDateTime('hasta'); + } + + public function buildDateTime(string $optionName): DateTime + { + try { + return new DateTime($this->getStringOption($optionName)); + } catch (Throwable $exception) { + throw new InputException( + sprintf('La opción "%s" no se pudo interpretar como fecha', $optionName), + $optionName, + $exception + ); + } + } + + public function buildDownloadType(): DownloadType + { + $downloadTypeInput = strtolower($this->getStringOption('tipo')); + return match ($downloadTypeInput) { + 'recibidos' => DownloadType::received(), + 'emitidos' => DownloadType::issued(), + default => throw new InputException('La opción "tipo" debe ser "recibidos" o "emitidos"', 'tipo'), + }; + } + + public function buildRfcMatch(): RfcMatch + { + $rfc = $this->getStringOption('rfc'); + if ('' === $rfc) { + return RfcMatch::empty(); + } + try { + return RfcMatch::create($rfc); + } catch (InvalidArgumentException $exception) { + throw new InputException('La opción "rfc" tiene un valor inválido', 'rfc', $exception); + } + } + + public function buildRequestType(): RequestType + { + $requestTypeInput = strtolower($this->getStringOption('paquete')); + return match ($requestTypeInput) { + 'metadata' => RequestType::metadata(), + 'xml' => RequestType::xml(), + default => throw new InputException('La opción "paquete" debe ser "xml" o "metadata"', 'paquete'), + }; + } + + public function buildDocumentStatus(): DocumentStatus + { + $documentStatusInput = strtolower($this->getStringOption('estado')); + return match ($documentStatusInput) { + '' => DocumentStatus::undefined(), + 'vigentes' => DocumentStatus::active(), + 'canceladas' => DocumentStatus::cancelled(), + default => throw new InputException( + 'Si se especifica, la opción "estado" debe ser "vigentes" o "canceladas"', + 'estado' + ), + }; + } + + public function buildDocumentType(): DocumentType + { + $documentTypeInput = strtolower(str_replace(['ó', 'Ó'], 'o', $this->getStringOption('documento'))); + return match ($documentTypeInput) { + '' => DocumentType::undefined(), + 'ingreso' => DocumentType::ingreso(), + 'egreso' => DocumentType::egreso(), + 'traslado' => DocumentType::traslado(), + 'pago' => DocumentType::pago(), + 'nomina' => DocumentType::nomina(), + default => throw new InputException( + 'Si se especifica la opción "documento" debe ser "ingreso", "egreso", "traslado", "pago" o "nómina"', + 'documento' + ), + }; + } + + public function buildUuid(): Uuid + { + $uuid = $this->getStringOption('uuid'); + if (! $uuid) { + return Uuid::empty(); + } + try { + return Uuid::create($uuid); + } catch (InvalidArgumentException $exception) { + throw new InputException( + 'Si se especifica la opción "uuid" debe contener un UUID válido', + 'uuid', + $exception + ); + } + } + + public function buildComplement(): ComplementoInterface + { + $isCfdi = $this->serviceType->isCfdi(); + $complement = $this->getStringOption('complemento'); + try { + return $isCfdi ? new ComplementoCfdi($complement) : new ComplementoRetenciones($complement); + } catch (EnumExceptionInterface $exception) { + $message = sprintf( + 'La opción "complemento" de %s tiene un valor inválido', + ($isCfdi) ? 'Cfdi' : 'Retenciones' + ); + throw new InputException($message, 'complemento', $exception); + } + } + + public function buildRfcOnBehalf(): RfcOnBehalf + { + $rfc = $this->getStringOption('tercero'); + if (! $rfc) { + return RfcOnBehalf::empty(); + } + try { + return RfcOnBehalf::create($rfc); + } catch (InvalidArgumentException $exception) { + throw new InputException( + 'La opción "tercero" tiene un valor inválido', + 'tercero', + $exception + ); + } + } + + private function getStringOption(string $name): string + { + /** @phpstan-var string $value */ + $value = $this->input->getOption($name) ?? ''; + return $value; + } +} diff --git a/src/Commands/VerifyCommand.php b/src/Commands/VerifyCommand.php new file mode 100644 index 0000000..cf50ff8 --- /dev/null +++ b/src/Commands/VerifyCommand.php @@ -0,0 +1,103 @@ +setDescription('Verifica el estado de una solicitud'); + // TODO: poner una descripción más larga + $this->setHelp('Verifica el estado de una solicitud'); + + $this->addArgument('solicitud', InputArgument::REQUIRED, 'Identificador de la solicitud'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $serviceBuilder = new ServiceBuilder($input, $output); + $service = $serviceBuilder->obtainService(); + + $requestId = $this->buildRequestIdFromInput($input); + + $output->writeln([ + 'Verificación:', + sprintf(' RFC: %s', $serviceBuilder->obtainRfc()), + sprintf(' Identificador de la solicitud: %s', $requestId), + ]); + + $verifyResult = $service->verify($requestId); + + $verifyStatus = $verifyResult->getStatus(); + $downloadStatus = $verifyResult->getCodeRequest(); + $statusRequest = $verifyResult->getStatusRequest(); + $output->writeln([ + 'Resultado:', + sprintf(' Verificación: %d - %s', $verifyStatus->getCode(), $verifyStatus->getMessage()), + sprintf(' Estado de la solicitud: %d - %s', $statusRequest->getValue(), $statusRequest->getMessage()), + sprintf(' Estado de la descarga: %d - %s', $downloadStatus->getValue(), $downloadStatus->getMessage()), + sprintf(' Número de CFDI: %d', $verifyResult->getNumberCfdis()), + sprintf(' Paquetes: %s', implode(', ', $verifyResult->getPackagesIds())), + ]); + + return $this->processResult($verifyResult); + } + + private function buildRequestIdFromInput(InputInterface $input): string + { + /** @var string $requestId */ + $requestId = $input->getArgument('solicitud'); + return $requestId; + } + + public function processResult(VerifyResult $verifyResult): int + { + $status = $verifyResult->getStatus(); + if (! $status->isAccepted()) { + throw ExecutionException::make( + sprintf('La petición no fue aceptada: %s - %s', $status->getCode(), $status->getMessage()) + ); + } + + $downloadStatus = $verifyResult->getCodeRequest(); + if ( + $downloadStatus->isDuplicated() + || $downloadStatus->isExhausted() + || $downloadStatus->isMaximumLimitReaded() + ) { + throw ExecutionException::make(sprintf( + 'El código de estado de la solicitud de descarga no es correcto: %s - %s', + $downloadStatus->getValue(), + $downloadStatus->getMessage() + )); + } + + $statusRequest = $verifyResult->getStatusRequest(); + if ($statusRequest->isExpired() || $statusRequest->isFailure() || $statusRequest->isRejected()) { + throw ExecutionException::make(sprintf( + 'El estado de solicitud de la descarga no es correcto: %s - %s', + $statusRequest->getValue(), + $statusRequest->getMessage() + )); + } + + return Command::SUCCESS; + } +} diff --git a/src/Commands/WithFielAbstractCommand.php b/src/Commands/WithFielAbstractCommand.php new file mode 100644 index 0000000..2e85c66 --- /dev/null +++ b/src/Commands/WithFielAbstractCommand.php @@ -0,0 +1,21 @@ +addOption('efirma', '', InputOption::VALUE_REQUIRED, 'Archivo de configuración de eFirma', ''); + $this->addOption('certificado', '', InputOption::VALUE_REQUIRED, 'Archivo de certificado de eFirma', ''); + $this->addOption('llave', '', InputOption::VALUE_REQUIRED, 'Archivo de llave primaria de eFirma', ''); + $this->addOption('password', '', InputOption::VALUE_REQUIRED, 'Contraseña de llave primaria de eFirma', ''); + $this->addOption('token', '', InputOption::VALUE_REQUIRED, 'Archivo de almacenamiento temporal del token', ''); + $this->addOption('servicio', '', InputOption::VALUE_REQUIRED, 'Cfdi o Retenciones', 'Cfdi'); + } +} diff --git a/src/Commands/ZipExportMetadataCommand.php b/src/Commands/ZipExportMetadataCommand.php new file mode 100644 index 0000000..f31b965 --- /dev/null +++ b/src/Commands/ZipExportMetadataCommand.php @@ -0,0 +1,120 @@ +setDescription('Exporta un archivo ZIP con Metadata a XSLX'); + // TODO: poner una descripción más larga + $this->setHelp('Exporta un archivo ZIP con Metadata a XSLX'); + + $this->addArgument('metadata', InputArgument::REQUIRED, 'Archivo del paquete metadata'); + $this->addArgument('destino', InputArgument::REQUIRED, 'Archivo de salida xlsx'); + } + + public function buildSourceFromInput(InputInterface $input): string + { + /** @var string $source */ + $source = $input->getArgument('metadata'); + return $source; + } + + public function buildDestinationFromInput(InputInterface $input): string + { + /** @var string $destination */ + $destination = $input->getArgument('destino'); + return $destination; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $sourcePath = $this->buildSourceFromInput($input); + $destinationPath = $this->buildDestinationFromInput($input); + + // create package reader + $fs = new Filesystem(); + try { + if (! $fs->exists($sourcePath) || $fs->isDirectory($sourcePath)) { + throw new RuntimeException("El archivo $sourcePath no existe"); + } + $packageReader = MetadataPackageReader::createFromFile($sourcePath); + } catch (RuntimeException $exception) { + throw ExecutionException::make("El archivo $sourcePath no se pudo abrir", $exception); + } + + // set up workbook + $dateTimeStyle = new Style(['format' => ['code' => Styles\Format::FORMAT_DATE_YMDHM]]); + $currencyStyle = new Style([ + 'format' => ['code' => Styles\Format::FORMAT_ACCOUNTING_00], + ]); + $count = $packageReader->count(); + /** + * @see MetadataItem for column names + * @var Iterator $iterator + */ + $iterator = $packageReader->metadata(); + $provider = new MetadataProviderIterator($iterator, $count); + $workbook = new WorkBook( + new WorkSheets( + new WorkSheet('data', $provider, new Columns( + new Column('uuid', 'UUID'), + new Column('rfcEmisor', 'RFC Emisor'), + new Column('nombreEmisor', 'Emisor'), + new Column('rfcReceptor', 'RFC Receptor'), + new Column('nombreReceptor', 'Receptor'), + new Column('rfcPac', 'RFC PAC'), + new Column('fechaEmision', 'Emisión', CellTypes::DATETIME, $dateTimeStyle), + new Column('fechaCertificacionSat', 'Certificación', CellTypes::DATETIME, $dateTimeStyle), + new Column('monto', 'Monto', CellTypes::NUMBER, $currencyStyle), + new Column('efectoComprobante', 'Efecto'), + new Column('estatus', 'Estado', CellTypes::NUMBER), + new Column('fechaCancelacion', 'Cancelación', CellTypes::DATETIME, $dateTimeStyle), + new Column('rfcACuentaTerceros', 'RFC a cuenta de terceros'), + new Column('nombreACuentaTerceros', 'A cuenta de terceros'), + )), + ), + ); + + // export to file + try { + XlsxExporter::save($workbook, $destinationPath); + } catch (Throwable $exception) { + throw ExecutionException::make("El archivo $destinationPath no se pudo escribir", $exception); + } + + return Command::SUCCESS; + } +} diff --git a/src/Commands/ZipExportMetadataCommand/MetadataProviderIterator.php b/src/Commands/ZipExportMetadataCommand/MetadataProviderIterator.php new file mode 100644 index 0000000..215a868 --- /dev/null +++ b/src/Commands/ZipExportMetadataCommand/MetadataProviderIterator.php @@ -0,0 +1,33 @@ +setDescription('Exporta un archivo ZIP con archivos XML a una carpeta'); + // TODO: poner una descripción más larga + $this->setHelp('Exporta un archivo ZIP con archivos XML a una carpeta'); + + $this->addArgument('paquete', InputArgument::REQUIRED, 'Archivo del paquete CFDI'); + $this->addArgument('destino', InputArgument::REQUIRED, 'Ruta de la carpeta de destino'); + } + + public function buildSourceFromInput(InputInterface $input): string + { + /** @var string $source */ + $source = $input->getArgument('paquete'); + return $source; + } + + public function buildDestinationFromInput(InputInterface $input): string + { + /** @var string $destination */ + $destination = $input->getArgument('destino'); + return $destination; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $sourcePath = $this->buildSourceFromInput($input); + $destinationPath = $this->buildDestinationFromInput($input); + + $fs = new Filesystem(); + try { + if (! $fs->exists($sourcePath)) { + throw new RuntimeException("El archivo $sourcePath no existe"); + } + $cfdiReader = CfdiPackageReader::createFromFile($sourcePath); + } catch (RuntimeException $exception) { + throw ExecutionException::make("El archivo $sourcePath no se pudo abrir", $exception); + } + + if (! $fs->isDirectory($destinationPath)) { + throw ExecutionException::make("La carpeta de destino $destinationPath no existe"); + } + if (! $fs->isWritable($destinationPath)) { + throw ExecutionException::make("La carpeta de destino $destinationPath no se puede escribir"); + } + + $totalFiles = $cfdiReader->count(); + $exported = 0; + foreach ($cfdiReader->cfdis() as $cfdi => $content) { + $destinationFile = sprintf('%s/%s.xml', $destinationPath, $cfdi); + try { + $fs->write($destinationFile, $content); + } catch (Throwable $exception) { + $message = sprintf( + 'Error al escribir %s, se exportaron %d de %d archivos', + $destinationFile, + $exported, + $totalFiles + ); + throw ExecutionException::make($message, $exception); + } + $exported = $exported + 1; + } + $output->writeln(sprintf('Exportados %d archivos', $exported)); + + return Command::SUCCESS; + } +} diff --git a/src/Internal/Filesystem.php b/src/Internal/Filesystem.php new file mode 100644 index 0000000..ce96ddc --- /dev/null +++ b/src/Internal/Filesystem.php @@ -0,0 +1,85 @@ +checkPathIsNonEmpty($path); + return read_file($path); + } + + public function write(string $path, string $content): void + { + if ($this->isDirectory($path)) { + throw new RuntimeException("Path $path is a directory"); + } + $path = $this->checkPathIsNonEmpty($path); + write_file($path, $content); + } + + public function isDirectory(string $path): bool + { + $path = $this->checkPathIsNonEmpty($path); + return is_directory($path); + } + + public function isWritable(string $path): bool + { + $path = $this->checkPathIsNonEmpty($path); + return is_writable($path); + } + + public function exists(string $path): bool + { + $path = $this->checkPathIsNonEmpty($path); + return exists($path); + } + + public function pathAbsoluteOrRelative(string $path, string $relativeTo): string + { + if ('' === $path) { + return ''; + } + + if ($this->isAbsolute($path)) { + return $path; + } + + return rtrim($relativeTo, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path; + } + + private function isAbsolute(string $path): bool + { + if (str_starts_with($path, DIRECTORY_SEPARATOR)) { + return true; + } + if (preg_match('#^[A-Za-z]:[/\\\\]#', $path) && PHP_OS_FAMILY === 'Windows') { + return true; + } + return false; + } + + /** @return non-empty-string */ + private function checkPathIsNonEmpty(string $path): string + { + if ('' === $path) { + throw new RuntimeException('Path cannot be empty'); + } + return $path; + } +} diff --git a/src/Service/ConfigValues.php b/src/Service/ConfigValues.php new file mode 100644 index 0000000..c267f78 --- /dev/null +++ b/src/Service/ConfigValues.php @@ -0,0 +1,21 @@ +logRequest($request); + }, + function (Response $response): void { + $this->logResponse($response); + } + ); + } + + public static function createDefault(LoggerInterface $logger): self + { + return new self( + new Client([ + RequestOptions::CONNECT_TIMEOUT => 10, + RequestOptions::TIMEOUT => 30, + ]), + $logger + ); + } + + private function logRequest(Request $request): void + { + $this->logger->debug(implode(PHP_EOL, [ + 'Request: ' . $request->getMethod() . ' ' . $request->getUri(), + 'Headers: ' . $this->headersToStrings($request->getHeaders()), + 'Body: ' . $request->getBody(), + ])); + } + + private function logResponse(Response $response): void + { + $this->logger->debug(implode(PHP_EOL, [ + 'Response: ' . $response->getStatusCode(), + 'Headers: ' . $this->headersToStrings($response->getHeaders()), + 'Body: ' . $response->getBody(), + ])); + } + + /** @param array $headers */ + private function headersToStrings(array $headers): string + { + $contents = []; + foreach ($headers as $header => $content) { + $contents[] = $header . ': ' . $content; + } + return implode(PHP_EOL . ' ', $contents); + } +} diff --git a/src/Service/ServiceBuilder.php b/src/Service/ServiceBuilder.php new file mode 100644 index 0000000..e514f64 --- /dev/null +++ b/src/Service/ServiceBuilder.php @@ -0,0 +1,197 @@ +fiel ??= $this->buildFielFromInput($this->input); + } + + public function obtainServiceEndPoints(): ServiceEndpoints + { + return $this->serviceEndPoints ??= $this->buildServiceEndpointsFromInput($this->input); + } + + public function obtainStorageToken(): StorageToken + { + return $this->storageToken ??= $this->buildStorageTokenFromInput($this->input); + } + + public function obtainLogger(): LoggerInterface + { + return $this->logger ??= ((! $this->output->isQuiet()) ? new ConsoleLogger($this->output) : new NullLogger()); + } + + public function obtainRfc(): string + { + return $this->obtainFiel()->getRfc(); + } + + public function obtainService(): Service + { + $fiel = $this->obtainFiel(); + $logger = $this->obtainLogger(); + $serviceEndpoints = $this->obtainServiceEndPoints(); + $storageToken = $this->obtainStorageToken(); + return $this->service ??= $this->buildService($fiel, $logger, $serviceEndpoints, $storageToken); + } + + public function buildFielFromInput(InputInterface $input): Fiel + { + $configValues = $this->obtainConfigValues(); + + /** @var string $certificateInput */ + $certificateInput = $input->getOption('certificado') ?: $configValues->certificate; + if ('' === $certificateInput) { + throw new Exceptions\InputException('La opción "certificado" no es válida', 'certificado'); + } + + /** @var string $primaryKeyInput */ + $primaryKeyInput = $input->getOption('llave') ?: $configValues->privateKey; + if ('' === $primaryKeyInput) { + throw new Exceptions\InputException('La opción "llave" no es válida', 'llave'); + } + + if (isset($_SERVER['EFIRMA_PASSPHRASE'])) { + $password = strval($_SERVER['EFIRMA_PASSPHRASE']); + } else { + /** @var string $password */ + $password = $input->getOption('password') ?: $configValues->passPhrase; + } + + $fs = new Filesystem(); + try { + return Fiel::create( + $fs->read($certificateInput), + $fs->read($primaryKeyInput), + $password + ); + } catch (Throwable $exception) { + throw new Exceptions\InputException('No fue posible crear la eFirma', 'certificado', $exception); + } + } + + public function buildServiceEndpointsFromInput(InputInterface $input): ServiceEndpoints + { + /** @var string $serviceInput */ + $serviceInput = $input->getOption('servicio'); + $serviceInput = strtolower($serviceInput); + return match ($serviceInput) { + 'cfdi' => ServiceEndpoints::cfdi(), + 'retenciones' => ServiceEndpoints::retenciones(), + default => throw new Exceptions\InputException( + 'La opción "servicio" no es válida, debe ser "cfdi" o "retenciones"', + 'servicio' + ), + }; + } + + public function buildStorageTokenFromInput(InputInterface $input): StorageToken + { + $configValues = $this->obtainConfigValues(); + + /** @var string $tokenInput */ + $tokenInput = $input->getOption('token') ?: $configValues->tokenFile; + + return new StorageToken($tokenInput); + } + + public function buildService( + Fiel $fiel, + LoggerInterface $logger, + ServiceEndpoints $endPoints, + StorageToken $storageToken + ): Service { + $fielRequestBuilder = new FielRequestBuilder($fiel); + $webClient = GuzzleWebClientWithLogger::createDefault($logger); + return new ServiceWithStorageToken($fielRequestBuilder, $webClient, $storageToken, $endPoints); + } + + public function obtainConfigValues(): ConfigValues + { + return $this->configValues ??= $this->buildConfigValues(); + } + + public function buildConfigValues(): ConfigValues + { + /** @var string $efirmaInput */ + $efirmaInput = $this->input->getOption('efirma'); + if ('' === $efirmaInput) { + return ConfigValues::empty(); + } + return $this->readConfigValues($efirmaInput); + } + + public function readConfigValues(string $configFile): ConfigValues + { + $fs = new Filesystem(); + try { + $contents = $fs->read($configFile); + } catch (Throwable $exception) { + throw new Exceptions\InputException( + "El archivo de configuración de eFirma '$configFile' no se pudo abrir", + 'efirma', + $exception + ); + } + + try { + $values = json_decode($contents, associative: true, flags: JSON_THROW_ON_ERROR); + if (! is_array($values)) { + throw new RuntimeException('Content is not an object'); + } + } catch (JsonException | RuntimeException $exception) { + throw new Exceptions\InputException( + sprintf('El archivo de configuración de eFirma "%s" no se pudo interpretar como JSON', $configFile), + 'efirma', + $exception + ); + } + + $values = array_filter($values, 'is_string'); + + $relativeTo = dirname($configFile); + + return new ConfigValues( + $fs->pathAbsoluteOrRelative($values['certificateFile'] ?? '', $relativeTo), + $fs->pathAbsoluteOrRelative($values['privateKeyFile'] ?? '', $relativeTo), + $values['passPhrase'] ?? '', + $fs->pathAbsoluteOrRelative($values['tokenFile'] ?? '', $relativeTo), + ); + } +} diff --git a/src/Service/ServiceWithStorageToken.php b/src/Service/ServiceWithStorageToken.php new file mode 100644 index 0000000..3c6d95f --- /dev/null +++ b/src/Service/ServiceWithStorageToken.php @@ -0,0 +1,42 @@ +current(), $endpoints); + $this->storageToken = $storageToken; + } + + public function obtainCurrentToken(): Token + { + $current = parent::obtainCurrentToken(); + $stored = $this->storageToken->current(); + if (null === $stored || ! $this->tokensAreEqual($stored, $current)) { + $this->storageToken->store($current); + } + + return $current; + } + + private function tokensAreEqual(Token $first, Token $second): bool + { + return $this->storageToken->serializeToken($first) === $this->storageToken->serializeToken($second); + } +} diff --git a/src/Service/StorageToken.php b/src/Service/StorageToken.php new file mode 100644 index 0000000..57997ea --- /dev/null +++ b/src/Service/StorageToken.php @@ -0,0 +1,122 @@ +fs = new Filesystem(); + } + + /** + * @return Token|null + * @throws RuntimeException + */ + public function current(): ?Token + { + if ('' === $this->filename) { + return $this->inMemoryToken; + } + + $contents = $this->readContents(); + if ('' === $contents) { + return null; + } + + try { + return self::unserializeToken($contents); + } catch (Throwable $exception) { + throw new RuntimeException( + sprintf('Unable to create token from file %s', $this->filename), + previous: $exception + ); + } + } + + public function store(Token $token): void + { + if ('' === $this->filename) { + $this->inMemoryToken = $token; + return; + } + + $content = self::serializeToken($token); + $this->storeContents($content); + } + + /** + * @throws JsonException + * @throws RuntimeException + */ + public static function unserializeToken(string $contents): Token + { + $values = json_decode($contents, associative: true, flags: JSON_THROW_ON_ERROR); + + if (! is_array($values)) { + throw new RuntimeException('Unexpected JSON contents from token'); + } + + if (! isset($values['created']) || ! is_int($values['created'])) { + throw new RuntimeException('Invalid JSON value on key "created"'); + } + $created = DateTime::create($values['created']); + + if (! isset($values['expires']) || ! is_int($values['expires'])) { + throw new RuntimeException('Invalid JSON value on key "expires"'); + } + $expires = DateTime::create($values['expires']); + + if (! isset($values['token']) || ! is_string($values['token']) || '' === $values['token']) { + throw new RuntimeException('Invalid JSON value on key "token"'); + } + $value = $values['token']; + + return new Token($created, $expires, $value); + } + + public static function serializeToken(Token $token): string + { + $values = [ + 'created' => (int) $token->getCreated()->format('U'), + 'expires' => (int) $token->getExpires()->format('U'), + 'token' => $token->getValue(), + ]; + + return (string) json_encode($values); + } + + private function readContents(): string + { + try { + return $this->fs->read($this->filename); + } catch (Throwable) { + return ''; + } + } + + private function storeContents(string $content): void + { + try { + $this->fs->write($this->filename, $content); + } catch (RuntimeException $exception) { + throw new RuntimeException( + sprintf('Unable to write contents on "%s"', $this->filename), + previous: $exception + ); + } + } +} diff --git a/tests/Helpers/ExceptionCatcherTrait.php b/tests/Helpers/ExceptionCatcherTrait.php new file mode 100644 index 0000000..df45f33 --- /dev/null +++ b/tests/Helpers/ExceptionCatcherTrait.php @@ -0,0 +1,22 @@ +path = $tempnam; + } + + public function __destruct() + { + if ($this->remove) { + $this->delete(); + } + } + + public function getPath(): string + { + return $this->path; + } + + public function getContents(): string + { + return (string) file_get_contents($this->path); + } + + public function putContents(string $data): void + { + file_put_contents($this->path, $data); + } + + public function delete(): void + { + if (file_exists($this->path)) { + /** @noinspection PhpUsageOfSilenceOperatorInspection */ + @unlink($this->path); + } + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..44a144e --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,58 @@ +temporaryFiles = []; + } + + protected function tearDown(): void + { + foreach ($this->temporaryFiles as $temporaryFile) { + unlink($temporaryFile); + } + parent::tearDown(); + } + + public static function createTemporaryName(): string + { + $tempnam = tempnam('', ''); + if (false === $tempnam) { + throw new RuntimeException('Unable to create a temporary file name'); + } + return $tempnam; + } + + public static function filePath(string $path): string + { + return __DIR__ . '/_files/' . $path; + } + + public static function fileContents(string $path): string + { + return file_get_contents(static::filePath($path)) ?: ''; + } + + public static function captureException(Closure $function): Throwable + { + try { + $function(); + } catch (Throwable $exception) { + return $exception; + } + throw new RuntimeException('No exception was thrown'); + } +} diff --git a/tests/Unit/Commands/DownloadCommandTest.php b/tests/Unit/Commands/DownloadCommandTest.php new file mode 100644 index 0000000..6dfa6d0 --- /dev/null +++ b/tests/Unit/Commands/DownloadCommandTest.php @@ -0,0 +1,125 @@ + */ + private function buildValidOptions(): array + { + return [ + 'paquete' => 'b9a869bf-8c6c-49f4-945e-126992b3b3e7_01', + '--destino' => (string) getcwd(), + '--efirma' => $this->filePath('fake-fiel/EKU9003173C9-efirma.json'), + ]; + } + + public function testHasDefinedOptions(): void + { + $command = new DownloadCommand(); + $this->assertInstanceOf(WithFielAbstractCommand::class, $command); + $this->assertTrue($command->getDefinition()->hasOption('destino')); + } + + public function testReceiveArguments(): void + { + $command = new DownloadCommand(); + $this->assertTrue($command->getDefinition()->hasArgument('paquete')); + $this->assertSame(1, $command->getDefinition()->getArgumentCount()); + } + + #[Group('integration')] + public function testCommandExecutionWithValidParametersButFakeFiel(): void + { + $command = new DownloadCommand(); + $tester = new CommandTester($command); + $validOptions = $this->buildValidOptions(); + + $this->expectException(ExecutionException::class); + $this->expectExceptionMessage('La petición no fue aceptada: 305 - Certificado Inválido'); + $tester->execute($validOptions); + } + + public function testProcessResultWithValidResult(): void + { + $command = new DownloadCommand(); + $result = new DownloadResult( + new StatusCode(5000, 'Solicitud recibida con éxito'), + 'package-content' + ); + + $destinationFile = $this->createTemporaryName(); + + $this->assertSame($command::SUCCESS, $command->processResult($result, $destinationFile)); + $this->assertFileExists($destinationFile); + $this->assertStringEqualsFile($destinationFile, $result->getPackageContent()); + } + + public function testProcessResultWithInvalidResultStatusCode(): void + { + $command = new DownloadCommand(); + $result = new DownloadResult( + new StatusCode(404, 'Error no controlado'), + 'package-content' + ); + $destinationFile = __DIR__ . '/file-must-not-exists'; + + $executionException = $this->captureException( + fn () => $command->processResult($result, $destinationFile) + ); + + $this->assertInstanceOf(ExecutionException::class, $executionException); + $this->assertStringContainsString('Error no controlado', $executionException->getMessage()); + $this->assertFileDoesNotExist($destinationFile); + } + + public function testProcessResultWithInvalidDestinationFile(): void + { + $command = new DownloadCommand(); + $result = new DownloadResult( + new StatusCode(5000, 'Solicitud recibida con éxito'), + 'package-content' + ); + $destinationFile = __DIR__; + + $this->expectException(ExecutionException::class); + $this->expectExceptionMessage('No se ha podido escribir el archivo'); + $command->processResult($result, $destinationFile); + } + + public function testArgumentPaqueteMissing(): void + { + $command = new DownloadCommand(); + $tester = new CommandTester($command); + $options = $this->buildValidOptions(); + unset($options['paquete']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('paquete'); + $tester->execute($options); + } + + public function testOptionDestinoIsNotDirectory(): void + { + $command = new DownloadCommand(); + $tester = new CommandTester($command); + $options = $this->buildValidOptions(); + $options['--destino'] = __FILE__; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('destino'); + $tester->execute($options); + } +} diff --git a/tests/Unit/Commands/ListComplementsCommandTest.php b/tests/Unit/Commands/ListComplementsCommandTest.php new file mode 100644 index 0000000..d4a6ca9 --- /dev/null +++ b/tests/Unit/Commands/ListComplementsCommandTest.php @@ -0,0 +1,50 @@ +assertTrue($command->getDefinition()->hasOption('servicio')); + $this->assertCount(1, $command->getDefinition()->getOptions()); + } + + public function testReceiveArguments(): void + { + $command = new ListComplementsCommand(); + $this->assertSame(0, $command->getDefinition()->getArgumentCount()); + } + + public function testCommandExecutionWithServicioCfdi(): void + { + $command = new ListComplementsCommand(); + $tester = new CommandTester($command); + $tester->execute(['--servicio' => 'cfdi']); + $display = $tester->getDisplay(); + foreach (ComplementoCfdi::getLabels() as $code => $label) { + $pattern = sprintf('/| %s.*| %s.*|/', preg_quote($code), preg_quote($label)); + $this->assertMatchesRegularExpression($pattern, $display); + } + } + + public function testCommandExecutionWithServicioRetenciones(): void + { + $command = new ListComplementsCommand(); + $tester = new CommandTester($command); + $tester->execute(['--servicio' => 'retenciones']); + $display = $tester->getDisplay(); + foreach (ComplementoCfdi::getLabels() as $code => $label) { + $pattern = sprintf('/| %s.*| %s.*|/', preg_quote($code), preg_quote($label)); + $this->assertMatchesRegularExpression($pattern, $display); + } + } +} diff --git a/tests/Unit/Commands/QueryCommand/QueryBuilderTest.php b/tests/Unit/Commands/QueryCommand/QueryBuilderTest.php new file mode 100644 index 0000000..43b27b8 --- /dev/null +++ b/tests/Unit/Commands/QueryCommand/QueryBuilderTest.php @@ -0,0 +1,307 @@ +getDefinition()); + $serviceType ??= ServiceType::cfdi(); + return new QueryBuilder($input, $serviceType); + } + + public function testBuildPeriodValid(): void + { + $desde = '2020-01-02 03:04:05'; + $hasta = '2020-12-31 23:59:59'; + $queryBuilder = $this->createQueryBuilder([ + '--desde' => $desde, + '--hasta' => $hasta, + ]); + $period = $queryBuilder->buildPeriod(); + $this->assertSame($desde, $period->getStart()->format('Y-m-d H:i:s')); + $this->assertSame($hasta, $period->getEnd()->format('Y-m-d H:i:s')); + } + + /** @return array */ + public static function providerBuildRequestType(): array + { + return [ + 'metadata' => ['METADATA', RequestType::metadata()], + 'xml' => ['XML', RequestType::xml()], + ]; + } + + #[DataProvider('providerBuildRequestType')] + public function testBuildRequestType(string $argument, RequestType $expected): void + { + $queryBuilder = $this->createQueryBuilder(['--paquete' => $argument]); + $requestType = $queryBuilder->buildRequestType(); + $this->assertEquals($expected, $requestType); + } + + public function testBuildRequestTypeInvalid(): void + { + $queryBuilder = $this->createQueryBuilder(['--paquete' => '']); + $catched = $this->catch(function () use ($queryBuilder): void { + $queryBuilder->buildRequestType(); + }); + $this->assertInstanceOf(InputException::class, $catched); + /** @var InputException $catched */ + $this->assertStringContainsString( + 'La opción "paquete" debe ser "xml" o "metadata"', + $catched->getMessage() + ); + $this->assertSame('paquete', $catched->getArgumentName()); + } + + /** @return array */ + public static function providerBuildDownloadType(): array + { + return [ + 'emitidos' => ['EMITIDOS', DownloadType::issued()], + 'recibidos' => ['RECIBIDOS', DownloadType::received()], + ]; + } + + #[DataProvider('providerBuildDownloadType')] + public function testBuildDownloadType(string $argument, DownloadType $expected): void + { + $queryBuilder = $this->createQueryBuilder(['--tipo' => $argument]); + $downloadType = $queryBuilder->buildDownloadType(); + $this->assertEquals($expected, $downloadType); + } + + public function testBuildDownloadTypeInvalid(): void + { + $queryBuilder = $this->createQueryBuilder(['--tipo' => '']); + $catched = $this->catch(function () use ($queryBuilder): void { + $queryBuilder->buildDownloadType(); + }); + $this->assertInstanceOf(InputException::class, $catched); + /** @var InputException $catched */ + $this->assertStringContainsString( + 'La opción "tipo" debe ser "recibidos" o "emitidos"', + $catched->getMessage() + ); + $this->assertSame('tipo', $catched->getArgumentName()); + } + + public function testBuildRfcMatchEmpty(): void + { + $queryBuilder = $this->createQueryBuilder(['--rfc' => '']); + $rfc = $queryBuilder->buildRfcMatch(); + $this->assertTrue($rfc->isEmpty()); + } + + public function testBuildRfcMatchValid(): void + { + $input = 'AAAA010101AAA'; + $queryBuilder = $this->createQueryBuilder(['--rfc' => $input]); + $rfc = $queryBuilder->buildRfcMatch(); + $this->assertSame($input, $rfc->getValue()); + } + + public function testBuildRfcMatchInvalid(): void + { + $queryBuilder = $this->createQueryBuilder(['--rfc' => 'invalid-rfc']); + $catched = $this->catch(function () use ($queryBuilder): void { + $queryBuilder->buildRfcMatch(); + }); + $this->assertInstanceOf(InputException::class, $catched); + /** @var InputException $catched */ + $this->assertStringContainsString( + 'La opción "rfc" tiene un valor inválido', + $catched->getMessage() + ); + $this->assertSame('rfc', $catched->getArgumentName()); + } + + /** @return array */ + public static function providerBuildDocumentStatus(): array + { + return [ + '(empty)' => ['', DocumentStatus::undefined()], + 'vigentes' => ['VIGENTES', DocumentStatus::active()], + 'canceladas' => ['CANCELADAS', DocumentStatus::cancelled()], + ]; + } + + #[DataProvider('providerBuildDocumentStatus')] + public function testBuildDocumentStatus(string $argument, DocumentStatus $expected): void + { + $queryBuilder = $this->createQueryBuilder(['--estado' => $argument]); + $documentStatus = $queryBuilder->buildDocumentStatus(); + $this->assertEquals($expected, $documentStatus); + } + + public function testBuildDocumentStatusInvalid(): void + { + $queryBuilder = $this->createQueryBuilder(['--estado' => 'foo']); + $catched = $this->catch(function () use ($queryBuilder): void { + $queryBuilder->buildDocumentStatus(); + }); + $this->assertInstanceOf(InputException::class, $catched); + /** @var InputException $catched */ + $this->assertStringContainsString( + 'Si se especifica, la opción "estado" debe ser "vigentes" o "canceladas"', + $catched->getMessage() + ); + $this->assertSame('estado', $catched->getArgumentName()); + } + + /** @return array */ + public static function providerBuildDocumentTypeValid(): array + { + return [ + '(empty)' => ['', DocumentType::undefined()], + 'ingreso' => ['INGRESO', DocumentType::ingreso()], + 'egreso' => ['EGRESO', DocumentType::egreso()], + 'traslado' => ['TRASLADO', DocumentType::traslado()], + 'pago' => ['PAGO', DocumentType::pago()], + 'nómina' => ['NÓMINA', DocumentType::nomina()], + 'nomina' => ['NOMINA', DocumentType::nomina()], + ]; + } + + #[DataProvider('providerBuildDocumentTypeValid')] + public function testBuildDocumentTypeValid(string $argument, DocumentType $expectedDocumentType): void + { + $queryBuilder = $this->createQueryBuilder(['--documento' => $argument]); + $documentType = $queryBuilder->buildDocumentType(); + $this->assertEquals($expectedDocumentType, $documentType); + } + + public function testBuildDocumentTypeInvalid(): void + { + $queryBuilder = $this->createQueryBuilder(['--documento' => 'foo']); + $catched = $this->catch(function () use ($queryBuilder): void { + $queryBuilder->buildDocumentType(); + }); + $this->assertInstanceOf(InputException::class, $catched); + /** @var InputException $catched */ + $this->assertStringContainsString( + 'Si se especifica la opción "documento" debe ser "ingreso", "egreso", "traslado", "pago" o "nómina"', + $catched->getMessage() + ); + $this->assertSame('documento', $catched->getArgumentName()); + } + + public function testBuildUuidEmpty(): void + { + $queryBuilder = $this->createQueryBuilder(['--uuid' => '']); + $uuid = $queryBuilder->buildUuid(); + $this->assertTrue($uuid->isEmpty()); + } + + public function testBuildUuidValid(): void + { + $input = 'b84835d8-2c55-4194-88a1-79edd961e4e7'; + $queryBuilder = $this->createQueryBuilder(['--uuid' => $input]); + $uuid = $queryBuilder->buildUuid(); + $this->assertEquals($input, $uuid->getValue()); + } + + public function testBuildUuidInvalid(): void + { + $input = 'invalid-uuid'; + $queryBuilder = $this->createQueryBuilder(['--uuid' => $input]); + $catched = $this->catch(function () use ($queryBuilder): void { + $queryBuilder->buildUuid(); + }); + $this->assertInstanceOf(InputException::class, $catched); + /** @var InputException $catched */ + $this->assertStringContainsString( + 'Si se especifica la opción "uuid" debe contener un UUID válido', + $catched->getMessage() + ); + $this->assertSame('uuid', $catched->getArgumentName()); + } + + public function testBuildComplementEmpty(): void + { + $queryBuilder = $this->createQueryBuilder(['--complemento' => '']); + $complement = $queryBuilder->buildComplement(); + $this->assertTrue($complement->isUndefined()); + } + + public function testBuildComplementCfdiValid(): void + { + $input = 'nomina12'; + $queryBuilder = $this->createQueryBuilder(['--complemento' => $input]); + $complement = $queryBuilder->buildComplement(); + $this->assertSame($input, $complement->value()); + } + + public function testBuildComplementRetencionesValid(): void + { + $input = 'dividendos'; + $queryBuilder = $this->createQueryBuilder(['--complemento' => $input], ServiceType::retenciones()); + $complement = $queryBuilder->buildComplement(); + $this->assertSame($input, $complement->value()); + } + + public function testBuildComplementInvalid(): void + { + $queryBuilder = $this->createQueryBuilder(['--complemento' => 'invalid-complement']); + $catched = $this->catch(function () use ($queryBuilder): void { + $queryBuilder->buildComplement(); + }); + $this->assertInstanceOf(InputException::class, $catched); + /** @var InputException $catched */ + $this->assertStringContainsString( + 'La opción "complemento" de Cfdi tiene un valor inválido', + $catched->getMessage() + ); + $this->assertSame('complemento', $catched->getArgumentName()); + } + + public function testBuildRfcOnBehalfEmpty(): void + { + $queryBuilder = $this->createQueryBuilder(['--tercero' => '']); + $rfc = $queryBuilder->buildRfcOnBehalf(); + $this->assertTrue($rfc->isEmpty()); + } + + public function testBuildRfcOnBehalfValid(): void + { + $input = 'AAAA010101AAA'; + $queryBuilder = $this->createQueryBuilder(['--tercero' => $input]); + $rfc = $queryBuilder->buildRfcOnBehalf(); + $this->assertSame($input, $rfc->getValue()); + } + + public function testBuildRfcOnBehalfInvalid(): void + { + $queryBuilder = $this->createQueryBuilder(['--tercero' => 'invalid-rfc']); + $catched = $this->catch(function () use ($queryBuilder): void { + $queryBuilder->buildRfcOnBehalf(); + }); + $this->assertInstanceOf(InputException::class, $catched); + /** @var InputException $catched */ + $this->assertStringContainsString( + 'La opción "tercero" tiene un valor inválido', + $catched->getMessage() + ); + $this->assertSame('tercero', $catched->getArgumentName()); + } +} diff --git a/tests/Unit/Commands/QueryCommandTest.php b/tests/Unit/Commands/QueryCommandTest.php new file mode 100644 index 0000000..0fbc7e1 --- /dev/null +++ b/tests/Unit/Commands/QueryCommandTest.php @@ -0,0 +1,193 @@ + */ + private function buildValidOptions(): array + { + return [ + '--efirma' => $this->filePath('fake-fiel/EKU9003173C9-efirma.json'), + '--certificado' => $this->filePath('fake-fiel/EKU9003173C9.cer'), + '--llave' => $this->filePath('fake-fiel/EKU9003173C9.key'), + '--password' => trim($this->fileContents('fake-fiel/EKU9003173C9-password.txt')), + '--token' => (new TemporaryFile(remove: false))->getPath(), + '--servicio' => 'cfdi', + '--desde' => '2020-01-01 00:00:00', + '--hasta' => '2020-01-31 23:59:59', + '--tipo' => 'emitidos', + '--rfc' => 'AAAA010101AAA', + '--paquete' => 'metadata', + '--estado' => 'vigentes', + '--documento' => 'nómina', + '--complemento' => 'nomina12', + '--tercero' => 'XXXX010101XXA', + ]; + } + + public function testHasDefinedOptions(): void + { + $command = new QueryCommand(); + $this->assertTrue($command->getDefinition()->hasOption('certificado')); + $this->assertTrue($command->getDefinition()->hasOption('llave')); + $this->assertTrue($command->getDefinition()->hasOption('password')); + $this->assertTrue($command->getDefinition()->hasOption('token')); + $this->assertTrue($command->getDefinition()->hasOption('servicio')); + $this->assertTrue($command->getDefinition()->hasOption('desde')); + $this->assertTrue($command->getDefinition()->hasOption('hasta')); + $this->assertTrue($command->getDefinition()->hasOption('tipo')); + $this->assertTrue($command->getDefinition()->hasOption('paquete')); + } + + #[Group('integration')] + public function testCommandExecutionWithValidParametersButFakeFiel(): void + { + $command = new QueryCommand(); + $tester = new CommandTester($command); + $validOptions = $this->buildValidOptions(); + + $executionException = $this->captureException( + fn () => $tester->execute($validOptions) + ); + + $expectedDisplay = <<< TEXT + Consulta: + Servicio: Cfdi + Paquete: Metadata + RFC: EKU9003173C9 + Desde: 2020-01-01 00:00:00 + Hasta: 2020-01-31 23:59:59 + Tipo: Emitidos + RFC de/para: AAAA010101AAA + Documentos: Nómina + Complemento: (nomina12) Nómina 1.2 + Estado: Vigentes + Tercero: XXXX010101XXA + Resultado: + Consulta: 305 - Certificado Inválido + Identificador de solicitud: (ninguno) + + TEXT; + $this->assertEquals($expectedDisplay, $tester->getDisplay()); + + $this->assertInstanceOf(ExecutionException::class, $executionException); + $this->assertStringContainsString( + 'La petición no fue aceptada: 305 - Certificado Inválido', + $executionException->getMessage(), + ); + } + + #[Group('integration')] + public function testCommandExecutionWithValidParametersButFakeFielUuid(): void + { + $command = new QueryCommand(); + $tester = new CommandTester($command); + $validOptions = [ + '--efirma' => $this->filePath('fake-fiel/EKU9003173C9-efirma.json'), + '--certificado' => $this->filePath('fake-fiel/EKU9003173C9.cer'), + '--llave' => $this->filePath('fake-fiel/EKU9003173C9.key'), + '--password' => trim($this->fileContents('fake-fiel/EKU9003173C9-password.txt')), + '--token' => (new TemporaryFile(remove: false))->getPath(), + '--uuid' => 'b84835d8-2c55-4194-88a1-79edd961e4e7', + ]; + + $executionException = $this->captureException( + fn () => $tester->execute($validOptions) + ); + + $expectedDisplay = <<< TEXT + Consulta: + Servicio: Cfdi + Paquete: Metadata + RFC: EKU9003173C9 + UUID: b84835d8-2c55-4194-88a1-79edd961e4e7 + Resultado: + Consulta: 305 - Certificado Inválido + Identificador de solicitud: (ninguno) + + TEXT; + $this->assertEquals($expectedDisplay, $tester->getDisplay()); + + $this->assertInstanceOf(ExecutionException::class, $executionException); + $this->assertStringContainsString( + 'La petición no fue aceptada: 305 - Certificado Inválido', + $executionException->getMessage(), + ); + } + + public function testProcessResultWithCorrectResult(): void + { + $command = new QueryCommand(); + $requestId = '1E172434-E10B-48FD-990C-6844B509ACA3'; + $queryResult = new QueryResult( + new StatusCode(5000, 'Solicitud recibida con éxito'), + $requestId + ); + + $this->assertSame($command::SUCCESS, $command->processResult($queryResult)); + } + + public function testProcessResultWithInCorrectResult(): void + { + $command = new QueryCommand(); + $requestId = '1E172434-E10B-48FD-990C-6844B509ACA3'; + $queryResult = new QueryResult( + new StatusCode(404, 'Error no controlado'), + $requestId + ); + + $this->expectException(ExecutionException::class); + $this->expectExceptionMessage('Error no controlado'); + $command->processResult($queryResult); + } + + #[TestWith(['efirma', 'foo bar'])] + #[TestWith(['certificado', 'foo bar'])] + #[TestWith(['llave', 'foo bar', 'certificado'])] + #[TestWith(['servicio', 'foo'])] + #[TestWith(['desde', 'foo bar'])] + #[TestWith(['desde', '2020-02-01 00:00:00', 'hasta'])] + #[TestWith(['hasta', 'foo bar'])] + #[TestWith(['hasta', '2019-12-31 23:59:59'])] + #[TestWith(['tipo', 'foo bar'])] + #[TestWith(['rfc', 'not-rfc'])] + #[TestWith(['paquete', 'foo bar'])] + #[TestWith(['estado', 'foo bar'])] + #[TestWith(['documento', 'foo bar'])] + #[TestWith(['complemento', 'foo bar'])] + #[TestWith(['tercero', 'not-rfc'])] + #[TestWith(['uuid', 'not-uuid'])] + public function testOptionWithInvalidValue(string $option, string $invalidValue, string $guilty = ''): void + { + $guilty = $guilty ?: $option; + $command = new QueryCommand(); + $tester = new CommandTester($command); + + /** @var InputException|null $expectedException */ + $expectedException = null; + try { + $tester->execute(["--$option" => $invalidValue] + $this->buildValidOptions()); + } catch (InputException $catchedException) { + $expectedException = $catchedException; + } + + if (null === $expectedException) { + $this->fail('The exception InputException was not thrown'); + } + $this->assertSame($guilty, $expectedException->getArgumentName()); + } +} diff --git a/tests/Unit/Commands/VerifyCommandTest.php b/tests/Unit/Commands/VerifyCommandTest.php new file mode 100644 index 0000000..a64ba3a --- /dev/null +++ b/tests/Unit/Commands/VerifyCommandTest.php @@ -0,0 +1,174 @@ + */ + private function buildValidOptions(): array + { + return [ + 'solicitud' => 'b9a869bf-8c6c-49f4-945e-126992b3b3e7', + + '--certificado' => $this->filePath('fake-fiel/EKU9003173C9.cer'), + '--llave' => $this->filePath('fake-fiel/EKU9003173C9.key'), + '--password' => trim($this->fileContents('fake-fiel/EKU9003173C9-password.txt')), + ]; + } + + public function testHasDefinedOptions(): void + { + $command = new VerifyCommand(); + $this->assertTrue($command->getDefinition()->hasOption('certificado')); + $this->assertTrue($command->getDefinition()->hasOption('llave')); + $this->assertTrue($command->getDefinition()->hasOption('password')); + } + + public function testReceiveOnlyOneArgument(): void + { + $command = new VerifyCommand(); + $this->assertTrue($command->getDefinition()->hasArgument('solicitud')); + $this->assertSame(1, $command->getDefinition()->getArgumentCount()); + } + + #[Group('integration')] + public function testCommandExecutionWithValidParametersButFakeFiel(): void + { + $command = new VerifyCommand(); + $tester = new CommandTester($command); + $validOptions = $this->buildValidOptions(); + + $this->expectException(ExecutionException::class); + $this->expectExceptionMessage('La petición no fue aceptada: 305 - Certificado Inválido'); + $tester->execute($validOptions); + } + + public function testProcessResultWithValidResult(): void + { + $command = new VerifyCommand(); + $result = new VerifyResult( + new StatusCode(5000, 'Solicitud recibida con éxito'), + new StatusRequest(1), + new CodeRequest(5000), + 99, + ...['1E172434-E10B-48FD-990C-6844B509ACA3_01', '1E172434-E10B-48FD-990C-6844B509ACA3_02'] + ); + + $this->assertSame($command::SUCCESS, $command->processResult($result)); + } + + public function testProcessResultWithInvalidResultStatusCode(): void + { + $command = new VerifyCommand(); + $result = new VerifyResult( + new StatusCode(404, 'Error no controlado'), + new StatusRequest(1), + new CodeRequest(5000), + 99, + ...['1E172434-E10B-48FD-990C-6844B509ACA3_01', '1E172434-E10B-48FD-990C-6844B509ACA3_02'] + ); + + $this->expectException(ExecutionException::class); + $this->expectExceptionMessage('Error no controlado'); + $command->processResult($result); + } + + /** @return array> */ + public static function providerProcessResultWithInvalidResultStatusRequest(): array + { + return [ + 'Failure' => [new StatusRequest(4)], + 'Rejected' => [new StatusRequest(5)], + 'Expired' => [new StatusRequest(6)], + ]; + } + + #[DataProvider('providerProcessResultWithInvalidResultStatusRequest')] + public function testProcessResultWithInvalidResultStatusRequest(StatusRequest $statusRequest): void + { + $command = new VerifyCommand(); + $result = new VerifyResult( + new StatusCode(5000, 'Solicitud recibida con éxito'), + $statusRequest, + new CodeRequest(5000), + 99, + ...['1E172434-E10B-48FD-990C-6844B509ACA3_01', '1E172434-E10B-48FD-990C-6844B509ACA3_02'] + ); + + $this->expectException(ExecutionException::class); + $this->expectExceptionMessage(sprintf( + 'El estado de solicitud de la descarga no es correcto: %s - %s', + $statusRequest->getValue(), + $statusRequest->getMessage(), + )); + $command->processResult($result); + } + + /** @return array> */ + public static function providerProcessResultWithInvalidResultCodeRequest(): array + { + return [ + 'Exhausted' => [new CodeRequest(5002)], + 'MaximumLimitReaded' => [new CodeRequest(5003)], + 'Duplicated' => [new CodeRequest(5005)], + ]; + } + + #[DataProvider('providerProcessResultWithInvalidResultCodeRequest')] + public function testProcessResultWithInvalidResultCodeRequest(CodeRequest $CodeRequest): void + { + $command = new VerifyCommand(); + $result = new VerifyResult( + new StatusCode(5000, 'Solicitud recibida con éxito'), + new StatusRequest(1), + $CodeRequest, + 99, + ...['1E172434-E10B-48FD-990C-6844B509ACA3_01', '1E172434-E10B-48FD-990C-6844B509ACA3_02'] + ); + + $this->expectException(ExecutionException::class); + $this->expectExceptionMessage(sprintf( + 'El código de estado de la solicitud de descarga no es correcto: %s - %s', + $CodeRequest->getValue(), + $CodeRequest->getMessage(), + )); + $command->processResult($result); + } + + #[TestWith(['certificado', 'foo bar'])] + #[TestWith(['llave', 'foo bar', 'certificado'])] + public function testOptionWithInvalidValue(string $option, string $invalidValue, string $guilty = ''): void + { + $guilty = $guilty ?: $option; + $command = new VerifyCommand(); + $tester = new CommandTester($command); + + /** @var InputException|null $expectedException */ + $expectedException = null; + try { + $tester->execute(["--$option" => $invalidValue] + $this->buildValidOptions()); + } catch (InputException $cachedException) { + $expectedException = $cachedException; + } + + if (null === $expectedException) { + $this->fail('The exception InputException was not thrown'); + } + $this->assertSame($guilty, $expectedException->getArgumentName()); + } +} diff --git a/tests/Unit/Commands/WithfieldAbstractCommandTest.php b/tests/Unit/Commands/WithfieldAbstractCommandTest.php new file mode 100644 index 0000000..bd317e2 --- /dev/null +++ b/tests/Unit/Commands/WithfieldAbstractCommandTest.php @@ -0,0 +1,161 @@ + $inputParameters */ + public function createServiceBuilder(array $inputParameters): ServiceBuilder + { + $command = new class () extends WithFielAbstractCommand { + }; + $input = new ArrayInput($inputParameters, $command->getDefinition()); + $output = $this->createMock(OutputInterface::class); + return new ServiceBuilder($input, $output); + } + + public function testServiceWithoutDefinition(): void + { + $builder = $this->createServiceBuilder([]); + + $serviceEndpoints = $builder->obtainServiceEndPoints(); + $this->assertTrue($serviceEndpoints->getServiceType()->isCfdi()); + } + + public function testServiceWithCfdi(): void + { + $builder = $this->createServiceBuilder([ + '--servicio' => 'cfdi', + ]); + + $serviceEndpoints = $builder->obtainServiceEndPoints(); + $this->assertTrue($serviceEndpoints->getServiceType()->isCfdi()); + } + + public function testServiceWithRetencion(): void + { + $builder = $this->createServiceBuilder([ + '--servicio' => 'retenciones', + ]); + + $serviceEndpoints = $builder->obtainServiceEndPoints(); + $this->assertTrue($serviceEndpoints->getServiceType()->isRetenciones()); + } + + public function testServiceWithInvalidValue(): void + { + $builder = $this->createServiceBuilder([ + '--servicio' => 'xxx', + ]); + + $this->expectException(InputException::class); + $this->expectExceptionMessage('La opción "servicio" no es válida'); + $builder->obtainServiceEndPoints(); + } + + public function testBuildFielFromInputWithoutCertificate(): void + { + $builder = $this->createServiceBuilder([]); + + $this->expectException(InputException::class); + $this->expectExceptionMessage('La opción "certificado" no es válida'); + $builder->obtainFiel(); + } + + public function testBuildFielWithPasswordFromServer(): void + { + $passwordOnServer = $_SERVER['EFIRMA_PASSPHRASE'] ?? null; + $_SERVER['EFIRMA_PASSPHRASE'] = trim($this->fileContents('fake-fiel/EKU9003173C9-password.txt')); + $builder = $this->createServiceBuilder([ + '--certificado' => $this->filePath('fake-fiel/EKU9003173C9.cer'), + '--llave' => $this->filePath('fake-fiel/EKU9003173C9.key'), + ]); + + try { + $fiel = $builder->obtainFiel(); + } finally { + if (null === $passwordOnServer) { + unset($_SERVER['EFIRMA_PASSPHRASE']); + } else { + $_SERVER['EFIRMA_PASSPHRASE'] = $passwordOnServer; + } + } + $this->assertNotNull($fiel); + } + + public function testBuildFielFromInputWithoutPrimaryKey(): void + { + $builder = $this->createServiceBuilder([ + '--certificado' => $this->filePath('fake-fiel/EKU9003173C9.cer'), + ]); + + $this->expectException(InputException::class); + $this->expectExceptionMessage('La opción "llave" no es válida'); + $builder->obtainFiel(); + } + + public function testBuildFielFromInputWithoutPassword(): void + { + $builder = $this->createServiceBuilder([ + '--certificado' => $this->filePath('fake-fiel/EKU9003173C9.cer'), + '--llave' => $this->filePath('fake-fiel/EKU9003173C9.key'), + ]); + + $this->expectException(InputException::class); + $this->expectExceptionMessage('No fue posible crear la eFirma'); + $builder->obtainFiel(); + } + + public function testBuildFielFromInputWithIncorrectPassword(): void + { + $builder = $this->createServiceBuilder([ + '--certificado' => $this->filePath('fake-fiel/EKU9003173C9.cer'), + '--llave' => $this->filePath('fake-fiel/EKU9003173C9.key'), + '--password' => trim($this->fileContents('fake-fiel/EKU9003173C9-password.txt')) . '-foo', + ]); + + $this->expectException(InputException::class); + $this->expectExceptionMessage('No fue posible crear la eFirma'); + $builder->obtainFiel(); + } + + public function testBuildFielWithEfirma(): void + { + $builder = $this->createServiceBuilder([ + '--efirma' => $this->filePath('fake-fiel/EKU9003173C9-efirma.json'), + ]); + + $this->assertNotNull($builder->obtainFiel()); + } + + #[TestWith(['not a json content'])] + #[TestWith([''])] + public function testBuildFielWithInvalidJsonContents(string $invalidJson): void + { + $temporaryFile = new TemporaryFile(); + $temporaryFile->putContents($invalidJson); + $builder = $this->createServiceBuilder([ + '--efirma' => $temporaryFile->getPath(), + ]); + + $this->expectException(InputException::class); + $this->expectExceptionMessage( + sprintf( + 'El archivo de configuración de eFirma "%s" no se pudo interpretar como JSON', + $temporaryFile->getPath() + ) + ); + $builder->obtainFiel(); + } +} diff --git a/tests/Unit/Commands/ZipExportMetadataCommandTest.php b/tests/Unit/Commands/ZipExportMetadataCommandTest.php new file mode 100644 index 0000000..7c016b2 --- /dev/null +++ b/tests/Unit/Commands/ZipExportMetadataCommandTest.php @@ -0,0 +1,122 @@ +assertTrue($command->getDefinition()->hasArgument('metadata')); + $this->assertTrue($command->getDefinition()->hasArgument('destino')); + } + + public function testCommandExecutionWithValidParameters(): void + { + $sourceFile = $this->filePath('metadata.zip'); + $destinationFile = $this->createTemporaryName(); + + $command = new ZipExportMetadataCommand(); + $tester = new CommandTester($command); + $validOptions = [ + 'metadata' => $sourceFile, + 'destino' => $destinationFile, + ]; + $exitCode = $tester->execute($validOptions); + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertFileExists($destinationFile); + $this->assertGreaterThan(0, filesize($destinationFile)); + } + + public function testCommandExecutionWithInvalidSourceFile(): void + { + $sourceFile = __DIR__ . '/non-existent.zip'; + $destinationFile = __DIR__ . '/destination-file.xlsx'; + + $command = new ZipExportMetadataCommand(); + $tester = new CommandTester($command); + $validOptions = [ + 'metadata' => $sourceFile, + 'destino' => $destinationFile, + ]; + + $executionException = $this->captureException( + fn () => $tester->execute($validOptions) + ); + + $this->assertInstanceOf(ExecutionException::class, $executionException); + $this->assertStringContainsString('no se pudo abrir', $executionException->getMessage()); + $this->assertFileDoesNotExist($destinationFile); + } + + public function testCommandExecutionWithSourceFileAsDirectory(): void + { + $sourceFile = __DIR__; + $destinationFile = __DIR__ . '/destination-file.xlsx'; + + $command = new ZipExportMetadataCommand(); + $tester = new CommandTester($command); + $validOptions = [ + 'metadata' => $sourceFile, + 'destino' => $destinationFile, + ]; + + $executionException = $this->captureException( + fn () => $tester->execute($validOptions) + ); + + $this->assertInstanceOf(ExecutionException::class, $executionException); + $this->assertStringContainsString('no se pudo abrir', $executionException->getMessage()); + $this->assertFileDoesNotExist($destinationFile); + } + + public function testCommandExecutionWithSourceFileAsNonZip(): void + { + $sourceFile = __FILE__; + $destinationFile = __DIR__ . '/destination-file.xlsx'; + + $command = new ZipExportMetadataCommand(); + $tester = new CommandTester($command); + $validOptions = [ + 'metadata' => $sourceFile, + 'destino' => $destinationFile, + ]; + + $executionException = $this->captureException( + fn () => $tester->execute($validOptions) + ); + + $this->assertInstanceOf(ExecutionException::class, $executionException); + $this->assertStringContainsString('no se pudo abrir', $executionException->getMessage()); + $this->assertFileDoesNotExist($destinationFile); + } + + public function testCommandExecutionWithInvalidDestinationFile(): void + { + $sourceFile = $this->filePath('metadata.zip'); + $destinationFile = __DIR__ . '/non-existent-dir/destination-file.xlsx'; + + $command = new ZipExportMetadataCommand(); + $tester = new CommandTester($command); + $validOptions = [ + 'metadata' => $sourceFile, + 'destino' => $destinationFile, + ]; + + $executionException = $this->captureException( + fn () => $tester->execute($validOptions) + ); + + $this->assertInstanceOf(ExecutionException::class, $executionException); + $this->assertStringContainsString('no se pudo escribir', $executionException->getMessage()); + $this->assertFileDoesNotExist($destinationFile); + } +} diff --git a/tests/Unit/Commands/ZipExportXmlCommandTest.php b/tests/Unit/Commands/ZipExportXmlCommandTest.php new file mode 100644 index 0000000..0c4844d --- /dev/null +++ b/tests/Unit/Commands/ZipExportXmlCommandTest.php @@ -0,0 +1,131 @@ +assertTrue($command->getDefinition()->hasArgument('destino')); + $this->assertTrue($command->getDefinition()->hasArgument('paquete')); + } + + public function testCommandExecutionWithValidParameters(): void + { + $sourceFile = $this->filePath('cfdi.zip'); + $destinationPath = sys_get_temp_dir(); + $expectedFiles = [ + "$destinationPath/11111111-2222-3333-4444-000000000001.xml", + "$destinationPath/11111111-2222-3333-4444-000000000002.xml", + ]; + + foreach ($expectedFiles as $expectedFile) { + if (file_exists($expectedFile)) { + unlink($expectedFile); + } + } + + $command = new ZipExportXmlCommand(); + $tester = new CommandTester($command); + $validOptions = [ + 'paquete' => $sourceFile, + 'destino' => $destinationPath, + ]; + $exitCode = $tester->execute($validOptions); + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Exportados 2 archivos', $tester->getDisplay()); + + foreach ($expectedFiles as $expectedFile) { + $this->assertFileExists($expectedFile); + } + } + + public function testCommandExecutionWithInvalidSourceFile(): void + { + $sourceFile = __DIR__ . '/non-existent.zip'; + $destinationFile = __DIR__ . '/destination-file.xlsx'; + + $command = new ZipExportXmlCommand(); + $tester = new CommandTester($command); + $validOptions = [ + 'destino' => $destinationFile, + 'paquete' => $sourceFile, + ]; + + $executionException = $this->captureException( + fn () => $tester->execute($validOptions) + ); + + $this->assertInstanceOf(ExecutionException::class, $executionException); + $this->assertStringContainsString('no se pudo abrir', $executionException->getMessage()); + $this->assertFileDoesNotExist($destinationFile); + } + + public function testCommandExecutionWithSourceFileAsDirectory(): void + { + $sourceFile = __DIR__; + $destinationFile = __DIR__ . '/destination-file.xlsx'; + + $command = new ZipExportXmlCommand(); + $tester = new CommandTester($command); + $validOptions = [ + 'destino' => $destinationFile, + 'paquete' => $sourceFile, + ]; + + $executionException = $this->captureException( + fn () => $tester->execute($validOptions) + ); + + $this->assertInstanceOf(ExecutionException::class, $executionException); + $this->assertStringContainsString('no se pudo abrir', $executionException->getMessage()); + $this->assertFileDoesNotExist($destinationFile); + } + + public function testCommandExecutionWithSourceFileAsNonZip(): void + { + $sourceFile = __FILE__; + $destinationFile = __DIR__ . '/destination-file.xlsx'; + + $command = new ZipExportXmlCommand(); + $tester = new CommandTester($command); + $validOptions = [ + 'destino' => $destinationFile, + 'paquete' => $sourceFile, + ]; + + $executionException = $this->captureException( + fn () => $tester->execute($validOptions) + ); + + $this->assertInstanceOf(ExecutionException::class, $executionException); + $this->assertStringContainsString('no se pudo abrir', $executionException->getMessage()); + $this->assertFileDoesNotExist($destinationFile); + } + + public function testCommandExecutionWithInvalidDestinationFile(): void + { + $sourceFile = $this->filePath('cfdi.zip'); + $destinationFile = __DIR__ . '/non-existent-dir/'; + + $command = new ZipExportXmlCommand(); + $tester = new CommandTester($command); + $validOptions = [ + 'destino' => $destinationFile, + 'paquete' => $sourceFile, + ]; + + $this->expectException(ExecutionException::class); + $this->expectExceptionMessage('no existe'); + $tester->execute($validOptions); + } +} diff --git a/tests/Unit/Internal/FileSystemTest.php b/tests/Unit/Internal/FileSystemTest.php new file mode 100644 index 0000000..2dfc05d --- /dev/null +++ b/tests/Unit/Internal/FileSystemTest.php @@ -0,0 +1,72 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage('Path cannot be empty'); + $fs->read(''); + } + + public function testWriteWithEmptyPathThrowsException(): void + { + $fs = new Filesystem(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Path cannot be empty'); + $fs->write('', ''); + } + + public function testIsDirectoryWithEmptyPathThrowsException(): void + { + $fs = new Filesystem(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Path cannot be empty'); + $fs->isDirectory(''); + } + + public function testIsWritableWithEmptyPathThrowsException(): void + { + $fs = new Filesystem(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Path cannot be empty'); + $fs->isWritable(''); + } + + public function testExistsWithEmptyPathThrowsException(): void + { + $fs = new Filesystem(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Path cannot be empty'); + $fs->exists(''); + } + + public function testWriteToDirectoryThrowsError(): void + { + $fs = new Filesystem(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('is a directory'); + $fs->write(__DIR__, 'foo'); + } + + public function testPathAbsoluteOrRelativeWithEmptyStringReturnEmptyString(): void + { + $fs = new Filesystem(); + $this->assertSame('', $fs->pathAbsoluteOrRelative('', '')); + } + + public function testPathAbsoluteOrRelativeWithAbsolutePathReturnSameAbsolutePath(): void + { + $fs = new Filesystem(); + $this->assertSame('/foo', $fs->pathAbsoluteOrRelative('/foo', '')); + } +} diff --git a/tests/Unit/Service/ServiceWithStorageTokenTest.php b/tests/Unit/Service/ServiceWithStorageTokenTest.php new file mode 100644 index 0000000..5c13a57 --- /dev/null +++ b/tests/Unit/Service/ServiceWithStorageTokenTest.php @@ -0,0 +1,63 @@ +store($token); + + $requestBuilder = $this->createMock(RequestBuilderInterface::class); + $webClient = $this->createMock(WebClientInterface::class); + $serviceEndpoints = ServiceEndpoints::cfdi(); + $service = new ServiceWithStorageToken($requestBuilder, $webClient, $storageToken, $serviceEndpoints); + + $this->assertSame($token, $service->currentToken); + } + + public function testObtainCurrentTokenWhenTokensAreEqual(): void + { + $storageToken = new StorageToken(''); + $token = new Token(DateTime::create(time() - 10), DateTime::create(time()), 'x-token'); + $storageToken->store($token); + + $requestBuilder = $this->createMock(RequestBuilderInterface::class); + $webClient = $this->createMock(WebClientInterface::class); + $serviceEndpoints = ServiceEndpoints::cfdi(); + $service = new ServiceWithStorageToken($requestBuilder, $webClient, $storageToken, $serviceEndpoints); + + $this->assertSame($token, $service->obtainCurrentToken()); + } + + public function testObtainCurrentTokenWhenTokensAreNotEqual(): void + { + $currentToken = new Token(DateTime::create(time() - 10), DateTime::create(time()), 'x-token-current'); + $storedToken = new Token(DateTime::create(time() - 10), DateTime::create(time()), 'x-token-stored'); + $storageToken = new StorageToken(''); + $storageToken->store($storedToken); + + $requestBuilder = $this->createMock(RequestBuilderInterface::class); + $webClient = $this->createMock(WebClientInterface::class); + $serviceEndpoints = ServiceEndpoints::cfdi(); + $service = new ServiceWithStorageToken($requestBuilder, $webClient, $storageToken, $serviceEndpoints); + $this->assertEquals($storedToken, $service->obtainCurrentToken()); + $service->currentToken = $currentToken; + + $this->assertEquals($currentToken, $service->obtainCurrentToken()); + $this->assertEquals($currentToken, $storageToken->current()); + } +} diff --git a/tests/Unit/Service/StorageTokenTest.php b/tests/Unit/Service/StorageTokenTest.php new file mode 100644 index 0000000..9b7adb5 --- /dev/null +++ b/tests/Unit/Service/StorageTokenTest.php @@ -0,0 +1,165 @@ +assertSame($filename, $storageToken->filename); + $this->assertNull($storageToken->current()); + } + + public function testCreateUsingNonExistentFile(): void + { + $temporaryFile = new TemporaryFile(); + $filename = $temporaryFile->getPath(); + $temporaryFile->delete(); + + $storageToken = new StorageToken($filename); + $this->assertSame($filename, $storageToken->filename); + $this->assertNull($storageToken->current()); + } + + public function testCreateUsingEmptyContent(): void + { + $temporaryFile = new TemporaryFile(); + $storageToken = new StorageToken($temporaryFile->getPath()); + $this->assertSame($temporaryFile->getPath(), $storageToken->filename); + $this->assertNull($storageToken->current()); + } + + public function testCreateUsingKnownContent(): void + { + $temporaryFile = new TemporaryFile(); + $storageToken = new StorageToken($temporaryFile->getPath()); + $this->assertSame($temporaryFile->getPath(), $storageToken->filename); + $this->assertNull($storageToken->current()); + } + + public function testReadToken(): void + { + $token = new Token(DateTime::create(time() - 10), DateTime::create(time()), 'x-token'); + $temporaryFile = new TemporaryFile(); + $contents = StorageToken::serializeToken($token); + $temporaryFile->putContents($contents); + + $storageToken = new StorageToken($temporaryFile->getPath()); + $this->assertEquals($token, $storageToken->current()); + } + + public function testStoreToken(): void + { + $token = new Token(DateTime::create(time() - 10), DateTime::create(time()), 'x-token'); + $temporaryFile = new TemporaryFile(); + + $storageToken = new StorageToken($temporaryFile->getPath()); + $storageToken->store($token); + $this->assertEquals($token, $storageToken->current()); + } + + public function testStoreTokenWithEmptyFile(): void + { + $token = new Token(DateTime::create(time() - 10), DateTime::create(time()), 'x-token'); + + $storageToken = new StorageToken(''); + $this->assertNull($storageToken->current()); + + $storageToken->store($token); + $this->assertSame( + $token, + $storageToken->current(), + 'When no file path is defined, the stored and current token must be the same object' + ); + } + + public function testUnserializeTokenWithInvalidJson(): void + { + $this->expectException(JsonException::class); + StorageToken::unserializeToken(''); + } + + public function testCurrentWithInvalidContent(): void + { + $temporaryFile = new TemporaryFile(); + $temporaryFile->putContents('invalid json'); + $storageToken = new StorageToken($temporaryFile->getPath()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to create token from file'); + $storageToken->current(); + } + + public function testStoreWithInvalidFilename(): void + { + $token = new Token(DateTime::create(time() - 10), DateTime::create(time()), 'x-token'); + $filename = __FILE__ . '/foo/bar/baz.txt'; + $storageToken = new StorageToken($filename); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf('Unable to write contents on "%s"', $filename)); + $storageToken->store($token); + } + + /** @return array */ + public static function providerUnserializeTokenWithInvalidJsonStructure(): array + { + return [ + 'not array' => [ + 'null', + 'Unexpected JSON contents from token', + ], + 'without created' => [ + (string) json_encode([]), + 'Invalid JSON value on key "created"', + ], + 'invalid created' => [ + (string) json_encode(['created' => '']), + 'Invalid JSON value on key "created"', + ], + 'without expires' => [ + (string) json_encode(['created' => time()]), + 'Invalid JSON value on key "expires"', + ], + 'invalid expires' => [ + (string) json_encode(['created' => time(), 'expires' => '']), + 'Invalid JSON value on key "expires"', + ], + 'without token' => [ + (string) json_encode(['created' => time(), 'expires' => time()]), + 'Invalid JSON value on key "token"', + ], + 'invalid token' => [ + (string) json_encode(['created' => time(), 'expires' => time(), 'token' => 0]), + 'Invalid JSON value on key "token"', + ], + 'empty token' => [ + (string) json_encode(['created' => time(), 'expires' => time(), 'token' => '']), + 'Invalid JSON value on key "token"', + ], + ]; + } + + #[DataProvider('providerUnserializeTokenWithInvalidJsonStructure')] + public function testUnserializeTokenWithInvalidJsonStructure(string $json, string $message): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage($message); + StorageToken::unserializeToken($json); + } +} diff --git a/tests/_files/cfdi.zip b/tests/_files/cfdi.zip new file mode 100644 index 0000000000000000000000000000000000000000..c3a9b6ad2289ddfce9195715e5492551d965d409 GIT binary patch literal 2727 zcmWIWW@Zs#-~d9eHD-YfP@vAmz@WjPz>o+9x=BEwn+ycHDL|lWfB=Si6}dT~A-oLi zx?8g1tARKLh)XND85mh!Ff%ZKiK$^{^B$WC?0c@sAy>63yG1cTO=zcv~tp<$F)7RJ@dW`eoB%Jnxx8lPARovhE&nMnLpc%pFCaeJZ+Kf)GkiB zgzIGni)_R=AMa+c&&yj={O9GXH%9l=a;$_S^^OWZWVLmEvEW9?(eozC6FWQ;zs&De zn$O3kudlyNsdcZge_5GC!>0r8%dd+tpWQFv;N{nywff)w@3y}eOe){PF;yvbj?mY_ z8)3_hubOS(Q}f)p?fUUvBIZt?b4k;GhduNcI{2| z?c1`qR_gZ7_iV5|E*sgGUb(pUwZHqhD~&3mI^x&2OrFT#d`cyhN!eAk`MsoDw1Y6? zxAUekA@4fo$QRApa3kflUq+qE-*4Jc(@OLMT;l5sdW@nvST2O8u%Gg76yto#dVA9h z6(?og(>Y-iE}u>aPFoTf*Sh_C#y*$+(*kSXFBUkvzb@|2z8SjjrVBJ{ct|~1Xj~>E z-Nf{yWz`NAlK@ktC8w7AZaph+dDV=i?lMb9q~n&h6*Ep9t83WW&>b1h!dVh?oaeK# zmAwBKGuO*ou31cL$glpv#d3Ax{B$4A&L?Jk1rluw`05P~CMZ=;I`B!x$U&6x{6(*6 zUpV`oSebC}9@~1Np}^w^Z?K_fpia<1$shH~o-(&Hd@Z6bx-gY*n3LrrXUw;-apF(L zpd&3Uw`BTkauNhjdGSrydG4)YXTXjEz0w_fQ=YXPEZklF_xpjQTt=^I^)M$%zvIpc z%>29V*+c)hnjCV%{T!E)@8yhFYrjcV7~Hs+)+cznBrYTUdg$J@pE3{nN?*8kc!|od zGYWBO7w$P6mF9~*->kDgvmmcpgnjP`t2tjJR^+Bua{d%pm8Eg~JbP5YhsYJnrhl1k zK54}i2al@58FzXYSuR2sBx39P zN4?3tJ6dxdIk5&F*kWxaU(5bWtop*%Hzy*$tE5$W8Q)!%{WWQ}kGaqE+^g2>Zu0d7 zMcuF6@ax{vawpFJ{nw9b&X%tenG;~AdVBY}O3elOZ@oFQa@M}@zj?N_;reHdUG0)? zr{7)O%x6B~+P80#R?7FktF3Flc-Sn?bK`om!?7D8cFlY2ton!1{n5ul#szt{I{a-< z4{vBQ&{;0aP_nrD#E+Q!rSDpw`sn2ED7^dfMa`@JZ1-N{vumzrRGoZt;??q_S8vt_ zc(ZeyTv90;!@|G-Dz^gQCEB@IF@G{jG$VM4_T*SxET}}w8ZFUAOSI7vZL~zAs6=~k zq*qVradGYSo#NAsH|}|OtaSO}&nxZM$(o)s*&Mg{rRrwo)$_%|)BiJqinJ-WL|J0t zMH(ZM2s5IYhFsi%nrSewq!GlTR--Mz8(}M`C`EP=s;!rR?j*uiq6*dkZ&o&tFPMPv L4Uk^S3E}|&e;lyT literal 0 HcmV?d00001 diff --git a/tests/_files/cfdi/aaaaaaaa-bbbb-cccc-dddd-000000000001.xml b/tests/_files/cfdi/aaaaaaaa-bbbb-cccc-dddd-000000000001.xml new file mode 100644 index 0000000..49859e3 --- /dev/null +++ b/tests/_files/cfdi/aaaaaaaa-bbbb-cccc-dddd-000000000001.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/_files/cfdi/aaaaaaaa-bbbb-cccc-dddd-000000000002.xml b/tests/_files/cfdi/aaaaaaaa-bbbb-cccc-dddd-000000000002.xml new file mode 100644 index 0000000..ec430b9 --- /dev/null +++ b/tests/_files/cfdi/aaaaaaaa-bbbb-cccc-dddd-000000000002.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/_files/fake-fiel/.gitignore b/tests/_files/fake-fiel/.gitignore new file mode 100644 index 0000000..2369c2c --- /dev/null +++ b/tests/_files/fake-fiel/.gitignore @@ -0,0 +1,2 @@ +# this is a temporary file created on integration tests, defined in EKU9003173C9-efirma.json +EKU9003173C9-token.json \ No newline at end of file diff --git a/tests/_files/fake-fiel/EKU9003173C9-efirma.json b/tests/_files/fake-fiel/EKU9003173C9-efirma.json new file mode 100644 index 0000000..f81a4ae --- /dev/null +++ b/tests/_files/fake-fiel/EKU9003173C9-efirma.json @@ -0,0 +1,6 @@ +{ + "certificateFile": "EKU9003173C9.cer", + "privateKeyFile": "EKU9003173C9.key", + "passPhrase": "12345678a", + "tokenFile": "EKU9003173C9-token.json" +} \ No newline at end of file diff --git a/tests/_files/fake-fiel/EKU9003173C9-password.txt b/tests/_files/fake-fiel/EKU9003173C9-password.txt new file mode 100644 index 0000000..65ad999 --- /dev/null +++ b/tests/_files/fake-fiel/EKU9003173C9-password.txt @@ -0,0 +1 @@ +12345678a \ No newline at end of file diff --git a/tests/_files/fake-fiel/EKU9003173C9.cer b/tests/_files/fake-fiel/EKU9003173C9.cer new file mode 100644 index 0000000000000000000000000000000000000000..fbeb7df72c0bd7beb621c91d00846fe12a2c6323 GIT binary patch literal 1544 zcmXqLVq-CAVt%uLnTe5!NyON|z`ziQfxrYr7?~KF8}PDmYPET^edlFlKW*O1h{xqgI$BdJe@uL6K$rn zU|?)$ZtQHSpsx_&8R%qTX=rF@7U*hXC}$u8QpPGQiX!Ff9^@MpWMB$RN=nG7i;OW_982*Z6I?M9j#4B-q;{5c|Pa*}+ z4Ilsb(fs*idE{AsF)#CK)yJ!!KL7u%vwL~#-4`DgelA_sX_nq``RUHn5u2B`@uaK$ zNobfhPf1yOd2C&<+GmE|_X7J5otXVlt?Q!w^T%^)K0V}Ru>WHFL%-lp<}Hq$9yY-m zr|z7W?O(f)$sv%}vHs#bV>LWdu?@xHs9X{b02kZZU zI}?uAOG?BQg+fS);w?!-|xG$Q^iT~qyCgxH{}^;TDnavJpTCR zk3hY)zZa%Go8Iij>Ef2xv3NoELO*3DW=00a#r_7q;DjtI%*gnkg~@=yfEyU9vivM8 z%uLKT3<|Zaa zhV{2Q<}Nu?zf{Z9u5gK$`o_)kR^I&NbZ~2$8n-sz(|N|q6Far5ilyd+K3Oh!r837R z`Jd6_6TL-QZ>})@le~Ac$nBKtNh=PxpCD`5%X#Rp_t~|uNDSQH7Z&6=yKULg}nIFh8^8;5o|I?UNF6j zKb3Lwl*n`z(a*KUF2cw7+%;ytcvG|Zal#V2)26xy-tW#5y)LmN9b*RxEWGv$L`r@Ax$@$di5=WUX0o}qP(`S+8_QLK+;Y-Rom zUwvkq-e!EXRhT`6d!sdr;bh%ChHOiIO|Q0yi{Y8kmiXa9`o?|h6qmHMdr0app7|!s zsaAJcTkc2yhSQt=*l)kP@@TF7d|V*Zelq{T1DE%lUs^!#=hr!cE2x3iwL% z9podH-C^JJt8|@$=IzLnEytD%uGZwa>USp2dFt(=?|s+vd6x74da$mX>ED-*wJ+9X aa%8_cynX);r7eHnEZnY>!0dF`I{^TBrzYkA literal 0 HcmV?d00001 diff --git a/tests/_files/fake-fiel/EKU9003173C9.key b/tests/_files/fake-fiel/EKU9003173C9.key new file mode 100644 index 0000000000000000000000000000000000000000..a1a0eaab5d15ed4656fcc85ea6f2d6e14b547f0a GIT binary patch literal 1298 zcmV+t1?~DUf&~sRKn4jahDe6@4FLrWFf%Y41_>&LNQU&4g?4S0RRGm0RaF4 z0tf&w6b1+?hDe6@4Fd-R2rz;Ky#fIMFa&}G$aoNWduY^xXu!bLSy7>IndL<$s7k&kVw$rh+L~FMT&CN^9#< zUVPenwS_&zwyNN__iqa)uHpmr$U#P}Iph7a1~};x;P{IGl#2f&aI3*%u+HN;rrw_j z;n}gY=gGh5c@MyJW8qT>uz#RAQlUKhikr1VRX}hrAem0EQbOYXN|6Qz)p6+5NOL-MWGo4*- z_sz5o$#9HRcelC~nvYq{7M%0}NE`t1&=E_0&Ne#$?j{W*9BLuLV^VshEUL?9yT9tT zmpq7ovQXS!mztQfNSDcgY?{%$kK|}eJ635#!W5e1-6{ui(CbkdVM7M`E0%>rWH01Y>CO_cQGzgp zDFiZ!Lm7ZccV_9OHOn3(W|I-DzYws}x>!XB9UW(OzTcCQXJY3G&j_3ig>TKnI>kvB zz23D1M$li@OwG!KYc~Z65}8g|Sd)%{Lz@Bnb_WQL)m{AISPsjGGK7 zofO#eU{x&>n|juM&I)yn54>?w*QZrGMonY8T939a#UEluSI&k#Wsm+`wX~I z{)2_LtpFt_qfpn5}YYRTvc2Z}Ig0f8(4Yq2PK+Y}AN6G;*ou9Cw477&?vHs;j=l!79Jf3VPcb`YXkNS|gdjXe(Yd99Y*ur6<%!uWEOIV8Z2kn3);h2uF@$nKw zMO7S04AMMYe?Pi*4dPMEYOR9uny)wNzm__{v7CjmRuY>*m#9D{vc-kDvo1K$c}O^0XCVy|y&A zEHe5g)y5ZMB)UsXiJ7k4#hqo=qx70_oa#Y`cS0qZL!nTa!rn5 zOg7{?7*PvmiyQe?Wy+(w0GiEfPt}J3WbMke7_u&!oA8^)xtrD;?F65?=%9@FF5~c@ z(Rrs~$VTR`5c+VG@C8r;8eiZ|bMD9 + +En el archivo ZIP +se tomaron los correspondientes al RFC EKU9003173C9. diff --git a/tests/_files/metadata.zip b/tests/_files/metadata.zip new file mode 100644 index 0000000000000000000000000000000000000000..adeaf228b4e059cd0c3c98a417b7d6082d4bce5e GIT binary patch literal 1542 zcmWIWW@Zs#W?VWWm6|U<1V3Kx_a7x*!^jfXT%h80eK$lyILsp%cci zXwM&^)+08o&mS1HZaFh2M0P?%QD9b*XI7V!=RPOTtin#GqPjTGtT;{&N6#!ladCzK zkOP3$b1)p(#o-UM6ArW)1Q<+AolTvMO-yuM91RS0O)SkFbuEp|4RuW&9gQtaja*&L z99`oL3_%VEjo@K;{lzEtIS_wi6k(|CyO?#zK&0(G>-~lL0w*nfuix5j;_G;$B*ZMG z+|WBRF?UJt_UAXl=bjLeeE#bH{{IKQm*v>6%v$Jzlaazt0Z!IiZ*=BjIr5vP?xE|1K5|hNDakVJs%|8TF=WFiLV*?9@8TWqUN@ zg~*f(n*@H@hwo{uV3*(BwLflh$e$^XoAn%-CaQ9KoL^@j;LXmldiM20%!~{SFM${o z)?DE5J%Se2oWQV-kN0(S_79HG2ZiJZU?_qxC?s)5kr}c!y?6>@D>*?lIXxjM;e)SF z*a!Zwj(`TH2@=fe0@92hbrsk?Nmfd9F!d=aItqwI9A{Mc<`{g4MN~W?VS?AW^H;rf zG){P4J?*KZr>X1b>*=ZE8Oq1ewrVfK5i=g1MI5EAOFLaVD^+J!uH>n<|9zxMB)0xS zI_Dzpue>}uJZpV}eI{M*@#S4R*SPCbaq!x?J#*&F;pvSjnH$S9x71fBhPQMsk8E!V zZ{s7qb7^gUZenUYz#w7gu%312+InDU^1wnCn2i`1rq9dp1_v>e0EKsIZb3<XNfK{n?i2ed7blgD@u$=a*!p7JtlEEj_UNCyKWg9JlERJ^iqykpfV{Jcpm}Ys6(nhH&+-_x!FhGThts14Kh29AY+RIzcu& jA80l_)gT+q;K}d~Y$O^T;LXYgiZeDK^Z^FM60ipVaQnHr literal 0 HcmV?d00001 diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..c30d8a6 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,7 @@ +