From b338039541aa10a47e71f70e465d0f47abba3a44 Mon Sep 17 00:00:00 2001 From: Dmitry Ermakovich Date: Tue, 22 Sep 2026 17:25:08 +0300 Subject: [PATCH] fix: TMS-41711 fix empty links and attachments --- docs/test-run-tags-and-links.md | 2 + .../client/api_client.py | 43 +++++++ .../testit_python_commons/client/converter.py | 34 ++++++ .../tests/client/test_test_run_v2_hydrate.py | 107 ++++++++++++++++++ update_versions.sh | 2 +- 5 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 testit-python-commons/tests/client/test_test_run_v2_hydrate.py diff --git a/docs/test-run-tags-and-links.md b/docs/test-run-tags-and-links.md index 18a2640..2c31b89 100644 --- a/docs/test-run-tags-and-links.md +++ b/docs/test-run-tags-and-links.md @@ -22,6 +22,8 @@ Python adapters pass them via config so a CI job URL is visible while the run is | Existing run (`adapterMode=0` / `1`) | Early merge in `get_test_run_id` (startup), not at session end | Merge keeps existing UI/API tags and links; adds new ones; skips duplicate tag names and link URLs. +Existing links/attachments are read via `GET /api/v2/testRuns/{id}` (TMS 5.8 workaround: +`GET /adapters/testRuns/{id}` returns empty `links`/`attachments`). Tags still come from adapters GET. ## Example diff --git a/testit-python-commons/src/testit_python_commons/client/api_client.py b/testit-python-commons/src/testit_python_commons/client/api_client.py index 0a05333..1dad0a6 100644 --- a/testit-python-commons/src/testit_python_commons/client/api_client.py +++ b/testit-python-commons/src/testit_python_commons/client/api_client.py @@ -114,12 +114,55 @@ def get_test_run(self, test_run_id: str) -> TestRunApiResult: test_run = self.__test_run_api.adapters_test_runs_id_get(id=test_run_id) if test_run is not None: + # Workaround TMS 5.8: adapters GET returns empty links/attachments. + self.__hydrate_test_run_links_and_attachments_from_v2(test_run) logging.debug(f"Got testrun: {test_run}") return test_run logging.error(f"Test run by id {test_run_id} not found!") raise Exception(f"Test run by id {test_run_id} not found!") + def __get_test_run_v2_json(self, test_run_id: str) -> dict: + """Read full test run via GET /api/v2/testRuns/{id} (links/attachments).""" + import json + import ssl + import urllib.request + + base = (self.__config.get_url() or "").rstrip("/") + url = f"{base}/api/v2/testRuns/{test_run_id}" + try: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "Authorization": f"PrivateToken {self.__config.get_private_token()}", + }, + method="GET", + ) + context = None + if not self.__config.get_cert_validation(): + context = ssl._create_unverified_context() + with urllib.request.urlopen(request, timeout=10, context=context) as response: + return json.loads(response.read().decode("utf-8")) or {} + except Exception as exc: + logging.debug("v2 getTestRun %s failed: %s", test_run_id, exc) + return {} + + def __hydrate_test_run_links_and_attachments_from_v2(self, test_run: TestRunApiResult) -> None: + payload = self.__get_test_run_v2_json(str(test_run.id)) + if not payload: + return + try: + test_run.links = Converter.v2_dicts_to_link_api_results(payload.get("links") or []) + test_run.attachments = Converter.v2_dicts_to_attachment_stubs( + payload.get("attachments") or []) + except Exception as exc: + logging.debug( + "Cannot hydrate test run %s links/attachments from v2: %s", + getattr(test_run, "id", None), + exc, + ) + @retry def update_test_run( self, diff --git a/testit-python-commons/src/testit_python_commons/client/converter.py b/testit-python-commons/src/testit_python_commons/client/converter.py index 3c965ae..38b4be6 100644 --- a/testit-python-commons/src/testit_python_commons/client/converter.py +++ b/testit-python-commons/src/testit_python_commons/client/converter.py @@ -177,6 +177,40 @@ def build_create_link_api_model(link: LinkApiResult) -> CreateLinkApiModel: def build_assign_attachment_api_model(attachment: AttachmentApiResult) -> AssignAttachmentApiModel: return AssignAttachmentApiModel(id=attachment.id) + @staticmethod + def v2_dicts_to_link_api_results(items: list) -> List[LinkApiResult]: + """Map GET /api/v2/testRuns links payloads to LinkApiResult.""" + result = [] + for item in items or []: + if not isinstance(item, dict): + continue + url = item.get("url") + if not url: + continue + link_type = item.get("type") or "Related" + result.append(LinkApiResult( + url=url, + type=LinkType(link_type), + id=item.get("id"), + title=item.get("title"), + description=item.get("description"), + )) + return result + + @staticmethod + def v2_dicts_to_attachment_stubs(items: list) -> list: + """Map v2 attachments to objects with .id (enough for AssignAttachmentApiModel).""" + from types import SimpleNamespace + + result = [] + for item in items or []: + if not isinstance(item, dict): + continue + attachment_id = item.get("id") + if attachment_id: + result.append(SimpleNamespace(id=attachment_id)) + return result + @staticmethod @adapter_logger def project_id_and_external_id_to_auto_tests_search_post_request( diff --git a/testit-python-commons/tests/client/test_test_run_v2_hydrate.py b/testit-python-commons/tests/client/test_test_run_v2_hydrate.py new file mode 100644 index 0000000..c7f864d --- /dev/null +++ b/testit-python-commons/tests/client/test_test_run_v2_hydrate.py @@ -0,0 +1,107 @@ +from types import SimpleNamespace + +from adapters_api.model.link_type import LinkType as ApiLinkType + +from testit_python_commons.client.api_client import ApiClientWorker +from testit_python_commons.client.converter import Converter +from testit_python_commons.models.link import Link +from testit_python_commons.models.link_type import LinkType + + +def test_v2_dicts_to_link_api_results(): + links = Converter.v2_dicts_to_link_api_results([ + { + "id": "l1", + "url": "https://existing", + "title": "Existing", + "type": "Related", + "description": None, + }, + {"title": "no-url"}, + ]) + assert len(links) == 1 + assert links[0].id == "l1" + assert links[0].url == "https://existing" + assert links[0].type == ApiLinkType("Related") + + +def test_v2_dicts_to_attachment_stubs(): + stubs = Converter.v2_dicts_to_attachment_stubs([ + {"id": "att-1", "name": "a.txt"}, + {"name": "no-id"}, + ]) + assert len(stubs) == 1 + assert stubs[0].id == "att-1" + + +def test_build_update_keeps_v2_links_and_attachments_when_merging(mocker): + existing_link = Converter.v2_dicts_to_link_api_results([ + {"id": "l1", "url": "https://existing", "title": "Existing", "type": "Related"}, + ])[0] + test_run = mocker.Mock() + test_run.id = "run-id" + test_run.name = "name" + test_run.description = None + test_run.launch_source = None + test_run.tags = ["ui"] + test_run.links = [existing_link] + test_run.attachments = [SimpleNamespace(id="att-1")] + + new_link = Link().set_url("https://ci.example/job/2").set_link_type(LinkType.RELATED) + model = Converter.build_update_empty_request( + test_run, + tags=["smoke"], + links=[new_link], + ) + + assert [link.url for link in model.links] == ["https://existing", "https://ci.example/job/2"] + assert [a.id for a in model.attachments] == ["att-1"] + assert model.tags == ["ui", "smoke"] + + +class TestGetTestRunHydrate: + def test_get_test_run_overlays_links_and_attachments_from_v2(self, mocker): + mocker.patch.object( + ApiClientWorker, + "_ApiClientWorker__get_status_codes", + return_value=["PASSED", "FAILED"], + ) + mocker.patch.object(ApiClientWorker, "_ApiClientWorker__get_api_client_configuration") + mocker.patch.object(ApiClientWorker, "_ApiClientWorker__get_api_client") + mocker.patch("testit_python_commons.client.api_client.TestRunsApi") + mocker.patch("testit_python_commons.client.api_client.AutoTestsApi") + mocker.patch("testit_python_commons.client.api_client.AttachmentsApi") + mocker.patch("testit_python_commons.client.api_client.TestResultsApi") + mocker.patch("testit_python_commons.client.api_client.WorkItemsApi") + mocker.patch("testit_python_commons.client.api_client.ProjectsApi") + mocker.patch("testit_python_commons.client.api_client.WorkflowsApi") + + config = mocker.Mock() + config.get_url.return_value = "https://tms.example" + config.get_private_token.return_value = "token" + config.get_cert_validation.return_value = True + config.get_project_id.return_value = "proj" + config.get_test_run_id.return_value = "run-1" + config.get_configuration_id.return_value = "cfg-1" + config.get_proxy.return_value = None + + worker = ApiClientWorker(config) + adapters_run = mocker.Mock(id="run-1", name="run", tags=["ui"], links=[], attachments=[]) + worker._ApiClientWorker__test_run_api.adapters_test_runs_id_get.return_value = adapters_run + mocker.patch.object( + worker, + "_ApiClientWorker__get_test_run_v2_json", + return_value={ + "links": [ + {"id": "l1", "url": "https://link-1", "title": "L1", "type": "Related"}, + ], + "attachments": [{"id": "att-1"}], + }, + ) + + result = worker.get_test_run("run-1") + + assert result is adapters_run + assert len(result.links) == 1 + assert result.links[0].url == "https://link-1" + assert [a.id for a in result.attachments] == ["att-1"] diff --git a/update_versions.sh b/update_versions.sh index 9b5b6e0..aa05d2f 100644 --- a/update_versions.sh +++ b/update_versions.sh @@ -1,6 +1,6 @@ #!/bin/bash -NEW_VERSION="5.2.2" +NEW_VERSION="5.2.3" echo "Updating all adapters to version: $NEW_VERSION"