diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f42e7..932a478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to this project will be documented in this file. ### Fixed +- **`consultar()->eventos()` exigia um campo que a rota não devolve — nenhuma consulta de evento existente podia ter sucesso.** O método pedia `eventoXmlGZipB64` no topo da resposta, que é o que `EventosPostResponseSucesso` (SefinNacional-swagger.json) declara e o que o próprio swagger aponta como resposta 200 do `GET /nfse/{chave}/eventos/{tipoEvento}/{nSequencial}`. O `SefinNacional_1.6.0` devolve outra coisa: uma lista `eventos[]`, com o XML em `eventos[].arquivoXml` e uma segunda camada de base64 sobre o gzip. Só o caminho da ausência funcionava — evento inexistente responde 404 e vira `EVENT_NOT_FOUND`; evento presente responde 200, não trazia o campo exigido e virava `IndeterminateResultException`. Quanto mais bem-sucedida a operação no SEFIN, pior a leitura do SDK: a reconciliação de um cancelamento registrado concluía "não foi possível confirmar", e uma nota cancelada por análise fiscal seguia autorizada no banco do consumidor. A suíte não via nada porque os fixtures foram construídos a partir do schema do swagger, não de tráfego real. As duas formas passam a ser aceitas — `eventos[]` primeiro, `eventoXmlGZipB64` de topo depois —, porque `storage/prefeituras.json` tem prefeituras com implementação própria que podem cumprir o contrato declarado. `EventsResponse` ganha `eventos`, uma lista de `EventoConsultado` com `chaveAcesso`, `tipoEvento`, `numeroPedidoRegistroEvento`, `dataHoraRecebimento`, `xml` e `parseError`; `xml` continua sendo o XML do primeiro item. Um evento ilegível não derruba a lista: os campos afetados vêm `null` e `parseError` diz o que faltou, como o `DocumentoFiscal` já fazia no lote de distribuição. + +- **`IndeterminateResultException` descartava a resposta que a motivou — a recusa era correta e indiagnosticável.** O `statusCode` chegava às fábricas, entrava na mensagem formatada e não virava dado; o corpo se perdia por completo. Do lado do consumidor a exceção rendia duas linhas de log sem nada sobre o que a SEFIN mandou, e diagnosticar o defeito acima exigiu escrever um script, subir no servidor de produção e refazer a chamada com o certificado — por fora do SDK. A exceção passa a carregar `statusCode`, `body` (truncado em 8 KiB) e `raw` (o JSON decodificado, sem truncar), preenchidos com o que cada ponto de falha tem em escopo. `NfseFailed` ganha `throwable`, para que o listener alcance a mesma evidência sem depender do `catch` do chamador — antes ele recebia `$throwable->getMessage()`, a mesma string da exceção. + +### Changed + +- **`GzipCompressor::decompressB64()` descasca uma segunda camada de base64 quando ela existe.** Condicionado ao magic gzip e limitado a duas camadas: se o primeiro decode não revelar `1f 8b`, tenta mais um e fica com o resultado apenas se ele for gzip. É o formato de `eventos[].arquivoXml` no `SefinNacional_1.6.0`, e cobre o `ArquivoXml` da distribuição caso o ADN faça o mesmo — hipótese ainda não medida. + +- **`ExecutesNfseRequests::executeRaw()` aceita campos obrigatórios alternativos**, declarados pela forma que a operação lê: `$requiredStrings` (texto não-vazio) e `$requiredLists` (lista não-vazia). Campo presente é o que veio **na forma exigida** — um `chaveAcesso` que chegue como lista continua sendo resposta fora do contrato, e não um campo presente que estouraria `TypeError` no construtor tipado logo adiante. Porta interna, marcada `@internal`. + - **Rejeição cujo `erro` singular vem como lista chegava ao consumidor sem código nem mensagem.** `ResponseErro` (SefinNacional-swagger.json) declara `erro` como uma `MensagemProcessamento`, mas a SEFIN também o envia como lista de mensagens — confirmado em produção com `SefinNacional_1.6.0`, numa rejeição `E0822` ("O prazo para o cancelamento da NFS-e expirou") de cancelamento. `extractErrors()` embrulhava o valor sem distinguir as duas formas, então a lista virava uma mensagem cuja única chave é o inteiro `0`; `fromArray()` procurava `codigo`/`mensagem`/`descricao`, não achava nada, e o evento `NfseRejected` saía com `UNKNOWN` e `null`. A classificação nunca esteve errada — `hasApiError()` devolvia `true`, a operação foi corretamente tratada como rejeitada —, o que se perdia era justamente o conteúdo que decide o próximo passo do consumidor. Mapa e lista passam pelo mesmo filtro de `onlyMessages()`, então as tolerâncias seguem valendo: `{"erro": "Bad Gateway"}` de proxy continua fora da classificação, e item não-mensagem no meio da lista continua sendo descartado sem levar os erros reais junto. - **`{"erros":[{}]}` classificava como rejeição sem conteúdo, enquanto `{"erro":{}}` não.** A assimetria vinha de `extractErrors()`: o singular descartava mensagem vazia, o plural só filtrava o que não fosse array. O consumidor recebia uma rejeição definitiva sem código nem texto — indistinguível, para ele, do defeito acima. Item vazio agora sai nos dois caminhos; sem mensagem alguma o status HTTP volta a ser a informação definitiva, e ele chega por `IndeterminateResultException` (POST 5xx) ou `HttpException`, ambas com o corpo íntegro. diff --git a/README.md b/README.md index fd20b2f..c9faf8d 100644 --- a/README.md +++ b/README.md @@ -318,11 +318,23 @@ $response = $client->consultar()->eventos( // BloqueioPorOficio — 305102 DesbloqueioPorOficio — 305103 // InclusaoNfseDan — 467201 TributosNfseRecolhidos — 907201 // +// No sucesso, $response->eventos traz um EventoConsultado por item devolvido e +// $response->xml repete o XML do primeiro: +foreach ($response->eventos as $evento) { + $evento->tipoEvento; // TipoEvento|null + $evento->numeroPedidoRegistroEvento; // int|null + $evento->dataHoraRecebimento; // string|null + $evento->xml; // string|null, já descomprimido + $evento->parseError; // string|null — por que o item não pôde ser lido +} +// // Quando a SEFIN responde 404 (evento inexistente), a resposta traz um erro // dedicado: $response->erros[0]->codigo === EventsResponse::EVENT_NOT_FOUND. // Sinal inequívoco de "não existe" — qualquer outro sucesso: false é -// inconclusivo. Um 2xx sem `eventoXmlGZipB64` não ocorre em operação normal e -// lança IndeterminateResultException (nunca vira sucesso com xml: null). +// inconclusivo. Um 2xx que não traga nem a lista `eventos` nem o +// `eventoXmlGZipB64` de topo lança IndeterminateResultException (nunca vira +// sucesso silencioso). Já um evento cujo XML não pôde ser lido mantém +// sucesso: true, com xml: null e o motivo em $evento->parseError. // Situação do cancelamento por análise fiscal (uma chamada ao ADN, resume os // eventos 101103/105104/105105 da nota) @@ -507,7 +519,7 @@ O pacote dispara eventos Laravel que podem ser escutados na sua aplicação: | `NfseQueried` | `operacao` | Consulta realizada | | `NfseRequested` | `operacao`, `metadata` | Operação iniciada | | `NfseRejected` | `operacao`, `codigoErro`, `mensagemErro`, `correcao` | Operação rejeitada pela API (`mensagemErro` e `correcao` ficam `null` quando a API não retorna os campos correspondentes ou no fallback `SEM_CHAVE`) | -| `NfseFailed` | `operacao`, `mensagem` | Falha na operação | +| `NfseFailed` | `operacao`, `mensagem`, `throwable` | Falha na operação | **Substituição:** como `substituir` delega ao `emitir` internamente, a sequência de eventos disparados é: `NfseRequested('emitir')` → `NfseEmitted` → `NfseSubstituted` @@ -562,17 +574,34 @@ Retornado por `consultar()->eventos()`. | Propriedade | Tipo | Descricao | |-------------|------|-----------| | `sucesso` | `bool` | Se a consulta teve sucesso | -| `xml` | `?string` | XML do evento (nunca `null` quando `sucesso` é `true`) | +| `xml` | `?string` | XML do primeiro evento de `eventos`, já descomprimido | | `erros` | `list` | Erros de processamento | | `tipoAmbiente` | `?int` | 1 = Produção, 2 = Homologação | | `versaoAplicativo` | `?string` | Versão do aplicativo da SEFIN | | `dataHoraProcessamento` | `?string` | Data/hora do processamento | | `raw` | `?array` | Corpo JSON decodificado, como a SEFIN o devolveu — ver [Corpo bruto](#corpo-bruto) | +| `eventos` | `list` | Os eventos devolvidos para o par `(tipoEvento, nSequencial)` | Constante `EventsResponse::EVENT_NOT_FOUND`: presente em `erros[0]->codigo` quando a SEFIN responde 404 — o evento comprovadamente não existe (distinto de erro transitório, que permanece `sucesso: false` sem esse código). +### `EventoConsultado` + +Um item de `EventsResponse::$eventos`. + +| Propriedade | Tipo | Descricao | +|-------------|------|-----------| +| `chaveAcesso` | `?string` | Chave de acesso da NFS-e a que o evento se vincula | +| `tipoEvento` | `?TipoEvento` | `null` quando o código não consta no enum | +| `numeroPedidoRegistroEvento` | `?int` | Sequencial do pedido de registro | +| `dataHoraRecebimento` | `?string` | Quando a SEFIN recebeu o evento | +| `xml` | `?string` | XML do evento, já descomprimido | +| `parseError` | `?string` | Por que o evento não pôde ser interpretado por completo; `null` quando íntegro | + +Um item que não pôde ser lido por completo não interrompe a resposta: os campos +afetados vêm `null` e `parseError` diz o que faltou. + ### `DistribuicaoResponse` Retornado por `distribuicao()->documentos()`, `distribuicao()->documento()` e `distribuicao()->eventos()`. @@ -692,11 +721,11 @@ cobre cinco situações: confirmou o processamento, mas o resultado não pôde ser interpretado; 4. **Resposta com JSON válido porém sem o campo obrigatório da operação** — um 2xx de `consultar()->nfse()` sem `nfseXmlGZipB64`, de - `consultar()->eventos()` sem `eventoXmlGZipB64` ou de `consultar()->dps()` - sem `chaveAcesso`, ou a resposta ao POST do evento em `cancelar()` sem - rejeição estruturada nem o recibo `eventoXmlGZipB64`, qualquer que seja o - status — shape que não ocorre em operação normal; ausência comprovada é - sinalizada por HTTP 404, nunca por corpo vazio; + `consultar()->eventos()` sem `eventos` nem `eventoXmlGZipB64`, ou de + `consultar()->dps()` sem `chaveAcesso`, ou a resposta ao POST do evento em + `cancelar()` sem rejeição estruturada nem o recibo `eventoXmlGZipB64`, + qualquer que seja o status — shape que não ocorre em operação normal; + ausência comprovada é sinalizada por HTTP 404, nunca por corpo vazio; 5. **Resposta 5xx a uma operação que altera estado** (`emitir`, `cancelar`, `substituir`) **sem rejeição estruturada da SEFIN no corpo** — o erro pode ter vindo de um proxy antes da SEFIN, ou da @@ -709,6 +738,21 @@ cobre cinco situações: > de JSON ali é a resposta correta e não estado indeterminado — `distribuicao()` > devolve `sucesso: false` com o código `EMPTY_RESPONSE`. +**A exceção carrega a resposta que a motivou**, para que o diagnóstico saia do log +em vez de exigir reproduzir a chamada: + +| Propriedade | Tipo | Descricao | +|-------------|------|-----------| +| `phase` | `?string` | Fase da falha de transporte (`dns`, `connect`, `tls`, `transfer`, `read`, `body`), quando detectável | +| `statusCode` | `?int` | Status HTTP, quando a resposta chegou | +| `body` | `?string` | Corpo cru, truncado em 8 KiB | +| `raw` | `?array` | Corpo JSON decodificado, sem truncar | + +Preenche-se o que o ponto da falha tinha em mãos: `body`/`statusCode` ficam `null` +nas rotas em que o SDK só recebe o JSON já decodificado, e `raw` fica `null` quando +não houve JSON legível (é justamente a causa) ou quando a falha antecedeu a +resposta. O mesmo objeto chega ao listener em `NfseFailed::$throwable`. + Nos cinco casos a ação é a mesma: **nunca faça retry cego de emissão** (a NFS-e pode já existir e um retry causaria dupla emissão). Calcule o ID com `DpsId::generate()` e consulte `consultar()->dps($id)`: se encontrou, a nota diff --git a/docs/superpowers/specs/2026-08-04-consulta-eventos-envelope-real-design.md b/docs/superpowers/specs/2026-08-04-consulta-eventos-envelope-real-design.md new file mode 100644 index 0000000..51a0ba7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-consulta-eventos-envelope-real-design.md @@ -0,0 +1,239 @@ +# Consulta de eventos — ler o envelope que a SEFIN devolve + +## Contexto + +`consultar()->eventos()` exige `eventoXmlGZipB64` no topo da resposta. A rota +`GET /nfse/{chave}/eventos/{tipoEvento}/{nSequencial}` do `SefinNacional_1.6.0` não +devolve esse campo: devolve uma lista `eventos[]`, e o XML vem em `eventos[].arquivoXml`. + +Consequência: **nenhuma consulta de evento existente pode ter sucesso.** Evento ausente +devolve 404 e é tratado corretamente; evento presente devolve 200, não encontra o campo +exigido em `NfseResponsePipeline::executeRaw` e vira `IndeterminateResultException`. +Só o caminho da ausência funciona. + +O defeito não foi pego porque `SefinNacional-swagger.json` declara +`EventosPostResponseSucesso` — o schema do POST — como resposta 200 do GET, e os fixtures +dos testes foram construídos a partir dele. Suíte verde contra um contrato que o servidor +não cumpre. + +Origem: incidente em produção do time Pulsar (nota central id 215 / número 122, IBGE +4304408), handoff de 2026-08-04. Nota cancelada por análise fiscal com evento 105104 +registrado no SEFIN desde 2026-08-03 permanece autorizada no banco do consumidor. + +## Evidência + +Varredura em produção, 4 tipos de evento × 3 sequenciais, feita com o próprio +`NfseHttpClient::getResponse()`. + +**Evento presente — 105104 seq 1 — HTTP 200:** + +```json +{ + "dataHoraProcessamento": "2026-08-04T07:13:04.3397841-03:00", + "tipoAmbiente": 1, + "versaoAplicativo": "SefinNacional_1.6.0", + "eventos": [ + { + "chaveAcesso": "430440…977201", + "tipoEvento": 105104, + "numeroPedidoRegistroEvento": 1, + "dataHoraRecebimento": "2026-08-03T07:31:39.737", + "arquivoXml": "SDRzSUFBQUFBQUFFQUsxWDZaS3EycEorbFlyYVA0MWRESUxDQ1hmZFdJdEpVRkFHVWZqVHdTU2dERElQZisr…" + } + ] +} +``` + +O 101103 seq 1 traz a mesma estrutura — é a forma da rota, não peculiaridade de um evento. + +**Evento ausente — HTTP 404:** corpo só com `dataHoraProcessamento`, `tipoAmbiente` e +`versaoAplicativo`, sem `erros`. `executeRaw` devolve a resposta e `eventos()` monta +`EVENT_NOT_FOUND`. **Esse caminho está correto e não muda.** + +**Codificação de `arquivoXml` — duas camadas de base64 sobre o gzip:** + +``` +base64_decode(arquivoXml) → "H4sIAAAAAAAEAK1X6ZKq2pJ+lYraP41d…" (texto ASCII) +base64_decode(disso) → 1f 8b 08 … (magic gzip) +``` + +Uma passada de `base64_decode` produz exatamente o que `GzipCompressor::decompressB64()` +já espera receber. + +## Decisão sobre tolerância a duas formas + +`storage/prefeituras.json` traz duas prefeituras com base URL e paths próprios +(`nfse.americana.sp.gov.br`, `nfsesantanadeparnaiba.simplissweb.com.br`). Implementações +não-nacionais existem e podem cumprir o swagger declarado. A leitura aceita as duas formas +em vez de trocar uma pela outra: `eventos[]` primeiro, `eventoXmlGZipB64` de topo como +alternativa. + +## API pública + +### `EventoConsultado` — novo DTO + +```php +namespace OwnerPro\Nfsen\Responses; + +final readonly class EventoConsultado +{ + public function __construct( + public ?string $chaveAcesso, + public ?TipoEvento $tipoEvento, + public ?int $numeroPedidoRegistroEvento, + public ?string $dataHoraRecebimento, + public ?string $xml, + public ?string $parseError = null, + ) {} +} +``` + +- `xml` já descomprimido. +- Item que não pôde ser interpretado por completo **não** derruba a resposta: os campos + afetados vêm `null` e `parseError` descreve o que faltou. Mesmo contrato de + `DocumentoFiscal`. +- `tipoEvento` fora do enum `TipoEvento` vira `null` com `parseError`, nunca `TypeError`. + +### `EventsResponse` + +Ganha `public array $eventos = []` (`list`) **no fim** da lista de +parâmetros — nenhum argumento existente se desloca. + +`xml` continua sendo o XML do primeiro evento. `sucesso` permanece `true` quando o +servidor devolveu eventos, ainda que um item traga `parseError` — o precedente é +`ParsesEventResponse`, que devolve `sucesso: true` com `xml: null` e um alerta quando o +recibo chega ilegível. + +### `ConsultsNfse::eventos` — docblock + +O trecho atual afirma o oposto do que ocorre: + +> Um 2xx com JSON válido porém sem `eventoXmlGZipB64` não ocorre em operação normal e +> lança `IndeterminateResultException` — nunca vira sucesso com `xml: null`. + +Reescrever descrevendo `eventos[]`, o preenchimento de `xml`, e a tolerância às duas +formas. O parágrafo sobre `EVENT_NOT_FOUND` fica como está. + +## Implementação + +### `NfseConsulter::eventos()` + +```php +$response = $this->client->executeRaw($url, 'eventos', 'eventoXmlGZipB64'); +``` + +Ordem de leitura no ramo de sucesso: + +1. `eventos[]` presente e não vazio → mapear cada item para `EventoConsultado`; + `xml` recebe `$eventos[0]->xml`. +2. Senão, `eventoXmlGZipB64` de topo → um único `EventoConsultado` com apenas `xml` + preenchido, os demais campos `null`. + +404 e rejeição estruturada seguem inalterados. + +### `GzipCompressor::decompressB64()` + +Descasca a camada extra condicionada ao magic gzip, com teto de duas camadas: + +```php +$decoded = base64_decode($gzipB64, true); + +if (! str_starts_with($decoded, "\x1f\x8b")) { + $peeled = base64_decode($decoded, true); + + if ($peeled !== false && str_starts_with($peeled, "\x1f\x8b")) { + $decoded = $peeled; + } +} +``` + +Um lugar só, determinístico. Cobre também `DocumentoFiscal::$arquivoXml`, caso o ADN +aplique a mesma camada — hipótese levantada pelo consumidor e ainda não medida. + +### `ExecutesNfseRequests::executeRaw` — campo obrigatório com alternativas + +```php +public function executeRaw(string $url, string ...$requiredFields): HttpResponse; +``` + +`executeRaw($url, 'chaveAcesso')` em `dps()` não muda. A checagem de presença passa a +aceitar **string não-vazia ou array não-vazio** — hoje é só `is_string`, e `eventos` é +lista. + +Mensagem da exceção, forma única para um ou mais campos: + +> Resultado indeterminado: o servidor respondeu HTTP %d, mas a resposta não traz nenhum +> dos campos exigidos pela operação (%s); não pôde ser interpretada. + +### `IndeterminateResultException` — preservar a evidência + +Os parâmetros novos entram **depois** de `$previous`, para não deslocar posicionalmente +quem já constrói a exceção: + +```php +public function __construct( + string $message, + public readonly ?string $phase = null, + ?Throwable $previous = null, + public readonly ?int $statusCode = null, + public readonly ?string $body = null, + public readonly ?array $raw = null, +) {} +``` + +- `body` com teto documentado de 8 KiB, em constante. +- `raw` guarda o array decodificado inteiro, sem teto — a estrutura completa fica sempre + disponível; `body` serve o caso do `fromUnreadableResponse`, em que não há JSON. + +Preenchimento por fábrica, limitado ao que cada call site tem em escopo: + +| Fábrica | `statusCode` | `body` | `raw` | +|---|---|---|---| +| `fromUnreadableResponse` | ✅ | ✅ | — (`json_decode` falhou, é a causa) | +| `fromServerError` | ✅ | ✅ | — | +| `fromMissingResponseField` | ✅ | ✅ | ✅ | +| `fromMissingQueryField` | — | — | ✅ | +| `fromMissingEventReceipt` | — | — | ✅ | + +As duas últimas não têm status nem corpo cru porque `SendsHttpRequests::get()` e +`post()` devolvem `array`. Mudar as portas driven para devolver `HttpResponse` foi +avaliado e ficou fora de escopo: alcança `Contracts/Driven`, `NfseHttpClient`, os dois +pipelines, quatro `Operations` e todos os fakes de teste, para um ganho que o `raw` já +cobre nesses dois pontos. + +### `NfseFailed` — alcançar a evidência sem depender do `catch` + +```php +public function __construct( + public string $operacao, + public string $mensagem, + public ?Throwable $throwable = null, +) {} +``` + +`DispatchesEvents::withFailureEvent` passa o throwable. + +## Testes + +O fixture é montado com a codificação real (base64 duplo sobre gzip), não com o formato +do swagger — foi exatamente daí que veio o ponto cego. + +- 200 com `eventos[]` → `sucesso: true`, `xml` preenchido, `eventos[0]` com todos os campos. +- 200 com `eventoXmlGZipB64` de topo → `sucesso: true`, um `EventoConsultado` só com `xml`. +- `arquivoXml` corrompido → `parseError` preenchido, `xml: null`, resposta de pé. +- `tipoEvento` fora do enum → `tipoEvento: null` com `parseError`. +- `eventos: []` → `IndeterminateResultException` (lista vazia não é resultado). +- 404 → `EVENT_NOT_FOUND` intacto. +- `GzipCompressor` com uma camada e com duas → ambos descomprimem. +- Cada fábrica de `IndeterminateResultException` → propriedades preenchidas conforme a + tabela acima. +- `NfseFailed` carregando o throwable. + +## Fora de escopo + +**O POST de evento (`ParsesEventResponse`).** A varredura cobriu apenas o GET, e a +rejeição E0822 que o consumidor capturou por essa rota indica que o POST devolve +`eventoXmlGZipB64` mesmo. Mexer sem medição trocaria um defeito conhecido por um suposto. + +**A camada extra de base64 no ADN.** O tratamento em `GzipCompressor` cobre o caso se ele +existir, mas nenhuma medição foi feita contra `DistribuicaoNSU.ArquivoXml`. diff --git a/src/Contracts/Driving/ConsultsNfse.php b/src/Contracts/Driving/ConsultsNfse.php index 0196b75..d765c95 100644 --- a/src/Contracts/Driving/ConsultsNfse.php +++ b/src/Contracts/Driving/ConsultsNfse.php @@ -7,6 +7,7 @@ use OwnerPro\Nfsen\Enums\SituacaoCancelamento; use OwnerPro\Nfsen\Enums\TipoEvento; use OwnerPro\Nfsen\Responses\DanfseResponse; +use OwnerPro\Nfsen\Responses\EventoConsultado; use OwnerPro\Nfsen\Responses\EventsResponse; use OwnerPro\Nfsen\Responses\NfseResponse; @@ -34,9 +35,16 @@ public function danfse(string $chave): DanfseResponse; * de ausência, distinto de erros transitórios (que permanecem * `sucesso: false` sem esse código, portanto inconclusivos). * - * Um 2xx com JSON válido porém sem `eventoXmlGZipB64` não ocorre em - * operação normal e lança `IndeterminateResultException` — nunca vira - * sucesso com `xml: null`. + * No sucesso, `eventos` traz um {@see EventoConsultado} por item devolvido e + * `xml` repete o XML do primeiro deles. Duas formas de resposta são aceitas: a + * lista `eventos[]` com o XML em `arquivoXml`, que é o que o SefinNacional 1.6.0 + * devolve, e o `eventoXmlGZipB64` de topo, que é o que o swagger declara e o que + * prefeituras de implementação própria podem devolver. + * + * Um 2xx sem nenhuma das duas formas lança `IndeterminateResultException` — + * nunca vira sucesso silencioso. Já um evento cujo XML não pôde ser lido mantém + * `sucesso: true` com `xml: null` e o motivo em `eventos[n]->parseError`: o + * servidor respondeu, e dizer o que faltou vale mais do que descartar a lista. */ public function eventos(string $chave, TipoEvento|int $tipoEvento = TipoEvento::Cancelamento, int $nSequencial = 1): EventsResponse; diff --git a/src/Contracts/Driving/ExecutesNfseRequests.php b/src/Contracts/Driving/ExecutesNfseRequests.php index 2c9cc53..61775d4 100644 --- a/src/Contracts/Driving/ExecutesNfseRequests.php +++ b/src/Contracts/Driving/ExecutesNfseRequests.php @@ -31,11 +31,21 @@ public function executeHead(string $url): int; * Lança HttpException quando o servidor responde status inesperado * (diferente de 200/201/404) sem corpo de erro estruturado (`erros`/`erro`). * - * Com $requiredField, um 2xx cujo corpo não traga esse campo como string - * não-vazia lança IndeterminateResultException: a operação exige o campo, e - * ausência comprovada é sinalizada por 404, não por corpo vazio. + * Um 2xx que não traga nenhum dos campos exigidos **na forma exigida** lança + * IndeterminateResultException: a operação exige o campo, e ausência comprovada + * é sinalizada por 404, não por corpo vazio. Os campos valem como alternativas + * entre si — é o caso da consulta de eventos, que aceita tanto a lista `eventos` + * quanto o texto `eventoXmlGZipB64`. + * + * A forma faz parte da exigência: um campo de texto que chegue como lista (ou + * vice-versa) é resposta fora do contrato, não campo presente — sem isso o valor + * seguiria para um construtor tipado e estouraria `TypeError`, fora do contrato + * de exceções do SDK. + * + * @param list $requiredStrings campos que valem como string não-vazia + * @param list $requiredLists campos que valem como lista não-vazia */ - public function executeRaw(string $url, ?string $requiredField = null): HttpResponse; + public function executeRaw(string $url, array $requiredStrings = [], array $requiredLists = []): HttpResponse; public function executeAndDownload(string $url): string; } diff --git a/src/Events/NfseFailed.php b/src/Events/NfseFailed.php index be14fc4..0b8ba08 100644 --- a/src/Events/NfseFailed.php +++ b/src/Events/NfseFailed.php @@ -4,13 +4,22 @@ namespace OwnerPro\Nfsen\Events; +use Throwable; + /** * @api */ final readonly class NfseFailed { + /** + * @param Throwable|null $throwable a falha inteira, não só a mensagem: em + * `IndeterminateResultException` é por onde + * o listener alcança `statusCode`, `body` e + * `raw` sem depender do `catch` do chamador + */ public function __construct( public string $operacao, public string $mensagem, + public ?Throwable $throwable = null, ) {} } diff --git a/src/Exceptions/IndeterminateResultException.php b/src/Exceptions/IndeterminateResultException.php index b68f780..98ff294 100644 --- a/src/Exceptions/IndeterminateResultException.php +++ b/src/Exceptions/IndeterminateResultException.php @@ -41,13 +41,24 @@ */ final class IndeterminateResultException extends CommunicationException { + /** Teto do corpo preservado em {@see self::$body}; `raw` guarda a estrutura inteira. */ + private const int BODY_MAX_BYTES = 8192; // @pest-mutate-ignore IncrementInteger,DecrementInteger — ±1 byte no teto não é observável por teste + /** * @param 'body'|'connect'|'dns'|'read'|'tls'|'transfer'|null $phase fase da falha, quando detectável + * @param int|null $statusCode status HTTP da resposta, quando ela chegou + * @param string|null $body corpo cru, truncado em 8 KiB; `null` quando a falha + * foi anterior a qualquer resposta ou quando o call + * site só recebeu o JSON já decodificado + * @param array|null $raw corpo JSON decodificado, quando houve um */ public function __construct( string $message, public readonly ?string $phase = null, ?Throwable $previous = null, + public readonly ?int $statusCode = null, + public readonly ?string $body = null, + public readonly ?array $raw = null, ) { parent::__construct($message, 0, $previous); } @@ -81,23 +92,30 @@ public static function fromUnreadableResponse(int $statusCode, string $body): se substr($body, 0, 200), ), 'body', + statusCode: $statusCode, + body: self::truncate($body), ); } /** - * Resposta 2xx com JSON válido, porém sem o campo obrigatório da operação: - * o corpo existe mas não é interpretável como resultado — mesma régua do - * 2xx ilegível, nunca um sucesso silencioso. + * Resposta 2xx com JSON válido, porém sem nenhum dos campos que a operação + * exige: o corpo existe mas não é interpretável como resultado — mesma régua + * do 2xx ilegível, nunca um sucesso silencioso. + * + * @param array $raw */ - public static function fromMissingResponseField(int $statusCode, string $field): self + public static function fromMissingResponseField(int $statusCode, string $body, array $raw, string ...$fields): self { return new self( sprintf( - 'Resultado indeterminado: o servidor respondeu HTTP %d sem o campo obrigatório "%s"; a resposta não pôde ser interpretada.', + 'Resultado indeterminado: o servidor respondeu HTTP %d, mas a resposta não traz nenhum dos campos exigidos pela operação (%s); não pôde ser interpretada.', $statusCode, - $field, + self::listFields($fields), ), 'body', + statusCode: $statusCode, + body: self::truncate($body), + raw: $raw, ); } @@ -106,8 +124,10 @@ public static function fromMissingResponseField(int $statusCode, string $field): * status não acompanha o corpo (`SendsHttpRequests::get()` devolve só o * array — e ela só entrega corpo de 2xx ou de rejeição estruturada, já * tratada antes): corpo legível, resultado ininterpretável. + * + * @param array $raw */ - public static function fromMissingQueryField(string $field): self + public static function fromMissingQueryField(string $field, array $raw): self { return new self( sprintf( @@ -115,6 +135,7 @@ public static function fromMissingQueryField(string $field): self $field, ), 'body', + raw: $raw, ); } @@ -124,8 +145,10 @@ public static function fromMissingQueryField(string $field): self * declara `eventoXmlGZipB64` required, então a ausência do campo é quebra * de contrato — nada prova que o evento foi registrado. O status HTTP não * chega a este ponto do pipeline; por isso a mensagem não o carrega. + * + * @param array $raw */ - public static function fromMissingEventReceipt(string $field): self + public static function fromMissingEventReceipt(string $field, array $raw): self { return new self( sprintf( @@ -134,6 +157,7 @@ public static function fromMissingEventReceipt(string $field): self $field, ), 'body', + raw: $raw, ); } @@ -152,9 +176,22 @@ public static function fromServerError(int $statusCode, string $body): self $statusCode, substr($body, 0, 200), ), + statusCode: $statusCode, + body: self::truncate($body), ); } + private static function truncate(string $body): string + { + return substr($body, 0, self::BODY_MAX_BYTES); + } + + /** @param array $fields */ + private static function listFields(array $fields): string + { + return '"'.implode('", "', $fields).'"'; + } + /** * @return 'connect'|'dns'|'read'|'tls'|'transfer'|null */ diff --git a/src/Operations/NfseConsulter.php b/src/Operations/NfseConsulter.php index 16484b2..ff967bb 100644 --- a/src/Operations/NfseConsulter.php +++ b/src/Operations/NfseConsulter.php @@ -16,10 +16,10 @@ use OwnerPro\Nfsen\Exceptions\NfseException; use OwnerPro\Nfsen\Pipeline\Concerns\ValidatesChaveAcesso; use OwnerPro\Nfsen\Responses\DanfseResponse; +use OwnerPro\Nfsen\Responses\EventoConsultado; use OwnerPro\Nfsen\Responses\EventsResponse; use OwnerPro\Nfsen\Responses\NfseResponse; use OwnerPro\Nfsen\Responses\ProcessingMessage; -use OwnerPro\Nfsen\Support\GzipCompressor; /** * @internal @@ -56,7 +56,7 @@ public function dps(string $id): NfseResponse // reconciliação pós-timeout — que é o fluxo que dps() existe para // servir — sem identificador algum da nota. O 404 não é afetado: // executeRaw retorna antes da checagem. - $response = $this->client->executeRaw($this->buildUrl($this->seFinBaseUrl, $path), 'chaveAcesso'); + $response = $this->client->executeRaw($this->buildUrl($this->seFinBaseUrl, $path), ['chaveAcesso']); /** * @var array{ @@ -180,12 +180,13 @@ public function eventos(string $chave, TipoEvento|int $tipoEvento = TipoEvento:: 'nSequencial' => $nSequencial, ]); - $response = $this->client->executeRaw($this->buildUrl($this->seFinBaseUrl, $path), 'eventoXmlGZipB64'); + $response = $this->client->executeRaw($this->buildUrl($this->seFinBaseUrl, $path), ['eventoXmlGZipB64'], ['eventos']); /** * @var array{ * erros?: list, * erro?: MessageData|list, + * eventos?: mixed, * eventoXmlGZipB64?: string, * tipoAmbiente?: int, * versaoAplicativo?: string, @@ -227,17 +228,40 @@ public function eventos(string $chave, TipoEvento|int $tipoEvento = TipoEvento:: ); } + $eventos = $this->parseEventos($result); + return new EventsResponse( sucesso: true, - // executeRaw('eventoXmlGZipB64') garante string não-vazia neste ponto. - xml: GzipCompressor::decompressB64($result['eventoXmlGZipB64'] ?? null), + xml: $eventos[0]->xml, tipoAmbiente: $tipoAmbiente, versaoAplicativo: $versaoAplicativo, dataHoraProcessamento: $dataHoraProcessamento, raw: $result, + eventos: $eventos, ); } + /** + * O SefinNacional 1.6.0 devolve `eventos[]` com o XML em `arquivoXml`; o + * `eventoXmlGZipB64` de topo é o que o swagger declara para esta rota, e é o que + * as prefeituras de implementação própria (`storage/prefeituras.json`) podem + * devolver. `executeRaw` já garante que um dos dois veio preenchido na forma + * que se lê aqui: lista não-vazia, ou texto não-vazio. + * + * @param array $result + * @return non-empty-list + */ + private function parseEventos(array $result): array + { + $eventos = $result['eventos'] ?? null; + + if (is_array($eventos) && $eventos !== []) { + return array_values(array_map(EventoConsultado::fromArray(...), $eventos)); + } + + return [EventoConsultado::fromArray(['arquivoXml' => $result['eventoXmlGZipB64'] ?? null])]; + } + public function situacaoCancelamento(string $chave): SituacaoCancelamento { $events = $this->distributor->eventos($chave); diff --git a/src/Pipeline/Concerns/DispatchesEvents.php b/src/Pipeline/Concerns/DispatchesEvents.php index 3109f81..e3e8340 100644 --- a/src/Pipeline/Concerns/DispatchesEvents.php +++ b/src/Pipeline/Concerns/DispatchesEvents.php @@ -41,7 +41,7 @@ private function withFailureEvent(string $operacao, Closure $fn): mixed try { return $fn(); } catch (Throwable $throwable) { - $this->dispatchEvent(new NfseFailed($operacao, $throwable->getMessage())); + $this->dispatchEvent(new NfseFailed($operacao, $throwable->getMessage(), $throwable)); throw $throwable; } } diff --git a/src/Pipeline/Concerns/ParsesEventResponse.php b/src/Pipeline/Concerns/ParsesEventResponse.php index 6464ad6..0913320 100644 --- a/src/Pipeline/Concerns/ParsesEventResponse.php +++ b/src/Pipeline/Concerns/ParsesEventResponse.php @@ -62,7 +62,7 @@ private function parseEventResponse(array $result, string $chave, string $operac // com JSON próprio que request() devolve para o resgate SEM_CHAVE // do emitter — resgate que evento nenhum tem. if (! is_string($eventoXml) || $eventoXml === '') { - throw IndeterminateResultException::fromMissingEventReceipt('eventoXmlGZipB64'); + throw IndeterminateResultException::fromMissingEventReceipt('eventoXmlGZipB64', $result); } $this->dispatchEvent($successEvent); diff --git a/src/Pipeline/NfseResponsePipeline.php b/src/Pipeline/NfseResponsePipeline.php index b75cc2f..4a328e4 100644 --- a/src/Pipeline/NfseResponsePipeline.php +++ b/src/Pipeline/NfseResponsePipeline.php @@ -73,7 +73,7 @@ public function executeAndDecompress(string $url): NfseResponse // consulta — não há sucesso a relatar. Mesma régua de eventos() // e dps(); só o 2xx chega aqui (get() lança nos demais casos). if (! is_string($nfseXml) || $nfseXml === '') { - throw IndeterminateResultException::fromMissingQueryField('nfseXmlGZipB64'); + throw IndeterminateResultException::fromMissingQueryField('nfseXmlGZipB64', $result); } $xml = GzipCompressor::decompressB64($nfseXml); @@ -92,12 +92,16 @@ public function executeAndDecompress(string $url): NfseResponse }); } - public function executeRaw(string $url, ?string $requiredField = null): HttpResponse + /** + * @param list $requiredStrings + * @param list $requiredLists + */ + public function executeRaw(string $url, array $requiredStrings = [], array $requiredLists = []): HttpResponse { $operacao = 'consultar'; $this->dispatchEvent(new NfseRequested($operacao)); - return $this->withFailureEvent($operacao, function () use ($url, $operacao, $requiredField): HttpResponse { + return $this->withFailureEvent($operacao, function () use ($url, $operacao, $requiredStrings, $requiredLists): HttpResponse { $response = $this->httpClient->getResponse($url); /** @var array{erros?: list, erro?: MessageData|list} $result */ @@ -116,16 +120,19 @@ public function executeRaw(string $url, ?string $requiredField = null): HttpResp return $response; } - // 2xx sem o campo exigido pela operação: corpo legível porém - // ininterpretável — indeterminado, nunca sucesso silencioso. + // 2xx sem nenhum dos campos exigidos pela operação: corpo legível + // porém ininterpretável — indeterminado, nunca sucesso silencioso. // Lançado aqui (e não no chamador) para que NfseFailed seja // disparado no lugar de NfseQueried. - if ($requiredField !== null) { - $field = $response->json[$requiredField] ?? null; - - if (! is_string($field) || $field === '') { - throw IndeterminateResultException::fromMissingResponseField($response->statusCode, $requiredField); - } + $required = [...$requiredStrings, ...$requiredLists]; + + if ($required !== [] && ! $this->hasAnyField($response->json, $requiredStrings, $requiredLists)) { + throw IndeterminateResultException::fromMissingResponseField( + $response->statusCode, + $response->body, + $response->json, + ...$required, + ); } } @@ -135,6 +142,37 @@ public function executeRaw(string $url, ?string $requiredField = null): HttpResp }); } + /** + * A rota GET de eventos entrega o resultado numa lista (`eventos`), e as demais + * num campo de texto. Campo presente é o que veio preenchido **na forma que a + * operação lê**: aceitar qualquer valor não-vazio deixaria o chamador entregar + * uma lista a um construtor que declara `?string`. + * + * @param array $json + * @param list $strings + * @param list $lists + */ + private function hasAnyField(array $json, array $strings, array $lists): bool + { + foreach ($strings as $field) { + $value = $json[$field] ?? null; + + if (is_string($value) && $value !== '') { + return true; + } + } + + foreach ($lists as $field) { + $value = $json[$field] ?? null; + + if (is_array($value) && $value !== []) { + return true; + } + } + + return false; + } + /** @param array{erros?: list, erro?: MessageData|list} $result */ private function dispatchResultEvents(array $result, string $operacao): void { diff --git a/src/Responses/EventoConsultado.php b/src/Responses/EventoConsultado.php new file mode 100644 index 0000000..789c472 --- /dev/null +++ b/src/Responses/EventoConsultado.php @@ -0,0 +1,118 @@ +eventos()`. + * + * A rota GET de eventos entrega uma lista, e o governo pode passar a registrar tipos + * que esta versão do SDK ainda não conhece. Por isso um item que não pôde ser + * interpretado por completo **não** interrompe a resposta: os campos afetados vêm + * `null` e {@see self::$parseError} descreve o que faltou. + * + * @api + */ +final readonly class EventoConsultado +{ + public function __construct( + public ?string $chaveAcesso, + public ?TipoEvento $tipoEvento, + public ?int $numeroPedidoRegistroEvento, + public ?string $dataHoraRecebimento, + /** XML do evento, já descomprimido. */ + public ?string $xml, + /** Por que o evento não pôde ser interpretado por completo; `null` quando íntegro. */ + public ?string $parseError = null, + ) {} + + /** + * O swagger não declara a lista `eventos`, então nada garante que cada item seja + * um objeto. Um item escalar vira um evento vazio com o motivo dito, e não um + * `TypeError` que levaria junto os itens íntegros da lista. + */ + public static function fromArray(mixed $data): self + { + if (! is_array($data)) { + return new self( + chaveAcesso: null, + tipoEvento: null, + numeroPedidoRegistroEvento: null, + dataHoraRecebimento: null, + xml: null, + parseError: sprintf('Item de eventos veio como %s, e não como objeto.', get_debug_type($data)), + ); + } + + $problems = []; + + $codigoEvento = self::readInt($data['tipoEvento'] ?? null, 'tipoEvento', $problems); + $tipoEvento = $codigoEvento !== null ? TipoEvento::tryFrom($codigoEvento) : null; + + if ($codigoEvento !== null && $tipoEvento === null) { + $problems[] = sprintf('TipoEvento desconhecido: "%d".', $codigoEvento); + } + + try { + $xml = GzipCompressor::decompressB64( + self::readString($data['arquivoXml'] ?? null, 'arquivoXml', $problems) + ); + } catch (NfseException $nfseException) { + $xml = null; + $problems[] = $nfseException->getMessage(); + } + + return new self( + chaveAcesso: self::readString($data['chaveAcesso'] ?? null, 'chaveAcesso', $problems), + tipoEvento: $tipoEvento, + numeroPedidoRegistroEvento: self::readInt($data['numeroPedidoRegistroEvento'] ?? null, 'numeroPedidoRegistroEvento', $problems), + dataHoraRecebimento: self::readString($data['dataHoraRecebimento'] ?? null, 'dataHoraRecebimento', $problems), + xml: $xml, + parseError: $problems === [] ? null : implode(' ', $problems), + ); + } + + /** + * Um item fora do tipo declarado não pode derrubar a lista inteira: o + * `array_map` que constrói os eventos levaria junto os itens íntegros, e a + * promessa do docblock da classe é que só o campo afetado se perde. + * + * @param list $problems + */ + private static function readString(mixed $value, string $campo, array &$problems): ?string + { + if ($value === null || is_string($value)) { + return $value; + } + + $problems[] = sprintf('Campo %s veio como %s, e não como texto.', $campo, get_debug_type($value)); + + return null; + } + + /** + * Texto decimal também serve: o swagger declara `integer`, mas é justamente a + * divergência entre o declarado e o devolvido que motivou esta classe. + * + * @param list $problems + */ + private static function readInt(mixed $value, string $campo, array &$problems): ?int + { + if ($value === null || is_int($value)) { + return $value; + } + + if (is_string($value) && ctype_digit($value)) { + return (int) $value; + } + + $problems[] = sprintf('Campo %s veio como %s, e não como número inteiro.', $campo, get_debug_type($value)); + + return null; + } +} diff --git a/src/Responses/EventsResponse.php b/src/Responses/EventsResponse.php index 5f01a59..09a61cc 100644 --- a/src/Responses/EventsResponse.php +++ b/src/Responses/EventsResponse.php @@ -18,10 +18,13 @@ public const string EVENT_NOT_FOUND = 'EVENT_NOT_FOUND'; /** + * @param string|null $xml XML do primeiro evento de `$eventos`, já descomprimido * @param list $erros * @param array|null $raw corpo JSON decodificado, como a API o * devolveu — o que a normalização não * reconhecer continua legível aqui + * @param list $eventos os eventos que a rota devolveu para o + * par `(tipoEvento, nSequencial)` consultado */ public function __construct( public bool $sucesso, @@ -31,5 +34,6 @@ public function __construct( public ?string $versaoAplicativo = null, public ?string $dataHoraProcessamento = null, public ?array $raw = null, + public array $eventos = [], ) {} } diff --git a/src/Support/GzipCompressor.php b/src/Support/GzipCompressor.php index 3a008be..2bb72fd 100644 --- a/src/Support/GzipCompressor.php +++ b/src/Support/GzipCompressor.php @@ -23,6 +23,8 @@ class GzipCompressor */ private const int MAX_DECOMPRESSED_BYTES = 52_428_800; // @pest-mutate-ignore IncrementInteger,DecrementInteger — ±1 byte no teto não é observável por teste + private const string GZIP_MAGIC = "\x1f\x8b"; + public function __invoke(string $data): string|false { return gzencode($data); @@ -39,6 +41,8 @@ public static function decompressB64(?string $gzipB64): ?string throw new NfseException('Falha ao decodificar base64 do XML.'); } + $decoded = self::peelExtraBase64Layer($decoded); + try { $decompressed = gzdecode($decoded, self::MAX_DECOMPRESSED_BYTES); // @codeCoverageIgnoreStart @@ -54,4 +58,23 @@ public static function decompressB64(?string $gzipB64): ?string return $decompressed; } + + /** + * `eventos[].arquivoXml`, na rota GET de eventos do SefinNacional 1.6.0, traz + * uma segunda camada de base64 sobre o gzip; os demais campos trazem uma só. + * + * Tentar descascar sempre é seguro sem conferir antes se já é gzip: o primeiro + * byte do gzip (`0x1f`) está fora do alfabeto base64, então o decode estrito de + * um conteúdo já descomprimido falha por conta própria. + */ + private static function peelExtraBase64Layer(string $decoded): string + { + $peeled = base64_decode($decoded, true); + + if ($peeled === false || ! str_starts_with($peeled, self::GZIP_MAGIC)) { + return $decoded; + } + + return $peeled; + } } diff --git a/tests/Feature/NfsenClientConsultarTest.php b/tests/Feature/NfsenClientConsultarTest.php index 8159c20..ab26b11 100644 --- a/tests/Feature/NfsenClientConsultarTest.php +++ b/tests/Feature/NfsenClientConsultarTest.php @@ -5,6 +5,7 @@ use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; +use OwnerPro\Nfsen\Enums\TipoEvento; use OwnerPro\Nfsen\Events\NfseFailed; use OwnerPro\Nfsen\Events\NfseQueried; use OwnerPro\Nfsen\Exceptions\HttpException; @@ -33,7 +34,9 @@ ); }); -it('consultar()->eventos returns EventsResponse', function () { +it('consultar()->eventos still reads the top-level eventoXmlGZipB64 the swagger declares', function () { + // Forma alternativa, para as prefeituras de implementação própria de + // `storage/prefeituras.json`. O SefinNacional 1.6.0 devolve `eventos[]`. $chave = makeChaveAcesso(); Http::fake(['*' => Http::response(['eventoXmlGZipB64' => base64_encode(gzencode(''))], 200)]); @@ -48,7 +51,26 @@ ); }); -it('consultar()->eventos throws IndeterminateResultException when no eventoXmlGZipB64', function () { +it('consultar()->eventos reads the eventos[] envelope the SefinNacional returns', function () { + $chave = makeChaveAcesso(); + Http::fake(['*' => Http::response(['eventos' => [[ + 'chaveAcesso' => $chave, + 'tipoEvento' => 105104, + 'numeroPedidoRegistroEvento' => 1, + 'dataHoraRecebimento' => '2026-08-03T07:31:39.737', + 'arquivoXml' => base64_encode(base64_encode((string) gzencode(''))), + ]]], 200)]); + + $client = NfsenClient::for(makePfxContent(), 'secret', '9999999'); + $response = $client->consultar()->eventos($chave, TipoEvento::CancelamentoDeferidoAnaliseFiscal); + + expect($response->sucesso)->toBeTrue() + ->and($response->xml)->toBe('') + ->and($response->eventos[0]->tipoEvento)->toBe(TipoEvento::CancelamentoDeferidoAnaliseFiscal) + ->and($response->eventos[0]->dataHoraRecebimento)->toBe('2026-08-03T07:31:39.737'); +}); + +it('consultar()->eventos throws IndeterminateResultException when neither eventos nor eventoXmlGZipB64 came', function () { Http::fake(['*' => Http::response([], 200)]); $client = NfsenClient::for(makePfxContent(), 'secret', '9999999'); @@ -57,6 +79,32 @@ ->toThrow(IndeterminateResultException::class); }); +it('consultar()->eventos keeps a proxy body out of the success path', function () { + // `eventos` como texto passaria por uma checagem de "campo não-vazio" e viraria + // sucesso com xml null e nenhum motivo declarado. + Http::fake(['*' => Http::response(['eventos' => 'texto de proxy'], 200)]); + + $client = NfsenClient::for(makePfxContent(), 'secret', '9999999'); + + expect(fn () => $client->consultar()->eventos(makeChaveAcesso())) + ->toThrow(IndeterminateResultException::class); +}); + +it('consultar()->dps throws IndeterminateResultException on a 200 without chaveAcesso', function (mixed $chaveAcesso) { + // DpsGetResponse declara chaveAcesso required no 200: sem ela a reconciliação + // pós-timeout encerraria sem identificador algum da nota. Fora do tipo conta + // como ausente — senão o valor seguiria para um construtor que declara ?string. + Http::fake(['*' => Http::response(array_filter(['tipoAmbiente' => 2, 'chaveAcesso' => $chaveAcesso]), 200)]); + + $client = NfsenClient::for(makePfxContent(), 'secret', '9999999'); + + expect(fn () => $client->consultar()->dps('DPS123')) + ->toThrow(IndeterminateResultException::class, 'chaveAcesso'); +})->with([ + 'ausente' => [null], + 'veio como lista' => [['CHAVE']], +]); + it('consultar()->eventos returns EVENT_NOT_FOUND failure on 404', function () { Http::fake(['*' => Http::response([], 404)]); diff --git a/tests/Feature/RawPayloadTest.php b/tests/Feature/RawPayloadTest.php index 2ec81ce..bce7cf2 100644 --- a/tests/Feature/RawPayloadTest.php +++ b/tests/Feature/RawPayloadTest.php @@ -91,7 +91,8 @@ function makeRawGzip(string $xml = ''): string expect($response->sucesso)->toBe($sucesso) ->and($response->raw)->toBe($body); })->with([ - 'sucesso' => [['eventoXmlGZipB64' => makeRawGzip(), 'campoDesconhecido' => 'x'], 200, true], + 'sucesso na forma do SefinNacional' => [['eventos' => [['arquivoXml' => base64_encode(makeRawGzip())]], 'campoDesconhecido' => 'x'], 200, true], + 'sucesso na forma do swagger' => [['eventoXmlGZipB64' => makeRawGzip(), 'campoDesconhecido' => 'x'], 200, true], 'rejeição' => [['erro' => [['codigo' => 'E999', 'descricao' => 'Evento rejeitado']], 'campoDesconhecido' => 'x'], 200, false], '404' => [['campoDesconhecido' => 'x'], 404, false], ]); diff --git a/tests/Unit/DTOs/EventoConsultadoTest.php b/tests/Unit/DTOs/EventoConsultadoTest.php new file mode 100644 index 0000000..2a4b651 --- /dev/null +++ b/tests/Unit/DTOs/EventoConsultadoTest.php @@ -0,0 +1,116 @@ +', + ); + + expect($evento) + ->chaveAcesso->toBe($chave) + ->tipoEvento->toBe(TipoEvento::CancelamentoDeferidoAnaliseFiscal) + ->numeroPedidoRegistroEvento->toBe(1) + ->dataHoraRecebimento->toBe('2026-08-03T07:31:39.737') + ->xml->toBe('') + ->parseError->toBeNull(); +}); + +it('reads the item as the SefinNacional sends it', function () { + $chave = makeChaveAcesso(); + + $evento = EventoConsultado::fromArray([ + 'chaveAcesso' => $chave, + 'tipoEvento' => 105104, + 'numeroPedidoRegistroEvento' => 1, + 'dataHoraRecebimento' => '2026-08-03T07:31:39.737', + 'arquivoXml' => base64_encode(base64_encode((string) gzencode(''))), + ]); + + expect($evento) + ->chaveAcesso->toBe($chave) + ->tipoEvento->toBe(TipoEvento::CancelamentoDeferidoAnaliseFiscal) + ->numeroPedidoRegistroEvento->toBe(1) + ->dataHoraRecebimento->toBe('2026-08-03T07:31:39.737') + ->xml->toBe('') + ->parseError->toBeNull(); +}); + +it('accepts an empty item, leaving every field null and nothing to report', function () { + $evento = EventoConsultado::fromArray([]); + + expect($evento) + ->chaveAcesso->toBeNull() + ->tipoEvento->toBeNull() + ->numeroPedidoRegistroEvento->toBeNull() + ->dataHoraRecebimento->toBeNull() + ->xml->toBeNull() + ->parseError->toBeNull(); +}); + +it('accepts numeric text where the swagger declares integer', function () { + $evento = EventoConsultado::fromArray([ + 'tipoEvento' => '105104', + 'numeroPedidoRegistroEvento' => '7', + ]); + + expect($evento) + ->tipoEvento->toBe(TipoEvento::CancelamentoDeferidoAnaliseFiscal) + ->numeroPedidoRegistroEvento->toBe(7) + ->parseError->toBeNull(); +}); + +it('reports the unknown tipoEvento instead of dropping the item', function () { + $evento = EventoConsultado::fromArray(['tipoEvento' => 999999]); + + expect($evento->tipoEvento)->toBeNull() + ->and($evento->parseError)->toBe('TipoEvento desconhecido: "999999".'); +}); + +it('reports a field that came outside its declared type', function (array $data, string $expectedError) { + expect(EventoConsultado::fromArray($data)->parseError)->toContain($expectedError); +})->with([ + 'chaveAcesso' => [['chaveAcesso' => 42], 'Campo chaveAcesso veio como int, e não como texto.'], + 'dataHoraRecebimento' => [['dataHoraRecebimento' => []], 'Campo dataHoraRecebimento veio como array, e não como texto.'], + 'arquivoXml' => [['arquivoXml' => 42], 'Campo arquivoXml veio como int, e não como texto.'], + 'tipoEvento' => [['tipoEvento' => 1.5], 'Campo tipoEvento veio como float, e não como número inteiro.'], + 'numeroPedidoRegistroEvento' => [['numeroPedidoRegistroEvento' => '1a'], 'Campo numeroPedidoRegistroEvento veio como string, e não como número inteiro.'], +]); + +it('reports an unreadable arquivoXml without losing the rest of the item', function () { + $evento = EventoConsultado::fromArray([ + 'numeroPedidoRegistroEvento' => 3, + 'arquivoXml' => '!!!nao-e-base64!!!', + ]); + + expect($evento->numeroPedidoRegistroEvento)->toBe(3) + ->and($evento->xml)->toBeNull() + ->and($evento->parseError)->toBe('Falha ao decodificar base64 do XML.'); +}); + +it('joins every problem found in the same item', function () { + $evento = EventoConsultado::fromArray([ + 'chaveAcesso' => 42, + 'tipoEvento' => 999999, + ]); + + expect($evento->parseError)->toBe( + 'TipoEvento desconhecido: "999999". Campo chaveAcesso veio como int, e não como texto.' + ); +}); + +it('turns a scalar item into an empty event with the reason, instead of a TypeError', function () { + $evento = EventoConsultado::fromArray('lixo'); + + expect($evento->xml)->toBeNull() + ->and($evento->parseError)->toBe('Item de eventos veio como string, e não como objeto.'); +}); diff --git a/tests/Unit/Exceptions/IndeterminateResultExceptionTest.php b/tests/Unit/Exceptions/IndeterminateResultExceptionTest.php index cde4c49..b7bc229 100644 --- a/tests/Unit/Exceptions/IndeterminateResultExceptionTest.php +++ b/tests/Unit/Exceptions/IndeterminateResultExceptionTest.php @@ -103,17 +103,17 @@ ->and($exception->getMessage())->not->toContain('Z'); }); -it('fromMissingResponseField sets body phase and names status and field', function () { - $exception = IndeterminateResultException::fromMissingResponseField(200, 'eventoXmlGZipB64'); +it('fromMissingResponseField sets body phase and names status and every accepted field', function () { + $exception = IndeterminateResultException::fromMissingResponseField(200, '{}', [], 'eventoXmlGZipB64', 'eventos'); expect($exception->phase)->toBe('body') ->and($exception->getPrevious())->toBeNull() ->and($exception->getMessage())->toContain('HTTP 200') - ->and($exception->getMessage())->toContain('"eventoXmlGZipB64"'); + ->and($exception->getMessage())->toContain('("eventoXmlGZipB64", "eventos")'); }); it('fromMissingQueryField sets body phase and names the field', function () { - $exception = IndeterminateResultException::fromMissingQueryField('nfseXmlGZipB64'); + $exception = IndeterminateResultException::fromMissingQueryField('nfseXmlGZipB64', []); expect($exception->phase)->toBe('body') ->and($exception->getPrevious())->toBeNull() @@ -122,7 +122,7 @@ }); it('fromMissingEventReceipt sets body phase, names the field and points to the reconciliation flow', function () { - $exception = IndeterminateResultException::fromMissingEventReceipt('eventoXmlGZipB64'); + $exception = IndeterminateResultException::fromMissingEventReceipt('eventoXmlGZipB64', []); expect($exception->phase)->toBe('body') ->and($exception->getPrevious())->toBeNull() @@ -130,3 +130,55 @@ ->and($exception->getMessage())->toContain('"eventoXmlGZipB64"') ->and($exception->getMessage())->toEndWith('Reconcilie com consultar()->eventos().'); }); + +it('preserves the evidence each factory has in scope', function (IndeterminateResultException $exception, ?int $statusCode, ?string $body, ?array $raw) { + expect($exception->statusCode)->toBe($statusCode) + ->and($exception->body)->toBe($body) + ->and($exception->raw)->toBe($raw); +})->with([ + 'fromUnreadableResponse' => [ + fn () => IndeterminateResultException::fromUnreadableResponse(200, 'gateway'), + 200, + 'gateway', + null, + ], + 'fromServerError' => [ + fn () => IndeterminateResultException::fromServerError(502, 'Bad Gateway'), + 502, + 'Bad Gateway', + null, + ], + 'fromMissingResponseField' => [ + fn () => IndeterminateResultException::fromMissingResponseField(200, '{"tipoAmbiente":1}', ['tipoAmbiente' => 1], 'eventos'), + 200, + '{"tipoAmbiente":1}', + ['tipoAmbiente' => 1], + ], + 'fromMissingQueryField' => [ + fn () => IndeterminateResultException::fromMissingQueryField('nfseXmlGZipB64', ['tipoAmbiente' => 1]), + null, + null, + ['tipoAmbiente' => 1], + ], + 'fromMissingEventReceipt' => [ + fn () => IndeterminateResultException::fromMissingEventReceipt('eventoXmlGZipB64', ['tipoAmbiente' => 1]), + null, + null, + ['tipoAmbiente' => 1], + ], + 'fromTransportFailure' => [ + fn () => IndeterminateResultException::fromTransportFailure(new ConnectionException('cURL error 7')), + null, + null, + null, + ], +]); + +it('truncates the preserved body at 8 KiB, leaving the whole structure in raw', function () { + $body = str_repeat('x', 8192).'EXCEDENTE'; + + $exception = IndeterminateResultException::fromUnreadableResponse(200, $body); + + expect($exception->body)->toHaveLength(8192) + ->and($exception->body)->not->toContain('EXCEDENTE'); +}); diff --git a/tests/Unit/Operations/NfseConsulterTest.php b/tests/Unit/Operations/NfseConsulterTest.php index 8e9511f..02bdc07 100644 --- a/tests/Unit/Operations/NfseConsulterTest.php +++ b/tests/Unit/Operations/NfseConsulterTest.php @@ -42,9 +42,13 @@ public function executeHead(string $url): int return $this->headStatus; } - public function executeRaw(string $url, ?string $requiredField = null): HttpResponse + /** @var list, lists: list}> */ + public array $required = []; + + public function executeRaw(string $url, array $requiredStrings = [], array $requiredLists = []): HttpResponse { $this->calls[] = $url; + $this->required[] = ['strings' => $requiredStrings, 'lists' => $requiredLists]; return $this->rawResponse ?? new HttpResponse(200, ['chaveAcesso' => null], ''); } @@ -52,7 +56,25 @@ public function executeRaw(string $url, ?string $requiredField = null): HttpResp function makeEventoResponse(int $statusCode = 200): HttpResponse { - return new HttpResponse($statusCode, ['eventoXmlGZipB64' => base64_encode((string) gzencode(''))], ''); + return new HttpResponse($statusCode, ['eventos' => [makeEventoItem()]], ''); +} + +/** + * A rota GET de eventos do SefinNacional 1.6.0 entrega `arquivoXml` com duas camadas + * de base64 sobre o gzip. O fixture reproduz a codificação medida em produção — o + * defeito original nasceu de um fixture construído a partir do swagger. + * + * @return array + */ +function makeEventoItem(string $xml = '', int $tipoEvento = 105104): array +{ + return [ + 'chaveAcesso' => makeChaveAcesso(), + 'tipoEvento' => $tipoEvento, + 'numeroPedidoRegistroEvento' => 1, + 'dataHoraRecebimento' => '2026-08-03T07:31:39.737', + 'arquivoXml' => base64_encode(base64_encode((string) gzencode($xml))), + ]; } function makeNfseConsulter(FakeNfsenClientForConsulta $fakeClient): NfseConsulter @@ -82,6 +104,20 @@ function makeNfseConsulter(FakeNfsenClientForConsulta $fakeClient): NfseConsulte expect($fakeClient->calls[0])->toBe('https://sefin.base/dps/DPS456'); }); +it('declares to the port which field each query cannot do without, and in which shape', function () { + // A forma faz parte da exigência: um campo de texto que chegue como lista + // passaria adiante e estouraria TypeError no construtor tipado da resposta. + $fakeClient = new FakeNfsenClientForConsulta; + $fakeClient->rawResponse = makeEventoResponse(); + $builder = makeNfseConsulter($fakeClient); + + $builder->dps('DPS456'); + $builder->eventos(makeChaveAcesso()); + + expect($fakeClient->required[0])->toBe(['strings' => ['chaveAcesso'], 'lists' => []]) + ->and($fakeClient->required[1])->toBe(['strings' => ['eventoXmlGZipB64'], 'lists' => ['eventos']]); +}); + it('dps returns failure when erros key present', function () { $fakeClient = new FakeNfsenClientForConsulta; $fakeClient->rawResponse = new HttpResponse(400, ['erros' => [['descricao' => 'DPS rejeitada']]], ''); @@ -159,7 +195,7 @@ public function executeHead(string $url): int return 200; } - public function executeRaw(string $url, ?string $requiredField = null): HttpResponse + public function executeRaw(string $url, array $requiredStrings = [], array $requiredLists = []): HttpResponse { return new HttpResponse(200, [], ''); } @@ -192,7 +228,7 @@ public function executeHead(string $url): int return 200; } - public function executeRaw(string $url, ?string $requiredField = null): HttpResponse + public function executeRaw(string $url, array $requiredStrings = [], array $requiredLists = []): HttpResponse { return new HttpResponse(200, [], ''); } @@ -229,7 +265,7 @@ public function executeHead(string $url): int return 200; } - public function executeRaw(string $url, ?string $requiredField = null): HttpResponse + public function executeRaw(string $url, array $requiredStrings = [], array $requiredLists = []): HttpResponse { return new HttpResponse(200, [], ''); } @@ -273,7 +309,7 @@ public function executeHead(string $url): int return 200; } - public function executeRaw(string $url, ?string $requiredField = null): HttpResponse + public function executeRaw(string $url, array $requiredStrings = [], array $requiredLists = []): HttpResponse { return new HttpResponse(200, [], ''); } @@ -307,7 +343,7 @@ public function executeHead(string $url): int return 200; } - public function executeRaw(string $url, ?string $requiredField = null): HttpResponse + public function executeRaw(string $url, array $requiredStrings = [], array $requiredLists = []): HttpResponse { return new HttpResponse(200, [], ''); } @@ -346,7 +382,7 @@ public function executeHead(string $url): int return 200; } - public function executeRaw(string $url, ?string $requiredField = null): HttpResponse + public function executeRaw(string $url, array $requiredStrings = [], array $requiredLists = []): HttpResponse { return new HttpResponse(200, [], ''); } @@ -383,7 +419,7 @@ public function executeHead(string $url): int return 200; } - public function executeRaw(string $url, ?string $requiredField = null): HttpResponse + public function executeRaw(string $url, array $requiredStrings = [], array $requiredLists = []): HttpResponse { return new HttpResponse(200, [], ''); } @@ -467,7 +503,7 @@ public function executeRaw(string $url, ?string $requiredField = null): HttpResp expect($response->erros[1]->codigo)->toBe('E404'); }); -it('eventos returns success with decompressed xml', function () { +it('eventos returns success with decompressed xml from the top-level eventoXmlGZipB64', function () { $fakeClient = new FakeNfsenClientForConsulta; $fakeClient->rawResponse = new HttpResponse( 200, @@ -490,6 +526,80 @@ public function executeRaw(string $url, ?string $requiredField = null): HttpResp expect($response->dataHoraProcessamento)->toBe('2026-01-01T00:00:00'); }); +it('eventos reads the eventos[] list the SefinNacional returns, with its second base64 layer', function () { + $chave = makeChaveAcesso(); + $fakeClient = new FakeNfsenClientForConsulta; + $fakeClient->rawResponse = new HttpResponse(200, ['eventos' => [makeEventoItem()]], ''); + $builder = makeNfseConsulter($fakeClient); + + $response = $builder->eventos($chave, TipoEvento::CancelamentoDeferidoAnaliseFiscal); + + expect($response->sucesso)->toBeTrue() + ->and($response->xml)->toBe('') + ->and($response->eventos)->toHaveCount(1) + ->and($response->eventos[0]->chaveAcesso)->toBe($chave) + ->and($response->eventos[0]->tipoEvento)->toBe(TipoEvento::CancelamentoDeferidoAnaliseFiscal) + ->and($response->eventos[0]->numeroPedidoRegistroEvento)->toBe(1) + ->and($response->eventos[0]->dataHoraRecebimento)->toBe('2026-08-03T07:31:39.737') + ->and($response->eventos[0]->xml)->toBe('') + ->and($response->eventos[0]->parseError)->toBeNull(); +}); + +it('eventos exposes every item of the list and points xml at the first', function () { + $fakeClient = new FakeNfsenClientForConsulta; + $fakeClient->rawResponse = new HttpResponse(200, [ + 'eventos' => [makeEventoItem(''), makeEventoItem('')], + ], ''); + $builder = makeNfseConsulter($fakeClient); + + $response = $builder->eventos(makeChaveAcesso()); + + expect($response->xml)->toBe('') + ->and($response->eventos)->toHaveCount(2) + ->and($response->eventos[1]->xml)->toBe(''); +}); + +it('eventos reindexes a list that came keyed as a JSON object', function () { + $fakeClient = new FakeNfsenClientForConsulta; + $fakeClient->rawResponse = new HttpResponse(200, [ + 'eventos' => ['b' => makeEventoItem(''), 'a' => makeEventoItem('')], + ], ''); + $builder = makeNfseConsulter($fakeClient); + + $response = $builder->eventos(makeChaveAcesso()); + + expect($response->xml)->toBe('') + ->and($response->eventos)->toHaveCount(2); +}); + +it('eventos keeps the response standing when an item cannot be read, stating why', function (array $evento, string $expectedError) { + $fakeClient = new FakeNfsenClientForConsulta; + $fakeClient->rawResponse = new HttpResponse(200, ['eventos' => [$evento]], ''); + $builder = makeNfseConsulter($fakeClient); + + $response = $builder->eventos(makeChaveAcesso()); + + expect($response->sucesso)->toBeTrue() + ->and($response->xml)->toBeNull() + ->and($response->eventos[0]->parseError)->toContain($expectedError); +})->with([ + 'arquivoXml corrompido' => [['arquivoXml' => '!!!nao-e-base64!!!'], 'Falha ao decodificar base64'], + 'arquivoXml fora do tipo' => [['arquivoXml' => 123], 'veio como int'], +]); + +it('eventos reports an unknown tipoEvento without losing the rest of the item', function () { + $fakeClient = new FakeNfsenClientForConsulta; + $fakeClient->rawResponse = new HttpResponse(200, ['eventos' => [makeEventoItem('', 999999)]], ''); + $builder = makeNfseConsulter($fakeClient); + + $response = $builder->eventos(makeChaveAcesso()); + + expect($response->sucesso)->toBeTrue() + ->and($response->xml)->toBe('') + ->and($response->eventos[0]->tipoEvento)->toBeNull() + ->and($response->eventos[0]->parseError)->toBe('TipoEvento desconhecido: "999999".'); +}); + it('eventos uses default nSequencial = 1 in URL', function () { $fakeClient = new FakeNfsenClientForConsulta; $fakeClient->rawResponse = makeEventoResponse(); @@ -644,7 +754,7 @@ public function executeHead(string $url): int return 200; } - public function executeRaw(string $url, ?string $requiredField = null): HttpResponse + public function executeRaw(string $url, array $requiredStrings = [], array $requiredLists = []): HttpResponse { return new HttpResponse(200, [], ''); } @@ -680,7 +790,7 @@ public function executeHead(string $url): int return 200; } - public function executeRaw(string $url, ?string $requiredField = null): HttpResponse + public function executeRaw(string $url, array $requiredStrings = [], array $requiredLists = []): HttpResponse { return new HttpResponse(200, [], ''); } diff --git a/tests/Unit/Pipeline/NfseResponsePipelineTest.php b/tests/Unit/Pipeline/NfseResponsePipelineTest.php index bfbe033..0c59fa2 100644 --- a/tests/Unit/Pipeline/NfseResponsePipelineTest.php +++ b/tests/Unit/Pipeline/NfseResponsePipelineTest.php @@ -281,31 +281,88 @@ public function head(string $url): int $pipeline = buildResponsePipeline(rawResult: new HttpResponse(200, [], '{}')); - expect(fn () => $pipeline->executeRaw('https://example.com/eventos', 'eventoXmlGZipB64')) + expect(fn () => $pipeline->executeRaw('https://example.com/eventos', ['eventoXmlGZipB64'])) ->toThrow(IndeterminateResultException::class, 'eventoXmlGZipB64'); Event::assertDispatched(NfseFailed::class); Event::assertNotDispatched(NfseQueried::class); }); -it('throws IndeterminateResultException when requiredField is empty or not a string on executeRaw', function (mixed $value): void { +it('throws IndeterminateResultException when requiredField came empty on executeRaw', function (mixed $value): void { $pipeline = buildResponsePipeline(rawResult: new HttpResponse(200, ['eventoXmlGZipB64' => $value], '')); - expect(fn () => $pipeline->executeRaw('https://example.com/eventos', 'eventoXmlGZipB64')) + expect(fn () => $pipeline->executeRaw('https://example.com/eventos', ['eventoXmlGZipB64'])) ->toThrow(IndeterminateResultException::class, 'eventoXmlGZipB64'); })->with([ 'empty string' => [''], + 'empty list' => [[]], 'int' => [123], 'null' => [null], ]); +it('accepts a required field that came as a non-empty list, as the eventos route sends it', function (): void { + Event::fake(); + + $raw = new HttpResponse(200, ['eventos' => [['arquivoXml' => 'Z3ppcA==']]], ''); + $pipeline = buildResponsePipeline(rawResult: $raw); + + $response = $pipeline->executeRaw('https://example.com/eventos', ['eventoXmlGZipB64'], ['eventos']); + + expect($response)->toBe($raw); + Event::assertDispatched(NfseQueried::class); +}); + +it('accepts any one of the required fields, and rejects only when none came', function (): void { + $pipeline = buildResponsePipeline(rawResult: new HttpResponse(200, ['tipoAmbiente' => 1], '{"tipoAmbiente":1}')); + + expect(fn () => $pipeline->executeRaw('https://example.com/eventos', ['eventoXmlGZipB64'], ['eventos'])) + ->toThrow(IndeterminateResultException::class, '("eventoXmlGZipB64", "eventos")'); +}); + +it('treats a field that came in the wrong shape as absent, not as present', function (array $json, array $strings, array $lists): void { + // Um valor fora da forma que a operação lê seguiria para um construtor tipado + // e estouraria TypeError — fora do contrato de exceções do SDK. + $pipeline = buildResponsePipeline(rawResult: new HttpResponse(200, $json, '')); + + expect(fn () => $pipeline->executeRaw('https://example.com/consulta', $strings, $lists)) + ->toThrow(IndeterminateResultException::class); +})->with([ + 'texto que veio como lista' => [['chaveAcesso' => ['CHAVE']], ['chaveAcesso'], []], + 'lista que veio como texto' => [['eventos' => 'texto de proxy'], [], ['eventos']], + 'lista que veio como texto, com o texto alternativo ausente' => [['eventos' => 'texto de proxy'], ['eventoXmlGZipB64'], ['eventos']], +]); + +it('hands the response that motivated the failure to the exception and to NfseFailed', function (): void { + Event::fake(); + + $pipeline = buildResponsePipeline(rawResult: new HttpResponse(200, ['tipoAmbiente' => 1], '{"tipoAmbiente":1}')); + + $exception = null; + + try { + $pipeline->executeRaw('https://example.com/eventos', [], ['eventos']); + } catch (IndeterminateResultException $caught) { + $exception = $caught; + } + + expect($exception?->statusCode)->toBe(200) + ->and($exception?->body)->toBe('{"tipoAmbiente":1}') + ->and($exception?->raw)->toBe(['tipoAmbiente' => 1]); + + Event::assertDispatched( + NfseFailed::class, + fn (NfseFailed $e): bool => $e->throwable instanceof IndeterminateResultException + && $e->throwable->raw === ['tipoAmbiente' => 1], + ); +}); + it('returns response without requiredField check when body has structured error', function (): void { Event::fake(); $raw = new HttpResponse(400, ['erros' => [['codigo' => 'E1', 'descricao' => 'Rejeitada']]], ''); $pipeline = buildResponsePipeline(rawResult: $raw); - $response = $pipeline->executeRaw('https://example.com/eventos', 'eventoXmlGZipB64'); + $response = $pipeline->executeRaw('https://example.com/eventos', ['eventoXmlGZipB64']); expect($response)->toBe($raw); Event::assertDispatched(NfseRejected::class); @@ -317,7 +374,7 @@ public function head(string $url): int $raw = new HttpResponse(200, ['eventoXmlGZipB64' => 'Z3ppcA=='], ''); $pipeline = buildResponsePipeline(rawResult: $raw); - $response = $pipeline->executeRaw('https://example.com/eventos', 'eventoXmlGZipB64'); + $response = $pipeline->executeRaw('https://example.com/eventos', ['eventoXmlGZipB64']); expect($response)->toBe($raw); Event::assertDispatched(NfseQueried::class); diff --git a/tests/Unit/Support/GzipCompressorTest.php b/tests/Unit/Support/GzipCompressorTest.php index 582a839..9e6d0dc 100644 --- a/tests/Unit/Support/GzipCompressorTest.php +++ b/tests/Unit/Support/GzipCompressorTest.php @@ -29,6 +29,22 @@ expect(GzipCompressor::decompressB64($compressed))->toBe($original); }); +it('decompressB64 peels the second base64 layer of eventos[].arquivoXml', function () { + $original = ''; + $duasCamadas = base64_encode(base64_encode((string) gzencode($original))); + + expect(GzipCompressor::decompressB64($duasCamadas))->toBe($original); +}); + +it('decompressB64 does not salvage a corrupt second layer by discarding what does not belong to it', function () { + // Descartar caractere inválido em silêncio entregaria XML de um payload que + // chegou corrompido — o SDK diria "evento lido" sobre bytes que não conferem. + $camadaInterna = '!'.base64_encode((string) gzencode('')); + + expect(fn () => GzipCompressor::decompressB64(base64_encode($camadaInterna))) + ->toThrow(NfseException::class, 'descomprimir'); +}); + it('decompressB64 throws NfseException on invalid base64', function () { expect(fn () => GzipCompressor::decompressB64('!!!invalid-base64!!!')) ->toThrow(NfseException::class, 'base64');