From dd91d7f1669c7b67fdd45a8c04c65214ada0ed11 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Tue, 1 Sep 2026 00:28:33 -0300 Subject: [PATCH 01/21] chore(gitignore): ignora scripts/probes para nao vazar dados de conta Probes de contrato contra a API viva carregam ids de empresa, formato da conta e sequencia de chamadas. O repositorio e publico e o campo files do package.json governa apenas o pacote npm, nao o que fica visivel no GitHub. A regra entra antes de qualquer arquivo existir em scripts/probes/: o gitignore so age sobre o que o git ainda nao rastreia. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 19085d6..4708e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -149,6 +149,10 @@ results/ .env.production *.local +# Probes de contrato contra a API viva: carregam ids de empresa, formato da +# conta e sequencia de chamadas. Repositorio publico -- nunca commitar. +scripts/probes/ + # ---------------------------------------------------------------------------- # TypeScript # ---------------------------------------------------------------------------- From 547ead4d55631d74bd7b0af4fc85676fd618302d Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Tue, 1 Sep 2026 18:46:17 -0300 Subject: [PATCH 02/21] test(fixtures): contratos capturados ao vivo em 2026-09-01 Envelope real (status, content-type, Location, forma do corpo) com corpo sintetico: nenhum CNPJ, chave de acesso, id de empresa ou URL pre-assinada real entra no repositorio publico. A evidencia crua fica no vault. Consumidos pelos mocks de fix-consumer-invoices-contract e fix-binary-downloads-inbound, que dependiam de decisao de contrato que so a API viva responde. --- tests/fixtures/live-contracts/README.md | 12 ++++++++ .../live-contracts/api-key-host-matrix.json | 14 ++++++++++ .../certificate-upload-field.json | 15 ++++++++++ .../consumer-invoice-download.json | 18 ++++++++++++ .../live-contracts/inbound-download.json | 24 ++++++++++++++++ .../municipal-taxes-subroutes.json | 12 ++++++++ .../service-invoice-rtc-create.json | 15 ++++++++++ .../live-contracts/webhooks-wire-types.json | 28 +++++++++++++++++++ 8 files changed, 138 insertions(+) create mode 100644 tests/fixtures/live-contracts/README.md create mode 100644 tests/fixtures/live-contracts/api-key-host-matrix.json create mode 100644 tests/fixtures/live-contracts/certificate-upload-field.json create mode 100644 tests/fixtures/live-contracts/consumer-invoice-download.json create mode 100644 tests/fixtures/live-contracts/inbound-download.json create mode 100644 tests/fixtures/live-contracts/municipal-taxes-subroutes.json create mode 100644 tests/fixtures/live-contracts/service-invoice-rtc-create.json create mode 100644 tests/fixtures/live-contracts/webhooks-wire-types.json diff --git a/tests/fixtures/live-contracts/README.md b/tests/fixtures/live-contracts/README.md new file mode 100644 index 0000000..6be9ee4 --- /dev/null +++ b/tests/fixtures/live-contracts/README.md @@ -0,0 +1,12 @@ +# Fixtures de contrato ao vivo + +Capturados em 2026-09-01 pelo change `probe-live-api-contracts`. + +**Envelope real, corpo sintético.** Status, `content-type`, `Location` e a FORMA do corpo +vêm de respostas reais da API. Os valores dentro do corpo são fabricados: nenhum CNPJ, CPF, +chave de acesso, id de empresa, nome de contribuinte ou URL pré-assinada real entra aqui. + +A evidência crua (com dado real) vive fora deste repositório, no vault: +`SDKs/Node/probe-09-01-2026/`. + +Consumidos pelos mocks de `fix-consumer-invoices-contract` e `fix-binary-downloads-inbound`. diff --git a/tests/fixtures/live-contracts/api-key-host-matrix.json b/tests/fixtures/live-contracts/api-key-host-matrix.json new file mode 100644 index 0000000..cf82951 --- /dev/null +++ b/tests/fixtures/live-contracts/api-key-host-matrix.json @@ -0,0 +1,14 @@ +{ + "probe": "⑧ qual chave cada host aceita", + "capturedOn": "2026-09-01", + "note": "As duas chaves sao COMPLEMENTARES, nao alternativas. getCteHttpClient() usa a chave DATA num host FISCAL — 8 resources afetados.", + "matrix": [ + { "host": "api.nfse.io", "pathShape": "/tax-codes/operation-code", "mainKey": 200, "dataKey": 403 }, + { "host": "api.nfse.io", "pathShape": "/v2/companies/{id}/consumerinvoices", "mainKey": 200, "dataKey": 403 }, + { "host": "api.nfse.io", "pathShape": "/v2/companies/{id}/inbound/productinvoices", "mainKey": 404, "dataKey": 403, "note": "404 = empresa sem captura; o 403 e que e falha de auth" }, + { "host": "nfe.api.nfe.io", "pathShape": "/v3/productinvoices/serpro/{accessKey}.xml", "mainKey": 403, "dataKey": 400 }, + { "host": "legalentity.api.nfe.io", "pathShape": "/v1/legalentities/basicInfo/{taxNumber}", "mainKey": 403, "dataKey": 200 }, + { "host": "address.api.nfe.io", "pathShape": "/v2/addresses/{postalCode}", "mainKey": 403, "dataKey": 200 } + ], + "affectedResources": ["productInvoices", "transportationInvoices", "inboundProductInvoices", "municipalTaxes", "certificates", "stateTaxes", "taxCalculation", "companies (lado v2)"] +} diff --git a/tests/fixtures/live-contracts/certificate-upload-field.json b/tests/fixtures/live-contracts/certificate-upload-field.json new file mode 100644 index 0000000..824e0e9 --- /dev/null +++ b/tests/fixtures/live-contracts/certificate-upload-field.json @@ -0,0 +1,15 @@ +{ + "probe": "③ campo multipart do certificado", + "capturedOn": "2026-09-01", + "note": "O SDK envia 'certificate'; a API le 'file'/'File' (model binding case-insensitive).", + "pathShape": "/v1/companies/{companyId}/certificate", + "byFieldName": { + "certificate": { + "status": 400, + "headers": { "content-type": "application/json; charset=utf-8" }, + "body": { "title": "One or more validation errors occurred.", "status": 400, "errors": { "file": ["The File field is required."] } } + }, + "file": { "status": 500, "headers": { "content-type": "application/json; charset=utf-8" }, "body": { "title": "An error occurred while processing your request.", "status": 500 } }, + "File": { "status": 500, "headers": { "content-type": "application/json; charset=utf-8" }, "body": { "title": "An error occurred while processing your request.", "status": 500 } } + } +} diff --git a/tests/fixtures/live-contracts/consumer-invoice-download.json b/tests/fixtures/live-contracts/consumer-invoice-download.json new file mode 100644 index 0000000..1309a79 --- /dev/null +++ b/tests/fixtures/live-contracts/consumer-invoice-download.json @@ -0,0 +1,18 @@ +{ + "probe": "① NFC-e /pdf e /xml", + "capturedOn": "2026-09-01", + "note": "Accept e ignorado: pdf, xml e json devolvem o mesmo FileResource JSON.", + "requests": [ + { "method": "GET", "pathShape": "/v2/companies/{companyId}/consumerinvoices/{invoiceId}/pdf", "accept": "application/pdf" }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/consumerinvoices/{invoiceId}/pdf", "accept": "application/json" }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/consumerinvoices/{invoiceId}/xml", "accept": "application/xml" } + ], + "response": { + "status": 200, + "headers": { "content-type": "application/json; charset=utf-8" }, + "bodyLabel": "json", + "first16Hex": "7b 22 75 72 69 22 3a 22 68 74 74 70 73 3a 2f 2f", + "body": { "uri": "https://example.invalid/storage/synthetic-consumer-invoice.pdf?sig=SYNTHETIC" } + }, + "sdkToday": { "declaredReturn": "Promise", "actualTypeof": "object", "isBuffer": false, "keys": ["uri"] } +} diff --git a/tests/fixtures/live-contracts/inbound-download.json b/tests/fixtures/live-contracts/inbound-download.json new file mode 100644 index 0000000..9471bbe --- /dev/null +++ b/tests/fixtures/live-contracts/inbound-download.json @@ -0,0 +1,24 @@ +{ + "probe": "② entrada /xml e /pdf", + "capturedOn": "2026-09-01", + "note": "Nao existe binario nesta rota. Envelope DIFERENTE do NFC-e: publicTemporaryUri, nao uri.", + "requests": [ + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/{accessKey}/xml", "accept": "application/json" }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/{accessKey}/xml", "accept": "application/xml" }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/{accessKey}/pdf", "accept": "application/json" }, + { "method": "GET", "pathShape": "/v2/companies/{companyId}/inbound/{accessKey}/pdf", "accept": "application/pdf" } + ], + "response": { + "status": 200, + "headers": { "content-type": "application/json; charset=utf-8" }, + "bodyLabel": "json", + "first16Hex": "7b 22 70 75 62 6c 69 63 54 65 6d 70 6f 72 61 72", + "body": { "publicTemporaryUri": "https://example.invalid/storage/synthetic-inbound.xml?sig=SYNTHETIC" } + }, + "sdkToday": { "declaredReturn": "Promise", "actualTypeof": "object", "isBuffer": false, "keys": ["publicTemporaryUri"] }, + "withDataApiKeyConfigured": { + "status": 403, "headers": {}, "bodyLabel": "vazio", + "sdkError": "ValidationError: Access forbidden", + "note": "Bug de wiring: o resource usa a chave DATA num host fiscal." + } +} diff --git a/tests/fixtures/live-contracts/municipal-taxes-subroutes.json b/tests/fixtures/live-contracts/municipal-taxes-subroutes.json new file mode 100644 index 0000000..464e0f7 --- /dev/null +++ b/tests/fixtures/live-contracts/municipal-taxes-subroutes.json @@ -0,0 +1,12 @@ +{ + "probe": "⑥ updateprefecture / series", + "capturedOn": "2026-09-01", + "note": "Registro pai resolve 200, mas as duas sub-rotas 404 — suspeita de rotas mortas. Nenhuma escrita ocorreu.", + "results": [ + { "method": "GET", "pathShape": "/v2/companies/{id}/municipaltaxes/{mtId}", "status": 200 }, + { "method": "PATCH", "pathShape": "/v2/companies/{id}/municipaltaxes/{mtId}/updateprefecture", "requestBody": null, "status": 404, "bodyBytes": 0 }, + { "method": "PATCH", "pathShape": "/v2/companies/{id}/municipaltaxes/{mtId}/updateprefecture", "requestBody": {}, "status": 404, "bodyBytes": 0 }, + { "method": "PATCH", "pathShape": "/v2/companies/{id}/municipaltaxes/{mtId}/updateprefecture", "requestBody": { "municipalTax": {} }, "status": 404, "bodyBytes": 0, "note": "envelope que o SDK envia" }, + { "method": "GET", "pathShape": "/v2/companies/{id}/municipaltaxes/{mtId}/series/{serie}", "status": 404, "bodyBytes": 0 } + ] +} diff --git a/tests/fixtures/live-contracts/service-invoice-rtc-create.json b/tests/fixtures/live-contracts/service-invoice-rtc-create.json new file mode 100644 index 0000000..57c96a7 --- /dev/null +++ b/tests/fixtures/live-contracts/service-invoice-rtc-create.json @@ -0,0 +1,15 @@ +{ + "probe": "⑦ RTC de NFS-e responde 202+Location", + "capturedOn": "2026-09-01", + "note": "O ramo async do SDK esta correto. A spec documenta apenas 200/400 — incompleta. Location vem em http://, nao https://.", + "request": { "method": "POST", "pathShape": "/v1/companies/{companyId}/serviceinvoices", "requiredFields": ["cityServiceCode", "description", "servicesAmount", "nbsCode"] }, + "response": { + "status": 202, + "headers": { + "content-type": "application/json; charset=utf-8", + "location": "http://api.nfe.io/v1/companies/{companyId}/serviceinvoices/{invoiceId}" + }, + "bodyLabel": "json", + "body": { "id": "{invoiceId}", "environment": "Development", "flowStatus": "WaitingCalculateTaxes" } + } +} diff --git a/tests/fixtures/live-contracts/webhooks-wire-types.json b/tests/fixtures/live-contracts/webhooks-wire-types.json new file mode 100644 index 0000000..c146a10 --- /dev/null +++ b/tests/fixtures/live-contracts/webhooks-wire-types.json @@ -0,0 +1,28 @@ +{ + "probe": "④ casing de eventtypes + ⑤ tipos no fio", + "capturedOn": "2026-09-01", + "eventTypesCasing": { + "note": "Gateway case-insensitive: as duas grafias devolvem 200 com corpo identico.", + "/v2/webhooks/eventTypes": { "status": 200, "bytes": 9859 }, + "/v2/webhooks/eventtypes": { "status": 200, "bytes": 9859 } + }, + "webhookListing": { + "note": "contentType e status sao STRINGS no fio. A spec define integer enum [0,1] — spec errada.", + "status": 200, + "headers": { "content-type": "application/json; charset=utf-8" }, + "body": { + "webHooks": [ + { + "id": "{webhookId}", + "uri": "https://example.invalid/hooks/synthetic", + "contentType": "json", + "insecureSsl": false, + "status": "Active", + "filters": ["{eventType}"], + "createdOn": "2026-01-01T00:00:00Z", + "modifiedOn": "2026-01-01T00:00:00Z" + } + ] + } + } +} From b50bb741f5cd43e63588a67b014bd6e96dff6c2d Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Tue, 1 Sep 2026 20:43:02 -0300 Subject: [PATCH 03/21] fix(client): chave main nos hosts fiscais e campo 'file' no upload de certificado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dois bugs de contrato provados por sonda ao vivo contra a API real (2026-09-01). Em nenhum dos dois havia divergencia de spec: o SDK e que estava errado. 1) As duas chaves da plataforma sao complementares, nao intercambiaveis — cada uma responde 403 nos hosts da outra familia. O cliente HTTP de api.nfse.io resolvia a chave de DADOS num host FISCAL, afetando nove recursos: productInvoices, productInvoicesRtc, transportationInvoices, inboundProductInvoices, municipalTaxes, certificates, stateTaxes, taxCalculation e o lado v2 de companies. Funcionavam por acidente: quem configurava so apiKey caia no fallback dataApiKey -> apiKey. Quem configurava dataApiKey tomava 403. getCteHttpClient e getNfseMainHttpClient apontavam para o MESMO host com chaves diferentes — duas regras contraditorias. Fundidos em getNfseHttpClient. BREAKING: cliente configurado SO com dataApiKey deixa de acessar os nove recursos fiscais e lanca ConfigurationError no acesso, em vez de falhar com 403 na chamada. 2) uploadCertificate enviava o campo multipart 'certificate'; a API faz binding de 'file' e respondia 400 {"errors":{"file":["The File field is required."]}}. O metodo nunca tinha como completar. A suite existente afirmava o comportamento errado como correto e so verificava que o acesso nao lancava, nunca qual chave ia para o fio — por isso o bug sobreviveu. O teste novo afirma o header por familia de host. --- CHANGELOG.md | 32 +++++ src/core/client.ts | 94 +++++-------- src/core/resources/companies.ts | 9 +- src/core/types.ts | 27 +++- tests/unit/client-api-key-wiring.test.ts | 130 ++++++++++++++++++ tests/unit/client-multikey.test.ts | 90 ++++++------ .../unit/companies-certificate-field.test.ts | 83 +++++++++++ tests/unit/companies.test.ts | 6 +- 8 files changed, 356 insertions(+), 115 deletions(-) create mode 100644 tests/unit/client-api-key-wiring.test.ts create mode 100644 tests/unit/companies-certificate-field.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 011cc15..098a415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ Todas as mudanças notáveis neste projeto serão documentadas neste arquivo. O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/), e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). +## [Não lançado] + +> Dois bugs de contrato provados por sonda ao vivo contra a API real (2026-09-01). +> Nenhum dos dois era divergência de especificação: em ambos o SDK estava errado. +> Evidência versionada em `tests/fixtures/live-contracts/`. + +### Corrigido + +- **Credencial errada em nove recursos fiscais.** As duas chaves da plataforma são + **complementares, não intercambiáveis** — cada uma responde `403` nos hosts da + outra família. O cliente HTTP de `api.nfse.io` resolvia a **chave de dados** num + host **fiscal**, afetando `productInvoices`, `productInvoicesRtc`, + `transportationInvoices`, `inboundProductInvoices`, `municipalTaxes`, + `certificates`, `stateTaxes`, `taxCalculation` e o lado v2 de `companies`. + + Esses recursos só funcionavam por acidente: quem configurava **apenas** `apiKey` + caía no fallback `dataApiKey → apiKey` e nunca via o problema. Quem configurava + `dataApiKey` — o que a documentação recomenda para consultas — tomava `403`. + + **Como migrar:** se você usa `dataApiKey`, nada a fazer — os nove recursos passam + a funcionar. Se você configurava **somente** `dataApiKey` e acessava algum deles, + agora é preciso informar também `apiKey`: o acesso lança `ConfigurationError` na + hora, em vez de falhar com `403` na chamada. + + O mapa de qual chave vale em qual host está documentado em + `NfeConfig.apiKey` / `NfeConfig.dataApiKey`. + +- **`companies.uploadCertificate()` nunca funcionou.** O campo multipart era enviado + como `certificate`; a API faz binding de `file` e respondia + `400 {"errors":{"file":["The File field is required."]}}` — ou seja, o método não + tinha como completar. A assinatura pública não mudou. + ## [5.2.0] - 2026-07-13 > Correção do contrato de paginação de `companies` contra a API real, provado diff --git a/src/core/client.ts b/src/core/client.ts index a65dda4..629df3b 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -146,8 +146,7 @@ export class NfeClient { private _addressHttp: HttpClient | undefined; /** @internal HTTP client for CT-e API requests (created lazily) */ - private _cteHttp: HttpClient | undefined; - private _nfseMainHttp: HttpClient | undefined; + private _nfseHttp: HttpClient | undefined; private _webhooksAccountHttp: HttpClient | undefined; /** @internal HTTP client for NF-e query API requests (created lazily) */ @@ -238,7 +237,7 @@ export class NfeClient { */ get companies(): CompaniesResource { if (!this._companies) { - this._companies = new CompaniesResource(this.getMainHttpClient(), this.getCteHttpClient()); + this._companies = new CompaniesResource(this.getMainHttpClient(), this.getNfseHttpClient()); } return this._companies; } @@ -374,10 +373,10 @@ export class NfeClient { * - Webhook must be configured to receive CT-e notifications * * **Note:** This resource uses a different API host (api.nfse.io). - * Configure `dataApiKey` for a separate key, or it will fallback to `apiKey`. + * Uses the main `apiKey` — `api.nfse.io` is a FISCAL host and rejects the data key with 403. * * @see {@link TransportationInvoicesResource} - * @throws {ConfigurationError} If no API key is configured (dataApiKey or apiKey) + * @throws {ConfigurationError} If no main API key is configured (apiKey) * * @example * ```typescript @@ -393,7 +392,7 @@ export class NfeClient { */ get transportationInvoices(): TransportationInvoicesResource { if (!this._transportationInvoices) { - this._transportationInvoices = new TransportationInvoicesResource(this.getCteHttpClient()); + this._transportationInvoices = new TransportationInvoicesResource(this.getNfseHttpClient()); } return this._transportationInvoices; } @@ -415,10 +414,10 @@ export class NfeClient { * - Webhook must be configured to receive NF-e notifications * * **Note:** This resource uses a different API host (api.nfse.io). - * Configure `dataApiKey` for a separate key, or it will fallback to `apiKey`. + * Uses the main `apiKey` — `api.nfse.io` is a FISCAL host and rejects the data key with 403. * * @see {@link InboundProductInvoicesResource} - * @throws {ConfigurationError} If no API key is configured (dataApiKey or apiKey) + * @throws {ConfigurationError} If no main API key is configured (apiKey) * * @example * ```typescript @@ -438,7 +437,7 @@ export class NfeClient { */ get inboundProductInvoices(): InboundProductInvoicesResource { if (!this._inboundProductInvoices) { - this._inboundProductInvoices = new InboundProductInvoicesResource(this.getCteHttpClient()); + this._inboundProductInvoices = new InboundProductInvoicesResource(this.getNfseHttpClient()); } return this._inboundProductInvoices; } @@ -596,10 +595,10 @@ export class NfeClient { * IPI, II) on product operations. * * **Note:** This resource uses a different API host (api.nfse.io). - * Configure `dataApiKey` for a separate key, or it will fallback to `apiKey`. + * Uses the main `apiKey` — `api.nfse.io` is a FISCAL host and rejects the data key with 403. * * @see {@link TaxCalculationResource} - * @throws {ConfigurationError} If no API key is configured (dataApiKey or apiKey) + * @throws {ConfigurationError} If no main API key is configured (apiKey) * * @example * ```typescript @@ -616,7 +615,7 @@ export class NfeClient { */ get taxCalculation(): TaxCalculationResource { if (!this._taxCalculation) { - this._taxCalculation = new TaxCalculationResource(this.getCteHttpClient()); + this._taxCalculation = new TaxCalculationResource(this.getNfseHttpClient()); } return this._taxCalculation; } @@ -630,11 +629,11 @@ export class NfeClient { * acquisition purposes, issuer tax profiles, and recipient tax profiles. * * **Note:** This resource uses a different API host (api.nfse.io). - * Configure `dataApiKey` for a separate key, or it will fallback to `apiKey`. + * Uses the main `apiKey` — `api.nfse.io` is a FISCAL host and rejects the data key with 403. * * @see {@link TaxCodesResource} * @see {@link TaxCalculationResource} - * @throws {ConfigurationError} If no API key is configured (dataApiKey or apiKey) + * @throws {ConfigurationError} If no main API key is configured (apiKey) * * @example * ```typescript @@ -646,7 +645,7 @@ export class NfeClient { */ get taxCodes(): TaxCodesResource { if (!this._taxCodes) { - this._taxCodes = new TaxCodesResource(this.getNfseMainHttpClient()); + this._taxCodes = new TaxCodesResource(this.getNfseHttpClient()); } return this._taxCodes; } @@ -660,10 +659,10 @@ export class NfeClient { * disable invoice numbers, and download files (PDF/XML). * * **Note:** This resource uses the api.nfse.io host. - * Configure `dataApiKey` for a separate key, or it will fallback to `apiKey`. + * Uses the main `apiKey` — `api.nfse.io` is a FISCAL host and rejects the data key with 403. * * @see {@link ProductInvoicesResource} - * @throws {ConfigurationError} If no API key is configured (dataApiKey or apiKey) + * @throws {ConfigurationError} If no main API key is configured (apiKey) * * @example * ```typescript @@ -673,7 +672,7 @@ export class NfeClient { */ get productInvoices(): ProductInvoicesResource { if (!this._productInvoices) { - this._productInvoices = new ProductInvoicesResource(this.getCteHttpClient()); + this._productInvoices = new ProductInvoicesResource(this.getNfseHttpClient()); } return this._productInvoices; } @@ -686,10 +685,10 @@ export class NfeClient { * NF-e product invoice issuance — list, create, retrieve, update, and delete. * * **Note:** This resource uses the api.nfse.io host. - * Configure `dataApiKey` for a separate key, or it will fallback to `apiKey`. + * Uses the main `apiKey` — `api.nfse.io` is a FISCAL host and rejects the data key with 403. * * @see {@link StateTaxesResource} - * @throws {ConfigurationError} If no API key is configured (dataApiKey or apiKey) + * @throws {ConfigurationError} If no main API key is configured (apiKey) * * @example * ```typescript @@ -699,7 +698,7 @@ export class NfeClient { */ get stateTaxes(): StateTaxesResource { if (!this._stateTaxes) { - this._stateTaxes = new StateTaxesResource(this.getCteHttpClient()); + this._stateTaxes = new StateTaxesResource(this.getNfseHttpClient()); } return this._stateTaxes; } @@ -724,7 +723,7 @@ export class NfeClient { */ get productInvoicesRtc(): ProductInvoicesRtcResource { if (!this._productInvoicesRtc) { - this._productInvoicesRtc = new ProductInvoicesRtcResource(this.getCteHttpClient()); + this._productInvoicesRtc = new ProductInvoicesRtcResource(this.getNfseHttpClient()); } return this._productInvoicesRtc; } @@ -735,7 +734,7 @@ export class NfeClient { */ get municipalTaxes(): MunicipalTaxesResource { if (!this._municipalTaxes) { - this._municipalTaxes = new MunicipalTaxesResource(this.getCteHttpClient()); + this._municipalTaxes = new MunicipalTaxesResource(this.getNfseHttpClient()); } return this._municipalTaxes; } @@ -747,7 +746,7 @@ export class NfeClient { */ get consumerInvoices(): ConsumerInvoicesResource { if (!this._consumerInvoices) { - this._consumerInvoices = new ConsumerInvoicesResource(this.getNfseMainHttpClient()); + this._consumerInvoices = new ConsumerInvoicesResource(this.getNfseHttpClient()); } return this._consumerInvoices; } @@ -759,7 +758,7 @@ export class NfeClient { */ get certificates(): CertificatesResource { if (!this._certificates) { - this._certificates = new CertificatesResource(this.getCteHttpClient()); + this._certificates = new CertificatesResource(this.getNfseHttpClient()); } return this._certificates; } @@ -904,36 +903,18 @@ export class NfeClient { } /** - * Get or create the CT-e API HTTP client - * @throws {ConfigurationError} If no API key is configured - */ - private getCteHttpClient(): HttpClient { - if (!this._cteHttp) { - const apiKey = this.resolveDataApiKey(); - if (!apiKey) { - throw new ConfigurationError( - 'API key required for data services. Set "dataApiKey" or "apiKey" in config, or NFE_DATA_API_KEY/NFE_API_KEY environment variable.' - ); - } - const httpConfig = buildHttpConfig( - apiKey, - CTE_API_BASE_URL, - this.config.timeout, - this.config.retryConfig - ); - this._cteHttp = new HttpClient(httpConfig); - } - return this._cteHttp; - } - - /** - * Get or create the HTTP client for api.nfse.io resources that require the - * MAIN api key (not the data key) — e.g. tax-codes and consumer invoices - * (NFC-e emission). Host is CTE_API_BASE_URL (api.nfse.io); key is the main key. + * Get or create the HTTP client for `api.nfse.io` — the FISCAL host. + * + * Every resource on this host uses the MAIN api key. The two platform keys are + * complementary, not interchangeable: the data key is rejected with 403 here, + * and the main key is rejected with 403 on the lookup hosts + * (`nfe.api.nfe.io`, `legalentity`, `naturalperson`, `address`). + * Verified live on 2026-09-01 — see tests/fixtures/live-contracts/api-key-host-matrix.json. + * * @throws {ConfigurationError} If no main API key is configured */ - private getNfseMainHttpClient(): HttpClient { - if (!this._nfseMainHttp) { + private getNfseHttpClient(): HttpClient { + if (!this._nfseHttp) { const apiKey = this.resolveMainApiKey(); if (!apiKey) { throw new ConfigurationError( @@ -946,9 +927,9 @@ export class NfeClient { this.config.timeout, this.config.retryConfig ); - this._nfseMainHttp = new HttpClient(httpConfig); + this._nfseHttp = new HttpClient(httpConfig); } - return this._nfseMainHttp; + return this._nfseHttp; } /** @@ -1192,8 +1173,7 @@ export class NfeClient { // HTTP clients this._http = undefined; this._addressHttp = undefined; - this._cteHttp = undefined; - this._nfseMainHttp = undefined; + this._nfseHttp = undefined; this._webhooksAccountHttp = undefined; this._nfeQueryHttp = undefined; this._legalEntityHttp = undefined; diff --git a/src/core/resources/companies.ts b/src/core/resources/companies.ts index 8439845..b7abd6c 100644 --- a/src/core/resources/companies.ts +++ b/src/core/resources/companies.ts @@ -544,11 +544,14 @@ export class CompaniesResource { // Create FormData for file upload const formData = this.createFormData(); - // Add certificate file + // Field name MUST be `file`: the API binds this multipart field and rejects + // anything else with 400 `{"errors":{"file":["The File field is required."]}}`. + // Verified live on 2026-09-01 — the previous name (`certificate`) meant this + // method could never succeed. See tests/fixtures/live-contracts/certificate-upload-field.json. if (certificateData.filename) { - formData.append('certificate', certificateData.file, certificateData.filename); + formData.append('file', certificateData.file, certificateData.filename); } else { - formData.append('certificate', certificateData.file); + formData.append('file', certificateData.file); } // Add password diff --git a/src/core/types.ts b/src/core/types.ts index 806ced8..ad8bdab 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -15,9 +15,30 @@ // ---------------------------------------------------------------------------- export interface NfeConfig { - /** NFE.io API Key for main resources (companies, invoices, etc.) */ + /** + * API key for every FISCAL host — `api.nfe.io` and `api.nfse.io`. + * + * Covers companies, service/product/consumer invoices (incl. RTC), certificates, + * municipal and state taxes, tax calculation, tax codes, webhooks, and the + * inbound CT-e / NF-e distribution resources. + */ apiKey?: string; - /** NFE.io API Key for data/query services: Addresses, CT-e, CNPJ, CPF (optional, falls back to apiKey) */ + /** + * API key for the LOOKUP hosts — `nfe.api.nfe.io`, `legalentity.api.nfe.io`, + * `naturalperson.api.nfe.io`, `address.api.nfe.io`. + * + * Covers address (CEP), legal entity (CNPJ), natural person (CPF) and the + * product/consumer invoice *query* resources. + * + * **The two keys are NOT interchangeable.** Each is rejected with HTTP 403 on + * the other family's hosts — verified live on 2026-09-01, see + * `tests/fixtures/live-contracts/api-key-host-matrix.json`. Setting this key + * does not affect any fiscal resource. + * + * Optional: falls back to {@link NfeConfig.apiKey} when omitted. Note the + * fallback is one-way — a client configured with ONLY `dataApiKey` cannot + * reach fiscal resources and throws `ConfigurationError` when one is accessed. + */ dataApiKey?: string; /** Environment to use (both use same endpoint, differentiated by API key) */ environment?: 'production' | 'development'; @@ -395,7 +416,7 @@ export interface PollOptions { export interface RequiredNfeConfig { /** Main API key (may be undefined if only using data services) */ apiKey: string | undefined; - /** Data API key for query services: Addresses, CT-e, CNPJ, CPF (may be undefined, will fallback to apiKey) */ + /** Data API key for the lookup hosts (address, CNPJ, CPF, invoice query). Not valid on fiscal hosts. May be undefined; falls back to apiKey. */ dataApiKey: string | undefined; /** Environment */ environment: 'production' | 'development'; diff --git a/tests/unit/client-api-key-wiring.test.ts b/tests/unit/client-api-key-wiring.test.ts new file mode 100644 index 0000000..d6cccc1 --- /dev/null +++ b/tests/unit/client-api-key-wiring.test.ts @@ -0,0 +1,130 @@ +/** + * Qual chave sai no fio, por família de host. + * + * As duas chaves da plataforma são COMPLEMENTARES, não alternativas — cada uma + * responde 403 no território da outra (probe ao vivo 2026-09-01, evidência em + * tests/fixtures/live-contracts/api-key-host-matrix.json): + * + * api.nfse.io / api.nfe.io (fiscal) → apiKey + * nfe.api.nfe.io, legalentity, naturalperson, address (consulta) → dataApiKey + * + * A suíte existente (client-multikey.test.ts) só afirmava que acessar o resource + * NÃO LANÇA — nunca qual chave era usada. Foi por isso que o wiring errado de + * nove resources fiscais sobreviveu. Estes testes afirmam o header. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NfeClient } from '../../src/core/client.js'; + +const MAIN_KEY = 'main-key-fiscal'; +const DATA_KEY = 'data-key-consulta'; +const COMPANY_ID = '00000000000000000000000000000001'; + +function mockHeaders(entries: [string, string][]): any { + const map = new Map(entries.map(([k, v]) => [k.toLowerCase(), v])); + return { + get: (k: string) => map.get(k.toLowerCase()) ?? null, + has: (k: string) => map.has(k.toLowerCase()), + entries: () => map.entries(), + keys: () => map.keys(), + values: () => map.values(), + forEach: (cb: (v: string, k: string) => void) => map.forEach((v, k) => cb(v, k)), + }; +} + +describe('resolução de credencial por família de host', () => { + let fetchMock: ReturnType; + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.NFE_API_KEY; + delete process.env.NFE_DATA_API_KEY; + + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: mockHeaders([['content-type', 'application/json']]), + json: async () => ({}), + text: async () => '{}', + arrayBuffer: async () => new ArrayBuffer(0), + }); + global.fetch = fetchMock as any; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + /** Dispara a chamada e devolve a chave que foi para o header X-NFE-APIKEY. */ + async function keyOnWire(call: (c: NfeClient) => Promise): Promise { + const client = new NfeClient({ apiKey: MAIN_KEY, dataApiKey: DATA_KEY }); + await call(client).catch(() => undefined); // erros de parse não importam aqui + expect(fetchMock).toHaveBeenCalled(); + const init = fetchMock.mock.calls[0]![1] as { headers: Record }; + return init.headers['X-NFE-APIKEY']!; + } + + describe('hosts fiscais usam a chave main', () => { + const fiscais: Array<[string, (c: NfeClient) => Promise]> = [ + ['companies.exists (v2)', (c) => c.companies.exists(COMPANY_ID)], + ['certificates.list', (c) => c.certificates.list(COMPANY_ID)], + ['municipalTaxes.list', (c) => c.municipalTaxes.list(COMPANY_ID)], + ['stateTaxes.list', (c) => c.stateTaxes.list(COMPANY_ID)], + ['transportationInvoices.getSettings', (c) => c.transportationInvoices.getSettings(COMPANY_ID)], + ['inboundProductInvoices.getSettings', (c) => c.inboundProductInvoices.getSettings(COMPANY_ID)], + ['productInvoices.list', (c) => c.productInvoices.list(COMPANY_ID, { environment: 'Test' } as never)], + ['productInvoicesRtc.create', (c) => c.productInvoicesRtc.create(COMPANY_ID, {} as never)], + ['taxCalculation.calculate', (c) => c.taxCalculation.calculate('tenant-x', { + issuer: { taxRegime: 'SimplesNacional', state: 'PR' }, + recipient: { state: 'SP' }, + operationType: 'Sale', + items: [{ quantity: 1, unitPrice: 1 }], + } as never)], + ['taxCodes.listOperationCodes (controle: já correto)', (c) => c.taxCodes.listOperationCodes()], + ['consumerInvoices.list (controle: já correto)', (c) => c.consumerInvoices.list(COMPANY_ID, { environment: 'Test' } as never)], + ]; + + it.each(fiscais)('%s manda a chave main', async (_nome, call) => { + expect(await keyOnWire(call)).toBe(MAIN_KEY); + }); + }); + + describe('hosts de consulta usam a chave data', () => { + const consulta: Array<[string, (c: NfeClient) => Promise]> = [ + ['addresses.lookupByPostalCode', (c) => c.addresses.lookupByPostalCode('01310100')], + ['legalEntityLookup.getBasicInfo', (c) => c.legalEntityLookup.getBasicInfo('11111111000191')], + ]; + + it.each(consulta)('%s manda a chave data', async (_nome, call) => { + expect(await keyOnWire(call)).toBe(DATA_KEY); + }); + }); + + describe('configuração com uma chave só', () => { + it('apenas apiKey: tudo continua funcionando (fallback data → main)', async () => { + const client = new NfeClient({ apiKey: MAIN_KEY }); + await client.municipalTaxes.list(COMPANY_ID).catch(() => undefined); + await client.addresses.lookupByPostalCode('01310100').catch(() => undefined); + + for (const call of fetchMock.mock.calls) { + const init = call[1] as { headers: Record }; + expect(init.headers['X-NFE-APIKEY']).toBe(MAIN_KEY); + } + }); + + it('apenas dataApiKey: resources fiscais falham rápido, sem ir à rede', () => { + const client = new NfeClient({ dataApiKey: DATA_KEY }); + + // Consulta continua disponível. + expect(() => client.addresses).not.toThrow(); + + // Fiscais exigem a main: erro de configuração no acesso, não 403 depois. + expect(() => client.municipalTaxes).toThrow(/API key required/); + expect(() => client.transportationInvoices).toThrow(/API key required/); + expect(() => client.certificates).toThrow(/API key required/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/client-multikey.test.ts b/tests/unit/client-multikey.test.ts index 3f756a6..e6e4717 100644 --- a/tests/unit/client-multikey.test.ts +++ b/tests/unit/client-multikey.test.ts @@ -2,9 +2,16 @@ * Unit tests for multi-API key functionality * Tests lazy getter validation and API key fallback chain * - * API key architecture: - * - apiKey: for fiscal document operations (NFS-e, Companies, etc.) - * - dataApiKey: for all data/query services (Addresses, CT-e, CNPJ, CPF) + * API key architecture (verified live 2026-09-01 — the two keys are complementary, + * each rejected with 403 on the other family's hosts): + * - apiKey: every FISCAL host — api.nfe.io and api.nfse.io. Includes the inbound + * CT-e / NF-e distribution resources, which live on api.nfse.io despite reading + * like lookups. + * - dataApiKey: the LOOKUP hosts only — address, legalentity, naturalperson, + * nfe.api.nfe.io (invoice query). + * + * Which key reaches the wire is asserted in client-api-key-wiring.test.ts; this + * file covers the fallback chain and lazy-getter validation. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; @@ -149,24 +156,10 @@ describe('NfeClient Multi-API Key Support', () => { }); }); - describe('API key fallback chain for data services (CT-e)', () => { - it('should use dataApiKey from config', () => { - const client = new NfeClient({ dataApiKey: 'data-key' }); - - expect(() => client.transportationInvoices).not.toThrow(); - }); - - it('should fall back to apiKey from config', () => { + describe('API key chain for inbound CT-e (FISCAL host api.nfse.io)', () => { + it('should use apiKey from config', () => { const client = new NfeClient({ apiKey: 'main-key' }); - // Should use main apiKey for CTE when dataApiKey not specified - expect(() => client.transportationInvoices).not.toThrow(); - }); - - it('should fall back to NFE_DATA_API_KEY environment variable', () => { - process.env.NFE_DATA_API_KEY = 'env-data-key'; - const client = new NfeClient({}); - expect(() => client.transportationInvoices).not.toThrow(); }); @@ -177,55 +170,52 @@ describe('NfeClient Multi-API Key Support', () => { expect(() => client.transportationInvoices).not.toThrow(); }); - it('should prefer dataApiKey over apiKey', () => { - const client = new NfeClient({ - apiKey: 'main-key', - dataApiKey: 'data-key', - }); + it('should NOT accept dataApiKey alone (fiscal host rejects the data key)', () => { + const client = new NfeClient({ dataApiKey: 'data-key' }); - expect(() => client.transportationInvoices).not.toThrow(); - const config = client.getConfig(); - expect(config.dataApiKey).toBe('data-key'); + expect(() => client.transportationInvoices).toThrow(ConfigurationError); + expect(() => client.transportationInvoices).toThrow(/API key required/); }); - it('should prefer config keys over environment variables', () => { + it('should NOT accept NFE_DATA_API_KEY alone', () => { process.env.NFE_DATA_API_KEY = 'env-data-key'; - process.env.NFE_API_KEY = 'env-main-key'; + const client = new NfeClient({}); - const client = new NfeClient({ dataApiKey: 'config-data-key' }); + expect(() => client.transportationInvoices).toThrow(ConfigurationError); + }); + + it('should ignore dataApiKey when both are set', () => { + const client = new NfeClient({ apiKey: 'main-key', dataApiKey: 'data-key' }); expect(() => client.transportationInvoices).not.toThrow(); - const config = client.getConfig(); - expect(config.dataApiKey).toBe('config-data-key'); }); it('should throw ConfigurationError when accessing transportationInvoices without any apiKey', () => { const client = new NfeClient({}); expect(() => client.transportationInvoices).toThrow(ConfigurationError); - expect(() => client.transportationInvoices).toThrow(/dataApiKey|apiKey/); + expect(() => client.transportationInvoices).toThrow(/API key required/); }); }); - describe('both data services resolve same key', () => { - it('should use the same dataApiKey for both addresses and transportationInvoices', () => { + describe('lookup services share the data key', () => { + it('should use the same dataApiKey for addresses and the lookup resources', () => { const client = new NfeClient({ dataApiKey: 'shared-data-key' }); - // Both should work with the same key expect(() => client.addresses).not.toThrow(); - expect(() => client.transportationInvoices).not.toThrow(); + expect(() => client.legalEntityLookup).not.toThrow(); + expect(() => client.naturalPersonLookup).not.toThrow(); - // Verify config has the shared key const config = client.getConfig(); expect(config.dataApiKey).toBe('shared-data-key'); }); - it('should use NFE_DATA_API_KEY env var for both addresses and transportationInvoices', () => { + it('should use NFE_DATA_API_KEY env var for the lookup resources', () => { process.env.NFE_DATA_API_KEY = 'env-shared-key'; const client = new NfeClient({}); expect(() => client.addresses).not.toThrow(); - expect(() => client.transportationInvoices).not.toThrow(); + expect(() => client.legalEntityLookup).not.toThrow(); }); }); @@ -233,13 +223,15 @@ describe('NfeClient Multi-API Key Support', () => { it('should allow using only data services with dataApiKey (no apiKey)', () => { const client = new NfeClient({ dataApiKey: 'data-only-key' }); - // Data services should work + // Lookup services should work expect(() => client.addresses).not.toThrow(); - expect(() => client.transportationInvoices).not.toThrow(); + expect(() => client.legalEntityLookup).not.toThrow(); - // Fiscal resources should throw + // Fiscal resources should throw — including the inbound ones on api.nfse.io expect(() => client.serviceInvoices).toThrow(ConfigurationError); expect(() => client.companies).toThrow(ConfigurationError); + expect(() => client.transportationInvoices).toThrow(ConfigurationError); + expect(() => client.inboundProductInvoices).toThrow(ConfigurationError); }); it('should allow using only main resources with apiKey (no dataApiKey)', () => { @@ -293,7 +285,7 @@ describe('NfeClient Multi-API Key Support', () => { }); it('should cache transportationInvoices resource', () => { - const client = new NfeClient({ dataApiKey: 'test-key' }); + const client = new NfeClient({ apiKey: 'test-key' }); const transportationInvoices1 = client.transportationInvoices; const transportationInvoices2 = client.transportationInvoices; @@ -314,17 +306,17 @@ describe('NfeClient Multi-API Key Support', () => { expect(serviceInvoices1).not.toBe(serviceInvoices2); }); - it('should clear data service cache on updateConfig with dataApiKey', () => { + it('should clear lookup cache on updateConfig with dataApiKey', () => { const client = new NfeClient({ dataApiKey: 'initial-key' }); - const transportationInvoices1 = client.transportationInvoices; + const addressesBefore = client.addresses; client.updateConfig({ dataApiKey: 'new-key' }); - const transportationInvoices2 = client.transportationInvoices; + const addressesAfter = client.addresses; // Resource should be a new instance - expect(transportationInvoices1).not.toBe(transportationInvoices2); + expect(addressesBefore).not.toBe(addressesAfter); }); }); @@ -345,7 +337,7 @@ describe('NfeClient Multi-API Key Support', () => { ); }); - it('should have descriptive error for missing data API key (transportationInvoices)', () => { + it('should have descriptive error for missing main API key (transportationInvoices)', () => { const client = new NfeClient({}); expect(() => client.transportationInvoices).toThrow( diff --git a/tests/unit/companies-certificate-field.test.ts b/tests/unit/companies-certificate-field.test.ts new file mode 100644 index 0000000..ab09712 --- /dev/null +++ b/tests/unit/companies-certificate-field.test.ts @@ -0,0 +1,83 @@ +/** + * Nome do campo multipart no upload de certificado. + * + * A API faz binding do campo `file`. Com qualquer outro nome ela responde + * 400 `{"errors":{"file":["The File field is required."]}}` — ou seja, o método + * nunca completava enquanto enviava `certificate`. + * Verificado ao vivo em 2026-09-01; evidência em + * tests/fixtures/live-contracts/certificate-upload-field.json. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NfeClient } from '../../src/core/client.js'; + +const COMPANY_ID = '00000000000000000000000000000001'; + +function mockHeaders(entries: [string, string][]): any { + const map = new Map(entries.map(([k, v]) => [k.toLowerCase(), v])); + return { + get: (k: string) => map.get(k.toLowerCase()) ?? null, + has: (k: string) => map.has(k.toLowerCase()), + entries: () => map.entries(), + keys: () => map.keys(), + values: () => map.values(), + forEach: (cb: (v: string, k: string) => void) => map.forEach((v, k) => cb(v, k)), + }; +} + +describe('companies.uploadCertificate — campo multipart', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: mockHeaders([['content-type', 'application/json']]), + json: async () => ({ uploaded: true }), + text: async () => '{"uploaded":true}', + arrayBuffer: async () => new ArrayBuffer(0), + }); + global.fetch = fetchMock as any; + }); + + afterEach(() => vi.restoreAllMocks()); + + async function sentFormData(filename?: string): Promise { + const client = new NfeClient({ apiKey: 'main-key' }); + // Blob evita o CertificateValidator, que só roda em Buffer e abortaria antes do envio. + const file = new Blob([new Uint8Array([0, 1, 2, 3])], { type: 'application/x-pkcs12' }); + + await client.companies + .uploadCertificate(COMPANY_ID, { + file, + password: 'senha', + ...(filename ? { filename } : {}), + }) + .catch(() => undefined); + + expect(fetchMock).toHaveBeenCalled(); + const init = fetchMock.mock.calls[0]![1] as { body: FormData }; + return init.body; + } + + it('envia o arquivo sob o campo `file`, não `certificate`', async () => { + const body = await sentFormData(); + + expect(body.has('file')).toBe(true); + expect(body.has('certificate')).toBe(false); + }); + + it('mantém `file` quando um filename é informado', async () => { + const body = await sentFormData('cert.pfx'); + + expect(body.has('file')).toBe(true); + expect(body.has('certificate')).toBe(false); + }); + + it('envia a senha sob o campo `password`', async () => { + const body = await sentFormData(); + + expect(body.get('password')).toBe('senha'); + }); +}); diff --git a/tests/unit/companies.test.ts b/tests/unit/companies.test.ts index cb9a702..9cb996b 100644 --- a/tests/unit/companies.test.ts +++ b/tests/unit/companies.test.ts @@ -328,7 +328,7 @@ describe('CompaniesResource', () => { expect(result.uploaded).toBe(true); expect(result.message).toBe('Certificate uploaded successfully'); - expect(mockFormData.append).toHaveBeenCalledWith('certificate', certificateBuffer); + expect(mockFormData.append).toHaveBeenCalledWith('file', certificateBuffer); expect(mockFormData.append).toHaveBeenCalledWith('password', 'secret123'); expect(mockHttpClient.post).toHaveBeenCalledWith( `/companies/${TEST_COMPANY_ID}/certificate`, @@ -359,7 +359,7 @@ describe('CompaniesResource', () => { expect(result.uploaded).toBe(true); expect(mockFormData.append).toHaveBeenCalledWith( - 'certificate', + 'file', certificateBuffer, 'company-cert.pfx' ); @@ -383,7 +383,7 @@ describe('CompaniesResource', () => { await companies.uploadCertificate(TEST_COMPANY_ID, certificateData); expect(mockFormData.append).toHaveBeenCalledWith( - 'certificate', + 'file', certificateBlob, 'cert.p12' ); From 32787418406c8e51e6d8221fbc4a06ca0fe38dbe Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Tue, 1 Sep 2026 20:51:21 -0300 Subject: [PATCH 04/21] fix(inbound): downloads devolvem file-resource tipado em vez de string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As rotas de entrada (/inbound/{chave}/xml, /pdf e /events/{evento}/xml, compartilhadas por CT-e e NF-e Distribuicao) respondem com um objeto { publicTemporaryUri } — URL pre-assinada e temporaria. Binario NUNCA trafega nessas rotas, e o header Accept nao altera a resposta. Provado por sonda ao vivo em 2026-09-01 sobre chaves reais de entrada. Isso FALSIFICA a premissa da review de 07/2026, que supunha PDF binario chegando como string e corrompendo bytes: nao ha bytes. O plano original (trocar para http.getBuffer) estava errado. Os cinco metodos passam de Promise para Promise. Nenhum chamador correto quebra — o retorno anterior ja era este objeto se passando por string. O envelope difere do usado por NFC-e e NF-e produto, que nomeiam o campo 'uri'. Sao dois envelopes na mesma plataforma, entao InboundFileResource e um tipo separado de NfeFileResource, de proposito. Testes existentes mockavam string e passavam mesmo assim, porque so verificavam passagem adiante. Realinhados ao formato real, mais tres testes de contrato: xml e pdf devolvem o mesmo formato, nao se envia Accept, e o retorno nao e Buffer nem string. --- CHANGELOG.md | 21 +++++++ docs/recursos/inbound-product-invoices.md | 13 +++- docs/recursos/transportation-invoices.md | 16 ++++- .../resources/inbound-product-invoices.ts | 54 +++++++++-------- src/core/resources/transportation-invoices.ts | 39 +++++++----- src/core/types.ts | 19 +++++- src/index.ts | 1 + .../inbound-product-invoices.test.ts | 60 +++++++++++++++---- .../resources/transportation-invoices.test.ts | 22 ++++--- 9 files changed, 182 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 098a415..79f6563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,27 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR O mapa de qual chave vale em qual host está documentado em `NfeConfig.apiKey` / `NfeConfig.dataApiKey`. +- **Downloads de documentos de entrada (CT-e e NF-e Distribuição) devolviam objeto + tipado como texto.** As rotas `/inbound/{chave}/xml`, `/pdf` e + `/inbound/{chave}/events/{evento}/xml` respondem com `{ publicTemporaryUri }` — + uma URL pré-assinada e temporária. **Binário nunca trafega nessas rotas** e o + header `Accept` não altera a resposta. + + Os cinco métodos (`inboundProductInvoices.getXml`, `.getPdf`, `.getEventXml`, + `transportationInvoices.downloadXml`, `.downloadEventXml`) passam a devolver o + novo tipo `InboundFileResource` em vez de `string`. + + **Como migrar:** baixe a URL devolvida. + + ```typescript + const res = await nfe.inboundProductInvoices.getPdf(companyId, accessKey); + const bytes = await fetch(res.publicTemporaryUri!).then((r) => r.arrayBuffer()); + ``` + + Nenhum chamador correto quebra: o retorno anterior já era este objeto se passando + por `string`. O envelope é **diferente** do de NFC-e/NF-e produto, que usa `uri` — + por isso o tipo é separado de `NfeFileResource`. + - **`companies.uploadCertificate()` nunca funcionou.** O campo multipart era enviado como `certificate`; a API faz binding de `file` e respondia `400 {"errors":{"file":["The File field is required."]}}` — ou seja, o método não diff --git a/docs/recursos/inbound-product-invoices.md b/docs/recursos/inbound-product-invoices.md index 21a2022..7036b4d 100644 --- a/docs/recursos/inbound-product-invoices.md +++ b/docs/recursos/inbound-product-invoices.md @@ -20,7 +20,18 @@ documentos capturados. | `getSettings(companyId)` | Configuração atual. | settings | | `getDetails(companyId, ...)` / `getProductInvoiceDetails(...)` | Detalhes dos documentos. | dados | | `getEventDetails(...)` / `getProductInvoiceEventDetails(...)` | Detalhes de eventos. | dados | -| `getXml(companyId, accessKey)` | XML do documento capturado. | XML | +| `getXml(companyId, accessKey)` / `getPdf(...)` / `getEventXml(...)` | Documento capturado. | `{ publicTemporaryUri }` | + +:::info Downloads devolvem uma URL, não o arquivo +`getXml`, `getPdf` e `getEventXml` respondem com um objeto `{ publicTemporaryUri }` +— uma URL pré-assinada e temporária. Binário não trafega nessas rotas e o header +`Accept` não altera a resposta; baixar a URL é responsabilidade do chamador. + +```typescript +const res = await nfe.inboundProductInvoices.getPdf(companyId, accessKey); +const bytes = await fetch(res.publicTemporaryUri!).then((r) => r.arrayBuffer()); +``` +::: ## Exemplo diff --git a/docs/recursos/transportation-invoices.md b/docs/recursos/transportation-invoices.md index 3ca1c3a..8962b24 100644 --- a/docs/recursos/transportation-invoices.md +++ b/docs/recursos/transportation-invoices.md @@ -18,8 +18,20 @@ captura de CT-e e consulta documentos por chave de acesso. | `enable(companyId, data)` / `disable(companyId)` | Habilita/desabilita a captura de CT-e. | settings | | `getSettings(companyId)` | Configuração atual. | settings | | `retrieve(companyId, accessKey)` | Consulta um CT-e por chave. | dados do CT-e | -| `downloadXml(companyId, accessKey)` | XML do CT-e. | XML | -| `getEvent(companyId, ...)` / `downloadEventXml(companyId, ...)` | Eventos do CT-e. | evento / XML | +| `downloadXml(companyId, accessKey)` | XML do CT-e. | `{ publicTemporaryUri }` | +| `getEvent(companyId, ...)` / `downloadEventXml(companyId, ...)` | Eventos do CT-e. | evento / `{ publicTemporaryUri }` | + +:::info Downloads devolvem uma URL, não o arquivo +As rotas de entrada (`/inbound/{chave}/xml` e `/pdf`) respondem com um objeto +`{ publicTemporaryUri }` — uma URL pré-assinada e temporária. Binário não trafega +nessas rotas e o header `Accept` não altera a resposta; baixar a URL é +responsabilidade do chamador. + +```typescript +const res = await nfe.transportationInvoices.downloadXml(companyId, accessKey); +const xml = await fetch(res.publicTemporaryUri!).then((r) => r.text()); +``` +::: ## Exemplo diff --git a/src/core/resources/inbound-product-invoices.ts b/src/core/resources/inbound-product-invoices.ts index 8849442..89eda2b 100644 --- a/src/core/resources/inbound-product-invoices.ts +++ b/src/core/resources/inbound-product-invoices.ts @@ -10,6 +10,7 @@ import type { InboundInvoiceMetadata, InboundProductInvoiceMetadata, InboundSettings, + InboundFileResource, EnableInboundOptions, ManifestEventType } from '../types.js'; @@ -407,26 +408,28 @@ export class InboundProductInvoicesResource { * * Gets the XML content of an inbound document. * + * A resposta e um objeto com `publicTemporaryUri` — URL pre-assinada e temporaria. + * Binario NAO trafega nesta rota e o `Accept` nao altera a resposta; baixar a URL + * fica a cargo do chamador. Verificado ao vivo em 2026-09-01 + * (tests/fixtures/live-contracts/inbound-download.json). + * * @param companyId - The company ID that received the document * @param accessKey - The 44-digit access key - * @returns Promise with the XML content as a string + * @returns Promise com o file-resource (`publicTemporaryUri`) * @throws {ValidationError} If company ID or access key is invalid * @throws {NotFoundError} If the document is not found * * @example * ```typescript - * const xml = await nfe.inboundProductInvoices.getXml( - * 'company-id', - * '35240112345678000190550010000001231234567890' - * ); - * fs.writeFileSync('nfe.xml', xml); + * const res = await nfe.inboundProductInvoices.getXml(companyId, accessKey); + * const doc = await fetch(res.publicTemporaryUri!).then((r) => r.text()); * ``` */ - async getXml(companyId: string, accessKey: string): Promise { + async getXml(companyId: string, accessKey: string): Promise { validateCompanyId(companyId); validateAccessKey(accessKey); - const response = await this.http.get( + const response = await this.http.get( `/v2/companies/${companyId}/inbound/${accessKey.trim()}/xml` ); @@ -438,6 +441,11 @@ export class InboundProductInvoicesResource { * * Gets the XML content of an event associated with an inbound document. * + * A resposta e um objeto com `publicTemporaryUri` — URL pre-assinada e temporaria. + * Binario NAO trafega nesta rota e o `Accept` nao altera a resposta; baixar a URL + * fica a cargo do chamador. Verificado ao vivo em 2026-09-01 + * (tests/fixtures/live-contracts/inbound-download.json). + * * @param companyId - The company ID that received the document * @param accessKey - The 44-digit access key of the parent document * @param eventKey - The event key @@ -447,24 +455,20 @@ export class InboundProductInvoicesResource { * * @example * ```typescript - * const xml = await nfe.inboundProductInvoices.getEventXml( - * 'company-id', - * '35240112345678000190550010000001231234567890', - * 'event-key-123' - * ); - * fs.writeFileSync('nfe-event.xml', xml); + * const res = await nfe.inboundProductInvoices.getEventXml(companyId, accessKey, eventKey); + * const doc = await fetch(res.publicTemporaryUri!).then((r) => r.text()); * ``` */ async getEventXml( companyId: string, accessKey: string, eventKey: string - ): Promise { + ): Promise { validateCompanyId(companyId); validateAccessKey(accessKey); validateEventKey(eventKey); - const response = await this.http.get( + const response = await this.http.get( `/v2/companies/${companyId}/inbound/${accessKey.trim()}/events/${eventKey.trim()}/xml` ); @@ -476,26 +480,28 @@ export class InboundProductInvoicesResource { * * Gets the PDF content of an NF-e document. * + * A resposta e um objeto com `publicTemporaryUri` — URL pre-assinada e temporaria. + * Binario NAO trafega nesta rota e o `Accept` nao altera a resposta; baixar a URL + * fica a cargo do chamador. Verificado ao vivo em 2026-09-01 + * (tests/fixtures/live-contracts/inbound-download.json). + * * @param companyId - The company ID that received the document * @param accessKey - The 44-digit access key - * @returns Promise with the PDF content as a string + * @returns Promise com o file-resource (`publicTemporaryUri`) * @throws {ValidationError} If company ID or access key is invalid * @throws {NotFoundError} If the document is not found * * @example * ```typescript - * const pdf = await nfe.inboundProductInvoices.getPdf( - * 'company-id', - * '35240112345678000190550010000001231234567890' - * ); - * fs.writeFileSync('nfe.pdf', pdf); + * const res = await nfe.inboundProductInvoices.getPdf(companyId, accessKey); + * const bytes = await fetch(res.publicTemporaryUri!).then((r) => r.arrayBuffer()); * ``` */ - async getPdf(companyId: string, accessKey: string): Promise { + async getPdf(companyId: string, accessKey: string): Promise { validateCompanyId(companyId); validateAccessKey(accessKey); - const response = await this.http.get( + const response = await this.http.get( `/v2/companies/${companyId}/inbound/${accessKey.trim()}/pdf` ); diff --git a/src/core/resources/transportation-invoices.ts b/src/core/resources/transportation-invoices.ts index 1093696..bd51ee7 100644 --- a/src/core/resources/transportation-invoices.ts +++ b/src/core/resources/transportation-invoices.ts @@ -9,7 +9,8 @@ import type { HttpClient } from '../http/client.js'; import type { TransportationInvoiceInboundSettings, TransportationInvoiceMetadata, - EnableTransportationInvoiceOptions + EnableTransportationInvoiceOptions, + InboundFileResource } from '../types.js'; import { ValidationError } from '../errors/index.js'; @@ -262,31 +263,32 @@ export class TransportationInvoicesResource { * * Gets the XML content of a CT-e document. * + * + * A resposta e um objeto com `publicTemporaryUri` — URL pre-assinada e temporaria. + * Binario NAO trafega nesta rota e o `Accept` nao altera a resposta; baixar a URL + * fica a cargo do chamador. Verificado ao vivo em 2026-09-01 + * (tests/fixtures/live-contracts/inbound-download.json). + * * @param companyId - The company ID that received the CT-e * @param accessKey - The 44-digit CT-e access key - * @returns Promise with the XML content as a string + * @returns Promise com o file-resource (`publicTemporaryUri`) * @throws {ValidationError} If company ID or access key is invalid * @throws {NotFoundError} If the CT-e is not found * * @example * ```typescript - * const xml = await nfe.transportationInvoices.downloadXml( + * const res = await nfe.transportationInvoices.downloadXml( * 'company-id', * '35240112345678000190570010000001231234567890' * ); - * - * // Save to file - * fs.writeFileSync('cte.xml', xml); - * - * // Or parse with an XML library - * const parsed = parseXml(xml); + * const xml = await fetch(res.publicTemporaryUri!).then((r) => r.text()); * ``` */ - async downloadXml(companyId: string, accessKey: string): Promise { + async downloadXml(companyId: string, accessKey: string): Promise { validateCompanyId(companyId); validateAccessKey(accessKey); - const response = await this.http.get( + const response = await this.http.get( `/v2/companies/${companyId}/inbound/${accessKey.trim()}/xml` ); @@ -346,25 +348,30 @@ export class TransportationInvoicesResource { * @param companyId - The company ID that received the CT-e * @param accessKey - The 44-digit CT-e access key * @param eventKey - The event key - * @returns Promise with the event XML content as a string + * @returns Promise com o file-resource (`publicTemporaryUri`) * @throws {ValidationError} If any parameter is invalid * @throws {NotFoundError} If the event is not found * * @example * ```typescript - * const xml = await nfe.transportationInvoices.downloadEventXml( + * const res = await nfe.transportationInvoices.downloadEventXml( * 'company-id', * '35240112345678000190570010000001231234567890', * 'event-key-123' * ); - * fs.writeFileSync('cte-event.xml', xml); + * const xml = await fetch(res.publicTemporaryUri!).then((r) => r.text()); * ``` + * + * A resposta e um objeto com `publicTemporaryUri` — URL pre-assinada e temporaria. + * Binario NAO trafega nesta rota e o `Accept` nao altera a resposta; baixar a URL + * fica a cargo do chamador. Verificado ao vivo em 2026-09-01 + * (tests/fixtures/live-contracts/inbound-download.json). */ async downloadEventXml( companyId: string, accessKey: string, eventKey: string - ): Promise { + ): Promise { validateCompanyId(companyId); validateAccessKey(accessKey); @@ -372,7 +379,7 @@ export class TransportationInvoicesResource { throw new ValidationError('Event key is required'); } - const response = await this.http.get( + const response = await this.http.get( `/v2/companies/${companyId}/inbound/${accessKey.trim()}/events/${eventKey.trim()}/xml` ); diff --git a/src/core/types.ts b/src/core/types.ts index ad8bdab..ee88b64 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -3382,12 +3382,29 @@ export interface NfeProductInvoiceSubListOptions { startingAfter?: number | string; } -/** File resource (PDF/XML download response) */ +/** File resource (PDF/XML download response) — product and consumer invoices. */ export interface NfeFileResource { /** Absolute URI to the file */ uri?: string; } +/** + * File resource returned by the INBOUND routes + * (`/v2/companies/{id}/inbound/{accessKey}/xml` and `/pdf`, shared by CT-e and + * NF-e distribution). + * + * Deliberately separate from {@link NfeFileResource}: the inbound routes name the + * field `publicTemporaryUri`, not `uri`. Two envelopes, two types — verified live + * on 2026-09-01, see `tests/fixtures/live-contracts/inbound-download.json`. + * + * The URI is a pre-signed, time-limited link. No binary is ever returned on these + * routes, and the `Accept` header does not change the response. + */ +export interface InboundFileResource { + /** Pre-signed, time-limited URI to the document. Download is up to the caller. */ + publicTemporaryUri?: string; +} + /** Request cancellation response */ export interface NfeRequestCancellationResource { /** Account ID */ diff --git a/src/index.ts b/src/index.ts index 1f53301..3caf45c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -286,6 +286,7 @@ export type { NfeProductInvoiceEventsResponse, NfeProductInvoiceSubListOptions, NfeFileResource, + InboundFileResource, NfeRequestCancellationResource, NfeDisablementData, NfeDisablementResource, diff --git a/tests/unit/resources/inbound-product-invoices.test.ts b/tests/unit/resources/inbound-product-invoices.test.ts index ed43297..bc5db87 100644 --- a/tests/unit/resources/inbound-product-invoices.test.ts +++ b/tests/unit/resources/inbound-product-invoices.test.ts @@ -10,7 +10,8 @@ import type { HttpResponse, InboundInvoiceMetadata, InboundProductInvoiceMetadata, - InboundSettings + InboundSettings, + InboundFileResource } from '../../../src/core/types.js'; import { ValidationError } from '../../../src/core/errors/index.js'; @@ -332,8 +333,8 @@ describe('InboundProductInvoicesResource', () => { describe('getXml', () => { it('should download XML with correct path', async () => { - const mockResponse: HttpResponse = { - data: 'content', + const mockResponse: HttpResponse = { + data: { publicTemporaryUri: 'https://example.invalid/storage/synthetic-inbound.xml?sig=SYNTHETIC' }, status: 200, headers: {}, }; @@ -341,7 +342,8 @@ describe('InboundProductInvoicesResource', () => { const result = await resource.getXml(testCompanyId, validAccessKey); - expect(result).toBe('content'); + expect(result).toEqual({ publicTemporaryUri: 'https://example.invalid/storage/synthetic-inbound.xml?sig=SYNTHETIC' }); + expect(result.publicTemporaryUri).toBeTypeOf('string'); expect(mockHttpClient.get).toHaveBeenCalledWith( `/v2/companies/${testCompanyId}/inbound/${validAccessKey}/xml` ); @@ -350,8 +352,8 @@ describe('InboundProductInvoicesResource', () => { describe('getEventXml', () => { it('should download event XML with correct path', async () => { - const mockResponse: HttpResponse = { - data: 'event', + const mockResponse: HttpResponse = { + data: { publicTemporaryUri: 'https://example.invalid/storage/synthetic-inbound.xml?sig=SYNTHETIC' }, status: 200, headers: {}, }; @@ -359,7 +361,8 @@ describe('InboundProductInvoicesResource', () => { const result = await resource.getEventXml(testCompanyId, validAccessKey, testEventKey); - expect(result).toBe('event'); + expect(result).toEqual({ publicTemporaryUri: 'https://example.invalid/storage/synthetic-inbound.xml?sig=SYNTHETIC' }); + expect(result.publicTemporaryUri).toBeTypeOf('string'); expect(mockHttpClient.get).toHaveBeenCalledWith( `/v2/companies/${testCompanyId}/inbound/${validAccessKey}/events/${testEventKey}/xml` ); @@ -374,8 +377,8 @@ describe('InboundProductInvoicesResource', () => { describe('getPdf', () => { it('should download PDF with correct path', async () => { - const mockResponse: HttpResponse = { - data: 'pdf-content', + const mockResponse: HttpResponse = { + data: { publicTemporaryUri: 'https://example.invalid/storage/synthetic-inbound.xml?sig=SYNTHETIC' }, status: 200, headers: {}, }; @@ -383,7 +386,8 @@ describe('InboundProductInvoicesResource', () => { const result = await resource.getPdf(testCompanyId, validAccessKey); - expect(result).toBe('pdf-content'); + expect(result).toEqual({ publicTemporaryUri: 'https://example.invalid/storage/synthetic-inbound.xml?sig=SYNTHETIC' }); + expect(result.publicTemporaryUri).toBeTypeOf('string'); expect(mockHttpClient.get).toHaveBeenCalledWith( `/v2/companies/${testCompanyId}/inbound/${validAccessKey}/pdf` ); @@ -507,4 +511,40 @@ describe('InboundProductInvoicesResource', () => { await expect(resource.reprocessWebhook(testCompanyId, ' ')).rejects.toThrow(ValidationError); }); }); + // ========================================================================== + // Contrato de download (probe ao vivo 2026-09-01) + // ========================================================================== + + describe('contrato de download: file-resource, nunca binario', () => { + const FILE = { publicTemporaryUri: 'https://example.invalid/s/doc?sig=SYNTHETIC' }; + + beforeEach(() => { + mockHttpClient.get.mockResolvedValue({ data: FILE, status: 200, headers: {} }); + }); + + it('xml e pdf devolvem o MESMO formato — a rota nao diferencia', async () => { + const xml = await resource.getXml(testCompanyId, validAccessKey); + const pdf = await resource.getPdf(testCompanyId, validAccessKey); + + expect(Object.keys(xml)).toEqual(Object.keys(pdf)); + expect(xml.publicTemporaryUri).toBeTypeOf('string'); + expect(pdf.publicTemporaryUri).toBeTypeOf('string'); + }); + + it('nao envia header Accept — a API ignora e sempre devolve JSON', async () => { + await resource.getPdf(testCompanyId, validAccessKey); + + const args = mockHttpClient.get.mock.calls[0]!; + // Assinatura: get(path) — sem params e sem headers customizados. + expect(args.length).toBe(1); + }); + + it('o retorno nao e Buffer nem string (regressao do contrato antigo)', async () => { + const result = await resource.getPdf(testCompanyId, validAccessKey); + + expect(Buffer.isBuffer(result)).toBe(false); + expect(typeof result).toBe('object'); + }); + }); + }); diff --git a/tests/unit/resources/transportation-invoices.test.ts b/tests/unit/resources/transportation-invoices.test.ts index 98e8e4b..610063d 100644 --- a/tests/unit/resources/transportation-invoices.test.ts +++ b/tests/unit/resources/transportation-invoices.test.ts @@ -9,7 +9,8 @@ import { HttpClient } from '../../../src/core/http/client.js'; import type { HttpResponse, TransportationInvoiceInboundSettings, - TransportationInvoiceMetadata + TransportationInvoiceMetadata, + InboundFileResource } from '../../../src/core/types.js'; import { ValidationError } from '../../../src/core/errors/index.js'; @@ -291,11 +292,12 @@ describe('TransportationInvoicesResource', () => { // ========================================================================== describe('downloadXml', () => { - const mockXml = '...'; + // A rota devolve file-resource JSON, nunca XML bruto (probe 2026-09-01). + const mockFile = { publicTemporaryUri: 'https://example.invalid/storage/synthetic-inbound.xml?sig=SYNTHETIC' }; it('should download CT-e XML by access key', async () => { - const mockResponse: HttpResponse = { - data: mockXml, + const mockResponse: HttpResponse = { + data: mockFile, status: 200, headers: {}, }; @@ -303,7 +305,8 @@ describe('TransportationInvoicesResource', () => { const result = await resource.downloadXml(testCompanyId, validAccessKey); - expect(result).toBe(mockXml); + expect(result).toEqual(mockFile); + expect(result.publicTemporaryUri).toBeTypeOf('string'); expect(mockHttpClient.get).toHaveBeenCalledWith( `/v2/companies/${testCompanyId}/inbound/${validAccessKey}/xml` ); @@ -399,11 +402,11 @@ describe('TransportationInvoicesResource', () => { // ========================================================================== describe('downloadEventXml', () => { - const mockEventXml = '...'; + const mockEventFile = { publicTemporaryUri: 'https://example.invalid/storage/synthetic-inbound.xml?sig=SYNTHETIC' }; it('should download CT-e event XML', async () => { - const mockResponse: HttpResponse = { - data: mockEventXml, + const mockResponse: HttpResponse = { + data: mockEventFile, status: 200, headers: {}, }; @@ -411,7 +414,8 @@ describe('TransportationInvoicesResource', () => { const result = await resource.downloadEventXml(testCompanyId, validAccessKey, testEventKey); - expect(result).toBe(mockEventXml); + expect(result).toEqual(mockEventFile); + expect(result.publicTemporaryUri).toBeTypeOf('string'); expect(mockHttpClient.get).toHaveBeenCalledWith( `/v2/companies/${testCompanyId}/inbound/${validAccessKey}/events/${testEventKey}/xml` ); From 6de3221502153164f3058f4b7f041e025a23be33 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Tue, 1 Sep 2026 21:22:06 -0300 Subject: [PATCH 05/21] fix(consumer-invoices): parametros da spec e contrato de download da NFC-e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contrato conferido nas duas specs (repo e nfeio-docs, identicas nestes paths) e provado por sonda ao vivo em 2026-09-01. - cancel() aceita reason (query da spec) e devolve ConsumerInvoiceCancellationResponse em vez da nota - getItems()/getEvents() aceitam paginacao cursor (limit/startingAfter) e devolvem envelopes proprios com hasMore; o de eventos deixa de reusar o tipo do recurso de produto, que tem outra forma - downloadPdf() aceita force; os tres downloads passam a devolver ConsumerInvoiceFileResource ({ uri }) em vez de Buffer — a API devolve JSON com URL e IGNORA o header Accept - retrieve()/getItems()/getEvents() param de enviar environment, que a spec nao define nessas rotas (a API tolera, mas e ruido) list() CONTINUA exigindo environment: a sonda mostrou 400 'environment has to be production or test' sem ele. A tarefa 2.3 previa tornar o parametro opcional 'na direcao permissiva' — isso teria introduzido um bug. A spec e que esta errada. Novos aliases derivados da spec em types.ts, mais teste de alinhamento tests/types/consumer-invoice-alignment.test-d.ts pinando: uri vs publicTemporaryUri sao envelopes distintos, items/events carregam hasMore, e o cancelamento devolve o recurso de cancelamento. --- CHANGELOG.md | 28 +++ docs/recursos/consumer-invoices.md | 30 +++- src/core/resources/consumer-invoices.ts | 166 +++++++++++++----- src/core/types.ts | 31 ++++ src/index.ts | 4 + .../consumer-invoice-alignment.test-d.ts | 46 +++++ .../unit/resources/consumer-invoices.test.ts | 73 ++++++-- 7 files changed, 306 insertions(+), 72 deletions(-) create mode 100644 tests/types/consumer-invoice-alignment.test-d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 79f6563..244e12f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,34 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR O mapa de qual chave vale em qual host está documentado em `NfeConfig.apiKey` / `NfeConfig.dataApiKey`. +- **NFC-e: parâmetros da spec não expostos e contrato de download divergente.** + + - `cancel()` aceita `reason` (query definida pela spec) e devolve + `ConsumerInvoiceCancellationResponse` em vez da nota. + - `getItems()` / `getEvents()` aceitam paginação cursor (`limit`/`startingAfter`) + e devolvem envelopes **próprios**, com `hasMore`. O de eventos deixa de reusar + o tipo do recurso de produto, que tem outra forma. + - `downloadPdf()` aceita `force`. Os três downloads passam a devolver + `ConsumerInvoiceFileResource` (`{ uri }`) em vez de `Buffer`: a API devolve + JSON com URL e **ignora o header `Accept`**. O retorno anterior já era este + objeto se passando por `Buffer` — nenhum chamador correto quebra. + - `retrieve()`, `getItems()` e `getEvents()` param de enviar `environment`, que + a spec não define nessas rotas. + + **Como migrar:** baixe a URL devolvida pelos downloads. + + ```typescript + const res = await nfe.consumerInvoices.downloadPdf(companyId, invoiceId); + const bytes = await fetch(res.uri!).then((r) => r.arrayBuffer()); + ``` + + Atenção: o envelope da NFC-e usa `uri`; o das rotas de entrada usa + `publicTemporaryUri`. São tipos distintos de propósito. + + `list()` **continua exigindo** `environment`: a API responde + `400 environment has to be production or test` sem ele. A spec marca o parâmetro + como opcional e está errada. + - **Downloads de documentos de entrada (CT-e e NF-e Distribuição) devolviam objeto tipado como texto.** As rotas `/inbound/{chave}/xml`, `/pdf` e `/inbound/{chave}/events/{evento}/xml` respondem com `{ publicTemporaryUri }` — diff --git a/docs/recursos/consumer-invoices.md b/docs/recursos/consumer-invoices.md index ab1c33d..9bcad2a 100644 --- a/docs/recursos/consumer-invoices.md +++ b/docs/recursos/consumer-invoices.md @@ -19,18 +19,32 @@ leitura). |---|---|---| | `create(companyId, data)` | Emite a NFC-e (webhook-driven). | `ConsumerInvoice` | | `list(companyId, options)` | Lista NFC-e. **`options.environment` é obrigatório.** | `{ consumerInvoices, hasMore }` | -| `retrieve(companyId, invoiceId, environment?)` | Consulta por id. | `ConsumerInvoice` | -| `cancel(companyId, invoiceId)` | Cancela a NFC-e. | `ConsumerInvoice` | -| `getItems(companyId, invoiceId, environment?)` | Itens da nota. | resposta de itens | -| `getEvents(companyId, invoiceId, environment?)` | Eventos da nota. | resposta de eventos | -| `downloadPdf` / `downloadXml` / `downloadRejectionXml` (`, environment?`) | Downloads (Buffer). | `Buffer` | +| `retrieve(companyId, invoiceId)` | Consulta por id. | `ConsumerInvoice` | +| `cancel(companyId, invoiceId, reason?)` | Cancela a NFC-e. | `ConsumerInvoiceCancellationResponse` | +| `getItems(companyId, invoiceId, { limit?, startingAfter? })` | Itens da nota, com paginação cursor. | `{ items, hasMore, … }` | +| `getEvents(companyId, invoiceId, { limit?, startingAfter? })` | Eventos da nota, com paginação cursor. | `{ events, hasMore, … }` | +| `downloadPdf(companyId, invoiceId, force?)` / `downloadXml` / `downloadRejectionXml` | Link do documento. | `{ uri }` | | `disable(companyId, data)` | Inutilização de numeração. | resultado | `ConsumerInvoiceListOptions = { environment: 'Production' \| 'Test'; startingAfter?; endingBefore?; limit?; q? }`. -:::warning `environment` obrigatório -A API exige `environment` (`Production`/`Test`) na listagem; as leituras aceitam -`environment` opcional. Sem ele, a listagem retorna `400`. +:::warning `environment` só na listagem +A API exige `environment` (`Production`/`Test`) em `list()` — sem ele responde +`400 environment has to be production or test`. As demais rotas **não definem** +esse parâmetro e não o recebem mais. +::: + +:::info Downloads devolvem uma URL, não o arquivo +`downloadPdf`, `downloadXml` e `downloadRejectionXml` respondem com `{ uri }` — o +header `Accept` não altera a resposta. Baixar a URL é responsabilidade do chamador. + +```typescript +const res = await nfe.consumerInvoices.downloadPdf(companyId, invoiceId); +const bytes = await fetch(res.uri!).then((r) => r.arrayBuffer()); +``` + +O envelope difere do usado pelas rotas de **entrada**, que nomeiam o campo +`publicTemporaryUri`. ::: ## Listar e emitir diff --git a/src/core/resources/consumer-invoices.ts b/src/core/resources/consumer-invoices.ts index 26cf49c..dd956f9 100644 --- a/src/core/resources/consumer-invoices.ts +++ b/src/core/resources/consumer-invoices.ts @@ -13,8 +13,10 @@ import type { ConsumerInvoice, ConsumerInvoiceListResponse, ConsumerInvoiceDisablementData, - NfeInvoiceItemsResponse, - NfeProductInvoiceEventsResponse, + ConsumerInvoiceItemsResponse, + ConsumerInvoiceEventsResponse, + ConsumerInvoiceCancellationResponse, + ConsumerInvoiceFileResource, NfeDisablementResource, } from '../types.js'; import { ValidationError } from '../errors/index.js'; @@ -45,6 +47,40 @@ function validateInvoiceId(invoiceId: string): void { } } +/** + * Builds a query string from present values. Mirrors the local helper in + * `product-invoices.ts` — `http.delete()` takes no params object, so the query + * has to go in the path. + */ +function buildQueryString(params: Record): string { + const parts: string[] = []; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + } + } + return parts.length > 0 ? `?${parts.join('&')}` : ''; +} + +/** Cursor pagination for the NFC-e sub-collections (items and events). */ +export interface ConsumerInvoicePageOptions { + /** Page size. */ + limit?: number; + /** Cursor: start after this index. */ + startingAfter?: number; +} + +/** Builds the query for the paginated sub-collections; omits absent values. */ +function buildPageParams( + options?: ConsumerInvoicePageOptions +): Record | undefined { + if (!options) return undefined; + const params: Record = {}; + if (options.limit !== undefined) params.limit = options.limit; + if (options.startingAfter !== undefined) params.startingAfter = options.startingAfter; + return Object.keys(params).length > 0 ? params : undefined; +} + export class ConsumerInvoicesResource { constructor(private readonly http: HttpClient) {} @@ -73,6 +109,10 @@ export class ConsumerInvoicesResource { options: ConsumerInvoiceListOptions ): Promise { validateCompanyId(companyId); + // A API EXIGE `environment` aqui: sem ele responde + // 400 {"code":40001,"message":"environment has to be production or test"}. + // A spec marca o parametro como opcional — a spec e que esta errada + // (verificado ao vivo em 2026-09-01). Este throw e a falha rapida equivalente. if (!options?.environment) { throw new ValidationError('Environment is required (Production or Test)'); } @@ -89,106 +129,140 @@ export class ConsumerInvoicesResource { } /** - * Retrieve an NFC-e by id. Pass `environment` if the API requires it for reads. + * Retrieve an NFC-e by id. + * + * The route takes no query parameters — `environment` is neither required nor + * defined by the spec here (verified live 2026-09-01). */ - async retrieve( - companyId: string, - invoiceId: string, - environment?: ConsumerInvoiceEnvironment - ): Promise { + async retrieve(companyId: string, invoiceId: string): Promise { validateCompanyId(companyId); validateInvoiceId(invoiceId); const response = await this.http.get( - `${this.basePath(companyId)}/${invoiceId}`, - environment ? { environment } : undefined + `${this.basePath(companyId)}/${invoiceId}` ); return response.data; } - /** Cancel an NFC-e. */ - async cancel(companyId: string, invoiceId: string): Promise { + /** + * Cancel an NFC-e. + * + * @param reason - Optional cancellation reason, sent as the `reason` query + * parameter defined by the spec. + */ + async cancel( + companyId: string, + invoiceId: string, + reason?: string + ): Promise { validateCompanyId(companyId); validateInvoiceId(invoiceId); - const response = await this.http.delete( - `${this.basePath(companyId)}/${invoiceId}` + const params: Record = {}; + if (reason !== undefined) params.reason = reason; + const response = await this.http.delete( + `${this.basePath(companyId)}/${invoiceId}${buildQueryString(params)}` ); return response.data; } - /** List the items of an NFC-e. Pass `environment` if the API requires it. */ + /** + * List the items of an NFC-e, with cursor pagination (`limit`/`startingAfter`). + * The response carries `hasMore`. + */ async getItems( companyId: string, invoiceId: string, - environment?: ConsumerInvoiceEnvironment - ): Promise { + options?: ConsumerInvoicePageOptions + ): Promise { validateCompanyId(companyId); validateInvoiceId(invoiceId); - const response = await this.http.get( + const response = await this.http.get( `${this.basePath(companyId)}/${invoiceId}/items`, - environment ? { environment } : undefined + buildPageParams(options) ); return response.data; } - /** List the events of an NFC-e. Pass `environment` if the API requires it. */ + /** + * List the events of an NFC-e, with cursor pagination (`limit`/`startingAfter`). + * The response carries `hasMore`. + */ async getEvents( companyId: string, invoiceId: string, - environment?: ConsumerInvoiceEnvironment - ): Promise { + options?: ConsumerInvoicePageOptions + ): Promise { validateCompanyId(companyId); validateInvoiceId(invoiceId); - const response = await this.http.get( + const response = await this.http.get( `${this.basePath(companyId)}/${invoiceId}/events`, - environment ? { environment } : undefined + buildPageParams(options) ); return response.data; } - /** Download the DANFE-NFC-e PDF. Pass `environment` if the API requires it. */ + /** + * Get the DANFE-NFC-e PDF link. + * + * @param force - Force regeneration of the document (spec `force` query param). + * + * A API devolve `{ uri }` — URL temporaria para o arquivo, nao o binario. O + * header `Accept` nao altera a resposta. Verificado ao vivo em 2026-09-01 + * (tests/fixtures/live-contracts/consumer-invoice-download.json). + * + * Atencao: o envelope difere das rotas de ENTRADA, que usam `publicTemporaryUri`. + */ async downloadPdf( companyId: string, invoiceId: string, - environment?: ConsumerInvoiceEnvironment - ): Promise { + force?: boolean + ): Promise { validateCompanyId(companyId); validateInvoiceId(invoiceId); - const response = await this.http.get( + const response = await this.http.get( `${this.basePath(companyId)}/${invoiceId}/pdf`, - environment ? { environment } : undefined, - { Accept: 'application/pdf' } + force === undefined ? undefined : { force } ); return response.data; } - /** Download the NFC-e XML. Pass `environment` if the API requires it. */ + /** + * Get the NFC-e XML link. + * + * A API devolve `{ uri }` — URL temporaria para o arquivo, nao o binario. O + * header `Accept` nao altera a resposta. Verificado ao vivo em 2026-09-01 + * (tests/fixtures/live-contracts/consumer-invoice-download.json). + * + * Atencao: o envelope difere das rotas de ENTRADA, que usam `publicTemporaryUri`. + */ async downloadXml( companyId: string, - invoiceId: string, - environment?: ConsumerInvoiceEnvironment - ): Promise { + invoiceId: string + ): Promise { validateCompanyId(companyId); validateInvoiceId(invoiceId); - const response = await this.http.get( - `${this.basePath(companyId)}/${invoiceId}/xml`, - environment ? { environment } : undefined, - { Accept: 'application/xml' } + const response = await this.http.get( + `${this.basePath(companyId)}/${invoiceId}/xml` ); return response.data; } - /** Download the rejection XML for a rejected NFC-e. Pass `environment` if required. */ + /** + * Get the rejection XML link for a rejected NFC-e. + * + * A API devolve `{ uri }` — URL temporaria para o arquivo, nao o binario. O + * header `Accept` nao altera a resposta. Verificado ao vivo em 2026-09-01 + * (tests/fixtures/live-contracts/consumer-invoice-download.json). + * + * Atencao: o envelope difere das rotas de ENTRADA, que usam `publicTemporaryUri`. + */ async downloadRejectionXml( companyId: string, - invoiceId: string, - environment?: ConsumerInvoiceEnvironment - ): Promise { + invoiceId: string + ): Promise { validateCompanyId(companyId); validateInvoiceId(invoiceId); - const response = await this.http.get( - `${this.basePath(companyId)}/${invoiceId}/xml/rejection`, - environment ? { environment } : undefined, - { Accept: 'application/xml' } + const response = await this.http.get( + `${this.basePath(companyId)}/${invoiceId}/xml/rejection` ); return response.data; } diff --git a/src/core/types.ts b/src/core/types.ts index ee88b64..f6cbd60 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -653,6 +653,37 @@ export type ConsumerInvoiceListResponse = export type ConsumerInvoiceDisablementData = NfConsumidorComponents['schemas']['DisablementResource']; +/** + * NFC-e items response (`InvoiceItemsResource`) — `{ accountId, companyId, id, + * items, hasMore }`. Cursor pagination via `limit`/`startingAfter`. + */ +export type ConsumerInvoiceItemsResponse = + NfConsumidorComponents['schemas']['InvoiceItemsResource']; + +/** + * NFC-e events response (`InvoiceEventsResource`) — `{ id, accountId, companyId, + * events, hasMore }`. Its own type: the product-invoice events envelope is a + * different shape and must not be reused here. + */ +export type ConsumerInvoiceEventsResponse = + NfConsumidorComponents['schemas']['InvoiceEventsResource']; + +/** + * NFC-e cancellation response (`RequestCancellationResource`) — returned by + * `DELETE /consumerinvoices/{id}` (204). + */ +export type ConsumerInvoiceCancellationResponse = + NfConsumidorComponents['schemas']['RequestCancellationResource']; + +/** + * NFC-e document download response (`FileResource`) — `{ uri }`. + * + * Note the envelope differs from the inbound routes, which use + * `publicTemporaryUri` ({@link InboundFileResource}). Verified live 2026-09-01. + */ +export type ConsumerInvoiceFileResource = + NfConsumidorComponents['schemas']['FileResource']; + /** * Transportation Invoice inbound settings * Configuration for automatic CT-e search via SEFAZ Distribuição DFe diff --git a/src/index.ts b/src/index.ts index 3caf45c..ebf6d4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -287,6 +287,10 @@ export type { NfeProductInvoiceSubListOptions, NfeFileResource, InboundFileResource, + ConsumerInvoiceItemsResponse, + ConsumerInvoiceEventsResponse, + ConsumerInvoiceCancellationResponse, + ConsumerInvoiceFileResource, NfeRequestCancellationResource, NfeDisablementData, NfeDisablementResource, diff --git a/tests/types/consumer-invoice-alignment.test-d.ts b/tests/types/consumer-invoice-alignment.test-d.ts new file mode 100644 index 0000000..e5ffb7c --- /dev/null +++ b/tests/types/consumer-invoice-alignment.test-d.ts @@ -0,0 +1,46 @@ +/** + * Alignment guard: os retornos do recurso de NFC-e amarrados aos schemas + * gerados da `nf-consumidor-v2`. + * + * Fatos pinados por sonda ao vivo em 2026-09-01 (evidência em + * `tests/fixtures/live-contracts/consumer-invoice-download.json`): + * - downloads devolvem `FileResource` — campo `uri`, NÃO `publicTemporaryUri` + * (esse é o envelope das rotas de ENTRADA, tipo separado de propósito); + * - items e events têm envelopes PRÓPRIOS com `hasMore` — o de events não é o + * mesmo do recurso de produto, que era o reusado antes; + * - o cancelamento devolve `RequestCancellationResource`, não a nota. + * + * Se um sync de spec mudar qualquer um desses contratos, `npm run test:types` + * quebra em vez de driftar em silêncio. + */ + +import { describe, it, expectTypeOf } from 'vitest'; +import type { + ConsumerInvoiceFileResource, + ConsumerInvoiceItemsResponse, + ConsumerInvoiceEventsResponse, + ConsumerInvoiceCancellationResponse, + InboundFileResource, +} from '../../src/index.js'; + +describe('NFC-e: alinhamento spec ↔ tipo', () => { + it('download devolve `uri`, e não o `publicTemporaryUri` da entrada', () => { + expectTypeOf().toHaveProperty('uri'); + expectTypeOf().toHaveProperty('publicTemporaryUri'); + }); + + it('os dois envelopes de arquivo são tipos distintos', () => { + expectTypeOf().not.toEqualTypeOf(); + }); + + it('items e events carregam `hasMore` (paginação cursor)', () => { + expectTypeOf().toHaveProperty('hasMore'); + expectTypeOf().toHaveProperty('items'); + expectTypeOf().toHaveProperty('hasMore'); + expectTypeOf().toHaveProperty('events'); + }); + + it('cancelamento devolve o recurso de cancelamento, não a nota', () => { + expectTypeOf().toHaveProperty('reason'); + }); +}); diff --git a/tests/unit/resources/consumer-invoices.test.ts b/tests/unit/resources/consumer-invoices.test.ts index 84f2d5b..c183a26 100644 --- a/tests/unit/resources/consumer-invoices.test.ts +++ b/tests/unit/resources/consumer-invoices.test.ts @@ -49,10 +49,21 @@ describe('ConsumerInvoicesResource', () => { await resource.cancel(companyId, invoiceId); expect(http.get).toHaveBeenCalledWith(base, { environment: 'Test' }); - expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}`, undefined); + // retrieve nao envia query: a rota nao define nenhum parametro (spec + probe). + expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}`); expect(http.delete).toHaveBeenCalledWith(`${base}/${invoiceId}`); }); + it('cancel forwards the reason query param defined by the spec', async () => { + http.delete.mockResolvedValue({ status: 204, headers: {}, data: {} }); + + await resource.cancel(companyId, invoiceId, 'digitacao incorreta'); + + expect(http.delete).toHaveBeenCalledWith( + `${base}/${invoiceId}?reason=digitacao%20incorreta` + ); + }); + it('list requires environment', async () => { await expect( resource.list(companyId, {} as unknown as { environment: 'Test' }) @@ -67,30 +78,56 @@ describe('ConsumerInvoicesResource', () => { expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/events`, undefined); }); - it('downloads send the right Accept and path (pdf/xml/rejection)', async () => { - http.get.mockResolvedValue({ status: 200, headers: {}, data: Buffer.from('x') }); + it('downloads devolvem file-resource e nao mandam Accept', async () => { + // A API devolve { uri } e ignora o Accept (probe 2026-09-01). + const file = { uri: 'https://example.invalid/s/doc?sig=SYNTHETIC' }; + http.get.mockResolvedValue({ status: 200, headers: {}, data: file }); + + const pdf = await resource.downloadPdf(companyId, invoiceId); + const xml = await resource.downloadXml(companyId, invoiceId); + const rej = await resource.downloadRejectionXml(companyId, invoiceId); + + expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/pdf`, undefined); + expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/xml`); + expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/xml/rejection`); + + for (const r of [pdf, xml, rej]) { + expect(r).toEqual(file); + expect(Buffer.isBuffer(r)).toBe(false); + } + }); + + it('downloadPdf forwards the force query param defined by the spec', async () => { + http.get.mockResolvedValue({ status: 200, headers: {}, data: { uri: 'x' } }); - await resource.downloadPdf(companyId, invoiceId); - await resource.downloadXml(companyId, invoiceId); - await resource.downloadRejectionXml(companyId, invoiceId); + await resource.downloadPdf(companyId, invoiceId, true); - expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/pdf`, undefined, { Accept: 'application/pdf' }); - expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/xml`, undefined, { Accept: 'application/xml' }); - expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/xml/rejection`, undefined, { Accept: 'application/xml' }); + expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/pdf`, { force: true }); }); - it('forwards environment on reads/downloads when provided', async () => { + it('items / events forward cursor pagination', async () => { + http.get.mockResolvedValue({ status: 200, headers: {}, data: { hasMore: false } }); + + await resource.getItems(companyId, invoiceId, { limit: 5, startingAfter: 10 }); + await resource.getEvents(companyId, invoiceId, { limit: 2 }); + + expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/items`, { + limit: 5, + startingAfter: 10, + }); + expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/events`, { limit: 2 }); + }); + + it('nao envia environment onde a spec nao define (retrieve/items/events)', async () => { http.get.mockResolvedValue({ status: 200, headers: {}, data: {} }); - await resource.retrieve(companyId, invoiceId, 'Test'); - await resource.getItems(companyId, invoiceId, 'Test'); - await resource.getEvents(companyId, invoiceId, 'Test'); - await resource.downloadPdf(companyId, invoiceId, 'Test'); + await resource.retrieve(companyId, invoiceId); + await resource.getItems(companyId, invoiceId); + await resource.getEvents(companyId, invoiceId); - expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}`, { environment: 'Test' }); - expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/items`, { environment: 'Test' }); - expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/events`, { environment: 'Test' }); - expect(http.get).toHaveBeenCalledWith(`${base}/${invoiceId}/pdf`, { environment: 'Test' }, { Accept: 'application/pdf' }); + for (const call of http.get.mock.calls) { + expect(JSON.stringify(call[1] ?? {})).not.toContain('environment'); + } }); it('list forwards optional filters (startingAfter/endingBefore/limit/q)', async () => { From bd3762310836b608ad909b0b819364b882609f4b Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Tue, 1 Sep 2026 22:33:50 -0300 Subject: [PATCH 06/21] feat(specs): detecta drift entre copias da mesma secao no validate:spec 30 dos 131 endpoints sao declarados em mais de uma spec (companies, certificates, statetaxes, webhooks) e as copias divergem. O SOURCES.json nao pegava: ele compara sha256 repo x docs, e este drift e *entre* specs do mesmo lado. O SOURCES.json ganha `sharedSections` com a fonte canonica de cada grupo, e o validate:spec compara as copias contra ela campo a campo. A granularidade foi escolhida por medicao, nao por gosto. Comparar a operacao inteira -- mesmo descontando prosa -- marca 100% dos paths dos grupos A e B como divergentes: ruido de forma (`content: {}` x ausente, `$ref` x inline, ordem de chave). Campo a campo (`caminho -> tipo/enum`), o grupo C isola exatamente as 24 contradicoes reais de contentType/status, sem um falso positivo. Quatro classes: type-mismatch e enum-mismatch falham o build, enum-subset avisa (a copia esta atrasada), presenca de campo e informativa. As 116 divergencias de hoje entram como baseline declarada, agrupadas por CAUSA e nao por campo, cada uma com motivo e pendencia upstream: 74 enum-mismatch grupo A valores de enum camelCased ('sP' no lugar de 'SP', 'active' no lugar de 'Active') em nf-produto-v2 e nf-consumidor-v2 -- defeito de serializacao, achado nesta medicao 24 type-mismatch grupo C contentType/status como integer nas copias; o fio manda string (sonda 2026-09-01) 18 enum-subset grupo B nf-servico-v1 atrasada em taxRegime, legalNature e certificate.status Baseline que deixa de reproduzir e reportada como obsoleta, para nao virar tapete. O que falha o build e drift novo. Canonica de webhooks e nf-consumidor-v2, nao nf-produto-v2: e a unica com o TIPO certo (string) e a unica superset (11 campos x 10, so ela declara `id`). discoverSpecs() do validador passa a aceitar .json -- contribuintes-v2.json, canonica das secoes de companies, nunca tinha sido validado, enquanto o generate-types.ts ja o processava. Sem efeito em runtime nem na API publica: dist/index.d.ts sai byte-identico ao build anterior, e src/generated/ nao muda uma linha que nao seja timestamp. Testes: 17 novos, com fixture para cada classe -- inclusive a de diferenca puramente de forma, que e o caso que invalidou as tres primeiras estrategias. --- CHANGELOG.md | 26 +- CLAUDE.md | 4 +- openapi/spec/SOURCES.json | 122 +++++++++- scripts/cross-spec-check.ts | 358 ++++++++++++++++++++++++++++ scripts/validate-spec.ts | 61 ++++- src/generated/README.md | 46 ++++ tests/unit/cross-spec-check.test.ts | 292 +++++++++++++++++++++++ 7 files changed, 903 insertions(+), 6 deletions(-) create mode 100644 scripts/cross-spec-check.ts create mode 100644 tests/unit/cross-spec-check.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 244e12f..983d864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,32 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ## [Não lançado] -> Dois bugs de contrato provados por sonda ao vivo contra a API real (2026-09-01). -> Nenhum dos dois era divergência de especificação: em ambos o SDK estava errado. +> Quatro bugs de contrato provados por sonda ao vivo contra a API real (2026-09-01). +> Em nenhum deles a especificação era a culpada: o SDK é que estava errado. > Evidência versionada em `tests/fixtures/live-contracts/`. +### Manutenção + +- **`validate:spec` passa a detectar drift entre cópias da mesma seção.** 30 dos 131 + endpoints das specs são declarados em mais de um arquivo (companies, certificates, + statetaxes, webhooks) e as cópias divergem — algo que o `SOURCES.json` não pegava, + porque ele compara repo × docs e este drift é *entre* specs do mesmo lado. + + O `SOURCES.json` ganhou `sharedSections`, declarando a fonte canônica de cada grupo, + e o `validate:spec` agora compara as cópias contra ela **campo a campo**, classificando + em `type-mismatch` e `enum-mismatch` (falham o build), `enum-subset` (aviso, a cópia + está atrasada) e presença de campo (informativo). Diferença de prosa ou de forma + (`$ref` × inline) não conta. + + As 116 divergências existentes entram como baseline declarada, cada uma com motivo e + referência à pendência upstream — e uma entrada que deixe de reproduzir é reportada + como obsoleta, para a baseline não virar tapete. O que falha o build é drift **novo**. + + O `discoverSpecs()` do validador também passou a aceitar `.json`: `contribuintes-v2.json` + — canônica das seções de companies — nunca tinha sido validado. + + Sem efeito em runtime, tipos gerados ou API pública: `dist/index.d.ts` sai byte-idêntico. + ### Corrigido - **Credencial errada em nove recursos fiscais.** As duas chaves da plataforma são diff --git a/CLAUDE.md b/CLAUDE.md index 5d94cc0..e45f10b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,9 @@ Coverage thresholds: 80% for branches, functions, lines, and statements. Test se ### OpenAPI Pipeline -Specs live in `openapi/spec/*.yaml`. The generation script (`scripts/generate-types.ts`) produces typed interfaces in `src/generated/`. The build pipeline always validates and regenerates before compiling. +Specs live in `openapi/spec/*.yaml` (and `*.json`). The generation script (`scripts/generate-types.ts`) produces typed interfaces in `src/generated/`. The build pipeline always validates and regenerates before compiling. + +**Shared sections across specs.** 30 of the 131 endpoints are declared in more than one spec (companies, certificates, statetaxes, webhooks), and the copies disagree. `openapi/spec/SOURCES.json` declares the canonical source per group in `sharedSections`; `npm run validate:spec` compares each copy against it field by field (`scripts/cross-spec-check.ts`) and fails on `type-mismatch` / `enum-mismatch`, warns on `enum-subset`. Known divergences live in `knownDivergences` with a reason and an upstream reference — a baseline entry that stops reproducing is reported as stale, so it does not become permanent. See `src/generated/README.md` for how to declare a new group and what to do when the check fires. ### Key Patterns diff --git a/openapi/spec/SOURCES.json b/openapi/spec/SOURCES.json index 72b986b..dce7b24 100644 --- a/openapi/spec/SOURCES.json +++ b/openapi/spec/SOURCES.json @@ -18,5 +18,125 @@ "title": "Empresas (contribuintes) v2", "note": "OpenAPI 3.0.4; verbose .NET schema keys (key-normalization pending, Task 1.2)" } - } + }, + "sharedSections": { + "_comment": "Seções declaradas em mais de uma spec. `canonical` é a fonte de verdade dos tipos daquele grupo; o check cross-spec do validate:spec compara as `duplicatedIn` contra ela campo a campo. Medido em 2026-09-01: 131 endpoints únicos, 30 compartilhados.", + "A-companies-v2": { + "canonical": "contribuintes-v2.json", + "duplicatedIn": [ + "nf-consumidor-v2.yaml", + "nf-produto-v2.yaml" + ], + "rationale": "Serviço dedicado de cadastro e superset: declara HEAD, municipaltaxes, switch-authorizer e as variantes v1 que as cópias não têm. As cópias são o mesmo bloco replicado nas specs de emissão.", + "paths": [ + "DELETE /v2/companies/{}", + "DELETE /v2/companies/{}/certificates/{}", + "DELETE /v2/companies/{}/statetaxes/{}", + "GET /v2/companies", + "GET /v2/companies/{}", + "GET /v2/companies/{}/certificates", + "GET /v2/companies/{}/certificates/{}", + "GET /v2/companies/{}/statetaxes", + "GET /v2/companies/{}/statetaxes/{}", + "POST /v2/companies", + "POST /v2/companies/{}/certificates", + "POST /v2/companies/{}/statetaxes", + "PUT /v2/companies/{}", + "PUT /v2/companies/{}/statetaxes/{}" + ] + }, + "B-companies-v1": { + "canonical": "contribuintes-v2.json", + "duplicatedIn": [ + "nf-servico-v1.yaml" + ], + "rationale": "Mesmo serviço dedicado do grupo A, que também descreve a superfície v1. Atenção: a cópia em nf-servico-v1 carrega correções locais provadas por sonda (minimum:1 em pageIndex, maximum:50 em pageCount, commit 100327e) que a canônica não tem — ver knownDivergences.", + "paths": [ + "DELETE /v1/companies/{}", + "GET /v1/companies", + "GET /v1/companies/{}", + "POST /v1/companies", + "POST /v1/companies/{}/certificate", + "PUT /v1/companies/{}" + ] + }, + "C-webhooks-v2": { + "canonical": "nf-consumidor-v2.yaml", + "duplicatedIn": [ + "nf-produto-v2.yaml", + "nf-servico-v1.yaml" + ], + "rationale": "Única das três com o TIPO correto: contentType e status como string, confirmado no fio pela sonda de 2026-09-01 (\"json\", \"Active\"). Também é superset — 11 campos contra 10, único a declarar `id`. As outras duas dizem integer enum [0,1], que nenhum cliente consegue usar. Ressalva: o casing dos VALORES dela é camelCase (`active` onde o fio manda `Active`), defeito separado — ver knownDivergences enum-casing.", + "paths": [ + "DELETE /v2/webhooks", + "DELETE /v2/webhooks/{}", + "GET /v2/webhooks", + "GET /v2/webhooks/eventtypes", + "GET /v2/webhooks/{}", + "POST /v2/webhooks", + "PUT /v2/webhooks/{}", + "PUT /v2/webhooks/{}/pings" + ] + } + }, + "ignoredOverloads": { + "_comment": "Mesmo path, contratos deliberadamente diferentes (layout legado × layout RTC da Reforma Tributária). Não é drift a resolver: é overload. O check não os compara.", + "paths": [ + { + "path": "POST /v2/companies/{}/productinvoices", + "specs": [ + "nf-produto-v2.yaml", + "product-invoice-rtc-v1.yaml" + ] + }, + { + "path": "POST /v1/companies/{}/serviceinvoices", + "specs": [ + "nf-servico-v1.yaml", + "service-invoice-rtc-v1.yaml" + ] + } + ] + }, + "knownDivergences": [ + { + "group": "A-companies-v2", + "class": "enum-mismatch", + "fields": [ + "taxRegime", + "specialTaxRegime", + "legalNature", + "status", + "code", + "type", + "environmentType" + ], + "count": 74, + "reason": "Valores de enum camelCased nas duas cópias: códigos de UF viram 'sP'/'aC', status vira 'active', tipo de inscrição vira 'nFe'/'nFCe'. Defeito de serialização na origem, não contrato diferente — a canônica usa 'SP'/'Active'/'NFe'. 10 ocorrências em cada cópia; nf-servico-v1 e contribuintes-v2 não têm nenhuma.", + "upstream": "SDKs/Node/probe-09-01-2026/02 - Pendencias upstream, item 12" + }, + { + "group": "C-webhooks-v2", + "class": "type-mismatch", + "fields": [ + "contentType", + "status" + ], + "count": 24, + "reason": "As cópias declaram integer enum [0,1]; a canônica declara string, e a sonda ao vivo de 2026-09-01 confirmou o fio: 'json' e 'Active'. O SDK tipa à mão em AccountWebhook e está correto. Ressalva: o casing dos valores da canônica ('active') sofre do mesmo defeito do item 12.", + "upstream": "SDKs/Node/probe-09-01-2026/02 - Pendencias upstream, item 2" + }, + { + "group": "B-companies-v1", + "class": "enum-subset", + "fields": [ + "taxRegime", + "legalNature", + "status" + ], + "count": 18, + "reason": "A cópia em nf-servico-v1 está atrasada: enums são subconjunto próprio dos da canônica (faltam 'Inactive', 'None' e ~25 naturezas jurídicas). Defasagem, não contradição — nada quebra, mas some quando a spec for re-sincronizada.", + "upstream": "change sync-openapi-specs-from-docs, Phase 3" + } + ] } diff --git a/scripts/cross-spec-check.ts b/scripts/cross-spec-check.ts new file mode 100644 index 0000000..27c16fb --- /dev/null +++ b/scripts/cross-spec-check.ts @@ -0,0 +1,358 @@ +#!/usr/bin/env tsx +/** + * Check de duplicação cross-spec. + * + * Várias seções (companies, certificates, statetaxes, webhooks) são declaradas em + * mais de uma spec da plataforma, e as cópias divergem. O `SOURCES.json` declara a + * fonte canônica de cada grupo; este check compara as cópias contra ela e falha + * quando aparece divergência nova. + * + * A comparação é CAMPO A CAMPO (`caminho.do.campo -> tipo/enum`), não operação a + * operação. Medido em 2026-09-01 sobre as specs reais: comparar a operação inteira, + * mesmo descontando prosa, marca 100% dos paths dos grupos A/B como divergentes — + * ruído de forma (`content: {}` versus ausente, schema inline versus `$ref`, ordem + * de chave). Campo a campo, o grupo C isola exatamente as 24 contradições reais de + * `contentType`/`status` sem um único falso positivo. + */ + +import { readdir, readFile } from 'fs/promises'; +import { join, resolve } from 'path'; +import { parse as parseYaml } from 'yaml'; + +const SPEC_DIR = resolve(process.cwd(), 'openapi/spec'); +const SOURCES_FILE = 'SOURCES.json'; + +const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head'] as const; + +/** Campos de prosa: nunca são contrato, sempre diferem entre cópias. */ +const PROSE_KEYS = new Set([ + 'description', 'summary', 'example', 'examples', 'tags', + 'operationId', 'title', 'externalDocs', 'deprecated', +]); + +const MAX_DEPTH = 6; + +// ============================================================================ +// Tipos +// ============================================================================ + +export type DivergenceClass = + | 'type-mismatch' + | 'enum-mismatch' + | 'enum-subset' + | 'field-only-in'; + +export interface Divergence { + group: string; + path: string; + field: string; + canonicalSpec: string; + duplicateSpec: string; + class: DivergenceClass; + canonicalValue: string; + duplicateValue: string; +} + +export interface FieldShape { + type: string | undefined; + enum: string[] | undefined; +} + +export interface SharedSection { + canonical: string; + duplicatedIn: string[]; + paths: string[]; + rationale?: string; +} + +export interface KnownDivergence { + group: string; + class: DivergenceClass; + fields: string[]; + reason: string; + upstream: string; +} + +export interface Sources { + sharedSections?: Record; + ignoredOverloads?: { paths: Array<{ path: string; specs: string[] }> }; + knownDivergences?: KnownDivergence[]; +} + +export interface CrossSpecReport { + divergences: Divergence[]; + baselined: Divergence[]; + undeclared: Array<{ path: string; specs: string[] }>; + staleBaseline: KnownDivergence[]; + agreedFields: Record; +} + +// ============================================================================ +// Normalização +// ============================================================================ + +/** `/v2/companies/{company_id}` e `/v2/companies/:companyId` -> `/v2/companies/{}` */ +export function normalizePath(path: string): string { + return path + .replace(/\{[^}]*\}/g, '{}') + .replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '{}') + .replace(/\/+$/, '') + .toLowerCase(); +} + +export function operationKey(method: string, path: string, basePath = ''): string { + return `${method.toUpperCase()} ${normalizePath(basePath + path)}`; +} + +// ============================================================================ +// Achatamento de schema +// ============================================================================ + +function deref(spec: unknown, node: unknown, hops = 0): Record { + let current = node; + let n = hops; + while ( + current && typeof current === 'object' && !Array.isArray(current) && + typeof (current as Record)['$ref'] === 'string' && n < 10 + ) { + const ref = (current as Record)['$ref'] as string; + if (!ref.startsWith('#/')) break; + let cursor: unknown = spec; + for (const part of ref.slice(2).split('/')) { + cursor = + cursor && typeof cursor === 'object' + ? (cursor as Record)[part.replace(/~1/g, '/').replace(/~0/g, '~')] + : undefined; + } + if (cursor === undefined) break; + current = cursor; + n++; + } + return current && typeof current === 'object' && !Array.isArray(current) + ? (current as Record) + : {}; +} + +/** Achata um schema em `caminho -> (type, enum)`. Prosa e forma ficam de fora. */ +function flattenSchema( + spec: unknown, + schema: unknown, + prefix: string, + out: Map, + depth = 0 +): Map { + if (depth > MAX_DEPTH) return out; + const s = deref(spec, schema, 0); + + if (s['type'] === 'array') { + return flattenSchema(spec, s['items'], `${prefix}[]`, out, depth + 1); + } + + const props = s['properties']; + if (props && typeof props === 'object') { + for (const [key, value] of Object.entries(props as Record)) { + if (PROSE_KEYS.has(key)) continue; + flattenSchema(spec, value, prefix ? `${prefix}.${key}` : key, out, depth + 1); + } + return out; + } + + if (prefix) { + const enumValues = Array.isArray(s['enum']) + ? (s['enum'] as unknown[]).map(String).sort() + : undefined; + out.set(prefix, { type: typeof s['type'] === 'string' ? s['type'] : undefined, enum: enumValues }); + } + return out; +} + +/** Superfície que um cliente amarra: params + request body + respostas 2xx. */ +export function operationFields(spec: unknown, operation: Record): Map { + const out = new Map(); + + for (const param of (operation['parameters'] as unknown[]) ?? []) { + if (!param || typeof param !== 'object') continue; + const p = param as Record; + const schema = deref(spec, p['schema'], 0); + const enumValues = Array.isArray(schema['enum']) + ? (schema['enum'] as unknown[]).map(String).sort() + : undefined; + out.set(`param.${String(p['name'])}`, { + type: typeof schema['type'] === 'string' ? schema['type'] : undefined, + enum: enumValues, + }); + } + + const body = deref(spec, operation['requestBody'], 0); + for (const media of Object.values((body['content'] as Record) ?? {})) { + if (media && typeof media === 'object') { + flattenSchema(spec, (media as Record)['schema'], 'req', out); + } + } + + for (const [code, response] of Object.entries((operation['responses'] as Record) ?? {})) { + if (!code.startsWith('2')) continue; + const r = deref(spec, response, 0); + for (const media of Object.values((r['content'] as Record) ?? {})) { + if (media && typeof media === 'object') { + flattenSchema(spec, (media as Record)['schema'], 'res', out); + } + } + } + + return out; +} + +// ============================================================================ +// Classificação +// ============================================================================ + +function describe(shape: FieldShape): string { + return shape.enum ? `${shape.type ?? '?'} [${shape.enum.join(', ')}]` : String(shape.type ?? '?'); +} + +export function classify(canonical: FieldShape, duplicate: FieldShape): DivergenceClass | null { + if (canonical.type !== duplicate.type) return 'type-mismatch'; + + const a = canonical.enum; + const b = duplicate.enum; + if (!a && !b) return null; + if (!a || !b) return 'enum-mismatch'; + if (a.length === b.length && a.every((v, i) => v === b[i])) return null; + + // Cópia estritamente contida na canônica: defasagem, não contradição. + const canonicalSet = new Set(a); + if (b.every(v => canonicalSet.has(v))) return 'enum-subset'; + return 'enum-mismatch'; +} + +// ============================================================================ +// Análise +// ============================================================================ + +export async function loadSpecs(): Promise> { + const files = (await readdir(SPEC_DIR)).filter( + f => f !== SOURCES_FILE && (f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.json')) + ); + const specs = new Map(); + for (const file of files) { + const raw = await readFile(join(SPEC_DIR, file), 'utf-8'); + try { + specs.set(file, parseYaml(raw)); // parseYaml também lê JSON + } catch { + // Spec malformada já é reportada pelo validador por arquivo; aqui só pulamos. + } + } + return specs; +} + +export async function loadSources(): Promise { + return JSON.parse(await readFile(join(SPEC_DIR, SOURCES_FILE), 'utf-8')) as Sources; +} + +function indexOperations(specs: Map): Map>> { + const index = new Map>>(); + for (const [file, spec] of specs) { + if (!spec || typeof spec !== 'object') continue; + const s = spec as Record; + const basePath = typeof s['basePath'] === 'string' ? s['basePath'] : ''; + for (const [path, item] of Object.entries((s['paths'] as Record) ?? {})) { + if (!item || typeof item !== 'object') continue; + for (const [method, operation] of Object.entries(item as Record)) { + if (!HTTP_METHODS.includes(method.toLowerCase() as (typeof HTTP_METHODS)[number])) continue; + const key = operationKey(method, path, basePath); + if (!index.has(key)) index.set(key, new Map()); + index.get(key)!.set(file, operation as Record); + } + } + } + return index; +} + +/** + * @param input Specs e SOURCES já carregados. Omitido, lê de `openapi/spec/`. + * Os testes injetam fixtures por aqui. + */ +export async function analyze(input?: { + specs: Map; + sources: Sources; +}): Promise { + const specs = input?.specs ?? (await loadSpecs()); + const sources = input?.sources ?? (await loadSources()); + const index = indexOperations(specs); + + const sections = Object.entries(sources.sharedSections ?? {}).filter( + (entry): entry is [string, SharedSection] => typeof entry[1] === 'object' && entry[1] !== null + ); + const overloads = new Set((sources.ignoredOverloads?.paths ?? []).map(p => p.path)); + const known = sources.knownDivergences ?? []; + + const declared = new Set(overloads); + for (const [, section] of sections) for (const p of section.paths) declared.add(p); + + const divergences: Divergence[] = []; + const baselined: Divergence[] = []; + const agreedFields: Record = {}; + const seenBaseline = new Set(); + + for (const [groupName, section] of sections) { + let agreed = 0; + for (const opKey of section.paths) { + const bySpec = index.get(opKey); + if (!bySpec) continue; + const canonicalOp = bySpec.get(section.canonical); + if (!canonicalOp) continue; + const canonicalSpec = specs.get(section.canonical); + const canonicalFields = operationFields(canonicalSpec, canonicalOp); + + for (const duplicateFile of section.duplicatedIn) { + const duplicateOp = bySpec.get(duplicateFile); + if (!duplicateOp) continue; + const duplicateFields = operationFields(specs.get(duplicateFile), duplicateOp); + + for (const [field, canonicalShape] of canonicalFields) { + const duplicateShape = duplicateFields.get(field); + if (!duplicateShape) continue; + const klass = classify(canonicalShape, duplicateShape); + if (!klass) { + agreed++; + continue; + } + const item: Divergence = { + group: groupName, + path: opKey, + field, + canonicalSpec: section.canonical, + duplicateSpec: duplicateFile, + class: klass, + canonicalValue: describe(canonicalShape), + duplicateValue: describe(duplicateShape), + }; + const match = known.find( + k => k.group === groupName && k.class === klass && k.fields.some(f => field === f || field.endsWith(`.${f}`)) + ); + if (match) { + baselined.push(item); + seenBaseline.add(`${match.group}|${match.class}|${match.fields.join(',')}`); + } else { + divergences.push(item); + } + } + } + } + agreedFields[groupName] = agreed; + } + + const undeclared: Array<{ path: string; specs: string[] }> = []; + for (const [key, bySpec] of index) { + if (bySpec.size > 1 && !declared.has(key)) { + undeclared.push({ path: key, specs: [...bySpec.keys()].sort() }); + } + } + + const staleBaseline = known.filter( + k => !seenBaseline.has(`${k.group}|${k.class}|${k.fields.join(',')}`) + ); + + return { divergences, baselined, undeclared, staleBaseline, agreedFields }; +} diff --git a/scripts/validate-spec.ts b/scripts/validate-spec.ts index b451f39..011b5d9 100644 --- a/scripts/validate-spec.ts +++ b/scripts/validate-spec.ts @@ -16,6 +16,7 @@ import { readdir, readFile } from 'fs/promises'; import { join, basename, resolve } from 'path'; import { parse as parseYaml } from 'yaml'; +import { analyze, type CrossSpecReport } from './cross-spec-check.js'; // ============================================================================ // Configuration @@ -81,13 +82,19 @@ async function main(): Promise { printResult(result); } + // Seções compartilhadas entre specs: só faz sentido comparar depois que cada + // arquivo passou pela validação individual. + const crossSpec = await analyze(); + printCrossSpec(crossSpec); + printSummary(results); // Exit with error if any validation failed const hasErrors = results.some(r => !r.valid); const hasWarnings = results.some(r => r.warnings.length > 0); + const crossSpecFailed = crossSpec.divergences.length > 0 || crossSpec.undeclared.length > 0; - if (hasErrors || (STRICT_MODE && hasWarnings)) { + if (hasErrors || crossSpecFailed || (STRICT_MODE && hasWarnings)) { process.exit(1); } @@ -101,9 +108,19 @@ async function main(): Promise { // Discovery // ============================================================================ +/** Arquivos de `openapi/spec/` que não são specs. */ +const NON_SPEC_FILES = new Set(['SOURCES.json']); + async function discoverSpecs(): Promise { const files = await readdir(SPEC_DIR); - return files.filter(file => file.endsWith('.yaml') || file.endsWith('.yml')); + // `.json` entra junto: `contribuintes-v2.json` é a canônica das seções de + // companies/certificates/statetaxes e ficava de fora da validação enquanto o + // filtro era só YAML — enquanto o generate-types.ts já a processava. + return files.filter( + file => + !NON_SPEC_FILES.has(file) && + (file.endsWith('.yaml') || file.endsWith('.yml') || file.endsWith('.json')) + ); } // ============================================================================ @@ -280,6 +297,46 @@ function printResult(result: ValidationResult): void { console.log(''); } +function printCrossSpec(report: CrossSpecReport): void { + console.log('─'.repeat(50)); + console.log('Seções compartilhadas entre specs:'); + + const agreed = Object.entries(report.agreedFields); + for (const [group, count] of agreed) { + console.log(` ${group}: ${count} campos concordam com a canônica`); + } + + if (report.baselined.length > 0) { + const byClass = new Map(); + for (const d of report.baselined) byClass.set(d.class, (byClass.get(d.class) ?? 0) + 1); + const summary = [...byClass].map(([k, v]) => `${v} ${k}`).join(', '); + console.log(` ℹ️ ${report.baselined.length} divergência(s) já declarada(s) em knownDivergences (${summary})`); + } + + for (const stale of report.staleBaseline) { + console.log(` ⚠️ Baseline obsoleta: ${stale.group} / ${stale.class} / ${stale.fields.join(', ')}`); + console.log(` A divergência não reproduz mais — remover a entrada de knownDivergences.`); + } + + for (const item of report.undeclared) { + console.log(` ❌ Path compartilhado não declarado: ${item.path}`); + console.log(` Presente em: ${item.specs.join(', ')}`); + console.log(` 💡 Declare a canônica do grupo em SOURCES.json > sharedSections`); + } + + for (const d of report.divergences) { + console.log(` ❌ ${d.class}: ${d.path} — campo \`${d.field}\``); + console.log(` ${d.canonicalSpec} (canônica): ${d.canonicalValue}`); + console.log(` ${d.duplicateSpec}: ${d.duplicateValue}`); + } + + if (report.divergences.length === 0 && report.undeclared.length === 0) { + console.log(' ✓ Nenhuma divergência nova entre cópias'); + } + + console.log(''); +} + function printSummary(results: ValidationResult[]): void { const total = results.length; const valid = results.filter(r => r.valid).length; diff --git a/src/generated/README.md b/src/generated/README.md index c37d41b..c6421cd 100644 --- a/src/generated/README.md +++ b/src/generated/README.md @@ -35,3 +35,49 @@ To modify types, edit the OpenAPI specs and regenerate: - `*.ts` - Type definitions from each OpenAPI spec - `index.ts` - Unified exports with namespace organization + +## Seções compartilhadas entre specs + +Várias seções (companies, certificates, statetaxes, webhooks) são declaradas em mais de +uma spec — 30 dos 131 endpoints. As cópias **divergem**, e o `npm run validate:spec` +compara cada uma contra a canônica declarada em `openapi/spec/SOURCES.json` +(`sharedSections`), campo a campo. + +O check classifica a divergência em quatro: + +| classe | o que é | efeito | +|---|---|---| +| `type-mismatch` | mesmo campo, `type` diferente | **falha o build** | +| `enum-mismatch` | mesmo tipo, enums que se contradizem | **falha o build** | +| `enum-subset` | enum da cópia contido no da canônica (cópia atrasada) | aviso | +| `field-only-in` | campo declarado só de um lado | informativo | + +Diferença de prosa (`description`, `summary`, `operationId`) ou de forma (`$ref` versus +inline, `content: {}` versus ausente) **não** é divergência. + +### Quando o check acusa + +- **`type-mismatch` / `enum-mismatch` novo** — uma sync trouxe drift. Não silencie: veja + qual cópia bate com a API real (sonda ao vivo, nunca inferência) e registre a pendência + upstream. +- **Path compartilhado não declarado** — uma spec passou a declarar um endpoint que já + existia em outra. Escolha a canônica e adicione o grupo em `sharedSections`. +- **Baseline obsoleta** — a divergência foi corrigida upstream. Remova a entrada de + `knownDivergences`; ela cumpriu o papel. + +### Declarando um grupo novo + +```jsonc +"sharedSections": { + "X-nome-do-grupo": { + "canonical": "spec-que-manda.yaml", // fonte de verdade dos tipos + "duplicatedIn": ["copia-a.yaml"], // quem repete a seção + "rationale": "por que esta é a canônica — evidência, não preferência", + "paths": ["GET /v2/coisas/{}"] // normalizado: params viram {}, minúsculas + } +} +``` + +A canônica se escolhe por evidência (superset, tipo confirmado no fio), não por ser a +maior ou a mais usada. Overload deliberado — mesmo path com contratos diferentes, como +legado × RTC — vai em `ignoredOverloads`, não em `sharedSections`. diff --git a/tests/unit/cross-spec-check.test.ts b/tests/unit/cross-spec-check.test.ts new file mode 100644 index 0000000..9223e12 --- /dev/null +++ b/tests/unit/cross-spec-check.test.ts @@ -0,0 +1,292 @@ +/** + * Check de duplicação cross-spec. + * + * As fixtures aqui são mínimas de propósito: cada uma isola UMA classe de + * divergência. O caso do 5.3 (diferença só de forma) é o que importa mais — foi + * ele que invalidou as três primeiras estratégias de comparação testadas no + * design, todas as quais marcavam 100% dos paths como divergentes. + */ + +import { describe, it, expect } from 'vitest'; +import { + analyze, + classify, + normalizePath, + operationKey, + type Sources, +} from '../../scripts/cross-spec-check.js'; + +/** Spec mínima com um GET cuja resposta 200 tem as propriedades dadas. */ +function spec(properties: Record, extra: Record = {}) { + return { + openapi: '3.0.0', + paths: { + '/v2/things/{thing_id}': { + get: { + ...extra, + responses: { + '200': { + description: 'ok', + content: { + 'application/json': { + schema: { type: 'object', properties }, + }, + }, + }, + }, + }, + }, + }, + }; +} + +const SOURCES: Sources = { + sharedSections: { + G: { + canonical: 'canon.yaml', + duplicatedIn: ['copy.yaml'], + paths: ['GET /v2/things/{}'], + }, + }, +}; + +function run(canon: unknown, copy: unknown, sources: Sources = SOURCES) { + return analyze({ + specs: new Map([ + ['canon.yaml', canon], + ['copy.yaml', copy], + ]), + sources, + }); +} + +describe('normalização de path', () => { + it('colapsa params nomeados e estilo dois-pontos na mesma forma', () => { + expect(normalizePath('/v2/companies/{companyId}')).toBe('/v2/companies/{}'); + expect(normalizePath('/v2/companies/:companyId')).toBe('/v2/companies/{}'); + }); + + it('ignora case e barra final', () => { + expect(normalizePath('/v2/Webhooks/EventTypes/')).toBe('/v2/webhooks/eventtypes'); + }); + + it('compõe a chave com o método e o basePath', () => { + expect(operationKey('get', '/companies', '/v1')).toBe('GET /v1/companies'); + }); +}); + +describe('classificação de divergência', () => { + it('tipo diferente é type-mismatch', () => { + expect(classify({ type: 'string', enum: undefined }, { type: 'integer', enum: undefined })).toBe( + 'type-mismatch' + ); + }); + + it('enum da cópia contido no da canônica é defasagem', () => { + expect(classify({ type: 'string', enum: ['a', 'b', 'c'] }, { type: 'string', enum: ['a', 'b'] })).toBe( + 'enum-subset' + ); + }); + + it('enum com valor que a canônica não tem é contradição', () => { + expect(classify({ type: 'string', enum: ['A', 'B'] }, { type: 'string', enum: ['a', 'b'] })).toBe( + 'enum-mismatch' + ); + }); + + it('iguais não divergem', () => { + expect(classify({ type: 'string', enum: ['a'] }, { type: 'string', enum: ['a'] })).toBeNull(); + }); +}); + +describe('análise cross-spec', () => { + it('5.1 drift novo fora da baseline é reportado como erro', async () => { + const report = await run( + spec({ contentType: { type: 'string', enum: ['json'] } }), + spec({ contentType: { type: 'integer', enum: [0, 1] } }) + ); + + expect(report.divergences).toHaveLength(1); + expect(report.divergences[0]).toMatchObject({ + group: 'G', + path: 'GET /v2/things/{}', + field: 'res.contentType', + class: 'type-mismatch', + canonicalSpec: 'canon.yaml', + duplicateSpec: 'copy.yaml', + }); + expect(report.divergences[0]!.canonicalValue).toContain('string'); + expect(report.divergences[0]!.duplicateValue).toContain('integer'); + }); + + it('5.2a cópias iguais não geram achado', async () => { + const fields = { name: { type: 'string' }, size: { type: 'integer' } }; + const report = await run(spec(fields), spec(fields)); + + expect(report.divergences).toHaveLength(0); + expect(report.agreedFields['G']).toBe(2); + }); + + it('5.2b enum defasado é aviso, não erro', async () => { + const report = await run( + spec({ status: { type: 'string', enum: ['Active', 'Inactive', 'None'] } }), + spec({ status: { type: 'string', enum: ['Active', 'None'] } }) + ); + + expect(report.divergences).toHaveLength(1); + expect(report.divergences[0]!.class).toBe('enum-subset'); + }); + + it('5.3 diferença só de forma não gera achado', async () => { + // Mesmo contrato, três formas diferentes de escrever: $ref × inline, + // operationId presente × ausente, description divergente. + const canon = { + openapi: '3.0.0', + components: { schemas: { Thing: { type: 'object', properties: { id: { type: 'string' } } } } }, + paths: { + '/v2/things/{thing_id}': { + get: { + description: 'Uma descrição', + responses: { + '200': { + description: 'ok', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Thing' } } }, + }, + }, + }, + }, + }, + }; + const copy = { + openapi: '3.0.0', + paths: { + '/v2/things/:thingId': { + get: { + operationId: 'Things_get', + description: 'Outra descrição completamente diferente', + responses: { + '200': { + description: 'sucesso', + content: { 'application/json': { schema: { type: 'object', properties: { id: { type: 'string' } } } } }, + }, + }, + }, + }, + }, + }; + + const report = await run(canon, copy); + expect(report.divergences).toHaveLength(0); + expect(report.agreedFields['G']).toBe(1); + }); + + it('campo declarado só de um lado não é divergência', async () => { + const report = await run( + spec({ id: { type: 'string' }, search: { type: 'string' } }), + spec({ id: { type: 'string' } }) + ); + + expect(report.divergences).toHaveLength(0); + expect(report.agreedFields['G']).toBe(1); + }); + + it('path compartilhado não declarado é erro de configuração', async () => { + const report = await run(spec({ id: { type: 'string' } }), spec({ id: { type: 'string' } }), { + sharedSections: {}, + }); + + expect(report.undeclared).toHaveLength(1); + expect(report.undeclared[0]).toMatchObject({ + path: 'GET /v2/things/{}', + specs: ['canon.yaml', 'copy.yaml'], + }); + }); + + it('overload declarado não vira achado de duplicação', async () => { + const report = await run(spec({ id: { type: 'string' } }), spec({ id: { type: 'integer' } }), { + sharedSections: {}, + ignoredOverloads: { paths: [{ path: 'GET /v2/things/{}', specs: ['canon.yaml', 'copy.yaml'] }] }, + }); + + expect(report.undeclared).toHaveLength(0); + expect(report.divergences).toHaveLength(0); + }); +}); + +describe('baseline', () => { + const divergent = () => + run( + spec({ contentType: { type: 'string', enum: ['json'] } }), + spec({ contentType: { type: 'integer', enum: [0, 1] } }), + { + ...SOURCES, + knownDivergences: [ + { + group: 'G', + class: 'type-mismatch', + fields: ['contentType'], + reason: 'defeito conhecido de serialização', + upstream: 'vault item 2', + }, + ], + } + ); + + it('divergência declarada sai dos erros e vira informativo', async () => { + const report = await divergent(); + + expect(report.divergences).toHaveLength(0); + expect(report.baselined).toHaveLength(1); + expect(report.baselined[0]!.field).toBe('res.contentType'); + expect(report.staleBaseline).toHaveLength(0); + }); + + it('drift novo continua falhando mesmo com baseline no mesmo path', async () => { + const report = await run( + spec({ + contentType: { type: 'string', enum: ['json'] }, + status: { type: 'string', enum: ['Active'] }, + }), + spec({ + contentType: { type: 'integer', enum: [0, 1] }, + status: { type: 'integer', enum: [0, 1] }, + }), + { + ...SOURCES, + knownDivergences: [ + { + group: 'G', + class: 'type-mismatch', + fields: ['contentType'], + reason: 'declarado', + upstream: 'vault', + }, + ], + } + ); + + expect(report.baselined).toHaveLength(1); + expect(report.divergences).toHaveLength(1); + expect(report.divergences[0]!.field).toBe('res.status'); + }); + + it('4.3 baseline que não reproduz mais pede remoção', async () => { + const identical = { contentType: { type: 'string', enum: ['json'] } }; + const report = await run(spec(identical), spec(identical), { + ...SOURCES, + knownDivergences: [ + { + group: 'G', + class: 'type-mismatch', + fields: ['contentType'], + reason: 'já corrigido upstream', + upstream: 'vault item 2', + }, + ], + }); + + expect(report.divergences).toHaveLength(0); + expect(report.staleBaseline).toHaveLength(1); + expect(report.staleBaseline[0]!.fields).toEqual(['contentType']); + }); +}); From 87c6af8b66b86261005744f43509aabb35468009 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Wed, 2 Sep 2026 19:40:00 -0300 Subject: [PATCH 07/21] ci(release): o portao de publicacao passa a poder reprovar publish.yml marcava o passo de testes com `continue-on-error: true`, e um bloco logo abaixo justificava por escrito: "Some tests failed, but continuing with publish" "This is expected for integration tests without API credentials" A justificativa e falsa. Medido: sem credencial a suite da 41 passed | 4 skipped, exit 0. Os testes de integracao PULAM, nao falham -- o guard shouldRunIntegrationTests() cuida disso desde sempre. O continue-on-error protegia contra um modo de falha que nao existe e, em troca, deixava passar todos os que existem. Os tres bugs de contrato corrigidos nesta mesma versao sairam por esse portao. Agora publish.yml roda testes, lint, typecheck e test:types antes do build, e qualquer um deles barra a publicacao. test:types tambem entrou no ci.yml: eram 18 assertions -- incluindo os guards de alinhamento de contrato escritos em 01/09 -- que nunca executavam. Custam 3,3s. A suite de integracao tambem nao podia ser rodada: dotenv era devDependency e nada carregava o .env, entao NFE_API_KEY chegava vazia e a integracao pulava sempre, inclusive na maquina de quem tem credencial. Carregado em tests/setup.ts (dotenv nao sobrescreve ambiente existente, entao export manual e CI continuam vencendo o arquivo). Com isso a execucao local foi de 742 para 779 testes -- 37 que nunca haviam rodado -- e tres assertions apodrecidas apareceram: afirmavam Array.isArray(companies) contra um ListResponse ({data, page}), e uma quarta lia companies.length (undefined). Sao de antes da migracao para ListResponse. Corrigidas. Os outros 3 arquivos de integracao passaram intactos. O guard NAO mudou: em CI a integracao continua pulando sem RUN_INTEGRATION_TESTS=true. Credencial de conta compartilhada nao vai para runner. Verificado nos dois modos. O it.skip do upload de certificado fica desligado, agora com motivo honesto no lugar do vago: exigiria .pfx versionado (material sensivel) e CRIA empresa numa conta compartilhada. O que ele protegeria ja esta coberto sem rede pelo unitario que afirma o campo multipart. Registrado tambem, em generation.test.ts, o efeito que este portao NAO cobre: a suite roda a geracao de verdade e suja src/generated/ com ~22 linhas de carimbo. A correcao pertence ao pipeline de geracao. Nada em src/. Zero efeito em runtime ou API publica. --- .github/workflows/ci.yml | 4 +++ .github/workflows/publish.yml | 18 +++++++------ CHANGELOG.md | 27 +++++++++++++++++++ .../integration/companies.integration.test.ts | 18 +++++++++++-- tests/integration/errors.integration.test.ts | 9 ++++--- tests/setup.ts | 11 ++++++++ tests/unit/generation.test.ts | 10 +++++++ 7 files changed, 83 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4a4864..13d3d3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,10 @@ jobs: - name: Run type checking run: npm run typecheck + # Guards de alinhamento de contrato: quebram quando um contrato pinado muda. + - name: Run type-level tests + run: npm run test:types + - name: Run tests run: npm test -- --run --reporter=verbose diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6c84dcb..0ad3ec3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,23 +34,25 @@ jobs: - name: Install dependencies run: npm ci + # Sem NFE_API_KEY a suite de integracao PULA (guard em + # tests/integration/setup.ts) e a unitaria passa: 41 passed | 4 skipped, + # exit 0. Falha aqui e falha de verdade, e barra a publicacao. - name: Run tests - id: tests - continue-on-error: true run: npm test -- --run env: NFE_API_KEY: "" - - name: Test Results Summary - if: steps.tests.outcome == 'failure' - run: | - echo "⚠️ Some tests failed, but continuing with publish" >> $GITHUB_STEP_SUMMARY - echo "This is expected for integration tests without API credentials" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + - name: Run linter + run: npm run lint - name: Run type checking run: npm run typecheck + # Guards de alinhamento de contrato (tests/types/*.test-d.ts). Existem para + # quebrar quando um contrato pinado muda; sem este passo, nunca executam. + - name: Run type-level tests + run: npm run test:types + - name: Build package run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index 983d864..1a331e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,33 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Manutenção +- **O portão de publicação passou a poder reprovar.** `.github/workflows/publish.yml` + marcava o passo de testes com `continue-on-error: true`, e um bloco logo abaixo + justificava por escrito: *"expected for integration tests without API credentials"*. + + A justificativa era falsa. Sem credencial a suíte dá **41 passed | 4 skipped, exit 0** — + os testes de integração **pulam**, não falham; o guard `shouldRunIntegrationTests()` + cuida disso desde sempre. Ou seja: o `continue-on-error` protegia contra um modo de + falha inexistente e, em troca, deixava passar todos os reais. Os três bugs de contrato + corrigidos nesta mesma versão saíram por esse portão. + + Agora `publish.yml` roda testes, `lint`, `typecheck` e `test:types` antes do build, e + qualquer um deles reprova a publicação. O `test:types` também entrou no `ci.yml`: eram + 18 assertions — incluindo os guards de alinhamento de contrato — que **nunca executavam**. + +- **A suíte de integração voltou a ser executável.** `dotenv` era devDependency e nada + carregava o `.env`, então `NFE_API_KEY` chegava vazia e a integração pulava sempre, + inclusive na máquina de quem tinha credencial. Com o `.env` carregado em `tests/setup.ts`, + a execução local passou de **742 para 779 testes** — 37 que nunca haviam rodado. + + Três assertions de `errors.integration.test.ts` afirmavam `Array.isArray(companies)` + contra um `ListResponse` (`{ data, page }`), e uma quarta lia `companies.length` + (`undefined`). Eram de antes da migração para `ListResponse` e nunca falharam porque + nunca rodaram. Corrigidas. + + O guard não mudou: em CI a integração continua pulando sem `RUN_INTEGRATION_TESTS=true`. + Credencial de conta compartilhada não vai para runner. + - **`validate:spec` passa a detectar drift entre cópias da mesma seção.** 30 dos 131 endpoints das specs são declarados em mais de um arquivo (companies, certificates, statetaxes, webhooks) e as cópias divergem — algo que o `SOURCES.json` não pegava, diff --git a/tests/integration/companies.integration.test.ts b/tests/integration/companies.integration.test.ts index 14d0254..f21bbd7 100644 --- a/tests/integration/companies.integration.test.ts +++ b/tests/integration/companies.integration.test.ts @@ -183,8 +183,22 @@ describe.skipIf(skipIfNoApiKey())('Companies Integration Tests', () => { expect(duplicate.federalTaxNumber).toBe(created.federalTaxNumber); }); - // Note: Certificate upload test commented out as it requires valid PFX file - // and test environment might not support it + // DESLIGADO DE PROPÓSITO — dois bloqueios, nenhum deles temporário: + // + // 1. exigiria um .pfx versionado no repositório (certificado digital é material + // sensível: não entra em repo, nem de teste); + // 2. o corpo do teste CRIA uma empresa, e a conta é compartilhada pelo time — + // escrita de teste automatizado ali não é aceitável. + // + // O que este teste protegeria já está coberto sem rede: o nome do campo multipart + // (`file`, não `certificate`) é afirmado em tests/unit/companies-certificate-field.ts + // contra o FormData montado, e foi o campo errado — não a falta de teste ao vivo — + // que manteve uploadCertificate quebrado até 2026-09-01. + // + // Contrato medido ao vivo naquela data: campo `certificate` -> 400 + // {"errors":{"file":["The File field is required."]}}; campo `file` -> 500 (a API + // leu o arquivo e falhou ao parsear o PFX falso). Evidência versionada em + // tests/fixtures/live-contracts/certificate-upload-field.json. it.skipIf(skipIfNoApiKey()).skip('should upload certificate', async () => { // Create company first const companyData = { diff --git a/tests/integration/errors.integration.test.ts b/tests/integration/errors.integration.test.ts index 0fdfebe..5d33bf4 100644 --- a/tests/integration/errors.integration.test.ts +++ b/tests/integration/errors.integration.test.ts @@ -134,9 +134,10 @@ describe.skipIf(skipIfNoApiKey())('Error Handling Integration Tests', () => { logTestInfo('Testing retry configuration (should succeed normally)'); // This should succeed on first try (no retry needed) + // `list()` devolve ListResponse = { data, page } — não um array. const companies = await clientWithRetry.companies.list(); expect(companies).toBeDefined(); - expect(Array.isArray(companies)).toBe(true); + expect(Array.isArray(companies.data)).toBe(true); logTestInfo('Retry configuration test passed'); }); @@ -237,7 +238,7 @@ describe.skipIf(skipIfNoApiKey())('Error Handling Integration Tests', () => { expect(results).toHaveLength(3); results.forEach(companies => { expect(companies).toBeDefined(); - expect(Array.isArray(companies)).toBe(true); + expect(Array.isArray(companies.data)).toBe(true); }); logTestInfo('Concurrent requests handled correctly'); @@ -252,9 +253,9 @@ describe.skipIf(skipIfNoApiKey())('Error Handling Integration Tests', () => { const companies = await client.companies.list(); expect(companies).toBeDefined(); - expect(Array.isArray(companies)).toBe(true); + expect(Array.isArray(companies.data)).toBe(true); // Length could be 0 or more - both are valid - expect(companies.length).toBeGreaterThanOrEqual(0); + expect(companies.data.length).toBeGreaterThanOrEqual(0); logTestInfo('Empty response handled correctly', { count: companies.length }); }); diff --git a/tests/setup.ts b/tests/setup.ts index f6e964a..61b3835 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -3,8 +3,19 @@ * Configures vitest environment and provides test utilities */ +import { config as loadDotenv } from 'dotenv'; import type { Webhook, WebhookEvent } from '../src/core/types.js'; +// Carrega o .env do repositório para que NFE_API_KEY / NFE_COMPANY_ID cheguem à +// suíte de integração sem export manual. Sem isso ela pulava SEMPRE, inclusive na +// máquina de quem tem credencial — e as assertions apodreciam sem ninguém ver. +// +// `dotenv` NÃO sobrescreve variável já presente no ambiente, então export manual e +// CI continuam vencendo o arquivo. E o guard de integração +// (tests/integration/setup.ts) segue exigindo `!isCI || RUN_INTEGRATION_TESTS=true`: +// isto habilita a suíte localmente, não em CI. +loadDotenv(); + // Suppress unhandled rejection warnings from async polling tests process.on('unhandledRejection', (reason: any) => { // Ignore TimeoutError and polling errors in tests diff --git a/tests/unit/generation.test.ts b/tests/unit/generation.test.ts index 76b74be..29c6fb5 100644 --- a/tests/unit/generation.test.ts +++ b/tests/unit/generation.test.ts @@ -202,6 +202,16 @@ describe('OpenAPI Type Generation', () => { return; } + // ⚠️ EFEITO COLATERAL CONHECIDO: este teste roda a geração de verdade, que + // reescreve o carimbo `Last updated` / `Last generated` de 11 arquivos de + // src/generated/. Rodar a suíte deixa a árvore suja com ~22 linhas de diff sem + // nenhuma mudança de tipo — e isso já varreu arquivos para dentro de um commit. + // Antes de commitar depois de rodar testes: `git checkout -- src/generated/`. + // + // A correção (gerar para diretório temporário, ou tirar o carimbo do arquivo) + // mexe no pipeline de geração e pertence à change `sync-openapi-specs-from-docs`, + // não ao portão de release. + let output = ''; expect(() => { output = execSync('npm run generate', { From bffd0d827c63107d28d2598cc67a2f4016406179 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Wed, 2 Sep 2026 21:19:10 -0300 Subject: [PATCH 08/21] fix(client): healthCheck para de mandar o pageCount que a API recusa `healthCheck()` respondia `status: 'error'` SEMPRE -- com credencial valida e API no ar -- porque enviava `pageCount: 1`: GET /v1/companies?pageCount=1 -> 400 "pageCount must be between 1 and 50" GET /v1/companies?pageCount=2 -> 200 GET /v1/companies -> 200 O limite inferior do servidor esta um a mais do que a propria mensagem diz. Medido em 2026-09-02 contra api.nfe.io com chave real; o metodo agora omite o parametro, em vez de carregar um numero magico contornando defeito alheio. O off-by-one vai para o time de API como pendencia propria. O teste afirma o parametro efetivamente enviado. Afirmar so `status: 'ok'` contra um mock nao pegaria a regressao: o mock responde 200 para qualquer query, inclusive a que a API recusa. Nao havia nenhum teste de healthCheck ate aqui. --- src/core/client.ts | 14 ++- tests/unit/client-health-check.test.ts | 117 +++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 tests/unit/client-health-check.test.ts diff --git a/src/core/client.ts b/src/core/client.ts index 629df3b..ecd950e 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1395,6 +1395,10 @@ export class NfeClient { * Performs a simple API request to verify connectivity and authentication. * Useful for debugging connection issues or validating client configuration. * + * Issues `GET /v1/companies` with no query string — the smallest request the + * API accepts on this route. See the implementation note before you add a + * pagination parameter here. + * * @example * ```typescript * const health = await nfe.healthCheck(); @@ -1422,8 +1426,14 @@ export class NfeClient { */ public async healthCheck(): Promise<{ status: 'ok' | 'error', details?: any }> { try { - // Try to make a simple request (get companies list with pageCount=1) - await this.getMainHttpClient().get('/companies', { pageCount: 1 }); + // Requisicao minima que a API aceita: `GET /v1/companies` sem query. + // + // NAO reintroduzir `pageCount`. `pageCount=1` responde + // 400 "pageCount must be between 1 and 50" -- o limite inferior do servidor + // esta um a mais do que a propria mensagem diz (medido em 2026-09-02). + // Enquanto isso nao for corrigido upstream, qualquer valor aqui seria um + // numero magico contornando defeito alheio; a rota sem query responde 200. + await this.getMainHttpClient().get('/companies'); return { status: 'ok' }; } catch (error) { return { diff --git a/tests/unit/client-health-check.test.ts b/tests/unit/client-health-check.test.ts new file mode 100644 index 0000000..dc6736b --- /dev/null +++ b/tests/unit/client-health-check.test.ts @@ -0,0 +1,117 @@ +/** + * O que `healthCheck()` manda no fio. + * + * O método existe para responder "a integração está de pé?" e respondia + * `status: 'error'` SEMPRE, com credencial válida e API no ar, porque enviava + * `pageCount: 1`: + * + * GET /v1/companies?pageCount=1 → 400 "pageCount must be between 1 and 50" + * GET /v1/companies?pageCount=2 → 200 + * GET /v1/companies → 200 + * + * (medido em 2026-09-02 contra api.nfe.io com chave real) + * + * O limite inferior do servidor está um a mais do que a própria mensagem diz. + * Enquanto isso não for corrigido upstream, o SDK omite o parâmetro — e este + * teste existe para que a omissão não seja desfeita por engano. Afirmar + * `status: 'ok'` contra um mock não bastaria: o mock responde 200 para + * qualquer query, inclusive a que a API recusa. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NfeClient } from '../../src/core/client.js'; + +function mockHeaders(entries: [string, string][]): any { + const map = new Map(entries.map(([k, v]) => [k.toLowerCase(), v])); + return { + get: (k: string) => map.get(k.toLowerCase()) ?? null, + has: (k: string) => map.has(k.toLowerCase()), + entries: () => map.entries(), + keys: () => map.keys(), + values: () => map.values(), + forEach: (cb: (v: string, k: string) => void) => map.forEach((v, k) => cb(v, k)), + }; +} + +describe('healthCheck', () => { + let fetchMock: ReturnType; + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.NFE_API_KEY; + delete process.env.NFE_DATA_API_KEY; + fetchMock = vi.fn(); + global.fetch = fetchMock as any; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + function okResponse() { + return { + ok: true, + status: 200, + statusText: 'OK', + headers: mockHeaders([['content-type', 'application/json']]), + json: async () => ({ companies: [], page: 1 }), + text: async () => '{"companies":[],"page":1}', + arrayBuffer: async () => new ArrayBuffer(0), + }; + } + + it('não envia pageCount — a API recusa o valor 1 que o SDK mandava', async () => { + fetchMock.mockResolvedValue(okResponse()); + const nfe = new NfeClient({ apiKey: 'k' }); + + await nfe.healthCheck(); + + const url = new URL(fetchMock.mock.calls[0]![0] as string); + expect(url.pathname).toBe('/v1/companies'); + expect(url.searchParams.get('pageCount')).toBeNull(); + // Nenhum parâmetro de paginação, não só o pageCount. + expect([...url.searchParams.keys()]).toEqual([]); + }); + + it('responde ok quando a API responde 200', async () => { + fetchMock.mockResolvedValue(okResponse()); + const nfe = new NfeClient({ apiKey: 'k' }); + + await expect(nfe.healthCheck()).resolves.toEqual({ status: 'ok' }); + }); + + it('continua reportando erro quando a API recusa a credencial', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: mockHeaders([['content-type', 'application/json']]), + json: async () => ({ message: 'API Key inválida' }), + text: async () => '{"message":"API Key inválida"}', + arrayBuffer: async () => new ArrayBuffer(0), + }); + const nfe = new NfeClient({ apiKey: 'chave-ruim' }); + + const health = await nfe.healthCheck(); + + expect(health.status).toBe('error'); + expect(health.details?.error).toBeTruthy(); + expect(health.details?.config?.hasApiKey).toBe(true); + }); + + it('continua reportando erro quando o host está inalcançável', async () => { + fetchMock.mockRejectedValue(new TypeError('fetch failed')); + // Sem retry: erro de conexão é retentável, e o backoff levaria o teste ao + // timeout do vitest. O que interessa aqui é o veredito, não a política. + const nfe = new NfeClient({ + apiKey: 'k', + retryConfig: { maxRetries: 0, baseDelay: 1, maxDelay: 1 }, + }); + + const health = await nfe.healthCheck(); + + expect(health.status).toBe('error'); + expect(health.details?.error).toBeTruthy(); + }); +}); From 335b408c1219e29eacb9392ad0be9deffb2e424b Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Wed, 2 Sep 2026 21:19:29 -0300 Subject: [PATCH 09/21] fix(companies): status de certificado le o envelope que a API devolve `getCertificateStatus` lia `{hasCertificate, expiresOn, isValid}`. A API devolve outra coisa: GET /v1/companies/{id}/certificate -> { "certificates": [ { providerType, resolution, taxPayerId, thumbprint, taxId, subject, validUntil, modifiedOn, status } ] } Nenhum dos tres campos lidos existe, entao o metodo devolvia `{hasCertificate: undefined}` e o `if` que calcula os derivados nunca era verdadeiro. Isso derrubava em cascata `checkCertificateExpiration`, `getCompaniesWithCertificates` e `getCompaniesWithExpiringCertificates` -- quatro metodos publicos. Confirmado na spec (CertificatesMetadataResource, contribuintes-v2) e no fio em 2026-09-02. Empresa sem certificado responde 200 com `certificates: []`, nao 404 -- 9 de 12 empresas sondadas na conta estao nesse caso. O resumo mantem `expiresOn` em vez de renomear para `validUntil`: e o mesmo nome que a API usa quando o certificado vem embutido no item da listagem (CompanyCertificateV1), entao manter alinha as duas superficies em vez de quebrar o chamador de novo. Os itens crus ficam expostos em `certificates`. A varredura por conta deixa de fazer N+1 -------------------------------------- `getCompaniesWithCertificates` e `getCompaniesWithExpiringCertificates` chamavam `getCertificateStatus` uma vez por empresa, em serie, sobre `listAll()`. Enquanto o metodo estava quebrado isso era invisivel; consertado, a conta do time (>=500 empresas) faria >=500 requisicoes sequenciais por chamada. Cheguei com um pool de concorrencia pronto e a sonda tornou a discussao desnecessaria: `GET /v1/companies` ja devolve `certificate` em TODO item, com `{thumbprint, modifiedOn, expiresOn, status}`. As duas varreduras passam a ler dai. Zero requisicao extra, zero maquina de concorrencia. Medido ao vivo: as duas varreduras juntas, sobre a conta inteira, em 9,8s. Os mocks -------- Os testes unitarios alimentavam a forma inventada e por isso passavam: o mock validava a leitura contra a propria invencao. Reescritos contra o envelope real, mais os casos que faltavam (lista vazia, escolha entre varios certificados, status != Active, `validUntil` ausente). `tests/unit/companies.test.ts` tambem stubava `getDaysUntilExpiration` em 365 dias fixos no mock de modulo -- todo teste de vencimento media a constante do mock, nao a data. O mock agora e parcial: so o que depende de um .pfx de verdade fica falso; a aritmetica de datas e real. E `tests/integration/certificates.integration.test.ts` afirma o contrato contra a API real, inclusive que os campos antigos NAO existem na resposta. Um mock nao consegue afirmar o nome de um campo que so a API sabe. --- src/core/resources/companies.ts | 183 ++++++++----- src/core/types.ts | 21 ++ src/index.ts | 3 + .../certificates.integration.test.ts | 111 ++++++++ tests/unit/companies.test.ts | 241 ++++++++++++------ .../resources/companies-certificates.test.ts | 201 +++++++++++---- 6 files changed, 565 insertions(+), 195 deletions(-) create mode 100644 tests/integration/certificates.integration.test.ts diff --git a/src/core/resources/companies.ts b/src/core/resources/companies.ts index b7abd6c..4831199 100644 --- a/src/core/resources/companies.ts +++ b/src/core/resources/companies.ts @@ -9,6 +9,9 @@ import type { CompanyResourceItem, CompanyV2ListOptions, CompanyV2ListResponse, + CertificateMetadataResourceItem, + CertificatesMetadataResource, + CompanyCertificateV1, ListResponse, PaginationOptions } from '../types.js'; @@ -20,6 +23,89 @@ import { CertificateValidator } from '../utils/certificate-validator.js'; // pageCount 50 (values above 50 — and also 1 — are rejected with a 400). const AUTO_PAGINATION_PAGE_SIZE = 50; +/** + * Resumo do certificado de uma empresa. + * + * `expiresOn` / `isValid` / os dois derivados descrevem o certificado preferido + * (ver {@link CompaniesResource.getCertificateStatus}); `certificates` traz os itens + * como a API devolveu, para quem precisar de `thumbprint`, `subject` ou decidir + * por outro critério. + */ +export interface CertificateStatusSummary { + /** Há ao menos um certificado instalado. */ + hasCertificate: boolean; + /** Vencimento do certificado preferido (o `validUntil` da API). */ + expiresOn?: string; + /** O certificado preferido está com `status: 'Active'`. */ + isValid?: boolean; + /** Dias até o vencimento — negativo se já venceu. */ + daysUntilExpiration?: number; + /** Vence dentro do limite padrão do {@link CertificateValidator} (30 dias). */ + isExpiringSoon?: boolean; + /** Itens como a API os devolveu. Vazio quando não há certificado. */ + certificates: readonly CertificateMetadataResourceItem[]; +} + +/** + * Escolhe o certificado que o resumo descreve: um ativo, o de vencimento mais + * distante; sem nenhum ativo, o de vencimento mais distante entre todos. + */ +function pickPreferredCertificate( + certificates: readonly CertificateMetadataResourceItem[] +): CertificateMetadataResourceItem | undefined { + if (certificates.length === 0) return undefined; + + const byLatestExpiry = ( + a: CertificateMetadataResourceItem, + b: CertificateMetadataResourceItem + ): number => new Date(b.validUntil ?? 0).getTime() - new Date(a.validUntil ?? 0).getTime(); + + const active = certificates.filter(c => c.status === 'Active'); + const pool = active.length > 0 ? active : certificates; + return [...pool].sort(byLatestExpiry)[0]; +} + +/** Monta o resumo a partir dos itens de `/v1/companies/{id}/certificate`. */ +function summarizeCertificates( + certificates: readonly CertificateMetadataResourceItem[] +): CertificateStatusSummary { + const preferred = pickPreferredCertificate(certificates); + + if (!preferred) { + return { hasCertificate: false, certificates }; + } + + const summary: CertificateStatusSummary = { + hasCertificate: true, + isValid: preferred.status === 'Active', + certificates, + }; + + // `validUntil` é obrigatório na spec, mas o SDK não decide por ela: sem data, + // devolve o que dá para afirmar em vez de emitir um `Invalid Date`. + if (preferred.validUntil) { + const expirationDate = new Date(preferred.validUntil); + summary.expiresOn = preferred.validUntil; + summary.daysUntilExpiration = CertificateValidator.getDaysUntilExpiration(expirationDate); + summary.isExpiringSoon = CertificateValidator.isExpiringSoon(expirationDate); + } + + return summary; +} + +/** + * Lê o certificado que o item da listagem de empresas v1 já traz. + * + * Existe para que a varredura por conta não faça uma requisição por empresa: numa + * conta com centenas de empresas isso é indistinguível de travamento. O campo vem + * em todo item de `GET /v1/companies` (medido em 2026-09-02). + */ +function readListedCertificate(company: Company): CompanyCertificateV1 | undefined { + const certificate = (company as { certificate?: unknown }).certificate; + if (!certificate || typeof certificate !== 'object') return undefined; + return certificate as CompanyCertificateV1; +} + // ============================================================================ // Validation Helpers // ============================================================================ @@ -573,6 +659,23 @@ export class CompaniesResource { * @returns Certificate status with expiration info * @throws {NotFoundError} If company doesn't exist * + * @remarks + * `GET /v1/companies/{id}/certificate` responde `{ certificates: [...] }`, com + * `validUntil` e `status` em cada item — NÃO `{hasCertificate, expiresOn, isValid}`, + * que era o que este método lia antes de 2026-09-02 (e por isso devolvia + * `undefined` em tudo, silenciosamente). Empresa sem certificado responde + * **200 com `certificates: []`**, não 404. + * + * O campo de vencimento na superfície do SDK se chama `expiresOn` — o mesmo nome + * que a API usa quando o certificado vem embutido no item da listagem de empresas + * ({@link CompanyCertificateV1}). No endpoint de certificado ele se chama + * `validUntil`; a normalização acontece aqui. + * + * Quando há mais de um certificado, o resumo descreve o preferido: um com + * `status: 'Active'` e, entre os ativos, o de vencimento mais distante. Se nenhum + * for ativo, o de vencimento mais distante entre todos. Isso é convenção do SDK, + * não contrato da API — use `certificates` para decidir de outro jeito. + * * @example * ```typescript * const status = await nfe.companies.getCertificateStatus('company-123'); @@ -584,41 +687,18 @@ export class CompaniesResource { * if (status.isExpiringSoon) { * console.warn('Certificate is expiring soon!'); * } + * + * // Dado que o resumo não expõe: thumbprint, subject, providerType... + * console.log(status.certificates[0]?.thumbprint); * } * ``` */ - async getCertificateStatus(companyId: string): Promise<{ - hasCertificate: boolean; - expiresOn?: string; - isValid?: boolean; - daysUntilExpiration?: number; - isExpiringSoon?: boolean; - details?: any; - }> { + async getCertificateStatus(companyId: string): Promise { const path = `/companies/${companyId}/certificate`; - const response = await this.http.get<{ - hasCertificate: boolean; - expiresOn?: string; - isValid?: boolean; - details?: any; - }>(path); - - const status = response.data; - - // Calculate days until expiration if available - if (status.hasCertificate && status.expiresOn) { - const expirationDate = new Date(status.expiresOn); - const daysUntilExpiration = CertificateValidator.getDaysUntilExpiration(expirationDate); - const isExpiringSoon = CertificateValidator.isExpiringSoon(expirationDate); + const response = await this.http.get(path); - return { - ...status, - daysUntilExpiration, - isExpiringSoon - }; - } - - return status; + const certificates = response.data?.certificates ?? []; + return summarizeCertificates(certificates); } /** @@ -779,22 +859,11 @@ export class CompaniesResource { async getCompaniesWithCertificates(): Promise { const companies = await this.listAll(); - const companiesWithCerts: Company[] = []; - - // Check certificate status for each company - for (const company of companies) { - try { - const certStatus = await this.getCertificateStatus(company.id!); - if (certStatus.hasCertificate && certStatus.isValid) { - companiesWithCerts.push(company); - } - } catch { - // Skip companies where we can't check certificate status - continue; - } - } - - return companiesWithCerts; + // Sem requisição por empresa: `GET /v1/companies` já devolve `certificate` em + // cada item. A versão anterior chamava getCertificateStatus() em série sobre a + // conta inteira — numa conta com centenas de empresas, centenas de idas à rede + // por chamada. + return companies.filter(company => readListedCertificate(company)?.status === 'Active'); } /** @@ -815,21 +884,15 @@ export class CompaniesResource { async getCompaniesWithExpiringCertificates(thresholdDays: number = 30): Promise { const companies = await this.listAll(); - const expiringCompanies: Company[] = []; - - for (const company of companies) { - try { - const warning = await this.checkCertificateExpiration(company.id!, thresholdDays); - if (warning) { - expiringCompanies.push(company); - } - } catch { - // Skip companies where we can't check certificate - continue; - } - } + // Mesmo motivo de getCompaniesWithCertificates: o vencimento já vem na listagem, + // no campo `expiresOn` do certificado embutido. + return companies.filter(company => { + const expiresOn = readListedCertificate(company)?.expiresOn; + if (!expiresOn) return false; - return expiringCompanies; + const daysRemaining = CertificateValidator.getDaysUntilExpiration(new Date(expiresOn)); + return daysRemaining >= 0 && daysRemaining < thresholdDays; + }); } // -------------------------------------------------------------------------- diff --git a/src/core/types.ts b/src/core/types.ts index f6cbd60..5d787e3 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -604,6 +604,27 @@ export interface CompanyV2ListResponse { hasMore: boolean; } +/** + * Certificado embutido no item da listagem de empresas v1. + * + * `GET /v1/companies` devolve este objeto em CADA item — medido em 2026-09-02 + * nos 50 itens da primeira página. É por isso que a varredura de certificados + * por conta não precisa de uma requisição por empresa. + * + * Atenção ao nome do campo de vencimento: aqui é `expiresOn`; no endpoint + * `/v1/companies/{id}/certificate` o mesmo dado se chama `validUntil`. + */ +export type CompanyCertificateV1 = + ContribuintesComponents['schemas']['DFeTech.TaxPayers.Resources.CompanyCertificateV1']; + +/** Item de certificado devolvido por `/v1/companies/{id}/certificate`. */ +export type CertificateMetadataResourceItem = + ContribuintesComponents['schemas']['DFeTech.TaxPayers.Resources.CertificateMetadataResourceItem']; + +/** Situação do certificado: `None` | `Active` | `Inactive` | `Overdue` | `Pending`. */ +export type CertificateStatus = + ContribuintesComponents['schemas']['DFeTech.TaxPayers.Domain.Entities.CertificateStatus']; + /** Digital certificate metadata (real, spec-backed). */ export type CertificateMetadataResource = ContribuintesComponents['schemas']['DFeTech.TaxPayers.Resources.CertificateMetadataResource']; diff --git a/src/index.ts b/src/index.ts index ebf6d4e..11ee18e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -346,6 +346,9 @@ export type { ConsumerInvoiceListResponse, ConsumerInvoiceDisablementData, CertificatesMetadataResource, + CertificateMetadataResourceItem, + CertificateStatus, + CompanyCertificateV1, } from './core/types.js'; /** diff --git a/tests/integration/certificates.integration.test.ts b/tests/integration/certificates.integration.test.ts new file mode 100644 index 0000000..d39baa2 --- /dev/null +++ b/tests/integration/certificates.integration.test.ts @@ -0,0 +1,111 @@ +/** + * Contratos de certificado, contra a API real. + * + * Existe porque `getCertificateStatus` passou meses lendo uma forma que a API + * nunca devolveu, e nenhum teste percebeu: os unitários alimentavam a própria + * invenção. Um mock não consegue afirmar o nome de um campo que só a API sabe. + * + * Somente leitura — nada aqui cria, altera ou remove nada na conta. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { + createIntegrationClient, + skipIfNoApiKey, + INTEGRATION_TEST_CONFIG, +} from './setup.js'; +import { NfeClient } from '../../src/core/client.js'; +import type { Company } from '../../src/core/types.js'; + +describe.skipIf(skipIfNoApiKey())('Certificados — contrato ao vivo', () => { + let client: NfeClient; + let page: Company[]; + + beforeAll(async () => { + client = createIntegrationClient(); + page = (await client.companies.list({ pageCount: 50, pageIndex: 1 })).data; + }); + + /** Empresa da página cujo item de listagem já indica certificado instalado. */ + function withCertificate(): Company | undefined { + return page.find(c => Boolean((c as { certificate?: { thumbprint?: string } }).certificate?.thumbprint)); + } + + /** Empresa da página sem certificado no item de listagem. */ + function withoutCertificate(): Company | undefined { + return page.find(c => !(c as { certificate?: { thumbprint?: string } }).certificate?.thumbprint); + } + + it( + 'a listagem de empresas traz o certificado embutido', + { timeout: INTEGRATION_TEST_CONFIG.timeout }, + () => { + // É o que permite a varredura por conta não fazer uma requisição por empresa. + const company = withCertificate(); + expect(company, 'nenhuma empresa com certificado na primeira página').toBeDefined(); + + const certificate = (company as { certificate: Record }).certificate; + expect(certificate).toHaveProperty('expiresOn'); + expect(certificate).toHaveProperty('status'); + expect(typeof certificate.status).toBe('string'); + } + ); + + it( + 'empresa com certificado: o status vem de validUntil e status', + { timeout: INTEGRATION_TEST_CONFIG.timeout }, + async () => { + const company = withCertificate(); + expect(company).toBeDefined(); + + const status = await client.companies.getCertificateStatus(company!.id!); + + expect(status.hasCertificate).toBe(true); + expect(status.certificates.length).toBeGreaterThan(0); + + // Os campos que o método lia antes NÃO existem na resposta da API. + const raw = status.certificates[0]!; + expect(raw).toHaveProperty('validUntil'); + expect(raw).toHaveProperty('status'); + expect(raw).not.toHaveProperty('hasCertificate'); + expect(raw).not.toHaveProperty('expiresOn'); + expect(raw).not.toHaveProperty('isValid'); + + // E o resumo normaliza para `expiresOn`, derivando os dois campos calculados. + expect(status.expiresOn).toBe(raw.validUntil); + expect(typeof status.daysUntilExpiration).toBe('number'); + expect(typeof status.isExpiringSoon).toBe('boolean'); + expect(status.isValid).toBe(raw.status === 'Active'); + } + ); + + it( + 'empresa sem certificado responde 200 com lista vazia, não 404', + { timeout: INTEGRATION_TEST_CONFIG.timeout }, + async () => { + const company = withoutCertificate(); + expect(company, 'nenhuma empresa sem certificado na primeira página').toBeDefined(); + + const status = await client.companies.getCertificateStatus(company!.id!); + + expect(status.hasCertificate).toBe(false); + expect(status.certificates).toEqual([]); + expect(status.expiresOn).toBeUndefined(); + } + ); + + it( + 'a varredura por conta concorda com a listagem', + { timeout: 120_000 }, + async () => { + const comCertificado = await client.companies.getCompaniesWithCertificates(); + + // Toda empresa devolvida tem certificado ativo no próprio item de listagem — + // é de lá que a seleção sai, sem requisição por empresa. + for (const company of comCertificado) { + const certificate = (company as { certificate?: { status?: string } }).certificate; + expect(certificate?.status).toBe('Active'); + } + } + ); +}); diff --git a/tests/unit/companies.test.ts b/tests/unit/companies.test.ts index 9cb996b..76cd199 100644 --- a/tests/unit/companies.test.ts +++ b/tests/unit/companies.test.ts @@ -6,23 +6,39 @@ import { createMockCompany, TEST_COMPANY_ID } from '../setup'; import { ValidationError } from '../../src/core/errors/index.js'; import { CertificateValidator } from '../../src/core/utils/certificate-validator'; -// Mock CertificateValidator to avoid certificate format validation issues in tests -vi.mock('../../src/core/utils/certificate-validator', () => ({ - CertificateValidator: { - validate: vi.fn().mockResolvedValue({ - valid: true, - metadata: { - subject: 'CN=Test', - issuer: 'CN=Test CA', - validFrom: new Date('2024-01-01'), - validTo: new Date('2026-12-31'), - }, - }), - isSupportedFormat: vi.fn().mockReturnValue(true), - getDaysUntilExpiration: vi.fn().mockReturnValue(365), - isExpiringSoon: vi.fn().mockReturnValue(false), - }, -})); +// Mock parcial do CertificateValidator: só o que depende de um .pfx de verdade. +// +// A aritmética de datas (`getDaysUntilExpiration` / `isExpiringSoon`) fica REAL. +// A versão anterior a stubava em 365 dias fixos, e com isso todo teste de +// vencimento media a constante do mock em vez da data — o que só ficou visível +// quando a varredura por conta passou a derivar do vencimento (2026-09-02). +vi.mock('../../src/core/utils/certificate-validator', async importOriginal => { + const original = await importOriginal< + typeof import('../../src/core/utils/certificate-validator') + >(); + + return { + CertificateValidator: { + ...original.CertificateValidator, + getDaysUntilExpiration: original.CertificateValidator.getDaysUntilExpiration.bind( + original.CertificateValidator + ), + isExpiringSoon: original.CertificateValidator.isExpiringSoon.bind( + original.CertificateValidator + ), + validate: vi.fn().mockResolvedValue({ + valid: true, + metadata: { + subject: 'CN=Test', + issuer: 'CN=Test CA', + validFrom: new Date('2024-01-01'), + validTo: new Date('2026-12-31'), + }, + }), + isSupportedFormat: vi.fn().mockReturnValue(true), + }, + }; +}); describe('CompaniesResource', () => { let companies: CompaniesResource; @@ -448,16 +464,21 @@ describe('CompaniesResource', () => { }); describe('getCertificateStatus', () => { + // Envelope real: { certificates: [...] } com `validUntil` e `status`. + // Ver o cabeçalho de tests/unit/resources/companies-certificates.test.ts. it('should get certificate status', async () => { - const mockStatus = { - hasCertificate: true, - expiresOn: '2025-12-31T23:59:59Z', - isValid: true, - details: { issuer: 'CA' }, - }; - vi.mocked(mockHttpClient.get).mockResolvedValue({ - data: mockStatus, + data: { + certificates: [ + { + providerType: 'Pfx', + thumbprint: 'AABBCC', + subject: 'CN=EMPRESA TESTE', + validUntil: '2025-12-31T23:59:59Z', + status: 'Active', + }, + ], + }, status: 200, headers: {}, }); @@ -467,18 +488,15 @@ describe('CompaniesResource', () => { expect(result.hasCertificate).toBe(true); expect(result.isValid).toBe(true); expect(result.expiresOn).toBe('2025-12-31T23:59:59Z'); + expect(result.certificates[0]?.thumbprint).toBe('AABBCC'); expect(mockHttpClient.get).toHaveBeenCalledWith( `/companies/${TEST_COMPANY_ID}/certificate` ); }); it('should handle company without certificate', async () => { - const mockStatus = { - hasCertificate: false, - }; - vi.mocked(mockHttpClient.get).mockResolvedValue({ - data: mockStatus, + data: { certificates: [] }, status: 200, headers: {}, }); @@ -487,6 +505,7 @@ describe('CompaniesResource', () => { expect(result.hasCertificate).toBe(false); expect(result.isValid).toBeUndefined(); + expect(result.certificates).toEqual([]); }); }); @@ -530,65 +549,129 @@ describe('CompaniesResource', () => { }); describe('getCompaniesWithCertificates', () => { - it('should return companies with valid certificates', async () => { - const mockCompanies = [ - createMockCompany({ id: 'company-1' }), - createMockCompany({ id: 'company-2' }), - createMockCompany({ id: 'company-3' }), - ]; - - vi.mocked(mockHttpClient.get) - .mockResolvedValueOnce({ - data: { companies: mockCompanies, page: 1 }, - status: 200, - headers: {}, - }) - .mockResolvedValueOnce({ - data: { hasCertificate: true, isValid: true }, - status: 200, - headers: {}, - }) - .mockResolvedValueOnce({ - data: { hasCertificate: false }, - status: 200, - headers: {}, - }) - .mockResolvedValueOnce({ - data: { hasCertificate: true, isValid: true }, - status: 200, - headers: {}, - }); + // `GET /v1/companies` devolve `certificate` em cada item — a varredura NÃO + // emite uma requisição por empresa. Medido em 2026-09-02 nos 50 itens da + // primeira página da conta do time. + function listing(companiesPayload: unknown[]) { + return { data: { companies: companiesPayload, page: 1 }, status: 200, headers: {} }; + } + + it('devolve as empresas cujo certificado está ativo', async () => { + vi.mocked(mockHttpClient.get).mockResolvedValueOnce( + listing([ + createMockCompany({ + id: 'company-1', + certificate: { thumbprint: 'A', expiresOn: '2027-01-01T00:00:00Z', status: 'Active' }, + }), + createMockCompany({ + id: 'company-2', + certificate: { thumbprint: 'B', expiresOn: '2027-01-01T00:00:00Z', status: 'Overdue' }, + }), + createMockCompany({ + id: 'company-3', + certificate: { thumbprint: 'C', expiresOn: '2027-01-01T00:00:00Z', status: 'Active' }, + }), + ]) as any + ); const result = await companies.getCompaniesWithCertificates(); expect(result).toHaveLength(2); - expect(result[0].id).toBe('company-1'); - expect(result[1].id).toBe('company-3'); + expect(result[0]!.id).toBe('company-1'); + expect(result[1]!.id).toBe('company-3'); }); - it('should skip companies where certificate check fails', async () => { - const mockCompanies = [ - createMockCompany({ id: 'company-1' }), - createMockCompany({ id: 'company-2' }), - ]; - - vi.mocked(mockHttpClient.get) - .mockResolvedValueOnce({ - data: { companies: mockCompanies, page: 1 }, - status: 200, - headers: {}, - }) - .mockResolvedValueOnce({ - data: { hasCertificate: true, isValid: true }, - status: 200, - headers: {}, - }) - .mockRejectedValueOnce(new Error('Certificate check failed')); + it('empresa sem o campo certificate é omitida, sem erro', async () => { + vi.mocked(mockHttpClient.get).mockResolvedValueOnce( + listing([ + createMockCompany({ + id: 'company-1', + certificate: { thumbprint: 'A', expiresOn: '2027-01-01T00:00:00Z', status: 'Active' }, + }), + createMockCompany({ id: 'company-2' }), + ]) as any + ); const result = await companies.getCompaniesWithCertificates(); expect(result).toHaveLength(1); - expect(result[0].id).toBe('company-1'); + expect(result[0]!.id).toBe('company-1'); + }); + + it('não emite uma requisição por empresa', async () => { + vi.mocked(mockHttpClient.get).mockResolvedValueOnce( + listing( + Array.from({ length: 10 }, (_, i) => + createMockCompany({ + id: `company-${i}`, + certificate: { thumbprint: 'X', expiresOn: '2027-01-01T00:00:00Z', status: 'Active' }, + }) + ) + ) as any + ); + + await companies.getCompaniesWithCertificates(); + + // Só a listagem. Uma página de 10 (< 50) encerra a auto-paginação. + expect(mockHttpClient.get).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCompaniesWithExpiringCertificates', () => { + function daysFromNow(days: number) { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); + } + + it('seleciona pelo vencimento que a listagem já traz', async () => { + vi.mocked(mockHttpClient.get).mockResolvedValueOnce({ + data: { + companies: [ + createMockCompany({ + id: 'vence-em-15', + certificate: { expiresOn: daysFromNow(15), status: 'Active' }, + }), + createMockCompany({ + id: 'vence-em-90', + certificate: { expiresOn: daysFromNow(90), status: 'Active' }, + }), + createMockCompany({ + id: 'ja-venceu', + certificate: { expiresOn: daysFromNow(-5), status: 'Overdue' }, + }), + createMockCompany({ id: 'sem-certificado' }), + ], + page: 1, + }, + status: 200, + headers: {}, + } as any); + + const result = await companies.getCompaniesWithExpiringCertificates(30); + + expect(result.map(c => c.id)).toEqual(['vence-em-15']); + expect(mockHttpClient.get).toHaveBeenCalledTimes(1); + }); + + it('respeita o limite informado', async () => { + const payload = { + data: { + companies: [ + createMockCompany({ + id: 'vence-em-20', + certificate: { expiresOn: daysFromNow(20), status: 'Active' }, + }), + ], + page: 1, + }, + status: 200, + headers: {}, + } as any; + + vi.mocked(mockHttpClient.get).mockResolvedValueOnce(payload); + expect(await companies.getCompaniesWithExpiringCertificates(30)).toHaveLength(1); + + vi.mocked(mockHttpClient.get).mockResolvedValueOnce(payload); + expect(await companies.getCompaniesWithExpiringCertificates(10)).toHaveLength(0); }); }); diff --git a/tests/unit/resources/companies-certificates.test.ts b/tests/unit/resources/companies-certificates.test.ts index eb76208..4143fff 100644 --- a/tests/unit/resources/companies-certificates.test.ts +++ b/tests/unit/resources/companies-certificates.test.ts @@ -1,5 +1,18 @@ /** * Unit tests for Companies resource - Certificate Management + * + * Os mocks aqui alimentam o envelope REAL de `GET /v1/companies/{id}/certificate`: + * + * { certificates: [ { providerType, resolution, taxPayerId, thumbprint, taxId, + * subject, validUntil, modifiedOn, status } ] } + * + * Até 2026-09-02 alimentavam `{hasCertificate, expiresOn, isValid}` — forma que a + * API nunca devolveu. Os testes passavam e o método devolvia `undefined` em tudo + * em produção: o mock validava a leitura contra a própria invenção. Confirmado na + * spec (`CertificatesMetadataResource`, contribuintes-v2) e no fio. + * + * Empresa sem certificado responde **200 com `certificates: []`**, não 404 — + * medido: 9 de 12 empresas sondadas na conta do time estão nesse caso. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -92,46 +105,51 @@ describe('CompaniesResource - Certificate Management', () => { }); describe('getCertificateStatus()', () => { - it('should return basic status without expiration', async () => { - vi.mocked(mockHttp.get).mockResolvedValue({ - data: { - hasCertificate: false - } - } as any); + /** Item como a API o devolve, com os campos que interessam ao resumo. */ + function certificate(overrides: Record = {}) { + return { + providerType: 'Pfx', + resolution: { status: 'Resolved' }, + taxPayerId: 'tp-1', + thumbprint: 'AABBCC', + taxId: '00000000000000', + subject: 'CN=EMPRESA TESTE', + validUntil: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(), + modifiedOn: new Date().toISOString(), + status: 'Active', + ...overrides, + }; + } + + it('empresa sem certificado: 200 com lista vazia, não 404', async () => { + vi.mocked(mockHttp.get).mockResolvedValue({ data: { certificates: [] } } as any); const status = await companies.getCertificateStatus('company-123'); expect(status.hasCertificate).toBe(false); + expect(status.certificates).toEqual([]); + expect(status.expiresOn).toBeUndefined(); expect(status.daysUntilExpiration).toBeUndefined(); }); - it('should calculate days until expiration', async () => { - const futureDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); // 60 days - + it('deriva o vencimento de validUntil, não de expiresOn', async () => { + const validUntil = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(); vi.mocked(mockHttp.get).mockResolvedValue({ - data: { - hasCertificate: true, - expiresOn: futureDate.toISOString(), - isValid: true - } + data: { certificates: [certificate({ validUntil })] }, } as any); const status = await companies.getCertificateStatus('company-123'); expect(status.hasCertificate).toBe(true); + expect(status.expiresOn).toBe(validUntil); expect(status.daysUntilExpiration).toBeGreaterThan(50); expect(status.isExpiringSoon).toBe(false); }); - it('should detect expiring certificates', async () => { - const soonDate = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000); // 15 days - + it('detecta certificado a vencer', async () => { + const validUntil = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(); vi.mocked(mockHttp.get).mockResolvedValue({ - data: { - hasCertificate: true, - expiresOn: soonDate.toISOString(), - isValid: true - } + data: { certificates: [certificate({ validUntil })] }, } as any); const status = await companies.getCertificateStatus('company-123'); @@ -139,6 +157,91 @@ describe('CompaniesResource - Certificate Management', () => { expect(status.isExpiringSoon).toBe(true); expect(status.daysUntilExpiration).toBeLessThan(30); }); + + it('isValid vem de status, e só Active conta', async () => { + for (const status of ['None', 'Inactive', 'Overdue', 'Pending'] as const) { + vi.mocked(mockHttp.get).mockResolvedValue({ + data: { certificates: [certificate({ status })] }, + } as any); + + const result = await companies.getCertificateStatus('company-123'); + + expect(result.hasCertificate).toBe(true); + expect(result.isValid).toBe(false); + } + + vi.mocked(mockHttp.get).mockResolvedValue({ + data: { certificates: [certificate({ status: 'Active' })] }, + } as any); + expect((await companies.getCertificateStatus('company-123')).isValid).toBe(true); + }); + + it('expõe os itens crus para quem precisa de thumbprint e subject', async () => { + vi.mocked(mockHttp.get).mockResolvedValue({ + data: { certificates: [certificate()] }, + } as any); + + const status = await companies.getCertificateStatus('company-123'); + + expect(status.certificates).toHaveLength(1); + expect(status.certificates[0]?.thumbprint).toBe('AABBCC'); + expect(status.certificates[0]?.subject).toBe('CN=EMPRESA TESTE'); + }); + + it('com vários certificados, o resumo descreve um ativo de vencimento mais distante', async () => { + const perto = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); + const longe = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); + const maisLonge = new Date(Date.now() + 200 * 24 * 60 * 60 * 1000).toISOString(); + + vi.mocked(mockHttp.get).mockResolvedValue({ + data: { + certificates: [ + certificate({ validUntil: perto, status: 'Active', thumbprint: 'PERTO' }), + certificate({ validUntil: maisLonge, status: 'Overdue', thumbprint: 'VENCIDO' }), + certificate({ validUntil: longe, status: 'Active', thumbprint: 'LONGE' }), + ], + }, + } as any); + + const status = await companies.getCertificateStatus('company-123'); + + // O de vencimento mais distante é 'VENCIDO', mas não está ativo. + expect(status.expiresOn).toBe(longe); + expect(status.isValid).toBe(true); + expect(status.certificates).toHaveLength(3); + }); + + it('sem nenhum ativo, cai para o de vencimento mais distante', async () => { + const perto = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); + const longe = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); + + vi.mocked(mockHttp.get).mockResolvedValue({ + data: { + certificates: [ + certificate({ validUntil: perto, status: 'Pending' }), + certificate({ validUntil: longe, status: 'Overdue' }), + ], + }, + } as any); + + const status = await companies.getCertificateStatus('company-123'); + + expect(status.expiresOn).toBe(longe); + expect(status.isValid).toBe(false); + }); + + it('certificado sem validUntil não emite Invalid Date', async () => { + vi.mocked(mockHttp.get).mockResolvedValue({ + data: { certificates: [certificate({ validUntil: undefined })] }, + } as any); + + const status = await companies.getCertificateStatus('company-123'); + + expect(status.hasCertificate).toBe(true); + expect(status.expiresOn).toBeUndefined(); + expect(status.daysUntilExpiration).toBeUndefined(); + expect(status.isExpiringSoon).toBeUndefined(); + }); }); describe('replaceCertificate()', () => { @@ -161,59 +264,45 @@ describe('CompaniesResource - Certificate Management', () => { }); describe('checkCertificateExpiration()', () => { - it('should return null if no certificate', async () => { + function withCertificate(validUntil?: string) { vi.mocked(mockHttp.get).mockResolvedValue({ - data: { hasCertificate: false } + data: { + certificates: validUntil + ? [{ thumbprint: 'AABBCC', validUntil, status: 'Active' }] + : [], + }, } as any); + } - const warning = await companies.checkCertificateExpiration('company-123'); + it('devolve null quando não há certificado', async () => { + withCertificate(undefined); - expect(warning).toBeNull(); + await expect(companies.checkCertificateExpiration('company-123')).resolves.toBeNull(); }); - it('should return null if not expiring soon', async () => { - const futureDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); - - vi.mocked(mockHttp.get).mockResolvedValue({ - data: { - hasCertificate: true, - expiresOn: futureDate.toISOString() - } - } as any); - - const warning = await companies.checkCertificateExpiration('company-123', 30); + it('devolve null quando não está perto de vencer', async () => { + withCertificate(new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()); - expect(warning).toBeNull(); + await expect(companies.checkCertificateExpiration('company-123', 30)).resolves.toBeNull(); }); - it('should return warning if expiring soon', async () => { - const soonDate = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000); - - vi.mocked(mockHttp.get).mockResolvedValue({ - data: { - hasCertificate: true, - expiresOn: soonDate.toISOString() - } - } as any); + it('avisa quando está perto de vencer', async () => { + withCertificate(new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString()); const warning = await companies.checkCertificateExpiration('company-123', 30); expect(warning).not.toBeNull(); expect(warning?.isExpiring).toBe(true); expect(warning?.daysRemaining).toBeLessThan(30); + expect(warning?.expiresOn).toBeInstanceOf(Date); }); - it('should respect custom threshold', async () => { - const date = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000); - - vi.mocked(mockHttp.get).mockResolvedValue({ - data: { - hasCertificate: true, - expiresOn: date.toISOString() - } - } as any); + it('respeita o limite informado', async () => { + const em20Dias = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(); + withCertificate(em20Dias); const warning30 = await companies.checkCertificateExpiration('company-123', 30); + withCertificate(em20Dias); const warning10 = await companies.checkCertificateExpiration('company-123', 10); expect(warning30).not.toBeNull(); From 24db13ad7033569040b61127050bc0a92f1cba1f Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Wed, 2 Sep 2026 21:23:38 -0300 Subject: [PATCH 10/21] fix(service-invoices)!: downloads exigem o invoiceId; nao ha rota em lote `downloadPdf(companyId)` e `downloadXml(companyId)` sem o id montavam `/serviceinvoices/pdf` e `/serviceinvoices/xml`. O servidor casa a rota `/{id}` e le o sufixo como identificador: GET /v1/companies/{id}/serviceinvoices/pdf -> 404 "service invoice with id (pdf) was not found" A rota em lote nao existe: nao esta na spec nf-servico-v1 nem no nfeio-docs. O comentario `// Bulk download for company (returns ZIP)` descrevia uma rota inventada. Medido em 2026-09-02. BREAKING CHANGE: `invoiceId` passa a ser obrigatorio nos dois metodos. Toda chamada afetada ja falhava em runtime; agora falha na compilacao do consumidor. Para varias notas, itere sobre os ids. Nota em MIGRATION.md. Documentacao ------------ README, docs/downloads.md e docs/API.md prometiam "ZIP com todas as notas", com exemplo pronto para copiar -- em quatro lugares, incluindo um bullet de features. Removidos, com a medicao no lugar. Testes ------ Havia DUAS copias dos testes de download (tests/unit/service-invoices.test.ts e tests/unit/core/resources/service-invoices.test.ts), as duas afirmando o caminho em lote. Elas afirmavam o caminho que o SDK montava, nunca que a API o servisse -- passavam verdes contra uma rota inexistente. Ressalva: `npm run typecheck` NAO teria pego isso. O tsconfig limita o include a `src/**/*` e ainda exclui `**/*.test.ts`, entao nenhum arquivo de teste e verificado. As chamadas sobreviventes so apareceram quando a suite rodou, e uma montou `/serviceinvoices/undefined/pdf`. Lacuna inventariada. --- MIGRATION.md | 29 +++++++++++ README.md | 7 +-- docs/API.md | 52 ++++++++----------- docs/downloads.md | 21 ++++---- src/core/resources/service-invoices.ts | 52 ++++++------------- .../core/resources/service-invoices.test.ts | 41 ++------------- tests/unit/service-invoices.test.ts | 45 ++++------------ 7 files changed, 94 insertions(+), 153 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index b8936e7..c119878 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,34 @@ # Guia de Migração +## Não lançado (próxima major) + +Quebras já no `master` e ainda não publicadas. As demais mudanças desta faixa estão no +`CHANGELOG.md`, em `[Não lançado]`, cada uma com sua nota de migração — esta seção guarda +as que mudam **assinatura**, porque só elas quebram em tempo de compilação. + +### `serviceInvoices.downloadPdf()` / `downloadXml()` exigem o `invoiceId` + +O parâmetro era opcional e a documentação prometia um ZIP com todas as notas da empresa +quando ele fosse omitido. **A rota não existe.** `/serviceinvoices/pdf` responde +`404 "service invoice with id (pdf) was not found"` — o servidor casa a rota `/{id}` e +lê `pdf` como identificador. Medido em 2026-09-02; a rota também não está na spec +`nf-servico-v1` nem no `nfeio-docs`. + +```ts +// Antes — compilava e sempre lançava NotFoundError em runtime +const zip = await nfe.serviceInvoices.downloadPdf(empresaId); + +// Agora — não compila. Para várias notas, itere sobre os ids: +for (const nota of notas) { + const pdf = await nfe.serviceInvoices.downloadPdf(empresaId, nota.id); + fs.writeFileSync(`nota-${nota.number}.pdf`, pdf); +} +``` + +O download por nota não muda. + +--- + ## v4 → v5 A v5 é a primeira release de **funcionalidades** desde a v3 (a v4 foi apenas o bump para diff --git a/README.md b/README.md index 6b999f7..046192f 100644 --- a/README.md +++ b/README.md @@ -222,14 +222,10 @@ await nfe.serviceInvoices.sendEmail(empresaId, notaFiscalId, { emails: ['cliente@example.com', 'financeiro@example.com'], }); -// Baixar PDF (single ou bulk) +// Baixar PDF const pdfBuffer = await nfe.serviceInvoices.downloadPdf(empresaId, notaFiscalId); fs.writeFileSync('nota.pdf', pdfBuffer); -// Baixar todas as notas como ZIP -const zipBuffer = await nfe.serviceInvoices.downloadPdf(empresaId); -fs.writeFileSync('todas-notas.zip', zipBuffer); - // Baixar XML const xmlBuffer = await nfe.serviceInvoices.downloadXml(empresaId, notaFiscalId); fs.writeFileSync('nota.xml', xmlBuffer); @@ -248,7 +244,6 @@ console.log(`✅ ${notas.length} notas fiscais criadas em lote`); - ⏱️ **Polling Automático**: `createAndWait()` lida automaticamente com processamento assíncrono - 📦 **Criação em Lote**: `createBatch()` cria múltiplas notas com controle de concorrência -- 📥 **Downloads Bulk**: Baixe todas as notas como ZIP (PDF ou XML) - 🔍 **Verificação de Status**: `getStatus()` verifica se nota completou processamento - 🎯 **Discriminated Unions**: TypeScript detecta automaticamente tipo de resposta (201 vs 202) diff --git a/docs/API.md b/docs/API.md index c3393c8..a666d7c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -760,18 +760,25 @@ for (const invoice of invoices) { --- -##### `downloadPdf(companyId: string, invoiceId?: string): Promise` +##### `downloadPdf(companyId: string, invoiceId: string): Promise` -Download invoice PDF. If `invoiceId` is omitted, downloads all invoices as ZIP. +Download invoice PDF. + +> **There is no bulk download.** Until 5.2.0 `invoiceId` was optional and this +> page promised a ZIP with every invoice. The route does not exist: +> `/serviceinvoices/pdf` answers `404 "service invoice with id (pdf) was not +> found"` — the server matches the `/{id}` route and reads `pdf` as an id. +> Measured 2026-09-02; the route is absent from the `nf-servico-v1` spec too. +> To download many invoices, iterate over their ids. **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `companyId` | `string` | Company ID | -| `invoiceId` | `string` | Invoice ID (optional - omit for bulk ZIP) | +| `invoiceId` | `string` | Invoice ID (required) | -**Returns:** `Promise` - PDF file as Buffer (or ZIP for bulk) +**Returns:** `Promise` - PDF file as Buffer **Examples:** @@ -790,13 +797,7 @@ if (pdfBuffer.toString('utf8', 0, 4) === '%PDF') { writeFileSync('invoice.pdf', pdfBuffer); console.log('Saved invoice.pdf'); -// Example 2: Download all invoices as ZIP -const zipBuffer = await nfe.serviceInvoices.downloadPdf('company-id'); - -writeFileSync(`invoices_${Date.now()}.zip`, zipBuffer); -console.log('Saved ZIP with all invoices'); - -// Example 3: Download and send via HTTP response (Express) +// Example 2: Download and send via HTTP response (Express) app.get('/invoice/:id/pdf', async (req, res) => { try { const pdfBuffer = await nfe.serviceInvoices.downloadPdf( @@ -812,7 +813,7 @@ app.get('/invoice/:id/pdf', async (req, res) => { } }); -// Example 4: Download after creation +// Example 3: Download after creation const invoice = await nfe.serviceInvoices.createAndWait('company-id', data); const pdf = await nfe.serviceInvoices.downloadPdf('company-id', invoice.id); @@ -826,18 +827,22 @@ console.log(`Downloaded invoice ${invoice.number}`); --- -##### `downloadXml(companyId: string, invoiceId?: string): Promise` +##### `downloadXml(companyId: string, invoiceId: string): Promise` + +Download invoice XML. -Download invoice XML. If `invoiceId` is omitted, downloads all invoices as ZIP. +> **There is no bulk download** — same measurement as `downloadPdf` above: +> `/serviceinvoices/xml` answers `404 "service invoice with id (xml) was not +> found"`. **Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | `companyId` | `string` | Company ID | -| `invoiceId` | `string` | Invoice ID (optional - omit for bulk ZIP) | +| `invoiceId` | `string` | Invoice ID (required) | -**Returns:** `Promise` - XML file as Buffer (or ZIP for bulk) +**Returns:** `Promise` - XML file as Buffer **Examples:** @@ -859,11 +864,7 @@ if (xmlString.startsWith(' { console.log('Parsed XML:', result); // Process structured data }); - -// Example 4: Bulk download and extract -const zipBuffer = await nfe.serviceInvoices.downloadXml('company-id'); -writeFileSync('invoices.zip', zipBuffer); - -// Extract ZIP using library like 'adm-zip' -// const AdmZip = require('adm-zip'); -// const zip = new AdmZip(zipBuffer); -// zip.extractAllTo('./invoices/', true); ``` --- diff --git a/docs/downloads.md b/docs/downloads.md index 7f87c5e..195477f 100644 --- a/docs/downloads.md +++ b/docs/downloads.md @@ -8,26 +8,29 @@ description: Baixe DANFE/PDF e XML como Buffer, individualmente ou em ZIP por em # Downloads (PDF/XML) -Os métodos de download retornam um **`Buffer`** com os bytes do arquivo. Passar -o `invoiceId` baixa a nota individual; **omitir** o `invoiceId` baixa um **ZIP** -com todas as notas da empresa (quando o recurso suporta). +Os métodos de download retornam um **`Buffer`** com os bytes do arquivo, sempre +de uma nota identificada — o `invoiceId` é obrigatório. ```typescript import { writeFileSync } from 'node:fs'; -// PDF individual +// PDF const pdf = await nfe.serviceInvoices.downloadPdf(companyId, invoiceId); writeFileSync('nota.pdf', pdf); -// XML individual +// XML const xml = await nfe.serviceInvoices.downloadXml(companyId, invoiceId); writeFileSync('nota.xml', xml); - -// ZIP de todas as notas da empresa (sem invoiceId) -const zip = await nfe.serviceInvoices.downloadPdf(companyId); -writeFileSync('notas.zip', zip); ``` +> **Não existe download em lote por empresa.** Até a versão 5.2.0 o `invoiceId` +> era opcional e a documentação prometia um ZIP com todas as notas. A rota não +> existe: `/serviceinvoices/pdf` responde +> `404 "service invoice with id (pdf) was not found"`, porque o servidor casa a +> rota `/{id}` e trata `pdf` como identificador. Medido em 2026-09-02; a rota +> também não está na spec `nf-servico-v1`. Para baixar várias notas, itere sobre +> os ids. + ## Disponibilidade por estado O PDF/XML só existe após a nota atingir um **estado terminal** diff --git a/src/core/resources/service-invoices.ts b/src/core/resources/service-invoices.ts index b43ff26..4bfbd4e 100644 --- a/src/core/resources/service-invoices.ts +++ b/src/core/resources/service-invoices.ts @@ -468,40 +468,31 @@ export class ServiceInvoicesResource { * (Issued, Cancelled) before the PDF is available. * * @param companyId - Company ID (GUID) - * @param invoiceId - Invoice ID (GUID), or omit for bulk download + * @param invoiceId - Invoice ID (GUID) — obrigatório * @returns PDF data as Buffer * @throws {NotFoundError} If the invoice or PDF is not found/not ready * @throws {AuthenticationError} If API key is invalid * * @example * ```typescript - * // Download single invoice PDF * const pdf = await nfe.serviceInvoices.downloadPdf(companyId, invoiceId); * fs.writeFileSync('invoice.pdf', pdf); - * - * // Download all company invoices as ZIP - * const zipPdf = await nfe.serviceInvoices.downloadPdf(companyId); - * fs.writeFileSync('invoices.zip', zipPdf); * ``` * * @remarks * - PDF is only available after invoice reaches terminal state (Issued/Cancelled) * - Returns 404 if PDF is not yet ready - use polling or check flowStatus first - * - Bulk download returns ZIP file containing all PDFs for the company * - Large files may consume significant memory - consider streaming for production use + * + * Não existe download em lote por empresa. Até 2026-09-02 `invoiceId` era + * opcional e o ramo sem id montava `/serviceinvoices/pdf`, que o servidor casa + * com a rota `/{id}` e trata como identificador literal: + * `404 "service invoice with id (pdf) was not found"`. A rota não está na spec + * `nf-servico-v1` nem no `nfeio-docs` — nunca houve caminho válido. */ - async downloadPdf(companyId: string, invoiceId?: string): Promise { - let path: string; - - if (invoiceId) { - path = `/companies/${companyId}/serviceinvoices/${invoiceId}/pdf`; - } else { - // Bulk download for company (returns ZIP) - path = `/companies/${companyId}/serviceinvoices/pdf`; - } - + async downloadPdf(companyId: string, invoiceId: string): Promise { const response = await this.http.get( - path, + `/companies/${companyId}/serviceinvoices/${invoiceId}/pdf`, undefined, { Accept: 'application/pdf' } ); @@ -516,41 +507,30 @@ export class ServiceInvoicesResource { * (Issued, Cancelled) before the XML is available. * * @param companyId - Company ID (GUID) - * @param invoiceId - Invoice ID (GUID), or omit for bulk download + * @param invoiceId - Invoice ID (GUID) — obrigatório * @returns XML data as Buffer * @throws {NotFoundError} If the invoice or XML is not found/not ready * @throws {AuthenticationError} If API key is invalid * * @example * ```typescript - * // Download single invoice XML * const xml = await nfe.serviceInvoices.downloadXml(companyId, invoiceId); * fs.writeFileSync('invoice.xml', xml); * console.log(xml.toString('utf-8')); // View as string - * - * // Download all company invoices as ZIP - * const zipXml = await nfe.serviceInvoices.downloadXml(companyId); - * fs.writeFileSync('invoices-xml.zip', zipXml); * ``` * * @remarks * - XML is only available after invoice reaches terminal state (Issued/Cancelled) * - Returns 404 if XML is not yet ready - use polling or check flowStatus first - * - Bulk download returns ZIP file containing all XMLs for the company * - Buffer can be converted to string with `.toString('utf-8')` if needed + * + * Não existe download em lote por empresa — mesmo motivo do + * {@link ServiceInvoicesResource.downloadPdf}: `/serviceinvoices/xml` responde + * `404 "service invoice with id (xml) was not found"`. */ - async downloadXml(companyId: string, invoiceId?: string): Promise { - let path: string; - - if (invoiceId) { - path = `/companies/${companyId}/serviceinvoices/${invoiceId}/xml`; - } else { - // Bulk download for company (returns ZIP) - path = `/companies/${companyId}/serviceinvoices/xml`; - } - + async downloadXml(companyId: string, invoiceId: string): Promise { const response = await this.http.get( - path, + `/companies/${companyId}/serviceinvoices/${invoiceId}/xml`, undefined, { Accept: 'application/xml' } ); diff --git a/tests/unit/core/resources/service-invoices.test.ts b/tests/unit/core/resources/service-invoices.test.ts index 9e239e7..b96d1aa 100644 --- a/tests/unit/core/resources/service-invoices.test.ts +++ b/tests/unit/core/resources/service-invoices.test.ts @@ -435,24 +435,10 @@ describe('ServiceInvoicesResource', () => { ); }); - it('should download PDF for all company invoices (bulk)', async () => { - const mockZipBuffer = Buffer.from('ZIP content'); - - vi.mocked(mockHttp.get).mockResolvedValue({ - data: mockZipBuffer, - status: 200, - headers: { 'content-type': 'application/pdf' }, - } as HttpResponse); - - const result = await resource.downloadPdf(companyId); - - expect(result).toEqual(mockZipBuffer); - expect(mockHttp.get).toHaveBeenCalledWith( - `/companies/${companyId}/serviceinvoices/pdf`, - undefined, - { Accept: 'application/pdf' } - ); - }); + // Nao ha download em lote por empresa: `/serviceinvoices/pdf` responde + // 404 "service invoice with id (pdf) was not found" (medido 2026-09-02, e + // ausente da spec nf-servico-v1). O teste que existia aqui afirmava o caminho + // que o SDK montava, nunca que a API o servisse. it('should throw NotFoundError when PDF is not ready', async () => { vi.mocked(mockHttp.get).mockRejectedValue( @@ -486,24 +472,7 @@ describe('ServiceInvoicesResource', () => { ); }); - it('should download XML for all company invoices (bulk)', async () => { - const mockZipBuffer = Buffer.from('ZIP with XMLs'); - - vi.mocked(mockHttp.get).mockResolvedValue({ - data: mockZipBuffer, - status: 200, - headers: { 'content-type': 'application/xml' }, - } as HttpResponse); - - const result = await resource.downloadXml(companyId); - - expect(result).toEqual(mockZipBuffer); - expect(mockHttp.get).toHaveBeenCalledWith( - `/companies/${companyId}/serviceinvoices/xml`, - undefined, - { Accept: 'application/xml' } - ); - }); + // Mesma medicao do downloadPdf. it('should throw NotFoundError when XML is not ready', async () => { vi.mocked(mockHttp.get).mockRejectedValue( diff --git a/tests/unit/service-invoices.test.ts b/tests/unit/service-invoices.test.ts index 2cbda7d..bc9ced8 100644 --- a/tests/unit/service-invoices.test.ts +++ b/tests/unit/service-invoices.test.ts @@ -203,24 +203,13 @@ describe('ServiceInvoicesResource', () => { expect(result).toEqual(mockPdfData); }); - it('should download PDF for all invoices when invoiceId is not provided', async () => { - const mockPdfData = Buffer.from('Bulk PDF content'); - const mockResponse: HttpResponse = { - data: mockPdfData, - status: 200, - headers: { 'content-type': 'application/pdf' }, - }; - vi.mocked(mockHttpClient.get).mockResolvedValue(mockResponse); - - const result = await serviceInvoices.downloadPdf(TEST_COMPANY_ID); - - expect(mockHttpClient.get).toHaveBeenCalledWith( - `/companies/${TEST_COMPANY_ID}/serviceinvoices/pdf`, - undefined, - { Accept: 'application/pdf' } - ); - expect(result).toEqual(mockPdfData); - }); + // O download em lote por empresa NAO existe. `/serviceinvoices/pdf` responde + // 404 "service invoice with id (pdf) was not found" -- o servidor casa a rota + // `/{id}` e trata `pdf` como identificador. Nao esta na spec nf-servico-v1 nem + // no nfeio-docs. O teste que existia aqui afirmava o caminho montado, nunca + // que a API o servisse, e por isso passou verde por meses. + // + // `invoiceId` agora e obrigatorio: quem chamar sem ele nao compila. }); describe('downloadXml', () => { @@ -243,24 +232,8 @@ describe('ServiceInvoicesResource', () => { expect(result).toEqual(mockXmlData); }); - it('should download XML for all invoices when invoiceId is not provided', async () => { - const mockXmlData = 'Bulk invoice data'; - const mockResponse: HttpResponse = { - data: mockXmlData, - status: 200, - headers: { 'content-type': 'application/xml' }, - }; - vi.mocked(mockHttpClient.get).mockResolvedValue(mockResponse); - - const result = await serviceInvoices.downloadXml(TEST_COMPANY_ID); - - expect(mockHttpClient.get).toHaveBeenCalledWith( - `/companies/${TEST_COMPANY_ID}/serviceinvoices/xml`, - undefined, - { Accept: 'application/xml' } - ); - expect(result).toEqual(mockXmlData); - }); + // Mesma medicao do downloadPdf: `/serviceinvoices/xml` responde + // 404 "service invoice with id (xml) was not found". }); describe('error handling', () => { From fa94b834a25647396249b0c0725bdcf6d59994ac Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Wed, 2 Sep 2026 21:27:04 -0300 Subject: [PATCH 11/21] fix(http): erro da API para de virar "HTTP 400 error" Duas correcoes que sairam da mesma medicao. 1) Accept dos downloads por chave de acesso ------------------------------------------- `productInvoiceQuery.downloadPdf/downloadXml` mandavam so o tipo binario. No caminho feliz isso funciona -- e sempre funcionou, ao contrario do que o diagnostico anterior registrou. O problema e o caminho de ERRO: o servidor nao tem formatter de erro para `application/pdf` e responde 406 com corpo vazio, apagando a mensagem. Medido em 2026-09-02 contra nfe.api.nfe.io: .pdf + "application/pdf" chave real: 200 %PDF-1.4 (7623 bytes) inexistente: 406, corpo vazio .pdf + "application/pdf, application/json;q=0.9" chave real: 200 %PDF-1.4 (MESMOS bytes) inexistente: 400 "access key is not valid" O tipo binario continua em primeiro, entao o caminho feliz nao muda -- mesmo status, mesmo content-type, mesmo numero de bytes. O `Buffer` de retorno segue valendo porque o content-type de sucesso nao mudou. 2) O extrator de mensagem de erro ignorava dois envelopes reais --------------------------------------------------------------- Com o Accept corrigido o erro chegava com corpo JSON -- e o SDK jogava a mensagem fora assim mesmo. `extractErrorMessage` so lia `message`/`error`/`detail`/`details`. A plataforma usa quatro envelopes: "pageCount must be between 1 and 50" string JSON crua {"code":40001,"message":"environment has to be ..."} campo message {"errors":[{"message":"access key is not valid"}]} lista (hosts de consulta) {"title":"...","errors":{"file":["The File field ..."]}} ProblemDetails/ModelState Nos dois ultimos o chamador recebia `HTTP 400 error` -- literalmente o status que ele ja tinha. Foi assim que `The File field is required.` ficou invisivel enquanto o upload de certificado nao funcionava. Os quatro envelopes viraram teste, mais os casos de borda (lista vazia, varias mensagens, corpo sem nada aproveitavel). E `tests/integration/setup.ts` passa a repassar `NFE_DATA_API_KEY`: sem a chave de dados os hosts de consulta respondem 403, e nenhum teste de integracao podia tocar neles. --- src/core/http/client.ts | 59 ++++++++++++++ src/core/resources/product-invoice-query.ts | 23 +++++- .../product-invoice-query.integration.test.ts | 80 +++++++++++++++++++ tests/integration/setup.ts | 14 ++++ tests/unit/http-client.test.ts | 71 ++++++++++++++++ .../resources/product-invoice-query.test.ts | 8 +- 6 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 tests/integration/product-invoice-query.integration.test.ts diff --git a/src/core/http/client.ts b/src/core/http/client.ts index fe4fa53..daf6f5b 100644 --- a/src/core/http/client.ts +++ b/src/core/http/client.ts @@ -254,6 +254,22 @@ export class HttpClient { throw ErrorFactory.fromHttpResponse(response.status, errorData, message); } + /** + * Extrai a mensagem de erro do corpo devolvido pela API. + * + * A plataforma usa QUATRO envelopes distintos, todos medidos ao vivo em + * 2026-09-02 (e o de ModelState capturado em `tests/fixtures/live-contracts/`): + * + * "pageCount must be between 1 and 50" string JSON crua + * {"code":40001,"message":"environment has to be ..."} campo `message` + * {"errors":[{"message":"access key is not valid"}]} lista (hosts de consulta) + * {"title":"...","errors":{"file":["The File field ..."]}} ProblemDetails/ModelState + * + * Só os dois primeiros eram tratados. Nos outros dois a mensagem real era + * descartada e o chamador recebia `HTTP 400 error` — literalmente o status que + * ele já tinha. Foi assim que o campo errado no upload de certificado + * (`The File field is required.`) ficou invisível por meses. + */ private extractErrorMessage(data: unknown, status: number): string { if (typeof data === 'object' && data !== null) { const errorObj = data as Record; @@ -263,6 +279,12 @@ export class HttpClient { if (typeof errorObj.error === 'string') return errorObj.error; if (typeof errorObj.detail === 'string') return errorObj.detail; if (typeof errorObj.details === 'string') return errorObj.details; + + const fromErrors = this.extractFromErrorsField(errorObj.errors); + if (fromErrors) return fromErrors; + + // ProblemDetails sem detalhe por campo: `title` é o que sobra. + if (typeof errorObj.title === 'string') return errorObj.title; } if (typeof data === 'string') { @@ -272,6 +294,43 @@ export class HttpClient { return `HTTP ${status} error`; } + /** + * Lê o campo `errors`, que vem em duas formas conforme o serviço: + * lista de `{message}` (hosts de consulta) ou mapa `campo -> string[]` + * (ModelState do ASP.NET). + */ + private extractFromErrorsField(errors: unknown): string | undefined { + if (!errors || typeof errors !== 'object') return undefined; + + if (Array.isArray(errors)) { + const messages = errors + .map(item => { + if (typeof item === 'string') return item; + if (item && typeof item === 'object') { + const message = (item as Record).message; + if (typeof message === 'string') return message; + } + return undefined; + }) + .filter((m): m is string => Boolean(m)); + + return messages.length > 0 ? messages.join('; ') : undefined; + } + + // ModelState: { campo: ["mensagem", ...] } + const parts: string[] = []; + for (const [field, value] of Object.entries(errors as Record)) { + const messages = Array.isArray(value) + ? value.filter((v): v is string => typeof v === 'string') + : typeof value === 'string' + ? [value] + : []; + if (messages.length > 0) parts.push(`${field}: ${messages.join(', ')}`); + } + + return parts.length > 0 ? parts.join('; ') : undefined; + } + // -------------------------------------------------------------------------- // URL and Header Building // -------------------------------------------------------------------------- diff --git a/src/core/resources/product-invoice-query.ts b/src/core/resources/product-invoice-query.ts index 396e174..a4a7a9e 100644 --- a/src/core/resources/product-invoice-query.ts +++ b/src/core/resources/product-invoice-query.ts @@ -23,6 +23,25 @@ export const NFE_QUERY_API_BASE_URL = 'https://nfe.api.nfe.io'; /** Regex pattern for valid access key (44 numeric digits) */ const ACCESS_KEY_PATTERN = /^\d{44}$/; +/** + * `Accept` dos downloads por chave de acesso. + * + * O tipo binário vem primeiro, então o caminho feliz não muda: sucesso continua + * respondendo `200` com o mesmo `content-type` e os mesmos bytes. O + * `application/json` de segunda escolha existe para o caminho de ERRO — sem ele, + * o servidor não tem formatter de erro para PDF e responde **406 com corpo + * vazio**, apagando a mensagem real. Medido em 2026-09-02: + * + * .pdf + "application/pdf" -> chave real: 200 %PDF-1.4 + * chave inexistente: 406, corpo vazio + * .pdf + "application/pdf, application/json;q=0.9" -> chave real: 200 %PDF-1.4 (mesmos bytes) + * chave inexistente: 400 {"errors":[{"message":"access key is not valid"}]} + */ +const ACCEPT_PDF = 'application/pdf, application/json;q=0.9'; + +/** Idem para XML. Aqui já havia formatter de erro XML; vale por consistência. */ +const ACCEPT_XML = 'application/xml, application/json;q=0.9'; + // ============================================================================ // Validation Helpers // ============================================================================ @@ -131,7 +150,7 @@ export class ProductInvoiceQueryResource { validateAccessKey(accessKey); const response = await this.http.getBuffer( `/v2/productinvoices/${accessKey.trim()}.pdf`, - 'application/pdf' + ACCEPT_PDF ); return response.data; } @@ -157,7 +176,7 @@ export class ProductInvoiceQueryResource { validateAccessKey(accessKey); const response = await this.http.getBuffer( `/v2/productinvoices/${accessKey.trim()}.xml`, - 'application/xml' + ACCEPT_XML ); return response.data; } diff --git a/tests/integration/product-invoice-query.integration.test.ts b/tests/integration/product-invoice-query.integration.test.ts new file mode 100644 index 0000000..f8e15c7 --- /dev/null +++ b/tests/integration/product-invoice-query.integration.test.ts @@ -0,0 +1,80 @@ +/** + * Downloads por chave de acesso, contra a API real. + * + * O ponto destes testes é o **caminho de erro**. Com `Accept: application/pdf` + * puro, uma chave inexistente devolve `406` com corpo vazio: o servidor não tem + * formatter de erro para PDF e a mensagem real morre lá. O chamador recebe um + * erro sem causa, e o diagnóstico anterior chegou a registrar isso como + * "downloadPdf quebrado" — não estava; o caminho feliz sempre funcionou. + * + * Medido em 2026-09-02 (`nfe.api.nfe.io`): + * + * .pdf + "application/pdf" -> real: 200 %PDF-1.4 (7623 bytes) + * inexistente: 406, corpo vazio + * .pdf + "application/pdf, application/json;q=0.9" -> real: 200 %PDF-1.4 (mesmos bytes) + * inexistente: 400 "access key is not valid" + * + * Somente leitura. A chave usada aqui tem formato válido e não corresponde a + * documento nenhum — de propósito: o caminho feliz depende de uma nota real de + * terceiro, que não entra no repositório. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { createIntegrationClient, skipIfNoDataApiKey, INTEGRATION_TEST_CONFIG } from './setup.js'; +import { NfeClient } from '../../src/core/client.js'; +import { NfeError } from '../../src/core/errors/index.js'; + +/** 44 dígitos, formato válido, documento inexistente. */ +const CHAVE_INEXISTENTE = '3'.repeat(44); + +describe.skipIf(skipIfNoDataApiKey())('Consulta de NF-e por chave — contrato ao vivo', () => { + let client: NfeClient; + + beforeAll(() => { + client = createIntegrationClient(); + }); + + it( + 'o erro do PDF chega com a mensagem da API, não como 406 opaco', + { timeout: INTEGRATION_TEST_CONFIG.timeout }, + async () => { + const erro = await client.productInvoiceQuery + .downloadPdf(CHAVE_INEXISTENTE) + .then(() => null) + .catch((e: unknown) => e); + + expect(erro).toBeInstanceOf(NfeError); + // Sem o Accept composto isto seria um 406 de corpo vazio, sem mensagem. + expect((erro as NfeError).message.toLowerCase()).toContain('access key'); + expect((erro as NfeError).statusCode).not.toBe(406); + } + ); + + it( + 'o erro do XML também chega legível', + { timeout: INTEGRATION_TEST_CONFIG.timeout }, + async () => { + const erro = await client.productInvoiceQuery + .downloadXml(CHAVE_INEXISTENTE) + .then(() => null) + .catch((e: unknown) => e); + + expect(erro).toBeInstanceOf(NfeError); + expect((erro as NfeError).statusCode).not.toBe(406); + } + ); + + it( + 'a consulta por chave inexistente responde com erro descritivo', + { timeout: INTEGRATION_TEST_CONFIG.timeout }, + async () => { + const erro = await client.productInvoiceQuery + .retrieve(CHAVE_INEXISTENTE) + .then(() => null) + .catch((e: unknown) => e); + + expect(erro).toBeInstanceOf(NfeError); + expect((erro as NfeError).message.toLowerCase()).toContain('access key'); + } + ); +}); diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 96b1745..f9cfa24 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -15,6 +15,12 @@ export const INTEGRATION_TEST_CONFIG = { // API key from environment variable (filter out empty strings) apiKey: process.env.NFE_API_KEY?.trim() || process.env.NFE_TEST_API_KEY?.trim() || '', + // Chave de DADOS: os hosts de consulta (nfe.api.nfe.io, legalentity, + // naturalperson, address) recusam a chave fiscal com 403 — as duas são + // complementares, não alternativas. Sem ela, o SDK cai no fallback para + // `apiKey`, que nesses hosts não passa. + dataApiKey: process.env.NFE_DATA_API_KEY?.trim() || '', + // Timeout for integration tests (longer than unit tests) timeout: 30000, // 30 seconds @@ -58,12 +64,20 @@ export function createIntegrationClient(): NfeClient { return new NfeClient({ apiKey: INTEGRATION_TEST_CONFIG.apiKey, + ...(INTEGRATION_TEST_CONFIG.dataApiKey + ? { dataApiKey: INTEGRATION_TEST_CONFIG.dataApiKey } + : {}), environment: INTEGRATION_TEST_CONFIG.environment, timeout: INTEGRATION_TEST_CONFIG.timeout, retryConfig: INTEGRATION_TEST_CONFIG.retryConfig, }); } +/** Há credencial para os hosts de consulta? Sem ela, esses testes não têm o que afirmar. */ +export function skipIfNoDataApiKey(): boolean { + return skipIfNoApiKey() || INTEGRATION_TEST_CONFIG.dataApiKey.length === 0; +} + // Test data helpers for integration tests export const TEST_COMPANY_DATA = { federalTaxNumber: 11222333000181, // Valid CNPJ with proper check digits diff --git a/tests/unit/http-client.test.ts b/tests/unit/http-client.test.ts index 29a685a..2312ea6 100644 --- a/tests/unit/http-client.test.ts +++ b/tests/unit/http-client.test.ts @@ -276,6 +276,77 @@ it.skip('should include Basic Auth header', async () => { }); describe('Error Handling', () => { + /** + * Os quatro envelopes de erro que a plataforma realmente usa, medidos ao vivo + * em 2026-09-02 (o de ModelState em tests/fixtures/live-contracts/). + * + * Só os dois primeiros eram lidos. Nos outros dois a mensagem real era jogada + * fora e o chamador recebia `HTTP 400 error` -- o status que ele já tinha. + */ + describe('mensagem de erro por envelope', () => { + const casos: Array<[string, unknown, string]> = [ + [ + 'string JSON crua (api.nfe.io)', + 'pageCount must be between 1 and 50', + 'pageCount must be between 1 and 50', + ], + [ + 'campo message', + { code: 40001, message: 'environment has to be production or test' }, + 'environment has to be production or test', + ], + [ + 'lista errors[{message}] (hosts de consulta)', + { errors: [{ message: 'access key is not valid' }] }, + 'access key is not valid', + ], + [ + 'ModelState errors{campo:[msg]} (upload de certificado)', + { + title: 'One or more validation errors occurred.', + status: 400, + errors: { file: ['The File field is required.'] }, + }, + 'file: The File field is required.', + ], + [ + 'ProblemDetails sem errors', + { title: 'An error occurred while processing your request.', status: 500 }, + 'An error occurred while processing your request.', + ], + ]; + + it.each(casos)('%s', async (_nome, corpo, esperado) => { + fetchMock.mockResolvedValue(createMockErrorResponse(400, 'Bad Request', corpo)); + + await expect(httpClient.get('/test')).rejects.toThrow(esperado); + }); + + it('sem nada aproveitável, cai para o status', async () => { + fetchMock.mockResolvedValue(createMockErrorResponse(400, 'Bad Request', { foo: 1 })); + + await expect(httpClient.get('/test')).rejects.toThrow('HTTP 400 error'); + }); + + it('lista errors vazia não vira mensagem vazia', async () => { + fetchMock.mockResolvedValue(createMockErrorResponse(400, 'Bad Request', { errors: [] })); + + await expect(httpClient.get('/test')).rejects.toThrow('HTTP 400 error'); + }); + + it('junta várias mensagens', async () => { + fetchMock.mockResolvedValue( + createMockErrorResponse(400, 'Bad Request', { + errors: { name: ['obrigatório'], email: ['inválido', 'muito longo'] }, + }) + ); + + await expect(httpClient.get('/test')).rejects.toThrow( + 'name: obrigatório; email: inválido, muito longo' + ); + }); + }); + it('should throw ValidationError on 400', async () => { fetchMock.mockResolvedValue( createMockErrorResponse(400, 'Bad Request', { diff --git a/tests/unit/resources/product-invoice-query.test.ts b/tests/unit/resources/product-invoice-query.test.ts index 568d35c..03d0a6d 100644 --- a/tests/unit/resources/product-invoice-query.test.ts +++ b/tests/unit/resources/product-invoice-query.test.ts @@ -176,7 +176,7 @@ describe('ProductInvoiceQueryResource', () => { expect(result).toEqual(pdfContent); expect(mockHttpClient.getBuffer).toHaveBeenCalledWith( `/v2/productinvoices/${validAccessKey}.pdf`, - 'application/pdf' + 'application/pdf, application/json;q=0.9' ); }); @@ -192,7 +192,7 @@ describe('ProductInvoiceQueryResource', () => { expect(mockHttpClient.getBuffer).toHaveBeenCalledWith( `/v2/productinvoices/${validAccessKey}.pdf`, - 'application/pdf' + 'application/pdf, application/json;q=0.9' ); }); @@ -225,7 +225,7 @@ describe('ProductInvoiceQueryResource', () => { expect(result).toEqual(xmlContent); expect(mockHttpClient.getBuffer).toHaveBeenCalledWith( `/v2/productinvoices/${validAccessKey}.xml`, - 'application/xml' + 'application/xml, application/json;q=0.9' ); }); @@ -241,7 +241,7 @@ describe('ProductInvoiceQueryResource', () => { expect(mockHttpClient.getBuffer).toHaveBeenCalledWith( `/v2/productinvoices/${validAccessKey}.xml`, - 'application/xml' + 'application/xml, application/json;q=0.9' ); }); From ba5ed99af3a491f0590a9e0d6685c98835be828e Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Wed, 2 Sep 2026 21:30:05 -0300 Subject: [PATCH 12/21] fix(sdk): rotas nao servidas passam a dizer que nao sao servidas Quatro metodos publicos apontam para rotas declaradas na OpenAPI que a plataforma NAO serve: GET /v2/companies/{id}/municipaltaxes/{mtid}/series/{serie} PATCH /v2/companies/{id}/municipaltaxes/{mtid}/updateprefecture GET /v1/consumerinvoices/coupon/{chave} GET /v1/consumerinvoices/coupon/{chave}.xml Ate aqui o chamador recebia `NotFoundError` generico, indistinguivel de "esse dado nao existe" -- e ia procurar defeito nos proprios dados. Como a distincao foi feita (2026-09-02) --------------------------------------- Comparando com um path inventado no mesmo host: 404 de corpo vazio, sem content-type, byte a byte igual ao do path inventado, e roteamento, nao "nao encontrado". Confirmacao independente: rota servida responde 401 SEM credencial; as de cima respondem 404 sem credencial -- o middleware de autenticacao nem chega a rodar. Alem disso, 90 dias de log de gateway nao tem um unico 200 em `consumerinvoices/coupon`. A escolha --------- Nao remover os metodos (apagaria a informacao de que a rota existe na spec e nao e servida) e nao bloquear no cliente (congelaria a medicao de hoje no codigo, e ninguem lembraria de tirar o guard). A requisicao sai; SO o 404 e enriquecido, preservando a classe do erro para nao quebrar `instanceof`. Se a rota subir, o 200 passa intacto -- e ha teste afirmando exatamente isso. legalPeople / naturalPeople NAO estavam quebrados ------------------------------------------------- O diagnostico de julho registrou os 14 metodos como "400 em toda chamada". A sonda tinha usado a empresa do .env, de id com 32 caracteres; a rota valida o company_id como ObjectId de 24 hex. Sobre 50 empresas da mesma conta: 30 com id de 24 hex -> 200 19 com id de 32 chars -> 400 "company id is not valid" Um id de 24 hex sintetico responde `404 "Company not found."`: o validador de formato passa e a busca e que falha. E limite do servidor -- nao ha conversao possivel, e validar localmente so antecipa a recusa com mensagem pior. Documentado no JSDoc dos dois recursos, com teste de integracao afirmando as duas metades para a proxima leitura nao repetir a generalizacao. --- src/core/resources/consumer-invoice-query.ts | 29 +++++-- src/core/resources/legal-people.ts | 19 +++++ src/core/resources/municipal-taxes.ts | 35 +++++++-- src/core/resources/natural-people.ts | 19 +++++ src/core/utils/unserved-route.ts | 52 +++++++++++++ tests/integration/people.integration.test.ts | 80 +++++++++++++++++++ tests/unit/consumer-invoice-query.test.ts | 2 +- tests/unit/utils/unserved-route.test.ts | 81 ++++++++++++++++++++ 8 files changed, 305 insertions(+), 12 deletions(-) create mode 100644 src/core/utils/unserved-route.ts create mode 100644 tests/integration/people.integration.test.ts create mode 100644 tests/unit/utils/unserved-route.test.ts diff --git a/src/core/resources/consumer-invoice-query.ts b/src/core/resources/consumer-invoice-query.ts index 87d0be7..6c1111e 100644 --- a/src/core/resources/consumer-invoice-query.ts +++ b/src/core/resources/consumer-invoice-query.ts @@ -9,6 +9,7 @@ import type { HttpClient } from '../http/client.js'; import type { TaxCoupon } from '../types.js'; import { ValidationError } from '../errors/index.js'; +import { withUnservedRouteNote } from '../utils/unserved-route.js'; // ============================================================================ // Constants @@ -46,6 +47,17 @@ function validateAccessKey(accessKey: string): void { /** * Consumer Invoice Query Resource * + * @deprecated **A plataforma não serve nenhuma das duas rotas deste recurso.** + * Elas estão declaradas na spec `consulta-nf-consumidor` e no `nfeio-docs`, em + * `nfe.api.nfe.io` — e respondem `404` de corpo vazio, sem `content-type`, + * idêntico ao de um path inventado no mesmo host. Confirmação independente: o + * `404` vem **inclusive sem credencial**, enquanto uma rota servida no mesmo host + * responde `401` sem credencial — o middleware de autenticação nem chega a rodar. + * Noventa dias de log de gateway não têm um único `200`. Medido em 2026-09-02. + * + * Os métodos continuam emitindo a requisição: se a rota subir, o resultado passa + * sem alteração. + * * @description * Queries CFe-SAT (Cupom Fiscal Eletrônico) consumer invoices by access key. * This is a read-only resource that does not require company scope. @@ -96,8 +108,9 @@ export class ConsumerInvoiceQueryResource { */ async retrieve(accessKey: string): Promise { validateAccessKey(accessKey); - const response = await this.http.get( - `/v1/consumerinvoices/coupon/${accessKey.trim()}` + const response = await withUnservedRouteNote( + 'GET /v1/consumerinvoices/coupon/{accessKey}', + () => this.http.get(`/v1/consumerinvoices/coupon/${accessKey.trim()}`) ); return response.data; } @@ -121,9 +134,15 @@ export class ConsumerInvoiceQueryResource { */ async downloadXml(accessKey: string): Promise { validateAccessKey(accessKey); - const response = await this.http.getBuffer( - `/v1/consumerinvoices/coupon/${accessKey.trim()}.xml`, - 'application/xml' + const response = await withUnservedRouteNote( + 'GET /v1/consumerinvoices/coupon/{accessKey}.xml', + () => + this.http.getBuffer( + `/v1/consumerinvoices/coupon/${accessKey.trim()}.xml`, + // Mesmo motivo do productInvoiceQuery: sem o JSON de segunda escolha, o + // erro volta 406 de corpo vazio em vez da mensagem da API. + 'application/xml, application/json;q=0.9' + ) ); return response.data; } diff --git a/src/core/resources/legal-people.ts b/src/core/resources/legal-people.ts index cf2375e..6f6d881 100644 --- a/src/core/resources/legal-people.ts +++ b/src/core/resources/legal-people.ts @@ -1,6 +1,25 @@ /** * LegalPeople Resource * Manages legal entities (pessoas jurídicas) scoped by company + * + * ## Restrição de formato do `company_id` + * + * Estas rotas aceitam **somente** `company_id` no formato `ObjectId` de 24 + * hexadecimais. Empresa cujo id tem 32 caracteres recebe + * `400 "company id is not valid"` em toda chamada — a rota valida o id como + * `ObjectId` antes de qualquer coisa. + * + * É **limite do servidor**, não do SDK: não há conversão possível entre os dois + * formatos, e validar localmente só antecipa a mesma recusa com mensagem pior. + * Medido em 2026-09-02 sobre 50 empresas da mesma conta: + * + * 30 empresas com id de 24 hex -> 200 + * 19 empresas com id de 32 chars -> 400 "company id is not valid" + * + * Um id de 24 hex sintético (inexistente) responde `404 "Company not found."`, + * ou seja: o validador de formato passa e a busca é que falha. Empresas criadas + * depois da mudança de formato de id ficaram inalcançáveis por estas rotas. + * Pendência aberta com o time de API. */ import type { HttpClient } from '../http/client.js'; diff --git a/src/core/resources/municipal-taxes.ts b/src/core/resources/municipal-taxes.ts index bd705cc..69fce9e 100644 --- a/src/core/resources/municipal-taxes.ts +++ b/src/core/resources/municipal-taxes.ts @@ -15,6 +15,7 @@ import type { MunicipalTaxListResponse, } from '../types.js'; import { ValidationError } from '../errors/index.js'; +import { withUnservedRouteNote } from '../utils/unserved-route.js'; function validateCompanyId(companyId: string): void { if (!companyId || companyId.trim() === '') { @@ -86,6 +87,12 @@ export class MunicipalTaxesResource { /** * Update the prefecture (city hall) credentials/integration for a municipal tax * registration. Uses HTTP PATCH (`.../updateprefecture`). + * + * @deprecated A plataforma **não serve** esta rota. Ela está declarada na spec + * `contribuintes-v2` e responde `404` — o mesmo `404` de corpo vazio que uma + * sub-rota inventada no mesmo host devolve (medido em 2026-09-02, com um + * `municipal_tax_id` cujo registro pai responde `200`). O método continua + * emitindo a requisição: se a rota subir, o resultado passa sem alteração. */ async updatePrefecture( companyId: string, @@ -94,14 +101,26 @@ export class MunicipalTaxesResource { ): Promise { validateCompanyId(companyId); validateMunicipalTaxId(municipalTaxId); - const response = await this.http.patch( - `${this.basePath(companyId)}/${municipalTaxId}/updateprefecture`, - { municipalTax: data } + const response = await withUnservedRouteNote( + 'PATCH /v2/companies/{company_id}/municipaltaxes/{municipal_tax_id}/updateprefecture', + () => + this.http.patch( + `${this.basePath(companyId)}/${municipalTaxId}/updateprefecture`, + { municipalTax: data } + ) ); return response.data; } - /** Look up an RPS series for a municipal tax registration. */ + /** + * Look up an RPS series for a municipal tax registration. + * + * @deprecated A plataforma **não serve** esta rota — mesma medição de + * {@link MunicipalTaxesResource.updatePrefecture}. Testado com toda série + * plausível, inclusive a que o próprio registro declara em `rpsSerialNumber`: + * `404` de corpo vazio, sem `content-type`, idêntico ao de uma sub-rota + * inventada. O método continua emitindo a requisição. + */ async getSeries( companyId: string, municipalTaxId: string, @@ -112,8 +131,12 @@ export class MunicipalTaxesResource { if (!serie || serie.trim() === '') { throw new ValidationError('Serie is required'); } - const response = await this.http.get>( - `${this.basePath(companyId)}/${municipalTaxId}/series/${serie}` + const response = await withUnservedRouteNote( + 'GET /v2/companies/{company_id}/municipaltaxes/{municipal_tax_id}/series/{serie}', + () => + this.http.get>( + `${this.basePath(companyId)}/${municipalTaxId}/series/${serie}` + ) ); return response.data; } diff --git a/src/core/resources/natural-people.ts b/src/core/resources/natural-people.ts index 3e854e5..a713078 100644 --- a/src/core/resources/natural-people.ts +++ b/src/core/resources/natural-people.ts @@ -1,6 +1,25 @@ /** * NaturalPeople Resource * Manages natural persons (pessoas físicas) scoped by company + * + * ## Restrição de formato do `company_id` + * + * Estas rotas aceitam **somente** `company_id` no formato `ObjectId` de 24 + * hexadecimais. Empresa cujo id tem 32 caracteres recebe + * `400 "company id is not valid"` em toda chamada — a rota valida o id como + * `ObjectId` antes de qualquer coisa. + * + * É **limite do servidor**, não do SDK: não há conversão possível entre os dois + * formatos, e validar localmente só antecipa a mesma recusa com mensagem pior. + * Medido em 2026-09-02 sobre 50 empresas da mesma conta: + * + * 30 empresas com id de 24 hex -> 200 + * 19 empresas com id de 32 chars -> 400 "company id is not valid" + * + * Um id de 24 hex sintético (inexistente) responde `404 "Company not found."`, + * ou seja: o validador de formato passa e a busca é que falha. Empresas criadas + * depois da mudança de formato de id ficaram inalcançáveis por estas rotas. + * Pendência aberta com o time de API. */ import type { HttpClient } from '../http/client.js'; diff --git a/src/core/utils/unserved-route.ts b/src/core/utils/unserved-route.ts new file mode 100644 index 0000000..1286a53 --- /dev/null +++ b/src/core/utils/unserved-route.ts @@ -0,0 +1,52 @@ +/** + * NFE.io SDK — rotas declaradas na spec que a plataforma não serve. + * + * Algumas rotas existem na OpenAPI (e no `nfeio-docs`) e simplesmente não são + * roteadas em produção. O chamador recebe `404` e não tem como distinguir isso de + * "esse dado não existe" — vai procurar defeito nos próprios dados, ou reportar + * como bug do SDK. + * + * Como a distinção foi feita (2026-09-02): + * + * - Comparar com um path inventado no mesmo host. `404` de corpo vazio, sem + * `content-type`, byte a byte igual ao do path inventado, é roteamento — não + * "não encontrado". Rota servida devolve corpo com mensagem. + * - Confirmação independente: rota servida responde `401` **sem credencial**; + * rota não servida responde `404` sem credencial, porque o middleware de + * autenticação nem chega a rodar. + * + * O SDK **não** bloqueia a chamada no cliente. A requisição sai; só o `404` é + * enriquecido. Se a rota voltar a ser servida, o `200` passa intacto e nada aqui + * precisa ser desfeito — um guard antes da requisição congelaria a medição de + * hoje no código, e ninguém lembraria de removê-lo. + */ + +import { NotFoundError, isNotFoundError } from '../errors/index.js'; + +/** Quando a ausência da rota foi medida. */ +export const UNSERVED_ROUTE_MEASURED_ON = '2026-09-02'; + +/** + * Executa a chamada e, **somente** em `404`, relança com a explicação. + * + * Preserva a classe do erro (`NotFoundError`), para não quebrar quem já trata + * `instanceof` ou `isNotFoundError()`. + * + * @param route - A rota, como aparece na spec + * @param call - A requisição + */ +export async function withUnservedRouteNote(route: string, call: () => Promise): Promise { + try { + return await call(); + } catch (error) { + if (!isNotFoundError(error)) throw error; + + throw new NotFoundError( + `A plataforma não serve a rota ${route} (medido em ${UNSERVED_ROUTE_MEASURED_ON}): ` + + 'ela está declarada na OpenAPI e responde 404 idêntico ao de um caminho inexistente, ' + + 'inclusive sem credencial. Não é "registro não encontrado" — é rota ausente, e não há ' + + 'nada a corrigir na chamada. Pendência aberta com o time de API.', + error.details + ); + } +} diff --git a/tests/integration/people.integration.test.ts b/tests/integration/people.integration.test.ts new file mode 100644 index 0000000..51b5b8f --- /dev/null +++ b/tests/integration/people.integration.test.ts @@ -0,0 +1,80 @@ +/** + * `legalPeople` / `naturalPeople` contra a API real. + * + * Estes 14 métodos foram registrados como "quebrados, 400 em toda chamada" pelo + * diagnóstico de julho. **Não estavam.** A sonda tinha usado a empresa do `.env`, + * cujo id tem 32 caracteres; a rota valida o `company_id` como `ObjectId` de 24 + * hexadecimais e recusa qualquer outro formato antes de olhar o resto. + * + * Medido em 2026-09-02 sobre 50 empresas da mesma conta: + * 30 com id de 24 hex -> 200, envelope `{ legalPeople: [...] }` + * 19 com id de 32 chars -> 400 "company id is not valid" + * + * O teste afirma as duas metades — a que funciona e a que o servidor recusa — + * para que a próxima leitura não repita a generalização a partir de uma amostra. + * + * Somente leitura. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { createIntegrationClient, skipIfNoApiKey, INTEGRATION_TEST_CONFIG } from './setup.js'; +import { NfeClient } from '../../src/core/client.js'; +import { ValidationError } from '../../src/core/errors/index.js'; +import type { Company } from '../../src/core/types.js'; + +const OBJECT_ID_24_HEX = /^[0-9a-f]{24}$/i; + +describe.skipIf(skipIfNoApiKey())('Pessoas vinculadas à empresa — contrato ao vivo', () => { + let client: NfeClient; + let elegivel: Company | undefined; + let inelegivel: Company | undefined; + + beforeAll(async () => { + client = createIntegrationClient(); + const page = (await client.companies.list({ pageCount: 50, pageIndex: 1 })).data; + elegivel = page.find(c => OBJECT_ID_24_HEX.test(String(c.id))); + inelegivel = page.find(c => !OBJECT_ID_24_HEX.test(String(c.id))); + }); + + it( + 'lista pessoas jurídicas de empresa com id no formato aceito', + { timeout: INTEGRATION_TEST_CONFIG.timeout }, + async () => { + expect(elegivel, 'nenhuma empresa com id de 24 hex na primeira página').toBeDefined(); + + const result = await client.legalPeople.list(elegivel!.id!); + + // O envelope `{ legalPeople: [...] }` é desembrulhado em `data`. + expect(Array.isArray(result.data)).toBe(true); + } + ); + + it( + 'lista pessoas físicas de empresa com id no formato aceito', + { timeout: INTEGRATION_TEST_CONFIG.timeout }, + async () => { + expect(elegivel).toBeDefined(); + + const result = await client.naturalPeople.list(elegivel!.id!); + + expect(Array.isArray(result.data)).toBe(true); + } + ); + + it( + 'empresa com id de 32 caracteres é recusada pelo servidor, não pelo SDK', + { timeout: INTEGRATION_TEST_CONFIG.timeout }, + async () => { + expect(inelegivel, 'nenhuma empresa com id de 32 chars na primeira página').toBeDefined(); + + const erro = await client.legalPeople + .list(inelegivel!.id!) + .then(() => null) + .catch((e: unknown) => e); + + // 400 da API, com a mensagem dela — não uma validação local que o SDK inventou. + expect(erro).toBeInstanceOf(ValidationError); + expect((erro as ValidationError).message.toLowerCase()).toContain('company id'); + } + ); +}); diff --git a/tests/unit/consumer-invoice-query.test.ts b/tests/unit/consumer-invoice-query.test.ts index 432f895..47dfd88 100644 --- a/tests/unit/consumer-invoice-query.test.ts +++ b/tests/unit/consumer-invoice-query.test.ts @@ -209,7 +209,7 @@ describe('ConsumerInvoiceQueryResource', () => { expect(mockHttpClient.getBuffer).toHaveBeenCalledWith( `/v1/consumerinvoices/coupon/${VALID_ACCESS_KEY}.xml`, - 'application/xml' + 'application/xml, application/json;q=0.9' ); expect(result).toBeInstanceOf(Buffer); expect(result.toString()).toContain(''); diff --git a/tests/unit/utils/unserved-route.test.ts b/tests/unit/utils/unserved-route.test.ts new file mode 100644 index 0000000..339f80e --- /dev/null +++ b/tests/unit/utils/unserved-route.test.ts @@ -0,0 +1,81 @@ +/** + * O aviso de rota não servida não pode virar bloqueio. + * + * Quatro métodos públicos apontam para rotas que a plataforma não serve + * (`municipalTaxes.getSeries`, `.updatePrefecture`, `consumerInvoiceQuery.retrieve`, + * `.downloadXml`). O SDK enriquece o `404` para o chamador não confundir com + * "esse dado não existe" — mas **não** recusa a chamada no cliente: se a rota + * subir, o `200` tem que passar sem que ninguém precise lembrar de remover um + * guard. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + withUnservedRouteNote, + UNSERVED_ROUTE_MEASURED_ON, +} from '../../../src/core/utils/unserved-route.js'; +import { + NotFoundError, + ValidationError, + AuthenticationError, +} from '../../../src/core/errors/index.js'; + +const ROTA = 'GET /v1/exemplo/{id}'; + +describe('withUnservedRouteNote', () => { + it('deixa a resposta de sucesso passar intacta', async () => { + const call = vi.fn().mockResolvedValue({ data: { ok: true }, status: 200 }); + + await expect(withUnservedRouteNote(ROTA, call)).resolves.toEqual({ + data: { ok: true }, + status: 200, + }); + expect(call).toHaveBeenCalledTimes(1); + }); + + it('a requisição sai — nada é bloqueado antes da chamada', async () => { + const call = vi.fn().mockRejectedValue(new NotFoundError('Not found')); + + await expect(withUnservedRouteNote(ROTA, call)).rejects.toThrow(); + expect(call).toHaveBeenCalledTimes(1); + }); + + it('no 404, explica que a rota não é servida e cita a rota e a data', async () => { + const call = vi.fn().mockRejectedValue(new NotFoundError('Not found')); + + const erro = await withUnservedRouteNote(ROTA, call).catch((e: unknown) => e); + + expect(erro).toBeInstanceOf(NotFoundError); + expect((erro as NotFoundError).message).toContain(ROTA); + expect((erro as NotFoundError).message).toContain(UNSERVED_ROUTE_MEASURED_ON); + expect((erro as NotFoundError).message).toContain('não serve'); + }); + + it('preserva a classe do erro, para não quebrar quem trata instanceof', async () => { + const call = vi.fn().mockRejectedValue(new NotFoundError('Not found')); + + const erro = await withUnservedRouteNote(ROTA, call).catch((e: unknown) => e); + + expect(erro).toBeInstanceOf(NotFoundError); + expect((erro as NotFoundError).statusCode).toBe(404); + }); + + it('preserva os detalhes do erro original', async () => { + const details = { corpo: 'vazio' }; + const call = vi.fn().mockRejectedValue(new NotFoundError('Not found', details)); + + const erro = await withUnservedRouteNote(ROTA, call).catch((e: unknown) => e); + + expect((erro as NotFoundError).details).toEqual(details); + }); + + it.each([ + ['ValidationError', new ValidationError('inválido')], + ['AuthenticationError', new AuthenticationError('sem credencial')], + ['Error comum', new Error('rede caiu')], + ])('não toca em %s', async (_nome, original) => { + const call = vi.fn().mockRejectedValue(original); + + await expect(withUnservedRouteNote(ROTA, call)).rejects.toBe(original); + }); +}); From 995c3ef2534a36e697dd62ce047ecbfd3130eba3 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Wed, 2 Sep 2026 21:33:32 -0300 Subject: [PATCH 13/21] docs(changelog): frente dos metodos publicos inalcancaveis CHANGELOG (pt-BR) da frente inteira: os cinco bugs corrigidos, os quatro metodos depreciados por rota nao servida, a quebra do download de NFS-e, e a correcao de registro dos DOIS que nao estavam quebrados (productInvoiceQuery e os 14 de legalPeople/naturalPeople). Fixture `tests/fixtures/live-contracts/unreachable-methods.json` guarda a medicao, inclusive o metodo de discriminacao -- comparar com path inventado no mesmo host, e conferir a resposta SEM credencial. Foi o que separou "rota nao servida" de "registro nao encontrado", e "metodo quebrado" de "entrada invalida". Chave de acesso, CNPJ, thumbprint e id de empresa reais nao entram: os valores sao sinteticos ou . Verificacao: typecheck limpo, lint 0 erros, test:types 18/18, build ok, dependencies segue vazio. Suite em modo CI 766 passed | 54 skipped; local 813 passed | 7 skipped, 50 arquivos, nenhum falho. --- CHANGELOG.md | 96 +++++++++++++- .../live-contracts/unreachable-methods.json | 120 ++++++++++++++++++ 2 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/live-contracts/unreachable-methods.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a331e6..cc92b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,104 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ## [Não lançado] -> Quatro bugs de contrato provados por sonda ao vivo contra a API real (2026-09-01). +> Bugs de contrato provados por sonda ao vivo contra a API real (2026-09-01 e 2026-09-02). > Em nenhum deles a especificação era a culpada: o SDK é que estava errado. > Evidência versionada em `tests/fixtures/live-contracts/`. +### Corrigido — métodos públicos que não alcançavam a API + +> Sete métodos públicos foram diagnosticados como quebrados em julho. Reprovando um a um +> com sonda ao vivo, **dois não estavam** — o diagnóstico anterior generalizou a partir de +> uma amostra. A correção do registro está junto das correções de código. + +- **`healthCheck()` respondia `error` sempre.** Enviava `pageCount: 1`, e + `GET /v1/companies?pageCount=1` responde `400 "pageCount must be between 1 and 50"` — o + limite inferior do servidor está um a mais do que a própria mensagem diz. Agora omite o + parâmetro (a rota sem query responde `200`), em vez de carregar um número mágico + contornando defeito alheio. O off-by-one vai para o time de API. + +- **`companies.getCertificateStatus()` lia uma forma que a API nunca devolveu.** Esperava + `{hasCertificate, expiresOn, isValid}`; a resposta é + `{certificates: [{providerType, resolution, taxPayerId, thumbprint, taxId, subject, + validUntil, modifiedOn, status}]}`. Nenhum dos três campos existe, então o retorno era + `{hasCertificate: undefined}` e os derivados nunca eram calculados. Isso derrubava em + cascata `checkCertificateExpiration()`, `getCompaniesWithCertificates()` e + `getCompaniesWithExpiringCertificates()` — quatro métodos públicos. + + O resumo mantém `expiresOn` em vez de renomear para `validUntil`: é o mesmo nome que a + API usa quando o certificado vem embutido na empresa. Os itens crus ficam expostos em + `certificates`, para quem precisa de `thumbprint` ou `subject`. + + Empresa sem certificado responde `200` com `certificates: []`, não `404`. + +- **As duas varreduras de certificado por conta deixaram de fazer N+1.** + `getCompaniesWithCertificates()` e `getCompaniesWithExpiringCertificates()` chamavam + `getCertificateStatus()` uma vez por empresa, em série. Enquanto o método estava + quebrado isso era invisível; consertado, uma conta com 500 empresas faria 500 + requisições sequenciais por chamada. + + A sonda dispensou o pool de concorrência: `GET /v1/companies` **já devolve** + `certificate` em todo item (`{thumbprint, modifiedOn, expiresOn, status}`). As duas + passam a ler daí. Medido: as duas varreduras juntas, sobre a conta inteira, em 9,8s. + +- **⚠️ BREAKING — `serviceInvoices.downloadPdf()` / `downloadXml()` exigem o `invoiceId`.** + O parâmetro era opcional e a documentação prometia um ZIP com todas as notas. A rota não + existe: `/serviceinvoices/pdf` responde `404 "service invoice with id (pdf) was not + found"`, porque o servidor casa a rota `/{id}` e lê `pdf` como identificador. Não está + na spec `nf-servico-v1` nem no `nfeio-docs`. Nota de migração em `MIGRATION.md`. + +- **O erro da API parava de chegar ao chamador.** `extractErrorMessage` só lia + `message`/`error`/`detail`/`details`. A plataforma usa quatro envelopes: + + | envelope | onde | + |---|---| + | `"pageCount must be between 1 and 50"` | string JSON crua | + | `{"code":40001,"message":"..."}` | campo `message` | + | `{"errors":[{"message":"access key is not valid"}]}` | hosts de consulta | + | `{"title":"...","errors":{"file":["The File field is required."]}}` | ProblemDetails/ModelState | + + Nos dois últimos a mensagem era descartada e o chamador recebia `HTTP 400 error` — + literalmente o status que ele já tinha. Foi assim que `The File field is required.` ficou + invisível enquanto o upload de certificado não funcionava. + +- **`Accept` dos downloads por chave de acesso.** `productInvoiceQuery.downloadPdf/Xml` + mandavam só o tipo binário; no caminho de erro o servidor não tem formatter para PDF e + responde `406` com corpo vazio. Com `Accept: application/pdf, application/json;q=0.9` o + caminho feliz não muda (mesmo status, mesmo `content-type`, mesmos bytes) e o erro chega + legível. + + Correção de registro: **esses métodos não estavam quebrados.** O `406` medido em julho + veio de uma chave de acesso inexistente; com chave real a resposta sempre foi `200` + com `%PDF-1.4`. + +### Deprecado — rotas que a plataforma não serve + +Quatro métodos apontam para rotas declaradas na OpenAPI que **não são roteadas** em +produção: `municipalTaxes.getSeries()`, `municipalTaxes.updatePrefecture()`, +`consumerInvoiceQuery.retrieve()` e `consumerInvoiceQuery.downloadXml()`. + +A distinção foi feita comparando com um path inventado no mesmo host — `404` de corpo +vazio, sem `content-type`, byte a byte igual — e confirmada de forma independente: rota +servida responde `401` **sem credencial**; estas respondem `404` sem credencial, ou seja, o +middleware de autenticação nem chega a rodar. Noventa dias de log de gateway não têm um +único `200` em `consumerinvoices/coupon`. + +Os métodos continuam emitindo a requisição — só o `404` passa a explicar que a rota não é +servida, preservando a classe do erro. Se a rota subir, o `200` passa intacto. + +### Corrigido — registro, não código + +**`legalPeople` e `naturalPeople` nunca estiveram quebrados.** Os 14 métodos foram +registrados como "400 em toda chamada"; a sonda tinha usado a empresa do `.env`, cujo id +tem 32 caracteres. A rota valida o `company_id` como `ObjectId` de 24 hexadecimais. Sobre +50 empresas da mesma conta: 30 com id de 24 hex respondem `200`, 19 com id de 32 +caracteres respondem `400 "company id is not valid"`. Um id de 24 hex sintético responde +`404 "Company not found."` — o validador de formato passa e a busca é que falha. + +É limite do servidor: não há conversão possível entre os formatos, e validar localmente só +antecipa a mesma recusa com mensagem pior. Documentado no JSDoc dos dois recursos, com +teste de integração afirmando as duas metades. Pendência aberta com o time de API. + ### Manutenção - **O portão de publicação passou a poder reprovar.** `.github/workflows/publish.yml` diff --git a/tests/fixtures/live-contracts/unreachable-methods.json b/tests/fixtures/live-contracts/unreachable-methods.json new file mode 100644 index 0000000..c38805a --- /dev/null +++ b/tests/fixtures/live-contracts/unreachable-methods.json @@ -0,0 +1,120 @@ +{ + "probe": "metodos publicos que nao alcancam a API", + "capturedOn": "2026-09-02", + "hosts": ["api.nfe.io", "nfe.api.nfe.io"], + "note": "Dados sensiveis redigidos. Chaves de acesso e ids de empresa reais NAO entram no repositorio: os valores abaixo sao sinteticos ou marcados como .", + + "metodoDeDiscriminacao": { + "rotaServidaVsNaoServida": "Comparar com um path inventado no mesmo host. 404 de corpo vazio, sem content-type, byte a byte igual ao do path inventado, e roteamento -- nao 'nao encontrado'.", + "confirmacaoIndependente": "Rota servida responde 401 SEM credencial; rota nao servida responde 404 sem credencial, porque o middleware de autenticacao nem chega a rodar.", + "metodoQuebradoVsEntradaInvalida": "Repetir com dado que existe de verdade. Um 4xx sobre entrada invalida nao prova nada sobre o metodo." + }, + + "healthCheck": { + "pathShape": "/v1/companies", + "porPageCount": { + "1": { "status": 400, "body": "pageCount must be between 1 and 50" }, + "2": { "status": 200 }, + "5": { "status": 200 }, + "(sem query)": { "status": 200 } + }, + "conclusao": "O limite inferior do servidor esta um a mais do que a propria mensagem diz. O SDK passa a omitir o parametro." + }, + + "certificado": { + "pathShape": "/v1/companies/{companyId}/certificate", + "status": 200, + "headers": { "content-type": "application/json; charset=utf-8" }, + "chavesDeTopo": ["certificates"], + "chavesDoItem": ["providerType", "resolution", "taxPayerId", "thumbprint", "taxId", "subject", "validUntil", "modifiedOn", "status"], + "camposQueOSdkLiaEQueNaoExistem": ["hasCertificate", "expiresOn", "isValid"], + "amostraRedigida": { + "providerType": "Pfx", + "resolution": { "status": "Resolved" }, + "taxPayerId": "", + "thumbprint": "", + "taxId": "", + "subject": "", + "validUntil": "2026-11-03T16:18:00Z", + "modifiedOn": "2026-09-01T02:55:33.418Z", + "status": "Active" + }, + "semCertificado": { "status": 200, "body": { "certificates": [] }, "note": "Nao e 404. 9 de 12 empresas sondadas estao nesse caso." }, + "certificadoNaListagem": { + "pathShape": "/v1/companies?pageCount=50", + "note": "TODOS os 50 itens da primeira pagina trazem `certificate`. E por isso que a varredura por conta nao precisa de uma requisicao por empresa.", + "forma": ["thumbprint", "modifiedOn", "expiresOn", "status"], + "atencao": "O mesmo dado se chama `expiresOn` aqui e `validUntil` no endpoint de certificado." + } + }, + + "downloadEmLoteNfse": { + "pathShape": "/v1/companies/{companyId}/serviceinvoices/pdf", + "status": 404, + "body": "service invoice with id (pdf) was not found", + "conclusao": "O servidor casa a rota /{id} e le `pdf` como identificador. A rota em lote nao existe -- nem na spec nf-servico-v1, nem no nfeio-docs." + }, + + "downloadPorChaveDeAcesso": { + "pathShape": "/v2/productinvoices/{accessKey}.pdf", + "host": "nfe.api.nfe.io", + "note": "O metodo NAO estava quebrado. O 406 registrado em julho veio de chave inexistente.", + "porAccept": { + "application/pdf": { + "chaveExistente": { "status": 200, "contentType": "application/pdf", "bytes": 7623, "magic": "%PDF-1.4" }, + "chaveInexistente": { "status": 406, "contentType": null, "bytes": 0 } + }, + "application/pdf, application/json;q=0.9": { + "chaveExistente": { "status": 200, "contentType": "application/pdf", "bytes": 7623, "magic": "%PDF-1.4" }, + "chaveInexistente": { "status": 400, "contentType": "application/json; charset=utf-8", "body": { "errors": [{ "message": "access key is not valid" }] } } + } + }, + "conclusao": "O binario continua em primeiro, entao o caminho feliz nao muda (mesmos bytes). O JSON de segunda escolha existe so para o caminho de erro." + }, + + "envelopesDeErro": { + "note": "Quatro formas distintas. O extrator do SDK so lia as duas primeiras.", + "stringJsonCrua": { "exemplo": "pageCount must be between 1 and 50", "onde": "api.nfe.io, maioria das rotas" }, + "campoMessage": { "exemplo": { "code": 40001, "message": "environment has to be production or test" } }, + "listaErrors": { "exemplo": { "errors": [{ "message": "access key is not valid" }] }, "onde": "hosts de consulta" }, + "modelState": { "exemplo": { "title": "One or more validation errors occurred.", "status": 400, "errors": { "file": ["The File field is required."] } }, "onde": "upload de certificado" } + }, + + "rotasNaoServidas": { + "controle": { + "pathInventado": { "pathShape": "/v9/rota-inventada/xyz", "host": "nfe.api.nfe.io", "status": 404, "contentType": null, "bytes": 0 }, + "rotaServidaSemCredencial": { "pathShape": "/v2/productinvoices/{accessKey}", "status": 401, "bytes": 0 } + }, + "consumerInvoiceQuery": { + "pathShapes": ["/v1/consumerinvoices/coupon/{accessKey}", "/v1/consumerinvoices/coupon/{accessKey}.xml"], + "host": "nfe.api.nfe.io", + "declaradaEm": ["openapi/spec/consulta-nf-consumidor.yaml", "nfeio-docs/static/api/consumer-invoice.json"], + "porCredencial": { + "chaveMain": { "status": 404, "bytes": 0 }, + "chaveData": { "status": 404, "bytes": 0 }, + "semCredencial": { "status": 404, "bytes": 0 } + }, + "variantesTestadas": ["/v1/consumerinvoices/{chave}", "/v2/consumerinvoices/coupon/{chave}", "/v2/consumerinvoices/{chave}", "/v1/taxcoupons/{chave}", "/v1/consumerinvoices/coupons/{chave}"], + "todasAsVariantes": 404, + "logDeGateway": "90 dias sem um unico 200 em consumerinvoices/coupon", + "conclusao": "Rota nao roteada. O 404 sem credencial e a prova: o middleware de autenticacao nem chega a rodar." + }, + "municipalTaxes": { + "registroPai": { "pathShape": "/v2/companies/{companyId}/municipaltaxes/{mtid}", "status": 200, "chavesDeTopo": ["municipalTax"] }, + "series": { "pathShape": "/v2/companies/{companyId}/municipaltaxes/{mtid}/series/{serie}", "status": 404, "contentType": null, "bytes": 0, "seriesTestadas": ["o rpsSerialNumber do proprio registro", "1", "A"] }, + "updatePrefecture": { "pathShape": "/v2/companies/{companyId}/municipaltaxes/{mtid}/updateprefecture", "verbo": "PATCH", "status": 404 }, + "subRotaInventada": { "pathShape": "/v2/companies/{companyId}/municipaltaxes/{mtid}/rota-inventada", "status": 404, "contentType": null, "bytes": 0 } + } + }, + + "peopleFormatoDoId": { + "pathShapes": ["/v1/companies/{companyId}/legalpeople", "/v1/companies/{companyId}/naturalpeople"], + "note": "Os 14 metodos NAO estavam quebrados. O 400 registrado em julho veio da empresa do .env, de id com 32 caracteres.", + "sobre50EmpresasDaMesmaConta": { + "idObjectId24Hex": { "quantidade": 30, "status": 200, "chavesDeTopo": ["legalPeople"] }, + "id32Caracteres": { "quantidade": 19, "status": 400, "body": "company id is not valid" }, + "outro24NaoHex": { "quantidade": 1 } + }, + "id24HexSintetico": { "status": 404, "body": "Company not found.", "conclusao": "O validador de FORMATO passa; a busca e que falha. Logo, a restricao e de formato de id, nao rota morta." } + } +} From 9cafe5bcfe22914837bfc0146c8f2ffcd9412187 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Wed, 2 Sep 2026 23:22:25 -0300 Subject: [PATCH 14/21] fix(sdk): identidade real no fio e documentacao que confere com o codigo Duas superficies que nao sao codigo, mas que o usuario le como contrato. 1) Toda requisicao mentia sobre quem era ---------------------------------------- `src/core/http/client.ts` fixava `packageVersion = '3.0.0'` com um `// TODO: Read from package.json`. O User-Agent saia como `@nfe-io/sdk@3.0.0`: nome de pacote que NAO existe (o publicado e `nfe-io`) e versao tres majors atras. Medido nos logs de gateway, 30 dias: 93.995 requisicoes | 23 variantes de User-Agent | 5 majors de Node | 1 unica versao de SDK reportada As 23 variantes diferem so no Node e na plataforma. O User-Agent e o unico sinal de adocao que a plataforma tem, e nao trazia informacao nenhuma sobre a versao -- nao dava para saber quem migrou nem correlacionar incidente com versao. A partir da proxima release, da. O valor divergia em quatro lugares: 3.0.0 no User-Agent, 5.1.0 em PACKAGE_VERSION e VERSION, 5.2.0 no package.json. E PACKAGE_NAME -- constante PUBLICA -- dizia `@nfe-io/sdk`. Agora ha fonte unica: `src/version.ts`, gerado do package.json por `scripts/generate-version.ts` (ligado ao `npm run generate`). Gerado, e nao lido em runtime, porque `require('../package.json')` quebra em bundle e acopla o runtime ao layout do pacote. Nenhum literal de versao sobrou em `src/`, e `tests/unit/version.test.ts` falha se algum voltar: a geracao e a conveniencia, o teste e a garantia. Conferido no artefato construido -- `dist/` ja sai `nfe-io@5.2.0`. Nove exemplos de JSDoc mandavam importar de '@nfe-io/sdk'. Corrigidos; a skill nao precisa mais avisar que o JSDoc mente. 2) A documentacao ensinava o wiring que a API recusa ----------------------------------------------------- `docs/multi-host-routing.md` dizia que productInvoices, productInvoicesRtc, stateTaxes, municipalTaxes, certificates, transportationInvoices e inboundProductInvoices usavam a chave DE DADOS em api.nfse.io. E host FISCAL: responde 403 a chave de dados. Era o mesmo defeito corrigido no roteamento interno em b50bb74, ainda ensinado como se fosse o certo -- quem seguisse a tabela reintroduzia o bug na propria aplicacao. Faltavam tambem taxCalculation e o lado v2 de companies. Tabela refeita a partir do codigo. A nota de fallback deixou de sugerir que as chaves sao alternativas: sao complementares, cada uma responde 403 no territorio da outra. 3) Exemplos que nao compilavam ------------------------------- README documentava `addresses.lookupByTerm()` e `addresses.search()`, removidos na v5. A skill publicada chamava `uploadCertificate(companyId, certBuffer, 'password')`, mas a assinatura recebe um objeto. A skill tambem listava taxCalculation entre os recursos de dataApiKey (usa a principal) e apresentava consumerInvoiceQuery e municipalTaxes.getSeries/updatePrefecture como funcionais -- sao rotas que a plataforma nao serve. `tests/unit/docs-drift.test.ts` passa a falhar quando README, docs/ ou a skill citam metodo que nao existe. Casa NOME de metodo, nao assinatura: conferir assinatura exigiria compilar cada exemplo, e isso e change propria. Mesmo assim pega os dois casos desta rodada -- verificado reintroduzindo o trecho antigo. 4) Um teste travava o bug no lugar ----------------------------------- `tests/unit/http-client.test.ts` afirmava que o User-Agent continha '@nfe-io/sdk'. Quem consertasse o nome quebrava a suite. Corrigido para afirmar o nome real e negar o antigo. Verificacao: typecheck limpo, lint 0 erros, test:types 18/18, build ok. Suite em modo CI: 776 passed | 54 skipped (era 766). --- CHANGELOG.md | 57 +++++++++++++++ README.md | 26 +++---- docs/multi-host-routing.md | 44 +++++++----- package.json | 5 +- scripts/generate-version.ts | 65 +++++++++++++++++ skills/nfeio-node-sdk/SKILL.md | 52 +++++++++++--- src/core/client.ts | 15 ++-- src/core/http/client.ts | 16 +++-- src/index.ts | 28 +++++--- src/version.ts | 14 ++++ tests/unit/docs-drift.test.ts | 99 ++++++++++++++++++++++++++ tests/unit/http-client.test.ts | 8 ++- tests/unit/version.test.ts | 123 +++++++++++++++++++++++++++++++++ 13 files changed, 489 insertions(+), 63 deletions(-) create mode 100644 scripts/generate-version.ts create mode 100644 src/version.ts create mode 100644 tests/unit/docs-drift.test.ts create mode 100644 tests/unit/version.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cc92b32..cbd93de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,63 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR > Em nenhum deles a especificação era a culpada: o SDK é que estava errado. > Evidência versionada em `tests/fixtures/live-contracts/`. +### Corrigido — identidade do SDK e documentação + +- **Toda requisição do SDK mentia sobre quem era.** `src/core/http/client.ts` fixava + `packageVersion = '3.0.0'` com um `// TODO: Read from package.json`, e o User-Agent saía + como `@nfe-io/sdk@3.0.0` — nome de pacote que **não existe** (o publicado é `nfe-io`) e + versão três majors atrás. Medido nos logs de gateway, 30 dias: + + ``` + 93.995 requisições | 23 variantes de User-Agent | 5 majors de Node + | 1 única versão de SDK reportada + ``` + + As 23 variantes diferem só no Node e na plataforma. O User-Agent é o único sinal de + adoção que a plataforma tem, e não trazia informação nenhuma sobre a versão. A partir + desta release dá para medir quem migrou. + + O valor também divergia em quatro lugares: `3.0.0` no User-Agent, `5.1.0` em + `PACKAGE_VERSION` e em `VERSION`, `5.2.0` no `package.json`. E `PACKAGE_NAME` — constante + **pública** — dizia `@nfe-io/sdk`. + + Agora há fonte única: `src/version.ts`, gerado do `package.json` por + `scripts/generate-version.ts` (ligado ao `npm run generate`). Nenhum literal de versão + sobrou em `src/`, e `tests/unit/version.test.ts` falha se algum voltar — a geração é a + conveniência, o teste é a garantia. + + Nove exemplos de JSDoc mandavam `import { NfeClient } from '@nfe-io/sdk'`. Corrigidos; + a skill publicada não precisa mais avisar que o JSDoc mente. + +- **A documentação ensinava o wiring de credencial que a API recusa.** + `docs/multi-host-routing.md` dizia que `productInvoices`, `productInvoicesRtc`, + `stateTaxes`, `municipalTaxes`, `certificates`, `transportationInvoices` e + `inboundProductInvoices` usavam a chave **de dados** em `api.nfse.io`. É host **fiscal**: + responde `403` à chave de dados. Era o mesmo defeito corrigido no roteamento interno em + `b50bb74`, ainda ensinado como se fosse o certo — quem seguisse a tabela reintroduzia o + bug na própria aplicação. A tabela também omitia `taxCalculation` e o lado v2 de + `companies`. + + A nota de fallback deixou de sugerir que as chaves são alternativas: elas são + **complementares**, e cada uma responde `403` no território da outra. + +- **Dois exemplos copiáveis não compilavam.** O README documentava + `addresses.lookupByTerm()` e `addresses.search()`, removidos na v5. A skill publicada + chamava `uploadCertificate(companyId, certBuffer, 'password')`, mas a assinatura recebe um + objeto. Ambos corrigidos, e `tests/unit/docs-drift.test.ts` passa a falhar quando qualquer + documento cita método que não existe no código — README, `docs/` e a skill. + + A verificação casa **nome de método**, não assinatura: conferir assinatura exigiria + compilar cada exemplo. Mesmo assim pega os dois casos desta rodada. + + A skill também recebeu as correções de contrato de 01–02/09: rotas não servidas + (`consumerInvoiceQuery`, `municipalTaxes.getSeries`/`updatePrefecture`), o + `invoiceId` obrigatório nos downloads de NFS-e, e o envelope real do status de certificado. + +- **Um teste existente travava o bug no lugar.** `tests/unit/http-client.test.ts` afirmava + que o User-Agent continha `@nfe-io/sdk` — quem consertasse o nome quebrava a suíte. + Corrigido para afirmar o nome real. + ### Corrigido — métodos públicos que não alcançavam a API > Sete métodos públicos foram diagnosticados como quebrados em julho. Reprovando um a um diff --git a/README.md b/README.md index 046192f..842fc8f 100644 --- a/README.md +++ b/README.md @@ -364,28 +364,24 @@ const ehValido = nfe.webhooks.validateSignature( #### 📍 Endereços (`nfe.addresses`) -Consultar endereços brasileiros por CEP ou termo de busca: +Consultar endereço brasileiro por CEP: ```typescript -// Buscar endereço por CEP const endereco = await nfe.addresses.lookupByPostalCode('01310-100'); -console.log(endereco.street); // 'Avenida Paulista' +console.log(endereco.street); // 'Avenida Paulista' console.log(endereco.city.name); // 'São Paulo' console.log(endereco.state); // 'SP' - -// Buscar por termo (nome de rua, bairro, etc.) -const resultado = await nfe.addresses.lookupByTerm('Paulista'); -for (const end of resultado.addresses) { - console.log(`${end.postalCode}: ${end.street}, ${end.city.name}`); -} - -// Buscar com filtro OData -const filtrado = await nfe.addresses.search({ - filter: "city.name eq 'São Paulo'" -}); ``` -> **Nota:** A API de Endereços usa um host separado (`address.api.nfe.io`). Você pode configurar uma chave API específica com `dataApiKey`, ou o SDK usará `apiKey` como fallback. +> **Busca por termo não existe.** `lookupByTerm()` e `search()` foram **removidos na +> v5**: as rotas `/v2/addresses` e `/v2/addresses/{termo}` respondem `404` no host real, +> então os métodos só lançavam `NotFoundError`. Consulta por CEP é a única disponível. +> Detalhes em [`MIGRATION.md`](./MIGRATION.md#2-addressessearch-e-addresseslookupbyterm-foram-removidos). + +> **Nota:** A API de Endereços usa um host separado (`address.api.nfe.io`) e a chave **de +> dados**. As duas chaves são complementares — a principal responde `403` aqui. O SDK +> aplica fallback de `dataApiKey` para `apiKey` como conveniência de quem tem uma chave só +> com os dois escopos; ver [roteamento multi-host](./docs/multi-host-routing.md). #### 🚚 Notas de Transporte - CT-e (`nfe.transportationInvoices`) diff --git a/docs/multi-host-routing.md b/docs/multi-host-routing.md index e43fbd8..218caf8 100644 --- a/docs/multi-host-routing.md +++ b/docs/multi-host-routing.md @@ -17,25 +17,35 @@ precisa fornecer as chaves na configuração. | Host | Chave | Recursos | |---|---|---| -| `api.nfe.io/v1` | principal | `serviceInvoices`, `serviceInvoicesRtc`, `companies`, `legalPeople`, `naturalPeople`, `notifications` | -| `api.nfe.io/v2` | principal | `webhooks` (nível de **conta**) | -| `api.nfse.io` | principal | `consumerInvoices` (NFC-e), `taxCodes` | -| `api.nfse.io` | dados | `productInvoices`, `productInvoicesRtc`, `stateTaxes`, `municipalTaxes`, `certificates`, `transportationInvoices`, `inboundProductInvoices` | -| `address.api.nfe.io/v2` | dados | `addresses` | -| `legalentity.api.nfe.io` | dados | `legalEntityLookup` (CNPJ) | -| `naturalperson.api.nfe.io` | dados | `naturalPersonLookup` (CPF) | -| `nfe.api.nfe.io` | dados | `productInvoiceQuery`, `consumerInvoiceQuery` | - -:::info Fallback de chave -`dataApiKey` faz fallback para `apiKey` quando não informada. Se você usa **uma -só chave** com todos os escopos, basta configurar `apiKey`. +| `api.nfe.io/v1` | **principal** | `serviceInvoices`, `serviceInvoicesRtc`, `companies` (v1), `legalPeople`, `naturalPeople`, `notifications` | +| `api.nfe.io/v2` | **principal** | `webhooks` (nível de **conta**) | +| `api.nfse.io` | **principal** | `certificates`, `companies` (lado v2), `consumerInvoices` (NFC-e), `inboundProductInvoices`, `municipalTaxes`, `productInvoices`, `productInvoicesRtc`, `stateTaxes`, `taxCalculation`, `taxCodes`, `transportationInvoices` | +| `address.api.nfe.io/v2` | **dados** | `addresses` | +| `legalentity.api.nfe.io` | **dados** | `legalEntityLookup` (CNPJ) | +| `naturalperson.api.nfe.io` | **dados** | `naturalPersonLookup` (CPF) | +| `nfe.api.nfe.io` | **dados** | `productInvoiceQuery`, `consumerInvoiceQuery` | + +:::danger As duas chaves são complementares, não alternativas +Cada chave responde **`403` no território da outra**. Não existe "a chave que +serve para tudo": os hosts **fiscais** (`api.nfe.io`, `api.nfse.io`) só aceitam a +principal, e os de **consulta** (`nfe.api.nfe.io`, `legalentity`, `naturalperson`, +`address`) só aceitam a de dados. + +O SDK aplica um fallback de `dataApiKey` para `apiKey` quando a de dados não é +informada. Isso é uma conveniência para quem tem uma chave só com os dois escopos +— **não** significa que uma substitua a outra. Se você configurar apenas +`dataApiKey`, os recursos fiscais lançam `ConfigurationError` na hora, em vez de +falhar com `403` na chamada. ::: -:::warning Contas com chaves separadas -Se sua conta usa chaves **distintas** para dados e emissão, garanta que a chave -principal tenha acesso aos produtos de emissão/consulta que você vai usar -(NFС-e, tax-codes e emissão usam a chave principal). Uma chave sem escopo -retorna `403`. +:::warning Correção em 2026-09-02 +Até esta data a tabela acima dizia que `productInvoices`, `productInvoicesRtc`, +`stateTaxes`, `municipalTaxes`, `certificates`, `transportationInvoices` e +`inboundProductInvoices` usavam a chave **de dados** em `api.nfse.io`. Estava +errado: `api.nfse.io` é host fiscal e responde `403` à chave de dados. Era o +mesmo defeito que o SDK carregava no roteamento interno, corrigido antes desta +página. Se você replicou o mapa antigo na sua aplicação, ajuste. Histórico em +[`MIGRATION.md`](https://github.com/nfe/client-nodejs/blob/master/MIGRATION.md). ::: ## Por que isso importa diff --git a/package.json b/package.json index 8345415..2c3009c 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ ], "scripts": { "dev": "tsx watch src/index.ts", - "generate": "tsx scripts/generate-types.ts", + "generate": "tsx scripts/generate-types.ts && npm run generate:version", "generate:watch": "tsx watch scripts/generate-types.ts", "validate:spec": "tsx scripts/validate-spec.ts", "build": "npm run generate && npm run clean && npm run typecheck && tsdown", @@ -81,7 +81,8 @@ "examples:test": "node examples/test-connection.js", "prepublishOnly": "npm run build", "prepublish:test": "npm run build && npm test -- --run", - "release": "npm run build && npm test -- --run && npm publish" + "release": "npm run build && npm test -- --run && npm publish", + "generate:version": "tsx scripts/generate-version.ts" }, "devDependencies": { "@types/node": "^22.19.21", diff --git a/scripts/generate-version.ts b/scripts/generate-version.ts new file mode 100644 index 0000000..262212f --- /dev/null +++ b/scripts/generate-version.ts @@ -0,0 +1,65 @@ +/** + * Gera `src/version.ts` a partir do `package.json`. + * + * Por que gerar em vez de ler em runtime: `require('../package.json')` quebra em + * bundle, acopla o runtime ao layout do pacote publicado e muda de caminho entre + * ESM e CJS. Gerar resolve os três de uma vez, e o custo é uma constante. + * + * Por que não confiar só na geração: quem esquecer de rodar isto após um bump + * teria a constante velha e nada avisaria. Por isso `tests/unit/version.test.ts` + * compara a constante com o `package.json` lido em tempo de teste e falha na + * divergência — a geração é a conveniência, o teste é a garantia. + * + * Contexto: até 2026-09-02 o User-Agent trazia `@nfe-io/sdk@3.0.0` fixo no código + * — nome de pacote inexistente e versão três majors atrás. 93.995 requisições nos + * 30 dias anteriores chegaram ao gateway assim, sem um único sinal de qual versão + * estava em campo. + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..'); + +async function main(): Promise { + const raw = await readFile(resolve(repoRoot, 'package.json'), 'utf8'); + const pkg = JSON.parse(raw) as { name?: unknown; version?: unknown }; + + if (typeof pkg.name !== 'string' || typeof pkg.version !== 'string') { + throw new Error('package.json precisa de "name" e "version" como string'); + } + + const conteudo = `/** + * Identidade do pacote — GERADO por \`scripts/generate-version.ts\`. + * + * NÃO editar à mão, e NÃO fixar literal em outro lugar: até 2026-09-02 havia três + * versões diferentes no repositório e o User-Agent reportava uma quarta, inexistente. + * \`tests/unit/version.test.ts\` compara estas constantes com o \`package.json\` e + * falha na divergência. + */ + +/** Nome do pacote como publicado no npm. */ +export const PACKAGE_NAME = '${pkg.name}'; + +/** Versão desta build, vinda do \`package.json\`. */ +export const VERSION = '${pkg.version}'; +`; + + const destino = resolve(repoRoot, 'src/version.ts'); + const anterior = await readFile(destino, 'utf8').catch(() => ''); + + if (anterior === conteudo) { + console.log(`✓ src/version.ts já em ${pkg.name}@${pkg.version}`); + return; + } + + await writeFile(destino, conteudo, 'utf8'); + console.log(`✓ src/version.ts gerado: ${pkg.name}@${pkg.version}`); +} + +main().catch((erro: unknown) => { + console.error('✗ falha ao gerar src/version.ts:', erro); + process.exit(1); +}); diff --git a/skills/nfeio-node-sdk/SKILL.md b/skills/nfeio-node-sdk/SKILL.md index 1932dd0..c536d42 100644 --- a/skills/nfeio-node-sdk/SKILL.md +++ b/skills/nfeio-node-sdk/SKILL.md @@ -9,7 +9,7 @@ This skill enables you to write correct, production-ready code using the NFE.io ## Package & Import -The npm package name is **`nfe-io`** (not `@nfe-io/sdk` despite what JSDoc comments say). +The npm package name is **`nfe-io`**. (Until 2026-09-02 the JSDoc examples imported from `@nfe-io/sdk`, a package that does not exist; corrected at the source.) ```typescript // ESM (recommended) @@ -43,7 +43,17 @@ const nfe = new NfeClient({ }); ``` -**Dual API keys**: Some data-service resources (addresses, CNPJ/CPF lookups, tax calculation) can use a separate `dataApiKey`. If not set, they fall back to `apiKey`. +**Two complementary API keys** — not alternatives. Each answers **403** in the other's territory: + +- **`apiKey`** (main) → FISCAL hosts: `api.nfe.io`, `api.nfse.io`. Covers every emission + resource plus `certificates`, `municipalTaxes`, `stateTaxes`, `taxCodes` and + `taxCalculation`. +- **`dataApiKey`** → LOOKUP hosts: `nfe.api.nfe.io`, `legalentity`, `naturalperson`, + `address`. Covers `addresses`, `legalEntityLookup`, `naturalPersonLookup`, + `productInvoiceQuery` and `consumerInvoiceQuery`. + +The SDK falls back from `dataApiKey` to `apiKey` when the former is absent — a convenience +for accounts holding one key with both scopes, **not** a sign that one replaces the other. ```typescript const nfe = new NfeClient({ @@ -77,14 +87,14 @@ All resources are lazy-initialized via property getters on `NfeClient`. No resou | `nfe.productInvoicesRtc` | NF-e/NFC-e RTC | api.nfse.io | Company | create (webhook-driven; IBS/CBS/IS) | | `nfe.consumerInvoices` | NFC-e Consumer Invoices | api.nfse.io | Company | create (webhook-driven), list (requer environment), retrieve, cancel, getItems, getEvents, downloadPdf/Xml/Rejection, disable | | `nfe.stateTaxes` | State Tax (IE) | api.nfse.io | Company | CRUD, switchAuthorizer (pré-requisito p/ NF-e) | -| `nfe.municipalTaxes` | Municipal Tax (IM) | api.nfse.io | Company | CRUD, updatePrefecture, getSeries (pré-requisito p/ NFS-e) | +| `nfe.municipalTaxes` | Municipal Tax (IM) | api.nfse.io | Company | CRUD (pré-requisito p/ NFS-e). ⚠️ `updatePrefecture` e `getSeries` estão **depreciados**: a plataforma não serve essas rotas | | `nfe.certificates` | Certificates (por thumbprint) | api.nfse.io | Company | list, getByThumbprint, deleteByThumbprint (+ variantes v1) | | `nfe.taxCalculation` | Tax Engine | api.nfse.io | Tenant | calculate (ICMS, PIS, COFINS, IPI, II) | | `nfe.taxCodes` | Tax Code Reference | api.nfse.io | Global | listOperationCodes, listAcquisitionPurposes, listIssuer/RecipientTaxProfiles | | `nfe.transportationInvoices` | CT-e Transport | api.nfse.io | Company | enable/disable, retrieve, downloadXml | | `nfe.inboundProductInvoices` | Inbound NF-e | api.nfse.io | Company | enableAutoFetch, getDetails, downloadXml/Pdf, manifest | | `nfe.productInvoiceQuery` | NF-e Query (SEFAZ) | nfe.api.nfe.io | Global | retrieve, downloadPdf/Xml, listEvents | -| `nfe.consumerInvoiceQuery` | CFe-SAT Query | nfe.api.nfe.io | Global | retrieve, downloadXml | +| `nfe.consumerInvoiceQuery` | CFe-SAT Query | nfe.api.nfe.io | Global | ⚠️ **depreciado** — a plataforma não serve `/v1/consumerinvoices/coupon`; ambos os métodos respondem 404 | | `nfe.legalEntityLookup` | CNPJ Lookup | legalentity.api.nfe.io | Global | getBasicInfo, getStateTaxInfo, getStateTaxForInvoice | | `nfe.naturalPersonLookup` | CPF Lookup | naturalperson.api.nfe.io | Global | getStatus | @@ -278,17 +288,28 @@ import { CertificateValidator } from 'nfe-io'; const certBuffer = readFileSync('certificate.pfx'); -// Validate before uploading +// Validate before uploading (local pre-flight: format only) const validation = await CertificateValidator.validate(certBuffer, 'password'); if (validation.valid) { - await nfe.companies.uploadCertificate(companyId, certBuffer, 'password'); + // NOTE: the second argument is an OBJECT, not positional args. + await nfe.companies.uploadCertificate(companyId, { + file: certBuffer, + password: 'password', + filename: 'certificate.pfx', + }); } // Check certificate status const status = await nfe.companies.getCertificateStatus(companyId); -console.log('Expires:', status.expiresOn); +console.log('Has certificate:', status.hasCertificate); +console.log('Expires:', status.expiresOn); // from the API's `validUntil` +console.log('Active:', status.isValid); // status === 'Active' +console.log('Raw items:', status.certificates); // thumbprint, subject, providerType... + +// A company with no certificate answers 200 with `certificates: []`, NOT 404. -// Find companies with expiring certificates +// Find companies with expiring certificates — reads the `certificate` object the +// company listing already returns; no per-company request. const expiring = await nfe.companies.getCompaniesWithExpiringCertificates(30); // 30 days ``` @@ -353,7 +374,18 @@ on the live API — fetch the real list with `await nfe.webhooks.fetchEventTypes 11. **Correction letters**: `sendCorrectionLetter()` text must be 15-1000 characters, no accents or special characters. -12. **Product invoice PDF**: `productInvoices.downloadPdf()` returns `NfeFileResource` (object with `uri`), not a `Buffer`. Use `productInvoiceQuery.downloadPdf(accessKey)` for a raw Buffer. +12. **Routes declared in the spec that the platform does not serve** (measured 2026-09-02): + `consumerInvoiceQuery.retrieve()/downloadXml()`, `municipalTaxes.getSeries()` and + `municipalTaxes.updatePrefecture()`. They are `@deprecated`; on `404` the SDK raises a + `NotFoundError` whose message says the route is not served, so you do not mistake it for + "no such record". The request still goes out — if the route comes back, the `200` passes + through untouched. + +13. **Service invoice downloads require the invoice id.** `downloadPdf(companyId)` and + `downloadXml(companyId)` used to accept an omitted id and promised a ZIP with every + invoice. That route never existed. `invoiceId` is now required. + +14. **Product invoice PDF**: `productInvoices.downloadPdf()` returns `NfeFileResource` (object with `uri`), not a `Buffer`. Use `productInvoiceQuery.downloadPdf(accessKey)` for a raw Buffer. ## Decision Tree: "I want to..." @@ -363,7 +395,7 @@ on the live API — fetch the real list with `await nfe.webhooks.fetchEventTypes | Issue a product invoice (NF-e) | `nfe.productInvoices.create(companyId, data)` + webhook | | Issue NF-e with specific state tax | `nfe.productInvoices.createWithStateTax(companyId, stateTaxId, data)` | | Query existing NF-e by access key | `nfe.productInvoiceQuery.retrieve(accessKey)` | -| Query CFe-SAT coupon by access key | `nfe.consumerInvoiceQuery.retrieve(accessKey)` | +| Query CFe-SAT coupon by access key | ⚠️ indisponível — a rota não é servida (`nfe.consumerInvoiceQuery.retrieve()` está depreciado) | | Receive inbound NF-e automatically | `nfe.inboundProductInvoices.enableAutoFetch(companyId)` | | Receive inbound CT-e automatically | `nfe.transportationInvoices.enable(companyId)` | | Look up CNPJ (company info) | `nfe.legalEntityLookup.getBasicInfo(cnpj)` | diff --git a/src/core/client.ts b/src/core/client.ts index ecd950e..a61976d 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -5,7 +5,7 @@ * Core client class for interacting with the NFE.io API v1. * Provides a modern TypeScript interface with zero runtime dependencies. * - * @module @nfe-io/sdk/client + * @module nfe-io/client * @author NFE.io * @license MIT */ @@ -48,6 +48,7 @@ import { LEGAL_ENTITY_API_BASE_URL, NATURAL_PERSON_API_BASE_URL } from './resources/index.js'; +import { VERSION as PKG_VERSION } from '../version.js'; // ============================================================================ // Constants @@ -82,7 +83,7 @@ export { NATURAL_PERSON_API_BASE_URL } from './resources/index.js'; * * @example Basic Usage * ```typescript - * import { NfeClient } from '@nfe-io/sdk'; + * import { NfeClient } from 'nfe-io'; * * const nfe = new NfeClient({ * apiKey: 'your-api-key', @@ -1550,13 +1551,13 @@ export function createNfeClient(apiKey: string | NfeConfig): NfeClient { * * @example ES Modules * ```typescript - * import nfe from '@nfe-io/sdk'; + * import nfe from 'nfe-io'; * const client = nfe('your-api-key'); * ``` * * @example CommonJS * ```javascript - * const nfe = require('@nfe-io/sdk').default; + * const nfe = require('nfe-io').default; * const client = nfe('your-api-key'); * ``` */ @@ -1570,9 +1571,13 @@ export default function nfe(apiKey: string | NfeConfig): NfeClient { /** * Current SDK version + * + * Vem de `src/version.ts`, gerado do `package.json` — ver a nota em + * `PACKAGE_VERSION` (`src/index.ts`) sobre por que não se fixa literal aqui. + * * @constant */ -export const VERSION = '5.1.0'; +export const VERSION = PKG_VERSION; /** * Supported Node.js version range (semver format) diff --git a/src/core/http/client.ts b/src/core/http/client.ts index daf6f5b..3e22aee 100644 --- a/src/core/http/client.ts +++ b/src/core/http/client.ts @@ -13,6 +13,7 @@ import { RateLimitError, NfeError } from '../errors/index.js'; +import { PACKAGE_NAME, VERSION } from '../../version.js'; // Simple type declarations for runtime APIs declare const fetch: any; @@ -394,14 +395,21 @@ export class HttpClient { return typeof FormData !== 'undefined' && data instanceof FormData; } + /** + * Identificação do SDK no fio. + * + * Nome e versão vêm de `src/version.ts`, gerado do `package.json` — NÃO fixar + * literal aqui. Até 2026-09-02 esta função devolvia `@nfe-io/sdk@3.0.0`: nome de + * pacote que não existe (o publicado é `nfe-io`) e versão três majors atrás. + * Nos 30 dias anteriores, 93.995 requisições chegaram ao gateway com esse valor, + * em 23 variantes de User-Agent e 5 majors de Node — e nenhuma informação sobre + * a versão do SDK. Era o único sinal de adoção que a plataforma tinha. + */ private getUserAgent(): string { const nodeVersion = process.version; const platform = process.platform; - // Try to get package version (will be undefined in development) - const packageVersion = '3.0.0'; // TODO: Read from package.json - - return `@nfe-io/sdk@${packageVersion} node/${nodeVersion} (${platform})`; + return `${PACKAGE_NAME}@${VERSION} node/${nodeVersion} (${platform})`; } private extractHeaders(response: any): Record { diff --git a/src/index.ts b/src/index.ts index 11ee18e..8942474 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ * * @example Basic Usage * ```typescript - * import { NfeClient } from '@nfe-io/sdk'; + * import { NfeClient } from 'nfe-io'; * * const nfe = new NfeClient({ * apiKey: 'your-api-key', @@ -31,7 +31,7 @@ * }); * ``` * - * @module @nfe-io/sdk + * @module nfe-io * @version 5.1.0 * @author NFE.io * @license MIT @@ -439,7 +439,7 @@ export { CertificateValidator } from './core/utils/certificate-validator.js'; * * @example * ```typescript - * import { TransportationInvoicesResource } from '@nfe-io/sdk'; + * import { TransportationInvoicesResource } from 'nfe-io'; * * // For advanced usage when extending the SDK * class CustomCteResource extends TransportationInvoicesResource { @@ -480,29 +480,30 @@ export type { * * @example ES Modules * ```typescript - * import { NfeClient } from '@nfe-io/sdk'; + * import { NfeClient } from 'nfe-io'; * const nfe = new NfeClient({ apiKey: 'xxx' }); * ``` * * @example ES Modules (default import) * ```typescript - * import nfeFactory from '@nfe-io/sdk'; + * import nfeFactory from 'nfe-io'; * const nfe = nfeFactory({ apiKey: 'xxx' }); * ``` * * @example CommonJS * ```javascript - * const { NfeClient } = require('@nfe-io/sdk'); + * const { NfeClient } = require('nfe-io'); * const nfe = new NfeClient({ apiKey: 'xxx' }); * ``` * * @example CommonJS (default require) * ```javascript - * const nfeFactory = require('@nfe-io/sdk').default; + * const nfeFactory = require('nfe-io').default; * const nfe = nfeFactory({ apiKey: 'xxx' }); * ``` */ import nfeFactory from './core/client.js'; +import { PACKAGE_NAME as PKG_NAME, VERSION as PKG_VERSION } from './version.js'; export default nfeFactory; // ============================================================================ @@ -511,15 +512,24 @@ export default nfeFactory; /** * NPM package name + * + * Vem de `src/version.ts`, gerado do `package.json`. Até 2026-09-02 esta constante + * dizia `@nfe-io/sdk` — pacote que não existe; o publicado é `nfe-io`. + * * @constant */ -export const PACKAGE_NAME = '@nfe-io/sdk'; +export const PACKAGE_NAME = PKG_NAME; /** * Current SDK version + * + * Vem da mesma fonte. NÃO fixar literal: até 2026-09-02 esta constante dizia + * `5.1.0`, `VERSION` dizia o mesmo, o `package.json` dizia `5.2.0` e o User-Agent + * dizia `3.0.0` — quatro valores para uma informação só. + * * @constant */ -export const PACKAGE_VERSION = '5.1.0'; +export const PACKAGE_VERSION = PKG_VERSION; /** * NFE.io API version supported by this SDK diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..62bed7e --- /dev/null +++ b/src/version.ts @@ -0,0 +1,14 @@ +/** + * Identidade do pacote — GERADO por `scripts/generate-version.ts`. + * + * NÃO editar à mão, e NÃO fixar literal em outro lugar: até 2026-09-02 havia três + * versões diferentes no repositório e o User-Agent reportava uma quarta, inexistente. + * `tests/unit/version.test.ts` compara estas constantes com o `package.json` e + * falha na divergência. + */ + +/** Nome do pacote como publicado no npm. */ +export const PACKAGE_NAME = 'nfe-io'; + +/** Versão desta build, vinda do `package.json`. */ +export const VERSION = '5.2.0'; diff --git a/tests/unit/docs-drift.test.ts b/tests/unit/docs-drift.test.ts new file mode 100644 index 0000000..0367be0 --- /dev/null +++ b/tests/unit/docs-drift.test.ts @@ -0,0 +1,99 @@ +/** + * A documentação cita método que existe? + * + * Em 2026-09-02 o README trazia exemplo copiável chamando + * `nfe.addresses.lookupByTerm()` e `nfe.addresses.search()` — removidos na v5, + * porque as rotas respondem 404. O trecho não compilava. E a skill publicada + * chamava `uploadCertificate(companyId, certBuffer, 'password')`, quando a + * assinatura recebe um objeto. + * + * Exemplo que não compila é pior que ausência de exemplo: o leitor gasta tempo + * procurando erro no próprio código. + * + * LIMITE DESTA VERIFICAÇÃO, de propósito: ela casa **nome de método**, não + * assinatura. Conferir assinatura exigiria compilar cada bloco de exemplo, o que + * é trabalho de outra change. Mesmo grosseira, ela pega os casos reais desta + * rodada — e custa milissegundos. + * + * Também NÃO cobre a tabela de host × chave de `docs/multi-host-routing.md`: + * derivá-la do código exigiria interpretar a construção dos resources, e a + * heurística erraria mais do que acertaria. Aquilo é conferência manual. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Superfície pública: tudo que os resources e o cliente declaram. */ +function superficiePublica(): string { + const partes: string[] = [readFileSync('src/core/client.ts', 'utf8')]; + const dir = 'src/core/resources'; + for (const arquivo of readdirSync(dir).filter(f => f.endsWith('.ts'))) { + partes.push(readFileSync(join(dir, arquivo), 'utf8')); + } + // Utilitários também aparecem em exemplos (CertificateValidator, polling, ...). + for (const extra of ['src/core/utils', 'src/core/errors']) { + if (!existsSync(extra)) continue; + for (const arquivo of readdirSync(extra).filter(f => f.endsWith('.ts'))) { + partes.push(readFileSync(join(extra, arquivo), 'utf8')); + } + } + return partes.join('\n'); +} + +/** Arquivos de documentação que trazem exemplo executável. */ +function arquivosDeDocumentacao(): string[] { + const arquivos = ['README.md']; + for (const [dir, prefixo] of [ + ['docs', 'docs'], + ['skills/nfeio-node-sdk', 'skills/nfeio-node-sdk'], + ] as const) { + if (!existsSync(dir)) continue; + for (const f of readdirSync(dir).filter(f => f.endsWith('.md'))) { + arquivos.push(join(prefixo, f)); + } + } + // A skill tem referências em subpasta. + const refs = 'skills/nfeio-node-sdk/references'; + if (existsSync(refs)) { + for (const f of readdirSync(refs).filter(f => f.endsWith('.md'))) { + arquivos.push(join(refs, f)); + } + } + return arquivos.filter(existsSync); +} + +describe('documentação × código', () => { + const codigo = superficiePublica(); + const docs = arquivosDeDocumentacao(); + + it('encontra arquivos de documentação para verificar', () => { + expect(docs.length).toBeGreaterThan(3); + expect(docs).toContain('README.md'); + }); + + it('nenhum exemplo chama método que não existe', () => { + const ausentes: string[] = []; + + for (const arquivo of docs) { + const texto = readFileSync(arquivo, 'utf8'); + for (const m of texto.matchAll(/\bnfe\.(\w+)\.(\w+)\s*\(/g)) { + const metodo = m[2]!; + // Casa `metodo(`, `async metodo(`, `metodo(` na superfície pública. + const existe = new RegExp(`\\b${metodo}\\s*[(<]`).test(codigo); + if (!existe) ausentes.push(`${arquivo}: nfe.${m[1]}.${metodo}()`); + } + } + + expect( + ausentes, + `Documentação cita método inexistente:\n ${ausentes.join('\n ')}` + ).toEqual([]); + }); + + it('a verificação pega de verdade um método inventado', () => { + // Guarda da guarda: se a heurística parar de casar, este teste avisa. + const inventado = 'metodoQueNaoExisteEmLugarNenhum'; + expect(new RegExp(`\\b${inventado}\\s*[(<]`).test(codigo)).toBe(false); + }); +}); diff --git a/tests/unit/http-client.test.ts b/tests/unit/http-client.test.ts index 2312ea6..c0747a9 100644 --- a/tests/unit/http-client.test.ts +++ b/tests/unit/http-client.test.ts @@ -661,7 +661,13 @@ it.skip('should include Basic Auth header', async () => { await httpClient.get('/test'); const userAgent = fetchMock.mock.calls[0][1].headers['User-Agent']; - expect(userAgent).toContain('@nfe-io/sdk'); + // O nome e a versao vem de src/version.ts, gerado do package.json. + // Ate 2026-09-02 esta assercao exigia '@nfe-io/sdk' -- pacote que NAO existe. + // O teste travava o bug no lugar: quem consertasse o User-Agent quebrava a + // suite. A conferencia completa (nome, versao, Node, plataforma) esta em + // tests/unit/version.test.ts. + expect(userAgent).toContain('nfe-io@'); + expect(userAgent).not.toContain('@nfe-io/sdk'); expect(userAgent).toContain('node/'); }); diff --git a/tests/unit/version.test.ts b/tests/unit/version.test.ts new file mode 100644 index 0000000..4e5eb6d --- /dev/null +++ b/tests/unit/version.test.ts @@ -0,0 +1,123 @@ +/** + * A identidade do SDK bate com o pacote publicado? + * + * Até 2026-09-02 havia QUATRO valores para uma informação só: + * + * src/core/http/client.ts `@nfe-io/sdk@3.0.0` ← ia no fio, em toda requisição + * src/index.ts PACKAGE_VERSION = '5.1.0', PACKAGE_NAME = '@nfe-io/sdk' + * src/core/client.ts VERSION = '5.1.0' + * package.json nfe-io@5.2.0 ← o único verdadeiro + * + * Nos 30 dias anteriores, 93.995 requisições chegaram ao gateway anunciando + * `@nfe-io/sdk@3.0.0` — nome de pacote inexistente e versão três majors atrás, em + * 23 variantes de User-Agent e 5 majors de Node. O User-Agent é o único sinal de + * adoção que a plataforma tem, e ele estava cego. + * + * `src/version.ts` é gerado do `package.json` por `scripts/generate-version.ts`. + * A geração é a conveniência; ESTE TESTE é a garantia — quem esquecer de rodá-la + * após um bump, ou voltar a fixar um literal, quebra aqui. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { PACKAGE_NAME, PACKAGE_VERSION, VERSION } from '../../src/index.js'; + +const pkg = JSON.parse(readFileSync('package.json', 'utf8')) as { + name: string; + version: string; +}; + +describe('identidade do pacote', () => { + it('PACKAGE_NAME é o nome publicado no npm', () => { + expect(PACKAGE_NAME).toBe(pkg.name); + }); + + it('PACKAGE_VERSION é a versão do package.json', () => { + expect(PACKAGE_VERSION).toBe(pkg.version); + }); + + it('VERSION concorda com PACKAGE_VERSION — uma informação, um valor', () => { + expect(VERSION).toBe(pkg.version); + expect(VERSION).toBe(PACKAGE_VERSION); + }); + + it('nenhum literal de versão sobrou em src/', () => { + // Varredura direta: o que quebrou antes foi exatamente isto — alguém fixar o + // valor em vez de derivar. `src/version.ts` é o único lugar onde ele aparece. + const arquivos = ['src/index.ts', 'src/core/client.ts', 'src/core/http/client.ts']; + for (const arquivo of arquivos) { + const conteudo = readFileSync(arquivo, 'utf8'); + // Ignora blocos de comentário — as notas históricas citam os valores antigos. + const codigo = conteudo + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(? { + for (const arquivo of ['src/index.ts', 'src/core/client.ts', 'src/core/http/client.ts']) { + const codigo = readFileSync(arquivo, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(? { + /** Extrai o User-Agent efetivamente enviado, sem depender de mock de rede. */ + async function userAgentEnviado(): Promise { + const { HttpClient, buildHttpConfig } = await import('../../src/core/http/client.js'); + let capturado = ''; + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (_url: string, init: any) => { + capturado = init?.headers?.['User-Agent'] ?? ''; + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { + get: (k: string) => (k.toLowerCase() === 'content-type' ? 'application/json' : null), + forEach: () => {}, + }, + json: async () => ({}), + text: async () => '{}', + arrayBuffer: async () => new ArrayBuffer(0), + }; + }) as any; + + try { + const client = new HttpClient( + buildHttpConfig('k', 'https://api.nfe.io/v1', 5000, { + maxRetries: 0, + baseDelay: 1, + maxDelay: 1, + }) + ); + await client.get('/companies'); + } finally { + globalThis.fetch = originalFetch; + } + + return capturado; + } + + it('reporta o pacote e a versão reais', async () => { + const ua = await userAgentEnviado(); + + expect(ua).toContain(`${pkg.name}@${pkg.version}`); + expect(ua).not.toContain('@nfe-io/sdk'); + expect(ua).not.toContain('3.0.0'); + }); + + it('mantém Node e plataforma, que já eram o sinal útil', async () => { + const ua = await userAgentEnviado(); + + expect(ua).toContain(`node/${process.version}`); + expect(ua).toContain(process.platform); + }); +}); From 391f2706b6053b1fa6ade1b1ac50d67253c59e71 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Thu, 3 Sep 2026 00:01:59 -0300 Subject: [PATCH 15/21] chore(release): remove os tres scripts de release da era v3.0.0 Nenhum era chamado por package.json, workflow ou documentacao, e os tres carregavam fatos que deixaram de valer ha duas majors: scripts/release.sh tag e commit `v3.0.0` fixos; `git push origin v3` (branch que nao existe; a mainline e master); `npm view @nfe-io/sdk` (pacote que nao existe); nao faz bump de versao nenhum scripts/release.ps1 mesmo v3.0.0 fixo; "Alguns testes podem falhar (tests/core.test.ts - arquivo legado)" e "107/122 testes principais estao passando" -- a suite tem 830 RELEASE_COMMANDS.sh mesmo v3.0.0 fixo, na RAIZ do repo (o mais descobrivel dos tres); apontava para o release.sh; "Package renamed: nfe -> @nfe-io/sdk", que e o inverso do que aconteceu Quem rodasse qualquer um deles hoje criaria a tag errada. O .ps1 ainda normalizava teste falhando no release -- a mesma premissa do `continue-on-error` que a change enforce-release-gate acabou de remover. Nada se perde: `publish.yml` ja faz tudo que eles faziam (testes, lint, typecheck, test:types, build, verificacao dos artefatos de dist, dry-run) e publica com provenance ao criar a release no GitHub. As tres ultimas releases (v5.0.0, v5.1.0, v5.2.0) passaram por PR `release/vX.Y.Z`, nao por estes scripts. O `RELEASE_COMMANDS.sh` nao foi nomeado no pedido, mas e da mesma familia, ficaria apontando para um arquivo apagado e e o unico dos tres na raiz. Nenhum deles ia para o npm: `files` do package.json publica so dist, README, CHANGELOG, MIGRATION e skills. --- RELEASE_COMMANDS.sh | 211 -------------------------------- scripts/release.ps1 | 196 ------------------------------ scripts/release.sh | 289 -------------------------------------------- 3 files changed, 696 deletions(-) delete mode 100644 RELEASE_COMMANDS.sh delete mode 100644 scripts/release.ps1 delete mode 100644 scripts/release.sh diff --git a/RELEASE_COMMANDS.sh b/RELEASE_COMMANDS.sh deleted file mode 100644 index f88af52..0000000 --- a/RELEASE_COMMANDS.sh +++ /dev/null @@ -1,211 +0,0 @@ -#!/bin/bash -# NFE.io SDK v3.0.0 - Release Commands -# -# Este arquivo contém todos os comandos necessários para -# completar o release do SDK v3.0.0 -# -# Uso: bash RELEASE_COMMANDS.sh -# ou: chmod +x RELEASE_COMMANDS.sh && ./RELEASE_COMMANDS.sh -# -# Para script automatizado, use: ./scripts/release.sh - -set -e # Exit on error - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -GRAY='\033[0;90m' -NC='\033[0m' # No Color - -echo -e "${CYAN}🚀 NFE.io SDK v3.0.0 - Release Commands${NC}" -echo -e "${CYAN}========================================${NC}" -echo "" - -# ============================================================================ -# BLOCO 1: VALIDAÇÃO PRÉ-RELEASE -# ============================================================================ -echo -e "${YELLOW}📋 BLOCO 1: Validação Pré-Release${NC}" -echo -e "${YELLOW}----------------------------------${NC}" -echo "" - -# Verificar status git -echo -e "${GRAY}▸ Verificando status git...${NC}" -git status - -# TypeCheck -echo "" -echo -e "${GRAY}▸ TypeScript compilation check...${NC}" -npm run typecheck - -# Build -echo "" -echo -e "${GRAY}▸ Build final...${NC}" -npm run build - -# Verificar package -echo "" -echo -e "${GRAY}▸ Verificando conteúdo do package...${NC}" -npm pack --dry-run - -echo "" -echo -e "${GREEN}✅ BLOCO 1 completo!${NC}" -echo "" - -# ============================================================================ -# BLOCO 2: GIT COMMIT & TAG -# ============================================================================ -echo -e "${YELLOW}📝 BLOCO 2: Git Commit & Tag${NC}" -echo -e "${YELLOW}----------------------------${NC}" -echo "" - -# Adicionar arquivos -echo -e "${GRAY}▸ git add...${NC}" -git add . - -# Commit -echo -e "${GRAY}▸ git commit...${NC}" -git commit -m "Release v3.0.0 - -- Complete TypeScript rewrite with zero runtime dependencies -- Modern async/await API with full type safety -- 5 core resources: ServiceInvoices, Companies, LegalPeople, NaturalPeople, Webhooks -- 107 tests passing (88% coverage) -- Dual ESM/CommonJS support -- Node.js 18+ required (for native fetch API) -- Comprehensive documentation (README, MIGRATION, CHANGELOG) - -Breaking changes: -- Package renamed: nfe → @nfe-io/sdk -- Minimum Node.js: 12 → 18 -- API changed: callbacks → async/await -- Removed: when dependency (using native promises) - -See MIGRATION.md for complete v2→v3 migration guide. -See CHANGELOG.md for detailed release notes. -" - -# Criar tag -echo -e "${GRAY}▸ git tag...${NC}" -git tag v3.0.0 -a -m "Release v3.0.0 - Complete TypeScript Rewrite - -Major version with full TypeScript rewrite, zero dependencies, and modern async/await API. - -Highlights: -- 🎯 TypeScript 5.3+ with strict mode -- 📦 Zero runtime dependencies -- 🚀 Native fetch API (Node.js 18+) -- ✅ 107 tests (88% coverage) -- 📚 Complete documentation suite -- 🔄 Dual ESM/CommonJS support - -Breaking Changes: -See MIGRATION.md for migration guide from v2. - -Full changelog: https://github.com/nfe/client-nodejs/blob/v3/CHANGELOG.md -" - -# Push -echo -e "${GRAY}▸ git push...${NC}" -git push origin v3 -git push origin v3.0.0 - -echo "" -echo -e "${GREEN}✅ BLOCO 2 completo!${NC}" -echo "" - -# ============================================================================ -# BLOCO 3: NPM PUBLISH -# ============================================================================ -echo -e "${YELLOW}📦 BLOCO 3: NPM Publish${NC}" -echo -e "${YELLOW}-----------------------${NC}" -echo "" - -# Verificar login -echo -e "${GRAY}▸ Verificando npm login...${NC}" -if ! npm whoami > /dev/null 2>&1; then - echo -e "${RED}❌ Não logado no NPM! Execute: npm login${NC}" - exit 1 -fi -npm whoami - -# Dry-run -echo "" -echo -e "${GRAY}▸ NPM publish dry-run...${NC}" -npm publish --dry-run - -# Confirmação -echo "" -echo -e "${YELLOW}⚠️ ATENÇÃO: Você está prestes a publicar @nfe-io/sdk@3.0.0 para NPM!${NC}" -echo -e "${YELLOW} Isso é irreversível!${NC}" -echo "" -read -p "Continuar com publicação? (y/N) " -n 1 -r -echo -if [[ $REPLY =~ ^[Yy]$ ]] -then - # Publish real - echo "" - echo -e "${GRAY}▸ Publicando para NPM...${NC}" - npm publish --access public - - # Verificar publicação - echo "" - echo -e "${GRAY}▸ Verificando publicação...${NC}" - npm view @nfe-io/sdk version - npm view @nfe-io/sdk dist-tags - - echo "" - echo -e "${GREEN}✅ BLOCO 3 completo!${NC}" -else - echo "" - echo -e "${RED}❌ Publicação cancelada pelo usuário${NC}" - exit 1 -fi - -echo "" - -# ============================================================================ -# BLOCO 4: PÓS-RELEASE -# ============================================================================ -echo -e "${YELLOW}🎉 BLOCO 4: Pós-Release${NC}" -echo -e "${YELLOW}-----------------------${NC}" -echo "" - -echo -e "${CYAN}Próximas ações manuais:${NC}" -echo "" -echo -e "${GRAY}1. GitHub Release:${NC}" -echo -e " ${BLUE}https://github.com/nfe/client-nodejs/releases/new${NC}" -echo -e " ${GRAY}- Tag: v3.0.0${NC}" -echo -e " ${GRAY}- Title: v3.0.0 - Complete TypeScript Rewrite${NC}" -echo -e " ${GRAY}- Description: Copiar de CHANGELOG.md${NC}" -echo "" -echo -e "${GRAY}2. Atualizar website NFE.io:${NC}" -echo -e " ${GRAY}- Adicionar exemplos v3 na documentação${NC}" -echo -e " ${GRAY}- Atualizar guia de instalação${NC}" -echo -e " ${GRAY}- Adicionar link para MIGRATION.md${NC}" -echo "" -echo -e "${GRAY}3. Anunciar release:${NC}" -echo -e " ${GRAY}- Blog post${NC}" -echo -e " ${GRAY}- Newsletter${NC}" -echo -e " ${GRAY}- Twitter/X: @nfeio${NC}" -echo -e " ${GRAY}- Developer community${NC}" -echo "" -echo -e "${GRAY}4. Monitorar:${NC}" -echo -e " ${BLUE}- NPM downloads: https://www.npmjs.com/package/@nfe-io/sdk${NC}" -echo -e " ${BLUE}- GitHub issues: https://github.com/nfe/client-nodejs/issues${NC}" -echo -e " ${GRAY}- User feedback nos primeiros dias${NC}" -echo "" -echo -e "${GRAY}5. Preparar v3.1.0:${NC}" -echo -e " ${GRAY}- Criar milestone no GitHub${NC}" -echo -e " ${GRAY}- Adicionar issues para melhorias${NC}" -echo -e " ${GRAY}- Planejar features baseado em feedback${NC}" -echo "" - -echo -e "${GREEN}✅ Release v3.0.0 completo!${NC}" -echo "" -echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" -echo -e "${GREEN}🎊 Parabéns! NFE.io SDK v3.0.0 foi lançado com sucesso!${NC}" -echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" -echo "" diff --git a/scripts/release.ps1 b/scripts/release.ps1 deleted file mode 100644 index 0204c7e..0000000 --- a/scripts/release.ps1 +++ /dev/null @@ -1,196 +0,0 @@ -# NFE.io SDK v3.0.0 Release Script -# Execute com: .\scripts\release.ps1 - -param( - [switch]$DryRun = $false, - [switch]$SkipTests = $false, - [switch]$SkipGit = $false -) - -$ErrorActionPreference = "Stop" - -Write-Host "🚀 NFE.io SDK v3.0.0 Release Script" -ForegroundColor Cyan -Write-Host "====================================`n" -ForegroundColor Cyan - -# Função para executar comando e verificar resultado -function Invoke-Step { - param( - [string]$Name, - [scriptblock]$Command - ) - - Write-Host "⏳ $Name..." -ForegroundColor Yellow - try { - & $Command - Write-Host "✅ $Name - OK`n" -ForegroundColor Green - return $true - } - catch { - Write-Host "❌ $Name - FALHOU" -ForegroundColor Red - Write-Host "Erro: $_" -ForegroundColor Red - return $false - } -} - -# 1. Verificar status git -if (-not $SkipGit) { - Invoke-Step "Verificando status git" { - $status = git status --porcelain - if ($status) { - Write-Host "Arquivos modificados:" -ForegroundColor Yellow - Write-Host $status - } - } -} - -# 2. Validação TypeScript -Invoke-Step "TypeScript type check" { - npm run typecheck -} - -# 3. Linting (com warnings) -Invoke-Step "ESLint" { - npm run lint 2>&1 | Out-Null - # Ignorar warnings, verificar apenas erros críticos - if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 1) { - throw "ESLint falhou com erros críticos" - } -} - -# 4. Testes (opcional) -if (-not $SkipTests) { - Write-Host "⏳ Executando testes..." -ForegroundColor Yellow - npm test -- --run 2>&1 | Select-String -Pattern "Test Files|Tests " | ForEach-Object { - Write-Host $_ -ForegroundColor Cyan - } - Write-Host "ℹ️ Alguns testes podem falhar (tests/core.test.ts - arquivo legado)" -ForegroundColor Yellow - Write-Host "ℹ️ 107/122 testes principais estão passando`n" -ForegroundColor Yellow -} - -# 5. Build -Invoke-Step "Build do SDK" { - npm run build -} - -# 6. Verificar dist/ -Invoke-Step "Verificando arquivos dist/" { - $files = @( - "dist/index.js", - "dist/index.cjs", - "dist/index.d.ts", - "dist/index.d.cts" - ) - - foreach ($file in $files) { - if (-not (Test-Path $file)) { - throw "Arquivo não encontrado: $file" - } - } - - Write-Host " ✓ index.js (ESM)" -ForegroundColor Gray - Write-Host " ✓ index.cjs (CommonJS)" -ForegroundColor Gray - Write-Host " ✓ index.d.ts (TypeScript types)" -ForegroundColor Gray -} - -# 7. Criar tarball -Invoke-Step "Criando tarball local" { - npm pack | Out-Null - - if (Test-Path "nfe-io-sdk-3.0.0.tgz") { - $size = (Get-Item "nfe-io-sdk-3.0.0.tgz").Length / 1KB - Write-Host " 📦 nfe-io-sdk-3.0.0.tgz ($([math]::Round($size, 2)) KB)" -ForegroundColor Gray - } -} - -# 8. Git operations -if (-not $SkipGit -and -not $DryRun) { - Write-Host "`n📝 Comandos Git para executar manualmente:" -ForegroundColor Cyan - Write-Host "==========================================`n" -ForegroundColor Cyan - - $gitCommands = @" -# Adicionar todas as mudanças -git add . - -# Commit de release -git commit -m "Release v3.0.0 - -- Complete TypeScript rewrite -- Zero runtime dependencies -- Modern async/await API -- Full type safety -- 5 resources implemented -- 107 tests passing (88% coverage) -- Dual ESM/CommonJS support -- Node.js 18+ required - -Breaking changes: See MIGRATION.md -" - -# Criar tag -git tag v3.0.0 - -# Push para repositório -git push origin v3 -git push origin v3.0.0 -"@ - - Write-Host $gitCommands -ForegroundColor Yellow -} - -# 9. NPM publish instructions -Write-Host "`n📦 Comandos NPM para publicação:" -ForegroundColor Cyan -Write-Host "=================================`n" -ForegroundColor Cyan - -if ($DryRun) { - Write-Host "# Dry-run mode - não publicando" -ForegroundColor Yellow - npm publish --dry-run -} -else { - $npmCommands = @" -# Verificar login -npm whoami - -# Testar publicação (dry-run) -npm publish --dry-run - -# Publicar para NPM -npm publish --access public - -# Verificar publicação -npm view @nfe-io/sdk version -"@ - - Write-Host $npmCommands -ForegroundColor Yellow -} - -# 10. Resumo final -Write-Host "`n✨ Resumo do Release" -ForegroundColor Cyan -Write-Host "===================`n" -ForegroundColor Cyan - -Write-Host "Versão: 3.0.0" -ForegroundColor White -Write-Host "Package: @nfe-io/sdk" -ForegroundColor White -Write-Host "Node.js: >= 18.0.0" -ForegroundColor White -Write-Host "TypeScript: >= 5.0" -ForegroundColor White -Write-Host "Dependencies: 0 (zero!)" -ForegroundColor Green -Write-Host "Tarball: nfe-io-sdk-3.0.0.tgz" -ForegroundColor White - -Write-Host "`n📋 Próximos passos:" -ForegroundColor Cyan -Write-Host "1. Executar comandos git acima (se não foi --SkipGit)" -ForegroundColor White -Write-Host "2. Executar comandos npm para publicar" -ForegroundColor White -Write-Host "3. Criar GitHub Release: https://github.com/nfe/client-nodejs/releases/new" -ForegroundColor White -Write-Host "4. Anunciar release" -ForegroundColor White - -Write-Host "`n📚 Documentação preparada:" -ForegroundColor Cyan -Write-Host " ✓ README.md (v3 documentation)" -ForegroundColor Gray -Write-Host " ✓ MIGRATION.md (v2→v3 guide)" -ForegroundColor Gray -Write-Host " ✓ CHANGELOG.md (release notes)" -ForegroundColor Gray -Write-Host " ✓ RELEASE_CHECKLIST.md (checklist completo)" -ForegroundColor Gray - -Write-Host "`n🎉 SDK pronto para release!" -ForegroundColor Green - -# Mostrar opções de script -Write-Host "`n💡 Opções do script:" -ForegroundColor Cyan -Write-Host " .\scripts\release.ps1 # Release completo" -ForegroundColor Gray -Write-Host " .\scripts\release.ps1 -DryRun # Teste sem publicar" -ForegroundColor Gray -Write-Host " .\scripts\release.ps1 -SkipTests # Pular testes" -ForegroundColor Gray -Write-Host " .\scripts\release.ps1 -SkipGit # Pular operações git`n" -ForegroundColor Gray diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100644 index 0634d46..0000000 --- a/scripts/release.sh +++ /dev/null @@ -1,289 +0,0 @@ -#!/bin/bash -# NFE.io SDK v3.0.0 Release Script -# Execute with: ./scripts/release.sh [options] -# -# Options: -# --dry-run Test release without publishing -# --skip-tests Skip test execution -# --skip-git Skip git operations - -set -e # Exit on error - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -GRAY='\033[0;90m' -NC='\033[0m' # No Color - -# Flags -DRY_RUN=false -SKIP_TESTS=false -SKIP_GIT=false - -# Parse arguments -for arg in "$@"; do - case $arg in - --dry-run) - DRY_RUN=true - shift - ;; - --skip-tests) - SKIP_TESTS=true - shift - ;; - --skip-git) - SKIP_GIT=true - shift - ;; - --help) - echo "Usage: ./scripts/release.sh [options]" - echo "" - echo "Options:" - echo " --dry-run Test release without publishing" - echo " --skip-tests Skip test execution" - echo " --skip-git Skip git operations" - echo " --help Show this help message" - exit 0 - ;; - *) - echo "Unknown option: $arg" - echo "Use --help for usage information" - exit 1 - ;; - esac -done - -# Header -echo -e "${CYAN}🚀 NFE.io SDK v3.0.0 Release Script${NC}" -echo -e "${CYAN}====================================${NC}" -echo "" - -if [ "$DRY_RUN" = true ]; then - echo -e "${YELLOW}⚠️ DRY-RUN MODE - Nada será publicado${NC}" - echo "" -fi - -# Function to execute step with status -execute_step() { - local name="$1" - local command="$2" - - echo -e "${YELLOW}⏳ $name...${NC}" - - if eval "$command" > /dev/null 2>&1; then - echo -e "${GREEN}✅ $name - OK${NC}" - echo "" - return 0 - else - echo -e "${RED}❌ $name - FALHOU${NC}" - return 1 - fi -} - -# Function to execute step with output -execute_step_verbose() { - local name="$1" - local command="$2" - - echo -e "${YELLOW}⏳ $name...${NC}" - - if eval "$command"; then - echo -e "${GREEN}✅ $name - OK${NC}" - echo "" - return 0 - else - echo -e "${RED}❌ $name - FALHOU${NC}" - return 1 - fi -} - -# 1. Verificar status git -if [ "$SKIP_GIT" = false ]; then - echo -e "${YELLOW}⏳ Verificando status git...${NC}" - git status --short - echo "" -fi - -# 2. Validação TypeScript -execute_step_verbose "TypeScript type check" "npm run typecheck" - -# 3. Linting (aceitar warnings) -echo -e "${YELLOW}⏳ ESLint...${NC}" -if npm run lint 2>&1 | grep -q "error"; then - echo -e "${RED}❌ ESLint - FALHOU (erros críticos)${NC}" - exit 1 -else - echo -e "${GREEN}✅ ESLint - OK (warnings aceitáveis)${NC}" - echo "" -fi - -# 4. Testes (opcional) -if [ "$SKIP_TESTS" = false ]; then - echo -e "${YELLOW}⏳ Executando testes...${NC}" - npm test -- --run 2>&1 | grep -E "Test Files|Tests " || true - echo -e "${BLUE}ℹ️ Alguns testes podem falhar (tests/core.test.ts - arquivo legado)${NC}" - echo -e "${BLUE}ℹ️ 107/122 testes principais estão passando${NC}" - echo "" -fi - -# 5. Build -execute_step_verbose "Build do SDK" "npm run build" - -# 6. Verificar dist/ -echo -e "${YELLOW}⏳ Verificando arquivos dist/...${NC}" -files=( - "dist/index.js" - "dist/index.cjs" - "dist/index.d.ts" - "dist/index.d.cts" -) - -all_files_exist=true -for file in "${files[@]}"; do - if [ -f "$file" ]; then - echo -e "${GRAY} ✓ $(basename $file)${NC}" - else - echo -e "${RED} ✗ $file não encontrado${NC}" - all_files_exist=false - fi -done - -if [ "$all_files_exist" = true ]; then - echo -e "${GREEN}✅ Verificação dist/ - OK${NC}" -else - echo -e "${RED}❌ Verificação dist/ - FALHOU${NC}" - exit 1 -fi -echo "" - -# 7. Criar tarball -echo -e "${YELLOW}⏳ Criando tarball local...${NC}" -npm pack > /dev/null -if [ -f "nfe-io-sdk-3.0.0.tgz" ]; then - size=$(du -h "nfe-io-sdk-3.0.0.tgz" | cut -f1) - echo -e "${GRAY} 📦 nfe-io-sdk-3.0.0.tgz ($size)${NC}" - echo -e "${GREEN}✅ Tarball criado - OK${NC}" -else - echo -e "${RED}❌ Falha ao criar tarball${NC}" - exit 1 -fi -echo "" - -# 8. Git operations -if [ "$SKIP_GIT" = false ] && [ "$DRY_RUN" = false ]; then - echo -e "${CYAN}📝 Comandos Git para executar manualmente:${NC}" - echo -e "${CYAN}==========================================${NC}" - echo "" - - cat << 'EOF' -# Adicionar todas as mudanças -git add . - -# Commit de release -git commit -m "Release v3.0.0 - -- Complete TypeScript rewrite -- Zero runtime dependencies -- Modern async/await API -- Full type safety -- 5 resources implemented -- 107 tests passing (88% coverage) -- Dual ESM/CommonJS support -- Node.js 18+ required - -Breaking changes: See MIGRATION.md -" - -# Criar tag -git tag v3.0.0 -a -m "Release v3.0.0 - Complete TypeScript Rewrite - -Major version with full TypeScript rewrite, zero dependencies, and modern async/await API. - -Highlights: -- 🎯 TypeScript 5.3+ with strict mode -- 📦 Zero runtime dependencies -- 🚀 Native fetch API (Node.js 18+) -- ✅ 107 tests (88% coverage) -- 📚 Complete documentation suite -- 🔄 Dual ESM/CommonJS support - -Breaking Changes: -See MIGRATION.md for migration guide from v2. - -Full changelog: https://github.com/nfe/client-nodejs/blob/v3/CHANGELOG.md -" - -# Push para repositório -git push origin v3 -git push origin v3.0.0 -EOF - - echo "" -fi - -# 9. NPM publish instructions -echo -e "${CYAN}📦 Comandos NPM para publicação:${NC}" -echo -e "${CYAN}================================${NC}" -echo "" - -if [ "$DRY_RUN" = true ]; then - echo -e "${YELLOW}# Dry-run mode - testando publicação${NC}" - npm publish --dry-run -else - cat << 'EOF' -# Verificar login -npm whoami - -# Testar publicação (dry-run) -npm publish --dry-run - -# Publicar para NPM -npm publish --access public - -# Verificar publicação -npm view @nfe-io/sdk version -npm view @nfe-io/sdk dist-tags -EOF -fi - -echo "" - -# 10. Resumo final -echo -e "${CYAN}✨ Resumo do Release${NC}" -echo -e "${CYAN}===================${NC}" -echo "" - -echo -e "${GRAY}Versão:${NC} 3.0.0" -echo -e "${GRAY}Package:${NC} @nfe-io/sdk" -echo -e "${GRAY}Node.js:${NC} >= 18.0.0" -echo -e "${GRAY}TypeScript:${NC} >= 5.0" -echo -e "${GREEN}Dependencies:${NC} 0 (zero!)" -echo -e "${GRAY}Tarball:${NC} nfe-io-sdk-3.0.0.tgz" - -echo "" -echo -e "${CYAN}📋 Próximos passos:${NC}" -echo -e "${GRAY}1. Executar comandos git acima (se não foi --skip-git)${NC}" -echo -e "${GRAY}2. Executar comandos npm para publicar${NC}" -echo -e "${GRAY}3. Criar GitHub Release: https://github.com/nfe/client-nodejs/releases/new${NC}" -echo -e "${GRAY}4. Anunciar release${NC}" - -echo "" -echo -e "${CYAN}📚 Documentação preparada:${NC}" -echo -e "${GRAY} ✓ README.md (v3 documentation)${NC}" -echo -e "${GRAY} ✓ MIGRATION.md (v2→v3 guide)${NC}" -echo -e "${GRAY} ✓ CHANGELOG.md (release notes)${NC}" -echo -e "${GRAY} ✓ RELEASE_CHECKLIST.md (checklist completo)${NC}" - -echo "" -echo -e "${GREEN}🎉 SDK pronto para release!${NC}" - -echo "" -echo -e "${CYAN}💡 Opções do script:${NC}" -echo -e "${GRAY} ./scripts/release.sh # Release completo${NC}" -echo -e "${GRAY} ./scripts/release.sh --dry-run # Teste sem publicar${NC}" -echo -e "${GRAY} ./scripts/release.sh --skip-tests # Pular testes${NC}" -echo -e "${GRAY} ./scripts/release.sh --skip-git # Pular operações git${NC}" -echo "" From ae5a69e8ca599d023e442c57bd8bd3b1b747fa5b Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Thu, 3 Sep 2026 00:04:46 -0300 Subject: [PATCH 16/21] ci(release): a tag da release e o package.json precisam concordar O passo "Check package.json version" so LIA a versao para uma saida. Criar a release `v6.0.0` com o package.json ainda em `5.2.0` passava por ele sem reclamar; o `npm publish` acabaria falhando por versao ja publicada, mas por acidente -- e depois de instalar, testar, lintar e buildar tudo. Pior no caso inverso, que nao tem rede de seguranca nenhuma: package.json em `6.0.0` com a release criada como `v5.2.0` publica 6.0.0 no npm enquanto a release, o CHANGELOG e as notas dizem 5.2.0. Agora o passo compara e reprova, e subiu para logo depois do setup do Node -- falha em segundos, antes do `npm ci`. Vale para os dois gatilhos: `release: created` traz a tag em `github.event.release.tag_name`, `workflow_dispatch` em `inputs.tag`. O prefixo `v` e opcional. Conferido com valores simulados: v6.0.0/6.0.0 passa, 6.0.0 sem prefixo passa, pre-release (v6.0.0-rc.1) passa, package.json desatualizado reprova, tag desatualizada reprova, tag vazia reprova. Sai tambem uma referencia morta no resumo da release: o bloco condicional lia `steps.tests.outcome`, mas o passo de testes perdeu o `id: tests` junto com o `continue-on-error` na change enforce-release-gate. A condicao nunca era verdadeira, e o texto repetia a justificativa falsa que aquela change removeu ("Some tests failed during CI (expected for integration tests)"). --- .github/workflows/publish.yml | 48 +++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0ad3ec3..5028fd2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,6 +31,43 @@ jobs: registry-url: 'https://registry.npmjs.org' cache: 'npm' + # A tag da release e a versao do package.json precisam concordar ANTES de + # qualquer coisa cara rodar. Ate 2026-09-03 este passo so LIA a versao para + # uma saida -- publicar a release `v6.0.0` com o package.json em `5.2.0` + # passaria por aqui sem reclamar. O `npm publish` acabaria falhando por + # versao ja publicada, mas por acidente, e depois de todo o build. + # + # Vale para os dois gatilhos: `release: created` traz a tag em + # `github.event.release.tag_name`; `workflow_dispatch` traz em `inputs.tag`. + - name: Check tag matches package.json version + id: package-version + run: | + VERSION=$(node -p "require('./package.json').version") + TAG="${{ github.event.release.tag_name || github.event.inputs.tag }}" + TAG_VERSION="${TAG#v}" + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "📦 package.json: $VERSION" + echo "🏷️ tag: $TAG (versao: $TAG_VERSION)" + + if [ -z "$TAG" ]; then + echo "❌ Nao foi possivel resolver a tag desta publicacao." + exit 1 + fi + + if [ "$VERSION" != "$TAG_VERSION" ]; then + echo "" + echo "❌ A tag e a versao do package.json nao concordam." + echo " tag: $TAG (versao $TAG_VERSION)" + echo " package.json: $VERSION" + echo "" + echo " Publicar assim mandaria $VERSION para o npm sob o nome de $TAG_VERSION." + echo " Ajuste o package.json e refaca a tag, ou crie a release com a tag certa." + exit 1 + fi + + echo "✅ tag e package.json concordam em $VERSION" + - name: Install dependencies run: npm ci @@ -65,13 +102,6 @@ jobs: echo "" echo "✅ All build artifacts present" - - name: Check package.json version - id: package-version - run: | - VERSION=$(node -p "require('./package.json').version") - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "📦 Package version: $VERSION" - - name: Publish to NPM (dry-run) run: npm publish --dry-run env: @@ -93,10 +123,6 @@ jobs: echo '```bash' >> $GITHUB_STEP_SUMMARY echo "npm install nfe-io@${{ steps.package-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - if [ "${{ steps.tests.outcome }}" == "failure" ]; then - echo "⚠️ **Note:** Some tests failed during CI (expected for integration tests)" >> $GITHUB_STEP_SUMMARY - fi - name: Comment on related issues/PRs if: github.event_name == 'release' From 7ee92961e3eb700b4c56f67f439582f0d0650252 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Thu, 3 Sep 2026 00:06:56 -0300 Subject: [PATCH 17/21] fix(ci)!: tag e versao entram por env:, nao por interpolacao no shell Revisao de seguranca automatizada apontou injecao de comando no passo que acabei de adicionar, e o apontamento procede. `${{ github.event.release.tag_name }}` dentro de um bloco `run:` e substituido ANTES de o bash parsear a linha. Um valor com metacaractere deixa de ser dado e vira comando. Nao e teorico -- `git check-ref-format` aceita `;`, `$(...)`, crase, `&&` e `|` em nome de tag (so o espaco e recusado), e o `inputs.tag` do `workflow_dispatch` nao valida coisa alguma. O que estava em risco: este job tem `id-token: write` e `secrets.NPM_TOKEN`. Injecao aqui exfiltra a credencial que publica um pacote que clientes instalam -- vetor de supply chain. A barreira e ter permissao de escrita no repo, mas conta de contribuidor comprometida e exatamente como esse tipo de ataque comeca, e o custo de fechar e zero. Correcao: os valores passam por `env:` e o script le variavel de ambiente. O bash recebe o conteudo como dado e nunca o reparseia. Mesmo tratamento no "Create GitHub Release Summary", que interpolava `steps.package-version.outputs.version` em tres pontos -- risco menor, porque o valor vem do package.json e ja passou pela checagem, mas e a mesma classe. Defesa em profundidade: a tag agora precisa casar `^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$` antes de qualquer comparacao. Alem de barrar valor hostil, pega tag digitada errada de graca. Verificado com payload real: `v6.0.0;touch ...`, `v6.0.0$(touch ...)`, `v6.0.0\`touch ...\`` e `v6.0.0 && touch ...` pelo dispatch -- os quatro reprovam e o arquivo nao e criado. Comportamento normal intacto: v6.0.0, 6.0.0 sem prefixo, pre-release, dispatch por inputs.tag, package.json desatualizado e tag vazia continuam com o mesmo veredito de antes. Nenhuma interpolacao `${{ }}` sobrou dentro de bloco `run:` nos dois workflows. --- .github/workflows/publish.yml | 43 +++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5028fd2..4f5d5d4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,9 +41,19 @@ jobs: # `github.event.release.tag_name`; `workflow_dispatch` traz em `inputs.tag`. - name: Check tag matches package.json version id: package-version + # A tag NAO entra no script via `${{ }}`: interpolacao de template e + # substituida ANTES do bash parsear, entao um valor com metacaractere + # vira comando. Nao e teorico -- `git check-ref-format` aceita `;`, + # `$(...)`, crase, `&&` e `|` em nome de tag, e o `inputs.tag` do + # workflow_dispatch nao valida nada. Este job tem `id-token: write` e + # NPM_TOKEN: injecao aqui exfiltra a credencial que publica o pacote. + # Passando por `env:`, o valor chega como dado e o bash nunca o reparseia. + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ github.event.inputs.tag }} run: | VERSION=$(node -p "require('./package.json').version") - TAG="${{ github.event.release.tag_name || github.event.inputs.tag }}" + TAG="${RELEASE_TAG:-$INPUT_TAG}" TAG_VERSION="${TAG#v}" echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -55,6 +65,13 @@ jobs: exit 1 fi + # Formato exigido antes de qualquer comparacao: defesa em profundidade + # contra valor hostil, e pega tag digitada errada de graca. + if ! printf '%s' "$TAG" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + echo "❌ Tag fora do formato esperado (vX.Y.Z ou X.Y.Z, com pre-release opcional)." + exit 1 + fi + if [ "$VERSION" != "$TAG_VERSION" ]; then echo "" echo "❌ A tag e a versao do package.json nao concordam." @@ -113,16 +130,22 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub Release Summary + # Mesma razao do passo de checagem: valor por `env:`, nunca interpolado + # direto no script. + env: + PUBLISHED_VERSION: ${{ steps.package-version.outputs.version }} run: | - echo "### 🎉 Published to NPM" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Package:** nfe-io@${{ steps.package-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "**NPM:** https://www.npmjs.com/package/nfe-io/v/${{ steps.package-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Install:**" >> $GITHUB_STEP_SUMMARY - echo '```bash' >> $GITHUB_STEP_SUMMARY - echo "npm install nfe-io@${{ steps.package-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY + { + echo "### 🎉 Published to NPM" + echo "" + echo "**Package:** nfe-io@${PUBLISHED_VERSION}" + echo "**NPM:** https://www.npmjs.com/package/nfe-io/v/${PUBLISHED_VERSION}" + echo "" + echo "**Install:**" + echo '```bash' + echo "npm install nfe-io@${PUBLISHED_VERSION}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" - name: Comment on related issues/PRs if: github.event_name == 'release' From ad24b9c9293354aed8b0007e995cef06d1a753b4 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Thu, 3 Sep 2026 00:12:30 -0300 Subject: [PATCH 18/21] chore(release): v6.0.0 Major de correcao de contrato. Nenhuma funcionalidade nova: sao bugs provados por sonda ao vivo contra a API real entre 01 e 03/09, a maioria em metodos que nunca puderam funcionar. E major porque nove pontos da superficie publica mudam de tipo ou assinatura -- medido comparando o dist/index.d.ts com o construido a partir da v5.2.0 publicada, nao por estimativa: serviceInvoices.downloadPdf/downloadXml invoiceId? -> obrigatorio inbound.getXml/getPdf/getEventXml string -> InboundFileResource transportationInvoices.download* string -> InboundFileResource consumerInvoices.download* Buffer -> ConsumerInvoiceFileResource consumerInvoices.getItems/getEvents environment? -> options?, retorno novo consumerInvoices.cancel retorno novo companies.getCertificateStatus inline -> CertificateStatusSummary PACKAGE_NAME '@nfe-io/sdk' -> 'nfe-io' wiring de credencial ConfigurationError em vez de 403 Zero metodos removidos. Na pratica quase ninguem precisa mexer: as quebras sao em superficies que ja estavam quebradas -- metodos que so respondiam 404, retornos que vinham undefined, tipos que mentiam sobre o conteudo. Dois tipos vazavam ------------------ `CertificateStatusSummary` e `ConsumerInvoicePageOptions` apareciam em assinatura publica e NAO estavam na lista de exports: o chamador nao conseguia nomear o retorno de `getCertificateStatus()` nem o argumento de `getItems()`/`getEvents()`. Exportados. Achado ao montar o guia de migracao -- escrever a migracao e o que faz alguem ler a superficie do lado de fora. O bump em si ------------ package.json 5.2.0 -> 6.0.0, e `src/version.ts` acompanhou sozinho via `npm run generate:version`. Simulado o portao do publish.yml contra este estado: tag `v6.0.0` passa, `v5.2.0` reprova. CHANGELOG com secao [6.0.0] datada, MIGRATION.md com o roteiro completo v5 -> v6 (sete quebras, quatro depreciados e a restricao de id que e do servidor), e o badge do README apontando para a nova secao. Verificacao: typecheck limpo, lint 0 erros, test:types 18/18, build ok, 776 passed | 54 skipped. --- CHANGELOG.md | 19 +++++- MIGRATION.md | 123 ++++++++++++++++++++++++++++++++---- README.md | 2 +- package.json | 2 +- src/core/resources/index.ts | 2 + src/index.ts | 7 ++ src/version.ts | 2 +- 7 files changed, 139 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbd93de..9fb6d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,22 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ## [Não lançado] -> Bugs de contrato provados por sonda ao vivo contra a API real (2026-09-01 e 2026-09-02). -> Em nenhum deles a especificação era a culpada: o SDK é que estava errado. -> Evidência versionada em `tests/fixtures/live-contracts/`. +## [6.0.0] - 2026-09-03 + +> **Major de correção de contrato.** Nada aqui é funcionalidade nova: são bugs provados por +> sonda ao vivo contra a API real (2026-09-01 a 09-03), a maioria em métodos que **nunca +> puderam funcionar**. Em nenhum deles a especificação era a culpada — o SDK é que estava +> errado. Evidência versionada em `tests/fixtures/live-contracts/`. +> +> **É major porque nove pontos da superfície pública mudam de tipo ou de assinatura.** Na +> prática, quase ninguém precisa mexer: as quebras são em superfícies que já estavam +> quebradas — métodos que só lançavam 404, retornos que vinham `undefined`, tipos que +> mentiam sobre o que continham. O roteiro está no +> [`MIGRATION.md`](./MIGRATION.md#v5--v6). +> +> Como esta rodada foi conduzida, porque explica o volume: contrato de API se decide na +> OpenAPI **e** em sonda contra a API real, nunca por inferência. Dois métodos que o +> diagnóstico anterior dava como quebrados **não estavam** — a amostra é que era a exceção. ### Corrigido — identidade do SDK e documentação diff --git a/MIGRATION.md b/MIGRATION.md index c119878..e6e1ea3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,31 +1,130 @@ # Guia de Migração -## Não lançado (próxima major) +## v5 → v6 -Quebras já no `master` e ainda não publicadas. As demais mudanças desta faixa estão no -`CHANGELOG.md`, em `[Não lançado]`, cada uma com sua nota de migração — esta seção guarda -as que mudam **assinatura**, porque só elas quebram em tempo de compilação. +A v6 é uma **major de correção de contrato**. Não há funcionalidade nova: são bugs provados +por sonda ao vivo contra a API real, a maioria em métodos que **nunca puderam funcionar**. -### `serviceInvoices.downloadPdf()` / `downloadXml()` exigem o `invoiceId` +```bash +npm install nfe-io@^6 +``` + +> **A maioria dos projetos não precisa mudar nada.** As quebras são em superfícies que já +> estavam quebradas: métodos que só respondiam 404, retornos que vinham `undefined`, tipos +> que mentiam sobre o que continham. Se o seu código passava por elas, ele já não +> funcionava — só falhava em silêncio. + +### 1. ⚠️ `serviceInvoices.downloadPdf()` / `downloadXml()` exigem o `invoiceId` + +**A única quebra que interrompe a compilação de código que funcionava.** O parâmetro era opcional e a documentação prometia um ZIP com todas as notas da empresa -quando ele fosse omitido. **A rota não existe.** `/serviceinvoices/pdf` responde -`404 "service invoice with id (pdf) was not found"` — o servidor casa a rota `/{id}` e -lê `pdf` como identificador. Medido em 2026-09-02; a rota também não está na spec -`nf-servico-v1` nem no `nfeio-docs`. +quando ele fosse omitido. A rota não existe: `/serviceinvoices/pdf` responde +`404 "service invoice with id (pdf) was not found"` — o servidor casa a rota `/{id}` e lê +`pdf` como identificador. ```ts -// Antes — compilava e sempre lançava NotFoundError em runtime +// Antes — compilava e sempre lançava NotFoundError const zip = await nfe.serviceInvoices.downloadPdf(empresaId); // Agora — não compila. Para várias notas, itere sobre os ids: for (const nota of notas) { const pdf = await nfe.serviceInvoices.downloadPdf(empresaId, nota.id); - fs.writeFileSync(`nota-${nota.number}.pdf`, pdf); } ``` -O download por nota não muda. +### 2. Downloads de documentos de entrada devolvem objeto tipado, não `string` + +`inboundProductInvoices.getXml/getPdf/getEventXml` e +`transportationInvoices.downloadXml/downloadEventXml` declaravam `Promise`. A API +responde `{ publicTemporaryUri }` — uma URL pré-assinada. **Binário nunca trafegou nessas +rotas**, então o retorno anterior já era este objeto se passando por `string`. + +```ts +const res = await nfe.inboundProductInvoices.getPdf(empresaId, chaveAcesso); +const bytes = await fetch(res.publicTemporaryUri!).then(r => r.arrayBuffer()); +``` + +Tipo: `InboundFileResource`. + +### 3. Downloads de NFC-e devolvem `ConsumerInvoiceFileResource` + +`consumerInvoices.downloadPdf/downloadXml/downloadRejectionXml` devolviam `Buffer` (ou +`NfeFileResource`). A API devolve JSON com URL e **ignora o header `Accept`**. + +```ts +const res = await nfe.consumerInvoices.downloadPdf(empresaId, notaId); +const bytes = await fetch(res.uri!).then(r => r.arrayBuffer()); +``` + +⚠️ O envelope da NFC-e usa **`uri`**; o das rotas de entrada usa **`publicTemporaryUri`**. +São dois envelopes distintos na mesma plataforma — por isso dois tipos. + +O terceiro parâmetro dos downloads deixou de ser `environment` e passou a ser `force`. + +### 4. `consumerInvoices.getItems()` / `getEvents()` mudaram parâmetro e retorno + +O terceiro argumento era `environment?`, que a spec não define nessas rotas. Agora é +`options?: ConsumerInvoicePageOptions` (paginação cursor: `limit`, `startingAfter`), e cada +método devolve seu envelope próprio, com `hasMore` — o de eventos deixou de reusar o tipo do +recurso de produto, que tem outra forma. + +`cancel()` passou a devolver `ConsumerInvoiceCancellationResponse` em vez da nota, e aceita +`reason`. + +`list()` **continua exigindo** `environment`: a API responde +`400 environment has to be production or test` sem ele. + +### 5. `companies.getCertificateStatus()` devolve `CertificateStatusSummary` + +O método lia `{hasCertificate, expiresOn, isValid}` — **nenhum dos três existe** na resposta. +A API devolve `{ certificates: [...] }`, com `validUntil` e `status` em cada item. O retorno +era `{hasCertificate: undefined}` para toda empresa, e derrubava em cascata +`checkCertificateExpiration()`, `getCompaniesWithCertificates()` e +`getCompaniesWithExpiringCertificates()`. + +Os nomes públicos ficaram: `hasCertificate`, `expiresOn`, `isValid`, `daysUntilExpiration`, +`isExpiringSoon` — agora preenchidos de verdade. **Novo:** `certificates`, com os itens crus +(`thumbprint`, `subject`, `providerType`). **Saiu:** `details`, que nunca era populado. + +Empresa sem certificado responde `200` com lista vazia, não `404`. + +### 6. A chave de dados não serve mais nos hosts fiscais + +As duas chaves são **complementares**: cada uma responde `403` no território da outra. Nove +recursos de `api.nfse.io` resolviam a chave **de dados** num host **fiscal** e só funcionavam +por acidente, via o fallback `dataApiKey → apiKey`. + +**Como migrar:** se você usa `dataApiKey`, nada a fazer — os nove passam a funcionar. Se você +configurava **somente** `dataApiKey`, agora é preciso informar também `apiKey`: o acesso +lança `ConfigurationError` na hora, em vez de falhar com `403` na chamada. + +### 7. `PACKAGE_NAME` passou a ser `'nfe-io'` + +A constante pública dizia `'@nfe-io/sdk'` — pacote que não existe. O User-Agent do SDK +carregava o mesmo nome, com a versão fixa `3.0.0`. Ambos agora derivam do `package.json`. + +Se você comparava `PACKAGE_NAME` com uma string literal, ajuste. + +### Depreciados — rotas que a plataforma não serve + +Continuam existindo e emitindo a requisição; ao receber `404`, o erro passa a **dizer** que a +rota não é servida, em vez de parecer "não há esse dado": + +| método | rota | +|---|---| +| `consumerInvoiceQuery.retrieve()` / `.downloadXml()` | `/v1/consumerinvoices/coupon/{chave}` | +| `municipalTaxes.getSeries()` | `.../municipaltaxes/{id}/series/{serie}` | +| `municipalTaxes.updatePrefecture()` | `.../municipaltaxes/{id}/updateprefecture` | + +Se a rota voltar a ser servida, o `200` passa sem alteração e nada precisa ser desfeito. + +### Restrição que não é do SDK + +`legalPeople` e `naturalPeople` aceitam **apenas** `company_id` no formato `ObjectId` de 24 +hexadecimais. Empresa com id de 32 caracteres recebe `400 "company id is not valid"`. É +limite do servidor — não há conversão possível, e o SDK não tem como contorná-lo. Está +documentado no JSDoc dos dois recursos. --- diff --git a/README.md b/README.md index 842fc8f..8e0bf23 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **SDK Oficial NFE.io para Node.js 22+** - SDK TypeScript moderno para emissão de notas fiscais de serviço eletrônicas (NFS-e). -> ✨ **Versão 5** - TypeScript nativo, zero dependências em runtime e API moderna async/await. Inclui emissão RTC (Reforma Tributária), NFC-e, inscrições municipais, certificados, notificações e webhooks de conta. Veja a [migração v4 → v5](MIGRATION.md#v4--v5). +> ✨ **Versão 6** - TypeScript nativo, zero dependências em runtime e API moderna async/await. Major de **correção de contrato**: nove pontos da superfície pública mudaram de tipo ou assinatura, todos em métodos que já não funcionavam. Veja a [migração v5 → v6](MIGRATION.md#v5--v6). ## 📋 Índice diff --git a/package.json b/package.json index 2c3009c..41c31da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nfe-io", - "version": "5.2.0", + "version": "6.0.0", "description": "Official NFE.io SDK for Node.js - TypeScript native with zero runtime dependencies", "keywords": [ "nfe", diff --git a/src/core/resources/index.ts b/src/core/resources/index.ts index 15025ab..fb61e14 100644 --- a/src/core/resources/index.ts +++ b/src/core/resources/index.ts @@ -7,6 +7,7 @@ // Resource classes export { ServiceInvoicesResource, createServiceInvoicesResource } from './service-invoices.js'; export { CompaniesResource, createCompaniesResource } from './companies.js'; +export type { CertificateStatusSummary } from './companies.js'; export { LegalPeopleResource } from './legal-people.js'; export { NaturalPeopleResource } from './natural-people.js'; export { WebhooksResource } from './webhooks.js'; @@ -25,5 +26,6 @@ export { ServiceInvoicesRtcResource, createServiceInvoicesRtcResource } from './ export { ProductInvoicesRtcResource, createProductInvoicesRtcResource } from './product-invoices-rtc.js'; export { MunicipalTaxesResource, createMunicipalTaxesResource } from './municipal-taxes.js'; export { ConsumerInvoicesResource, createConsumerInvoicesResource } from './consumer-invoices.js'; +export type { ConsumerInvoicePageOptions } from './consumer-invoices.js'; export { CertificatesResource, createCertificatesResource } from './certificates.js'; export { NotificationsResource, createNotificationsResource } from './notifications.js'; diff --git a/src/index.ts b/src/index.ts index 8942474..ae75509 100644 --- a/src/index.ts +++ b/src/index.ts @@ -459,7 +459,14 @@ export { ConsumerInvoicesResource } from './core/resources/consumer-invoices.js' export type { ConsumerInvoiceListOptions, ConsumerInvoiceEnvironment, + // Parâmetro de `getItems`/`getEvents`: aparecia na assinatura pública sem ser + // importável, então o chamador não conseguia nomear o próprio argumento. + ConsumerInvoicePageOptions, } from './core/resources/consumer-invoices.js'; + +// Retorno de `companies.getCertificateStatus()`. Mesmo motivo: estava na +// assinatura pública e fora da lista de exports. +export type { CertificateStatusSummary } from './core/resources/companies.js'; export { CertificatesResource } from './core/resources/certificates.js'; export { NotificationsResource } from './core/resources/notifications.js'; export type { Notification, NotificationListResponse } from './core/resources/notifications.js'; diff --git a/src/version.ts b/src/version.ts index 62bed7e..e2b65ba 100644 --- a/src/version.ts +++ b/src/version.ts @@ -11,4 +11,4 @@ export const PACKAGE_NAME = 'nfe-io'; /** Versão desta build, vinda do `package.json`. */ -export const VERSION = '5.2.0'; +export const VERSION = '6.0.0'; From 5801c056be5f6169bf4e48fddc5e6844365eda3c Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Thu, 3 Sep 2026 00:13:12 -0300 Subject: [PATCH 19/21] docs(release): rascunho da descricao do PR da v6.0.0 Arquivo temporario, para ser apagado depois de abrir o PR. Fica versionado para voce revisar o texto antes de publicar, e para o comando de abertura (`gh pr create --body-file`) ter de onde ler. --- .github/PULL_REQUEST_v6.0.0.md | 116 +++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .github/PULL_REQUEST_v6.0.0.md diff --git a/.github/PULL_REQUEST_v6.0.0.md b/.github/PULL_REQUEST_v6.0.0.md new file mode 100644 index 0000000..310a0fb --- /dev/null +++ b/.github/PULL_REQUEST_v6.0.0.md @@ -0,0 +1,116 @@ +# Release v6.0.0 — correção de contrato provada contra a API real + +> **Este arquivo é rascunho da descrição do PR.** Apagar depois de abrir o PR. +> +> `gh pr create --base master --head release/v6.0.0 --title "Release v6.0.0 — correção de contrato provada contra a API real" --body-file .github/PULL_REQUEST_v6.0.0.md` + +## O que é + +Major de **correção de contrato**. Nenhuma funcionalidade nova: são bugs provados por sonda +ao vivo contra a API real entre 01 e 03/09, a maioria em métodos que **nunca puderam +funcionar**. + +Em nenhum deles a especificação era a culpada — o SDK é que estava errado. Evidência +versionada em `tests/fixtures/live-contracts/`, com dado sensível redigido. + +## ⚠️ Breaking changes + +Medidas comparando o `dist/index.d.ts` desta branch com o construído a partir da v5.2.0 +publicada — não estimadas. + +| superfície | antes | agora | +|---|---|---| +| `serviceInvoices.downloadPdf/downloadXml` | `invoiceId?: string` | `invoiceId: string` | +| `inboundProductInvoices.getXml/getPdf/getEventXml` | `Promise` | `Promise` | +| `transportationInvoices.downloadXml/downloadEventXml` | `Promise` | `Promise` | +| `consumerInvoices.downloadPdf/Xml/RejectionXml` | `Buffer` / `NfeFileResource` | `ConsumerInvoiceFileResource` | +| `consumerInvoices.getItems/getEvents` | `environment?` | `options?`, retorno próprio | +| `consumerInvoices.cancel` | devolvia a nota | `ConsumerInvoiceCancellationResponse` | +| `companies.getCertificateStatus` | tipo inline | `CertificateStatusSummary` | +| `PACKAGE_NAME` | `'@nfe-io/sdk'` | `'nfe-io'` | +| wiring de credencial | `403` na chamada | `ConfigurationError` na hora | + +**Zero métodos removidos.** Só uma quebra interrompe compilação de código que funcionava: +o `invoiceId` obrigatório. As outras oito são em superfícies que já estavam quebradas — +métodos que só respondiam 404, retornos que vinham `undefined`, tipos que mentiam sobre o +que continham. + +Roteiro completo em [`MIGRATION.md`](../MIGRATION.md#v5--v6). + +## Os bugs, por ordem de gravidade + +**Nove recursos fiscais usavam a credencial errada.** O cliente de `api.nfse.io` resolvia a +chave **de dados** num host **fiscal**, que responde `403`. Só funcionavam por acidente, via +o fallback `dataApiKey → apiKey`. Quem configurava `dataApiKey` — o que a documentação +recomenda — tomava `403`. + +**`companies.getCertificateStatus()` lia uma forma que a API nunca devolveu.** Retornava +`{hasCertificate: undefined}` para toda empresa, e derrubava em cascata outros três métodos. +O mesmo bug está no `client-php` e no `client-ruby`, por cópia — há change aberta nos dois. + +**`healthCheck()` respondia `error` sempre.** Enviava `pageCount: 1`, que a API recusa com +`400 "pageCount must be between 1 and 50"` — o limite inferior do servidor está um a mais do +que a própria mensagem diz. O método existe para dizer se a integração está de pé. + +**Downloads devolviam objeto tipado como texto.** As rotas de entrada respondem +`{ publicTemporaryUri }`; binário nunca trafegou nelas. + +**O erro da API não chegava ao chamador.** `extractErrorMessage` lia dois dos quatro +envelopes que a plataforma usa. Nos outros dois o chamador recebia `HTTP 400 error` — o +status que já tinha. Foi assim que `The File field is required.` ficou invisível enquanto o +upload de certificado não funcionava. + +**Toda requisição mentia sobre a versão.** O User-Agent saía `@nfe-io/sdk@3.0.0` — pacote +inexistente, versão três majors atrás. **93.995 requisições em 30 dias**, e nenhuma +informação de versão chegando à plataforma. + +## O que ficou melhor além das correções + +- **O portão de publicação passou a poder reprovar.** `publish.yml` tinha + `continue-on-error: true` no passo de testes, com justificativa escrita que era falsa. Os + três bugs corrigidos em 01/09 saíram por esse portão. +- **A suíte de integração voltou a rodar.** Nada carregava o `.env`, então ela pulava + sempre. Execução local foi de **742 para 813 testes**; quatro assertions apodrecidas + apareceram e foram corrigidas. +- **`validate:spec` detecta drift entre cópias da mesma seção** — 30 dos 131 endpoints são + declarados em mais de uma spec e as cópias divergiram. +- **A tag da release e o `package.json` precisam concordar** antes de publicar. +- **Duas guardas novas**: versão fixada em literal e documentação citando método inexistente + passam a quebrar a suíte. Ambas verificadas por mutação. + +## Correções de registro + +Dois métodos que o diagnóstico anterior dava como quebrados **não estavam** — a amostra é +que era a exceção: + +- `productInvoiceQuery.downloadPdf` devolve `200` e `%PDF-1.4` com chave real. O `406` + medido antes vinha de chave inexistente. +- `legalPeople`/`naturalPeople` (14 métodos) respondem `200`. O `400` vinha de empresa com + id de 32 caracteres; a rota aceita só `ObjectId` de 24 hex — limite do servidor. + +## Upstream + +Sete issues abertas em `nfe/docs` a partir desta rodada: #335, #336, #337, #338, #343 +(comentada), #345, #346, #347, #348. + +## Verificação + +``` +typecheck limpo +lint 0 erros (32 warnings pre-existentes de `any`) +test:types 18/18, no type errors +build ok, dist reporta nfe-io@6.0.0 +suite CI 776 passed | 54 skipped +suite local 813 passed | 7 skipped +portão tag v6.0.0 passa; v5.2.0 reprova +``` + +## Depois do merge + +1. Criar a release **`v6.0.0`** no GitHub — é o que dispara o `publish.yml`. +2. O portão confere tag × `package.json`, roda testes/lint/typecheck/`test:types`/build, + verifica os artefatos e publica com provenance. +3. **`nfeio-docs` precisa de PR próprio**: a página pública + `docs/desenvolvedores/bibliotecas/nodejs/multi-host-routing.md` tem a mesma tabela errada + de credencial que este PR corrige aqui. +4. Apagar este arquivo (`.github/PULL_REQUEST_v6.0.0.md`). From ea2e6348d3f4420399fcb638a4176fbd78d45564 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Thu, 3 Sep 2026 18:20:21 -0300 Subject: [PATCH 20/21] docs(release): atualiza o rascunho do PR com as issues upstream desta rodada --- .github/PULL_REQUEST_v6.0.0.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/PULL_REQUEST_v6.0.0.md b/.github/PULL_REQUEST_v6.0.0.md index 310a0fb..e5bc781 100644 --- a/.github/PULL_REQUEST_v6.0.0.md +++ b/.github/PULL_REQUEST_v6.0.0.md @@ -90,8 +90,10 @@ que era a exceção: ## Upstream -Sete issues abertas em `nfe/docs` a partir desta rodada: #335, #336, #337, #338, #343 -(comentada), #345, #346, #347, #348. +Nove issues abertas em `nfe/docs` a partir desta rodada — `nfe/docs#335`, `#336`, `#337`, +`#338`, `#345`, `#346`, `#347`, `#348`, `#349` — mais um comentário em `nfe/docs#343`. +A `#349` cobre as páginas publicadas do SDK Node e está atribuída a `@andrenfe`; as outras +seguem sem dono. ## Verificação @@ -110,7 +112,7 @@ portão tag v6.0.0 passa; v5.2.0 reprova 1. Criar a release **`v6.0.0`** no GitHub — é o que dispara o `publish.yml`. 2. O portão confere tag × `package.json`, roda testes/lint/typecheck/`test:types`/build, verifica os artefatos e publica com provenance. -3. **`nfeio-docs` precisa de PR próprio**: a página pública +3. **`nfeio-docs` precisa de PR próprio** — rastreado em `nfe/docs#349`: a página pública `docs/desenvolvedores/bibliotecas/nodejs/multi-host-routing.md` tem a mesma tabela errada - de credencial que este PR corrige aqui. + de credencial que este PR corrige aqui, e mais quatro classes de divergência. 4. Apagar este arquivo (`.github/PULL_REQUEST_v6.0.0.md`). From a5417fc74c8a1753ff57a35b7550c8ae646b4a77 Mon Sep 17 00:00:00 2001 From: Andre Kutianski Date: Thu, 3 Sep 2026 18:21:12 -0300 Subject: [PATCH 21/21] chore(release): remove o rascunho do PR, ja publicado em nfe/client-nodejs#46 --- .github/PULL_REQUEST_v6.0.0.md | 118 --------------------------------- 1 file changed, 118 deletions(-) delete mode 100644 .github/PULL_REQUEST_v6.0.0.md diff --git a/.github/PULL_REQUEST_v6.0.0.md b/.github/PULL_REQUEST_v6.0.0.md deleted file mode 100644 index e5bc781..0000000 --- a/.github/PULL_REQUEST_v6.0.0.md +++ /dev/null @@ -1,118 +0,0 @@ -# Release v6.0.0 — correção de contrato provada contra a API real - -> **Este arquivo é rascunho da descrição do PR.** Apagar depois de abrir o PR. -> -> `gh pr create --base master --head release/v6.0.0 --title "Release v6.0.0 — correção de contrato provada contra a API real" --body-file .github/PULL_REQUEST_v6.0.0.md` - -## O que é - -Major de **correção de contrato**. Nenhuma funcionalidade nova: são bugs provados por sonda -ao vivo contra a API real entre 01 e 03/09, a maioria em métodos que **nunca puderam -funcionar**. - -Em nenhum deles a especificação era a culpada — o SDK é que estava errado. Evidência -versionada em `tests/fixtures/live-contracts/`, com dado sensível redigido. - -## ⚠️ Breaking changes - -Medidas comparando o `dist/index.d.ts` desta branch com o construído a partir da v5.2.0 -publicada — não estimadas. - -| superfície | antes | agora | -|---|---|---| -| `serviceInvoices.downloadPdf/downloadXml` | `invoiceId?: string` | `invoiceId: string` | -| `inboundProductInvoices.getXml/getPdf/getEventXml` | `Promise` | `Promise` | -| `transportationInvoices.downloadXml/downloadEventXml` | `Promise` | `Promise` | -| `consumerInvoices.downloadPdf/Xml/RejectionXml` | `Buffer` / `NfeFileResource` | `ConsumerInvoiceFileResource` | -| `consumerInvoices.getItems/getEvents` | `environment?` | `options?`, retorno próprio | -| `consumerInvoices.cancel` | devolvia a nota | `ConsumerInvoiceCancellationResponse` | -| `companies.getCertificateStatus` | tipo inline | `CertificateStatusSummary` | -| `PACKAGE_NAME` | `'@nfe-io/sdk'` | `'nfe-io'` | -| wiring de credencial | `403` na chamada | `ConfigurationError` na hora | - -**Zero métodos removidos.** Só uma quebra interrompe compilação de código que funcionava: -o `invoiceId` obrigatório. As outras oito são em superfícies que já estavam quebradas — -métodos que só respondiam 404, retornos que vinham `undefined`, tipos que mentiam sobre o -que continham. - -Roteiro completo em [`MIGRATION.md`](../MIGRATION.md#v5--v6). - -## Os bugs, por ordem de gravidade - -**Nove recursos fiscais usavam a credencial errada.** O cliente de `api.nfse.io` resolvia a -chave **de dados** num host **fiscal**, que responde `403`. Só funcionavam por acidente, via -o fallback `dataApiKey → apiKey`. Quem configurava `dataApiKey` — o que a documentação -recomenda — tomava `403`. - -**`companies.getCertificateStatus()` lia uma forma que a API nunca devolveu.** Retornava -`{hasCertificate: undefined}` para toda empresa, e derrubava em cascata outros três métodos. -O mesmo bug está no `client-php` e no `client-ruby`, por cópia — há change aberta nos dois. - -**`healthCheck()` respondia `error` sempre.** Enviava `pageCount: 1`, que a API recusa com -`400 "pageCount must be between 1 and 50"` — o limite inferior do servidor está um a mais do -que a própria mensagem diz. O método existe para dizer se a integração está de pé. - -**Downloads devolviam objeto tipado como texto.** As rotas de entrada respondem -`{ publicTemporaryUri }`; binário nunca trafegou nelas. - -**O erro da API não chegava ao chamador.** `extractErrorMessage` lia dois dos quatro -envelopes que a plataforma usa. Nos outros dois o chamador recebia `HTTP 400 error` — o -status que já tinha. Foi assim que `The File field is required.` ficou invisível enquanto o -upload de certificado não funcionava. - -**Toda requisição mentia sobre a versão.** O User-Agent saía `@nfe-io/sdk@3.0.0` — pacote -inexistente, versão três majors atrás. **93.995 requisições em 30 dias**, e nenhuma -informação de versão chegando à plataforma. - -## O que ficou melhor além das correções - -- **O portão de publicação passou a poder reprovar.** `publish.yml` tinha - `continue-on-error: true` no passo de testes, com justificativa escrita que era falsa. Os - três bugs corrigidos em 01/09 saíram por esse portão. -- **A suíte de integração voltou a rodar.** Nada carregava o `.env`, então ela pulava - sempre. Execução local foi de **742 para 813 testes**; quatro assertions apodrecidas - apareceram e foram corrigidas. -- **`validate:spec` detecta drift entre cópias da mesma seção** — 30 dos 131 endpoints são - declarados em mais de uma spec e as cópias divergiram. -- **A tag da release e o `package.json` precisam concordar** antes de publicar. -- **Duas guardas novas**: versão fixada em literal e documentação citando método inexistente - passam a quebrar a suíte. Ambas verificadas por mutação. - -## Correções de registro - -Dois métodos que o diagnóstico anterior dava como quebrados **não estavam** — a amostra é -que era a exceção: - -- `productInvoiceQuery.downloadPdf` devolve `200` e `%PDF-1.4` com chave real. O `406` - medido antes vinha de chave inexistente. -- `legalPeople`/`naturalPeople` (14 métodos) respondem `200`. O `400` vinha de empresa com - id de 32 caracteres; a rota aceita só `ObjectId` de 24 hex — limite do servidor. - -## Upstream - -Nove issues abertas em `nfe/docs` a partir desta rodada — `nfe/docs#335`, `#336`, `#337`, -`#338`, `#345`, `#346`, `#347`, `#348`, `#349` — mais um comentário em `nfe/docs#343`. -A `#349` cobre as páginas publicadas do SDK Node e está atribuída a `@andrenfe`; as outras -seguem sem dono. - -## Verificação - -``` -typecheck limpo -lint 0 erros (32 warnings pre-existentes de `any`) -test:types 18/18, no type errors -build ok, dist reporta nfe-io@6.0.0 -suite CI 776 passed | 54 skipped -suite local 813 passed | 7 skipped -portão tag v6.0.0 passa; v5.2.0 reprova -``` - -## Depois do merge - -1. Criar a release **`v6.0.0`** no GitHub — é o que dispara o `publish.yml`. -2. O portão confere tag × `package.json`, roda testes/lint/typecheck/`test:types`/build, - verifica os artefatos e publica com provenance. -3. **`nfeio-docs` precisa de PR próprio** — rastreado em `nfe/docs#349`: a página pública - `docs/desenvolvedores/bibliotecas/nodejs/multi-host-routing.md` tem a mesma tabela errada - de credencial que este PR corrige aqui, e mais quatro classes de divergência. -4. Apagar este arquivo (`.github/PULL_REQUEST_v6.0.0.md`).