|
| 1 | +import inspect |
| 2 | +import json |
| 3 | +from copy import deepcopy |
| 4 | + |
| 5 | +import httpx |
| 6 | +import pytest |
| 7 | + |
| 8 | +from qca import AsyncForward, AsyncManaged, Forward, Managed |
| 9 | +from qca._version import __version__ |
| 10 | +from qca.common._types import NOT_GIVEN |
| 11 | + |
| 12 | +STAMP = { |
| 13 | + "__qca_sdk_created": "true", |
| 14 | + "__qca_sdk_language": "python", |
| 15 | + "__qca_sdk_version": __version__, |
| 16 | +} |
| 17 | + |
| 18 | + |
| 19 | +async def resolve(value): |
| 20 | + return await value if inspect.isawaitable(value) else value |
| 21 | + |
| 22 | + |
| 23 | +@pytest.fixture(params=[("forward", False), ("forward", True), ("managed", False), ("managed", True)]) |
| 24 | +async def session_client(request): |
| 25 | + mode, async_ = request.param |
| 26 | + recorded = [] |
| 27 | + |
| 28 | + def handle(req): |
| 29 | + recorded.append(req) |
| 30 | + return httpx.Response(200, json={"id": "sess_test"}) |
| 31 | + |
| 32 | + cls = { |
| 33 | + (False, "forward"): Forward, |
| 34 | + (True, "forward"): AsyncForward, |
| 35 | + (False, "managed"): Managed, |
| 36 | + (True, "managed"): AsyncManaged, |
| 37 | + }[async_, mode] |
| 38 | + http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle)) |
| 39 | + client = cls(pat="test", http_client=http, max_retries=0) |
| 40 | + params = ( |
| 41 | + {"identity_id": "idn", "template_id": "tmpl"} |
| 42 | + if mode == "forward" |
| 43 | + else {"agent": "agent", "environment_id": "env"} |
| 44 | + ) |
| 45 | + yield client, params, recorded, mode |
| 46 | + await resolve(client.close()) |
| 47 | + |
| 48 | + |
| 49 | +@pytest.mark.parametrize("metadata", [NOT_GIVEN, None, {}, {"ticket": "A-123"}, STAMP]) |
| 50 | +async def test_create_stamps_metadata_without_mutating_input(session_client, metadata): |
| 51 | + client, params, recorded, _ = session_client |
| 52 | + original = deepcopy(metadata) if isinstance(metadata, dict) else metadata |
| 53 | + for _ in range(2): |
| 54 | + await resolve(client.sessions.create(**params, metadata=metadata)) |
| 55 | + expected = {**(metadata if isinstance(metadata, dict) else {}), **STAMP} |
| 56 | + for req in recorded: |
| 57 | + assert req.method == "POST" and req.url.path.endswith("/sessions") |
| 58 | + assert json.loads(req.content) == {**params, "metadata": expected} |
| 59 | + assert req.headers["x-qoder-package-version"] == expected["__qca_sdk_version"] |
| 60 | + assert metadata == original |
| 61 | + |
| 62 | + |
| 63 | +@pytest.mark.parametrize("key", STAMP) |
| 64 | +@pytest.mark.parametrize("value", ["user-value", None, True, 12]) |
| 65 | +@pytest.mark.parametrize("extra", [False, True]) |
| 66 | +async def test_conflicts_fail_before_transport(session_client, key, value, extra): |
| 67 | + client, params, recorded, _ = session_client |
| 68 | + metadata = {"ticket": "A-123", key: value} |
| 69 | + kwargs = {"extra_body": {"metadata": metadata}} if extra else {"metadata": metadata} |
| 70 | + with pytest.raises(ValueError, match=f"conflicting SDK metadata field: {key}") as exc: |
| 71 | + await resolve(client.sessions.create(**params, **kwargs)) |
| 72 | + assert "user-value" not in str(exc.value) |
| 73 | + assert metadata == {"ticket": "A-123", key: value} |
| 74 | + assert not recorded |
| 75 | + |
| 76 | + |
| 77 | +async def test_extra_body_and_headers_keep_real_sdk_version(session_client): |
| 78 | + client, params, recorded, mode = session_client |
| 79 | + metadata = {"ticket": "extra", "__qca_sdk_future": "preserved"} |
| 80 | + if mode == "forward": |
| 81 | + metadata["nested"] = {"items": [1, False, None]} |
| 82 | + original = deepcopy(metadata) |
| 83 | + await resolve( |
| 84 | + client.sessions.create( |
| 85 | + **params, |
| 86 | + metadata={"replaced": "typed"}, |
| 87 | + extra_body={"metadata": metadata}, |
| 88 | + extra_headers={"X-Qoder-Package-Version": "custom-header"}, |
| 89 | + ) |
| 90 | + ) |
| 91 | + assert json.loads(recorded[0].content) == {**params, "metadata": {**original, **STAMP}} |
| 92 | + assert metadata == original |
| 93 | + |
| 94 | + |
| 95 | +@pytest.mark.parametrize("metadata", [[], "invalid", 1, False]) |
| 96 | +async def test_invalid_metadata_fails_before_transport(session_client, metadata): |
| 97 | + client, params, recorded, _ = session_client |
| 98 | + with pytest.raises(ValueError, match="metadata must be a JSON object or null"): |
| 99 | + await resolve(client.sessions.create(**params, extra_body={"metadata": metadata})) |
| 100 | + assert not recorded |
| 101 | + |
| 102 | + |
| 103 | +async def test_update_and_retrieve_do_not_stamp_metadata(session_client): |
| 104 | + client, _, recorded, _ = session_client |
| 105 | + metadata = {"ticket": "A-123", "__qca_sdk_language": "historic"} |
| 106 | + await resolve(client.sessions.update("sess_test", metadata=metadata)) |
| 107 | + await resolve(client.sessions.retrieve("sess_test")) |
| 108 | + assert json.loads(recorded[0].content) == {"metadata": metadata} |
| 109 | + assert not recorded[1].content |
0 commit comments