diff --git a/.github/workflows/workflow-tests.yml b/.github/workflows/workflow-tests.yml index ce9cd5d..f3afb3d 100644 --- a/.github/workflows/workflow-tests.yml +++ b/.github/workflows/workflow-tests.yml @@ -23,7 +23,7 @@ jobs: sync: ${{ steps.changes.outputs.sync_all }} steps: - name: "Check out repository code" - uses: "actions/checkout@v4" + uses: "actions/checkout@v5" - name: Check for file changes uses: dorny/paths-filter@v3 id: changes @@ -31,35 +31,37 @@ jobs: token: ${{ github.token }} filters: .github/file-filters.yml - # TODO: Create the unit tests... - # unit-tests: - # name: Unit Tests (py${{ matrix.python-version }}) - # strategy: - # matrix: - # python-version: - # - "3.9" - # - "3.10" - # - "3.11" - # - "3.12" - # if: needs.files-changed.outputs.sync == 'true' - # runs-on: ubuntu-latest - # timeout-minutes: 30 - # defaults: - # run: - # working-directory: sync/ - # steps: - # - name: "Check out repository code" - # uses: "actions/checkout@v4" - # - name: Set up Python ${{ matrix.python-version }} - # uses: actions/setup-python@v5 - # with: - # python-version: ${{ matrix.python-version }} - # - name: "Setup environment" - # run: | - # pipx install poetry - # poetry config virtualenvs.prefer-active-python true - # pip install toml invoke - # - name: "Install Package" - # run: "poetry install" - # - name: "markdownlint-cli2, ruff, and pylint Tests" - # run: "poetry run invoke tests.tests-unit" + unit-tests: + name: "Unit Tests (py${{ matrix.python-version }})" + needs: ["files-changed"] + if: needs.files-changed.outputs.sync == 'true' + runs-on: "ubuntu-22.04" + timeout-minutes: 15 + strategy: + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + steps: + - name: "Check out repository code" + uses: "actions/checkout@v5" + + - name: "Install uv" + uses: "astral-sh/setup-uv@v4" + with: + version: "latest" + + - name: "Set up Python ${{ matrix.python-version }}" + run: uv python install ${{ matrix.python-version }} + + - name: "Install dependencies" + run: uv sync --frozen --extra dev + + # Runs only `-m "not integration"`. Integration tests need a live + # Infrahub instance and are opted into via `invoke tests-integration` + # (run locally; not gated here yet — TODO: add Infrahub service + # container or compose stack and a separate integration-tests job). + - name: "pytest: unit tests" + run: uv run invoke tests.tests-unit diff --git a/infrahub_sync/adapters/infrahub.py b/infrahub_sync/adapters/infrahub.py index 6f351f2..573b118 100644 --- a/infrahub_sync/adapters/infrahub.py +++ b/infrahub_sync/adapters/infrahub.py @@ -343,11 +343,17 @@ def infrahub_node_to_diffsync(self, node: InfrahubNodeSync) -> dict[str, Any]: if has_field(config=self.config, name=node._schema.kind, field=attr_name): attr = getattr(node, attr_name) val = attr.value - # Convert IP types and other non-string values to strings for DiffSync models + # IP types come back from the Infrahub SDK as ipaddress + # objects; DiffSync models store them as their string form + # (e.g. "10.0.0.1/32"), so normalise here. Other non-string + # kinds — List, Number, Boolean, DateTime — pass through + # unchanged: stringifying them turns a real list `[]` into + # the four-character literal `"[]"`, which then fails + # Pydantic validation on `list[str]`-typed fields. if isinstance( val, (ipaddress.IPv4Interface, ipaddress.IPv6Interface, ipaddress.IPv4Network, ipaddress.IPv6Network), - ) or (val is not None and not isinstance(val, str)): + ): data[attr_name] = str(val) else: data[attr_name] = val diff --git a/pyproject.toml b/pyproject.toml index 7c5a72a..a8ba0bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,6 +124,9 @@ asyncio_mode = "auto" testpaths = [ "tests" ] +markers = [ + "integration: tests that require a running Infrahub instance. Skipped by default; opt in with -m integration and INFRAHUB_ADDRESS + INFRAHUB_API_TOKEN set.", +] filterwarnings = [ "ignore:Module already imported so cannot be rewritten", "ignore:the imp module is deprecated", diff --git a/tasks/tests.py b/tasks/tests.py index 7c6278f..572748a 100644 --- a/tasks/tests.py +++ b/tasks/tests.py @@ -13,9 +13,17 @@ @task def tests_unit(context: Context) -> None: - pass + """Run unit tests — everything under tests/ except integration-marked tests.""" + with context.cd(MAIN_DIRECTORY): + context.run('pytest -m "not integration"', pty=True) @task def tests_integration(context: Context) -> None: - pass + """Run integration tests against a live Infrahub. + + Requires INFRAHUB_ADDRESS and INFRAHUB_API_TOKEN in the environment; + tests skip themselves when those aren't set. + """ + with context.cd(MAIN_DIRECTORY): + context.run("pytest -m integration", pty=True) diff --git a/tests/adapters/test_infrahub_node_to_diffsync.py b/tests/adapters/test_infrahub_node_to_diffsync.py new file mode 100644 index 0000000..8d557f7 --- /dev/null +++ b/tests/adapters/test_infrahub_node_to_diffsync.py @@ -0,0 +1,402 @@ +"""Tests for InfrahubAdapter.infrahub_node_to_diffsync attribute serialisation. + +These tests exercise the value-transformation logic in +``infrahub_sync/adapters/infrahub.py``: which attribute kinds round-trip +unchanged, which get string-coerced (e.g. ipaddress objects), and which +must NOT be coerced (e.g. ``kind: List``). + +The tests bypass network setup by constructing the adapter via +``__new__`` and supplying only the bits the method touches: ``config``, +plus a node-like object exposing ``id``, ``_schema``, and one attribute +per kind. Relationship handling is out of scope here — the fixtures use +empty ``relationships`` so the method's relationship branch is a no-op. +""" + +from __future__ import annotations + +import ipaddress +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from infrahub_sync import ( + SchemaMappingField, + SchemaMappingModel, + SyncAdapter, + SyncConfig, +) +from infrahub_sync.adapters.infrahub import InfrahubAdapter + +# Test constant — extracted to avoid PLR2004 (magic-value) warnings on the +# repeated literal. 443 is just a representative HTTPS port for the Number +# kind; no significance beyond exercising int round-trip. +HTTPS_PORT = 443 + + +# --------------------------------------------------------------------------- +# Lightweight stand-ins for ``InfrahubNodeSync`` and its schema. We only +# need the bits ``infrahub_node_to_diffsync`` reads — no SDK plumbing. +# --------------------------------------------------------------------------- + + +@dataclass +class FakeAttr: + """Stand-in for ``InfrahubNodeSync``'s attribute manager — only ``.value`` is read.""" + + value: Any + + +@dataclass +class FakeSchema: + """Stand-in for ``node._schema``.""" + + kind: str + attribute_names: list[str] = field(default_factory=list) + relationships: list = field(default_factory=list) + relationship_names: list[str] = field(default_factory=list) + + +class FakeNode: + """Stand-in for ``InfrahubNodeSync`` exposing the attributes by name.""" + + def __init__(self, node_id: str, kind: str, attrs: dict[str, Any]) -> None: + self.id = node_id + self._schema = FakeSchema(kind=kind, attribute_names=list(attrs.keys())) + for name, value in attrs.items(): + setattr(self, name, FakeAttr(value=value)) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_config(kind: str, field_names: list[str]) -> SyncConfig: + """SyncConfig with one schema_mapping entry covering ``field_names``.""" + + return SyncConfig( + name="test", + source=SyncAdapter(name="src", adapter="x:x"), + destination=SyncAdapter(name="dst", adapter="x:x"), + order=[kind], + schema_mapping=[ + SchemaMappingModel( + name=kind, + mapping=kind, + identifiers=["name"], + fields=[SchemaMappingField(name=n, mapping=n) for n in field_names], + ), + ], + ) + + +def _make_adapter(kind: str, field_names: list[str]) -> InfrahubAdapter: + """Build an InfrahubAdapter that bypasses network setup. + + ``__init__`` requires live Infrahub plumbing (client, schema fetch, + source/owner lookup). We don't need any of that for value-transform + tests — only ``self.config`` is touched. Using ``__new__`` skips + ``__init__`` entirely. + """ + adapter = InfrahubAdapter.__new__(InfrahubAdapter) + adapter.config = _make_config(kind, field_names) + return adapter + + +def _serialise(adapter: InfrahubAdapter, node: FakeNode) -> dict[str, Any]: + """Call ``adapter.infrahub_node_to_diffsync`` on a duck-typed fake node. + + The method's parameter annotation is ``InfrahubNodeSync``, but the + function body only reads ``node.id``, ``node._schema``, and per-name + attribute managers — all of which ``FakeNode`` provides. The type + suppression below is a single, scoped location rather than once per + test. + """ + return adapter.infrahub_node_to_diffsync(node=node) # ty: ignore[invalid-argument-type] + + +# --------------------------------------------------------------------------- +# Tests — one per attribute kind. The pass-through cases assert both the +# value AND the resulting Python type. Pydantic's downstream validation +# rejects a stringified list (``"[]"``) against a ``list[str]`` field, so +# locking the type at the dict boundary is the contract that matters; +# value equality alone (``[] == "[]"`` is False but ``str([]) == "[]"``) +# is a weaker invariant. +# --------------------------------------------------------------------------- + + +def test_always_sets_local_id_from_node_id() -> None: + """``local_id`` must be the stringified node id, regardless of attrs.""" + adapter = _make_adapter("Thing", []) + node = FakeNode(node_id="abc-123", kind="Thing", attrs={}) + data = _serialise(adapter, node) + assert data == {"local_id": "abc-123"} + + +def test_text_value_passes_through_unchanged() -> None: + """``kind: Text`` is already str — no transformation needed.""" + adapter = _make_adapter("Thing", ["label"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"label": "hello"}) + data = _serialise(adapter, node) + assert data["label"] == "hello" + assert isinstance(data["label"], str) + + +def test_list_value_passes_through_as_list() -> None: + """``kind: List`` must arrive as a real list. + + Stringifying turns a list into its ``repr`` (e.g. ``"[]"`` / ``"['a']"``), + which then fails Pydantic validation against ``list[str]``-typed DiffSync + fields. + """ + adapter = _make_adapter("Thing", ["tags"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"tags": ["foo", "bar"]}) + data = _serialise(adapter, node) + assert data["tags"] == ["foo", "bar"] + assert isinstance(data["tags"], list) + + +def test_empty_list_value_passes_through_as_empty_list() -> None: + """Empty list is a separate code path under ``str()`` — exercise it explicitly.""" + adapter = _make_adapter("Thing", ["tags"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"tags": []}) + data = _serialise(adapter, node) + assert data["tags"] == [] + assert isinstance(data["tags"], list) + + +def test_number_value_passes_through_as_int() -> None: + """``kind: Number`` must stay numeric, not become a stringified int.""" + adapter = _make_adapter("Thing", ["port"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"port": HTTPS_PORT}) + data = _serialise(adapter, node) + assert data["port"] == HTTPS_PORT + assert isinstance(data["port"], int) + + +def test_boolean_value_passes_through_as_bool() -> None: + """``kind: Boolean`` must stay bool, not become ``"True"`` / ``"False"``.""" + adapter = _make_adapter("Thing", ["enabled"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"enabled": True}) + data = _serialise(adapter, node) + assert data["enabled"] is True + assert isinstance(data["enabled"], bool) + + +def test_dict_value_passes_through_as_dict() -> None: + """``kind: JSON`` arrives from the SDK as a dict — must not be stringified.""" + adapter = _make_adapter("Thing", ["payload"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"payload": {"a": 1, "b": [2, 3]}}) + data = _serialise(adapter, node) + assert data["payload"] == {"a": 1, "b": [2, 3]} + assert isinstance(data["payload"], dict) + + +def test_datetime_string_value_passes_through() -> None: + """``kind: DateTime`` already arrives from the SDK as an ISO-8601 string.""" + adapter = _make_adapter("Thing", ["expires_at"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"expires_at": "2027-08-21T08:21:16Z"}) + data = _serialise(adapter, node) + assert data["expires_at"] == "2027-08-21T08:21:16Z" + assert isinstance(data["expires_at"], str) + + +def test_none_value_passes_through_as_none() -> None: + """A null attribute must stay None, not become the string ``"None"``.""" + adapter = _make_adapter("Thing", ["nickname"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"nickname": None}) + data = _serialise(adapter, node) + assert data["nickname"] is None + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (ipaddress.IPv4Interface("10.0.0.1/24"), "10.0.0.1/24"), + (ipaddress.IPv6Interface("2001:db8::1/64"), "2001:db8::1/64"), + (ipaddress.IPv4Network("10.0.0.0/24"), "10.0.0.0/24"), + (ipaddress.IPv6Network("2001:db8::/64"), "2001:db8::/64"), + ], +) +def test_ip_types_are_stringified(raw: Any, expected: str) -> None: # noqa: ANN401 — heterogeneous ipaddress types + """IP types: stringified intentionally — DiffSync models store them as str.""" + adapter = _make_adapter("Thing", ["address"]) + node = FakeNode(node_id="1", kind="Thing", attrs={"address": raw}) + data = _serialise(adapter, node) + assert data["address"] == expected + assert isinstance(data["address"], str) + + +def test_field_not_in_schema_mapping_is_skipped() -> None: + """``has_field`` filters out attributes not in the schema_mapping config.""" + adapter = _make_adapter("Thing", ["wanted"]) # only "wanted" is in the mapping + node = FakeNode(node_id="1", kind="Thing", attrs={"wanted": "yes", "ignored": "no"}) + data = _serialise(adapter, node) + assert "wanted" in data + assert "ignored" not in data + + +def test_mixed_kinds_round_trip_together() -> None: + """End-to-end sanity: a node with one attribute of each kind survives intact. + + This is what the real ``CertificateCertificate`` shape looks like: + serial (str), expiration (datetime as str), alternative_name (list), + key_size (str via Dropdown), and a None optional field. + """ + field_names = ["serial", "expiration", "sans", "key_size", "validation", "is_revoked"] + adapter = _make_adapter("Cert", field_names) + node = FakeNode( + node_id="cert-1", + kind="Cert", + attrs={ + "serial": "abc123", + "expiration": "2027-08-21T08:21:16Z", + "sans": ["a.com", "b.com"], + "key_size": "2048", + "validation": None, + "is_revoked": False, + }, + ) + data = _serialise(adapter, node) + assert data == { + "local_id": "cert-1", + "serial": "abc123", + "expiration": "2027-08-21T08:21:16Z", + "sans": ["a.com", "b.com"], + "key_size": "2048", + "validation": None, + "is_revoked": False, + } + # Explicit type checks on the kinds where Pydantic won't coerce a + # stringified value back (list, bool) — locks in correct typing at + # the dict boundary. + assert isinstance(data["sans"], list) + assert isinstance(data["is_revoked"], bool) + + +# --------------------------------------------------------------------------- +# End-to-end: the real consumer of this method's output is +# ``DiffSyncModel(**data)`` inside ``InfrahubAdapter.load``. Validating +# the dict shape alone misses the layer that matters in practice — +# Pydantic typed-field validation on the DiffSync model. These tests +# feed the adapter's output straight into a model and prove construction +# succeeds with values of the right Python type. +# --------------------------------------------------------------------------- + + +from diffsync import DiffSyncModel # noqa: E402 + +from infrahub_sync import DiffSyncModelMixin # noqa: E402 + + +class TypedCertModel(DiffSyncModelMixin, DiffSyncModel): + """A Pydantic-typed DiffSync model representative of real downstream usage. + + Every attribute is typed *exactly* (``list[str]`` not ``list``; + ``int`` not ``int | str``). The adapter's output must construct this + model cleanly without Pydantic coercing values across types. + """ + + _modelname = "TypedCert" + _identifiers = ("serial",) + _attributes = ( + "subject_dn", + "expiration", + "port", + "enabled", + "sans", + "metadata", + "validation", + ) + + serial: str + subject_dn: str + expiration: str + port: int + enabled: bool + sans: list[str] + metadata: dict[str, Any] + validation: str | None = None + local_id: str | None = None + local_data: Any | None = None + + +def test_adapter_output_constructs_pydantic_diffsync_model() -> None: + """The output of ``infrahub_node_to_diffsync`` must be directly + consumable by ``DiffSyncModel(**data)``. + + This is what ``InfrahubAdapter.load`` does at runtime. The other + tests in this module check dict shape; this one checks the contract + that matters downstream: Pydantic-typed model construction. + """ + field_names = ["serial", "subject_dn", "expiration", "port", "enabled", "sans", "metadata", "validation"] + adapter = _make_adapter("TypedCert", field_names) + node = FakeNode( + node_id="cert-real", + kind="TypedCert", + attrs={ + "serial": "abc123", + "subject_dn": "CN=app.example.com", + "expiration": "2027-08-21T08:21:16Z", + "port": HTTPS_PORT, + "enabled": True, + "sans": ["app.example.com", "alt.example.com"], + "metadata": {"issuer": "demo-ca", "chain_depth": 2}, + "validation": None, + }, + ) + + data = _serialise(adapter, node) + + # The adapter's dict must satisfy strict Pydantic field types — any + # cross-type coercion here would surface as ``ValidationError``. + instance = TypedCertModel(**data) + + # Values survive intact. + assert instance.serial == "abc123" + assert instance.subject_dn == "CN=app.example.com" + assert instance.port == HTTPS_PORT + assert instance.enabled is True + assert instance.sans == ["app.example.com", "alt.example.com"] + assert instance.metadata == {"issuer": "demo-ca", "chain_depth": 2} + assert instance.validation is None + + # Each non-string kind must arrive with the exact Python type the + # model annotates — Pydantic strict-mode would reject a coerced str. + assert isinstance(instance.sans, list) + assert isinstance(instance.port, int) + assert isinstance(instance.enabled, bool) + assert isinstance(instance.metadata, dict) + + +def test_adapter_output_constructs_model_with_empty_list() -> None: + """Empty-list and empty-dict edge cases must construct the model. + + ``str([])`` and ``str({})`` produce ``"[]"`` / ``"{}"`` — distinct + Python literals that look list/dict-ish but fail Pydantic validation + against ``list[str]`` / ``dict[str, Any]`` fields. + """ + field_names = ["serial", "subject_dn", "expiration", "port", "enabled", "sans", "metadata"] + adapter = _make_adapter("TypedCert", field_names) + node = FakeNode( + node_id="cert-empty", + kind="TypedCert", + attrs={ + "serial": "empty-sans", + "subject_dn": "CN=no-sans.example.com", + "expiration": "2027-08-21T08:21:16Z", + "port": HTTPS_PORT, + "enabled": True, + "sans": [], + "metadata": {}, + }, + ) + + data = _serialise(adapter, node) + instance = TypedCertModel(**data) + + assert instance.sans == [] + assert isinstance(instance.sans, list) + assert instance.metadata == {} + assert isinstance(instance.metadata, dict) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..3a3109d --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,10 @@ +"""Integration tests for infrahub-sync. + +These tests require a running Infrahub instance and are skipped when +``INFRAHUB_ADDRESS`` and ``INFRAHUB_API_TOKEN`` are not set in the +environment. Run locally with:: + + INFRAHUB_ADDRESS=http://localhost:8000 \\ + INFRAHUB_API_TOKEN= \\ + pytest tests/integration -m integration +""" diff --git a/tests/integration/test_infrahub_node_to_diffsync_integration.py b/tests/integration/test_infrahub_node_to_diffsync_integration.py new file mode 100644 index 0000000..4ba08a0 --- /dev/null +++ b/tests/integration/test_infrahub_node_to_diffsync_integration.py @@ -0,0 +1,272 @@ +"""Integration tests for InfrahubAdapter.infrahub_node_to_diffsync. + +The unit tests in ``tests/adapters/`` use lightweight ``FakeNode`` stand-ins +that match the shape ``infrahub_node_to_diffsync`` reads. That catches the +behavior under test but can't catch drift between our fixture and the real +``InfrahubNodeSync`` shape returned by the SDK — e.g. if a future SDK +version starts returning ``DateTime`` as a real ``datetime`` object instead +of an ISO string, the unit tests would still pass but the contract with +downstream DiffSync models would break. + +These integration tests exercise the same code path against a live +Infrahub. They apply a throwaway schema with one attribute per kind, +create a node, run the adapter, feed the result into a Pydantic-typed +DiffSync model, and tear everything down. + +Skipped automatically when ``INFRAHUB_ADDRESS`` + ``INFRAHUB_API_TOKEN`` +are not set. Run locally with:: + + INFRAHUB_ADDRESS=http://localhost:8000 \\ + INFRAHUB_API_TOKEN= \\ + pytest tests/integration -m integration +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Any + +import pytest +import requests +from diffsync import DiffSyncModel + +from infrahub_sync import ( + DiffSyncModelMixin, + SchemaMappingField, + SchemaMappingModel, + SyncAdapter, + SyncConfig, +) +from infrahub_sync.adapters.infrahub import InfrahubAdapter + +if TYPE_CHECKING: + from collections.abc import Iterator + +pytestmark = pytest.mark.integration + + +# --------------------------------------------------------------------------- +# Setup / teardown helpers +# --------------------------------------------------------------------------- + + +_SCHEMA = { + "version": "1.0", + "nodes": [ + { + "name": "AdapterProbe", + "namespace": "Test", + "include_in_menu": False, + "attributes": [ + {"name": "name", "kind": "Text", "unique": True}, + {"name": "tags", "kind": "List", "optional": True}, + {"name": "port", "kind": "Number", "optional": True}, + {"name": "enabled", "kind": "Boolean", "optional": True}, + {"name": "issued_at", "kind": "DateTime", "optional": True}, + {"name": "payload", "kind": "JSON", "optional": True}, + {"name": "address", "kind": "IPHost", "optional": True}, + ], + }, + ], +} + +_NODE_VALUES: dict[str, Any] = { + "name": "adapter-probe-1", + "tags": ["alpha", "beta"], + "port": 443, + "enabled": True, + "issued_at": "2027-08-21T08:21:16Z", + "payload": {"k": "v", "n": 7}, + "address": "10.10.10.10/24", +} + + +def _env_or_skip() -> tuple[str, str]: + address = os.environ.get("INFRAHUB_ADDRESS") + token = os.environ.get("INFRAHUB_API_TOKEN") + if not address or not token: + pytest.skip("INFRAHUB_ADDRESS and INFRAHUB_API_TOKEN must be set") + return address, token + + +def _graphql(address: str, token: str, query: str) -> dict[str, Any]: + response = requests.post( + f"{address}/graphql", + headers={"X-INFRAHUB-KEY": token, "Content-Type": "application/json"}, + json={"query": query}, + timeout=30, + ) + response.raise_for_status() + body = response.json() + if body.get("errors"): + msg = f"GraphQL errors: {body['errors']}" + raise RuntimeError(msg) + return body + + +@pytest.fixture +def live_probe_node() -> Iterator[tuple[str, str, str]]: + """Apply the throwaway schema, create one node, yield ids for the test, tear down.""" + address, token = _env_or_skip() + + # Load the test schema via the schema API (matches what infrahubctl does). + schema_response = requests.post( + f"{address}/api/schema/load?branch=main", + headers={"X-INFRAHUB-KEY": token, "Content-Type": "application/json"}, + json={"schemas": [_SCHEMA]}, + timeout=60, + ) + schema_response.raise_for_status() + + # Create one probe node with one attribute of each kind populated. + inputs = ", ".join(f"{name}: {{value: {_graphql_literal(value)}}}" for name, value in _NODE_VALUES.items()) + created = _graphql( + address, + token, + f"mutation {{ TestAdapterProbeCreate(data: {{{inputs}}}) {{ ok object {{ id }} }} }}", + ) + node_id = created["data"]["TestAdapterProbeCreate"]["object"]["id"] + + try: + yield address, token, node_id + finally: + _graphql( + address, + token, + f'mutation {{ TestAdapterProbeDelete(data: {{id: "{node_id}"}}) {{ ok }} }}', + ) + + +def _graphql_literal(value: Any) -> str: # noqa: ANN401 — heterogeneous Python types by design + """Render a Python value as a GraphQL literal for our input shape. + + Handles only the kinds the test fixture uses; the dict / list / scalar + cases are sufficient. Keeps the fixture free of an external GraphQL + library dependency. + """ + if isinstance(value, str): + return f'"{value}"' + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, list): + return "[" + ", ".join(_graphql_literal(v) for v in value) + "]" + if isinstance(value, dict): + items = ", ".join(f"{k}: {_graphql_literal(v)}" for k, v in value.items()) + return "{" + items + "}" + msg = f"Unsupported literal kind: {type(value).__name__}" + raise TypeError(msg) + + +# --------------------------------------------------------------------------- +# Pydantic-typed DiffSync model — the actual consumer of the adapter output. +# --------------------------------------------------------------------------- + + +class AdapterProbe(DiffSyncModelMixin, DiffSyncModel): + _modelname = "TestAdapterProbe" + _identifiers = ("name",) + _attributes = ("tags", "port", "enabled", "issued_at", "payload", "address") + + name: str + tags: list[str] + port: int + enabled: bool + issued_at: str + payload: dict[str, Any] + address: str + local_id: str | None = None + local_data: Any | None = None + + +# --------------------------------------------------------------------------- +# The test +# --------------------------------------------------------------------------- + + +def test_real_sdk_node_round_trips_into_typed_diffsync_model( + live_probe_node: tuple[str, str, str], +) -> None: + """End-to-end: load a real SDK node, run it through the adapter, feed + into a Pydantic-typed DiffSync model. Asserts both per-field type and + value survive the trip — that's the contract ``InfrahubAdapter.load`` + relies on at runtime. + """ + address, token, _ = live_probe_node + + cfg = SyncConfig( + name="integration", + source=SyncAdapter(name="src", adapter="x:x"), + destination=SyncAdapter( + name="dst", + adapter="x:x", + settings={"url": address, "token": token}, + ), + order=["TestAdapterProbe"], + schema_mapping=[ + SchemaMappingModel( + name="TestAdapterProbe", + mapping="TestAdapterProbe", + identifiers=["name"], + fields=[ + SchemaMappingField(name=n, mapping=n) + for n in ("name", "tags", "port", "enabled", "issued_at", "payload", "address") + ], + ), + ], + ) + + # Wire a real InfrahubAdapter against the lab. We don't need a real + # source — the adapter loads from Infrahub itself, which is the side + # under test. ``__new__`` skips the source-node/owner setup that + # would otherwise require a CoreAccount lookup. + adapter = InfrahubAdapter.__new__(InfrahubAdapter) + adapter.config = cfg + adapter.target = "dst" + adapter.client = _make_infrahub_client(address, token) + adapter.schema = adapter.client.schema.all(branch="main") + adapter.store = type("S", (), {"get": lambda _self, **_k: None})() + adapter.source_node = None + adapter.owner_node = None + + nodes = adapter.client.filters( + kind="TestAdapterProbe", + name__value=_NODE_VALUES["name"], + populate_store=True, + ) + assert nodes, "Probe node should be visible to the SDK after creation" + node = nodes[0] + + data = adapter.infrahub_node_to_diffsync(node=node) + + # Pydantic-typed DiffSync model construction is the consumer contract + # under test. Construction must not raise. + instance = AdapterProbe(**data) + + # Value + type checks per kind. Pydantic ``list[str]`` and + # ``dict[str, Any]`` fields reject string inputs outright, so both + # halves of the contract (value AND Python type) are asserted here. + assert instance.name == _NODE_VALUES["name"] + assert instance.tags == _NODE_VALUES["tags"] + assert isinstance(instance.tags, list) + assert instance.port == _NODE_VALUES["port"] + assert isinstance(instance.port, int) + assert instance.enabled is _NODE_VALUES["enabled"] + assert isinstance(instance.enabled, bool) + assert instance.issued_at == _NODE_VALUES["issued_at"] + assert isinstance(instance.issued_at, str) + assert instance.payload == _NODE_VALUES["payload"] + assert isinstance(instance.payload, dict) + # IPHost intentionally stringifies — the SDK returns an + # ``IPv4Interface``; the adapter normalises to its str form. + assert instance.address == _NODE_VALUES["address"] + assert isinstance(instance.address, str) + + +def _make_infrahub_client(address: str, token: str) -> Any: # noqa: ANN401 — SDK client is dynamically typed + """Build a sync Infrahub client. Imported lazily so unit-only test + runs aren't forced to install the SDK extras.""" + from infrahub_sdk import Config, InfrahubClientSync + + return InfrahubClientSync(config=Config(address=address, api_token=token))