diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20a4900..a48004c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,8 @@ jobs: strategy: matrix: python-version: ["3.12", "3.13"] + env: + UV_PYTHON: ${{ matrix.python-version }} steps: - uses: actions/checkout@v4 - name: Install uv @@ -26,17 +28,19 @@ jobs: - name: Install dependencies run: uv sync --all-groups --python ${{ matrix.python-version }} - name: Ruff check - run: uv run ruff check . + run: uv run --all-groups ruff check . - name: Ruff format check - run: uv run ruff format --check . + run: uv run --all-groups ruff format --check . - name: Basedpyright - run: uv run basedpyright + run: uv run --all-groups basedpyright - name: Pytest - run: uv run python -m pytest + run: uv run --all-groups python -m pytest codegen-drift: name: Codegen Drift Check runs-on: ubuntu-latest + env: + UV_PYTHON: "3.12" steps: - uses: actions/checkout@v4 - name: Install uv diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c03654..e315132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.1] - 2026-10-06 + +### Fixed + +- CI quality and codegen jobs now keep their configured Python version instead of recreating the environment with the repository's default interpreter. Quality commands and Makefile gates explicitly retain all dependency groups, so tests that invoke the real model generator can find `datamodel-codegen`. +- NF-e and NFC-e read responses no longer require `contato.tipoPessoa` and `contato.contribuinte`: the official OpenAPI marks both fields `writeOnly`, so they are required only in requests. Generated response-only DTOs retain typed values when present and represent missing fields as `None`, omitted by `to_json_object()`. Detail reads, lists and pagination accept these responses; POST/PUT requirements, existing public methods, aliases and all unrelated endpoint contracts are preserved. The returned contact uses `NotasFiscaisContatoResponseDTO` instead of the shared request DTO. +- Model generation now projects required `writeOnly` fields and referencing component schemas only for responses, including operation metadata for pagination, without modifying the vendored OpenAPI or weakening request models. Inline compositions, array items, nested properties, reusable response components and inline/reusable callbacks are covered; request body components remain unchanged. + ## [0.3.0] - 2026-09-29 ### Breaking Changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e6661f..9f3f5a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,17 +66,22 @@ Generated files live in: Run the test suite: ```bash -uv run python -m pytest +uv run --all-groups python -m pytest ``` Or with coverage: ```bash -uv run python -m pytest --cov=bling_erp_api +uv run --all-groups python -m pytest --cov=bling_erp_api ``` **Important**: Tests must not make real API calls unless explicitly gated behind environment variables. Use `pytest-httpx` for HTTP mocking. +The test suite invokes the real model generator, so the `codegen` dependency +group must remain installed. `make check` includes all groups automatically. +To reproduce each CI interpreter locally, use `UV_PYTHON=3.12 make check` or +`UV_PYTHON=3.13 make check`; this overrides the repository's `.python-version`. + ## Adding a New Endpoint Follow the checklist in `AGENTS.md` (located in the repository root) for adding new endpoints. The key steps are: diff --git a/Makefile b/Makefile index 29a9bd7..b3b67c6 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: check build codegen codegen-check deploy sync UV := uv +UV_RUN := $(UV) run --all-groups GENERATED_DIRS := src/bling_erp_api/models/generated src/bling_erp_api/contracts/generated docs/resources UNTRACKED_GENERATED := git ls-files --others --exclude-standard -- $(GENERATED_DIRS) @@ -8,14 +9,14 @@ sync: $(UV) sync --all-groups check: sync - $(UV) run ruff check . - $(UV) run ruff format --check . - $(UV) run basedpyright - $(UV) run python -m pytest + $(UV_RUN) ruff check . + $(UV_RUN) ruff format --check . + $(UV_RUN) basedpyright + $(UV_RUN) python -m pytest codegen: - $(UV) run --group codegen python scripts/generate_models.py - $(UV) run python scripts/generate_openapi_contracts.py + $(UV_RUN) python scripts/generate_models.py + $(UV_RUN) python scripts/generate_openapi_contracts.py codegen-check: @if $(UNTRACKED_GENERATED) | grep -q .; then \ diff --git a/docs/api-reference/nfe.md b/docs/api-reference/nfe.md index 6ec9bb1..8d7b09e 100644 --- a/docs/api-reference/nfe.md +++ b/docs/api-reference/nfe.md @@ -1,5 +1,31 @@ # Notas Fiscais (NF-e) +Nas respostas de leitura, `contato.tipo_pessoa` e `contato.contribuinte` podem +estar ausentes. O OpenAPI do Bling marca `tipoPessoa` e `contribuinte` como +`writeOnly`: sua obrigatoriedade aplica-se à requisição, conforme a +[especificação OpenAPI 3.0](https://swagger.io/specification/v3/#schema-object). +O SDK usa `NotasFiscaisContatoResponseDTO` nessas respostas, preserva os valores +quando presentes e representa a ausência como `None`. `to_json_object()` omite +esses campos ausentes; não preenche valores fiscais por conta própria. + +Essa distinção também vale para a listagem, a paginação e as leituras de NFC-e +que reutilizam o mesmo schema. `NotasFiscaisContatoDTO` e os modelos de criação e +alteração continuam exigindo ambos os campos. Os métodos públicos e seus aliases +mantêm as assinaturas; o tipo do contato retornado passa a ser a variante de +resposta. `nome` e `numero_documento` continuam obrigatórios, e os aliases e a +rejeição de chaves conflitantes permanecem ativos. + +A geração faz essa projeção numa cópia temporária do OpenAPI, sem alterar +`specs/bling-openapi-reference.json`. A revisão de campos `writeOnly` obrigatórios +do schema versionado identificou somente esses dois campos; os demais campos +`writeOnly` já eram opcionais nas respostas. Contratos de outros recursos e +todos os request bodies são preservados por testes de regressão. + +A projeção também percorre schemas inline aninhados (composições, itens de +arrays e propriedades), componentes reutilizáveis de resposta e respostas de +callbacks. As exigências são removidas somente no objeto que declara o campo; +os objetos pais, exemplos e componentes `requestBodies` permanecem intactos. + ::: bling_erp_api.resources.nfe.NfeResource options: members: diff --git a/scripts/generate_models.py b/scripts/generate_models.py index 08d0a7c..ca39690 100644 --- a/scripts/generate_models.py +++ b/scripts/generate_models.py @@ -10,6 +10,7 @@ import re import shutil import subprocess +import tempfile from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, cast @@ -219,9 +220,15 @@ class FieldDoc: def main() -> None: """Generate canonical schemas, resource reexports, and operation model metadata.""" payload = cast("dict[str, object]", json.loads(SPEC_PATH.read_text(encoding="utf-8"))) + generation_payload = _response_generation_spec(payload) _prepare_generated_dirs() - _run_datamodel_codegen() + # Required writeOnly properties apply to requests only (OpenAPI 3.0). + # Keep the vendored spec intact and project response-only schemas locally. + with tempfile.TemporaryDirectory() as directory: + input_path = Path(directory) / "openapi.json" + input_path.write_text(json.dumps(generation_payload), encoding="utf-8") + _run_datamodel_codegen(input_path) class_order, class_nodes = _schema_class_nodes() # Inject response data wrapper models (new classes or body overwrites). @@ -230,7 +237,7 @@ def main() -> None: _APPLIED_FIELD_OVERRIDES.clear() class_names = set(class_order) - all_contracts = _contracts_by_module(payload) + all_contracts = _contracts_by_module(generation_payload) class_modules = _schema_class_modules(class_order, all_contracts) _write_schema_package(class_order, class_nodes, class_modules) @@ -276,11 +283,149 @@ def _prepare_generated_dirs() -> None: SCHEMAS_PACKAGE_DIR.mkdir(parents=True) -def _run_datamodel_codegen() -> None: +def _response_generation_spec(payload: Mapping[str, object]) -> dict[str, object]: + """Separate required writeOnly fields from response validation. + + Clone only affected component schemas and their referencing ancestors. + Requests and the public shared DTOs retain the original requirements. + Response variants keep the fields typed for compatibility when present, + but do not require them or supply wire values when they are absent. + """ + result = copy.deepcopy(dict(payload)) + components = cast("dict[str, object]", result["components"]) + schemas = cast("dict[str, dict[str, object]]", components["schemas"]) + affected = {name for name, schema in schemas.items() if _has_required_write_only_fields(schema)} + while True: + ancestors = { + name for name, schema in schemas.items() if _schema_ref_names(schema) & affected + } + if ancestors <= affected: + break + affected.update(ancestors) + + replacements = {name: f"{name.removesuffix('DTO')}ResponseDTO" for name in sorted(affected)} + for name, replacement in replacements.items(): + if replacement in schemas: + msg = f"Response schema projection collides with {replacement}" + raise ValueError(msg) + schemas[replacement] = _project_response_schema(schemas[name], replacements) + + paths = cast("dict[str, dict[str, object]]", result["paths"]) + for path in paths.values(): + _project_path_responses(path, replacements) + responses = cast("dict[str, dict[str, object]]", components.get("responses", {})) + for name, response in responses.items(): + responses[name] = _project_response_object(response, replacements) + callbacks = cast("dict[str, dict[str, object]]", components.get("callbacks", {})) + for callback in callbacks.values(): + _project_callback_responses(callback, replacements) + return result + + +def _required_write_only_fields(schema: Mapping[str, object]) -> set[str]: + properties = cast("dict[str, dict[str, object]]", schema.get("properties", {})) + required = cast("list[str]", schema.get("required", [])) + return {name for name in required if properties.get(name, {}).get("writeOnly") is True} + + +def _nested_schemas(schema: Mapping[str, object]) -> Iterable[dict[str, object]]: + """Yield inline subschemas without traversing examples or other arbitrary data.""" + properties = cast("dict[str, dict[str, object]]", schema.get("properties", {})) + yield from properties.values() + for key in ("items", "additionalProperties", "not"): + value = schema.get(key) + if isinstance(value, dict): + yield cast("dict[str, object]", value) + for key in ("allOf", "anyOf", "oneOf"): + yield from cast("list[dict[str, object]]", schema.get(key, [])) + + +def _has_required_write_only_fields(schema: Mapping[str, object]) -> bool: + return bool(_required_write_only_fields(schema)) or any( + _has_required_write_only_fields(child) for child in _nested_schemas(schema) + ) + + +def _project_response_schema( + schema: Mapping[str, object], replacements: Mapping[str, str] +) -> dict[str, object]: + result = copy.deepcopy(dict(schema)) + _rewrite_response_schema(result, replacements) + return result + + +def _rewrite_response_schema(schema: dict[str, object], replacements: Mapping[str, str]) -> None: + omitted = _required_write_only_fields(schema) + if omitted: + required = cast("list[str]", schema["required"]) + schema["required"] = [field for field in required if field not in omitted] + reference = schema.get("$ref") + if isinstance(reference, str) and reference.startswith("#/components/schemas/"): + name = reference.rsplit("/", 1)[1] + if name in replacements: + schema["$ref"] = f"#/components/schemas/{replacements[name]}" + for child in _nested_schemas(schema): + _rewrite_response_schema(child, replacements) + + +def _project_response_object( + response: Mapping[str, object], replacements: Mapping[str, str] +) -> dict[str, object]: + result = copy.deepcopy(dict(response)) + content = cast("dict[str, dict[str, object]]", result.get("content", {})) + for media_type in content.values(): + schema = media_type.get("schema") + if isinstance(schema, dict): + media_type["schema"] = _project_response_schema( + cast("dict[str, object]", schema), replacements + ) + headers = cast("dict[str, dict[str, object]]", result.get("headers", {})) + for header in headers.values(): + schema = header.get("schema") + if isinstance(schema, dict): + header["schema"] = _project_response_schema( + cast("dict[str, object]", schema), replacements + ) + return result + + +def _project_path_responses(path: dict[str, object], replacements: Mapping[str, str]) -> None: + for method in ("get", "put", "post", "delete", "options", "head", "patch", "trace"): + value = path.get(method) + if not isinstance(value, dict): + continue + operation = cast("dict[str, object]", value) + responses = cast("dict[str, dict[str, object]]", operation.get("responses", {})) + for status, response in responses.items(): + responses[status] = _project_response_object(response, replacements) + callbacks = cast("dict[str, dict[str, object]]", operation.get("callbacks", {})) + for callback in callbacks.values(): + _project_callback_responses(callback, replacements) + + +def _project_callback_responses( + callback: dict[str, object], replacements: Mapping[str, str] +) -> None: + for expression, value in callback.items(): + if expression != "$ref" and isinstance(value, dict): + _project_path_responses(cast("dict[str, object]", value), replacements) + + +def _schema_ref_names(schema: Mapping[str, object]) -> set[str]: + reference = schema.get("$ref") + names = set[str]() + if isinstance(reference, str) and reference.startswith("#/components/schemas/"): + names.add(reference.rsplit("/", 1)[1]) + for child in _nested_schemas(schema): + names.update(_schema_ref_names(child)) + return names + + +def _run_datamodel_codegen(input_path: Path) -> None: command = [ _datamodel_codegen_bin(), "--input", - str(SPEC_PATH), + str(input_path), "--input-file-type", "openapi", "--openapi-scopes", diff --git a/src/bling_erp_api/_version.py b/src/bling_erp_api/_version.py index 00329cc..a86b5c4 100644 --- a/src/bling_erp_api/_version.py +++ b/src/bling_erp_api/_version.py @@ -1,3 +1,3 @@ """Package version.""" -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/src/bling_erp_api/contracts/generated/_operation_models.py b/src/bling_erp_api/contracts/generated/_operation_models.py index d953827..80c22d0 100644 --- a/src/bling_erp_api/contracts/generated/_operation_models.py +++ b/src/bling_erp_api/contracts/generated/_operation_models.py @@ -568,7 +568,7 @@ "path": "/nfce", "pattern": r"^/nfce/?$", "response_model": "bling_erp_api.models.generated.schemas.notas_fiscais_consumidor.NfceGetResponse200", - "response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseDTO", + "response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseResponseDTO", }, { "resource_class": "NfeResource", @@ -667,7 +667,7 @@ "path": "/nfe", "pattern": r"^/nfe/?$", "response_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NfeGetResponse200", - "response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseDTO", + "response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseResponseDTO", }, { "resource_class": "NfeResource", @@ -1675,7 +1675,7 @@ "path": "/nfce", "pattern": r"^/nfce/?$", "response_model": "bling_erp_api.models.generated.schemas.notas_fiscais_consumidor.NfceGetResponse200", - "response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseDTO", + "response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseResponseDTO", }, { "resource_class": "NfceResource", @@ -1774,7 +1774,7 @@ "path": "/nfe", "pattern": r"^/nfe/?$", "response_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NfeGetResponse200", - "response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseDTO", + "response_item_model": "bling_erp_api.models.generated.schemas.notas_fiscais.NotasFiscaisDadosBaseResponseDTO", }, { "resource_class": "NfceResource", diff --git a/src/bling_erp_api/models/aliases.py b/src/bling_erp_api/models/aliases.py index 68bfee1..9e2a37f 100644 --- a/src/bling_erp_api/models/aliases.py +++ b/src/bling_erp_api/models/aliases.py @@ -86,6 +86,7 @@ NfePostResponse201, NotaFiscalResponsePOST, NotasFiscaisDadosBaseDTO, + NotasFiscaisDadosBaseResponseDTO, ) # NFSe @@ -150,6 +151,9 @@ VendasResponsePOSTPUT, ) +# Invoice response models +from bling_erp_api.models.generated.schemas.notas_fiscais import NotasFiscaisContatoResponseDTO + # Situations from bling_erp_api.models.generated.situacoes import ( SituacoesDadosDTO, @@ -241,7 +245,9 @@ "NfsePostRequest", "NfsePostResponse201", "NotaFiscalResponsePOST", + "NotasFiscaisContatoResponseDTO", "NotasFiscaisDadosBaseDTO", + "NotasFiscaisDadosBaseResponseDTO", "NotasServicosDadosBaseDTO", "NotasServicosDadosDTO", "NotasServicosResponsePOSTPUT", diff --git a/src/bling_erp_api/models/generated/__init__.py b/src/bling_erp_api/models/generated/__init__.py index 7035683..2cd17c9 100644 --- a/src/bling_erp_api/models/generated/__init__.py +++ b/src/bling_erp_api/models/generated/__init__.py @@ -426,7 +426,9 @@ "NotaFiscalResponsePOST", "NotasFiscaisContatoDTO", "NotasFiscaisContatoEnderecoDTO", + "NotasFiscaisContatoResponseDTO", "NotasFiscaisDadosBaseDTO", + "NotasFiscaisDadosBaseResponseDTO", "NotasFiscaisDadosGetDTO", "NotasFiscaisDadosPostDTO", "NotasFiscaisDocumentoDTO", diff --git a/src/bling_erp_api/models/generated/invoices.py b/src/bling_erp_api/models/generated/invoices.py index f5f8a52..e502550 100644 --- a/src/bling_erp_api/models/generated/invoices.py +++ b/src/bling_erp_api/models/generated/invoices.py @@ -19,6 +19,7 @@ NfePostResponse201, NotaFiscalResponsePOST, NotasFiscaisDadosBaseDTO, + NotasFiscaisDadosBaseResponseDTO, NotasFiscaisDadosGetDTO, NotasFiscaisDadosPostDTO, NotasFiscaisDocumentoDTO, @@ -56,6 +57,7 @@ "NfePostResponse201", "NotaFiscalResponsePOST", "NotasFiscaisDadosBaseDTO", + "NotasFiscaisDadosBaseResponseDTO", "NotasFiscaisDadosGetDTO", "NotasFiscaisDadosPostDTO", "NotasFiscaisDocumentoDTO", diff --git a/src/bling_erp_api/models/generated/nfce.py b/src/bling_erp_api/models/generated/nfce.py index d83fe73..b4a86a6 100644 --- a/src/bling_erp_api/models/generated/nfce.py +++ b/src/bling_erp_api/models/generated/nfce.py @@ -19,6 +19,7 @@ NfePostResponse201, NotaFiscalResponsePOST, NotasFiscaisDadosBaseDTO, + NotasFiscaisDadosBaseResponseDTO, NotasFiscaisDadosGetDTO, NotasFiscaisDadosPostDTO, NotasFiscaisDocumentoDTO, @@ -56,6 +57,7 @@ "NfePostResponse201", "NotaFiscalResponsePOST", "NotasFiscaisDadosBaseDTO", + "NotasFiscaisDadosBaseResponseDTO", "NotasFiscaisDadosGetDTO", "NotasFiscaisDadosPostDTO", "NotasFiscaisDocumentoDTO", diff --git a/src/bling_erp_api/models/generated/schemas/__init__.py b/src/bling_erp_api/models/generated/schemas/__init__.py index 33518b8..e167880 100644 --- a/src/bling_erp_api/models/generated/schemas/__init__.py +++ b/src/bling_erp_api/models/generated/schemas/__init__.py @@ -495,7 +495,9 @@ NotaFiscalResponsePOST, NotasFiscaisContatoDTO, NotasFiscaisContatoEnderecoDTO, + NotasFiscaisContatoResponseDTO, NotasFiscaisDadosBaseDTO, + NotasFiscaisDadosBaseResponseDTO, NotasFiscaisDadosGetDTO, NotasFiscaisDadosPostDTO, NotasFiscaisDocumentoDTO, @@ -1208,7 +1210,9 @@ "NotaFiscalResponsePOST", "NotasFiscaisContatoDTO", "NotasFiscaisContatoEnderecoDTO", + "NotasFiscaisContatoResponseDTO", "NotasFiscaisDadosBaseDTO", + "NotasFiscaisDadosBaseResponseDTO", "NotasFiscaisDadosGetDTO", "NotasFiscaisDadosPostDTO", "NotasFiscaisDocumentoDTO", diff --git a/src/bling_erp_api/models/generated/schemas/common.py b/src/bling_erp_api/models/generated/schemas/common.py index 016ac35..888a1fa 100644 --- a/src/bling_erp_api/models/generated/schemas/common.py +++ b/src/bling_erp_api/models/generated/schemas/common.py @@ -37,7 +37,11 @@ from .estoques import EstoquesSaldosBaseDTO, EstoquesSaldosDTO from .formas_pagamentos import FormasPagamentosDadosBaseDTO, FormasPagamentosDadosDTO from .homologacao import HomologacaoDadosBaseDTO, HomologacaoDadosDTO -from .notas_fiscais import NotaFiscalResponsePOST, NotasFiscaisDadosBaseDTO, NotasFiscaisDadosGetDTO +from .notas_fiscais import ( + NotaFiscalResponsePOST, + NotasFiscaisDadosBaseResponseDTO, + NotasFiscaisDadosGetDTO, +) from .notas_servicos import ( NotasServicosContatoBaseDTO, NotasServicosContatoDTO, @@ -1544,13 +1548,13 @@ class Error(BlingModel): fields: list[ErrorField] | None = None -class Data14(NotasFiscaisDadosBaseDTO, NotasFiscaisDadosGetDTO): +class Data14(NotasFiscaisDadosBaseResponseDTO, NotasFiscaisDadosGetDTO): """OpenAPI schema ``Data14``. Modelo Pydantic gerado a partir do contrato OpenAPI do Bling. Use este schema quando ele aparecer como request body ou response schema nos métodos do SDK. - Herda campos de: NotasFiscaisDadosBaseDTO, NotasFiscaisDadosGetDTO. + Herda campos de: NotasFiscaisDadosBaseResponseDTO, NotasFiscaisDadosGetDTO. Fields: id: Bling ``id``; type ``int | None``; opcional. @@ -1560,7 +1564,7 @@ class Data14(NotasFiscaisDadosBaseDTO, NotasFiscaisDadosGetDTO): data_emissao: Bling ``dataEmissao``; type ``str | None``; opcional. Data e hora da emissão. data_operacao: Bling ``dataOperacao``; type ``str``; obrigatório. Data de saída/entrada de acordo com o tipo da nota. chave_acesso: Bling ``chaveAcesso``; type ``str | None``; opcional. - contato: Bling ``contato``; type ``NotasFiscaisContatoDTO``; obrigatório. + contato: Bling ``contato``; type ``NotasFiscaisContatoResponseDTO``; obrigatório. natureza_operacao: Bling ``naturezaOperacao``; type ``NotasFiscaisNaturezaOperacaoDTO``; obrigatório. loja: Bling ``loja``; type ``NotasFiscaisLojaDTO | None``; opcional. serie: Bling ``serie``; type ``int | None``; opcional. diff --git a/src/bling_erp_api/models/generated/schemas/notas_fiscais.py b/src/bling_erp_api/models/generated/schemas/notas_fiscais.py index c3cbd51..9426903 100644 --- a/src/bling_erp_api/models/generated/schemas/notas_fiscais.py +++ b/src/bling_erp_api/models/generated/schemas/notas_fiscais.py @@ -523,6 +523,106 @@ class NotaFiscalResponsePOST(BlingModel): contato: Contato1 +class NotasFiscaisContatoResponseDTO(BlingModel): + """OpenAPI schema ``NotasFiscaisContatoResponseDTO``. + + Modelo Pydantic gerado a partir do contrato OpenAPI do Bling. Use este schema + quando ele aparecer como request body ou response schema nos métodos do SDK. + + Fields: + id: Bling ``id``; type ``int | None``; opcional. + nome: Bling ``nome``; type ``str``; obrigatório. + tipo_pessoa: Bling ``tipoPessoa``; type ``str | None``; opcional. `F` Física
`J` Jurídica
`E` Estrangeira. + numero_documento: Bling ``numeroDocumento``; type ``str``; obrigatório. CNPJ ou CPF. + ie: Bling ``ie``; type ``str | None``; opcional. + rg: Bling ``rg``; type ``str | None``; opcional. + contribuinte: Bling ``contribuinte``; type ``int | None``; opcional. `1` Contribuinte do ICMS
`2` Contribuinte isento de ICMS
`9` Não contribuinte. + telefone: Bling ``telefone``; type ``str | None``; opcional. + email: Bling ``email``; type ``str | None``; opcional. + endereco: Bling ``endereco``; type ``NotasFiscaisContatoEnderecoDTO | None``; opcional.""" + + id: int | None = Field(default=None, examples=[12345678]) + nome: str = Field(..., examples=["Contato do Bling"]) + tipo_pessoa: str | None = Field( + default=None, + validation_alias=AliasChoices("tipo_pessoa", "tipoPessoa"), + examples=["J"], + serialization_alias="tipoPessoa", + ) + numero_documento: str = Field( + ..., + validation_alias=AliasChoices("numero_documento", "numeroDocumento"), + examples=["30188025000121"], + serialization_alias="numeroDocumento", + ) + ie: str | None = Field(default=None, examples=["7364873393"]) + rg: str | None = Field(default=None, examples=["451838701"]) + contribuinte: int | None = Field(default=None, examples=[1]) + telefone: str | None = Field(default=None, examples=["54 3771-7278"]) + email: str | None = Field(default=None, examples=["pedrosilva@bling.com.br"]) + endereco: NotasFiscaisContatoEnderecoDTO | None = None + + +class NotasFiscaisDadosBaseResponseDTO(BlingModel): + """OpenAPI schema ``NotasFiscaisDadosBaseResponseDTO``. + + Modelo Pydantic gerado a partir do contrato OpenAPI do Bling. Use este schema + quando ele aparecer como request body ou response schema nos métodos do SDK. + + Fields: + id: Bling ``id``; type ``int | None``; opcional. + tipo: Bling ``tipo``; type ``int``; obrigatório. `0` Entrada
`1` Saída + situacao: Bling ``situacao``; type ``int | None``; opcional. `1` Pendente
`2` Cancelada
`3` Aguardando recibo
`4` Rejeitada
`5` Autorizada
`6` Emitida DANFE
`7` Registrada
`8` Aguardando protocolo
`9` Denegada
`10... + numero: Bling ``numero``; type ``str``; obrigatório. + data_emissao: Bling ``dataEmissao``; type ``str | None``; opcional. Data e hora da emissão. + data_operacao: Bling ``dataOperacao``; type ``str``; obrigatório. Data de saída/entrada de acordo com o tipo da nota. + chave_acesso: Bling ``chaveAcesso``; type ``str | None``; opcional. + contato: Bling ``contato``; type ``NotasFiscaisContatoResponseDTO``; obrigatório. + natureza_operacao: Bling ``naturezaOperacao``; type ``NotasFiscaisNaturezaOperacaoDTO``; obrigatório. + loja: Bling ``loja``; type ``NotasFiscaisLojaDTO | None``; opcional.""" + + id: int | None = Field(default=None, examples=[12345678]) + tipo: int = Field(..., examples=[1]) + situacao: int | None = Field(default=None, examples=[1]) + numero: str = Field(..., examples=["6541"]) + data_emissao: str | None = Field( + default=None, + validation_alias=AliasChoices("data_emissao", "dataEmissao"), + examples=["2023-01-12 09:52:12"], + serialization_alias="dataEmissao", + ) + data_operacao: str = Field( + ..., + validation_alias=AliasChoices("data_operacao", "dataOperacao"), + examples=["2023-01-12 09:52:12"], + serialization_alias="dataOperacao", + ) + chave_acesso: str | None = Field( + default=None, + validation_alias=AliasChoices("chave_acesso", "chaveAcesso"), + serialization_alias="chaveAcesso", + ) + contato: NotasFiscaisContatoResponseDTO + natureza_operacao: NotasFiscaisNaturezaOperacaoDTO = Field( + ..., + validation_alias=AliasChoices("natureza_operacao", "naturezaOperacao"), + serialization_alias="naturezaOperacao", + ) + loja: NotasFiscaisLojaDTO | None = None + + +class NfeGetResponse200(BlingModel): + """OpenAPI schema ``NfeGetResponse200``. + + Modelo Pydantic gerado a partir do contrato OpenAPI do Bling. Use este schema + quando ele aparecer como request body ou response schema nos métodos do SDK. + + Fields: + data: Bling ``data``; type ``list[NotasFiscaisDadosBaseResponseDTO] | None``; opcional.""" + + data: list[NotasFiscaisDadosBaseResponseDTO] | None = None + + class NfePostResponse201(BlingModel): """OpenAPI schema ``NfePostResponse201``. @@ -735,18 +835,6 @@ class NotasFiscaisTransportePostDTO(BlingModel): etiqueta: NotasFiscaisTransporteEtiquetaDTO | None = None -class NfeGetResponse200(BlingModel): - """OpenAPI schema ``NfeGetResponse200``. - - Modelo Pydantic gerado a partir do contrato OpenAPI do Bling. Use este schema - quando ele aparecer como request body ou response schema nos métodos do SDK. - - Fields: - data: Bling ``data``; type ``list[NotasFiscaisDadosBaseDTO] | None``; opcional.""" - - data: list[NotasFiscaisDadosBaseDTO] | None = None - - class NfeIdNotaFiscalPutResponse200(BlingModel): """OpenAPI schema ``NfeIdNotaFiscalPutResponse200``. @@ -1002,7 +1090,9 @@ class NfeIdNotaFiscalPutRequest(NotasFiscaisDadosBaseDTO, NotasFiscaisDadosPostD "NotaFiscalResponsePOST", "NotasFiscaisContatoDTO", "NotasFiscaisContatoEnderecoDTO", + "NotasFiscaisContatoResponseDTO", "NotasFiscaisDadosBaseDTO", + "NotasFiscaisDadosBaseResponseDTO", "NotasFiscaisDadosGetDTO", "NotasFiscaisDadosPostDTO", "NotasFiscaisDocumentoDTO", diff --git a/src/bling_erp_api/models/generated/schemas/notas_fiscais_consumidor.py b/src/bling_erp_api/models/generated/schemas/notas_fiscais_consumidor.py index 5d6f2d5..996cd28 100644 --- a/src/bling_erp_api/models/generated/schemas/notas_fiscais_consumidor.py +++ b/src/bling_erp_api/models/generated/schemas/notas_fiscais_consumidor.py @@ -15,7 +15,19 @@ if TYPE_CHECKING: from .common import Data13, Data14, Data16 - from .notas_fiscais import NotasFiscaisDadosBaseDTO + from .notas_fiscais import NotasFiscaisDadosBaseResponseDTO + + +class NfceGetResponse200(BlingModel): + """OpenAPI schema ``NfceGetResponse200``. + + Modelo Pydantic gerado a partir do contrato OpenAPI do Bling. Use este schema + quando ele aparecer como request body ou response schema nos métodos do SDK. + + Fields: + data: Bling ``data``; type ``list[NotasFiscaisDadosBaseResponseDTO] | None``; opcional.""" + + data: list[NotasFiscaisDadosBaseResponseDTO] | None = None class NfcePostResponse201(BlingModel): @@ -54,18 +66,6 @@ class NfceIdNotaFiscalConsumidorEnviarPostResponse200(BlingModel): data: Data16 | None = None -class NfceGetResponse200(BlingModel): - """OpenAPI schema ``NfceGetResponse200``. - - Modelo Pydantic gerado a partir do contrato OpenAPI do Bling. Use este schema - quando ele aparecer como request body ou response schema nos métodos do SDK. - - Fields: - data: Bling ``data``; type ``list[NotasFiscaisDadosBaseDTO] | None``; opcional.""" - - data: list[NotasFiscaisDadosBaseDTO] | None = None - - class NfcePostRequest(NotasFiscaisDadosBaseDTO, NotasFiscaisDadosPostDTO): """OpenAPI schema ``NfcePostRequest``. diff --git a/tests/fixtures/responses/nfe_get_read_contact.json b/tests/fixtures/responses/nfe_get_read_contact.json new file mode 100644 index 0000000..8c5a10f --- /dev/null +++ b/tests/fixtures/responses/nfe_get_read_contact.json @@ -0,0 +1,68 @@ +{ + "data": { + "id": 12345, + "tipo": 1, + "situacao": 3, + "numero": "000001234", + "serie": 1, + "dataEmissao": "2024-01-15 10:30:00", + "dataOperacao": "2024-01-15", + "chaveAcesso": "35240112345678901234567890123456789012345678", + "contato": { + "id": 100, + "nome": "CLIENTE SINTETICO", + "numeroDocumento": "00000000000000" + }, + "naturezaOperacao": { + "id": 1 + }, + "loja": { + "id": 1 + }, + "valorNota": 1500.5, + "valorFrete": 50.0, + "finalidade": 1, + "xml": "...", + "linkDanfe": "https://example.test/danfe/12345", + "linkPDF": "https://example.test/pdf/12345", + "optanteSimplesNacional": true, + "vendedor": { + "id": 10 + }, + "itens": [ + { + "codigo": "PROD-001", + "descricao": "Produto Exemplo", + "unidade": "UN", + "quantidade": 10.0, + "valor": 100.0, + "valorTotal": 1000.0, + "tipo": "P", + "classificacaoFiscal": "8471.30.12", + "origem": 0 + } + ], + "parcelas": [ + { + "data": "2024-02-15", + "valor": 1500.5, + "formaPagamento": { + "id": 1 + } + } + ], + "transporte": { + "fretePorConta": 0, + "transportador": { + "nome": "TRANSPORTADORA SINTETICA", + "numeroDocumento": "00000000000000" + }, + "volumes": [ + { + "id": 501, + "codigoRastreamento": "TESTE000001BR" + } + ] + } + } +} diff --git a/tests/unit/test_generate_models_overrides.py b/tests/unit/test_generate_models_overrides.py index 8e9202d..4f4e4df 100644 --- a/tests/unit/test_generate_models_overrides.py +++ b/tests/unit/test_generate_models_overrides.py @@ -4,12 +4,15 @@ import ast import importlib.util +import json import re import sys +from copy import deepcopy from pathlib import Path from typing import TYPE_CHECKING import pytest +from pydantic import ValidationError if TYPE_CHECKING: from collections.abc import Iterator @@ -475,3 +478,254 @@ def test_schema_module_content_imports_alias_choices_for_inserted_field( # ``alias='xStr'`` is a substring of ``serialization_alias='xStr'``; the # lookbehind only matches a bare raw ``alias`` keyword surviving the run. assert re.search(r"(? None: + """The vendored contract changes only for NF-e/NFC-e response references.""" + original = json.loads((_scripts_dir.parent / "specs/bling-openapi-reference.json").read_text()) + before = json.dumps(original, sort_keys=True) + projected = override_guard._response_generation_spec(original) # noqa: SLF001 + assert json.dumps(original, sort_keys=True) == before + original_schemas = original["components"]["schemas"] + projected_schemas = projected["components"]["schemas"] + assert all(projected_schemas[name] == schema for name, schema in original_schemas.items()) + assert set(projected_schemas) - set(original_schemas) == { + "NotasFiscaisContatoResponseDTO", + "NotasFiscaisDadosBaseResponseDTO", + } + contact = projected_schemas["NotasFiscaisContatoResponseDTO"] + assert contact["required"] == ["nome", "numeroDocumento"] + assert contact["properties"] == original_schemas["NotasFiscaisContatoDTO"]["properties"] + for path, operations in original["paths"].items(): + for method, operation in operations.items(): + projected_operation = projected["paths"][path][method] + assert projected_operation.get("requestBody") == operation.get("requestBody") + if not path.startswith(("/nfe", "/nfce")): + assert projected_operation == operation + + +def test_response_projection_traverses_composed_schemas(override_guard: ModuleType) -> None: + """Required writeOnly fields stay optional only through response schema references.""" + contact = { + "type": "object", + "required": ["name", "secret"], + "properties": { + "name": {"type": "string"}, + "secret": {"type": "string", "writeOnly": True}, + }, + } + base = {"properties": {"contact": {"$ref": "#/components/schemas/ContactDTO"}}} + detail = {"allOf": [{"$ref": "#/components/schemas/BaseDTO"}]} + wrapper = { + "content": { + "application/json": {"schema": {"items": {"$ref": "#/components/schemas/DetailDTO"}}} + } + } + payload = { + "components": {"schemas": {"ContactDTO": contact, "BaseDTO": base, "DetailDTO": detail}}, + "paths": {"/test": {"post": {"requestBody": wrapper, "responses": {"200": wrapper}}}}, + } + result = override_guard._response_generation_spec(payload) # noqa: SLF001 + schemas = result["components"]["schemas"] + assert schemas["ContactDTO"]["required"] == ["name", "secret"] + assert schemas["ContactResponseDTO"]["required"] == ["name"] + assert ( + schemas["DetailResponseDTO"]["allOf"][0]["$ref"] == "#/components/schemas/BaseResponseDTO" + ) + operation = result["paths"]["/test"]["post"] + assert ( + operation["requestBody"]["content"]["application/json"]["schema"]["items"]["$ref"] + == "#/components/schemas/DetailDTO" + ) + assert ( + operation["responses"]["200"]["content"]["application/json"]["schema"]["items"]["$ref"] + == "#/components/schemas/DetailResponseDTO" + ) + + +def test_response_projection_rejects_schema_name_collisions(override_guard: ModuleType) -> None: + """A spec refresh cannot silently overwrite a new official response component.""" + payload = { + "components": { + "schemas": { + "ContactDTO": { + "required": ["secret"], + "properties": {"secret": {"type": "string", "writeOnly": True}}, + }, + "ContactResponseDTO": {}, + }, + }, + "paths": {}, + } + with pytest.raises(ValueError, match="collides with ContactResponseDTO"): + override_guard._response_generation_spec(payload) # noqa: SLF001 + + +def _write_only_schema() -> dict[str, object]: + return { + "type": "object", + "required": ["name", "secret"], + "properties": { + "name": {"type": "string"}, + "secret": {"type": "string", "writeOnly": True}, + }, + } + + +@pytest.mark.parametrize( + "branch", ["allOf", "anyOf", "oneOf", "items", "properties", "additionalProperties"] +) +def test_response_projection_relaxes_nested_inline_schema( + override_guard: ModuleType, branch: str +) -> None: + """Inline children relax their own requirements without relaxing their parent.""" + child = _write_only_schema() + schema: dict[str, object] + if branch in {"allOf", "anyOf", "oneOf"}: + schema = {branch: [child]} + elif branch == "properties": + schema = {"type": "object", "required": ["secret"], "properties": {"secret": child}} + else: + schema = {branch: child} + literal = { + "$ref": "#/components/schemas/NestedDTO", + "required": ["secret"], + "properties": {"secret": {"writeOnly": True}}, + } + schema["example"] = literal + payload = {"components": {"schemas": {"NestedDTO": schema}}, "paths": {}} + before = deepcopy(payload) + projected = override_guard._response_generation_spec(payload) # noqa: SLF001 + response = projected["components"]["schemas"]["NestedResponseDTO"] + if branch in {"allOf", "anyOf", "oneOf"}: + projected_child = response[branch][0] + elif branch == "properties": + projected_child = response[branch]["secret"] + assert response["required"] == ["secret"] + else: + projected_child = response[branch] + assert projected_child["required"] == ["name"] + assert projected_child["properties"] == child["properties"] + assert response["example"] == literal + assert payload == before + assert projected["components"]["schemas"]["NestedDTO"] == schema + + +def test_response_projection_visits_reusable_responses_and_callbacks( + override_guard: ModuleType, +) -> None: + """Reusable and callback responses change while every request body stays intact.""" + reference = {"$ref": "#/components/schemas/ContactDTO"} + body = {"content": {"application/json": {"schema": reference}}} + reusable_response = {"$ref": "#/components/responses/Contact"} + callback_operation = { + "post": { + "requestBody": body, + "responses": {"200": body, "201": reusable_response}, + }, + } + callback = {"{$request.body#/callbackUrl}": callback_operation} + payload = { + "components": { + "schemas": {"ContactDTO": _write_only_schema()}, + "requestBodies": {"Contact": body}, + "responses": {"Contact": body, "Alias": reusable_response}, + "callbacks": {"Contact": callback}, + }, + "paths": { + "/test": { + "parameters": list[object](), + "post": { + "requestBody": {"$ref": "#/components/requestBodies/Contact"}, + "responses": {"200": reusable_response}, + "callbacks": { + "inline": callback, + "reusable": {"$ref": "#/components/callbacks/Contact"}, + }, + }, + }, + }, + } + before = deepcopy(payload) + projected = override_guard._response_generation_spec(payload) # noqa: SLF001 + components = projected["components"] + assert components["requestBodies"] == payload["components"]["requestBodies"] + assert components["responses"]["Contact"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ContactResponseDTO", + } + assert components["responses"]["Alias"] == reusable_response + operation = projected["paths"]["/test"]["post"] + assert operation["requestBody"] == {"$ref": "#/components/requestBodies/Contact"} + assert operation["responses"]["200"] == reusable_response + assert operation["callbacks"]["reusable"] == {"$ref": "#/components/callbacks/Contact"} + for projected_callback in ( + components["callbacks"]["Contact"], + operation["callbacks"]["inline"], + ): + callback_post = projected_callback["{$request.body#/callbackUrl}"]["post"] + assert callback_post["requestBody"] == body + assert callback_post["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ContactResponseDTO", + } + assert callback_post["responses"]["201"] == reusable_response + assert payload == before + + +def test_response_projection_relaxes_inline_response_without_component( + override_guard: ModuleType, +) -> None: + """Inline response schemas also respect writeOnly without a component reference.""" + body = {"content": {"application/json": {"schema": {"allOf": [_write_only_schema()]}}}} + payload = { + "components": {"schemas": dict[str, object]()}, + "paths": {"/test": {"post": {"requestBody": body, "responses": {"200": body}}}}, + } + result = override_guard._response_generation_spec(payload) # noqa: SLF001 + operation = result["paths"]["/test"]["post"] + assert operation["requestBody"] == body + response_schema = operation["responses"]["200"]["content"]["application/json"]["schema"] + assert response_schema["allOf"][0]["required"] == ["name"] + + +@pytest.mark.parametrize("array", [False, True]) +def test_projected_inline_schema_generates_valid_pydantic_response( + override_guard: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, array: bool +) -> None: + """The real generator accepts absent writeOnly data and keeps requests strict.""" + child = _write_only_schema() + schema = {"type": "array", "items": child} if array else {"allOf": [child]} + body = { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ContactDTO"}}} + } + payload = { + "openapi": "3.0.3", + "info": {"title": "Synthetic", "version": "1"}, + "components": {"schemas": {"ContactDTO": schema}, "responses": {"Contact": body}}, + "paths": { + "/test": { + "get": {"responses": {"200": {"$ref": "#/components/responses/Contact"}}}, + "post": {"requestBody": body, "responses": {"204": {"description": "Empty"}}}, + }, + }, + } + projected = override_guard._response_generation_spec(payload) # noqa: SLF001 + input_path = tmp_path / "openapi.json" + output_path = tmp_path / "models.py" + input_path.write_text(json.dumps(projected), encoding="utf-8") + monkeypatch.setattr(override_guard, "RAW_SCHEMAS_MODULE", output_path) + override_guard._run_datamodel_codegen(input_path) # noqa: SLF001 + spec = importlib.util.spec_from_file_location("synthetic_invoice_models", output_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, spec.name, module) + spec.loader.exec_module(module) + data = [{"name": "SYNTHETIC"}] if array else {"name": "SYNTHETIC"} + module.ContactResponseDTO.model_validate(data) + with pytest.raises(ValidationError): + module.ContactDTO.model_validate(data) + invalid = [{"secret": "value"}] if array else {"secret": "value"} + with pytest.raises(ValidationError): + module.ContactResponseDTO.model_validate(invalid) diff --git a/tests/unit/test_invoices_models.py b/tests/unit/test_invoices_models.py new file mode 100644 index 0000000..7aab122 --- /dev/null +++ b/tests/unit/test_invoices_models.py @@ -0,0 +1,222 @@ +"""Response-only invoice contact validation, using fictional HTTP fixtures.""" + +from __future__ import annotations + +import json +from inspect import signature +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import httpx +import pytest +from pydantic import ValidationError + +from bling_erp_api import BlingClient +from bling_erp_api.models.aliases import ( + NfeIdNotaFiscalGetResponse200, + NotasFiscaisContatoResponseDTO, +) +from bling_erp_api.models.generated.invoices import ( + NfeGetResponse200, + NfeIdNotaFiscalPutRequest, + NfePostRequest, +) +from bling_erp_api.models.generated.nfce import ( + NfceGetResponse200, + NfceIdNotaFiscalConsumidorGetResponse200, + NfceIdNotaFiscalConsumidorPutRequest, + NfcePostRequest, +) +from bling_erp_api.models.generated.schemas.notas_fiscais import NotasFiscaisContatoDTO +from bling_erp_api.utils.serialization import to_json_object + +if TYPE_CHECKING: + from pydantic import BaseModel + + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "responses" + + +def _invoice_payload() -> dict[str, object]: + return cast( + "dict[str, object]", + json.loads((FIXTURES / "nfe_get_read_contact.json").read_text(encoding="utf-8")), + ) + + +def _contact(payload: dict[str, object]) -> dict[str, object]: + data = cast("dict[str, object]", payload["data"]) + return cast("dict[str, object]", data["contato"]) + + +def _mock_client(payload: dict[str, object], calls: list[httpx.Request]) -> BlingClient: + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + if int(request.url.params.get("pagina", "1")) > 1: + return httpx.Response(200, json={"data": []}) + return httpx.Response(200, json=payload) + + return BlingClient( + auth=httpx.BasicAuth("synthetic", "synthetic"), + http_client=httpx.Client( + base_url="https://api.bling.test/Api/v3", + transport=httpx.MockTransport(handler), + ), + ) + + +@pytest.mark.parametrize("with_write_fields", [False, True]) +@pytest.mark.parametrize("english_alias", [False, True]) +def test_public_invoice_read(*, with_write_fields: bool, english_alias: bool) -> None: + """Public reads parse missing or present writeOnly fields through real transport.""" + payload = _invoice_payload() + if with_write_fields: + _contact(payload).update({"tipoPessoa": "J", "contribuinte": 1}) + calls: list[httpx.Request] = [] + with _mock_client(payload, calls) as client: + response = ( + client.invoices.get(invoice_id=12345) + if english_alias + else client.notas_fiscais.obter(id_nota_fiscal=12345) + ) + + assert isinstance(response, NfeIdNotaFiscalGetResponse200) + invoice = response.data + assert invoice is not None + assert invoice.id == 12345 + assert invoice.contato.tipo_pessoa == ("J" if with_write_fields else None) + assert invoice.contato.contribuinte == (1 if with_write_fields else None) + assert invoice.contato.numero_documento == "00000000000000" + assert invoice.itens is not None + assert invoice.itens[0].codigo == "PROD-001" + assert invoice.transporte is not None + assert invoice.transporte.volumes is not None + assert invoice.transporte.volumes[0].id == 501 + assert invoice.transporte.volumes[0].model_extra == {"codigoRastreamento": "TESTE000001BR"} + serialized = to_json_object(invoice.contato) + if with_write_fields: + assert serialized["tipoPessoa"] == "J" + assert serialized["contribuinte"] == 1 + else: + assert "tipoPessoa" not in serialized + assert "contribuinte" not in serialized + assert "tipo_pessoa" not in invoice.contato.model_fields_set + assert "contribuinte" not in invoice.contato.model_fields_set + assert len(calls) == 1 + assert calls[0].method == "GET" + assert calls[0].url.path == "/Api/v3/nfe/12345" + assert not calls[0].url.query + assert calls[0].content == b"" + + +@pytest.mark.parametrize( + ("field", "value", "error_type"), + [ + ("nome", None, "missing"), + ("numeroDocumento", None, "missing"), + ("nome", [], "string_type"), + ("tipoPessoa", [], "string_type"), + ("contribuinte", "invalid", "int_parsing"), + ("id", "invalid", "int_parsing"), + ], +) +def test_public_invoice_read_rejects_invalid_contact( + field: str, value: object, error_type: str +) -> None: + """Invalid response data still fails, inspecting sanitized error metadata only.""" + payload = _invoice_payload() + contact = _contact(payload) + if value is None: + contact.pop(field) + else: + contact[field] = value + with _mock_client(payload, []) as client, pytest.raises(ValidationError) as caught: + client.notas_fiscais.obter(id_nota_fiscal=12345) + errors = caught.value.errors(include_input=False, include_context=False, include_url=False) + assert [(error["loc"], error["type"]) for error in errors] == [ + ( + ( + "data", + "contato", + {"tipoPessoa": "tipo_pessoa", "numeroDocumento": "numero_documento"}.get( + field, field + ), + ), + error_type, + ) + ] + + +@pytest.mark.parametrize("resource", ["nfe", "nfce"]) +def test_other_invoice_read_paths(resource: str) -> None: + """Lists, pagination, and NFC-e detail share the same response-only contract.""" + payload = _invoice_payload() + with _mock_client(payload, []) as client: + if resource == "nfce": + response = client.notas_fiscais_consumidor.obter(id_nota_fiscal_consumidor=12345) + assert isinstance(response, NfceIdNotaFiscalConsumidorGetResponse200) + assert response.data is not None + assert response.data.contato.tipo_pessoa is None + listing: dict[str, object] = {"data": [payload["data"]]} + calls: list[httpx.Request] = [] + with _mock_client(listing, calls) as client: + invoices = client.notas_fiscais if resource == "nfe" else client.notas_fiscais_consumidor + response = invoices.listar() + assert isinstance(response, (NfceGetResponse200, NfeGetResponse200)) + assert response.data is not None + assert response.data[0].contato.contribuinte is None + records = list(invoices.iterar(limite=100)) + assert len(records) == 1 + assert cast("dict[str, object]", records[0]["contato"])["tipo_pessoa"] is None + assert all(request.url.path == f"/Api/v3/{resource}" for request in calls) + + +@pytest.mark.parametrize( + "model", + [ + NfePostRequest, + NfeIdNotaFiscalPutRequest, + NfcePostRequest, + NfceIdNotaFiscalConsumidorPutRequest, + ], +) +@pytest.mark.parametrize("missing", ["tipoPessoa", "contribuinte"]) +def test_invoice_writes_still_require_contact_fields(model: type[BaseModel], missing: str) -> None: + """All NF-e and NFC-e POST/PUT models retain their required contact fields.""" + data = cast("dict[str, object]", _invoice_payload()["data"]) + contact = cast("dict[str, object]", data["contato"]) + contact.update({"tipoPessoa": "J", "contribuinte": 1}) + # The response transport shape differs from the request transport shape. + data.pop("transporte") + model.model_validate(data) + contact.pop(missing) + with pytest.raises(ValidationError) as caught: + model.model_validate(data) + errors = caught.value.errors(include_input=False, include_context=False, include_url=False) + expected = "tipo_pessoa" if missing == "tipoPessoa" else "contribuinte" + assert [(error["loc"], error["type"]) for error in errors] == [ + (("contato", expected), "missing") + ] + assert NotasFiscaisContatoDTO.model_fields[expected].is_required() + + +def test_response_contact_alias_contract() -> None: + """Response variants retain snake_case signatures, aliases, extras and conflicts.""" + contact = NotasFiscaisContatoResponseDTO( + nome="CLIENTE SINTETICO", numero_documento="00000000000000", tipo_pessoa="J", contribuinte=1 + ) + serialized = to_json_object(contact) + assert serialized["numeroDocumento"] == "00000000000000" + assert serialized["tipoPessoa"] == "J" + parsed = NotasFiscaisContatoResponseDTO.model_validate( + {**serialized, "novoCampo": "preservado"} + ) + assert parsed.tipo_pessoa == "J" + assert parsed.model_extra == {"novoCampo": "preservado"} + parameters = signature(NotasFiscaisContatoResponseDTO).parameters + assert "tipo_pessoa" in parameters + assert "numero_documento" in parameters + assert "tipoPessoa" not in parameters + assert "numeroDocumento" not in parameters + with pytest.raises(ValidationError): + NotasFiscaisContatoResponseDTO.model_validate({**serialized, "tipo_pessoa": "F"})