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..4f5d5d4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,26 +31,82 @@ 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 + # 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="${RELEASE_TAG:-$INPUT_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 + + # 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." + 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 + # 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 @@ -63,13 +119,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: @@ -81,20 +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 "" >> $GITHUB_STEP_SUMMARY - if [ "${{ steps.tests.outcome }}" == "failure" ]; then - echo "⚠️ **Note:** Some tests failed during CI (expected for integration tests)" >> $GITHUB_STEP_SUMMARY - fi + { + 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' 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 # ---------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 011cc15..9fb6d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,300 @@ 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] + +## [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 + +- **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 +> 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` + 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, + 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 + **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`. + +- **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 }` — + 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 + 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/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/MIGRATION.md b/MIGRATION.md index b8936e7..e6e1ea3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,133 @@ # Guia de Migração +## v5 → v6 + +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**. + +```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. + +```ts +// 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); +} +``` + +### 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. + +--- + ## 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..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 @@ -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) @@ -369,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/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/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/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/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/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/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/package.json b/package.json index 8345415..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", @@ -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/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/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/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 "" 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/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 a65dda4..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', @@ -146,8 +147,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 +238,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 +374,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 +393,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 +415,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 +438,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 +596,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 +616,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 +630,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 +646,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 +660,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 +673,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 +686,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 +699,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 +724,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 +735,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 +747,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 +759,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 +904,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 +928,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 +1174,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; @@ -1415,6 +1396,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(); @@ -1442,8 +1427,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 { @@ -1560,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'); * ``` */ @@ -1580,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 fe4fa53..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; @@ -254,6 +255,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 +280,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 +295,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 // -------------------------------------------------------------------------- @@ -335,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/core/resources/companies.ts b/src/core/resources/companies.ts index 8439845..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 // ============================================================================ @@ -544,11 +630,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 @@ -570,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'); @@ -581,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); } /** @@ -776,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'); } /** @@ -812,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/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/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/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/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/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/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/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/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 806ced8..5d787e3 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'; @@ -583,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']; @@ -632,6 +674,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 @@ -3361,12 +3434,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/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/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/src/index.ts b/src/index.ts index 1f53301..ae75509 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 @@ -286,6 +286,11 @@ export type { NfeProductInvoiceEventsResponse, NfeProductInvoiceSubListOptions, NfeFileResource, + InboundFileResource, + ConsumerInvoiceItemsResponse, + ConsumerInvoiceEventsResponse, + ConsumerInvoiceCancellationResponse, + ConsumerInvoiceFileResource, NfeRequestCancellationResource, NfeDisablementData, NfeDisablementResource, @@ -341,6 +346,9 @@ export type { ConsumerInvoiceListResponse, ConsumerInvoiceDisablementData, CertificatesMetadataResource, + CertificateMetadataResourceItem, + CertificateStatus, + CompanyCertificateV1, } from './core/types.js'; /** @@ -431,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 { @@ -451,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'; @@ -472,29 +487,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; // ============================================================================ @@ -503,15 +519,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..e2b65ba --- /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 = '6.0.0'; 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/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." } + } +} 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" + } + ] + } + } +} 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/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/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/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/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/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/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-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(); + }); +}); 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..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; @@ -328,7 +344,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 +375,7 @@ describe('CompaniesResource', () => { expect(result.uploaded).toBe(true); expect(mockFormData.append).toHaveBeenCalledWith( - 'certificate', + 'file', certificateBuffer, 'company-cert.pfx' ); @@ -383,7 +399,7 @@ describe('CompaniesResource', () => { await companies.uploadCertificate(TEST_COMPANY_ID, certificateData); expect(mockFormData.append).toHaveBeenCalledWith( - 'certificate', + 'file', certificateBlob, 'cert.p12' ); @@ -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/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/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/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']); + }); +}); 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/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', { diff --git a/tests/unit/http-client.test.ts b/tests/unit/http-client.test.ts index 29a685a..c0747a9 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', { @@ -590,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/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(); 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 () => { 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/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' ); }); 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` ); 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', () => { 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); + }); +}); 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); + }); +});