diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 2e19ed8..f8bc0fd 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,74 +1,85 @@ -# Contributor Covenant Code of Conduct -## Our Pledge +# Código de Conducta convenido para Contribuyentes -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of experience, -nationality, personal appearance, race, religion, or sexual identity and -orientation. +## Nuestro compromiso -## Our Standards +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. -Examples of behavior that contributes to creating a positive environment -include: +Nos comprometemos a actuar e interactuar de maneras que contribuyan a una comunidad abierta, acogedora, diversa, inclusiva y sana. -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +## Nuestros estándares -Examples of unacceptable behavior by participants include: +Ejemplos de comportamiento que contribuyen a crear un ambiente positivo para nuestra comunidad: -* The use of sexualized language or imagery and unwelcome sexual attention or -advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting +* 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 -## Our Responsibilities +Ejemplos de comportamiento inaceptable: -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. +* 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 -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. +## Aplicación de las responsabilidades -## Scope +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. -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. +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. -## Enforcement +## Alcance -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at eclipxe13@gmail.com. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. +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. -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. +## Aplicación -## Attribution +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 [INSERTAR MÉTODO DE CONTACTO]. Todas las quejas serán evaluadas e investigadas de una manera puntual y justa. -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [http://contributor-covenant.org/version/1/4][version] +Todos los administradores de la comunidad están obligados a respetar la privacidad y la seguridad de quienes reporten incidentes. -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ +## 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 index 7626f62..9c349b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,81 +1,85 @@ -# Contributing +# Contribuciones -Contributions are welcome. We accept pull requests on [GitHub](https://github.com/phpcfdi/sat-ws-descarga-masiva). +Las contribuciones son bienvenidas. Aceptamos *Pull Requests* en el [repositorio GitHub][homepage]. -This project adheres to a -[Contributor Code of Conduct](https://github.com/phpcfdi/sat-ws-descarga-masiva/blob/master/CODE_OF_CONDUCT.md). -By participating in this project and its community, you are expected to uphold this code. +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. -## Team members +## Miembros del equipo -* [phpCfdi](https://github.com/phpcfdi) - organization maintainer -* [GitHub contributors](https://github.com/phpcfdi/sat-ws-descarga-masiva/graphs/contributors) +* [phpCfdi][] - Organización que mantiene el proyecto. +* [Contribuidores][contributors]. -## Communication Channels +## Canales de comunicación -You can find help and discussion in the following places: +Puedes encontrar ayuda y comentar asuntos relacionados con este proyecto en estos lugares: +* Comunidad Discord: * GitHub Issues: -## Reporting Bugs +## Reportar Bugs -Bugs are tracked in our project's [issue tracker](https://github.com/phpcfdi/sat-ws-descarga-masiva/issues). +Publica los *Bugs* en la sección [GitHub Issues][issues] del proyecto. -When submitting a bug report, please include enough information for us to reproduce the bug. -A good bug report includes the following sections: +Sigue las recomendaciones generales de [phpCfdi][] para reportar problemas +. -* Expected outcome -* Actual outcome -* Steps to reproduce, including sample code -* Any other information that will help us debug and reproduce the issue, including stack traces, system/environment information, and screenshots +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. -**Please do not include passwords or any personally identifiable information in your bug report and sample code.** +**Recuerda no incluir contraseñas, información personal o confidencial.** -## Fixing Bugs +## Corrección de Bugs -We welcome pull requests to fix bugs! +Apreciamos mucho los *Pull Request* para corregir Bugs. -If you see a bug report that you'd like to fix, please feel free to do so. -Following the directions and guidelines described in the "Adding New Features" -section below, you may create bugfix branches and send us pull requests. +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. -## Adding New Features +## Agregar nuevas funcionalidades -If you have an idea for a new feature, it's a good idea to check out our -[issues](https://github.com/phpcfdi/sat-ws-descarga-masiva/issues) or active -[pull requests](https://github.com/phpcfdi/sat-ws-descarga-masiva/pulls) -first to see if the feature is already being worked on. -If not, feel free to submit an issue first, asking whether the feature is beneficial to the project. -This will save you from doing a lot of development work only to have your feature rejected. -We don't enjoy rejecting your hard work, but some features just don't fit with the goals of the project. +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. -When you do begin working on your feature, here are some guidelines to consider: +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. -* Your pull request description should clearly detail the changes you have made. -* Follow our code style using `squizlabs/php_codesniffer` and `friendsofphp/php-cs-fixer`. -* Please **write tests** for any new features you add. -* Please **ensure that tests pass** before submitting your pull request. We have Travis CI automatically running tests for pull requests. However, running the tests locally will help save time. -* **Use topic/feature branches.** Please do not ask us to pull from your master branch. -* **Submit one feature per pull request.** If you have multiple features you wish to submit, please break them up into separate pull requests. -* **Send coherent history**. Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please squash them before submitting. +Considera las siguientes directrices: -## Check the code style +* 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 contínua 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. -If you are having issues with coding standars use `php-cs-fixer` and `phpcbf` +## Proceso de construcción ```shell -composer dev:fix-style -``` +# Actualiza tus dependencias +composer update + +# Verificación de estilo de código +composer dev:check-style -## Running Tests +# Corrección de estilo de código +composer dev:fix-style -The following tests must pass before we will accept a pull request. -If any of these do not pass, it will result in a complete build failure. -Before you can run these, be sure to `composer install` or `composer update`. +# Ejecución de pruebas +composer dev:test -```shell -vendor/bin/phpcs -sp src/ tests/ -vendor/bin/php-cs-fixer fix -v --dry-run -vendor/bin/phpunit --coverage-text -vendor/bin/phpstan analyse --no-progress --level max src/ tests/ +# Ejecución todo en uno, corregir estilo, verificar estilo y correr pruebas +composer dev:build ``` + +[phpCfdi]: https://github.com/phpcfdi/ +[project]: https://github.com/phpcfdi/sat-ws-descarga-masiva +[contributors]: https://github.com/phpcfdi/sat-ws-descarga-masiva/graphs/contributors +[coc]: https://github.com/phpcfdi/sat-ws-descarga-masiva/blob/master/CODE_OF_CONDUCT.md +[issues]: https://github.com/phpcfdi/sat-ws-descarga-masiva/issues diff --git a/README.md b/README.md index d65b509..cd0c5c2 100644 --- a/README.md +++ b/README.md @@ -27,29 +27,30 @@ Utiliza [composer](https://getcomposer.org/), instala de la siguiente forma: composer require phpcfdi/sat-ws-descarga-masiva ``` -## Ejemplo básico de uso +## Ejemplos de uso + +Todos los objetos de entrada y salida se pueden exportar como JSON para su fácil depuración. + +### Creación el servicio + +Ejemplo creando el servicio usando una FIEL disponible localmente. ```php isValid()) { return; } @@ -58,40 +59,238 @@ if (! $fiel->isValid()) { // para usarlo necesitas instalar guzzlehttp/guzzle pues no es una dependencia directa $webClient = new GuzzleWebClient(); +// creación del objeto encargado de crear las solicitudes firmadas usando una FIEL +$requestBuilder = new FielRequestBuilder($fiel); + +// Creación del servicio +$service = new Service($requestBuilder, $webClient); +``` + +### Cliente para consumir los servicios de CFDI de Retenciones + +Existen dos tipos de Comprobantes Fiscales Digitales, los regulares (ingresos, egresos, traslados, nóminas y pagos), +y los CFDI de retenciones e información de pagos (retenciones). + +Puede utilizar esta librería para consumir los CFDI de Retenciones. Para lograrlo construya el servicio con +la especificación de `ServiceEndpoints::retenciones()`. + +```php +use PhpCfdi\SatWsDescargaMasiva\RequestBuilder\RequestBuilderInterface; +use PhpCfdi\SatWsDescargaMasiva\Service; +use PhpCfdi\SatWsDescargaMasiva\Shared\ServiceEndpoints; +use PhpCfdi\SatWsDescargaMasiva\WebClient\GuzzleWebClient; + +/** + * @var GuzzleWebClient $webClient + * @var RequestBuilderInterface $requestBuilder + */ // Creación del servicio -$service = new Service($fiel, $webClient); +$service = new Service($requestBuilder, $webClient, null, ServiceEndpoints::retenciones()); +``` + +### Realizar una consulta + +Una vez creado el servicio se puede presentar la consulta. + +Valores por defecto: -// presentar una solicitud -$request = new QueryParameters( - new DateTimePeriod(new DateTime('2019-01-13 00:00:00'), new DateTime('2019-01-13 23:59:59')), +- Consultar comprobantes emitidos (`DownloadType::issued()`). +- Solicitar información de metadata (`RequestType::metadata()`). +- No filtrar por RFC. +- Usar el servicio para CFDI Regulares (en lugar de CFDI de Retenciones). + +```php +query($request); -$requestId = $query->getRequestId(); + +// verificar que el proceso de consulta fue correcto +if (! $query->getStatus()->isAccepted()) { + echo "Fallo al presentar la consulta: {$query->getStatus()->getMessage()}"; + return; +} + +// el identificador de la consulta está en $query->getRequestId() +echo "Se generó la solicitud {$query->getRequestId()}", PHP_EOL; +``` + +### Verificar una consulta + +La verificación depende de que la consulta haya sido aceptada. + +```php +verify($requestId); -$packageId = $verify->getPackagesIds()[0]; -// descargar -$download = $service->download($packageId); -$zipfile = "$packageId.zip"; -file_put_contents($zipfile, $download->getPackageContent()); +// revisar que el proceso de verificación fue correcto +if (! $verify->getStatus()->isAccepted()) { + echo "Fallo al verificar la consulta {$requestId}: {$verify->getStatus()->getMessage()}"; + return; +} -// obtener los CFDI del archivo ZIP -$cfdiReader = new CfdiPackageReader($zipfile); -foreach ($cfdiReader->fileContents() as $name => $content) { - file_put_contents("cfdis/$name", $content); +// revisar que la consulta no haya sido rechazada +if (! $verify->getCodeRequest()->isAccepted()) { + echo "La solicitud {$requestId} fue rechazada: {$verify->getCodeRequest()->getMessage()}", PHP_EOL; + return; } -// y si el contenido fuera un metadata -$metadataReader = new MetadataPackageReader($zipfile); -foreach ($metadataReader->metadata() as $metadata) { - echo $metadata->uuid, PHP_EOL; +// revisar el progreso de la generación de los paquetes +$statusRequest = $verify->getStatusRequest(); +if ($statusRequest->isExpired() || $statusRequest->isFailure() || $statusRequest->isRejected()) { + echo "La solicitud {$requestId} no se puede completar", PHP_EOL; + return; +} +if ($statusRequest->isInProgress() || $statusRequest->isAccepted()) { + echo "La solicitud {$requestId} se está procesando", PHP_EOL; + return; +} +if ($statusRequest->isFinished()) { + echo "La solicitud {$requestId} está lista", PHP_EOL; +} + +echo "Se encontraron {$verify->countPackages()} paquetes", PHP_EOL; +foreach ($verify->getPackagesIds() as $packageId) { + echo " > {$packageId}", PHP_EOL; +} +``` + +### Descargar los paquetes de la consulta + +La descarga de los paquetes depende de que la consulta haya sido correctamente verificada. + +Una consulta genera un identificador de la solicitud, +la verificación retorna **uno o varios** identificadores de paquetes. +Necesitas descargar todos y cada uno de los paquetes para tener la información completa de la consulta. + +```php +download($packageId); + if ($download->getStatus()->isAccepted()) { + echo "El paquete {$packageId} no se ha podido descargar: {$download->getStatus()->getMessage()}", PHP_EOL; + continue; + } + $zipfile = "$packageId.zip"; + file_put_contents($zipfile, $download->getPackageContent()); + echo "El paquete {$packageId} se ha almacenado", PHP_EOL; +} +``` + +### Lectura de paquetes + +Los paquetes de Metadata y CFDI se pueden leer con las clases `MetadataPackageReader` y `CfdiPackageReader` respectivamente. +Para fabricar los objetos, se pueden usar sus métodos `createFromFile` para crearlo a partir de un archivo existente +o `createFromContents` para crearlo a partir del contenido del archivo en memoria. + +Cada paquete puede contener uno o más archivos internos. Cada paquete se lee individualmente. + +#### Lectura de paquetes de tipo Metadata + +```php +getMessage(), PHP_EOL; + return; +} + +// leer todos los registros de metadata dentro de todos los archivos del archivo ZIP +foreach ($metadataReader->metadata() as $uuid => $metadata) { + echo $metadata->uuid, ': ', $metadata->fechaEmision, PHP_EOL; } ``` +#### Lectura de paquetes de tipo CFDI + +```php +getMessage(), PHP_EOL; + return; +} + +// leer todos los CFDI dentro del archivo ZIP con el UUID como llave +foreach ($cfdiReader->cfdis() as $uuid => $content) { + file_put_contents("cfdis/$uuid.xml", $content); +} +``` + +## Información técnica + +### Acerca de la interfaz `RequestBuilderInterface` + +El Servicio Web del SAT de Descarga Masiva requiere comunicación SOAP especial, con autenticación +y mensajes firmados. Generar estos mensajes requiere de gran detalle porque si el mensaje contiene +errores será inmediatamente rechazado. + +La firma de estos mensajes es con la FIEL, así que se puede utilizar la clase `FielRequestBuilder` que +junto con la clase `Fiel` y la librería [phpcfdi/credentials](https://github.com/phpcfdi/credentials) +hacen la combinación adecuada para firmar los mensajes. + +Sin embargo, existen escenarios distribuidos donde lo mejor sería contar con la creación de estos mensajes +firmados en un lugar externo, de esta forma la FIEL (la llave privada y contraseña) no se necesita exponer +al exterior. Para estos (u otros) escenarios, es posible crear una implementación de `RequestBuilderInterface` +que contenga la lógica adecuada y entregue los mensajes firmados necesarios para la comunicación. + ### Acerca de la interfaz `WebClientInterface` Para hacer esta librería compatible con diferentes formas de comunicación se utiliza una interfaz de cliente HTTP. @@ -103,14 +302,22 @@ Si lo prefieres -como en el ejemplo de uso- podrías instalar Guzzle `composer r ### Recomendación de fábrica del servicio Te recomendamos configurar el framework de tu aplicación (Dependency Injection Container) o crear una clase que -fabrique los objetos `Service`, `Fiel` y `WebClient` usando tus propias configuraciones de certificado, llave privada -y contraseña. +fabrique los objetos `Service`, `RequestBuilder` y `WebClient`, usando tus propias configuraciones de `Fiel` +en caso de que tengas disponible el certificado, llave privada y contraseña. + +### Manejo de excepciones + +Al trabajar con el lector de paquetes (PackageReader) o con la comunicación HTTP con el servidor +web set SAT (WebClient), la librería puede lanzar excepciones que puedes atrapar y analizar, ya +sea en el momento de implementación o para personalizar los mensajes de error. + +- [Documentación específica de excepciones de `phpcfd/sat-ws-descarga-masiva`](docs/Excepciones.md). ## Acerca del Servicio Web de Descarga Masiva de CFDI y Retenciones El servicio se compone de 4 partes: -1. Autenticación: Esto se hace con tu fiel y la libería oculta la lógica de obtener y usar el Token. +1. Autenticación: Esto se hace con tu FIEL y la libería oculta la lógica de obtener y usar el Token. 2. Solicitud: Presentar una solicitud incluyendo la fecha de inicio, fecha de fin, tipo de solicitud emitidas/recibidas y tipo de información solicitada (cfdi o metadata). 3. Verificación: pregunta al SAT si ya tiene disponible la solicitud. @@ -132,21 +339,22 @@ Si le pides muchas veces una caja puede que te digan que dejes de estar pidiendo al funcionario del SAT y no te la da más. * Todo esto sucede con un máximo de seguridad, cada vez que hablas con un funcionario te pide que le enseñes tu permiso -y si no lo tienes o ya está vencido (duran apenas unos minutos) te mandan con el de la entrada para que le demuestres -que eres tú y te extienda un nuevo permiso. +y si no lo tienes o ya está vencido (duran apenas unos minutos) te mandan con la persona de seguridad para que le +demuestres que eres tú y te extienda un nuevo permiso. ### Información oficial -- Liga oficial del SAT - -- Solicitud de descargas para CFDI y retenciones: +- Liga oficial del SAT + +- Solicitud de descargas para CFDI y retenciones: (no definida, la información oficial tiene errores). - Verificación de descargas de solicitudes exitosas: - + - Descarga de solicitudes exitosas: - + Notas importantes del web service: -- Podrás recuperar hasta 200 mil registros por petición y hasta un millón en metadata. + +- Podrás recuperar hasta 200 mil registros por petición y hasta 1,000,000 en metadata. - No existe limitante en cuanto al número de solicitudes siempre que no se descargue en más de dos ocasiones un XML. ### Notas de uso @@ -157,9 +365,11 @@ Se ha encontrado que la regla relacionada con las descargas de tipo CFDI no se a Sin embargo, se ha encontrado que la regla que sí aplica es: *no solicitar en más de 2 ocasiones el mismo periodo*. Cuando esto ocurre, el proceso de solicitud devuelve el mensaje *"5002: Se han agotado las solicitudes de por vida"*. -Recuerda que, si se cambia la fecha inicial o final en al menos un segundo ya se trata de otro periodo, por lo que si te encuentras en este problema podrías solucionarlo de esta forma. +Recuerda que, si se cambia la fecha inicial o final en al menos un segundo ya se trata de otro periodo, +por lo que si te encuentras en este problema podrías solucionarlo de esta forma. -En consultas del tipo Metadata no aplica dicha limitante mencionada anteriormente, por ello es recomendable hacer las pruebas de implementación con este tipo de consulta. +En consultas del tipo Metadata no se aplica la limitante mencionada anteriormente, por ello es recomendable +hacer las pruebas de implementación con este tipo de consulta. ## Compatilibilidad @@ -169,6 +379,10 @@ Esta librería se mantendrá compatible con al menos la versión con 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. +### Actualizaciones + +- [Guía de actualización de versión 0.3 a 0.4](docs/UPGRADE_0.3_0.4.md). + ## Contribuciones Las contribuciones con bienvenidas. Por favor lee [CONTRIBUTING][] para más detalles @@ -179,7 +393,6 @@ y recuerda revisar el archivo de tareas pendientes [TODO][] y el [CHANGELOG][]. The `phpcfdi/sat-ws-descarga-masiva` library 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/blob/master/CONTRIBUTING.md [changelog]: https://github.com/phpcfdi/sat-ws-descarga-masiva/blob/master/docs/CHANGELOG.md [todo]: https://github.com/phpcfdi/sat-ws-descarga-masiva/blob/master/docs/TODO.md diff --git a/composer.json b/composer.json index 82faf42..9b80d7c 100644 --- a/composer.json +++ b/composer.json @@ -30,11 +30,11 @@ "eclipxe/micro-catalog": "^0.1.2" }, "require-dev": { - "guzzlehttp/guzzle": "^6.3", - "robrichards/xmlseclibs": "^3.0.4", - "phpunit/phpunit": "^9.1", - "squizlabs/php_codesniffer": "^3.0", - "friendsofphp/php-cs-fixer": "^2.4", + "guzzlehttp/guzzle": "^7.2.0", + "robrichards/xmlseclibs": "^3.1.0", + "phpunit/phpunit": "^9.3.5", + "squizlabs/php_codesniffer": "^3.5.6", + "friendsofphp/php-cs-fixer": "^2.16.4", "phpstan/phpstan": "^0.12" }, "suggest": { diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b2035dd..637e2b7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,6 +14,49 @@ que nombraremos así: ` Breaking . Feature . Fix `, donde: **Importante:** Las reglas de SEMVER no aplican si estás usando una rama (por ejemplo `master-dev`) o estás usando una versión cero (por ejemplo `0.18.4`). +## Version 0.4.0 2020-10-14 + +- Guía de actualización de la versión 0.3.2 a la versión 0.4.0: [UPGRADE_0.3_0.4](UPGRADE_0.3_0.4.md) +- Se agregan [excepciones específicas en la librería](Excepciones.md). Además, cuando se detecta una respuesta + que contiene un *SOAP Fault* se genera una excepción. +- Se rompe la dependencia directa de `Service` a `Fiel`, ahora depende de `RequestBuilderInterface`. +- Se crea la implementación `FielRequestBuilder` para seguir trabajando con la `Fiel`. +- Se mueve `Fiel` adentro del namespace `PhpCfdi\SatWsDescargaMasiva\RequestBuilder\FielRequestBuilder`. +- Se modifican los servicios de autenticación, consulta, descarga y verificación para que, + en lugar de que ellos mismos construyan las peticiones XML firmadas, ahora las deleguen a `RequestBuilderInterface`. +- Ahora se puede especificar un RFC específico en la consulta: + - Si consultamos los emitidos podríamos filtrar por el RFC receptor. + - Si consultamos los recibidos podríamos filtrar por el RFC emisor. +- Ahora se puede consumir el servicio para los CFDI de retenciones e información de pagos. +- Se agrega la interfaz `PackageReaderInterface` que contiene el contrato esperado por un lector de paquetes. +- Se crea la clase interna `FilteredPackageReader` que implementa `PackageReaderInterface`, también se agregan + las clases `MetadataFileFilter` y `CfdiFileFilter` que permiten el filtrado de los archivos correctos dentro + de los paquetes del SAT. +- Se restructura `MetadataPackageReader` para cumplir con la interfaz `PackageReaderInterface`, + ahora se comporta como una fachada de un `FilteredPackageReader`. +- Se restructura `CfdiPackageReader` para cumplir con la interfaz `PackageReaderInterface`, + ahora se comporta como una fachada de un `FilteredPackageReader`. +- Se agrega el método generador `CfdiPackageReader::cfdis()` que contiene en su llave el UUID del CFDI + y en el valor el contenido del CFDI. +- Se agregan los constructores estáticos `::create()` de los objetos usados en `QueryParameters` y en la propia clase. +- Se convierten varias clases en finales: `StatusCode`, `DateTime`, `DateTimePeriod`, `DownloadType`, `Fiel`, + `RequestType`, `Token`, `QueryParameters`, `QueryResult`, `VerifyResult`, `DownloadResult`. +- Se mueven y crean diferentes clases que solo deben ser utilizadas internamente al namespace "interno" + `PhpCfdi\SatWsDescargaMasiva\Internal`: `Helpers`, `InteractsXmlTrait`, `ServiceConsumer`, `SoapFaultInfoExtractor`. +- Se marcan como clases internas los traductores usados dentro de los servicios. +- Se mueve lógica repetida en los servicios de autenticación, consulta, verificación y descarga hacia dentro + del método `InteractsXmlTrait::createSignature`. +- Se implementa `JsonSerializable` en todos los DTO, en los lectores de paquetes y en las excepciones específicas. +- Se agregan muchas pruebas unitarias para comprobar el funcionamiento esperado y la cobertura de código. +- Se actualizan las dependencias: + - `guzzlehttp/guzzle` de `6.3` a `7.2` + - `robrichards/xmlseclibs` de `3.0` a `3.1` + - `phpunit/phpunit` de `9.1` a `9.3` +- Documentación general: + - Se agregan bloques de documentación a clases y métodos en toda la librería. + - Se separan los bloques de ejemplos de uso en cada caso en lugar de usar solo un bloque. + - Los códigos de servicios cambian de `Services-StatusCode.md` a `CodigosDeServicios`. + ## Version 0.3.2 2020-07-28 - Se corrige el problema de cambio de formato al definir el nombre de los archivos contenidos en diff --git a/docs/CodigosDeServicios.md b/docs/CodigosDeServicios.md new file mode 100644 index 0000000..616fa05 --- /dev/null +++ b/docs/CodigosDeServicios.md @@ -0,0 +1,87 @@ +# Códigos de servicios + +Los 3 servicios de consumo `Consulta/Query`, `Verificación/Verify` y `Descarga/Download` entregan información predefinida. +`CodEstatus` y `Mensaje` se repite en los 3 servicios. + +* Consulta/Query + - CodEstatus: Código de estado de la llamada. + - Mensaje: Pequeña descripcion del código de estado. + +* Verificación/Verify + - CodEstatus: Código de estado de la llamada. + - Mensaje: Pequeña descripcion del código de estado. + - CodigoEstadoSolicitud: `CodeRequest` Estado de la solicitud de la descarga + - EstadoSolicitud: `StatusRequest` número correspondiente al estado de la solicitud de descarga. + +* Descarga/Download + - CodEstatus: Código de estado de la llamada. + - Mensaje: Pequeña descripcion del código de estado. + +## Acerca de `CodEstatus` + +Códigos de estado de la petición realizada. No se cuenta con un catálogo específico porque el mensaje está devuelto en `Mensaje`. + +Ambos valores se pueden obtener con el objeto `StatusCode` que contiene las propiedades +`getCode(): int` y `getMessage(): string`. + +Las respuestas de los servivios cuentan con la propiedad `getStatusCode(): StatusCode`, por ejemplo `VerifyResult::getStatusCode()`. + +| Servicio | Code | Descripción | +| ----------------- | ---- | --------------------------------------------------------------------------------------- | +| All | 300 | Usuario no válido | +| All | 301 | XML mal formado | +| All | 302 | Sello mal formado | +| All | 303 | Sello no corresponde con RfcSolicitante | +| All | 304 | Certificado revocado o caduco | +| All | 305 | Certificado inválido | +| All | 5000 | Solicitud recibida con éxito | +| Query | 5001 | Tercero no autorizado | +| Query | 5002 | Se agotó las solicitudes de por vida: Máximo para solicitudes con los mismos parámetros | +| Verify & download | 5004 | No se encontró la solicitud | +| Query | 5005 | Solicitud duplicada: Si existe una solicitud vigente con los mismos parámetros | +| Query & download | 404 | Error no controlado: Reintentar más tarde la petición | + +## Acerca de `CodigoEstadoSolicitud` + +Este campo se parece mucho a `StatusCode` sin embargo tiene algunas diferencias: solo aparece en el servicio de +verificación y no contiene todos los valores posibles, incluso agrega el código `5003`. + +Está implementado en el objeto `CodeRequest` disponible desde `VerifyResult::getCodeRequest()`. + +El valor del código se puede obtener con `CodeRequest::getValue(): int`. +Aunque la descripción no es devuelta como respuesta del servicio, se ha documentado en la clase +y se puede obtener con el método `CodeRequest::getMessage(): string`. + +Este objeto también permite la comprobación por *nombre clave*, por lo que puedes usar por ejemplo +`CodeRequest::isEmptyResult()` para conocer si se encuentra en el estado `5004: No se encontró la solicitud`. + +| Code | Name | Descripción | +| ---- | ------------------ | --------------------------------------------------------------------------------------- | +| 5000 | Accepted | Solicitud recibida con éxito | +| 5002 | Exhausted | Se agotó las solicitudes de por vida: Máximo para solicitudes con los mismos parámetros | +| 5003 | MaximumLimitReaded | Tope máximo: Indica que se está superando el tope máximo de CFDI o Metadata | +| 5004 | EmptyResult | No se encontró la solicitud | +| 5005 | Duplicated | Solicitud duplicada: Si existe una solicitud vigente con los mismos parámetros | + +## Acerca de `EstadoSolicitud` + +Este código solo está presente en el servicio de verificación. + +Está implementado en el objeto `StatusRequest` disponible desde `VerifyResult::getStatusRequest()`. + +El valor del código se puede obtener con `StatusRequest::getValue(): int`. +Aunque la descripción no es devuelta como respuesta del servicio, se ha documentado en la clase +y se puede obtener con el método `StatusRequest::getMessage(): string`. + +Este objeto también permite la comprobación por *nombre clave*, por lo que puedes usar por ejemplo +`StatusRequest::isExpired()` para conocer si se encuentra en el estado `6: Vencida`. + +| Code | Name | Descripción | +| ---- | ------------ | ------------ | +| 1 | Accepted | Aceptada | +| 2 | InProgress | En proceso | +| 3 | Finished | Terminada | +| 4 | Failure | Error | +| 5 | Rejected | Rechazada | +| 6 | Expired | Vencida | + diff --git a/docs/EstructuraDelProyecto.md b/docs/EstructuraDelProyecto.md new file mode 100644 index 0000000..58d9eac --- /dev/null +++ b/docs/EstructuraDelProyecto.md @@ -0,0 +1,36 @@ +# Estructura del proyecto + +## Organización del código + +El código está en `src/` y tiene la siguiente estructura: + +- `Service` Clase principal de toda la librería. +- `Internal` Objetos privados de la librería. +- `Services\` Donde los 4 servicios están ubicados. +- `Shared` Objetos compartidos, en su mayoría DTO. +- `PackageReader` Objetos relacionados con la lectura de paquetes descargados del SAT. +- `RequestBuilder` Interfaz de generación de solicitudes XML e implementación local usando `Fiel` y `Credentials`. +- `WebClient` Cliente HTTP de comunicación con el Webservice del SAT, definición e implementación. + +### `Services\` + +Hay cuatro servicios fundamentales, los objetos particulares de estos servicios están almacenados en cada directorio + +- `Service\Authenticate` +- `Service\Query` +- `Service\Verify` +- `Service\Download` + +Cada servicio puede contener algunos objetos con propósito especial. + +- `Translators` Crean o transforman un objeto de dominio a SOAP y viceversa. +- `Result` Resultado de la operación de consumir el servicio. +- `Parameters` Parámetros para realizar la operación. + +## Organización de las pruebas `tests/` + +- `bootstrap.php` PHP Unit boostrap file +- `TestCase.php` Main test case where all test cases depends on +- `_files/` Where common files lives, use helper methods on `TestCase` to retrieve path or contents +- `Unit\` Unit tests, they don't touch external world +- `Integration\` Integration tests, they touch the SAT web service diff --git a/docs/Excepciones.md b/docs/Excepciones.md new file mode 100644 index 0000000..e35e876 --- /dev/null +++ b/docs/Excepciones.md @@ -0,0 +1,72 @@ +# Excepciones de `phpcfd/sat-ws-descarga-masiva` + +El manejo de excepciones en esta librería está basado en el artículo +. + +Al implementar la librería no se espera que tu implementación fabrique ninguna de estas excepciones. +Lo que se espera es que, cuando se hacen llamadas a los métodos de esta librería, tú puedas atrapar +las excepciones de forma plenamente identificada y con todos los datos contextuales para que las +puedas aprovechar. + +## Excepciones lógicas + +La librería utiliza la excepción SPL `LogicException` o alguna de sus derivadas como `InvalidArgumentException` +para identificar excepciones de implementación. Cuando se encuentra una excepción de este tipo significa +que se está utilizando erróneamente la librería, es una excepción que te debería llevar a modificar tu +código para que no vuelva a ocurrir. + +## Excepciones de tiempo de ejecución + +La librería utiliza excepciones de tipo `RuntimeException` para identificar condiciones inesperadas. +Las excepciones identificadas dentro de la librería son de este tipo. + +## Excepciones de PackageReader + +`PackageReaderException` es una interfaz para englobar las excepciones lanzadas desde el espacio de nombres +`PhpCfdi\SatWsDescargaMasiva\PackageReader`. +De esta forma se puede utilizar un flujo para atrapar estas excepciones con `try {} catch {PackageReaderException $e}`. + +``` +- PackageReaderException + - OpenZipFileException + - CreateTemporaryZipFileException +``` + +`OpenZipFileException` es una `PackageReaderException`. Es lanzada cuando no ha sido posible leer un archivo ZIP. +Dentro de sus propiedades está `getFileName(): string` para conocer exactamente la ubicación del archivo que no fue +posible abrir, así como `getCode(): int` para saber el código de error devuelto por el objeto `ZipArchive`. + +`CreateTemporaryZipFileException` es una `PackageReaderException`. Es lanzada cuando no ha sido posible almacenar +un archivo temporal con el contenido provisto. + +## Excepciones de WebClient + +`WebClientException` es una clase para englobar las excepciones lanzadas desde el espacio de nombres +`\PhpCfdi\SatWsDescargaMasiva\WebClient`. +De esta forma se puede utilizar un flujo para atrapar estas excepciones con `try {} catch {WebClientException $e}`. + +``` +- WebClientException + - HttpServerError + - HttpClientError + - SoapFaultError +``` + +Las excepciones son de dos tipos `HttpServerError` y `HttpClientError` con una especialización `SoapFaultError` +para cuando la respuesta no fue un error de tipo HTTP pero el servidor SOAP sí reportó un error. + +Lo principal es que `WebClientException` contiene los métodos `getRequest(): Request` y `getResponse(): Response`, +por lo que siempre puedes conocer la comunicación básica HTTP cuando ocurre un error de este tipo. + +Adicionalmente, `SoapFaultError` contiene el método `getFault(): SoapFaultInfo`, con lo que se puede conocer +el código y mensaje de error SOAP devuelto por el servidor. + +## Excepciones de RequestBuilder + +El objeto `FielRequestBuilder` implementa la interfaz `RequestBuilderInterface`. +Los métodos de `RequestBuilderInterface` podrían devolver excepciones de tipo `RequestBuilderException`. + +Sin embargo, no es necesario peocuparse por estos métodos, dado que el objeto `FielRequestBuilder` no se +utiliza directamente, solo se utiliza indirectamente a través del objeto `Service`. + +Si recibe alguna excepción de este tipo por favor levante un ticket porque significa un error en nuestra librería. diff --git a/docs/ProjectStructure.md b/docs/ProjectStructure.md deleted file mode 100644 index 2ff6ff2..0000000 --- a/docs/ProjectStructure.md +++ /dev/null @@ -1,43 +0,0 @@ -# Project structure - -## Source root level - -At root level there are only: - -- `Service`: Main class of the whole library -- `Services\`: Where the 4 main services specific classes are located -- `Shared\`: DTO, internal and helper objects -- `WebClient\`: http web client specification and classes - -### `Services\` - -There are 4 main services, objects related to this are located into `Services\` - -- Service\Authenticate -- Service\Query -- Service\Verify -- Service\Download - -Each service can have different objects by whet they do: - -- Translators: Create one object from other, create SOAP XML requests, read SOAP XML responses -- Result: DTO containing the result of an operation -- Parameters: DTO containing the parameters to perform an operation - -### Shared objects - -The objects located here are common for two or more services - -### WebClient - -Contains the web client abstraction/simplification, http requests, http responses and Web Client Interface - -## Tests organization - -- `bootstrap.php` PHP Unit boostrap file -- `TestCase.php` Main test case where all test cases depends on -- `_files/` Where common files lives, use helper methods on `TestCase` to retrieve path or contents -- `Unit\` Unit tests, they don't touch external world -- `Integration\` Integration tests, they touch the SAT web service -- `Scripts\` command line interface utility for testing - diff --git a/docs/Services-StatusCode.md b/docs/Services-StatusCode.md deleted file mode 100644 index 0b37610..0000000 --- a/docs/Services-StatusCode.md +++ /dev/null @@ -1,46 +0,0 @@ - -Los tres servicios tienen statusCode & Message - -Códigos de error según la documentación. -Q V D 300 Usuario no válido -Q V D 301 XML mal formado -Q V D 302 Sello mal formado -Q V D 303 Sello no corresponde con RfcSolicitante -Q V D 304 Certificado revocado o caduco -Q V D 305 Certificado inválido -Q V D 5000 Solicitud recibida con éxito -Q - - 5001 Tercero no autorizado -Q - - 5002 Se agotó las solicitudes de por vida: Máximo para solicitudes con los mismos parámetros -- V D 5004 No se encontró la solicitud -Q - - 5005 Solicitud duplicada: Si existe una solicitud vigente con los mismos parámetros -Q - D 404 Error no controlado: Reintentar más tarde la petición - -CodigoEstadoSolicitud: -Q V D 5000 Solicitud recibida con éxito -Q - - 5002 Se agotó las solicitudes de por vida: Máximo para solicitudes con los mismos parámetros -- - - 5003 Tope máximo: Indica que se está superando el tope máximo de CFDI o Metadata -- V D 5004 No se encontró la solicitud -Q - - 5005 Solicitud duplicada: Si existe una solicitud vigente con los mismos parámetros - -EstadoSolicitud: -1 Aceptada -2 En proceso -3 Terminada -4 Error -5 Rechazada -6 Vencida - -* Query - CodStatus <- Código de estado de la llamada - Mensaje <- Pequeña descripcion del código de estatus - -* Verify - CodEstatus <- Código de estado de la llamada - Mensaje <- Pequeña descripcion del código de estado - CodigoEstadoSolicitud <- (CodeRequest) Estado de la solicitud de la descarga (X) - EstadoSolicitud <- (StatusRequest) número correspondiente al estado de la solicitud de descarga - -* Download: Envelope/Header/respuesta - CodEstatus <- Código de estado de la llamada - Mensaje <- Pequeña descripcion del código de estado - diff --git a/docs/TODO.md b/docs/TODO.md index 8699172..b2ce96b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,24 +1,32 @@ # phpcfdi/sat-ws-descarga-masiva To Do List -- Generar excepciones del proyecto en lugar de excepciones genéricas. +## Tareas pendientes + +- Llevar el code coverage a 100% con las pruebas + 2020-10-09: Version 0.4.0 99% + 2020-05-01: Version 0.3.0 93% + 2019-12-06: Version 0.2.4 92% + +## Posibles ideas + +- Separar `PhpCfdi\SatWsDescargaMasiva\RequestBuilder` y `PhpCfdi\SatWsDescargaMasiva\RequestBuilder\Fiel` + a sus propios proyectos de librería. O implementar un "monorepo" que genere las tres librerías: + `phpcfdi/sat-ws-descarga-masiva`, `phpcfdi/sat-ws-request-builder` y , `phpcfdi/sat-ws-request-builder-fiel`. + +## Tareas resueltas -- Mover el script de consumo con credenciales válidas a su propio proyecto dependiente de este. +- Mover la herramienta CLI de consumo con credenciales válidas a su propio proyecto dependiente de este. + 2020-10-14: Ya inició el desarrollo de `phpcfdi/sat-ws-descarga-masiva-cli` + +- Generar excepciones del proyecto en lugar de excepciones genéricas. + 2020-10-09: Hecho en v0.4 - Los objetos CfdiPackageReader y MetadataPackageReader deberían de utilizar objetos independientes para el filtrado, las forma de estructurarlo a través de un AbstractPackageReader no es la mejor opción. Un ejemplo claro es la imposibilidad de crear tests unitarios correctos, porque el objeto encargado de leer las entradas del archivo zip comparte la responsabilidad de filtrar por nombre o por contenido, estas últimas dos responsabilidades deberían ser independientes. - -- Llevar el code coverage a 100% con test unitarios - 2020-05-01: Version 0.3.0 93% - 2019-12-06: Version 0.2.4 92% - 2019-09-23: Version 0.2.3 93% - 2019-08-23: Current 93% - 2019-08-09: Current 86% - 2019-08-08: Current 84% - -## Tareas resueltas + 2020-10-09: Hecho en v0.4 - Poner la versión mínima de PHP a 7.3 2020-05-01: Hecho! @@ -45,7 +53,7 @@ 2019-08-08: Se creó el StatusCode para exponer el código y mensaje en los servicios comúnes - Mejorar la búsqueda de elementos con DOMXPath - 2019-08-08: No se cambia, aún cuando la búsqueda es costosa, si se cambia, + 2019-08-08: No se cambia, aun cuando la búsqueda es costosa, si se cambia, nos meteremos en problemas de espacios de nombres y soporte de mayúsculas y minúsculas con los nombres de los atributos. Lo que se podría hacer es usar una librería de lectura rápida como QuickReader de CfdiUtils, @@ -60,8 +68,8 @@ 2019-08-07: Se creó el script test/Scripts/sat-ws-descarga-masiva.php que consume los servicios. Lo más seguro es que esto sea extraído a su propio proyecto con un framework de CLI bien hecho. -- Change `Service::authenticate()` behavior, store the last valid token, if token still valid return that value instead - of creating a new one. +- Change `Service::authenticate()` behavior, store the last valid token, + if token still valid return it instead of creating a new one. 2019-08-07: Mientras el Token sea válido se reutiliza - Verificar que los atributos en QueryTranslator SolicitaDescarga/solicitud son importantes, diff --git a/docs/UPGRADE_0.3_0.4.md b/docs/UPGRADE_0.3_0.4.md new file mode 100644 index 0000000..673b6bd --- /dev/null +++ b/docs/UPGRADE_0.3_0.4.md @@ -0,0 +1,78 @@ +# Actualizar de `0.3.x` a `0.4.x` + +Nota: De antemano lamento que las implementaciones hechas en la versión `0.3` se rompan, era necesario. +El cambio de la versión `0.4` a la versión `1.0` podría tener también algunos cambios importantes. + +## Servicio principal + +Anteriormente la clase `Service` dependía directamente del objeto `Fiel`, ahora utiliza un fabricador +de solicitudes `RequestBuilderInterface`. El constructor de solicitudes `FielRequestBuilder` es una +implementación de la interfaz `RequestBuilderInterface`, que a su vez depende de `Fiel`. + +```text +// antes: +$service = new Service($fiel, $webClient); + +// ahora +$service = new Service(new FielRequestBuilder($fiel), $webClient); +``` + +Gracias al cambio anterior, ahora es posible implementar la lógica de creación de mensajes firmados de +manera remota y no solo local, por lo que se podría tener una implementación que no necesite tener +siempre disponible la eFirma como un archivo local. + +## Construcción de consultas + +Las consultas se construyen con el objeto `QueryParameters`, ahora se incluyen constructores estáticos +`Object::create` y se favorece que se utilicen en lugar de los constructores naturales `new Object()`. + +## Uso del WebClient + +La interfaz `WebClientInterface` y la clase `GuzzleWebClient` no ha cambiado, pero ahora `Service` utiliza +el conector ligeramente diferente, esto es porque ahora es capaz de detectar si se recibió una respuesta +de error tipo SOAP desde el servicio web del SAT y entonces desencadenar la excepción. + +## Excepciones + +La librería cuenta ahora con excepciones específicas, por lo que es más sencillo atrapar errores +y gestionar qué hacer cuando ocurren. [Vea la documentación de excepciones](Excepciones.md). + +## Lectura de paquetes + +Las clases que permiten la lectura de paquetes descargados ahora son diferentes. + +En el caso de paquetes CFDI se agregó el método `CfdiPackageReader::cfdi()` que contiene +el UUID como clave y el contenido como valor. + +En el caso de paquetes Metadata el conteo `Metadata::count()` o `count($metadata)` devuelve +el número de registros y no el conteo de archivos. + +```text +// la forma recomendada para leer paquetes de CFDI es ahora en el método iterador `cfdi()` +$cfdiReader = CfdiPackageReader::createFromFile($zipfile); +foreach ($cfdiReader->cfdis() as $uuid => $content) { + file_put_contents("cfdis/$uuid.xml", $content); +} +``` + +- Se agregó el método `CfdiPackageReader::cfdis()` que devuelve un objeto traversable con UUID como llave + y el contenido XML valor. Esto garantiza que pueda guardar el archivo con su nombre correcto porque el SAT + en algunas ocasiones entrega como nombre de archivo un valor distinto al UUID del CFDI. +- Cambio en el constructor del lector de Metadata, antes usaba `$reader = new MetadataPackageReader($filename);` + y ahora `$reader = MetadataPackageReader::createFromFile($filename);`. +- Cambio en el constructor del lector de CFDI, antes usaba `$reader = new CfdiPackageReader($filename);` + y ahora `$reader = CfdiPackageReader::createFromFile($filename);`. +- El valor devuelto en `count(MetadataPackageReader)` o `MetadataPackageReader::count()` antes era el número + de archivos contenidos en el paquete de Metadata. Ahora corresponde al conteo de registros de los archivos + contenidos en todo el paquete. Si necesita obtener el conteo de los archivos puede hacerlo de la siguiente + forma: `iterator_count(MetadataPackageReader::fileContents())`. +- Se eliminó `MetadataPackageReader::createMetadataContent()`. +- Antes se generaban excepciones estándar `\RuntimeException`, ahora se usan excepciones específicas + `OpenZipFileException` y `CreateTemporaryZipFileException` que extienden `\RuntimeException`. +- Antes se exponía la clase `MetadataContent` en el método `MetadataPackageReader::createMetadataContent()`. + Ahora la clase `MetadataContent` es totalmente interna. + +## Otros cambios importantes + +Los parámetros y resultados de los servicios así como los objetos de valor se han marcado como inmutables +y ahora se pueden exportar a JSON usando `json_decode` porque implementan la interfaz `JsonSerializable`. diff --git a/phpunit.xml.dist b/phpunit.xml.dist index eca2b09..a4dcaa3 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,15 +1,16 @@ + + xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd" + bootstrap="./tests/bootstrap.php" colors="true" verbose="true" + cacheResultFile="./build/phpunit.result.cache"> ./tests/ - - - + + ./src/ - - + + diff --git a/src/Internal/Helpers.php b/src/Internal/Helpers.php new file mode 100644 index 0000000..6ea5cf7 --- /dev/null +++ b/src/Internal/Helpers.php @@ -0,0 +1,30 @@ + - - - - - - - - ${digested} - - - EOT; - return $this->nospaces($xml); - } - - protected function createKeyInfoData(Fiel $fiel): string - { - $certificate = Helpers::cleanPemContents($fiel->getCertificatePemContents()); - $serial = $fiel->getCertificateSerial(); - $issuerName = $fiel->getCertificateIssuerName(); - - return << - - - ${issuerName} - ${serial} - - ${certificate} - - - EOT; - } - - protected function createSignatureData(string $signedInfo, string $signatureValue, string $keyInfo): string - { - $signedInfo = str_replace('', '', $signedInfo); - return << - ${signedInfo} - ${signatureValue} - ${keyInfo} - - EOT; - } } diff --git a/src/Internal/ServiceConsumer.php b/src/Internal/ServiceConsumer.php new file mode 100644 index 0000000..b13935a --- /dev/null +++ b/src/Internal/ServiceConsumer.php @@ -0,0 +1,104 @@ +execute($webclient, $soapAction, $uri, $body, $token); + } + + public function execute(WebClientInterface $webclient, string $soapAction, string $uri, string $body, ?Token $token): string + { + $headers = $this->createHeaders($soapAction, $token); + $request = $this->createRequest($uri, $body, $headers); + $exception = null; + try { + $response = $this->runRequest($webclient, $request); + } catch (WebClientException $webClientException) { + $exception = $webClientException; + $response = $webClientException->getResponse(); + } + $this->checkErrors($request, $response, $exception); + return $response->getBody(); + } + + /** + * @param string $uri + * @param string $body + * @param array $headers + * @return Request + */ + public function createRequest(string $uri, string $body, array $headers): Request + { + return new Request('POST', $uri, $body, $headers); + } + + /** + * @param string $soapAction + * @param Token|null $token + * @return array + */ + public function createHeaders(string $soapAction, ?Token $token): array + { + $headers = ['SOAPAction' => $soapAction]; + if (null !== $token) { + $headers['Authorization'] = 'WRAP access_token="' . $token->getValue() . '"'; + } + return $headers; + } + + public function runRequest(WebClientInterface $webclient, Request $request): Response + { + $webclient->fireRequest($request); + try { + $response = $webclient->call($request); + } catch (WebClientException $exception) { + $webclient->fireResponse($exception->getResponse()); + throw $exception; + } + $webclient->fireResponse($response); + return $response; + } + + public function checkErrors(Request $request, Response $response, ?Throwable $exception = null): void + { + // evaluate SoapFaultInfo + $fault = SoapFaultInfoExtractor::extract($response->getBody()); + if (null !== $fault) { + throw new SoapFaultError($request, $response, $fault, $exception); + } + + // evaluate response + if ($response->statusCodeIsClientError()) { + $message = sprintf('Unexpected client error status code %d', $response->getStatusCode()); + throw new HttpClientError($message, $request, $response, $exception); + } + if ($response->statusCodeIsServerError()) { + $message = sprintf('Unexpected server error status code %d', $response->getStatusCode()); + throw new HttpServerError($message, $request, $response, $exception); + } + if ($response->isEmpty()) { + throw new HttpServerError('Unexpected empty response from server', $request, $response, $exception); + } + } +} diff --git a/src/Internal/SoapFaultInfoExtractor.php b/src/Internal/SoapFaultInfoExtractor.php new file mode 100644 index 0000000..750d57d --- /dev/null +++ b/src/Internal/SoapFaultInfoExtractor.php @@ -0,0 +1,45 @@ +obtainFault($source); + } + + public function obtainFault(string $source): ?SoapFaultInfo + { + try { + /** + * don't care about errors from invalid xml + * @noinspection PhpUsageOfSilenceOperatorInspection + */ + $env = @$this->readXmlElement($source); + } catch (Throwable $exception) { + return null; + } + + $code = trim($this->findElement($env, 'body', 'fault', 'faultcode')->textContent ?? ''); + $message = trim($this->findElement($env, 'body', 'fault', 'faultstring')->textContent ?? ''); + if ('' === $code && '' === $message) { + return null; + } + + return new SoapFaultInfo($code, $message); + } +} diff --git a/src/PackageReader/AbstractPackageReader.php b/src/PackageReader/AbstractPackageReader.php deleted file mode 100644 index 3e07cbf..0000000 --- a/src/PackageReader/AbstractPackageReader.php +++ /dev/null @@ -1,125 +0,0 @@ -zip = new ZipArchive(); - $zipCode = $this->zip->open($filename, ZipArchive::CREATE); - if (true !== $zipCode) { - throw new RuntimeException(sprintf('Could not open zip file (code %s)', $zipCode)); - } - - $this->filename = $filename; - $this->removeOnDestruct = false; - } - - public function __destruct() - { - // destruct does not enter if the object was not fully constructed - $this->zip->close(); - if ($this->removeOnDestruct && file_exists($this->filename)) { - unlink($this->filename); - } - } - - public static function createFromContents(string $content): self - { - /** @noinspection PhpUsageOfSilenceOperatorInspection will check and throw exception */ - $tmpfile = @tempnam(sys_get_temp_dir(), 'TMP_'); - if (false === $tmpfile) { - /** @codeCoverageIgnore */ - throw new RuntimeException('Could not create the temporary file'); - } - - /** @noinspection PhpUsageOfSilenceOperatorInspection will check and throw exception */ - $written = @file_put_contents($tmpfile, $content); - if (false === $written) { - /** @codeCoverageIgnore */ - throw new RuntimeException('Could not write in temporary file'); - } - - try { - $zip = new static($tmpfile); - } catch (Throwable $exception) { - unlink($tmpfile); - throw $exception; - } - - $zip->removeOnDestruct = true; - return $zip; - } - - public function count(): int - { - return iterator_count($this->fileContents()); - } - - /** - * Generates the list of name => contents and yield only entries that pass name and content filters - * - * @return Generator|string[] pair of file name and contents - */ - public function fileContents() - { - for ($i = 0; $i < $this->zip->numFiles; $i++) { - $filename = strval($this->zip->getNameIndex($i)); - if ('' === $filename) { - /** @codeCoverageIgnore */ - continue; // cannot get the file name - } - if (! $this->filterEntryFilename($filename)) { - continue; // did not pass the filename filter - } - - $contents = $this->zip->getFromName($filename); - if (false === $contents || ! $this->filterContents($contents)) { - unset($contents); // release memory as it was filtered - continue; // did not pass the filename filter - } - - yield $filename => $contents; - } - } - - public function getFilename(): string - { - return $this->filename; - } -} diff --git a/src/PackageReader/CfdiPackageReader.php b/src/PackageReader/CfdiPackageReader.php index 442d78e..ea21054 100644 --- a/src/PackageReader/CfdiPackageReader.php +++ b/src/PackageReader/CfdiPackageReader.php @@ -4,23 +4,82 @@ namespace PhpCfdi\SatWsDescargaMasiva\PackageReader; -class CfdiPackageReader extends AbstractPackageReader +use PhpCfdi\SatWsDescargaMasiva\PackageReader\Internal\FileFilters\CfdiFileFilter; +use PhpCfdi\SatWsDescargaMasiva\PackageReader\Internal\FilteredPackageReader; +use Traversable; + +final class CfdiPackageReader implements PackageReaderInterface { - protected function filterEntryFilename(string $filename): bool + /** @var PackageReaderInterface */ + private $packageReader; + + private function __construct(PackageReaderInterface $packageReader) + { + $this->packageReader = $packageReader; + } + + public static function createFromFile(string $filename): self + { + $packageReader = FilteredPackageReader::createFromFile($filename); + $packageReader->setFilter(new CfdiFileFilter()); + return new self($packageReader); + } + + public static function createFromContents(string $contents): self + { + $packageReader = FilteredPackageReader::createFromContents($contents); + $packageReader->setFilter(new CfdiFileFilter()); + return new self($packageReader); + } + + /** + * Traverse the CFDI contained in the hole package + * The key is the UUID and the content is the XML + * + * @return Traversable + */ + public function cfdis() { - // this regexp means that start with al least 1 char that is not "/" or "\" - // and continues and ends with ".xml". So x.xml x.xml.xml are valid, but not a/x.xml - if (boolval(preg_match('/^[^\/\\\\]+\.xml$/i', $filename))) { - return true; + foreach ($this->packageReader->fileContents() as $content) { + yield $this->obtainUuidFromXmlCfdi($content) => $content; } - return false; } - protected function filterContents(string &$contents): bool + public function getFilename(): string { - if (false === strpos($contents, 'packageReader->getFilename(); + } + + public function count(): int + { + return iterator_count($this->cfdis()); + } + + public function fileContents() + { + yield from $this->packageReader->fileContents(); + } + + /** + * Helper method to extract the UUID from the TimbreFiscalDigital + * + * @param string $xmlContent + * @return string + */ + public static function obtainUuidFromXmlCfdi(string $xmlContent): string + { + $found = preg_match('/TimbreFiscalDigital.*?UUID="(?[-a-zA-Z0-9]{36})"/s', $xmlContent, $matches); + if (false !== $found && isset($matches['uuid'])) { + return strtolower($matches['uuid']); } - return true; + return ''; + } + + /** @return array */ + public function jsonSerialize(): array + { + return $this->packageReader->jsonSerialize() + [ + 'cfdis' => iterator_to_array($this->cfdis()), + ]; } } diff --git a/src/PackageReader/Exceptions/CreateTemporaryZipFileException.php b/src/PackageReader/Exceptions/CreateTemporaryZipFileException.php new file mode 100644 index 0000000..de69126 --- /dev/null +++ b/src/PackageReader/Exceptions/CreateTemporaryZipFileException.php @@ -0,0 +1,21 @@ +filename = $filename; + } + + public static function create(string $filename, int $code, Throwable $previous = null): self + { + return new self(sprintf('Unable to open Zip file %s', $filename), $code, $filename, $previous); + } + + public function getFileName(): string + { + return $this->filename; + } +} diff --git a/src/PackageReader/Exceptions/PackageReaderException.php b/src/PackageReader/Exceptions/PackageReaderException.php new file mode 100644 index 0000000..6691376 --- /dev/null +++ b/src/PackageReader/Exceptions/PackageReaderException.php @@ -0,0 +1,11 @@ +filename = $filename; + $this->archive = $archive; + } + + public function __destruct() + { + if ($this->removeOnDestruct) { + /** + * @noinspection PhpUsageOfSilenceOperatorInspection + * @scrutinizer ignore-unhandled + */ + @unlink($this->filename); + } + } + + /** @inheritDoc */ + public static function createFromFile(string $filename): self + { + $archive = new ZipArchive(); + $zipCode = $archive->open($filename, ZipArchive::CREATE); + if (true !== $zipCode) { + throw OpenZipFileException::create($filename, $zipCode); + } + + return new self($filename, $archive); + } + + /** @inheritDoc */ + public static function createFromContents(string $content): self + { + // create temp file + try { + $tmpfile = tempnam(sys_get_temp_dir(), ''); + } catch (Throwable $exception) { + /** @codeCoverageIgnore */ + throw CreateTemporaryZipFileException::create('Cannot create a temporary file', $exception); + } + if (false === $tmpfile) { + /** @codeCoverageIgnore */ + throw CreateTemporaryZipFileException::create('Cannot not create a temporary file'); + } + + // write contents + try { + $write = file_put_contents($tmpfile, $content); + } catch (Throwable $exception) { + /** @codeCoverageIgnore */ + throw CreateTemporaryZipFileException::create('Cannot store contents on temporary file', $exception); + } + if (false === $write) { + /** @codeCoverageIgnore */ + throw CreateTemporaryZipFileException::create('Cannot store contents on temporary file'); + } + + // build object + try { + $package = static::createFromFile($tmpfile); + } catch (OpenZipFileException $exception) { + unlink($tmpfile); + throw $exception; + } + + // set special flag to remove file when this object is destroyed + $package->removeOnDestruct = true; + return $package; + } + + public function fileContents() + { + $archive = $this->getArchive(); + $filter = $this->getFilter(); + for ($i = 0; $i < $archive->numFiles; $i++) { + $filename = $archive->getNameIndex($i); + if (false === $filename || ! $filter->filterFilename($filename)) { + continue; // did not pass the filename filter + } + + $contents = $archive->getFromName($filename); + if (false === $contents || ! $filter->filterContents($contents)) { + unset($contents); // release memory as it was filtered + continue; // did not pass the filename filter + } + + yield $filename => $contents; + } + } + + public function count(): int + { + return iterator_count($this->fileContents()); + } + + public function getFilename(): string + { + return $this->filename; + } + + protected function getArchive(): ZipArchive + { + return $this->archive; + } + + public function getFilter(): FileFilterInterface + { + return $this->filter; + } + + public function setFilter(?FileFilterInterface $filter): void + { + $this->filter = $filter ?? new NullFileFilter(); + } + + /** @return array */ + public function jsonSerialize(): array + { + return [ + 'source' => $this->getFilename(), + 'files' => iterator_to_array($this->fileContents()), + ]; + } +} diff --git a/src/PackageReader/MetadataContent.php b/src/PackageReader/Internal/MetadataContent.php similarity index 92% rename from src/PackageReader/MetadataContent.php rename to src/PackageReader/Internal/MetadataContent.php index d9de6e5..1bc3d19 100644 --- a/src/PackageReader/MetadataContent.php +++ b/src/PackageReader/Internal/MetadataContent.php @@ -2,14 +2,19 @@ declare(strict_types=1); -namespace PhpCfdi\SatWsDescargaMasiva\PackageReader; +namespace PhpCfdi\SatWsDescargaMasiva\PackageReader\Internal; use Generator; use Iterator; +use PhpCfdi\SatWsDescargaMasiva\PackageReader\MetadataItem; use SplTempFileObject; -/** @internal */ -class MetadataContent +/** + * Helper to iterate inside a Metadata CSV file + * + * @internal + */ +final class MetadataContent { /** @var Iterator */ private $iterator; diff --git a/src/PackageReader/MetadataPreprocessor.php b/src/PackageReader/Internal/MetadataPreprocessor.php similarity index 92% rename from src/PackageReader/MetadataPreprocessor.php rename to src/PackageReader/Internal/MetadataPreprocessor.php index c36efaf..9a8755d 100644 --- a/src/PackageReader/MetadataPreprocessor.php +++ b/src/PackageReader/Internal/MetadataPreprocessor.php @@ -2,15 +2,16 @@ declare(strict_types=1); -namespace PhpCfdi\SatWsDescargaMasiva\PackageReader; +namespace PhpCfdi\SatWsDescargaMasiva\PackageReader\Internal; /** * This preprocesor fixes metadata issues: * - SAT CSV EOL is and might contain inside a field * + * @see MetadataContent * @internal */ -class MetadataPreprocessor +final class MetadataPreprocessor { private const CONTROL_CR = "\r"; diff --git a/src/PackageReader/MetadataItem.php b/src/PackageReader/MetadataItem.php index 073ef2d..159bdc6 100644 --- a/src/PackageReader/MetadataItem.php +++ b/src/PackageReader/MetadataItem.php @@ -4,9 +4,13 @@ namespace PhpCfdi\SatWsDescargaMasiva\PackageReader; +use JsonSerializable; + /** * Metadata DTO object - * @internal This struct is reported as of 2019-08-01, if changes use all()/get() methods + * + * @internal This collection of magic properties is reported as of 2019-08-01, if it changes use all()/get() methods + * * @property-read string $uuid * @property-read string $rfcEmisor * @property-read string $nombreEmisor @@ -20,7 +24,7 @@ * @property-read string $estatus * @property-read string $fechaCancelacion */ -class MetadataItem +final class MetadataItem implements JsonSerializable { /** @var array */ private $data; @@ -50,4 +54,10 @@ public function get(string $key): string { return $this->data[$key] ?? ''; } + + /** @return array */ + public function jsonSerialize(): array + { + return ['uuid' => $this->get('uuid')] + $this->data; + } } diff --git a/src/PackageReader/MetadataPackageReader.php b/src/PackageReader/MetadataPackageReader.php index f61e419..799902b 100644 --- a/src/PackageReader/MetadataPackageReader.php +++ b/src/PackageReader/MetadataPackageReader.php @@ -4,41 +4,71 @@ namespace PhpCfdi\SatWsDescargaMasiva\PackageReader; -use Generator; +use PhpCfdi\SatWsDescargaMasiva\PackageReader\Internal\FileFilters\MetadataFileFilter; +use PhpCfdi\SatWsDescargaMasiva\PackageReader\Internal\FilteredPackageReader; +use PhpCfdi\SatWsDescargaMasiva\PackageReader\Internal\MetadataContent; +use Traversable; -class MetadataPackageReader extends AbstractPackageReader +final class MetadataPackageReader implements PackageReaderInterface { + /** @var PackageReaderInterface */ + private $packageReader; + + private function __construct(PackageReaderInterface $packageReader) + { + $this->packageReader = $packageReader; + } + + public static function createFromFile(string $filename): self + { + $packageReader = FilteredPackageReader::createFromFile($filename); + $packageReader->setFilter(new MetadataFileFilter()); + return new self($packageReader); + } + + public static function createFromContents(string $contents): self + { + $packageReader = FilteredPackageReader::createFromContents($contents); + $packageReader->setFilter(new MetadataFileFilter()); + return new self($packageReader); + } + /** - * @return Generator|MetadataItem[] + * Traverse the metadata items contained in the hole package. + * The key is the UUID and the content is the MetadataItem + * + * @return Traversable */ public function metadata() { - foreach ($this->fileContents() as $content) { - $reader = $this->createMetadataContent($content); + foreach ($this->packageReader->fileContents() as $content) { + $reader = MetadataContent::createFromContents($content); foreach ($reader->eachItem() as $item) { - yield $item; + yield $item->uuid => $item; } } } - protected function filterEntryFilename(string $filename): bool + public function getFilename(): string { - if (boolval(preg_match('/^[^\/\\\\]+\.txt$/i', $filename))) { - return true; - } - return false; + return $this->packageReader->getFilename(); } - protected function filterContents(string &$contents): bool + public function count(): int { - if ('Uuid~RfcEmisor~' === substr($contents, 0, 15)) { - return true; - } - return false; + return iterator_count($this->metadata()); + } + + public function fileContents() + { + yield from $this->packageReader->fileContents(); } - public function createMetadataContent(string $content): MetadataContent + /** @return array */ + public function jsonSerialize(): array { - return MetadataContent::createFromContents($content); + return $this->packageReader->jsonSerialize() + [ + 'metadata' => iterator_to_array($this->metadata()), + ]; } } diff --git a/src/PackageReader/PackageReaderInterface.php b/src/PackageReader/PackageReaderInterface.php new file mode 100644 index 0000000..78914a1 --- /dev/null +++ b/src/PackageReader/PackageReaderInterface.php @@ -0,0 +1,58 @@ + + */ + public function fileContents(); + + /** + * Return the number of elements on the package + * + * @return int + */ + public function count(): int; + + /** + * Retrieve the currently open file name + * + * @return string + */ + public function getFilename(): string; +} diff --git a/src/RequestBuilder/Exceptions/PeriodEndInvalidDateFormatException.php b/src/RequestBuilder/Exceptions/PeriodEndInvalidDateFormatException.php new file mode 100644 index 0000000..0da2b60 --- /dev/null +++ b/src/RequestBuilder/Exceptions/PeriodEndInvalidDateFormatException.php @@ -0,0 +1,25 @@ +periodEnd = $periodEnd; + } + + public function getPeriodEnd(): string + { + return $this->periodEnd; + } +} diff --git a/src/RequestBuilder/Exceptions/PeriodStartGreaterThanEndException.php b/src/RequestBuilder/Exceptions/PeriodStartGreaterThanEndException.php new file mode 100644 index 0000000..b50c3f1 --- /dev/null +++ b/src/RequestBuilder/Exceptions/PeriodStartGreaterThanEndException.php @@ -0,0 +1,34 @@ +periodStart = $periodStart; + $this->periodEnd = $periodEnd; + } + + public function getPeriodStart(): string + { + return $this->periodStart; + } + + public function getPeriodEnd(): string + { + return $this->periodEnd; + } +} diff --git a/src/RequestBuilder/Exceptions/PeriodStartInvalidDateFormatException.php b/src/RequestBuilder/Exceptions/PeriodStartInvalidDateFormatException.php new file mode 100644 index 0000000..90aaafe --- /dev/null +++ b/src/RequestBuilder/Exceptions/PeriodStartInvalidDateFormatException.php @@ -0,0 +1,25 @@ +periodStart = $periodStart; + } + + public function getPeriodStart(): string + { + return $this->periodStart; + } +} diff --git a/src/RequestBuilder/Exceptions/RequestTypeInvalidException.php b/src/RequestBuilder/Exceptions/RequestTypeInvalidException.php new file mode 100644 index 0000000..1eb216c --- /dev/null +++ b/src/RequestBuilder/Exceptions/RequestTypeInvalidException.php @@ -0,0 +1,25 @@ +requestType = $requestType; + } + + public function getRequestType(): string + { + return $this->requestType; + } +} diff --git a/src/RequestBuilder/Exceptions/RfcIsNotIssuerOrReceiverException.php b/src/RequestBuilder/Exceptions/RfcIsNotIssuerOrReceiverException.php new file mode 100644 index 0000000..f6cd156 --- /dev/null +++ b/src/RequestBuilder/Exceptions/RfcIsNotIssuerOrReceiverException.php @@ -0,0 +1,44 @@ +rfcSigner = $rfcSigner; + $this->rfcIssuer = $rfcIssuer; + $this->rfcReceiver = $rfcReceiver; + } + + public function getRfcSigner(): string + { + return $this->rfcSigner; + } + + public function getRfcIssuer(): string + { + return $this->rfcIssuer; + } + + public function getRfcReceiver(): string + { + return $this->rfcReceiver; + } +} diff --git a/src/RequestBuilder/Exceptions/RfcIssuerAndReceiverAreEmptyException.php b/src/RequestBuilder/Exceptions/RfcIssuerAndReceiverAreEmptyException.php new file mode 100644 index 0000000..e16346e --- /dev/null +++ b/src/RequestBuilder/Exceptions/RfcIssuerAndReceiverAreEmptyException.php @@ -0,0 +1,16 @@ +fiel = $fiel; + } + + public function getFiel(): Fiel + { + return $this->fiel; + } + + public function authorization(string $created, string $expires, string $securityTokenId = ''): string + { + $uuid = $securityTokenId ?: $this->createXmlSecurityTokenId(); + $certificate = Helpers::cleanPemContents($this->getFiel()->getCertificatePemContents()); + + $keyInfoData = << + + + + + EOT; + $toDigestXml = << + ${created} + ${expires} + + EOT; + $signatureData = $this->createSignature($toDigestXml, '#_0', $keyInfoData); + + $xml = << + + + + ${created} + ${expires} + + + ${certificate} + + ${signatureData} + + + + + + + EOT; + + return $this->nospaces($xml); + } + + public function query(string $start, string $end, string $rfcIssuer, string $rfcReceiver, string $requestType): string + { + // normalize input + $rfcSigner = mb_strtoupper($this->getFiel()->getRfc()); + $rfcIssuer = mb_strtoupper((self::USE_SIGNER === $rfcIssuer) ? $rfcSigner : $rfcIssuer); + $rfcReceiver = mb_strtoupper((self::USE_SIGNER === $rfcReceiver) ? $rfcSigner : $rfcReceiver); + + // check inputs + if (! boolval(preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/', $start))) { + throw new PeriodStartInvalidDateFormatException($start); + } + if (! boolval(preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/', $end))) { + throw new PeriodEndInvalidDateFormatException($end); + } + if ($start > $end) { + throw new PeriodStartGreaterThanEndException($start, $end); + } + if ('' === $rfcReceiver && '' === $rfcIssuer) { + throw new RfcIssuerAndReceiverAreEmptyException(); + } + if (! in_array($rfcSigner, [$rfcReceiver, $rfcIssuer], true)) { + throw new RfcIsNotIssuerOrReceiverException($rfcSigner, $rfcIssuer, $rfcReceiver); + } + if (! in_array($requestType, ['CFDI', 'Metadata'], true)) { + throw new RequestTypeInvalidException($requestType); + } + + $solicitudAttributes = array_filter([ + 'RfcSolicitante' => $rfcSigner, + 'FechaInicial' => $start, + 'FechaFinal' => $end, + 'TipoSolicitud' => $requestType, + 'RfcEmisor' => $rfcIssuer, + 'RfcReceptor' => $rfcReceiver, + ]); + ksort($solicitudAttributes); + + $solicitudAttributesAsText = implode(' ', array_map( + function (string $name, string $value): string { + return sprintf('%s="%s"', htmlspecialchars($name, ENT_XML1), htmlspecialchars($value, ENT_XML1)); + }, + array_keys($solicitudAttributes), + $solicitudAttributes, + )); + + $toDigestXml = << + + + EOT; + $signatureData = $this->createSignature($toDigestXml); + + $xml = << + + + + + ${signatureData} + + + + + EOT; + + return $this->nospaces($xml); + } + + public function verify(string $requestId): string + { + $rfc = $this->getFiel()->getRfc(); + + $toDigestXml = << + + + EOT; + $signatureData = $this->createSignature($toDigestXml); + + $xml = << + + + + + ${signatureData} + + + + + EOT; + + return $this->nospaces($xml); + } + + public function download(string $packageId): string + { + $rfcOwner = $this->getFiel()->getRfc(); + + $toDigestXml = << + + + EOT; + $signatureData = $this->createSignature($toDigestXml); + + $xml = << + + + + + ${signatureData} + + + + + EOT; + + return $this->nospaces($xml); + } + + private static function createXmlSecurityTokenId(): string + { + $md5 = md5(uniqid()); + return sprintf( + 'uuid-%08s-%04s-%04s-%04s-%012s-1', + substr($md5, 0, 8), + substr($md5, 8, 4), + substr($md5, 12, 4), + substr($md5, 16, 4), + substr($md5, 20) + ); + } + + private function createSignature(string $toDigest, string $signedInfoUri = '', string $keyInfo = ''): string + { + $toDigest = $this->nospaces($toDigest); + $digested = base64_encode(sha1($toDigest, true)); + $signedInfo = $this->createSignedInfoCanonicalExclusive($digested, $signedInfoUri); + $signatureValue = base64_encode($this->getFiel()->sign($signedInfo, OPENSSL_ALGO_SHA1)); + $signedInfo = str_replace('', '', $signedInfo); + + if ('' === $keyInfo) { + $keyInfo = $this->createKeyInfoData(); + } + + return << + ${signedInfo} + ${signatureValue} + ${keyInfo} + + EOT; + } + + private function createSignedInfoCanonicalExclusive(string $digested, string $uri = ''): string + { + // see https://www.w3.org/TR/xmlsec-algorithms/ to understand the algorithm + // http://www.w3.org/2001/10/xml-exc-c14n# - Exclusive Canonicalization XML 1.0 (omit comments) + $xml = << + + + + + + + + ${digested} + + + EOT; + return $this->nospaces($xml); + } + + private function createKeyInfoData(): string + { + $fiel = $this->getFiel(); + $certificate = Helpers::cleanPemContents($fiel->getCertificatePemContents()); + $serial = $fiel->getCertificateSerial(); + $issuerName = $fiel->getCertificateIssuerName(); + + return << + + + ${issuerName} + ${serial} + + ${certificate} + + + EOT; + } + + private function nospaces(string $input): string + { + return preg_replace(['/^\h*/m', '/\h*\r?\n/m'], '', $input) ?? ''; + } +} diff --git a/src/RequestBuilder/RequestBuilderException.php b/src/RequestBuilder/RequestBuilderException.php new file mode 100644 index 0000000..5fc2d25 --- /dev/null +++ b/src/RequestBuilder/RequestBuilderException.php @@ -0,0 +1,11 @@ +fiel = $fiel; + /** @var ServiceEndpoints */ + private $endpoints; + + /** + * Client constructor of "servicio de consulta y recuperación de comprobantes" + * + * @param RequestBuilderInterface $requestBuilder + * @param WebClientInterface $webclient + * @param Token|null $currentToken + * @param ServiceEndpoints|null $endpoints If NULL uses CFDI endpoints + */ + public function __construct( + RequestBuilderInterface $requestBuilder, + WebClientInterface $webclient, + Token $currentToken = null, + ServiceEndpoints $endpoints = null + ) { + $this->requestBuilder = $requestBuilder; $this->webclient = $webclient; $this->currentToken = $currentToken; + $this->endpoints = $endpoints ?? ServiceEndpoints::cfdi(); } /** @@ -52,93 +69,82 @@ public function obtainCurrentToken(): Token return $this->currentToken; } + /** + * Perform authentication and return a Token, the token might be invalid + * + * @return Token + */ public function authenticate(): Token { $authenticateTranslator = new AuthenticateTranslator(); - $soapBody = $authenticateTranslator->createSoapRequest($this->fiel); + $soapBody = $authenticateTranslator->createSoapRequest($this->requestBuilder); $responseBody = $this->consume( 'http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica', - 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc', + $this->endpoints->getAuthenticate(), $soapBody ); - $token = $authenticateTranslator->createTokenFromSoapResponse($responseBody); - return $token; - } - - public function consume(string $soapAction, string $uri, string $body, ?Token $token = null): string - { - // prepare headers - $headers = ['SOAPAction' => $soapAction]; - if (null !== $token) { - $headers['Authorization'] = 'WRAP access_token="' . $token->getValue() . '"'; - } - - // webclient interaction and notifications - $request = new Request('POST', $uri, $body, $headers); - $this->webclient->fireRequest($request); - try { - $response = $this->webclient->call($request); - } catch (WebClientException $exception) { - $this->webclient->fireResponse($exception->getResponse()); - throw $exception; - } - $this->webclient->fireResponse($response); - - // evaluate response - if ($response->statusCodeIsClientError()) { - $message = sprintf('Unexpected client error status code %d', $response->getStatusCode()); - throw new HttpClientError($message, $request, $response); - } - if ($response->statusCodeIsServerError()) { - $message = sprintf('Unexpected server error status code %d', $response->getStatusCode()); - throw new HttpServerError($message, $request, $response); - } - if ($response->isEmpty()) { - throw new HttpServerError('Unexpected empty response from server', $request, $response); - } - - return $response->getBody(); + return $authenticateTranslator->createTokenFromSoapResponse($responseBody); } + /** + * Consume the "SolicitaDescarga" web service + * + * @param QueryParameters $parameters + * @return QueryResult + */ public function query(QueryParameters $parameters): QueryResult { $queryTranslator = new QueryTranslator(); - $soapBody = $queryTranslator->createSoapRequest($this->fiel, $parameters); + $soapBody = $queryTranslator->createSoapRequest($this->requestBuilder, $parameters); $responseBody = $this->consume( 'http://DescargaMasivaTerceros.sat.gob.mx/ISolicitaDescargaService/SolicitaDescarga', - 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc', + $this->endpoints->getQuery(), $soapBody, $this->obtainCurrentToken() ); - $queryResult = $queryTranslator->createQueryResultFromSoapResponse($responseBody); - return $queryResult; + return $queryTranslator->createQueryResultFromSoapResponse($responseBody); } + /** + * Consume the "VerificaSolicitudDescarga" web service + * + * @param string $requestId + * @return VerifyResult + */ public function verify(string $requestId): VerifyResult { $verifyTranslator = new VerifyTranslator(); - $soapBody = $verifyTranslator->createSoapRequest($this->fiel, $requestId); + $soapBody = $verifyTranslator->createSoapRequest($this->requestBuilder, $requestId); $responseBody = $this->consume( 'http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga', - 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc', + $this->endpoints->getVerify(), $soapBody, $this->obtainCurrentToken() ); - $verifyResult = $verifyTranslator->createVerifyResultFromSoapResponse($responseBody); - return $verifyResult; + return $verifyTranslator->createVerifyResultFromSoapResponse($responseBody); } + /** + * Consume the "Descargar" web service + * + * @param string $packageId + * @return DownloadResult + */ public function download(string $packageId): DownloadResult { $downloadTranslator = new DownloadTranslator(); - $soapBody = $downloadTranslator->createSoapRequest($this->fiel, $packageId); + $soapBody = $downloadTranslator->createSoapRequest($this->requestBuilder, $packageId); $responseBody = $this->consume( 'http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaTercerosService/Descargar', - 'https://cfdidescargamasiva.clouda.sat.gob.mx/DescargaMasivaService.svc', + $this->endpoints->getDownload(), $soapBody, $this->obtainCurrentToken() ); - $downloadResult = $downloadTranslator->createDownloadResultFromSoapResponse($responseBody); - return $downloadResult; + return $downloadTranslator->createDownloadResultFromSoapResponse($responseBody); + } + + private function consume(string $soapAction, string $uri, string $body, ?Token $token = null): string + { + return ServiceConsumer::consume($this->webclient, $soapAction, $uri, $body, $token); } } diff --git a/src/Services/Authenticate/AuthenticateTranslator.php b/src/Services/Authenticate/AuthenticateTranslator.php index 0386a58..bd34bc2 100644 --- a/src/Services/Authenticate/AuthenticateTranslator.php +++ b/src/Services/Authenticate/AuthenticateTranslator.php @@ -4,10 +4,9 @@ namespace PhpCfdi\SatWsDescargaMasiva\Services\Authenticate; +use PhpCfdi\SatWsDescargaMasiva\Internal\InteractsXmlTrait; +use PhpCfdi\SatWsDescargaMasiva\RequestBuilder\RequestBuilderInterface; use PhpCfdi\SatWsDescargaMasiva\Shared\DateTime; -use PhpCfdi\SatWsDescargaMasiva\Shared\Fiel; -use PhpCfdi\SatWsDescargaMasiva\Shared\Helpers; -use PhpCfdi\SatWsDescargaMasiva\Shared\InteractsXmlTrait; use PhpCfdi\SatWsDescargaMasiva\Shared\Token; /** @internal */ @@ -18,70 +17,25 @@ class AuthenticateTranslator public function createTokenFromSoapResponse(string $content): Token { $env = $this->readXmlElement($content); - $created = new DateTime($this->findContent($env, 'header', 'security', 'timestamp', 'created') ?: 0); - $expires = new DateTime($this->findContent($env, 'header', 'security', 'timestamp', 'expires') ?: 0); + $created = DateTime::create($this->findContent($env, 'header', 'security', 'timestamp', 'created') ?: 0); + $expires = DateTime::create($this->findContent($env, 'header', 'security', 'timestamp', 'expires') ?: 0); $value = $this->findContent($env, 'body', 'autenticaResponse', 'autenticaResult'); return new Token($created, $expires, $value); } - public function createSoapRequest(Fiel $fiel): string + public function createSoapRequest(RequestBuilderInterface $requestBuilder): string { $since = DateTime::now(); $until = $since->modify('+ 5 minutes'); - $uuid = Helpers::createXmlSecurityTokenId(); - return $this->createSoapRequestWithData($fiel, $since, $until, $uuid); + return $this->createSoapRequestWithData($requestBuilder, $since, $until); } - public function createSoapRequestWithData(Fiel $fiel, DateTime $since, DateTime $until, string $uuid): string - { - $created = $since->formatSat(); - $expires = $until->formatSat(); - $toDigest = $this->nospaces( - << - ${created} - ${expires} - - EOT - ); - $digested = base64_encode(sha1($toDigest, true)); - $signedInfoData = $this->createSignedInfoCanonicalExclusive($digested, '#_0'); - $signed = base64_encode($fiel->sign($signedInfoData, OPENSSL_ALGO_SHA1)); - $keyInfoData = $this->createKeyInfoSecurityToken($uuid); - $signatureData = $this->createSignatureData($signedInfoData, $signed, $keyInfoData); - $certificate = Helpers::cleanPemContents($fiel->getCertificatePemContents()); - - $xml = << - - - - ${created} - ${expires} - - - ${certificate} - - ${signatureData} - - - - - - - EOT; - - return $this->nospaces($xml); - } - - public function createKeyInfoSecurityToken(string $uuid): string - { - return << - - - - - EOT; + public function createSoapRequestWithData( + RequestBuilderInterface $requestBuilder, + DateTime $since, + DateTime $until, + string $securityTokenId = '' + ): string { + return $requestBuilder->authorization($since->formatSat(), $until->formatSat(), $securityTokenId); } } diff --git a/src/Services/Download/DownloadResult.php b/src/Services/Download/DownloadResult.php index 84a0619..59df9c8 100644 --- a/src/Services/Download/DownloadResult.php +++ b/src/Services/Download/DownloadResult.php @@ -4,9 +4,10 @@ namespace PhpCfdi\SatWsDescargaMasiva\Services\Download; +use JsonSerializable; use PhpCfdi\SatWsDescargaMasiva\Shared\StatusCode; -class DownloadResult +final class DownloadResult implements JsonSerializable { /** @var StatusCode */ private $status; @@ -24,18 +25,42 @@ public function __construct(StatusCode $statusCode, string $packageContent) $this->packageLength = strlen($this->packageContent); } + /** + * Status of the download call + * + * @return StatusCode + */ public function getStatus(): StatusCode { return $this->status; } + /** + * If available, contains the package contents + * + * @return string + */ public function getPackageContent(): string { return $this->packageContent; } + /** + * If available, contains the package contents length in bytes + * + * @return int + */ public function getPackageLenght(): int { return $this->packageLength; } + + /** @return array */ + public function jsonSerialize(): array + { + return [ + 'status' => $this->status, + 'length' => $this->packageLength, + ]; + } } diff --git a/src/Services/Download/DownloadTranslator.php b/src/Services/Download/DownloadTranslator.php index 3b9c494..114152a 100644 --- a/src/Services/Download/DownloadTranslator.php +++ b/src/Services/Download/DownloadTranslator.php @@ -4,10 +4,11 @@ namespace PhpCfdi\SatWsDescargaMasiva\Services\Download; -use PhpCfdi\SatWsDescargaMasiva\Shared\Fiel; -use PhpCfdi\SatWsDescargaMasiva\Shared\InteractsXmlTrait; +use PhpCfdi\SatWsDescargaMasiva\Internal\InteractsXmlTrait; +use PhpCfdi\SatWsDescargaMasiva\RequestBuilder\RequestBuilderInterface; use PhpCfdi\SatWsDescargaMasiva\Shared\StatusCode; +/** @internal */ class DownloadTranslator { use InteractsXmlTrait; @@ -21,39 +22,8 @@ public function createDownloadResultFromSoapResponse(string $content): DownloadR return new DownloadResult($status, base64_decode($package, true) ?: ''); } - public function createSoapRequest(Fiel $fiel, string $packageId): string + public function createSoapRequest(RequestBuilderInterface $requestBuilder, string $packageId): string { - return $this->createSoapRequestWithData($fiel, $fiel->getRfc(), $packageId); - } - - public function createSoapRequestWithData(Fiel $fiel, string $rfc, string $packageId): string - { - $toDigest = $this->nospaces( - << - - - EOT - ); - - $digested = base64_encode(sha1($toDigest, true)); - $signedInfoData = $this->createSignedInfoCanonicalExclusive($digested); - $signed = base64_encode($fiel->sign($signedInfoData, OPENSSL_ALGO_SHA1)); - $keyInfoData = $this->createKeyInfoData($fiel); - $signatureData = $this->createSignatureData($signedInfoData, $signed, $keyInfoData); - - $xml = << - - - - - ${signatureData} - - - - - EOT; - return $this->nospaces($xml); + return $requestBuilder->download($packageId); } } diff --git a/src/Services/Query/QueryParameters.php b/src/Services/Query/QueryParameters.php index 22069c1..b326687 100644 --- a/src/Services/Query/QueryParameters.php +++ b/src/Services/Query/QueryParameters.php @@ -4,14 +4,18 @@ namespace PhpCfdi\SatWsDescargaMasiva\Services\Query; +use JsonSerializable; use PhpCfdi\SatWsDescargaMasiva\Shared\DateTimePeriod; use PhpCfdi\SatWsDescargaMasiva\Shared\DownloadType; use PhpCfdi\SatWsDescargaMasiva\Shared\RequestType; -class QueryParameters +/** + * This class contains all the information required to perform a query on the SAT Web Service + */ +final class QueryParameters implements JsonSerializable { /** @var DateTimePeriod */ - private $dateTimePeriod; + private $period; /** @var DownloadType */ private $downloadType; @@ -19,19 +23,44 @@ class QueryParameters /** @var RequestType */ private $requestType; + /** @var string */ + private $rfcMatch; + public function __construct( - DateTimePeriod $dateTimePeriod, + DateTimePeriod $period, DownloadType $downloadType, - RequestType $requestType + RequestType $requestType, + string $rfcMatch ) { - $this->dateTimePeriod = $dateTimePeriod; + $this->period = $period; $this->downloadType = $downloadType; $this->requestType = $requestType; + $this->rfcMatch = $rfcMatch; + } + + /** + * Query static constructor method + * + * @param DateTimePeriod $period + * @param DownloadType|null $downloadType if null uses Issued + * @param RequestType|null $requestType If null uses Metadata + * @param string $rfcMatch Only when counterpart matches this Rfc + * @return self + */ + public static function create( + DateTimePeriod $period, + DownloadType $downloadType = null, + RequestType $requestType = null, + string $rfcMatch = '' + ): self { + $downloadType = $downloadType ?: DownloadType::issued(); + $requestType = $requestType ?? RequestType::metadata(); + return new self($period, $downloadType, $requestType, $rfcMatch); } - public function getDateTimePeriod(): DateTimePeriod + public function getPeriod(): DateTimePeriod { - return $this->dateTimePeriod; + return $this->period; } public function getDownloadType(): DownloadType @@ -43,4 +72,20 @@ public function getRequestType(): RequestType { return $this->requestType; } + + public function getRfcMatch(): string + { + return $this->rfcMatch; + } + + /** @return array */ + public function jsonSerialize(): array + { + return [ + 'period' => $this->period, + 'downloadType' => $this->downloadType, + 'requestType' => $this->requestType, + 'rfcMatch' => $this->rfcMatch, + ]; + } } diff --git a/src/Services/Query/QueryResult.php b/src/Services/Query/QueryResult.php index 9d69a09..f81035f 100644 --- a/src/Services/Query/QueryResult.php +++ b/src/Services/Query/QueryResult.php @@ -4,9 +4,10 @@ namespace PhpCfdi\SatWsDescargaMasiva\Services\Query; +use JsonSerializable; use PhpCfdi\SatWsDescargaMasiva\Shared\StatusCode; -class QueryResult +final class QueryResult implements JsonSerializable { /** @var StatusCode */ private $status; @@ -20,13 +21,32 @@ public function __construct(StatusCode $statusCode, string $requestId) $this->requestId = $requestId; } + /** + * Status of the verification call + * + * @return StatusCode + */ public function getStatus(): StatusCode { return $this->status; } + /** + * If accepted, contains the request identification required for verification + * + * @return string + */ public function getRequestId(): string { return $this->requestId; } + + /** @return array */ + public function jsonSerialize(): array + { + return [ + 'status' => $this->status, + 'requestId' => $this->requestId, + ]; + } } diff --git a/src/Services/Query/QueryTranslator.php b/src/Services/Query/QueryTranslator.php index d73083d..08be5dd 100644 --- a/src/Services/Query/QueryTranslator.php +++ b/src/Services/Query/QueryTranslator.php @@ -4,11 +4,8 @@ namespace PhpCfdi\SatWsDescargaMasiva\Services\Query; -use PhpCfdi\SatWsDescargaMasiva\Shared\DateTime; -use PhpCfdi\SatWsDescargaMasiva\Shared\DownloadType; -use PhpCfdi\SatWsDescargaMasiva\Shared\Fiel; -use PhpCfdi\SatWsDescargaMasiva\Shared\InteractsXmlTrait; -use PhpCfdi\SatWsDescargaMasiva\Shared\RequestType; +use PhpCfdi\SatWsDescargaMasiva\Internal\InteractsXmlTrait; +use PhpCfdi\SatWsDescargaMasiva\RequestBuilder\RequestBuilderInterface; use PhpCfdi\SatWsDescargaMasiva\Shared\StatusCode; /** @internal */ @@ -26,59 +23,15 @@ public function createQueryResultFromSoapResponse(string $content): QueryResult return new QueryResult($status, $requestId); } - public function createSoapRequest(Fiel $fiel, QueryParameters $parameters): string + public function createSoapRequest(RequestBuilderInterface $requestBuilder, QueryParameters $parameters): string { - $dateTimePeriod = $parameters->getDateTimePeriod(); - - return $this->createSoapRequestWithData( - $fiel, - $fiel->getRfc(), - $dateTimePeriod->getStart(), - $dateTimePeriod->getEnd(), - $parameters->getDownloadType(), - $parameters->getRequestType() - ); - } - - public function createSoapRequestWithData( - Fiel $fiel, - string $rfc, - DateTime $start, - DateTime $end, - DownloadType $downloadType, - RequestType $requestType - ): string { - $start = $start->format('Y-m-d\TH:i:s'); - $end = $end->format('Y-m-d\TH:i:s'); - - $rfcKey = $downloadType->value(); - $requestTypeValue = $requestType->value(); - - $toDigest = $this->nospaces( - << - - - EOT - ); - $digested = base64_encode(sha1($toDigest, true)); - $signedInfoData = $this->createSignedInfoCanonicalExclusive($digested); - $signed = base64_encode($fiel->sign($signedInfoData, OPENSSL_ALGO_SHA1)); - $keyInfoData = $this->createKeyInfoData($fiel); - $signatureData = $this->createSignatureData($signedInfoData, $signed, $keyInfoData); - $xml = << - - - - - ${signatureData} - - - - - EOT; - - return $this->nospaces($xml); + $start = $parameters->getPeriod()->getStart()->format('Y-m-d\TH:i:s'); + $end = $parameters->getPeriod()->getEnd()->format('Y-m-d\TH:i:s'); + $rfcIssuer = $parameters->getDownloadType()->isIssued() ? RequestBuilderInterface::USE_SIGNER : $parameters->getRfcMatch(); + $rfcReceiver = $parameters->getDownloadType()->isReceived() ? RequestBuilderInterface::USE_SIGNER : $parameters->getRfcMatch(); + $requestType = $parameters->getRequestType()->value(); + + /** @noinspection PhpUnhandledExceptionInspection */ + return $requestBuilder->query($start, $end, $rfcIssuer, $rfcReceiver, $requestType); } } diff --git a/src/Services/Verify/VerifyResult.php b/src/Services/Verify/VerifyResult.php index 3411a21..e5f7b80 100644 --- a/src/Services/Verify/VerifyResult.php +++ b/src/Services/Verify/VerifyResult.php @@ -4,11 +4,15 @@ namespace PhpCfdi\SatWsDescargaMasiva\Services\Verify; +use JsonSerializable; use PhpCfdi\SatWsDescargaMasiva\Shared\CodeRequest; use PhpCfdi\SatWsDescargaMasiva\Shared\StatusCode; use PhpCfdi\SatWsDescargaMasiva\Shared\StatusRequest; -class VerifyResult +/** + * Service Verify Result + */ +final class VerifyResult implements JsonSerializable { /** @var StatusCode */ private $status; @@ -26,47 +30,88 @@ class VerifyResult private $packagesIds; public function __construct( - StatusCode $status, + StatusCode $statusCode, StatusRequest $statusRequest, CodeRequest $codeRequest, int $numberCfdis, string ...$packagesIds ) { - $this->status = $status; + $this->status = $statusCode; $this->statusRequest = $statusRequest; $this->codeRequest = $codeRequest; $this->numberCfdis = $numberCfdis; $this->packagesIds = $packagesIds; } + /** + * Status of the verification call + * + * @return StatusCode + */ public function getStatus(): StatusCode { return $this->status; } + /** + * Status of the query + * + * @return StatusRequest + */ public function getStatusRequest(): StatusRequest { return $this->statusRequest; } + /** + * Code related to the status of the query + * + * @return CodeRequest + */ public function getCodeRequest(): CodeRequest { return $this->codeRequest; } + /** + * Number of CFDI given by the query + * + * @return int + */ public function getNumberCfdis(): int { return $this->numberCfdis; } - /** @return string[] */ + /** + * An array containing the package identifications, required to perform the download process + * + * @return string[] + */ public function getPackagesIds(): array { return $this->packagesIds; } + /** + * Count of package identifications + * + * @return int + */ public function countPackages(): int { return count($this->packagesIds); } + + /** @return array */ + public function jsonSerialize(): array + { + return [ + 'status' => $this->status, + 'codeRequest' => $this->codeRequest, + 'statusRequest' => $this->statusRequest, + 'numberCfdis' => $this->numberCfdis, + 'packagesIds' => $this->packagesIds, + ]; + } } diff --git a/src/Services/Verify/VerifyTranslator.php b/src/Services/Verify/VerifyTranslator.php index fa3048b..5874bbd 100644 --- a/src/Services/Verify/VerifyTranslator.php +++ b/src/Services/Verify/VerifyTranslator.php @@ -4,12 +4,13 @@ namespace PhpCfdi\SatWsDescargaMasiva\Services\Verify; +use PhpCfdi\SatWsDescargaMasiva\Internal\InteractsXmlTrait; +use PhpCfdi\SatWsDescargaMasiva\RequestBuilder\RequestBuilderInterface; use PhpCfdi\SatWsDescargaMasiva\Shared\CodeRequest; -use PhpCfdi\SatWsDescargaMasiva\Shared\Fiel; -use PhpCfdi\SatWsDescargaMasiva\Shared\InteractsXmlTrait; use PhpCfdi\SatWsDescargaMasiva\Shared\StatusCode; use PhpCfdi\SatWsDescargaMasiva\Shared\StatusRequest; +/** @internal */ class VerifyTranslator { use InteractsXmlTrait; @@ -34,41 +35,8 @@ public function createVerifyResultFromSoapResponse(string $content): VerifyResul return new VerifyResult($status, $statusRequest, $codeRequest, $numberCfdis, ...$packages); } - public function createSoapRequest(Fiel $fiel, string $requestId): string + public function createSoapRequest(RequestBuilderInterface $requestBuilder, string $requestId): string { - return $this->createSoapRequestWithData($fiel, $fiel->getRfc(), $requestId); - } - - public function createSoapRequestWithData( - Fiel $fiel, - string $rfc, - string $requestId - ): string { - $toDigest = $this->nospaces( - << - - - EOT - ); - $digested = base64_encode(sha1($toDigest, true)); - $signedInfoData = $this->createSignedInfoCanonicalExclusive($digested); - $signed = base64_encode($fiel->sign($signedInfoData, OPENSSL_ALGO_SHA1)); - $keyInfoData = $this->createKeyInfoData($fiel); - $signatureData = $this->createSignatureData($signedInfoData, $signed, $keyInfoData); - $xml = << - - - - - ${signatureData} - - - - - EOT; - - return $this->nospaces($xml); + return $requestBuilder->verify($requestId); } } diff --git a/src/Shared/CodeRequest.php b/src/Shared/CodeRequest.php index a8343c1..3c1d58d 100644 --- a/src/Shared/CodeRequest.php +++ b/src/Shared/CodeRequest.php @@ -5,17 +5,20 @@ namespace PhpCfdi\SatWsDescargaMasiva\Shared; use Eclipxe\MicroCatalog\MicroCatalog; +use JsonSerializable; /** + * Defines "CodigoEstadoSolicitud" + * * @method bool isAccepted() * @method bool isExhausted() * @method bool isMaximumLimitReaded() * @method bool isEmptyResult() * @method bool isDuplicated() - * @method string getMessage() - * @method string getName() + * @method string getMessage() Contains the known message in spanish + * @method string getName() Contains the internal name */ -final class CodeRequest extends MicroCatalog +final class CodeRequest extends MicroCatalog implements JsonSerializable { protected const VALUES = [ 5000 => [ @@ -55,8 +58,22 @@ public function getEntryId(): string return $this->getName(); } + /** + * Contains the value of "CodigoEstadoSolicitud" + * + * @return int + */ public function getValue(): int { return intval($this->getEntryIndex()); } + + /** @return array */ + public function jsonSerialize(): array + { + return [ + 'value' => $this->getValue(), + 'message' => $this->getMessage(), + ]; + } } diff --git a/src/Shared/DateTime.php b/src/Shared/DateTime.php index 19618b7..64bb67f 100644 --- a/src/Shared/DateTime.php +++ b/src/Shared/DateTime.php @@ -7,9 +7,13 @@ use DateTimeImmutable; use DateTimeZone; use InvalidArgumentException; +use JsonSerializable; use Throwable; -class DateTime +/** + * Defines a date and time + */ +final class DateTime implements JsonSerializable { /** @var DateTimeImmutable */ private $value; @@ -43,6 +47,20 @@ public function __construct($value = null) $this->value = $value; } + /** + * Create a DateTime instance + * + * If $value is an integer is used as a timestamp, if is a string is evaluated + * as an argument for DateTimeImmutable and if it is DateTimeImmutable is used as is. + * + * @param int|string|DateTimeImmutable|null $value + * @return self + */ + public static function create($value = null): self + { + return new self($value); + } + public static function now(): self { return new self(); @@ -86,4 +104,9 @@ public function equalsTo(self $expectedExpires): bool { return $this->formatSat() === $expectedExpires->formatSat(); } + + public function jsonSerialize(): int + { + return intval($this->value->format('U')); + } } diff --git a/src/Shared/DateTimePeriod.php b/src/Shared/DateTimePeriod.php index 836f30d..f766100 100644 --- a/src/Shared/DateTimePeriod.php +++ b/src/Shared/DateTimePeriod.php @@ -5,8 +5,12 @@ namespace PhpCfdi\SatWsDescargaMasiva\Shared; use InvalidArgumentException; +use JsonSerializable; -class DateTimePeriod +/** + * Defines a period of time by start of period and end of period values + */ +final class DateTimePeriod implements JsonSerializable { /** @var DateTime */ private $start; @@ -24,6 +28,30 @@ public function __construct(DateTime $start, DateTime $end) $this->end = $end; } + /** + * Create a new instance of the period object + * + * @param DateTime $start + * @param DateTime $end + * @return self + */ + public static function create(DateTime $start, DateTime $end): self + { + return new self($start, $end); + } + + /** + * Create a new instance of the period object based on a string representations or unix timestamps + * + * @param mixed $start + * @param mixed $end + * @return self + */ + public static function createFromValues($start, $end): self + { + return static::create(DateTime::create($start), DateTime::create($end)); + } + public function getStart(): DateTime { return $this->start; @@ -33,4 +61,13 @@ public function getEnd(): DateTime { return $this->end; } + + /** @return array */ + public function jsonSerialize(): array + { + return [ + 'start' => $this->start, + 'end' => $this->end, + ]; + } } diff --git a/src/Shared/DownloadType.php b/src/Shared/DownloadType.php index 30e8b47..a682d11 100644 --- a/src/Shared/DownloadType.php +++ b/src/Shared/DownloadType.php @@ -5,8 +5,10 @@ namespace PhpCfdi\SatWsDescargaMasiva\Shared; use Eclipxe\Enum\Enum; +use JsonSerializable; /** + * Defines the download type (issued or received) * * @method static self issued() * @method static self received() @@ -14,7 +16,7 @@ * @method bool isIssued() * @method bool isReceived() */ -class DownloadType extends Enum +final class DownloadType extends Enum implements JsonSerializable { protected static function overrideValues(): array { @@ -23,4 +25,9 @@ protected static function overrideValues(): array 'received' => 'RfcReceptor', ]; } + + public function jsonSerialize(): string + { + return $this->value(); + } } diff --git a/src/Shared/Helpers.php b/src/Shared/Helpers.php deleted file mode 100644 index 16bb0cc..0000000 --- a/src/Shared/Helpers.php +++ /dev/null @@ -1,37 +0,0 @@ - 'Metadata', ]; } + + public function jsonSerialize(): string + { + return $this->value(); + } } diff --git a/src/Shared/ServiceEndpoints.php b/src/Shared/ServiceEndpoints.php new file mode 100644 index 0000000..5a3d57b --- /dev/null +++ b/src/Shared/ServiceEndpoints.php @@ -0,0 +1,86 @@ +authenticate = $authenticate; + $this->query = $query; + $this->verify = $verify; + $this->download = $download; + } + + /** + * Create an object with known endpoints for "CFDI regulares" + * + * @return self + */ + public static function cfdi(): self + { + return new self( + 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc', + 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc', + 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc', + 'https://cfdidescargamasiva.clouda.sat.gob.mx/DescargaMasivaService.svc' + ); + } + + /** + * Create an object with known endpoints for "CFDI de retenciones e información de pagos" + * + * @return self + */ + public static function retenciones(): self + { + return new self( + 'https://retendescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc', + 'https://retendescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc', + 'https://retendescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc', + 'https://retendescargamasiva.clouda.sat.gob.mx/DescargaMasivaService.svc' + ); + } + + public function getAuthenticate(): string + { + return $this->authenticate; + } + + public function getQuery(): string + { + return $this->query; + } + + public function getVerify(): string + { + return $this->verify; + } + + public function getDownload(): string + { + return $this->download; + } +} diff --git a/src/Shared/StatusCode.php b/src/Shared/StatusCode.php index 93c8d4c..03e5049 100644 --- a/src/Shared/StatusCode.php +++ b/src/Shared/StatusCode.php @@ -4,7 +4,12 @@ namespace PhpCfdi\SatWsDescargaMasiva\Shared; -class StatusCode +use JsonSerializable; + +/** + * Defines "CodEstatus" and "Mensaje" + */ +final class StatusCode implements JsonSerializable { /** @var int */ private $code; @@ -18,18 +23,43 @@ public function __construct(int $code, string $message) $this->message = $message; } + /** + * Contains the value of "CodEstatus" + * + * @return int + */ public function getCode(): int { return $this->code; } + /** + * Contains the value of "Mensaje" + * + * @return string + */ public function getMessage(): string { return $this->message; } + /** + * Return true when "CodEstatus" is success + * The only success code is "5000: Solicitud recibida con éxito" + * + * @return bool + */ public function isAccepted(): bool { return (5000 === $this->code); } + + /** @return array */ + public function jsonSerialize(): array + { + return [ + 'code' => $this->code, + 'message' => $this->message, + ]; + } } diff --git a/src/Shared/StatusRequest.php b/src/Shared/StatusRequest.php index b71579b..1404157 100644 --- a/src/Shared/StatusRequest.php +++ b/src/Shared/StatusRequest.php @@ -5,18 +5,22 @@ namespace PhpCfdi\SatWsDescargaMasiva\Shared; use Eclipxe\MicroCatalog\MicroCatalog; +use JsonSerializable; /** + * Defines "EstadoSolicitud" + * * @method bool isAccepted() * @method bool isInProgress() * @method bool isFinished() * @method bool isFailure() * @method bool isRejected() * @method bool isExpired() - * @method string getMessage() - * @method string getName() + * + * @method string getMessage() Contains the known message in spanish + * @method string getName() Contains the internal name */ -final class StatusRequest extends MicroCatalog +final class StatusRequest extends MicroCatalog implements JsonSerializable { protected const VALUES = [ 1 => ['name' => 'Accepted', 'message' => 'Aceptada'], @@ -42,8 +46,22 @@ public function getEntryId(): string return $this->getName(); } + /** + * Contains the "EstadoSolicitud" value + * + * @return int + */ public function getValue(): int { return intval($this->getEntryIndex()); } + + /** @return array */ + public function jsonSerialize(): array + { + return [ + 'value' => $this->getValue(), + 'message' => $this->getMessage(), + ]; + } } diff --git a/src/Shared/Token.php b/src/Shared/Token.php index 8c5d571..31803d6 100644 --- a/src/Shared/Token.php +++ b/src/Shared/Token.php @@ -5,8 +5,12 @@ namespace PhpCfdi\SatWsDescargaMasiva\Shared; use InvalidArgumentException; +use JsonSerializable; -class Token +/** + * Defines a Token as given from SAT + */ +final class Token implements JsonSerializable { /** @var DateTime */ private $created; @@ -27,33 +31,72 @@ public function __construct(DateTime $created, DateTime $expires, string $value) $this->value = $value; } + /** + * Token creation date + * @return DateTime + */ public function getCreated(): DateTime { return $this->created; } + /** + * Token expiration date + * + * @return DateTime + */ public function getExpires(): DateTime { return $this->expires; } + /** + * Token value + * + * @return string + */ public function getValue(): string { return $this->value; } + /** + * A token is empty if does not contains an internal value + * + * @return bool + */ public function isValueEmpty(): bool { return ('' === $this->value); } + /** + * A token is expired if the expiration date is greater or equal to current time + * + * @return bool + */ public function isExpired(): bool { return $this->expires->compareTo(DateTime::now()) < 0; } + /** + * A token is valid if contains a value and is not expired + * + * @return bool + */ public function isValid(): bool { return ! ($this->isValueEmpty() || $this->isExpired()); } + + /** @return array */ + public function jsonSerialize(): array + { + return [ + 'created' => $this->created, + 'expires' => $this->expires, + 'value' => $this->value, + ]; + } } diff --git a/src/WebClient/Exceptions/SoapFaultError.php b/src/WebClient/Exceptions/SoapFaultError.php new file mode 100644 index 0000000..e43d10d --- /dev/null +++ b/src/WebClient/Exceptions/SoapFaultError.php @@ -0,0 +1,28 @@ +getCode(), $fault->getMessage()); + parent::__construct($message, $request, $response, $previous); + $this->fault = $fault; + } + + public function getFault(): SoapFaultInfo + { + return $this->fault; + } +} diff --git a/src/WebClient/GuzzleWebClient.php b/src/WebClient/GuzzleWebClient.php index 92596f8..34459db 100644 --- a/src/WebClient/GuzzleWebClient.php +++ b/src/WebClient/GuzzleWebClient.php @@ -1,7 +1,5 @@ client = $client ?? new GuzzleClient(); - $this->fireRequestClousure = $onFireRequest; - $this->fireResponseClousure = $onFireResponse; + $this->fireRequestClosure = $onFireRequest; + $this->fireResponseClosure = $onFireResponse; } public function fireRequest(Request $request): void { - if (null !== $this->fireRequestClousure) { - call_user_func($this->fireRequestClousure, $request); + if (null !== $this->fireRequestClosure) { + call_user_func($this->fireRequestClosure, $request); } } public function fireResponse(Response $response): void { - if (null !== $this->fireResponseClousure) { - call_user_func($this->fireResponseClousure, $response); + if (null !== $this->fireResponseClosure) { + call_user_func($this->fireResponseClosure, $response); } } public function call(Request $request): Response { try { - /** @var ResponseInterface $psr7Response */ $psr7Response = $this->client->request($request->getMethod(), $request->getUri(), [ 'headers' => $request->getHeaders(), 'body' => $request->getBody(), ]); } catch (GuzzleException $exception) { - /** @var ResponseInterface|null $psr7Response */ $psr7Response = ($exception instanceof RequestException) ? $exception->getResponse() : null; $response = $this->createResponseFromPsr7Response($psr7Response); $message = sprintf('Error connecting to %s', $request->getUri()); throw new WebClientException($message, $request, $response, $exception); } - $response = $this->createResponseFromPsr7Response($psr7Response); - return $response; + return $this->createResponseFromPsr7Response($psr7Response); } private function createResponseFromPsr7Response(?ResponseInterface $response): Response diff --git a/src/WebClient/Request.php b/src/WebClient/Request.php index 37e00cb..7fdf2e0 100644 --- a/src/WebClient/Request.php +++ b/src/WebClient/Request.php @@ -6,7 +6,7 @@ use JsonSerializable; -class Request implements JsonSerializable +final class Request implements JsonSerializable { /** @var string */ private $method; @@ -21,7 +21,7 @@ class Request implements JsonSerializable private $headers; /** - * Request constructor. + * Minimal representation of http request object. * * @param string $method * @param string $uri diff --git a/src/WebClient/Response.php b/src/WebClient/Response.php index 3b0d08b..d96fcce 100644 --- a/src/WebClient/Response.php +++ b/src/WebClient/Response.php @@ -6,7 +6,7 @@ use JsonSerializable; -class Response implements JsonSerializable +final class Response implements JsonSerializable { /** @var int */ private $statusCode; @@ -18,7 +18,8 @@ class Response implements JsonSerializable private $headers; /** - * Response constructor. + * Minimal representation of http response object. + * * @param int $statusCode * @param string $body * @param array $headers @@ -61,7 +62,7 @@ public function statusCodeIsServerError(): bool return ($this->statusCode < 600 && $this->statusCode >= 500); } - /** @return array */ + /** @return array */ public function jsonSerialize(): array { return get_object_vars($this); diff --git a/src/WebClient/SoapFaultInfo.php b/src/WebClient/SoapFaultInfo.php new file mode 100644 index 0000000..804cc5b --- /dev/null +++ b/src/WebClient/SoapFaultInfo.php @@ -0,0 +1,43 @@ +code = $code; + $this->message = $message; + } + + public function getCode(): string + { + return $this->code; + } + + public function getMessage(): string + { + return $this->message; + } + + public function __toString(): string + { + return $this->message; + } + + /** @return array */ + public function jsonSerialize(): array + { + return get_object_vars($this); + } +} diff --git a/src/WebClient/WebClientInterface.php b/src/WebClient/WebClientInterface.php index 8502574..1644da7 100644 --- a/src/WebClient/WebClientInterface.php +++ b/src/WebClient/WebClientInterface.php @@ -4,16 +4,33 @@ namespace PhpCfdi\SatWsDescargaMasiva\WebClient; +/** + * Interface to proxy an http client + * @see GuzzleWebClient + */ interface WebClientInterface { /** + * Make the Http call to the web service + * This method should *not* call fireRequest/fireResponse + * * @param Request $request * @return Response * @throws Exceptions\WebClientException when an error is found */ public function call(Request $request): Response; + /** + * Method called before calling the web service + * + * @param Request $request + */ public function fireRequest(Request $request): void; + /** + * Method called after calling the web service + * + * @param Response $response + */ public function fireResponse(Response $response): void; } diff --git a/tests/Integration/ConsumeCfdiServicesUsingFakeFielTest.php b/tests/Integration/ConsumeCfdiServicesUsingFakeFielTest.php new file mode 100644 index 0000000..0463fa1 --- /dev/null +++ b/tests/Integration/ConsumeCfdiServicesUsingFakeFielTest.php @@ -0,0 +1,18 @@ +createFielRequestBuilderUsingTestingFiles(); + $webclient = new GuzzleWebClient(); + return new Service($requestBuilder, $webclient); + } +} diff --git a/tests/Integration/ConsumeRetencionesServicesUsingFakeFielTest.php b/tests/Integration/ConsumeRetencionesServicesUsingFakeFielTest.php new file mode 100644 index 0000000..c02239a --- /dev/null +++ b/tests/Integration/ConsumeRetencionesServicesUsingFakeFielTest.php @@ -0,0 +1,19 @@ +createFielRequestBuilderUsingTestingFiles(); + $webclient = new GuzzleWebClient(); + return new Service($requestBuilder, $webclient, null, ServiceEndpoints::retenciones()); + } +} diff --git a/tests/Integration/ConsumeServicesUsingFakeFielTest.php b/tests/Integration/ConsumeServiceTestCase.php similarity index 76% rename from tests/Integration/ConsumeServicesUsingFakeFielTest.php rename to tests/Integration/ConsumeServiceTestCase.php index 993c492..878b10a 100644 --- a/tests/Integration/ConsumeServicesUsingFakeFielTest.php +++ b/tests/Integration/ConsumeServiceTestCase.php @@ -11,16 +11,10 @@ use PhpCfdi\SatWsDescargaMasiva\Shared\DownloadType; use PhpCfdi\SatWsDescargaMasiva\Shared\RequestType; use PhpCfdi\SatWsDescargaMasiva\Tests\TestCase; -use PhpCfdi\SatWsDescargaMasiva\WebClient\GuzzleWebClient; -class ConsumeServicesUsingFakeFielTest extends TestCase +abstract class ConsumeServiceTestCase extends TestCase { - protected function createService(): Service - { - $fiel = $this->createFielUsingTestingFiles(); - $webclient = new GuzzleWebClient(); - return new Service($fiel, $webclient); - } + abstract protected function createService(): Service; public function testAuthentication(): void { @@ -33,8 +27,8 @@ public function testQuery(): void { $service = $this->createService(); - $dateTimePeriod = new DateTimePeriod(new DateTime('2019-01-01 00:00:00'), new DateTime('2019-01-01 00:04:00')); - $parameters = new QueryParameters($dateTimePeriod, DownloadType::received(), RequestType::cfdi()); + $dateTimePeriod = DateTimePeriod::create(DateTime::create('2019-01-01 00:00:00'), DateTime::create('2019-01-01 00:04:00')); + $parameters = QueryParameters::create($dateTimePeriod, DownloadType::received(), RequestType::cfdi()); $result = $service->query($parameters); $this->assertSame( diff --git a/tests/Scripts/Actions/Credentials.php b/tests/Scripts/Actions/Credentials.php deleted file mode 100644 index 98700e4..0000000 --- a/tests/Scripts/Actions/Credentials.php +++ /dev/null @@ -1,41 +0,0 @@ -getFielData(); - $passPhraseLength = mb_strlen($fielData->getPassPhrase()); - $this->stdout(...[ - 'Certificate: ' . $fielData->getCertificateFile(), - 'Private key: ' . $fielData->getPrivateKeyFile(), - 'Pass phrase: ' . (($passPhraseLength > 0) ? str_repeat('*', $passPhraseLength) : '(none)'), - ]); - try { - $fiel = $fielData->createFiel(); - } catch (Throwable $exception) { - throw new RuntimeException('Unable to create fiel from current data', 0, $exception); - } - $fielIsValid = $fiel->isValid(); - $this->stdout(...[ - 'Valid: ' . (($fielIsValid) ? 'yes' : 'no'), - 'RFC: ' . $fiel->getRfc(), - ]); - if (! $fielIsValid) { - throw new RuntimeException('Fiel is not valid!'); - } - } - - public function runHelp(): void - { - $this->stdout('return information about current credentials'); - } -} diff --git a/tests/Scripts/Actions/Download.php b/tests/Scripts/Actions/Download.php deleted file mode 100644 index f0008de..0000000 --- a/tests/Scripts/Actions/Download.php +++ /dev/null @@ -1,64 +0,0 @@ -createArguments(); - - ['matched' => $values, 'unmatched' => $unmatched] = $arguments->parseParameters($parameters); - if ([] !== $unmatched) { - throw new RuntimeException(sprintf('Unmatched arguments %s', implode(', ', $unmatched))); - } - - $packageId = strval($values['i'] ?? ''); - $destination = strval($values['d'] ?? ''); - $this->stdout( - 'Download: ', - ' PackageId: ' . $packageId, - ' Destination: ' . $destination - ); - - $service = $this->createService(); - $result = $service->download($packageId); - - if ('' !== $destination) { - /** @noinspection PhpUsageOfSilenceOperatorInspection */ - $write = @file_put_contents($destination, $result->getPackageContent()); - } else { - $write = $result->getPackageLenght(); - } - - $status = $result->getStatus(); - $this->stdout(...[ - 'Result:', - ' Is accepted: ' . (($status->isAccepted()) ? 'yes' : 'no'), - ' StatusCode: ' . $status->getCode(), - ' Message: ' . $status->getMessage(), - ' Package: ' . ((false === $write) ? 'error writting on destination' : "$write bytes"), - ]); - } - - public function runHelp(): void - { - $this->stdout('Download a package id, the result contains codes information and zero or one package stream'); - $this->stdout(...$this->createArguments()->toArray()); - } - - protected function createArguments(): Arguments - { - return new Arguments(...[ - new Argument('i', 'package-id', 'package-id as received by verify command'), - new Argument('d', 'destination', 'file name store the package contents'), - ]); - } -} diff --git a/tests/Scripts/Actions/Help.php b/tests/Scripts/Actions/Help.php deleted file mode 100644 index 36f54b7..0000000 --- a/tests/Scripts/Actions/Help.php +++ /dev/null @@ -1,29 +0,0 @@ -stdout(...[ - 'Use this tool to run command using credentials defined in environment', - 'Commands:', - ' help: This command', - ' credentials: Show credential information from environment', - ' WSDM_CERTIFICATE path to certificate', - ' WSDM_PRIVATEKEY path to private key', - ' WSDM_PASSPHRASE pass phrase to open private key', - ' request: Perform a request', - ]); - } - - public function runHelp(): void - { - $this->run(); - } -} diff --git a/tests/Scripts/Actions/Query.php b/tests/Scripts/Actions/Query.php deleted file mode 100644 index c0354ee..0000000 --- a/tests/Scripts/Actions/Query.php +++ /dev/null @@ -1,85 +0,0 @@ -createArguments(); - - ['matched' => $values, 'unmatched' => $unmatched] = $arguments->parseParameters($parameters); - if ([] !== $unmatched) { - throw new RuntimeException(sprintf('Unmatched arguments %s', implode(', ', $unmatched))); - } - - // period - $period = new DateTimePeriod(new DateTime($values['s'] ?? ''), new DateTime($values['u'] ?? '')); - // download type - if ('issued' === strval($values['d'] ?? '')) { - $downloadType = DownloadType::issued(); - } elseif ('received' === strval($values['d'] ?? '')) { - $downloadType = DownloadType::received(); - } else { - throw new RuntimeException('Invalid download type'); - } - // request type - if ('metadata' === strval($values['r'] ?? '')) { - $requestType = RequestType::metadata(); - } elseif ('cfdi' === strval($values['r'] ?? '')) { - $requestType = RequestType::cfdi(); - } else { - throw new RuntimeException('Invalid request type'); - } - // query - $query = new QueryParameters($period, $downloadType, $requestType); - - $this->stdout(...[ - 'Query:', - ' Since: ' . $query->getDateTimePeriod()->getStart()->formatDefaultTimeZone(), - ' Until: ' . $query->getDateTimePeriod()->getEnd()->formatDefaultTimeZone(), - ' Download type: ' . $query->getDownloadType()->value(), - ' Request type: ' . $query->getRequestType()->value(), - ]); - - $service = $this->createService(); - $result = $service->query($query); - - $status = $result->getStatus(); - $this->stdout(...[ - 'Result:', - ' IsAccepted: ' . (($status->isAccepted()) ? 'yes' : 'no'), - ' Message: ' . $status->getMessage(), - ' StatusCode: ' . $status->getCode(), - ' RequestId: ' . $result->getRequestId(), - ]); - } - - public function runHelp(): void - { - $this->stdout('Perform a request, uses the following parameters:'); - $this->stdout(...$this->createArguments()->toArray()); - } - - protected function createArguments(): Arguments - { - return new Arguments(...[ - new Argument('s', 'since', 'start date time expression for period'), - new Argument('u', 'until', 'end date time expression for period'), - new Argument('d', 'download-type', '"issued" or "received"'), - new Argument('r', 'request-type', '"cfdi" or "metadata"'), - ]); - } -} diff --git a/tests/Scripts/Actions/Verify.php b/tests/Scripts/Actions/Verify.php deleted file mode 100644 index 24c1613..0000000 --- a/tests/Scripts/Actions/Verify.php +++ /dev/null @@ -1,56 +0,0 @@ -createArguments(); - - ['matched' => $values, 'unmatched' => $unmatched] = $arguments->parseParameters($parameters); - if ([] !== $unmatched) { - throw new RuntimeException(sprintf('Unmatched arguments %s', implode(', ', $unmatched))); - } - - $requestId = strval($values['r'] ?? ''); - $this->stdout('RequestId: ' . $requestId); - - $service = $this->createService(); - $result = $service->verify($requestId); - $status = $result->getStatus(); - $codeRequest = $result->getCodeRequest(); - $statusRequest = $result->getStatusRequest(); - - $this->stdout(...[ - 'Result:', - ' Is accepted: ' . (($status->isAccepted()) ? 'yes' : 'no'), - ' Message: ' . $status->getMessage(), - ' StatusCode: ' . $status->getCode(), - ' Code Request: ' . $codeRequest->getValue() . ' - ' . $codeRequest->getMessage(), - ' Status Request: ' . $statusRequest->getValue() . ' - ' . $statusRequest->getMessage(), - ' Number CFDI: ' . $result->getNumberCfdis(), - ' Packages: ' . implode(', ', $result->getPackagesIds()), - ]); - } - - public function runHelp(): void - { - $this->stdout('Verify a request id, the result contains codes information and zero, one or more package id'); - $this->stdout(...$this->createArguments()->toArray()); - } - - protected function createArguments(): Arguments - { - return new Arguments(...[ - new Argument('r', 'request-id', 'request-id as received by request command'), - ]); - } -} diff --git a/tests/Scripts/CLI/AbstractAction.php b/tests/Scripts/CLI/AbstractAction.php deleted file mode 100644 index 1024726..0000000 --- a/tests/Scripts/CLI/AbstractAction.php +++ /dev/null @@ -1,87 +0,0 @@ -fielData = $fielData; - $this->printer = $printer; - $this->outputDirectory = $outputDirectory; - } - - public function getFielData(): FielData - { - return $this->fielData; - } - - public function createFiel(): Fiel - { - return $this->fielData->createFiel(); - } - - public function stdout(string ...$lines): void - { - $this->printer->stdout(...$lines); - } - - public function stderr(string ...$lines): void - { - $this->printer->stderr(...$lines); - } - - public function createService(): Service - { - $fiel = $this->createFiel(); - if (! $fiel->isValid()) { - throw new RuntimeException('The current credential is not valid'); - } - return new Service($fiel, $this->createWebClient()); - } - - private function createWebClient(): WebClientInterface - { - $jsonPrinter = null; - if ('' !== $this->outputDirectory) { - /** @param Request|Response $payload */ - $jsonPrinter = function ($payload): void { - $now = new DateTimeImmutable(); - $jsonFile = sprintf( - '%s/%s_%s.json', - $this->outputDirectory, - $now->format('Ymd-His.u'), - strtolower(basename(str_replace('\\', '/', get_class($payload)))) - ); - file_put_contents($jsonFile, json_encode($payload, JSON_PRETTY_PRINT)); - $bodyFile = substr($jsonFile, 0, -4) . 'xml'; - $xmlBody = $payload->getBody(); - if ('' !== $xmlBody) { - file_put_contents($bodyFile, $xmlBody); - } - }; - } - return new GuzzleWebClient(null, $jsonPrinter, $jsonPrinter); - } -} diff --git a/tests/Scripts/CLI/ActionInterface.php b/tests/Scripts/CLI/ActionInterface.php deleted file mode 100644 index 00bf3e2..0000000 --- a/tests/Scripts/CLI/ActionInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -key = $key; - $this->alias = $alias; - $this->info = $info; - } - - public function getKey(): string - { - return $this->key; - } - - public function getAlias(): string - { - return $this->alias; - } - - public function matchParameter(string $parameter): bool - { - return ($parameter === '-' . $this->key || $parameter === '--' . $this->alias); - } - - public function getInfo(): string - { - return $this->info; - } -} diff --git a/tests/Scripts/CLI/Arguments.php b/tests/Scripts/CLI/Arguments.php deleted file mode 100644 index 4dbbc7f..0000000 --- a/tests/Scripts/CLI/Arguments.php +++ /dev/null @@ -1,63 +0,0 @@ -arguments = $arguments; - } - - /** - * @param string[] $parameters - * @return array - */ - public function parseParameters(array $parameters): array - { - /** @var string[] $matches */ - $matches = []; - /** @var string[] $unmatched */ - $unmatched = []; - $length = count($parameters); - for ($i = 0; $i < $length; $i = $i + 1) { - $parameter = $parameters[$i]; - $argument = $this->findArgumentByParameter($parameter); - if (null !== $argument) { - $matches[$argument->getKey()] = $parameters[$i + 1] ?? ''; - $i = $i + 1; - } else { - $unmatched[] = $parameter; - } - } - return ['matched' => $matches, 'unmatched' => $unmatched]; - } - - public function findArgumentByParameter(string $parameter): ?Argument - { - foreach ($this->arguments as $argument) { - if ($argument->matchParameter($parameter)) { - return $argument; - } - } - return null; - } - - /** - * @return array - */ - public function toArray(): array - { - return array_map( - function (Argument $argument) { - return sprintf(' -%s, --%s: %s', $argument->getKey(), $argument->getAlias(), $argument->getInfo()); - }, - $this->arguments - ); - } -} diff --git a/tests/Scripts/CLI/Printer.php b/tests/Scripts/CLI/Printer.php deleted file mode 100644 index 4354087..0000000 --- a/tests/Scripts/CLI/Printer.php +++ /dev/null @@ -1,37 +0,0 @@ -stdout = $stdout; - $this->stderr = $stderr; - } - - public function stdout(string ...$lines): void - { - $this->print($this->stdout, ...$lines); - } - - public function stderr(string ...$lines): void - { - $this->print($this->stderr, ...$lines); - } - - private function print(string $where, string ...$lines): void - { - foreach ($lines as $line) { - file_put_contents($where, $line . PHP_EOL, FILE_APPEND); - } - } -} diff --git a/tests/Scripts/Helpers/FielData.php b/tests/Scripts/Helpers/FielData.php deleted file mode 100644 index 26f30a8..0000000 --- a/tests/Scripts/Helpers/FielData.php +++ /dev/null @@ -1,53 +0,0 @@ -certificateFile = $certificateFile; - $this->privateKeyFile = $privateKeyFile; - $this->passPhrase = $passPhrase; - } - - public function getCertificateFile(): string - { - return $this->certificateFile; - } - - public function getPrivateKeyFile(): string - { - return $this->privateKeyFile; - } - - public function getPassPhrase(): string - { - return $this->passPhrase; - } - - public function createFiel(): Fiel - { - return new Fiel( - Credential::openFiles( - $this->getCertificateFile(), - $this->getPrivateKeyFile(), - $this->getPassPhrase() - ) - ); - } -} diff --git a/tests/Scripts/README.md b/tests/Scripts/README.md deleted file mode 100644 index b7ac1f8..0000000 --- a/tests/Scripts/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# About sat-ws-descarga-masiva.php - -The script `sat-ws-descarga-masiva.php` is a command line interface utility to consume the webservice provided by SAT. - -It works with credentials (FIEL certificate, private key and pass phrase). - -## The credentials - -To setup the credentials use the following parameters: - -- `-c|--certificate file.cer`: Certificate file. -- `-k|--private-key file.key`: Private key file. -- `-p|--pass-phrase passphrase`: Pass phrase to open the private key. - -## The output folder - -The query, verify and download commands can create output files, you must set the folder to use -for dumping the request and response information. - -The files dumped has the format `/_.` where: - -- output-folder: as provided -- date: by example 20191231-235959.987654 -- name: request or response -- format: json includes headers and body, xml includes only the xml body - -You can specify the output directory path using the parameter `[-o|--output example/storage/logs]` like: - -```shell -php tests/Scripts/sat-ws-descarga-masiva.php query --output example/storage/logs -``` - -## The Actions - -It offers the following actions: - -- `credentials`: show information about the credentials to use and if they are valid or not. -- `query`: submit a *query*, the result should contain a *request id*. -- `verify`: verify a *request id*, the result contains codes information and zero, one or more *package id*. -- `download`: download a *package id*. - -On query, verify and download you can use output folder to dump request and responses. - -### Credentials - -Show information about the credentials to use and if they are valid or not. - -It does not contact the webservice. - -### Query - -```shell -php tests/Scripts/sat-ws-descarga-masiva.php query -h -Perform a request, uses the following parameters: - -s, --since: start date time expression for period - -u, --until: end date time expression for period - -d, --download-type: "issued" or "received" - -r, --request-type: "cfdi" or "metadata" -``` - -### Verify - -```shell -php tests/Scripts/sat-ws-descarga-masiva.php verify -h -Verify a request id, the result contains codes information and zero, one or more package id - -r, --request-id: request-id as received by request command -``` - -### Download - -```shell -php tests/Scripts/sat-ws-descarga-masiva.php verify -h - -work-in-progress... - -``` - -## About this sub-project - -Surely this script will be removed from this project and will find a place by its own. -I don't think any of the namespace in `PhpCfdi\SatWsDescargaMasiva\Tests\Scripts\` would survive. - -The probable future for this is to create a Laravel/Symfony CLI that requires the library as a dependence. diff --git a/tests/Scripts/sat-ws-descarga-masiva.php b/tests/Scripts/sat-ws-descarga-masiva.php deleted file mode 100644 index 185d2dd..0000000 --- a/tests/Scripts/sat-ws-descarga-masiva.php +++ /dev/null @@ -1,52 +0,0 @@ - $matched, 'unmatched' => $unmatched] = $arguments->parseParameters($parameters); - $outputDirectory = strval($matched['o'] ?? ''); - try { - $askForHelp = (in_array('-h', $unmatched, true) || in_array('--help', $unmatched, true)); - $fielData = new Helpers\FielData( - strval($matched['c'] ?? ''), - strval($matched['k'] ?? ''), - strval($matched['p'] ?? '') - ); - $actionClass = __NAMESPACE__ . '\Actions\\' . ucfirst($action); - if (! class_exists($actionClass)) { - throw new RuntimeException("Action $action not found"); - } - /** @var CLI\ActionInterface $actionObject */ - $actionObject = new $actionClass($fielData, $printer, $outputDirectory); - if ($askForHelp) { - $actionObject->runHelp(); - } else { - $actionObject->run(...$unmatched); - } - return 0; - } catch (Throwable $exception) { - $printer->stderr("ERROR: {$exception->getMessage()}"); - return 1; - } - }, - $argv[1] ?? '' ?: 'help', - ...(array_slice($argv, 2) ?? []) -)); diff --git a/tests/TestCase.php b/tests/TestCase.php index e4d5f21..228ff4a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,8 +5,9 @@ namespace PhpCfdi\SatWsDescargaMasiva\Tests; use DOMDocument; -use PhpCfdi\SatWsDescargaMasiva\Shared\Fiel; -use PhpCfdi\SatWsDescargaMasiva\Tests\Scripts\Helpers\FielData; +use PhpCfdi\Credentials\Credential; +use PhpCfdi\SatWsDescargaMasiva\RequestBuilder\FielRequestBuilder\Fiel; +use PhpCfdi\SatWsDescargaMasiva\RequestBuilder\FielRequestBuilder\FielRequestBuilder; abstract class TestCase extends \PHPUnit\Framework\TestCase { @@ -21,14 +22,21 @@ public static function fileContents(string $filename): string return strval(@file_get_contents(static::filePath($filename))) ?: ''; } + public function createFielRequestBuilderUsingTestingFiles(string $password = null): FielRequestBuilder + { + $fiel = $this->createFielUsingTestingFiles($password); + return new FielRequestBuilder($fiel); + } + public function createFielUsingTestingFiles(string $password = null): Fiel { - $fielData = new FielData( - $this->filePath('fake-fiel/EKU9003173C9.cer'), - $this->filePath('fake-fiel/EKU9003173C9.key'), - $password ?? trim($this->fileContents('fake-fiel/EKU9003173C9-password.txt')) + return new Fiel( + Credential::openFiles( + $this->filePath('fake-fiel/EKU9003173C9.cer'), + $this->filePath('fake-fiel/EKU9003173C9.key'), + $password ?? trim($this->fileContents('fake-fiel/EKU9003173C9-password.txt')) + ) ); - return $fielData->createFiel(); } public static function xmlFormat(string $content): string diff --git a/tests/Unit/Internal/HelpersTest.php b/tests/Unit/Internal/HelpersTest.php new file mode 100644 index 0000000..9f14c6d --- /dev/null +++ b/tests/Unit/Internal/HelpersTest.php @@ -0,0 +1,31 @@ + + foo + + + + BAZZ + + + + + EOT; + + $expected = 'fooBAZZ'; + $this->assertSame($expected, Helpers::nospaces($source)); + } +} diff --git a/tests/Unit/Internal/InteractsXmlTraitSpecimen.php b/tests/Unit/Internal/InteractsXmlTraitSpecimen.php new file mode 100644 index 0000000..ca63f85 --- /dev/null +++ b/tests/Unit/Internal/InteractsXmlTraitSpecimen.php @@ -0,0 +1,12 @@ + - foo - - - - BAZZ - - - - - EOT; - - $expected = 'fooBAZZ'; - $specimen = new InteractsXmlTraitSpecimen(); - $this->assertSame($expected, $specimen->nospaces($source)); - } - public function testFindElementExpectingOne(): void { $specimen = new InteractsXmlTraitSpecimen(); diff --git a/tests/Unit/Internal/ServiceConsumerTest.php b/tests/Unit/Internal/ServiceConsumerTest.php new file mode 100644 index 0000000..595a2ca --- /dev/null +++ b/tests/Unit/Internal/ServiceConsumerTest.php @@ -0,0 +1,159 @@ +fileContents('authenticate/response-with-token.xml'); + $response = new Response(200, $responseBody); + + /** @var WebClientInterface&MockObject $webClient */ + $webClient = $this->getMockBuilder(WebClientInterface::class)->getMock(); + $webClient->expects($this->once())->method('call')->willReturn($response); + + $consumer = new ServiceConsumer(); + $token = new Token(new DateTime('2020-01-13 14:15:16'), new DateTime('2020-01-13 14:15:16'), 'token-value'); + $return = $consumer->execute($webClient, 'soap-action', 'uri', 'body', $token); + + $this->assertSame($responseBody, $return); + } + + public function testCreateRequest(): void + { + $consumer = new ServiceConsumer(); + $request = $consumer->createRequest('uri', 'body', ['x-foo' => 'foo value']); + $expected = new Request('POST', 'uri', 'body', ['x-foo' => 'foo value']); + $this->assertEquals($expected, $request); + } + + public function testCreateHeadersWithToken(): void + { + $consumer = new ServiceConsumer(); + $soapAction = 'soap-action'; + $tokenValue = 'token-value'; + $token = new Token(new DateTime('2020-01-13 14:15:16'), new DateTime('2020-01-13 14:15:16'), $tokenValue); + $headers = $consumer->createHeaders($soapAction, $token); + $expected = [ + 'SOAPAction' => $soapAction, + 'Authorization' => 'WRAP access_token="' . $tokenValue . '"', + ]; + $this->assertSame($expected, $headers); + } + + public function testCreateHeadersWithOutToken(): void + { + $consumer = new ServiceConsumer(); + $soapAction = 'soap-action'; + $headers = $consumer->createHeaders($soapAction, null); + $expected = ['SOAPAction' => $soapAction]; + $this->assertSame($expected, $headers); + } + + public function testRunRequest(): void + { + $request = new Request('POST', 'uri', 'request', ['x-foo' => 'foo value']); + $response = new Response(200, 'response'); + + /** @var WebClientInterface&MockObject $webClient */ + $webClient = $this->getMockBuilder(WebClientInterface::class)->getMock(); + $webClient->expects($this->once())->method('fireRequest')->with($request); + $webClient->expects($this->once())->method('call')->with($request)->willReturn($response); + $webClient->expects($this->once())->method('fireResponse')->with($response); + + $consumer = new ServiceConsumer(); + $return = $consumer->runRequest($webClient, $request); + + $this->assertSame($response, $return); + } + + public function testRunRequestWithWebClientException(): void + { + $request = new Request('POST', 'uri', 'request', ['x-foo' => 'foo value']); + $response = new Response(500, ''); + $exception = new WebClientException('foo', $request, $response); + + /** @var WebClientInterface&MockObject $webClient */ + $webClient = $this->getMockBuilder(WebClientInterface::class)->getMock(); + $webClient->expects($this->once())->method('fireRequest')->with($request); + $webClient->expects($this->once())->method('call')->willThrowException($exception); + $webClient->expects($this->once())->method('fireResponse')->with($response); + + $consumer = new ServiceConsumer(); + $thrownException = null; + try { + $consumer->runRequest($webClient, $request); + } catch (WebClientException $webClientException) { + $thrownException = $webClientException; + } + if (null === $thrownException) { + $this->fail('The WebClientException was not thrown'); + return; + } + $this->assertSame($response, $thrownException->getResponse()); + } + + public function testCheckErrorWithFault(): void + { + $request = new Request('POST', 'uri', 'body', []); + $responseBody = $this->fileContents('authenticate/response-with-error.xml'); + $response = new Response(200, $responseBody); + $consumer = new ServiceConsumer(); + + $this->expectException(SoapFaultError::class); + $consumer->checkErrors($request, $response); + } + + public function testCheckErrorOnClientSide(): void + { + $request = new Request('POST', 'uri', 'body', []); + $response = new Response(400, ''); + $consumer = new ServiceConsumer(); + + $this->expectException(HttpClientError::class); + $this->expectExceptionMessage('Unexpected client error status code'); + $consumer->checkErrors($request, $response); + } + + public function testCheckErrorOnServerSide(): void + { + $request = new Request('POST', 'uri', 'body', []); + $response = new Response(500, ''); + $consumer = new ServiceConsumer(); + + $this->expectException(HttpServerError::class); + $this->expectExceptionMessage('Unexpected server error status code'); + $consumer->checkErrors($request, $response); + } + + public function testCheckErrorOnEmptyResponse(): void + { + $request = new Request('POST', 'uri', 'body', []); + $response = new Response(200, ''); + $consumer = new ServiceConsumer(); + + $this->expectException(HttpServerError::class); + $this->expectExceptionMessage('Unexpected empty response from server'); + $consumer->checkErrors($request, $response); + } +} diff --git a/tests/Unit/Internal/SoapFaultInfoExtractorTest.php b/tests/Unit/Internal/SoapFaultInfoExtractorTest.php new file mode 100644 index 0000000..87346b8 --- /dev/null +++ b/tests/Unit/Internal/SoapFaultInfoExtractorTest.php @@ -0,0 +1,47 @@ +fileContents('authenticate/response-with-error.xml'); + $fault = SoapFaultInfoExtractor::extract($source); + if (null === $fault) { + $this->fail('It was expected to receive an instace of SoapFaultInfo'); + return; + } + $this->assertInstanceOf(SoapFaultInfo::class, $fault); + $this->assertSame('a:InvalidSecurity', $fault->getCode()); + $this->assertSame('An error occurred when verifying security for the message.', $fault->getMessage()); + } + + public function testExtractOnNotFaultyResponse(): void + { + $source = $this->fileContents('authenticate/response-with-token.xml'); + $fault = SoapFaultInfoExtractor::extract($source); + $this->assertNull($fault); + } + + /** + * @param string $source + * @testWith ["not valid xml"] + * [""] + * [""] + */ + public function testExtractOnNotXmlContent(string $source): void + { + $fault = SoapFaultInfoExtractor::extract($source); + $this->assertNull($fault); + } +} diff --git a/tests/Unit/PackageReader/CfdiPackageReaderTest.php b/tests/Unit/PackageReader/CfdiPackageReaderTest.php index 7d465b1..3d6e9e4 100644 --- a/tests/Unit/PackageReader/CfdiPackageReaderTest.php +++ b/tests/Unit/PackageReader/CfdiPackageReaderTest.php @@ -4,9 +4,10 @@ namespace PhpCfdi\SatWsDescargaMasiva\Tests\Unit\PackageReader; +use JsonSerializable; use PhpCfdi\SatWsDescargaMasiva\PackageReader\CfdiPackageReader; +use PhpCfdi\SatWsDescargaMasiva\PackageReader\Exceptions\OpenZipFileException; use PhpCfdi\SatWsDescargaMasiva\Tests\TestCase; -use RuntimeException; /** * This tests uses the Zip file located at tests/_files/zip/cfdi.zip that contains: @@ -25,8 +26,7 @@ class CfdiPackageReaderTest extends TestCase public function testReaderZipWhenTheContentIsInvalid(): void { $zipContents = 'INVALID_ZIP_CONTENT'; - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Could not open zip'); + $this->expectException(OpenZipFileException::class); CfdiPackageReader::createFromContents($zipContents); } @@ -47,7 +47,7 @@ public function testReaderZipWithOtherFiles(): void $expectedNumberCfdis = 2; $filename = $this->filePath('zip/cfdi.zip'); - $cfdiPackageReader = new CfdiPackageReader($filename); + $cfdiPackageReader = CfdiPackageReader::createFromFile($filename); $this->assertCount($expectedNumberCfdis, $cfdiPackageReader); } @@ -64,21 +64,105 @@ public function testReaderZipWithOtherFilesAndDoubleXmlExtension(): void sort($expectedFilenames); $filename = $this->filePath('zip/cfdi.zip'); - $cfdiPackageReader = new CfdiPackageReader($filename); + $cfdiPackageReader = CfdiPackageReader::createFromFile($filename); $filenames = array_keys(iterator_to_array($cfdiPackageReader->fileContents())); sort($filenames); $this->assertEquals($expectedFilenames, $filenames); } - public function testReaderCfdiInZip(): void + public function testCfdiReaderObtainFirstFileAsExpected(): void { $expectedCfdi = $this->fileContents('zip/cfdi.xml'); $zipFilename = $this->filePath('zip/cfdi.zip'); - $cfdiPackageReader = new CfdiPackageReader($zipFilename); + $cfdiPackageReader = CfdiPackageReader::createFromFile($zipFilename); - $cfdi = $cfdiPackageReader->fileContents()->current(); - $this->assertEquals($expectedCfdi, $cfdi); + $cfdi = current(iterator_to_array($cfdiPackageReader->fileContents())); + $this->assertSame($expectedCfdi, $cfdi); + } + + public function testCreateFromFileAndContents(): void + { + $filename = $this->filePath('zip/cfdi.zip'); + $first = CfdiPackageReader::createFromFile($filename); + $this->assertSame($filename, $first->getFilename()); + + $contents = $this->fileContents('zip/cfdi.zip'); + $second = CfdiPackageReader::createFromContents($contents); + + $this->assertEquals( + iterator_to_array($first->cfdis()), + iterator_to_array($second->cfdis()), + 'createFromFile & createFromContents get the same contents' + ); + } + + /** @return array> */ + public function providerObtainUuidFromXmlCfdi(): array + { + return [ + 'common' => [ + '', + 'ff833b27-c8ab-4c44-a559-2c197bdd4067', + ], + 'upper-case' => [ + '', + 'ff833b27-c8ab-4c44-a559-2c197bdd4067', + ], + 'middle-vertical-content' => [ + '', + 'ff833b27-c8ab-4c44-a559-2c197bdd4067', + ], + 'middle-vertical-space' => [ + "", + 'ff833b27-c8ab-4c44-a559-2c197bdd4067', + ], + 'invalid-uuid' => [ + "", + '', + ], + 'empty-content' => ['', ''], + 'invalid xml' => ['invalid xml', ''], + 'xml without tfd' => ['', ''], + ]; + } + + /** + * @param string $source + * @param string $expected + * @dataProvider providerObtainUuidFromXmlCfdi + */ + public function testObtainUuidFromXmlCfdi(string $source, string $expected): void + { + $uuid = CfdiPackageReader::obtainUuidFromXmlCfdi($source); + $this->assertSame($expected, $uuid); + } + + public function testJson(): void + { + $zipFilename = $this->filePath('zip/cfdi.zip'); + $packageReader = CfdiPackageReader::createFromFile($zipFilename); + $this->assertInstanceOf(JsonSerializable::class, $packageReader); + + /** @var array $jsonData */ + $jsonData = $packageReader->jsonSerialize(); + + $this->assertSame($zipFilename, $jsonData['source'] ?? ''); + + $expectedFiles = [ + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.xml', + 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.xml.xml', + ]; + /** @var string[] $jsonDataFiles */ + $jsonDataFiles = $jsonData['files']; + $this->assertSame($expectedFiles, array_keys($jsonDataFiles)); + + $expectedCfdis = [ + '11111111-2222-3333-4444-000000000001', + ]; + /** @var string[] $jsonDataCfdis */ + $jsonDataCfdis = $jsonData['cfdis']; + $this->assertSame($expectedCfdis, array_keys($jsonDataCfdis)); } } diff --git a/tests/Unit/PackageReader/Exceptions/CreateTemporaryZipFileExceptionTest.php b/tests/Unit/PackageReader/Exceptions/CreateTemporaryZipFileExceptionTest.php new file mode 100644 index 0000000..c459e2f --- /dev/null +++ b/tests/Unit/PackageReader/Exceptions/CreateTemporaryZipFileExceptionTest.php @@ -0,0 +1,21 @@ +assertSame($message, $exception->getMessage()); + $this->assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/Unit/PackageReader/Exceptions/OpenZipFileExceptionTest.php b/tests/Unit/PackageReader/Exceptions/OpenZipFileExceptionTest.php new file mode 100644 index 0000000..071251d --- /dev/null +++ b/tests/Unit/PackageReader/Exceptions/OpenZipFileExceptionTest.php @@ -0,0 +1,24 @@ +assertStringContainsString('Unable to open Zip file', $exception->getMessage()); + $this->assertSame($filename, $exception->getFileName()); + $this->assertSame($code, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/Unit/PackageReader/Internal/FilteredPackageReaderTest.php b/tests/Unit/PackageReader/Internal/FilteredPackageReaderTest.php new file mode 100644 index 0000000..59bae64 --- /dev/null +++ b/tests/Unit/PackageReader/Internal/FilteredPackageReaderTest.php @@ -0,0 +1,58 @@ +expectException(OpenZipFileException::class); + FilteredPackageReader::createFromFile($filename); + } + + public function testCreateFromContentWithInvalidContent(): void + { + $this->expectException(OpenZipFileException::class); + FilteredPackageReader::createFromContents('invalid content'); + } + + public function testFileContentsAndCountWithFile(): void + { + $archiveFile = tempnam('', ''); + if (false === $archiveFile) { + throw new LogicException('Unable to create a temporary file'); + } + $archive = new ZipArchive(); + $archive->open($archiveFile, ZipArchive::CREATE); + $archive->addEmptyDir('empty dir'); + $archive->addFromString('empty file.txt', ''); + $archive->addFromString('foo.txt', 'foo'); + $archive->addFromString('sub/bar.txt', 'bar'); + $archive->close(); + + $expected = [ + 'empty dir/' => '', + 'empty file.txt' => '', + 'foo.txt' => 'foo', + 'sub/bar.txt' => 'bar', + ]; + + $packageReader = FilteredPackageReader::createFromFile($archiveFile); + $packageReader->setFilter(new NullFileFilter()); + $fileContents = iterator_to_array($packageReader->fileContents()); + $this->assertSame($expected, $fileContents); + $this->assertCount(count($expected), $packageReader); + + unlink($archiveFile); + } +} diff --git a/tests/Unit/PackageReader/MetadataContentTest.php b/tests/Unit/PackageReader/MetadataContentTest.php index 584d096..3dc691e 100644 --- a/tests/Unit/PackageReader/MetadataContentTest.php +++ b/tests/Unit/PackageReader/MetadataContentTest.php @@ -4,7 +4,7 @@ namespace PhpCfdi\SatWsDescargaMasiva\Tests\Unit\PackageReader; -use PhpCfdi\SatWsDescargaMasiva\PackageReader\MetadataContent; +use PhpCfdi\SatWsDescargaMasiva\PackageReader\Internal\MetadataContent; use PhpCfdi\SatWsDescargaMasiva\Tests\TestCase; class MetadataContentTest extends TestCase @@ -12,7 +12,7 @@ class MetadataContentTest extends TestCase public function testReadMetadata(): void { $contents = $this->fileContents('zip/metadata.txt'); - $reader = MetadataContent::createFromContents($contents); + $reader = \PhpCfdi\SatWsDescargaMasiva\PackageReader\Internal\MetadataContent::createFromContents($contents); $extracted = []; foreach ($reader->eachItem() as $item) { $extracted[] = $item->uuid; @@ -38,7 +38,7 @@ public function testReadMetadataWithBlankLines(): void '', // trailing blank lines '', ]); - $reader = MetadataContent::createFromContents($contents); + $reader = \PhpCfdi\SatWsDescargaMasiva\PackageReader\Internal\MetadataContent::createFromContents($contents); $extracted = []; foreach ($reader->eachItem() as $item) { $extracted[] = $item->all(); @@ -67,7 +67,7 @@ public function testCreateMetadataWithMoreValuesThanHeaders(): void $headers = ['xee', 'foo']; $values = ['x-xee', 'x-foo', 'x-bar']; $expected = ['xee' => 'x-xee', 'foo' => 'x-foo', '#extra-01' => 'x-bar']; - $reader = MetadataContent::createFromContents(''); + $reader = \PhpCfdi\SatWsDescargaMasiva\PackageReader\Internal\MetadataContent::createFromContents(''); $metadata = $reader->createMetadataItem($headers, $values); $this->assertSame($expected, $metadata->all()); } @@ -105,7 +105,7 @@ public function testReadMetadataWithSpecialCharacters(string $sourceValue, strin implode('~', ['1', $sourceValue, 'x-foo', 'x-bar']), implode('~', ['2', 'second', 'x-foo', 'x-bar']), ]); - $reader = MetadataContent::createFromContents($contents); + $reader = \PhpCfdi\SatWsDescargaMasiva\PackageReader\Internal\MetadataContent::createFromContents($contents); $extracted = []; foreach ($reader->eachItem() as $item) { diff --git a/tests/Unit/PackageReader/MetadataItemTest.php b/tests/Unit/PackageReader/MetadataItemTest.php index c1ae10a..6b12c35 100644 --- a/tests/Unit/PackageReader/MetadataItemTest.php +++ b/tests/Unit/PackageReader/MetadataItemTest.php @@ -34,8 +34,8 @@ public function testReaderCfdiInZip(): void $expectedContent = $this->fileContents('zip/metadata.txt'); $zipFilename = $this->filePath('zip/metadata.zip'); - $cfdiPackageReader = new MetadataPackageReader($zipFilename); - $extracted = $cfdiPackageReader->fileContents()->current(); + $packageReader = MetadataPackageReader::createFromFile($zipFilename); + $extracted = current(iterator_to_array($packageReader->fileContents())); // normalize line endings $expectedContent = str_replace("\r\n", "\n", $expectedContent); diff --git a/tests/Unit/PackageReader/MetadataPackageReaderTest.php b/tests/Unit/PackageReader/MetadataPackageReaderTest.php index 68b45f5..409a373 100644 --- a/tests/Unit/PackageReader/MetadataPackageReaderTest.php +++ b/tests/Unit/PackageReader/MetadataPackageReaderTest.php @@ -4,25 +4,38 @@ namespace PhpCfdi\SatWsDescargaMasiva\Tests\Unit\PackageReader; +use JsonSerializable; use PhpCfdi\SatWsDescargaMasiva\PackageReader\MetadataPackageReader; use PhpCfdi\SatWsDescargaMasiva\Tests\TestCase; +/** + * This tests uses the Zip file located at tests/_files/zip/metadata.zip that contains: + * + * __MACOSX/ // commonly generated by MacOS when open the file + * __MACOSX/._45C5C344-DA01-497A-9271-5AA3852EE6AE_01.txt // commonly generated by MacOS when open the file + * 00000000-0000-0000-0000-000000000000_00.txt // file with correct name but not a metadata file + * 45C5C344-DA01-497A-9271-5AA3852EE6AE_01.txt // file with metadata 2 rows + * empty-file // zero bytes file + * other.txt // file with incorrect extension and incorrect content + */ class MetadataPackageReaderTest extends TestCase { - public function testRetrieveAllContents(): void + public function testCountAllContents(): void { - $expectedNumberCfdis = 1; + $expectedNumberFiles = 1; + $expectedNumberRows = 2; $filename = $this->filePath('zip/metadata.zip'); - $metadataPackageReader = new MetadataPackageReader($filename); + $metadataPackageReader = MetadataPackageReader::createFromFile($filename); - $this->assertCount($expectedNumberCfdis, $metadataPackageReader); + $this->assertCount($expectedNumberRows, $metadataPackageReader); + $this->assertCount($expectedNumberFiles, $metadataPackageReader->fileContents()); } - public function testRetrieveMetadata(): void + public function testRetrieveMetadataContents(): void { $filename = $this->filePath('zip/metadata.zip'); - $metadataPackageReader = new MetadataPackageReader($filename); + $metadataPackageReader = MetadataPackageReader::createFromFile($filename); $this->assertCount(2, $metadataPackageReader->metadata()); @@ -37,4 +50,57 @@ public function testRetrieveMetadata(): void ]; $this->assertSame($expected, $extracted); } + + public function testCreateFromFileAndContents(): void + { + $filename = $this->filePath('zip/metadata.zip'); + $first = MetadataPackageReader::createFromFile($filename); + $this->assertSame($filename, $first->getFilename()); + + $contents = $this->fileContents('zip/metadata.zip'); + $second = MetadataPackageReader::createFromContents($contents); + + $this->assertEquals( + iterator_to_array($first->metadata()), + iterator_to_array($second->metadata()), + 'createFromFile & createFromContents get the same contents' + ); + } + + public function testJson(): void + { + $zipFilename = $this->filePath('zip/metadata.zip'); + $packageReader = MetadataPackageReader::createFromFile($zipFilename); + $this->assertInstanceOf(JsonSerializable::class, $packageReader); + + /** @var array $jsonData */ + $jsonData = $packageReader->jsonSerialize(); + + $this->assertSame($zipFilename, $jsonData['source'] ?? ''); + + $expectedFiles = [ + '45C5C344-DA01-497A-9271-5AA3852EE6AE_01.txt', + ]; + /** @var string[] $jsonDataFiles */ + $jsonDataFiles = $jsonData['files']; + $this->assertSame($expectedFiles, array_keys($jsonDataFiles)); + + $expectedMetadata = [ + 'E7215E3B-2DC5-4A40-AB10-C902FF9258DF', + '129C4D12-1415-4ACE-BE12-34E71C4EAB4E', + ]; + /** @var string[] $jsonDataMetadata */ + $jsonDataMetadata = $jsonData['metadata']; + $this->assertSame($expectedMetadata, array_keys($jsonDataMetadata)); + } + + public function testMetadataJson(): void + { + $zipFilename = $this->filePath('zip/metadata.zip'); + $packageReader = MetadataPackageReader::createFromFile($zipFilename); + + $expectedFile = $this->filePath('zip/metadata.json'); + $metadata = iterator_to_array($packageReader->metadata()); + $this->assertJsonStringEqualsJsonFile($expectedFile, json_encode($metadata) ?: ''); + } } diff --git a/tests/Unit/RequestBuilder/Exceptions/PeriodEndInvalidDateFormatExceptionTest.php b/tests/Unit/RequestBuilder/Exceptions/PeriodEndInvalidDateFormatExceptionTest.php new file mode 100644 index 0000000..3517524 --- /dev/null +++ b/tests/Unit/RequestBuilder/Exceptions/PeriodEndInvalidDateFormatExceptionTest.php @@ -0,0 +1,26 @@ +assertContains(RequestBuilderException::class, $interfaces); + } + + public function testGetProperties(): void + { + $periodEnd = 'foo'; + $exception = new PeriodEndInvalidDateFormatException($periodEnd); + $this->assertSame('The end date time "foo" does not have the correct format', $exception->getMessage()); + $this->assertSame($periodEnd, $exception->getPeriodEnd()); + } +} diff --git a/tests/Unit/RequestBuilder/Exceptions/PeriodStartGreaterThanEndExceptionTest.php b/tests/Unit/RequestBuilder/Exceptions/PeriodStartGreaterThanEndExceptionTest.php new file mode 100644 index 0000000..0530817 --- /dev/null +++ b/tests/Unit/RequestBuilder/Exceptions/PeriodStartGreaterThanEndExceptionTest.php @@ -0,0 +1,28 @@ +assertContains(RequestBuilderException::class, $interfaces); + } + + public function testGetProperties(): void + { + $periodStart = 'foo'; + $periodEnd = 'bar'; + $exception = new PeriodStartGreaterThanEndException($periodStart, $periodEnd); + $this->assertSame('The period start "foo" is greater than end "bar"', $exception->getMessage()); + $this->assertSame($periodStart, $exception->getPeriodStart()); + $this->assertSame($periodEnd, $exception->getPeriodEnd()); + } +} diff --git a/tests/Unit/RequestBuilder/Exceptions/PeriodStartInvalidDateFormatExceptionTest.php b/tests/Unit/RequestBuilder/Exceptions/PeriodStartInvalidDateFormatExceptionTest.php new file mode 100644 index 0000000..4d495ec --- /dev/null +++ b/tests/Unit/RequestBuilder/Exceptions/PeriodStartInvalidDateFormatExceptionTest.php @@ -0,0 +1,26 @@ +assertContains(RequestBuilderException::class, $interfaces); + } + + public function testGetProperties(): void + { + $periodStart = 'foo'; + $exception = new PeriodStartInvalidDateFormatException($periodStart); + $this->assertSame('The start date time "foo" does not have the correct format', $exception->getMessage()); + $this->assertSame($periodStart, $exception->getPeriodStart()); + } +} diff --git a/tests/Unit/RequestBuilder/Exceptions/RequestTypeInvalidExceptionTest.php b/tests/Unit/RequestBuilder/Exceptions/RequestTypeInvalidExceptionTest.php new file mode 100644 index 0000000..618d517 --- /dev/null +++ b/tests/Unit/RequestBuilder/Exceptions/RequestTypeInvalidExceptionTest.php @@ -0,0 +1,26 @@ +assertContains(RequestBuilderException::class, $interfaces); + } + + public function testGetProperties(): void + { + $requestType = 'foo'; + $exception = new RequestTypeInvalidException($requestType); + $this->assertSame('The request type "foo" is not CFDI or Metadata', $exception->getMessage()); + $this->assertSame($requestType, $exception->getRequestType()); + } +} diff --git a/tests/Unit/RequestBuilder/Exceptions/RfcIsNotIssuerOrReceiverExceptionTest.php b/tests/Unit/RequestBuilder/Exceptions/RfcIsNotIssuerOrReceiverExceptionTest.php new file mode 100644 index 0000000..b97ec40 --- /dev/null +++ b/tests/Unit/RequestBuilder/Exceptions/RfcIsNotIssuerOrReceiverExceptionTest.php @@ -0,0 +1,30 @@ +assertContains(RequestBuilderException::class, $interfaces); + } + + public function testGetProperties(): void + { + $rfcSigner = 'a'; + $rfcIssuer = 'b'; + $rfcReceiver = 'c'; + $exception = new RfcIsNotIssuerOrReceiverException($rfcSigner, $rfcIssuer, $rfcReceiver); + $this->assertSame('The RFC "a" must be the issuer "b" or receiver "c"', $exception->getMessage()); + $this->assertSame($rfcSigner, $exception->getRfcSigner()); + $this->assertSame($rfcIssuer, $exception->getRfcIssuer()); + $this->assertSame($rfcReceiver, $exception->getRfcReceiver()); + } +} diff --git a/tests/Unit/RequestBuilder/Exceptions/RfcIssuerAndReceiverAreEmptyExceptionTest.php b/tests/Unit/RequestBuilder/Exceptions/RfcIssuerAndReceiverAreEmptyExceptionTest.php new file mode 100644 index 0000000..b995183 --- /dev/null +++ b/tests/Unit/RequestBuilder/Exceptions/RfcIssuerAndReceiverAreEmptyExceptionTest.php @@ -0,0 +1,24 @@ +assertContains(RequestBuilderException::class, $interfaces); + } + + public function testGetProperties(): void + { + $exception = new RfcIssuerAndReceiverAreEmptyException(); + $this->assertSame('The RFC issuer and RFC receiver are empty', $exception->getMessage()); + } +} diff --git a/tests/EnvelopSignatureVerifier.php b/tests/Unit/RequestBuilder/FielRequestBuilder/EnvelopSignatureVerifier.php similarity index 97% rename from tests/EnvelopSignatureVerifier.php rename to tests/Unit/RequestBuilder/FielRequestBuilder/EnvelopSignatureVerifier.php index c55fa36..7c23ffe 100644 --- a/tests/EnvelopSignatureVerifier.php +++ b/tests/Unit/RequestBuilder/FielRequestBuilder/EnvelopSignatureVerifier.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpCfdi\SatWsDescargaMasiva\Tests; +namespace PhpCfdi\SatWsDescargaMasiva\Tests\Unit\RequestBuilder\FielRequestBuilder; use DOMDocument; use DOMElement; diff --git a/tests/Unit/RequestBuilder/FielRequestBuilder/FielRequestBuilderTest.php b/tests/Unit/RequestBuilder/FielRequestBuilder/FielRequestBuilderTest.php new file mode 100644 index 0000000..9231a98 --- /dev/null +++ b/tests/Unit/RequestBuilder/FielRequestBuilder/FielRequestBuilderTest.php @@ -0,0 +1,198 @@ +assertContains($expected, $interfaces); + } + + public function testFielRequestContainsGivenFiel(): void + { + $fiel = $this->createFielUsingTestingFiles(); + $requestBuilder = new FielRequestBuilder($fiel); + $this->assertSame($fiel, $requestBuilder->getFiel()); + } + + public function testAuthorization(): void + { + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); + $created = '2019-08-01T03:38:19.000Z'; + $expires = '2019-08-01T03:43:19.000Z'; + $token = 'uuid-cf6c80fb-00ae-44c0-af56-54ec65decbaa-1'; + $requestBody = $requestBuilder->authorization($created, $expires, $token); + + $this->assertSame( + $this->xmlFormat(Helpers::nospaces($this->fileContents('authenticate/request.xml'))), + $this->xmlFormat($requestBody) + ); + + $xmlSecVerification = (new EnvelopSignatureVerifier())->verify( + $requestBody, + 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd', + 'Security', + ['http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd'], + $requestBuilder->getFiel()->getCertificatePemContents() + ); + $this->assertTrue($xmlSecVerification, 'The signature cannot be verified using XMLSecLibs'); + } + + public function testAuthorizationWithoutSecurityTokenUuidCreatesRandom(): void + { + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); + $created = '2019-08-01T03:38:19.000Z'; + $expires = '2019-08-01T03:43:19.000Z'; + + $requestBody = $requestBuilder->authorization($created, $expires); + $securityTokenId = $this->extractSecurityTokenFromXml($requestBody); + $this->assertNotEmpty($securityTokenId); + + $otherRequestBody = $requestBuilder->authorization($created, $expires); + $otherSecurityTokenId = $this->extractSecurityTokenFromXml($otherRequestBody); + $this->assertNotEmpty($otherSecurityTokenId); + $this->assertNotEquals($securityTokenId, $otherSecurityTokenId, 'Both generated tokens must not be equal'); + } + + private function extractSecurityTokenFromXml(string $requestBody): string + { + preg_match('/o:BinarySecurityToken u:Id="(?.*?)"/u', $requestBody, $matches); + return $matches['id'] ?? ''; + } + + public function testQuery(): void + { + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); + $start = '2019-01-01T00:00:00'; + $end = '2019-01-01T00:04:00'; + $rfcIssuer = ''; + $rfcReceiver = $requestBuilder->getFiel()->getRfc(); + $requestType = 'CFDI'; + $requestBody = $requestBuilder->query($start, $end, $rfcIssuer, $rfcReceiver, $requestType); + + $this->assertSame( + $this->xmlFormat(Helpers::nospaces($this->fileContents('query/request.xml'))), + $this->xmlFormat($requestBody) + ); + + $xmlSecVerification = (new EnvelopSignatureVerifier()) + ->verify($requestBody, 'http://DescargaMasivaTerceros.sat.gob.mx', 'SolicitaDescarga'); + $this->assertTrue($xmlSecVerification, 'The signature cannot be verified using XMLSecLibs'); + } + + public function testQueryWithInvalidStartDate(): void + { + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); + $invalidDate = '2019-01-01 00:00:00'; // contains an space instead of T + $validDate = '2019-01-01T00:00:00'; + $this->expectException(PeriodStartInvalidDateFormatException::class); + $requestBuilder->query($invalidDate, $validDate, RequestBuilderInterface::USE_SIGNER, '', 'CFDI'); + } + + public function testQueryWithInvalidEndDate(): void + { + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); + $invalidDate = '2019-01-01 00:00:00'; // contains an space instead of T + $validDate = '2019-01-01T00:00:00'; + $this->expectException(PeriodEndInvalidDateFormatException::class); + $requestBuilder->query($validDate, $invalidDate, RequestBuilderInterface::USE_SIGNER, '', 'CFDI'); + } + + public function testQueryWithStartGreaterThanEnd(): void + { + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); + $lower = '2019-01-01T00:00:00'; + $upper = '2019-01-01T00:00:01'; + $this->expectException(PeriodStartGreaterThanEndException::class); + $requestBuilder->query($upper, $lower, RequestBuilderInterface::USE_SIGNER, '', 'CFDI'); + } + + public function testQueryWithEmptyIssuerReceiver(): void + { + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); + $date = '2019-01-01T00:00:00'; + $requestType = 'CFDI'; + $this->expectException(RfcIssuerAndReceiverAreEmptyException::class); + $requestBuilder->query($date, $date, '', '', $requestType); + } + + public function testQueryWithIssuerReceiverNotSigner(): void + { + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); + $date = '2019-01-01T00:00:00'; + $requestType = 'CFDI'; + $this->expectException(RfcIsNotIssuerOrReceiverException::class); + $requestBuilder->query($date, $date, 'FOO', 'BAR', $requestType); + } + + public function testQueryWithInvalidRequestType(): void + { + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); + $date = '2019-01-01T00:00:00'; + $this->expectException(RequestTypeInvalidException::class); + $requestBuilder->query($date, $date, RequestBuilderInterface::USE_SIGNER, '', 'cfdi'); + } + + public function testVerify(): void + { + $fiel = Fiel::create( + $this->fileContents('fake-fiel/EKU9003173C9.cer'), + $this->fileContents('fake-fiel/EKU9003173C9.key'), + trim($this->fileContents('fake-fiel/EKU9003173C9-password.txt')) + ); + $requestBuilder = new FielRequestBuilder($fiel); + + $requestId = '3f30a4e1-af73-4085-8991-e4d97eef16bd'; + $requestBody = $requestBuilder->verify($requestId); + + $this->assertSame( + $this->xmlFormat(Helpers::nospaces($this->fileContents('verify/request.xml'))), + $this->xmlFormat($requestBody) + ); + + $xmlSecVerification = (new EnvelopSignatureVerifier()) + ->verify($requestBody, 'http://DescargaMasivaTerceros.sat.gob.mx', 'VerificaSolicitudDescarga'); + $this->assertTrue($xmlSecVerification, 'The signature cannot be verified using XMLSecLibs'); + } + + public function testDownload(): void + { + $fiel = Fiel::create( + $this->fileContents('fake-fiel/EKU9003173C9.cer'), + $this->fileContents('fake-fiel/EKU9003173C9.key'), + trim($this->fileContents('fake-fiel/EKU9003173C9-password.txt')) + ); + $requestBuilder = new FielRequestBuilder($fiel); + + $packageId = '4e80345d-917f-40bb-a98f-4a73939343c5_01'; + $requestBody = $requestBuilder->download($packageId); + + $this->assertSame( + $this->xmlFormat(Helpers::nospaces($this->fileContents('download/request.xml'))), + $this->xmlFormat($requestBody) + ); + + $xmlSecVerification = (new EnvelopSignatureVerifier()) + ->verify($requestBody, 'http://DescargaMasivaTerceros.sat.gob.mx', 'PeticionDescargaMasivaTercerosEntrada'); + $this->assertTrue($xmlSecVerification, 'The signature cannot be verified using XMLSecLibs'); + } +} diff --git a/tests/Unit/Shared/FielTest.php b/tests/Unit/RequestBuilder/FielRequestBuilder/FielTest.php similarity index 79% rename from tests/Unit/Shared/FielTest.php rename to tests/Unit/RequestBuilder/FielRequestBuilder/FielTest.php index 2f67881..1bf9e0e 100644 --- a/tests/Unit/Shared/FielTest.php +++ b/tests/Unit/RequestBuilder/FielRequestBuilder/FielTest.php @@ -2,11 +2,10 @@ declare(strict_types=1); -namespace PhpCfdi\SatWsDescargaMasiva\Tests\Unit\Shared; +namespace PhpCfdi\SatWsDescargaMasiva\Tests\Unit\RequestBuilder\FielRequestBuilder; use Exception; -use PhpCfdi\SatWsDescargaMasiva\Shared\Fiel; -use PhpCfdi\SatWsDescargaMasiva\Tests\Scripts\Helpers\FielData; +use PhpCfdi\SatWsDescargaMasiva\RequestBuilder\FielRequestBuilder\Fiel; use PhpCfdi\SatWsDescargaMasiva\Tests\TestCase; class FielTest extends TestCase @@ -25,11 +24,11 @@ public function testFielWithCorrectPassword(): void public function testFielUnprotectedPEM(): void { - $fiel = (new FielData( - $this->filePath('fake-fiel/EKU9003173C9.cer'), - $this->filePath('fake-fiel/EKU9003173C9.key.pem'), + $fiel = Fiel::create( + $this->fileContents('fake-fiel/EKU9003173C9.cer'), + $this->fileContents('fake-fiel/EKU9003173C9.key.pem'), '' - ))->createFiel(); + ); $this->assertTrue($fiel->isValid()); } diff --git a/tests/Unit/Services/Authenticate/AuthenticateTranslatorTest.php b/tests/Unit/Services/Authenticate/AuthenticateTranslatorTest.php index 0a5bfd8..6d19de5 100644 --- a/tests/Unit/Services/Authenticate/AuthenticateTranslatorTest.php +++ b/tests/Unit/Services/Authenticate/AuthenticateTranslatorTest.php @@ -4,9 +4,9 @@ namespace PhpCfdi\SatWsDescargaMasiva\Tests\Unit\Services\Authenticate; +use PhpCfdi\SatWsDescargaMasiva\Internal\Helpers; use PhpCfdi\SatWsDescargaMasiva\Services\Authenticate\AuthenticateTranslator; use PhpCfdi\SatWsDescargaMasiva\Shared\DateTime; -use PhpCfdi\SatWsDescargaMasiva\Tests\EnvelopSignatureVerifier; use PhpCfdi\SatWsDescargaMasiva\Tests\TestCase; class AuthenticateTranslatorTest extends TestCase @@ -14,34 +14,25 @@ class AuthenticateTranslatorTest extends TestCase public function testCreateSoapRequest(): void { $translator = new AuthenticateTranslator(); - $fiel = $this->createFielUsingTestingFiles(); + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); - $since = new DateTime('2019-08-01T03:38:19Z'); - $until = new DateTime('2019-08-01T03:43:19Z'); - $uuid = 'uuid-cf6c80fb-00ae-44c0-af56-54ec65decbaa-1'; - $requestBody = $translator->createSoapRequestWithData($fiel, $since, $until, $uuid); + $since = DateTime::create('2019-07-31 22:38:19 CDT'); + $until = DateTime::create('2019-07-31 22:43:19 CDT'); + $securityTokenId = 'uuid-cf6c80fb-00ae-44c0-af56-54ec65decbaa-1'; + $requestBody = $translator->createSoapRequestWithData($requestBuilder, $since, $until, $securityTokenId); $this->assertSame( - $this->xmlFormat($translator->nospaces($this->fileContents('authenticate/request.xml'))), + $this->xmlFormat(Helpers::nospaces($this->fileContents('authenticate/request.xml'))), $this->xmlFormat($requestBody) ); - - $xmlSecVerification = (new EnvelopSignatureVerifier())->verify( - $requestBody, - 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd', - 'Security', - ['http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd'], - $fiel->getCertificatePemContents() - ); - $this->assertTrue($xmlSecVerification, 'The signature cannot be verified using XMLSecLibs'); } public function testCreateTokenFromSoapResponseWithToken(): void { - $expectedCreated = new DateTime('2019-08-01T03:38:20.044Z'); - $expectedExpires = new DateTime('2019-08-01T03:43:20.044Z'); + $expectedCreated = DateTime::create('2019-08-01T03:38:20.044Z'); + $expectedExpires = DateTime::create('2019-08-01T03:43:20.044Z'); $translator = new AuthenticateTranslator(); - $responseBody = $translator->nospaces($this->fileContents('authenticate/response-with-token.xml')); + $responseBody = Helpers::nospaces($this->fileContents('authenticate/response-with-token.xml')); $token = $translator->createTokenFromSoapResponse($responseBody); $this->assertFalse($token->isValueEmpty()); $this->assertTrue($token->isExpired()); @@ -53,7 +44,7 @@ public function testCreateTokenFromSoapResponseWithToken(): void public function testCreateTokenFromSoapResponseWithError(): void { $translator = new AuthenticateTranslator(); - $responseBody = $translator->nospaces($this->fileContents('authenticate/response-with-error.xml')); + $responseBody = Helpers::nospaces($this->fileContents('authenticate/response-with-error.xml')); $token = $translator->createTokenFromSoapResponse($responseBody); $this->assertTrue($token->isValueEmpty()); $this->assertTrue($token->isExpired()); diff --git a/tests/Unit/Services/Download/DownloadResultTest.php b/tests/Unit/Services/Download/DownloadResultTest.php new file mode 100644 index 0000000..3d1497f --- /dev/null +++ b/tests/Unit/Services/Download/DownloadResultTest.php @@ -0,0 +1,38 @@ +assertSame($statusCode, $result->getStatus()); + $this->assertSame($packageContent, $result->getPackageContent()); + $this->assertSame(strlen($packageContent), $result->getPackageLenght()); + } + + public function testJson(): void + { + $statusCode = new StatusCode(5000, 'Solicitud recibida con éxito'); + $packageContent = 'x-content'; + $result = new DownloadResult($statusCode, $packageContent); + $this->assertInstanceOf(JsonSerializable::class, $result); + $expectedFile = $this->filePath('json/download-result.json'); + $this->assertJsonStringEqualsJsonFile($expectedFile, json_encode($result) ?: ''); + $this->assertSame( + ['status', 'length'], + array_keys($result->jsonSerialize()), + 'jsonSerialize must not include content, only status and length' + ); + } +} diff --git a/tests/Unit/Services/Download/DownloadTranslatorTest.php b/tests/Unit/Services/Download/DownloadTranslatorTest.php index 2f134d2..767ddf2 100644 --- a/tests/Unit/Services/Download/DownloadTranslatorTest.php +++ b/tests/Unit/Services/Download/DownloadTranslatorTest.php @@ -4,9 +4,9 @@ namespace PhpCfdi\SatWsDescargaMasiva\Tests\Unit\Services\Download; +use PhpCfdi\SatWsDescargaMasiva\Internal\Helpers; +use PhpCfdi\SatWsDescargaMasiva\Internal\InteractsXmlTrait; use PhpCfdi\SatWsDescargaMasiva\Services\Download\DownloadTranslator; -use PhpCfdi\SatWsDescargaMasiva\Shared\InteractsXmlTrait; -use PhpCfdi\SatWsDescargaMasiva\Tests\EnvelopSignatureVerifier; use PhpCfdi\SatWsDescargaMasiva\Tests\TestCase; class DownloadTranslatorTest extends TestCase @@ -19,7 +19,7 @@ public function testCreateDownloadResultFromSoapResponseWithPackage(): void $expectedMessage = 'Solicitud Aceptada'; $translator = new DownloadTranslator(); - $responseBody = $translator->nospaces($this->fileContents('download/response-with-package.xml')); + $responseBody = Helpers::nospaces($this->fileContents('download/response-with-package.xml')); $result = $translator->createDownloadResultFromSoapResponse($responseBody); $status = $result->getStatus(); @@ -33,19 +33,14 @@ public function testCreateDownloadResultFromSoapResponseWithPackage(): void public function testCreateSoapRequest(): void { $translator = new DownloadTranslator(); - $fiel = $this->createFielUsingTestingFiles(); + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); - $rfc = 'AAA010101AAA'; $packageId = '4e80345d-917f-40bb-a98f-4a73939343c5_01'; - $requestBody = $translator->createSoapRequestWithData($fiel, $rfc, $packageId); + $requestBody = $translator->createSoapRequest($requestBuilder, $packageId); $this->assertSame( - $this->xmlFormat($translator->nospaces($this->fileContents('download/request.xml'))), + $this->xmlFormat(Helpers::nospaces($this->fileContents('download/request.xml'))), $this->xmlFormat($requestBody) ); - - $xmlSecVerification = (new EnvelopSignatureVerifier()) - ->verify($requestBody, 'http://DescargaMasivaTerceros.sat.gob.mx', 'PeticionDescargaMasivaTercerosEntrada'); - $this->assertTrue($xmlSecVerification, 'The signature cannot be verified using XMLSecLibs'); } } diff --git a/tests/Unit/Services/Query/QueryParametersTest.php b/tests/Unit/Services/Query/QueryParametersTest.php new file mode 100644 index 0000000..1c07e02 --- /dev/null +++ b/tests/Unit/Services/Query/QueryParametersTest.php @@ -0,0 +1,50 @@ +assertSame($period, $query->getPeriod()); + $this->assertSame($downloadType, $query->getDownloadType()); + $this->assertSame($requestType, $query->getRequestType()); + $this->assertSame($rfcMatch, $query->getRfcMatch()); + } + + public function testMinimalCreate(): void + { + $period = DateTimePeriod::create(DateTime::create('2019-01-01 00:00:00'), DateTime::create('2019-01-01 00:04:00')); + $query = QueryParameters::create($period); + $this->assertTrue($query->getRequestType()->isMetadata()); + $this->assertTrue($query->getDownloadType()->isIssued()); + $this->assertEmpty($query->getRfcMatch()); + } + + public function testJson(): void + { + $period = DateTimePeriod::createFromValues('2019-01-01T00:00:00-06:00', '2019-01-01T00:04:00-06:00'); + $downloadType = DownloadType::received(); + $requestType = RequestType::cfdi(); + $rfcMatch = 'AAAA010101AAA'; + $query = QueryParameters::create($period, $downloadType, $requestType, $rfcMatch); + $this->assertInstanceOf(JsonSerializable::class, $query); + $expectedFile = $this->filePath('json/query-parameters.json'); + $this->assertJsonStringEqualsJsonFile($expectedFile, json_encode($query) ?: ''); + } +} diff --git a/tests/Unit/Services/Query/QueryResultTest.php b/tests/Unit/Services/Query/QueryResultTest.php new file mode 100644 index 0000000..74b27fc --- /dev/null +++ b/tests/Unit/Services/Query/QueryResultTest.php @@ -0,0 +1,39 @@ +assertSame($statusCode, $result->getStatus()); + $this->assertSame($requestId, $result->getRequestId()); + } + + public function testEmptyRequestId(): void + { + $requestId = ''; + $result = new QueryResult(new StatusCode(9, 'foo'), $requestId); + $this->assertSame($requestId, $result->getRequestId()); + } + + public function testJson(): void + { + $statusCode = new StatusCode(9, 'foo'); + $requestId = 'x-request-id'; + $result = new QueryResult($statusCode, $requestId); + $this->assertInstanceOf(JsonSerializable::class, $result); + $expectedFile = $this->filePath('json/query-result.json'); + $this->assertJsonStringEqualsJsonFile($expectedFile, json_encode($result) ?: ''); + } +} diff --git a/tests/Unit/Services/Query/QueryTranslatorTest.php b/tests/Unit/Services/Query/QueryTranslatorTest.php index 736277e..552c878 100644 --- a/tests/Unit/Services/Query/QueryTranslatorTest.php +++ b/tests/Unit/Services/Query/QueryTranslatorTest.php @@ -4,11 +4,13 @@ namespace PhpCfdi\SatWsDescargaMasiva\Tests\Unit\Services\Query; +use PhpCfdi\SatWsDescargaMasiva\Internal\Helpers; +use PhpCfdi\SatWsDescargaMasiva\Services\Query\QueryParameters; use PhpCfdi\SatWsDescargaMasiva\Services\Query\QueryTranslator; use PhpCfdi\SatWsDescargaMasiva\Shared\DateTime; +use PhpCfdi\SatWsDescargaMasiva\Shared\DateTimePeriod; use PhpCfdi\SatWsDescargaMasiva\Shared\DownloadType; use PhpCfdi\SatWsDescargaMasiva\Shared\RequestType; -use PhpCfdi\SatWsDescargaMasiva\Tests\EnvelopSignatureVerifier; use PhpCfdi\SatWsDescargaMasiva\Tests\TestCase; class QueryTranslatorTest extends TestCase @@ -20,7 +22,7 @@ public function testCreateQueryResultFromSoapResponse(): void $expectedMessage = 'Solicitud Aceptada'; $translator = new QueryTranslator(); - $responseBody = $translator->nospaces($this->fileContents('query/response-with-id.xml')); + $responseBody = Helpers::nospaces($this->fileContents('query/response-with-id.xml')); $result = $translator->createQueryResultFromSoapResponse($responseBody); $status = $result->getStatus(); @@ -33,23 +35,17 @@ public function testCreateQueryResultFromSoapResponse(): void public function testCreateSoapRequest(): void { $translator = new QueryTranslator(); - $fiel = $this->createFielUsingTestingFiles(); - - $requestBody = $translator->createSoapRequestWithData( - $fiel, - 'aaa010101aaa', // the file was created using rfc in lower case - new DateTime('2019-01-01 00:00:00'), - new DateTime('2019-01-01 00:04:00'), + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); + $query = QueryParameters::create( + DateTimePeriod::create(DateTime::create('2019-01-01 00:00:00'), DateTime::create('2019-01-01 00:04:00')), DownloadType::received(), RequestType::cfdi() ); + + $requestBody = $translator->createSoapRequest($requestBuilder, $query); $this->assertSame( - $this->xmlFormat($translator->nospaces($this->fileContents('query/request.xml'))), + $this->xmlFormat(Helpers::nospaces($this->fileContents('query/request.xml'))), $this->xmlFormat($requestBody) ); - - $xmlSecVerification = (new EnvelopSignatureVerifier()) - ->verify($requestBody, 'http://DescargaMasivaTerceros.sat.gob.mx', 'SolicitaDescarga'); - $this->assertTrue($xmlSecVerification, 'The signature cannot be verified using XMLSecLibs'); } } diff --git a/tests/Unit/Services/Verify/VerifyResultTest.php b/tests/Unit/Services/Verify/VerifyResultTest.php new file mode 100644 index 0000000..1472499 --- /dev/null +++ b/tests/Unit/Services/Verify/VerifyResultTest.php @@ -0,0 +1,43 @@ +assertSame($statusCode, $result->getStatus()); + $this->assertSame($statusRequest, $result->getStatusRequest()); + $this->assertSame($codeRequest, $result->getCodeRequest()); + $this->assertSame($numberCfdis, $result->getNumberCfdis()); + $this->assertSame($packagesIds, $result->getPackagesIds()); + } + + public function testJson(): void + { + $statusCode = new StatusCode(5000, 'Solicitud recibida con éxito'); + $statusRequest = new StatusRequest(3); + $codeRequest = new CodeRequest(5003); + $packagesIds = ['x-package-1', 'x-package-2']; + $numberCfdis = 1000; + $result = new VerifyResult($statusCode, $statusRequest, $codeRequest, $numberCfdis, ...$packagesIds); + $this->assertInstanceOf(JsonSerializable::class, $result); + $expectedFile = $this->filePath('json/verify-result.json'); + $this->assertJsonStringEqualsJsonFile($expectedFile, json_encode($result) ?: ''); + } +} diff --git a/tests/Unit/Services/Verify/VerifyTranslatorTest.php b/tests/Unit/Services/Verify/VerifyTranslatorTest.php index d090c0c..9b7cdf7 100644 --- a/tests/Unit/Services/Verify/VerifyTranslatorTest.php +++ b/tests/Unit/Services/Verify/VerifyTranslatorTest.php @@ -4,8 +4,8 @@ namespace PhpCfdi\SatWsDescargaMasiva\Tests\Unit\Services\Verify; +use PhpCfdi\SatWsDescargaMasiva\Internal\Helpers; use PhpCfdi\SatWsDescargaMasiva\Services\Verify\VerifyTranslator; -use PhpCfdi\SatWsDescargaMasiva\Tests\EnvelopSignatureVerifier; use PhpCfdi\SatWsDescargaMasiva\Tests\TestCase; class VerifyTranslatorTest extends TestCase @@ -20,7 +20,7 @@ public function testCreateVerifyResultFromSoapResponseWithZeroPackages(): void $expectedPackagesIds = []; $translator = new VerifyTranslator(); - $responseBody = $translator->nospaces($this->fileContents('verify/response-0-packages.xml')); + $responseBody = Helpers::nospaces($this->fileContents('verify/response-0-packages.xml')); $result = $translator->createVerifyResultFromSoapResponse($responseBody); $status = $result->getStatus(); $statusRequest = $result->getStatusRequest(); @@ -45,7 +45,7 @@ public function testCreateVerifyResultFromSoapResponseWithTwoPackages(): void ]; $translator = new VerifyTranslator(); - $responseBody = $translator->nospaces($this->fileContents('verify/response-2-packages.xml')); + $responseBody = Helpers::nospaces($this->fileContents('verify/response-2-packages.xml')); $result = $translator->createVerifyResultFromSoapResponse($responseBody); $this->assertEquals($expectedPackagesIds, $result->getPackagesIds()); $this->assertSame(2, $result->countPackages()); @@ -54,19 +54,14 @@ public function testCreateVerifyResultFromSoapResponseWithTwoPackages(): void public function testCreateSoapRequest(): void { $translator = new VerifyTranslator(); - $fiel = $this->createFielUsingTestingFiles(); + $requestBuilder = $this->createFielRequestBuilderUsingTestingFiles(); - $rfc = 'AAA010101AAA'; $requestId = '3f30a4e1-af73-4085-8991-e4d97eef16bd'; - $requestBody = $translator->createSoapRequestWithData($fiel, $rfc, $requestId); + $requestBody = $translator->createSoapRequest($requestBuilder, $requestId); $this->assertSame( - $this->xmlFormat($translator->nospaces($this->fileContents('verify/request.xml'))), + $this->xmlFormat(Helpers::nospaces($this->fileContents('verify/request.xml'))), $this->xmlFormat($requestBody) ); - - $xmlSecVerification = (new EnvelopSignatureVerifier()) - ->verify($requestBody, 'http://DescargaMasivaTerceros.sat.gob.mx', 'VerificaSolicitudDescarga'); - $this->assertTrue($xmlSecVerification, 'The signature cannot be verified using XMLSecLibs'); } } diff --git a/tests/Unit/Shared/DateTimePeriodTest.php b/tests/Unit/Shared/DateTimePeriodTest.php index 7e4f97a..6dc2192 100644 --- a/tests/Unit/Shared/DateTimePeriodTest.php +++ b/tests/Unit/Shared/DateTimePeriodTest.php @@ -13,21 +13,30 @@ class DateTimePeriodTest extends TestCase { public function testCreateWithCorrectStartDateTimeAndEndDateTime(): void { - $start = new DateTime('2019-01-01 00:00:59'); - $end = new DateTime('2019-01-01 00:01:00'); + $start = DateTime::create('2019-01-01 00:00:59'); + $end = DateTime::create('2019-01-01 00:01:00'); - $dateTimePeriod = new DateTimePeriod($start, $end); + $dateTimePeriod = DateTimePeriod::create($start, $end); $this->assertTrue($start->equalsTo($dateTimePeriod->getStart())); $this->assertTrue($end->equalsTo($dateTimePeriod->getEnd())); } + public function testCreateWithStringValues(): void + { + $startValue = '2019-01-01 00:00:59'; + $endValue = '2019-01-01 00:01:00'; + $dateTimePeriod = DateTimePeriod::createFromValues($startValue, $endValue); + $this->assertTrue(DateTime::create($startValue)->equalsTo($dateTimePeriod->getStart())); + $this->assertTrue(DateTime::create($endValue)->equalsTo($dateTimePeriod->getEnd())); + } + public function testCreateWithEndDateTimeLessThanStartDateTime(): void { - $start = new DateTime('2019-01-01 00:00:59'); - $end = new DateTime('2019-01-01 00:00:55'); + $start = DateTime::create('2019-01-01 00:00:59'); + $end = DateTime::create('2019-01-01 00:00:55'); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The final date must be greater than the initial date'); - new DateTimePeriod($start, $end); + DateTimePeriod::create($start, $end); } } diff --git a/tests/Unit/Shared/DateTimeTest.php b/tests/Unit/Shared/DateTimeTest.php index 876f934..26710b1 100644 --- a/tests/Unit/Shared/DateTimeTest.php +++ b/tests/Unit/Shared/DateTimeTest.php @@ -10,10 +10,30 @@ class DateTimeTest extends TestCase { + /** @var string */ + private $backupTimeZone; + + protected function setUp(): void + { + parent::setUp(); + $this->backupTimeZone = date_default_timezone_get(); + if (! date_default_timezone_set('America/Mexico_City')) { + trigger_error('Unable to setup time zone to America/Mexico_City', E_USER_ERROR); + } + } + + protected function tearDown(): void + { + if (! date_default_timezone_set($this->backupTimeZone)) { + trigger_error("Unable to restore time zone to $this->backupTimeZone", E_USER_ERROR); + } + parent::tearDown(); + } + public function testCreateUsingTimeZoneZulu(): void { // remember that per bootstrap default time zone is America/Mexico_City - $date = new DateTime('2019-01-14T04:23:24.000Z'); + $date = DateTime::create('2019-01-14T04:23:24.000Z'); $this->assertSame('2019-01-14T04:23:24.000Z', $date->formatSat()); $this->assertSame('2019-01-13T22:23:24.000CST', $date->formatDefaultTimeZone()); } @@ -21,7 +41,7 @@ public function testCreateUsingTimeZoneZulu(): void public function testCreateWithoutTimeZone(): void { // remember that per bootstrap default time zone is America/Mexico_City - $date = new DateTime('2019-01-13 22:23:24'); // as it does not include time zone is created as default + $date = DateTime::create('2019-01-13 22:23:24'); // as it does not include time zone is created as default $this->assertSame('2019-01-14T04:23:24.000Z', $date->formatSat()); $this->assertSame('2019-01-13T22:23:24.000CST', $date->formatDefaultTimeZone()); } @@ -29,14 +49,14 @@ public function testCreateWithoutTimeZone(): void public function testFormatSatUsesZuluTimeZone(): void { // remember that per bootstrap default time zone is America/Mexico_City - $date = new DateTime('2019-01-13 22:23:24'); // as it does not include time zone is created as default + $date = DateTime::create('2019-01-13 22:23:24'); // as it does not include time zone is created as default $this->assertSame('2019-01-14T04:23:24.000Z', $date->formatSat()); $this->assertSame('2019-01-13T22:23:24.000CST', $date->formatDefaultTimeZone()); } public function testCreateDateTimeWithTimestamp(): void { - $date = new DateTime(316569600); + $date = DateTime::create(316569600); $this->assertSame('1980-01-13T00:00:00.000Z', $date->formatSat()); } @@ -44,13 +64,15 @@ public function testCreateDateTimeWithInvalidStringValue(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Unable to create a Datetime("foo")'); - new DateTime('foo'); + DateTime::create('foo'); } public function testCreateDateTimeWithInvalidArgument(): void { + /** @var int $knownInvalidInput */ + $knownInvalidInput = []; $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Unable to create a Datetime'); - new DateTime([]); + DateTime::create($knownInvalidInput); } } diff --git a/tests/Unit/Shared/InteractsXmlTraitSpecimen.php b/tests/Unit/Shared/InteractsXmlTraitSpecimen.php deleted file mode 100644 index 75060bd..0000000 --- a/tests/Unit/Shared/InteractsXmlTraitSpecimen.php +++ /dev/null @@ -1,12 +0,0 @@ -modify('- 1 second'); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cannot create a token with expiration lower than creation'); @@ -22,7 +22,7 @@ public function testCreateTokenWithInvalidDates(): void public function testTokenNotExpired(): void { - $created = new DateTime(); + $created = DateTime::create(); $expires = $created->modify('+ 5 seconds'); $token = new Token($created, $expires, ''); $this->assertFalse($token->isExpired()); @@ -30,7 +30,7 @@ public function testTokenNotExpired(): void public function testTokenExpired(): void { - $created = new DateTime('- 10 seconds'); + $created = DateTime::create('- 10 seconds'); $expires = $created->modify('+ 5 seconds'); $token = new Token($created, $expires, ''); $this->assertTrue($token->isExpired()); @@ -38,7 +38,7 @@ public function testTokenExpired(): void public function testValueNotEmpty(): void { - $created = new DateTime('- 10 seconds'); + $created = DateTime::create('- 10 seconds'); $expires = $created->modify('+ 5 seconds'); $token = new Token($created, $expires, ''); $this->assertTrue($token->isValueEmpty()); @@ -46,7 +46,7 @@ public function testValueNotEmpty(): void public function testValueIsNotEmpty(): void { - $created = new DateTime('- 10 seconds'); + $created = DateTime::create('- 10 seconds'); $expires = $created->modify('+ 5 seconds'); $token = new Token($created, $expires, 'foo'); $this->assertFalse($token->isValueEmpty()); @@ -64,7 +64,16 @@ public function testValueIsNotEmpty(): void */ public function testIsValid(string $created, string $expires, string $value, bool $expected): void { - $token = new Token(new DateTime($created), new DateTime($expires), $value); + $token = new Token(DateTime::create($created), DateTime::create($expires), $value); $this->assertSame($expected, $token->isValid()); } + + public function testJson(): void + { + $created = DateTime::create('2020-01-13T14:15:16-0600'); + $expires = $created->modify('+ 5 seconds'); + $token = new Token($created, $expires, 'x-value'); + $expectedFile = $this->filePath('json/token.json'); + $this->assertJsonStringEqualsJsonFile($expectedFile, json_encode($token) ?: ''); + } } diff --git a/tests/Unit/WebClient/Exceptions/HttpClientErrorTest.php b/tests/Unit/WebClient/Exceptions/HttpClientErrorTest.php new file mode 100644 index 0000000..fa48eee --- /dev/null +++ b/tests/Unit/WebClient/Exceptions/HttpClientErrorTest.php @@ -0,0 +1,24 @@ +assertInstanceOf(WebClientException::class, $exception); + } +} diff --git a/tests/Unit/WebClient/Exceptions/HttpServerErrorTest.php b/tests/Unit/WebClient/Exceptions/HttpServerErrorTest.php new file mode 100644 index 0000000..9c209c7 --- /dev/null +++ b/tests/Unit/WebClient/Exceptions/HttpServerErrorTest.php @@ -0,0 +1,24 @@ +assertInstanceOf(WebClientException::class, $exception); + } +} diff --git a/tests/Unit/WebClient/Exceptions/SoapFaultErrorTest.php b/tests/Unit/WebClient/Exceptions/SoapFaultErrorTest.php new file mode 100644 index 0000000..eaea9ab --- /dev/null +++ b/tests/Unit/WebClient/Exceptions/SoapFaultErrorTest.php @@ -0,0 +1,27 @@ +assertInstanceOf(HttpClientError::class, $exception); + $this->assertSame($fault, $exception->getFault()); + } +} diff --git a/tests/Unit/WebClient/Exceptions/WebClientExceptionTest.php b/tests/Unit/WebClient/Exceptions/WebClientExceptionTest.php new file mode 100644 index 0000000..d8f126c --- /dev/null +++ b/tests/Unit/WebClient/Exceptions/WebClientExceptionTest.php @@ -0,0 +1,27 @@ +assertSame($message, $exception->getMessage()); + $this->assertSame($request, $exception->getRequest()); + $this->assertSame($response, $exception->getResponse()); + $this->assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/Unit/WebClient/GuzzleWebClientTest.php b/tests/Unit/WebClient/GuzzleWebClientTest.php new file mode 100644 index 0000000..4eaf1e6 --- /dev/null +++ b/tests/Unit/WebClient/GuzzleWebClientTest.php @@ -0,0 +1,58 @@ +call($request); + } catch (WebClientException $catched) { + $exception = $catched; + } + $this->assertFalse(isset($response), '$response should not be defined'); + if (null === $exception) { + $this->fail('Exception was not catched'); + return; + } + $this->assertInstanceOf(WebClientException::class, $exception); + $this->assertSame($request, $exception->getRequest()); + } + + public function testFireRequest(): void + { + $captured = null; + $observer = function (Request $request) use (&$captured): void { + $captured = $request; + }; + $request = new Request('GET', 'unknown://invalid uri/', '', []); + $webClient = new GuzzleWebClient(null, $observer); + $webClient->fireRequest($request); + $this->assertSame($request, $captured); + } + + public function testFireResponse(): void + { + $captured = null; + $observer = function (Response $request) use (&$captured): void { + $captured = $request; + }; + $response = new Response(200, '', []); + $webClient = new GuzzleWebClient(null, null, $observer); + $webClient->fireResponse($response); + $this->assertSame($response, $captured); + } +} diff --git a/tests/Unit/WebClient/RequestTest.php b/tests/Unit/WebClient/RequestTest.php new file mode 100644 index 0000000..bf20882 --- /dev/null +++ b/tests/Unit/WebClient/RequestTest.php @@ -0,0 +1,42 @@ + 'content']; + + $request = new Request($method, $uri, $body, $customHeaders); + $headers = array_merge($request->defaultHeaders(), $customHeaders); + + $this->assertSame($method, $request->getMethod()); + $this->assertSame($uri, $request->getUri()); + $this->assertSame($body, $request->getBody()); + $this->assertSame($headers, $request->getHeaders()); + } + + public function testJson(): void + { + $method = 'POST'; + $uri = 'http://localhost'; + $body = 'this is the body'; + $customHeaders = ['X-Header' => 'content']; + + $request = new Request($method, $uri, $body, $customHeaders); + + $this->assertInstanceOf(JsonSerializable::class, $request); + $expectedFile = $this->filePath('json/webclient-request.json'); + $this->assertJsonStringEqualsJsonFile($expectedFile, json_encode($request) ?: ''); + } +} diff --git a/tests/Unit/WebClient/ResponseTest.php b/tests/Unit/WebClient/ResponseTest.php new file mode 100644 index 0000000..8d7f317 --- /dev/null +++ b/tests/Unit/WebClient/ResponseTest.php @@ -0,0 +1,77 @@ + 'content']; + $response = new Response($statusCode, $body, $headers); + $this->assertSame($statusCode, $response->getStatusCode()); + $this->assertSame($body, $response->getBody()); + $this->assertSame($headers, $response->getHeaders()); + $this->assertFalse($response->isEmpty()); + } + + public function testResponseWithEmptyContent(): void + { + $response = new Response(200, '', []); + $this->assertEmpty($response->getBody()); + $this->assertTrue($response->isEmpty()); + } + + /** + * @param int $code + * @param bool $expected + * @testWith [200, false] + * [399, false] + * [400, true] + * [499, true] + * [500, false] + */ + public function testStatusCodeIsClientError(int $code, bool $expected): void + { + $response = new Response($code, '', []); + $this->assertSame($expected, $response->statusCodeIsClientError()); + } + + /** + * @param int $code + * @param bool $expected + * @testWith [200, false] + * [399, false] + * [400, false] + * [499, false] + * [500, true] + * [599, true] + * [600, false] + */ + public function testStatusCodeIsServerError(int $code, bool $expected): void + { + $response = new Response($code, '', []); + $this->assertSame($expected, $response->statusCodeIsServerError()); + } + + public function testJson(): void + { + $statusCode = 200; + $body = 'this is the body'; + $headers = [ + 'X-First' => 'first header', + 'X-Second' => 'second header', + ]; + $response = new Response($statusCode, $body, $headers); + $this->assertInstanceOf(JsonSerializable::class, $response); + $expectedFile = $this->filePath('json/webclient-response.json'); + $this->assertJsonStringEqualsJsonFile($expectedFile, json_encode($response) ?: ''); + } +} diff --git a/tests/Unit/WebClient/SoapFaultInfoTest.php b/tests/Unit/WebClient/SoapFaultInfoTest.php new file mode 100644 index 0000000..287e1db --- /dev/null +++ b/tests/Unit/WebClient/SoapFaultInfoTest.php @@ -0,0 +1,22 @@ +assertSame($code, $fault->getCode()); + $this->assertSame($message, $fault->getMessage()); + $this->assertSame($message, (string) $fault); + $this->assertSame(compact('code', 'message'), json_decode(json_encode($fault) ?: '', true)); + } +} diff --git a/tests/_files/download/request.xml b/tests/_files/download/request.xml index b17a401..12d6e44 100644 --- a/tests/_files/download/request.xml +++ b/tests/_files/download/request.xml @@ -3,7 +3,7 @@ - + @@ -13,10 +13,10 @@ - UoCBjlNVOxn045eLZTe3vfjnnoU= + f0UuGyvdRtYmUtV7HA3c6lVwhdQ= - V12bEJ8JJk739KHFJSBORG+EAJ20Sklur3DZb7SBCCt6g5PO1aDxrU1vg5ogXd/tKRhpACPA8f1FUfZegui2tXpRbG3EHnSdeJ9OVTQZF/DUvX+rQezA2X+uf4LuBYqdeAZKI2H8EuD9k4epJSqE8ZMvTTZafvFgosnZEcB+JDRpBl4WJHb4QC+3ZxVm0HJg2WqfjBlTAkwUGEqNPgMeDfdYAJ9li8xXQDJiAgKEF3Kax/lZ1oRnaD/OJQP5Lsi4KJBr5EzSsClxrmr0LOirC4w0GeJLPLCZ3I9aSgoJACEZ1dhGtEjsysvCy7+qdQ0a4FPgKa9L431Grf87XNyxaA== + TAQtqEfbGM1Vv1KjkdY7ewPCTJ2yBsmqXpcwpl65S+SFqwS9Oz171suCH2cJhv+Ia8Mc0iKcc68HDZWDNI0pJdd7nYCuRlY9jBa0x9U/fssxj4TJTzLqF3jzQLqYeY7kkXE3+bBqW6h9JVoL7pHuDTBABBWGlNGLVx6q+4w9Vw6DbmlfrSfHwaWVRlZuYOMjn0t2Gs4krfh5a9D8b9NPJHPW4VxYLH6NNL1W6nwkNrTgJRJp+ta1OmzwjCjaZiUPLZEhCP1qxfVibPNOUSUCzutGES8oznXmYgaAGB9NzopSA3Qhgxk8sJEb5LjhirwIO8uDA8dFNdZmiy1cdPbkmQ== diff --git a/tests/_files/json/download-result.json b/tests/_files/json/download-result.json new file mode 100644 index 0000000..6334eb5 --- /dev/null +++ b/tests/_files/json/download-result.json @@ -0,0 +1,7 @@ +{ + "status": { + "code": 5000, + "message": "Solicitud recibida con \u00e9xito" + }, + "length": 9 +} diff --git a/tests/_files/json/query-parameters.json b/tests/_files/json/query-parameters.json new file mode 100644 index 0000000..7341c08 --- /dev/null +++ b/tests/_files/json/query-parameters.json @@ -0,0 +1,9 @@ +{ + "period": { + "start": 1546322400, + "end": 1546322640 + }, + "downloadType": "RfcReceptor", + "requestType": "CFDI", + "rfcMatch": "AAAA010101AAA" +} diff --git a/tests/_files/json/query-result.json b/tests/_files/json/query-result.json new file mode 100644 index 0000000..c1f997b --- /dev/null +++ b/tests/_files/json/query-result.json @@ -0,0 +1,7 @@ +{ + "status": { + "code": 9, + "message": "foo" + }, + "requestId": "x-request-id" +} diff --git a/tests/_files/json/token.json b/tests/_files/json/token.json new file mode 100644 index 0000000..3f025b1 --- /dev/null +++ b/tests/_files/json/token.json @@ -0,0 +1,5 @@ +{ + "created": 1578946516, + "expires": 1578946521, + "value": "x-value" +} diff --git a/tests/_files/json/verify-result.json b/tests/_files/json/verify-result.json new file mode 100644 index 0000000..33a87b0 --- /dev/null +++ b/tests/_files/json/verify-result.json @@ -0,0 +1,19 @@ +{ + "status": { + "code": 5000, + "message": "Solicitud recibida con éxito" + }, + "codeRequest": { + "value": 5003, + "message": "Tope máximo: Indica que se está superando el tope máximo de CFDI o Metadata" + }, + "statusRequest": { + "value": 3, + "message": "Terminada" + }, + "numberCfdis": 1000, + "packagesIds": [ + "x-package-1", + "x-package-2" + ] +} diff --git a/tests/_files/json/webclient-request.json b/tests/_files/json/webclient-request.json new file mode 100644 index 0000000..67f8a74 --- /dev/null +++ b/tests/_files/json/webclient-request.json @@ -0,0 +1,11 @@ +{ + "method": "POST", + "uri": "http://localhost", + "body": "this is the body", + "headers": { + "Content-type": "text/xml; charset=\"utf-8\"", + "Accept": "text/xml", + "Cache-Control": "no-cache", + "X-Header": "content" + } +} diff --git a/tests/_files/json/webclient-response.json b/tests/_files/json/webclient-response.json new file mode 100644 index 0000000..618e023 --- /dev/null +++ b/tests/_files/json/webclient-response.json @@ -0,0 +1,8 @@ +{ + "statusCode": 200, + "body": "this is the body", + "headers": { + "X-First": "first header", + "X-Second": "second header" + } +} diff --git a/tests/_files/query/request.xml b/tests/_files/query/request.xml index 960c84b..0bd40c9 100644 --- a/tests/_files/query/request.xml +++ b/tests/_files/query/request.xml @@ -3,7 +3,7 @@ - + @@ -13,10 +13,10 @@ - wqN4k7fa+oWyH7o1nUcKoPJdu8g= + kJHoUsUmsDAYPRuTOumOjXan01Y= - J7/SE6DgDclKmq09Ov4lTY/+ZuatYxUslj3LaBRbsuV7B6rinOYo+nFQDs2vwbgXTuXBtGcnUYdNqo+YYCTZEOmFe+vWnjHF4hHvAr2IowltBSlxOg68AyanOYAuRJ9YZgqdqBRcvgkiwwrWWclf0gpQQx3tvvWTTNigGjm3xRLtsLnuPT8ZJzasGatz9aa83ep/z3A91ZPqqY/FWiuvq4TZEHD7WpF7YHZl5MFsnUNFENbMyacujBIcyDUUojt5vs4DgS67+AFpsCyHlA7zUO99yiCEXdM8/mOqIX9E0aMEJJsXGK8lDIFW592tKCQMWHlvQB+zzC5MU7CestjZ3g== + PqoLaAsWXey9PiImjWCEYC1PoXyfrlErEK5bF9yyO5d08PVMi2UMXODkzpOGSPz8RiYxnSJBs2M+NTMNl/VLxn+A01Oj1cdvZ1oV249XAZrI981CgYhVsG70W7XYD4Z4VCZmgYivymneBR/GLKaZpKVNm0MQMtsBcycBCx+XDF3e9DIxHFNS8zrgBkoW8otfPUX2fC4DVwonMdxcDq+3mEvyGlwehxjUsxfoOxKwNS6WCPksn3j7J35BUZO9kvx4d5wLXVt0983LqMU8ajQyNcmn4546XrJIAJ9unmQkd1xwqGCATXTkdvNvs+RG5ydriK0OxyGZKTyrr+eDML6aPg== diff --git a/tests/_files/verify/request.xml b/tests/_files/verify/request.xml index ed8114a..bc13086 100644 --- a/tests/_files/verify/request.xml +++ b/tests/_files/verify/request.xml @@ -3,7 +3,7 @@ - + @@ -13,10 +13,10 @@ - NrEtpjqrh86K5/IZmdvXPkP3nuw= + DVHsbfyUP1fm7CPdctOp8N6132E= - ZzdIbHvrXUfPfCTubJKZgckfRZtMf39148tC4mjU4DXi1OpFWysf7JaGmli1JxY05SXKyOYRrtNpmyDHSNTuNn4iS6yfV5VZl1bJ685o9p8LWeYmdlWKzzx/2zzd0spaYYiiO6yk0aB5l27qQwJCBT4Z+waGe8EJOkjjZP+GzUSOc/pzsptpFk94V4eMK4HgBKyoN74txSHwMrGJjKDKUJeWn5x1unPkU+uzi0sxl2iozIzQUSsE4Swvt98Kwhruu6TA31FT1Xq6UTEz6muVoO9zKYaYpAe+ww98whdUMGZFyencR1nh7O7HFL/w3FK0b31vOVoA0HMa0L97zcn4ZA== + CriIWteGBGqF9hsO2EWi9MUK+OFZNSA3FbbuqvbiEU8jL0lY2Vfn2rTBiV8r13bQPjmLEO6WGLedtOAzPulxI2XMq/pRhpohuXSZF4bUA3nJmALb1T1X8LMAB2bllBK5CheBELKHJLw4MxIs32Ui333eqCl0+0p1gDrEE6Jcir9fMTM4wC2b9Wzgim7JMfLKZtq/AfeFPkQh15cvS13goIVZ+rxhpeCU25JlcFMyD1D5IMwjIOcd3I9LCx/a6ZNCQNYwe5EOzuRhYa+tosPB01bCzYAkHyuF0Bh7ajJ5EN1UnBQG9Akh3j7LLXEAOOFeoOuWfhDHvoenzwsBnwVb4A== diff --git a/tests/_files/zip/metadata.json b/tests/_files/zip/metadata.json new file mode 100644 index 0000000..9242336 --- /dev/null +++ b/tests/_files/zip/metadata.json @@ -0,0 +1,30 @@ +{ + "E7215E3B-2DC5-4A40-AB10-C902FF9258DF": { + "uuid": "E7215E3B-2DC5-4A40-AB10-C902FF9258DF", + "rfcEmisor": "XAXX010101000", + "nombreEmisor": "RAZON SOCIAL 1", + "rfcReceptor": "XAXX010101001", + "nombreReceptor": "RAZON SOCIAL 1 SA DE CV", + "rfcPac": "XAXX010101003", + "fechaEmision": "2018-01-11 07:10:23", + "fechaCertificacionSat": "2018-01-11 07:10:51", + "monto": "1124.11", + "efectoComprobante": "I", + "estatus": "1", + "fechaCancelacion": "" + }, + "129C4D12-1415-4ACE-BE12-34E71C4EAB4E": { + "uuid": "129C4D12-1415-4ACE-BE12-34E71C4EAB4E", + "rfcEmisor": "XAXX010101000", + "nombreEmisor": "RAZON SOCIAL 2", + "rfcReceptor": "XAXX010101002", + "nombreReceptor": "RAZON SOCIAL 2 SA DE CV", + "rfcPac": "XAXX010101004", + "fechaEmision": "2018-01-11 10:36:24", + "fechaCertificacionSat": "2018-01-11 10:36:52", + "monto": "1485.17", + "efectoComprobante": "I", + "estatus": "1", + "fechaCancelacion": "" + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 745e50f..af4ba64 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,8 +5,5 @@ // report all errors error_reporting(-1); -// set a timezone different from UTC -date_default_timezone_set('America/Mexico_City'); - // require composer autoloader require_once __DIR__ . '/../vendor/autoload.php';