From 1caf38947a87e773e981697311c6a97075df7265 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Fri, 28 Aug 2026 17:29:22 +0200 Subject: [PATCH 01/59] draft for testing /shells endpoints This is a draft to test unittest implementations for the server. Implement unittests using `werkzeug.test.Client` to test the `/shells` endpoint, as example. Each endpoint and method is tested for success and possible failures. The object store is reset prior to every test case. Tests are repeated for `application/json` and `application/xml` Content-Types. Therfore test are written against an abstract `FromatClient` that covers the details of (de-)serialization behind a simple API for requesting and parsing. Therefore the base class defining the test cases (`_ShellsEndpointTest`) is disabled for testing. Two subclasses are derived from this class, one for each format, that define the correct `FormatClient` and execute the tests. --- server/test/interfaces/format_utils.py | 164 ++++++++ server/test/interfaces/test_repository.py | 460 ++++++++++++++++------ 2 files changed, 511 insertions(+), 113 deletions(-) create mode 100644 server/test/interfaces/format_utils.py diff --git a/server/test/interfaces/format_utils.py b/server/test/interfaces/format_utils.py new file mode 100644 index 000000000..be27b8499 --- /dev/null +++ b/server/test/interfaces/format_utils.py @@ -0,0 +1,164 @@ +import abc +import json +from typing import Any, Optional + +from basyx.aas import adapter +from basyx.aas.adapter._generic import XML_NS_MAP +from lxml import etree +from werkzeug.test import Client, TestResponse + + +class FormatClient(abc.ABC): + """ + Wraps a :class:`werkzeug.test.Client` and hides the request/response *format* (JSON or XML) behind a small, + format-agnostic API, so an endpoint test can be written once and run against every format. + + * request helpers (:meth:`get` / :meth:`post` / ...) inject the ``Accept`` / ``Content-Type`` headers and + serialize model objects passed as ``obj`` with :meth:`serialize`, + * parsing helpers (:meth:`parse_object` / :meth:`parse_collection` / :meth:`identifier` / ...) turn a response + body into plain Python values that are identical for both formats. + """ + + content_type: str + + def __init__(self, client: Client): + self.client = client + + def request(self, method: str, path: str, obj: Optional[object] = None, data: Any = None, **kwargs) -> TestResponse: + """ + Issue a request, setting the ``Accept`` header to the class' :attr:`content_type`. + + :param method: HTTP method to perform the request with + :param path: path to perform the request to + :param obj: If given, the object is parsed to :attr:`content_type` using :meth:`serialize` and sent as body. + :param data: If given, this is directly sent as body. Caution: gets overridden by :param:`obj` + :param kwargs: Additional arguments passed directly to :meth:`werkzeug.test.Client.open`. Can be used to set + different ``Content-Type`` for :param:`data`. + :return: The :class:`~werkzeug.test.TestResponse` object + """ + headers = dict(kwargs or {}) + headers["Accept"] = self.content_type + + if obj is not None: + data = self.serialize(obj) + kwargs.pop("content_type", None) + + return self.client.open( + path, method=method, headers=headers, data=data, content_type=self.content_type, **kwargs + ) + + def get(self, path: str, **kwargs) -> TestResponse: + return self.request("GET", path, **kwargs) + + def post(self, path: str, obj: Optional[object] = None, **kwargs: Any) -> TestResponse: + return self.request("POST", path, obj=obj, **kwargs) + + def put(self, path: str, obj: Optional[object] = None, **kwargs: Any) -> TestResponse: + return self.request("PUT", path, obj=obj, **kwargs) + + def patch(self, path: str, obj: Optional[object] = None, **kwargs: Any) -> TestResponse: + return self.request("PATCH", path, obj=obj, **kwargs) + + def delete(self, path: str, **kwargs: Any) -> TestResponse: + return self.request("DELETE", path, **kwargs) + + # ------------------------------------------------------------------ format-specific hooks + + @abc.abstractmethod + def serialize(self, obj: object) -> bytes: + """Serialize a model object to a request body in the format under test.""" + + @abc.abstractmethod + def parse_object(self, response: TestResponse) -> Any: + """Return the single-object node of an object response (accepted by :meth:`identifier`, :meth:`field`, ...).""" + + @abc.abstractmethod + def parse_collection(self, response: TestResponse) -> list[Any]: + """Return the list of item nodes of a collection response, in document order.""" + + @abc.abstractmethod + def identifier(self, node: Any) -> str: + """The ``id`` of an Identifiable from a :meth:`parse_object` / :meth:`parse_collection` node.""" + + @abc.abstractmethod + def reference_target(self, node: Any) -> str: + """The value of the last key of a Reference node (a single-reference response or a collection item).""" + + @abc.abstractmethod + def field(self, node: Any, name: str) -> Optional[str]: + """The text of a direct scalar child ``name`` of ``node`` (JSON member / ``aas:``-prefixed XML element).""" + + @abc.abstractmethod + def result_success(self, response: TestResponse) -> bool: + """The value of the ``success`` flag in a ``Result`` body.""" + + +class JsonFormatClient(FormatClient): + content_type = "application/json" + + def serialize(self, obj: object) -> bytes: + return json.dumps(obj, cls=adapter.json.AASToJsonEncoder).encode("utf-8") + + def _payload(self, response: TestResponse) -> Any: + return json.loads(response.get_data(as_text=True)) + + def parse_object(self, response: TestResponse) -> Any: + return self._payload(response) + + def parse_collection(self, response: TestResponse) -> list[Any]: + payload = self._payload(response) + if isinstance(payload, dict) and "result" in payload: + return list(payload["result"]) + return list(payload) + + def identifier(self, node: Any) -> str: + return node["id"] + + def reference_target(self, node: Any) -> str: + return node["keys"][-1]["value"] + + def field(self, node: Any, name: str) -> Optional[str]: + return node.get(name) + + def result_success(self, response: TestResponse) -> bool: + body = self._payload(response) + return "success" not in body or bool(body["success"]) + + +class XmlFormatClient(FormatClient): + content_type = "application/xml" + + def serialize(self, obj: object) -> bytes: + item_elem = adapter.xml.object_to_xml_element(obj) + etree.cleanup_namespaces(item_elem, top_nsmap=XML_NS_MAP) + return etree.tostring(item_elem, xml_declaration=True, encoding="utf-8") + + def _root(self, response: TestResponse) -> etree._Element: + return etree.fromstring(response.data) + + def parse_object(self, response: TestResponse) -> Any: + # An object response is with the object's children hoisted onto it, so the root itself is the + # object node and `identifier` / `field` find e.g. directly beneath it. + return self._root(response) + + def parse_collection(self, response: TestResponse) -> list[Any]: + # A collection response is with one child element per item. + return list(self._root(response)) + + def identifier(self, node: Any) -> str: + found = node.findtext("aas:id", namespaces=XML_NS_MAP) + assert found is not None + return found + + def reference_target(self, node: Any) -> str: + values = node.findall(".//aas:key/aas:value", namespaces=XML_NS_MAP) + assert values, "no keys in reference node" + return values[-1].text + + def field(self, node: Any, name: str) -> Optional[str]: + return node.findtext(f"aas:{name}", namespaces=XML_NS_MAP) + + def result_success(self, response: TestResponse) -> bool: + # true|false... -- not namespaced in Result bodies. + success_elem = self._root(response).find("success") + return success_elem is None or success_elem.text == "true" diff --git a/server/test/interfaces/test_repository.py b/server/test/interfaces/test_repository.py index d60926132..6b4c976d1 100644 --- a/server/test/interfaces/test_repository.py +++ b/server/test/interfaces/test_repository.py @@ -1,133 +1,367 @@ -# Copyright (c) 2026 the Eclipse BaSyx Authors -# -# This program and the accompanying materials are made available under the terms of the MIT License, available in -# the LICENSE file of this project. -# -# SPDX-License-Identifier: MIT - -""" -This test uses the schemathesis package to perform automated stateful testing on the implemented http api. Requests -are created automatically based on the json schemata given in the api specification, responses are also validated -against said schemata. - -For data generation schemathesis uses hypothesis and hypothesis-jsonschema, hence the name. hypothesis is a library -for automated, property-based testing. It can generate test cases based on strategies. hypothesis-jsonschema is such -a strategy for generating data that matches a given JSON schema. - -schemathesis allows stateful testing by generating a statemachine based on the OAS links contained in the specification. -This is applied here with the APIWorkflowAAS and APIWorkflowSubmodel classes. They inherit the respective state machine -and offer an automatically generated python unittest TestCase. -""" - -# TODO: lookup schemathesis deps and add them to the readme -# TODO: implement official Plattform I4.0 HTTP API -# TODO: check required properties of schema -# TODO: add id_short format to schemata - -import os -import pathlib -import random -import urllib.parse -from typing import Set - -import hypothesis.strategies -import schemathesis -from app.interfaces.repository import WSGIApp +import unittest +from unittest import mock + +from app.interfaces import repository +from app.util.converters import base64url_encode from basyx.aas import model -from basyx.aas.adapter.aasx import DictSupplementaryFileContainer -from basyx.aas.examples.data.example_aas import create_full_example +from basyx.aas.adapter import aasx +from basyx.aas.examples.data.example_aas_missing_attributes import ( + create_example_asset_administration_shell, + create_example_submodel, +) +from werkzeug.test import Client, TestResponse + +from .format_utils import FormatClient, JsonFormatClient, XmlFormatClient -def _encode_and_quote(identifier: model.Identifier) -> str: - return urllib.parse.quote(urllib.parse.quote(identifier, safe=""), safe="") +class TestServiceDescription(unittest.TestCase): + def setUp(self) -> None: + object_store: model.DictIdentifiableStore = model.DictIdentifiableStore() + file_store = mock.Mock(spec=aasx.AbstractSupplementaryFileContainer) + self.client = Client(repository.WSGIApp(object_store, file_store, base_path="")) + def test_description(self): + response = self.client.get("/description") + self.assertEqual(200, response.status_code) -def _check_transformed(response, case): +class _ShellsEndpointsTest(unittest.TestCase): """ - This helper function performs an additional checks on requests that have been *transformed*, i.e. requests, that - resulted from schemathesis using an OpenAPI Spec link. It asserts, that requests that are performed after a link has - been used, must be successful and result in a 2xx response. The exception are requests where hypothesis generates - invalid data (data, that validates against the schema, but is still semantically invalid). Such requests would - result in a 422 - Unprocessable Entity, which is why the 422 status code is ignored here. + Endpoint tests for the implemented ``/shells`` routes of :class:`~app.interfaces.repository.WSGIApp`. + + Bodies are written once against the format-agnostic :attr:`fmt` helper; the concrete + :class:`TestShellsEndpointsJson` / :class:`TestShellsEndpointsXml` subclasses run them once per format by + swapping :attr:`format_client_cls`. """ - if case.source is not None: - assert 200 <= response.status_code < 300 or response.status_code == 422 - - -# define some settings for hypothesis, used in both api test cases -HYPOTHESIS_SETTINGS = hypothesis.settings( - max_examples=int(os.getenv("HYPOTHESIS_MAX_EXAMPLES", 10)), - stateful_step_count=5, - # disable the filter_too_much health check, which triggers if a strategy filters too much data, raising an error - suppress_health_check=[hypothesis.HealthCheck.filter_too_much], - # disable data generation deadlines, which would result in an error if data generation takes too much time - deadline=None, -) -BASE_URL = "/api/v1" -IDENTIFIER_AAS: Set[str] = set() -IDENTIFIER_SUBMODEL: Set[str] = set() - -# register hypothesis strategy for generating valid idShorts -ID_SHORT_STRATEGY = hypothesis.strategies.from_regex(r"\A[A-Za-z_][0-9A-Za-z_]*\Z") -schemathesis.register_string_format("id_short", ID_SHORT_STRATEGY) - -# store identifiers of available AAS and Submodels -for obj in create_full_example(): - if isinstance(obj, model.AssetAdministrationShell): - IDENTIFIER_AAS.add(_encode_and_quote(obj.id)) - if isinstance(obj, model.Submodel): - IDENTIFIER_SUBMODEL.add(_encode_and_quote(obj.id)) - -# load aas and submodel api specs -AAS_SCHEMA = schemathesis.from_path( - pathlib.Path(__file__).parent / "http-api-oas-aas.yaml", - app=WSGIApp(create_full_example(), DictSupplementaryFileContainer()), -) + __test__ = False -SUBMODEL_SCHEMA = schemathesis.from_path( - pathlib.Path(__file__).parent / "http-api-oas-submodel.yaml", - app=WSGIApp(create_full_example(), DictSupplementaryFileContainer()), -) + format_client_cls: type[FormatClient] = None # type: ignore + + object_store: model.DictIdentifiableStore + file_store: mock.Mock + repository_server: repository.WSGIApp + fmt: FormatClient + + @classmethod + def setUpClass(cls) -> None: + if cls.format_client_cls is None: + raise unittest.SkipTest("abstract base class") + super().setUpClass() + + cls.object_store = model.DictIdentifiableStore() + cls.file_store = mock.Mock(spec=aasx.AbstractSupplementaryFileContainer) + cls.repository_server = repository.WSGIApp(cls.object_store, cls.file_store, base_path="") + cls.fmt = cls.format_client_cls(Client(cls.repository_server)) + + def setUp(self) -> None: + self.object_store.clear() + self.file_store.reset_mock() + + def two_shells_store(self): + store = model.DictIdentifiableStore() + store.add(create_example_asset_administration_shell()) + second_shell = create_example_asset_administration_shell() + second_shell.id = "https://example.org/Test_AssetAdministrationShell_Second" + store.add(second_shell) + return store + + # ------------------------------------------------------------------ shared assertion helpers + + def assert_ok(self, response: TestResponse) -> None: + self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) + self.assertEqual(self.fmt.content_type, response.mimetype) + + def assert_error(self, response: TestResponse, status_code: int) -> None: + self.assertEqual(status_code, response.status_code, msg=response.get_data(as_text=True)) + self.assertFalse(self.fmt.result_success(response)) + + # ------------------------------------------------------------------ GET /shells + + def test_shells_get(self): + self.object_store.update(self.two_shells_store()) + + response = self.fmt.get("/shells") + + self.assert_ok(response) + self.assertEqual(2, len(self.fmt.parse_collection(response))) + + # ------------------------------------------------------------------ POST /shells + + def test_shells_post_success(self): + example_shell = create_example_asset_administration_shell() + + response = self.fmt.post("/shells", obj=example_shell) + + self.assertEqual(201, response.status_code) + self.assertIsNotNone(self.object_store.get(example_shell.id, None)) + + def test_shells_post_bad(self): + example_shell = create_example_asset_administration_shell() + example_shell.id = None # type: ignore + + response = self.fmt.post("/shells", obj=example_shell) + + self.assert_error(response, 400) + + def test_shells_post_conflict(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + response = self.fmt.post("/shells", obj=example_shell) + + self.assert_error(response, 409) + + # ------------------------------------------------------------------ GET /shells/$reference + + def test_shells_reference_get(self): + self.object_store.update(self.two_shells_store()) + example_shell = next(iter(self.object_store)) + + response = self.fmt.get("/shells/$reference") + + self.assert_ok(response) + references = self.fmt.parse_collection(response) + self.assertEqual(2, len(references)) + self.assertIn(example_shell.id, [self.fmt.reference_target(ref) for ref in references]) + + # ------------------------------------------------------------------ GET /shells/ + + def test_shell_get_success(self): + self.object_store.update(self.two_shells_store()) + example_shell = next(iter(self.object_store)) + + response = self.fmt.get(f"/shells/{base64url_encode(example_shell.id)}") + + self.assert_ok(response) + self.assertEqual(example_shell.id, self.fmt.identifier(self.fmt.parse_object(response))) + + def test_shell_get_not_found(self): + response = self.fmt.get(f"/shells/{base64url_encode('https://example.org/unknown')}") + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ GET /shells//$reference + + def test_shell_reference_get(self): + self.object_store.update(self.two_shells_store()) + example_shell = next(iter(self.object_store)) + + response = self.fmt.get(f"/shells/{base64url_encode(example_shell.id)}/$reference") + + self.assert_ok(response) + self.assertEqual(example_shell.id, self.fmt.reference_target(self.fmt.parse_object(response))) + + # ------------------------------------------------------------------ PUT /shells/ + + def test_shell_put_success(self): + self.object_store.add(create_example_asset_administration_shell()) + updated_shell = create_example_asset_administration_shell() + updated_shell.id_short = "UpdatedIdShort" + + response = self.fmt.put(f"/shells/{base64url_encode(updated_shell.id)}", obj=updated_shell) + + self.assertEqual(204, response.status_code) + retrieved_shell = self.object_store.get(updated_shell.id, None) + self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + self.assertEqual("UpdatedIdShort", retrieved_shell.id_short) + + def test_shell_put_not_found(self): + updated_shell = create_example_asset_administration_shell() + + response = self.fmt.put(f"/shells/{base64url_encode('https://example.org/unknown')}", obj=updated_shell) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ DELETE /shells/ + + def test_shell_delete_success(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + response = self.fmt.delete(f"/shells/{base64url_encode(example_shell.id)}") + + self.assertEqual(204, response.status_code) + self.assertIsNone(self.object_store.get(example_shell.id, None)) + + def test_shell_delete_not_found(self): + response = self.fmt.delete(f"/shells/{base64url_encode('https://example.org/unknown')}") + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ GET /shells//asset-information + + def test_shell_asset_information_get(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + response = self.fmt.get(f"/shells/{base64url_encode(example_shell.id)}/asset-information") + + self.assert_ok(response) + self.assertEqual( + example_shell.asset_information.global_asset_id, + self.fmt.field(self.fmt.parse_object(response), "globalAssetId"), + ) + + # ------------------------------------------------------------------ PUT /shells//asset-information + + def test_shell_asset_information_put(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + new_asset_information = model.AssetInformation( + asset_kind=model.AssetKind.INSTANCE, + global_asset_id="http://example.org/changed_asset", + ) + + response = self.fmt.put( + f"/shells/{base64url_encode(example_shell.id)}/asset-information", + obj=new_asset_information, + ) + + self.assertEqual(204, response.status_code) + retrieved_shell = self.object_store.get(example_shell.id) + self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + self.assertEqual( + "http://example.org/changed_asset", + retrieved_shell.asset_information.global_asset_id, + ) + + # ------------------------------------------------------------------ GET /shells//submodel-refs + + def test_shell_submodel_refs_get(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + response = self.fmt.get(f"/shells/{base64url_encode(example_shell.id)}/submodel-refs") + + self.assert_ok(response) + references = self.fmt.parse_collection(response) + self.assertEqual(1, len(references)) + self.assertEqual("https://example.org/Test_Submodel_Missing", self.fmt.reference_target(references[0])) + + # ------------------------------------------------------------------ POST /shells//submodel-refs + + def test_shell_submodel_refs_post_success(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + new_ref = model.ModelReference( + (model.Key(model.KeyTypes.SUBMODEL, "https://example.org/NewSubmodel"),), model.Submodel + ) + + response = self.fmt.post(f"/shells/{base64url_encode(example_shell.id)}/submodel-refs", obj=new_ref) + + self.assertEqual(201, response.status_code) + retrieved_shell = self.object_store.get(example_shell.id) + self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + identifiers = {ref.get_identifier() for ref in retrieved_shell.submodel} + self.assertIn("https://example.org/NewSubmodel", identifiers) + + def test_shell_submodel_refs_post_conflict(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + existing_ref = model.ModelReference( + (model.Key(model.KeyTypes.SUBMODEL, "https://example.org/Test_Submodel_Missing"),), model.Submodel + ) + + response = self.fmt.post(f"/shells/{base64url_encode(example_shell.id)}/submodel-refs", obj=existing_ref) + + self.assert_error(response, 409) + + # ------------------------------------------------------------------ DELETE /shells//submodel-refs/ + + def test_shell_submodel_refs_delete_success(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + submodel_id = "https://example.org/Test_Submodel_Missing" + + response = self.fmt.delete( + f"/shells/{base64url_encode(example_shell.id)}/submodel-refs/{base64url_encode(submodel_id)}" + ) + + self.assertEqual(204, response.status_code) + retrieved_shell = self.object_store.get(example_shell.id) + self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + self.assertEqual(0, len(list(retrieved_shell.submodel))) + + def test_shell_submodel_refs_delete_not_found(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + response = self.fmt.delete( + f"/shells/{base64url_encode(example_shell.id)}/submodel-refs/" + f"{base64url_encode('https://example.org/unknown')}" + ) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ PUT /shells//submodels/ + + def test_shell_submodel_refs_submodel_put(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + self.object_store.add(create_example_submodel()) + updated_submodel = create_example_submodel() + updated_submodel.id_short = "UpdatedSubmodel" + + response = self.fmt.put( + f"/shells/{base64url_encode(example_shell.id)}/submodels/{base64url_encode(updated_submodel.id)}", + obj=updated_submodel, + ) + + self.assertEqual(204, response.status_code) + retrieved_sm = self.object_store.get(updated_submodel.id) + self.assertIsInstance(retrieved_sm, model.Submodel) + self.assertEqual("UpdatedSubmodel", retrieved_sm.id_short) + + # ------------------------------------------------------------------ DELETE /shells//submodels/ + + def test_shell_submodel_refs_submodel_delete(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = self.fmt.delete( + f"/shells/{base64url_encode(example_shell.id)}/submodels/{base64url_encode(example_submodel.id)}" + ) + + self.assertEqual(204, response.status_code) + self.assertIsNone(self.object_store.get(example_submodel.id, None)) + retrieved_shell = self.object_store.get(example_shell.id) + self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + self.assertEqual(0, len(list(retrieved_shell.submodel))) + # ------------------------------------------------------------------ /shells//submodels/ redirect -class APIWorkflowAAS(AAS_SCHEMA.as_state_machine()): # type: ignore - def setup(self): - self.schema.app.identifiable_store = create_full_example() - # select random identifier for each test scenario - self.schema.base_url = BASE_URL + "/aas/" + random.choice(tuple(IDENTIFIER_AAS)) + def test_shell_submodel_refs_submodel_redirect(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + submodel_id = "https://example.org/Test_Submodel_Missing" - def transform(self, result, direction, case): - out = super().transform(result, direction, case) - print("transformed") - print(out) - print(result.response, direction.name) - return out + response = self.fmt.get( + f"/shells/{base64url_encode(example_shell.id)}/submodels/{base64url_encode(submodel_id)}" + ) - def validate_response(self, response, case, additional_checks=()) -> None: - super().validate_response(response, case, additional_checks + (_check_transformed,)) + self.assertEqual(307, response.status_code) + self.assertIn(f"/submodels/{base64url_encode(submodel_id)}", response.headers["Location"]) + def test_shell_submodel_refs_submodel_redirect_with_path(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + submodel_id = "https://example.org/Test_Submodel_Missing" -class APIWorkflowSubmodel(SUBMODEL_SCHEMA.as_state_machine()): # type: ignore - def setup(self): - self.schema.app.identifiable_store = create_full_example() - self.schema.base_url = BASE_URL + "/submodels/" + random.choice(tuple(IDENTIFIER_SUBMODEL)) + response = self.fmt.get( + f"/shells/{base64url_encode(example_shell.id)}/submodels/{base64url_encode(submodel_id)}/submodel-elements" + ) - def transform(self, result, direction, case): - out = super().transform(result, direction, case) - print("transformed") - print(out) - print(result.response, direction.name) - return out + self.assertEqual(307, response.status_code) + self.assertTrue(response.headers["Location"].endswith("/submodel-elements")) - def validate_response(self, response, case, additional_checks=()) -> None: - super().validate_response(response, case, additional_checks + (_check_transformed,)) +class TestShellsEndpointsJson(_ShellsEndpointsTest): + __test__ = True + format_client_cls = JsonFormatClient -# APIWorkflow.TestCase is a standard python unittest.TestCase -# TODO: Fix HTTP API Tests -# ApiTestAAS = APIWorkflowAAS.TestCase -# ApiTestAAS.settings = HYPOTHESIS_SETTINGS -# ApiTestSubmodel = APIWorkflowSubmodel.TestCase -# ApiTestSubmodel.settings = HYPOTHESIS_SETTINGS +class TestShellsEndpointsXml(_ShellsEndpointsTest): + __test__ = True + format_client_cls = XmlFormatClient From 496190f42dedae55125ec88fe67e94c3813b801a Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Sun, 30 Aug 2026 12:19:36 +0200 Subject: [PATCH 02/59] Implement and test GET/PUT/DELETE for the shells asset-information thumbnail endpoint. --- server/app/interfaces/repository.py | 74 ++++++++++++- server/test/interfaces/test_repository.py | 127 ++++++++++++++++++++++ 2 files changed, 199 insertions(+), 2 deletions(-) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 8f931c786..551125835 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -79,8 +79,18 @@ def __init__( ), Rule( "/asset-information/thumbnail", - methods=["GET", "PUT", "DELETE"], - endpoint=self.not_implemented, + methods=["GET"], + endpoint=self.get_aas_thumbnail, + ), + Rule( + "/asset-information/thumbnail", + methods=["PUT"], + endpoint=self.put_aas_thumbnail, + ), + Rule( + "/asset-information/thumbnail", + methods=["DELETE"], + endpoint=self.delete_aas_thumbnail, ), Rule("/submodel-refs", methods=["GET"], endpoint=self.get_aas_submodel_refs), Rule("/submodel-refs", methods=["POST"], endpoint=self.post_aas_submodel_refs), @@ -586,6 +596,66 @@ def put_aas_asset_information( self.object_store.commit(aas) return response_t() + def get_aas_thumbnail( + self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs + ) -> Response: + shell = self._get_shell(url_args) + thumbnail = shell.asset_information.default_thumbnail + if thumbnail is None or not thumbnail.path: + raise NotFound(f"{shell!r} has no default thumbnail set!") + if not thumbnail.path.startswith("/"): + raise BadRequest(f"{shell!r} references an external thumbnail: {thumbnail.path}") + bytes_io = io.BytesIO() + try: + self.file_store.write_file(thumbnail.path, bytes_io) + except KeyError: + raise NotFound(f"No thumbnail file found at path: {thumbnail.path}") + return Response(bytes_io.getvalue(), content_type=thumbnail.content_type or "application/octet-stream") + + def put_aas_thumbnail( + self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs + ) -> Response: + shell = self._get_shell(url_args) + filename = request.form.get("fileName") + if filename is None: + raise BadRequest("No 'fileName' specified!") + elif not filename.startswith("/"): + raise BadRequest(f"Given 'fileName' doesn't start with a slash (/): {filename}") + + file_storage: Optional[FileStorage] = request.files.get("file") + if file_storage is None: + raise BadRequest("Missing file to upload") + + old_thumbnail = shell.asset_information.default_thumbnail + new_path = self.file_store.add_file(filename, file_storage.stream, file_storage.mimetype) + if old_thumbnail is not None and old_thumbnail.path and old_thumbnail.path.startswith("/") \ + and old_thumbnail.path != new_path: + try: + self.file_store.delete_file(old_thumbnail.path) + except KeyError: + pass + + shell.asset_information.default_thumbnail = model.Resource(new_path, file_storage.mimetype) + self.object_store.commit(shell) + return response_t() + + def delete_aas_thumbnail( + self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs + ) -> Response: + shell = self._get_shell(url_args) + thumbnail = shell.asset_information.default_thumbnail + if thumbnail is None or not thumbnail.path: + raise NotFound(f"{shell!r} has no default thumbnail set!") + if not thumbnail.path.startswith("/"): + raise BadRequest(f"{shell!r} references an external thumbnail: {thumbnail.path}") + try: + self.file_store.delete_file(thumbnail.path) + except KeyError: + pass + shell.asset_information.default_thumbnail = None + self.object_store.commit(shell) + return response_t() + def get_aas_submodel_refs( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: diff --git a/server/test/interfaces/test_repository.py b/server/test/interfaces/test_repository.py index 6b4c976d1..e7f77f832 100644 --- a/server/test/interfaces/test_repository.py +++ b/server/test/interfaces/test_repository.py @@ -1,3 +1,4 @@ +import io import unittest from unittest import mock @@ -225,6 +226,132 @@ def test_shell_asset_information_put(self): retrieved_shell.asset_information.global_asset_id, ) + # ------------------------------------------------------------------ GET .../asset-information/thumbnail + + def thumbnail_path(self, aas_id: str) -> str: + return f"/shells/{base64url_encode(aas_id)}/asset-information/thumbnail" + + def test_shell_thumbnail_get_success(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") + self.object_store.add(example_shell) + self.file_store.write_file.side_effect = lambda name, stream: stream.write(b"thumbnail-bytes") + + response = self.fmt.get(self.thumbnail_path(example_shell.id)) + + self.assertEqual(200, response.status_code) + self.assertEqual("image/png", response.mimetype) + self.assertEqual(b"thumbnail-bytes", response.get_data()) + self.file_store.write_file.assert_called_once_with("/thumbnail.png", mock.ANY) + + def test_shell_thumbnail_get_no_thumbnail_set(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = None + self.object_store.add(example_shell) + + response = self.fmt.get(self.thumbnail_path(example_shell.id)) + + self.assert_error(response, 404) + + def test_shell_thumbnail_get_external_reference(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource( + "https://example.org/thumbnail.png", "image/png" + ) + self.object_store.add(example_shell) + + response = self.fmt.get(self.thumbnail_path(example_shell.id)) + + self.assert_error(response, 400) + + # ------------------------------------------------------------------ PUT .../asset-information/thumbnail + + def test_shell_thumbnail_put_success(self): + # Also exercises the "replace an existing local thumbnail" branch, since the fixture shell already + # carries a (non-local) default_thumbnail; a fresh local one is added on top of that here. + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource("/old.png", "image/png") + self.object_store.add(example_shell) + self.file_store.add_file.return_value = "/new.png" + + response = self.fmt.client.put( + self.thumbnail_path(example_shell.id), + data={ + "fileName": "/new.png", + "file": (io.BytesIO(b"thumbnail-bytes"), "new.png", "image/png"), + }, + headers={"Accept": self.fmt.content_type}, + ) + + self.assertEqual(204, response.status_code) + self.file_store.add_file.assert_called_once_with("/new.png", mock.ANY, "image/png") + self.file_store.delete_file.assert_called_once_with("/old.png") + retrieved_shell = self.object_store.get(example_shell.id) + self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + new_thumbnail = retrieved_shell.asset_information.default_thumbnail + self.assertIsNotNone(new_thumbnail) + self.assertEqual("/new.png", new_thumbnail.path) + self.assertEqual("image/png", new_thumbnail.content_type) + + def test_shell_thumbnail_put_missing_filename(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + response = self.fmt.client.put( + self.thumbnail_path(example_shell.id), + data={"file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png")}, + headers={"Accept": self.fmt.content_type}, + ) + + self.assert_error(response, 400) + + def test_shell_thumbnail_put_shell_not_found(self): + response = self.fmt.client.put( + self.thumbnail_path("https://example.org/unknown"), + data={ + "fileName": "/thumbnail.png", + "file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png"), + }, + headers={"Accept": self.fmt.content_type}, + ) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ DELETE .../asset-information/thumbnail + + def test_shell_thumbnail_delete_success(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") + self.object_store.add(example_shell) + + response = self.fmt.delete(self.thumbnail_path(example_shell.id)) + + self.assertEqual(204, response.status_code) + self.file_store.delete_file.assert_called_once_with("/thumbnail.png") + retrieved_shell = self.object_store.get(example_shell.id) + self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + self.assertIsNone(retrieved_shell.asset_information.default_thumbnail) + + def test_shell_thumbnail_delete_no_thumbnail_set(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = None + self.object_store.add(example_shell) + + response = self.fmt.delete(self.thumbnail_path(example_shell.id)) + + self.assert_error(response, 404) + + def test_shell_thumbnail_delete_external_reference(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource( + "https://example.org/thumbnail.png", "image/png" + ) + self.object_store.add(example_shell) + + response = self.fmt.delete(self.thumbnail_path(example_shell.id)) + + self.assert_error(response, 400) + # ------------------------------------------------------------------ GET /shells//submodel-refs def test_shell_submodel_refs_get(self): From d21a139ad94107df7fdcee3ab8d051ec8df88e0b Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sun, 30 Aug 2026 17:59:19 +0200 Subject: [PATCH 03/59] extract abstract RepositoryEndpointTestBase --- server/test/interfaces/format_utils.py | 8 +- server/test/interfaces/test_repository.py | 338 ++++++++++++---------- 2 files changed, 190 insertions(+), 156 deletions(-) diff --git a/server/test/interfaces/format_utils.py b/server/test/interfaces/format_utils.py index be27b8499..8f7e0dfef 100644 --- a/server/test/interfaces/format_utils.py +++ b/server/test/interfaces/format_utils.py @@ -36,15 +36,17 @@ def request(self, method: str, path: str, obj: Optional[object] = None, data: An different ``Content-Type`` for :param:`data`. :return: The :class:`~werkzeug.test.TestResponse` object """ - headers = dict(kwargs or {}) + headers = dict(kwargs.get("headers", {})) headers["Accept"] = self.content_type if obj is not None: data = self.serialize(obj) - kwargs.pop("content_type", None) + kwargs["content_type"] = self.content_type + + kwargs.update({"data": data, "headers": headers}) return self.client.open( - path, method=method, headers=headers, data=data, content_type=self.content_type, **kwargs + path, method=method, **kwargs ) def get(self, path: str, **kwargs) -> TestResponse: diff --git a/server/test/interfaces/test_repository.py b/server/test/interfaces/test_repository.py index e7f77f832..e5fd779b3 100644 --- a/server/test/interfaces/test_repository.py +++ b/server/test/interfaces/test_repository.py @@ -1,3 +1,4 @@ +import abc import io import unittest from unittest import mock @@ -15,50 +16,34 @@ from .format_utils import FormatClient, JsonFormatClient, XmlFormatClient -class TestServiceDescription(unittest.TestCase): - def setUp(self) -> None: - object_store: model.DictIdentifiableStore = model.DictIdentifiableStore() - file_store = mock.Mock(spec=aasx.AbstractSupplementaryFileContainer) - self.client = Client(repository.WSGIApp(object_store, file_store, base_path="")) - - def test_description(self): - response = self.client.get("/description") - self.assertEqual(200, response.status_code) - -class _ShellsEndpointsTest(unittest.TestCase): - """ - Endpoint tests for the implemented ``/shells`` routes of :class:`~app.interfaces.repository.WSGIApp`. - - Bodies are written once against the format-agnostic :attr:`fmt` helper; the concrete - :class:`TestShellsEndpointsJson` / :class:`TestShellsEndpointsXml` subclasses run them once per format by - swapping :attr:`format_client_cls`. - """ - +class RespsitoryEdpointTestBase(unittest.TestCase, abc.ABC): __test__ = False - format_client_cls: type[FormatClient] = None # type: ignore - object_store: model.DictIdentifiableStore file_store: mock.Mock repository_server: repository.WSGIApp fmt: FormatClient + @classmethod + @abc.abstractmethod + def build_format_client(cls) -> FormatClient: + raise NotImplementedError() + @classmethod def setUpClass(cls) -> None: - if cls.format_client_cls is None: - raise unittest.SkipTest("abstract base class") super().setUpClass() cls.object_store = model.DictIdentifiableStore() cls.file_store = mock.Mock(spec=aasx.AbstractSupplementaryFileContainer) cls.repository_server = repository.WSGIApp(cls.object_store, cls.file_store, base_path="") - cls.fmt = cls.format_client_cls(Client(cls.repository_server)) + cls.fmt = cls.build_format_client() def setUp(self) -> None: self.object_store.clear() self.file_store.reset_mock() - def two_shells_store(self): + @classmethod + def two_shells_store(cls): store = model.DictIdentifiableStore() store.add(create_example_asset_administration_shell()) second_shell = create_example_asset_administration_shell() @@ -76,6 +61,173 @@ def assert_error(self, response: TestResponse, status_code: int) -> None: self.assertEqual(status_code, response.status_code, msg=response.get_data(as_text=True)) self.assertFalse(self.fmt.result_success(response)) + +class TestServiceDescription(RespsitoryEdpointTestBase): + __test__ = True + + @classmethod + def build_format_client(cls) -> FormatClient: + return JsonFormatClient(Client(cls.repository_server)) + + def test_description(self): + response = self.fmt.get("/description") + self.assertEqual(200, response.status_code) + + +class TestShellsThumbnailEndpoint(RespsitoryEdpointTestBase): + __test__ = True + + @classmethod + def build_format_client(cls) -> FormatClient: + return JsonFormatClient(Client(cls.repository_server)) + + # ------------------------------------------------------------------ GET .../asset-information/thumbnail + + def thumbnail_path(self, aas_id: str) -> str: + return f"/shells/{base64url_encode(aas_id)}/asset-information/thumbnail" + + def test_shell_thumbnail_get_success(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") + self.object_store.add(example_shell) + self.file_store.write_file.side_effect = lambda name, stream: stream.write(b"thumbnail-bytes") + + response = self.fmt.get(self.thumbnail_path(example_shell.id)) + + self.assertEqual(200, response.status_code) + self.assertEqual("image/png", response.mimetype) + self.assertEqual(b"thumbnail-bytes", response.get_data()) + self.file_store.write_file.assert_called_once_with("/thumbnail.png", mock.ANY) + + def test_shell_thumbnail_get_no_thumbnail_set(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = None + self.object_store.add(example_shell) + + response = self.fmt.get(self.thumbnail_path(example_shell.id)) + + self.assert_error(response, 404) + + def test_shell_thumbnail_get_external_reference(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource( + "https://example.org/thumbnail.png", "image/png" + ) + self.object_store.add(example_shell) + + response = self.fmt.get(self.thumbnail_path(example_shell.id)) + + self.assert_error(response, 400) + + # ------------------------------------------------------------------ PUT .../asset-information/thumbnail + + def test_shell_thumbnail_put_success(self): + # Also exercises the "replace an existing local thumbnail" branch, since the fixture shell already + # carries a (non-local) default_thumbnail; a fresh local one is added on top of that here. + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource("/old.png", "image/png") + self.object_store.add(example_shell) + self.file_store.add_file.return_value = "/new.png" + + response = self.fmt.put( + self.thumbnail_path(example_shell.id), + data={ + "fileName": "/new.png", + "file": (io.BytesIO(b"thumbnail-bytes"), "new.png", "image/png"), + }, + headers={"Accept": self.fmt.content_type}, + content_type="multipart/form-data" + ) + + self.assertEqual(204, response.status_code) + self.file_store.add_file.assert_called_once_with("/new.png", mock.ANY, "image/png") + self.file_store.delete_file.assert_called_once_with("/old.png") + retrieved_shell = self.object_store.get(example_shell.id) + self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + new_thumbnail = retrieved_shell.asset_information.default_thumbnail + self.assertIsNotNone(new_thumbnail) + self.assertEqual("/new.png", new_thumbnail.path) + self.assertEqual("image/png", new_thumbnail.content_type) + + def test_shell_thumbnail_put_missing_filename(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + response = self.fmt.client.put( + self.thumbnail_path(example_shell.id), + data={"file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png")}, + headers={"Accept": self.fmt.content_type}, + ) + + self.assert_error(response, 400) + + def test_shell_thumbnail_put_shell_not_found(self): + response = self.fmt.client.put( + self.thumbnail_path("https://example.org/unknown"), + data={ + "fileName": "/thumbnail.png", + "file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png"), + }, + headers={"Accept": self.fmt.content_type}, + ) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ DELETE .../asset-information/thumbnail + + def test_shell_thumbnail_delete_success(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") + self.object_store.add(example_shell) + + response = self.fmt.delete(self.thumbnail_path(example_shell.id)) + + self.assertEqual(204, response.status_code) + self.file_store.delete_file.assert_called_once_with("/thumbnail.png") + retrieved_shell = self.object_store.get(example_shell.id) + self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + self.assertIsNone(retrieved_shell.asset_information.default_thumbnail) + + def test_shell_thumbnail_delete_no_thumbnail_set(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = None + self.object_store.add(example_shell) + + response = self.fmt.delete(self.thumbnail_path(example_shell.id)) + + self.assert_error(response, 404) + + def test_shell_thumbnail_delete_external_reference(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource( + "https://example.org/thumbnail.png", "image/png" + ) + self.object_store.add(example_shell) + + response = self.fmt.delete(self.thumbnail_path(example_shell.id)) + + self.assert_error(response, 400) + + +class _ShellsEndpointsTest(RespsitoryEdpointTestBase, abc.ABC): + """ + Endpoint tests for the implemented ``/shells`` routes of :class:`~app.interfaces.repository.WSGIApp`. + + Bodies are written once against the format-agnostic :attr:`fmt` helper; the concrete + :class:`TestShellsEndpointsJson` / :class:`TestShellsEndpointsXml` subclasses run them once per format by + swapping :attr:`format_client_cls`. + """ + + __test__ = False + + def two_shells_store(self): + store = model.DictIdentifiableStore() + store.add(create_example_asset_administration_shell()) + second_shell = create_example_asset_administration_shell() + second_shell.id = "https://example.org/Test_AssetAdministrationShell_Second" + store.add(second_shell) + return store + # ------------------------------------------------------------------ GET /shells def test_shells_get(self): @@ -226,132 +378,6 @@ def test_shell_asset_information_put(self): retrieved_shell.asset_information.global_asset_id, ) - # ------------------------------------------------------------------ GET .../asset-information/thumbnail - - def thumbnail_path(self, aas_id: str) -> str: - return f"/shells/{base64url_encode(aas_id)}/asset-information/thumbnail" - - def test_shell_thumbnail_get_success(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") - self.object_store.add(example_shell) - self.file_store.write_file.side_effect = lambda name, stream: stream.write(b"thumbnail-bytes") - - response = self.fmt.get(self.thumbnail_path(example_shell.id)) - - self.assertEqual(200, response.status_code) - self.assertEqual("image/png", response.mimetype) - self.assertEqual(b"thumbnail-bytes", response.get_data()) - self.file_store.write_file.assert_called_once_with("/thumbnail.png", mock.ANY) - - def test_shell_thumbnail_get_no_thumbnail_set(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = None - self.object_store.add(example_shell) - - response = self.fmt.get(self.thumbnail_path(example_shell.id)) - - self.assert_error(response, 404) - - def test_shell_thumbnail_get_external_reference(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource( - "https://example.org/thumbnail.png", "image/png" - ) - self.object_store.add(example_shell) - - response = self.fmt.get(self.thumbnail_path(example_shell.id)) - - self.assert_error(response, 400) - - # ------------------------------------------------------------------ PUT .../asset-information/thumbnail - - def test_shell_thumbnail_put_success(self): - # Also exercises the "replace an existing local thumbnail" branch, since the fixture shell already - # carries a (non-local) default_thumbnail; a fresh local one is added on top of that here. - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource("/old.png", "image/png") - self.object_store.add(example_shell) - self.file_store.add_file.return_value = "/new.png" - - response = self.fmt.client.put( - self.thumbnail_path(example_shell.id), - data={ - "fileName": "/new.png", - "file": (io.BytesIO(b"thumbnail-bytes"), "new.png", "image/png"), - }, - headers={"Accept": self.fmt.content_type}, - ) - - self.assertEqual(204, response.status_code) - self.file_store.add_file.assert_called_once_with("/new.png", mock.ANY, "image/png") - self.file_store.delete_file.assert_called_once_with("/old.png") - retrieved_shell = self.object_store.get(example_shell.id) - self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) - new_thumbnail = retrieved_shell.asset_information.default_thumbnail - self.assertIsNotNone(new_thumbnail) - self.assertEqual("/new.png", new_thumbnail.path) - self.assertEqual("image/png", new_thumbnail.content_type) - - def test_shell_thumbnail_put_missing_filename(self): - example_shell = create_example_asset_administration_shell() - self.object_store.add(example_shell) - - response = self.fmt.client.put( - self.thumbnail_path(example_shell.id), - data={"file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png")}, - headers={"Accept": self.fmt.content_type}, - ) - - self.assert_error(response, 400) - - def test_shell_thumbnail_put_shell_not_found(self): - response = self.fmt.client.put( - self.thumbnail_path("https://example.org/unknown"), - data={ - "fileName": "/thumbnail.png", - "file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png"), - }, - headers={"Accept": self.fmt.content_type}, - ) - - self.assert_error(response, 404) - - # ------------------------------------------------------------------ DELETE .../asset-information/thumbnail - - def test_shell_thumbnail_delete_success(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") - self.object_store.add(example_shell) - - response = self.fmt.delete(self.thumbnail_path(example_shell.id)) - - self.assertEqual(204, response.status_code) - self.file_store.delete_file.assert_called_once_with("/thumbnail.png") - retrieved_shell = self.object_store.get(example_shell.id) - self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) - self.assertIsNone(retrieved_shell.asset_information.default_thumbnail) - - def test_shell_thumbnail_delete_no_thumbnail_set(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = None - self.object_store.add(example_shell) - - response = self.fmt.delete(self.thumbnail_path(example_shell.id)) - - self.assert_error(response, 404) - - def test_shell_thumbnail_delete_external_reference(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource( - "https://example.org/thumbnail.png", "image/png" - ) - self.object_store.add(example_shell) - - response = self.fmt.delete(self.thumbnail_path(example_shell.id)) - - self.assert_error(response, 400) - # ------------------------------------------------------------------ GET /shells//submodel-refs def test_shell_submodel_refs_get(self): @@ -486,9 +512,15 @@ def test_shell_submodel_refs_submodel_redirect_with_path(self): class TestShellsEndpointsJson(_ShellsEndpointsTest): __test__ = True - format_client_cls = JsonFormatClient + + @classmethod + def build_format_client(cls) -> FormatClient: + return JsonFormatClient(Client(cls.repository_server)) class TestShellsEndpointsXml(_ShellsEndpointsTest): __test__ = True - format_client_cls = XmlFormatClient + + @classmethod + def build_format_client(cls) -> FormatClient: + return XmlFormatClient(Client(cls.repository_server)) From e51bd389462d367078ffb22c4b5194e9cfdd9058 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sun, 30 Aug 2026 18:41:57 +0200 Subject: [PATCH 04/59] use decorators to indicate which clients to use for which test case --- server/test/interfaces/format_utils.py | 35 +++++++++++ server/test/interfaces/test_repository.py | 75 ++++++++++------------- 2 files changed, 68 insertions(+), 42 deletions(-) diff --git a/server/test/interfaces/format_utils.py b/server/test/interfaces/format_utils.py index 8f7e0dfef..bea7e908d 100644 --- a/server/test/interfaces/format_utils.py +++ b/server/test/interfaces/format_utils.py @@ -164,3 +164,38 @@ def result_success(self, response: TestResponse) -> bool: # true|false... -- not namespaced in Result bodies. success_elem = self._root(response).find("success") return success_elem is None or success_elem.text == "true" + + +def with_json_client(func): + client_types = getattr(func, "_client_types", []) + client_types.append("json") + func._client_types = client_types + return func + +def with_xml_client(func): + client_types = getattr(func, "_client_types", []) + client_types.append("xml") + func._client_types = client_types + return func + +def with_formatted_clients(cls): + format_map: dict[str, type[FormatClient]] = {"json": JsonFormatClient, "xml": XmlFormatClient} + + def build_test(method, format_client_type): + def wrapper(self): + formatted_client = format_client_type(getattr(cls, "client")) + return method(self, formatted_client) + + return wrapper + + for name, method in list(vars(cls).items()): + method_client_type: Optional[list[str]] = getattr(method, "_client_types", None) + if method_client_type is None: + continue + + # Method was decorated -> remove original method and insert new methods + delattr(cls, name) + for format_name in method_client_type: + format_client_type = format_map[format_name] + setattr(cls, f"{name}_{format_name}", build_test(method, format_client_type)) + return cls diff --git a/server/test/interfaces/test_repository.py b/server/test/interfaces/test_repository.py index e5fd779b3..3a4451818 100644 --- a/server/test/interfaces/test_repository.py +++ b/server/test/interfaces/test_repository.py @@ -13,21 +13,17 @@ ) from werkzeug.test import Client, TestResponse -from .format_utils import FormatClient, JsonFormatClient, XmlFormatClient +from .format_utils import FormatClient, JsonFormatClient, XmlFormatClient, with_json_client, with_formatted_clients, \ + with_xml_client -class RespsitoryEdpointTestBase(unittest.TestCase, abc.ABC): +class RespsitoryEdpointTestBase(unittest.TestCase): __test__ = False object_store: model.DictIdentifiableStore file_store: mock.Mock repository_server: repository.WSGIApp - fmt: FormatClient - - @classmethod - @abc.abstractmethod - def build_format_client(cls) -> FormatClient: - raise NotImplementedError() + client: Client @classmethod def setUpClass(cls) -> None: @@ -36,7 +32,7 @@ def setUpClass(cls) -> None: cls.object_store = model.DictIdentifiableStore() cls.file_store = mock.Mock(spec=aasx.AbstractSupplementaryFileContainer) cls.repository_server = repository.WSGIApp(cls.object_store, cls.file_store, base_path="") - cls.fmt = cls.build_format_client() + cls.client = Client(cls.repository_server) def setUp(self) -> None: self.object_store.clear() @@ -55,32 +51,23 @@ def two_shells_store(cls): def assert_ok(self, response: TestResponse) -> None: self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) - self.assertEqual(self.fmt.content_type, response.mimetype) def assert_error(self, response: TestResponse, status_code: int) -> None: self.assertEqual(status_code, response.status_code, msg=response.get_data(as_text=True)) - self.assertFalse(self.fmt.result_success(response)) + self.assertIn("success", response.get_data(as_text=True), msg=response.get_data(as_text=True)) class TestServiceDescription(RespsitoryEdpointTestBase): __test__ = True - - @classmethod - def build_format_client(cls) -> FormatClient: - return JsonFormatClient(Client(cls.repository_server)) def test_description(self): - response = self.fmt.get("/description") + response = self.client.get("/description") self.assertEqual(200, response.status_code) class TestShellsThumbnailEndpoint(RespsitoryEdpointTestBase): __test__ = True - @classmethod - def build_format_client(cls) -> FormatClient: - return JsonFormatClient(Client(cls.repository_server)) - # ------------------------------------------------------------------ GET .../asset-information/thumbnail def thumbnail_path(self, aas_id: str) -> str: @@ -92,7 +79,7 @@ def test_shell_thumbnail_get_success(self): self.object_store.add(example_shell) self.file_store.write_file.side_effect = lambda name, stream: stream.write(b"thumbnail-bytes") - response = self.fmt.get(self.thumbnail_path(example_shell.id)) + response = self.client.get(self.thumbnail_path(example_shell.id)) self.assertEqual(200, response.status_code) self.assertEqual("image/png", response.mimetype) @@ -104,7 +91,7 @@ def test_shell_thumbnail_get_no_thumbnail_set(self): example_shell.asset_information.default_thumbnail = None self.object_store.add(example_shell) - response = self.fmt.get(self.thumbnail_path(example_shell.id)) + response = self.client.get(self.thumbnail_path(example_shell.id)) self.assert_error(response, 404) @@ -115,7 +102,7 @@ def test_shell_thumbnail_get_external_reference(self): ) self.object_store.add(example_shell) - response = self.fmt.get(self.thumbnail_path(example_shell.id)) + response = self.client.get(self.thumbnail_path(example_shell.id)) self.assert_error(response, 400) @@ -129,13 +116,12 @@ def test_shell_thumbnail_put_success(self): self.object_store.add(example_shell) self.file_store.add_file.return_value = "/new.png" - response = self.fmt.put( + response = self.client.put( self.thumbnail_path(example_shell.id), data={ "fileName": "/new.png", "file": (io.BytesIO(b"thumbnail-bytes"), "new.png", "image/png"), }, - headers={"Accept": self.fmt.content_type}, content_type="multipart/form-data" ) @@ -153,22 +139,20 @@ def test_shell_thumbnail_put_missing_filename(self): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) - response = self.fmt.client.put( + response = self.client.put( self.thumbnail_path(example_shell.id), - data={"file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png")}, - headers={"Accept": self.fmt.content_type}, + data={"file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png")} ) self.assert_error(response, 400) def test_shell_thumbnail_put_shell_not_found(self): - response = self.fmt.client.put( + response = self.client.put( self.thumbnail_path("https://example.org/unknown"), data={ "fileName": "/thumbnail.png", "file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png"), - }, - headers={"Accept": self.fmt.content_type}, + } ) self.assert_error(response, 404) @@ -180,7 +164,7 @@ def test_shell_thumbnail_delete_success(self): example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") self.object_store.add(example_shell) - response = self.fmt.delete(self.thumbnail_path(example_shell.id)) + response = self.client.delete(self.thumbnail_path(example_shell.id)) self.assertEqual(204, response.status_code) self.file_store.delete_file.assert_called_once_with("/thumbnail.png") @@ -193,7 +177,7 @@ def test_shell_thumbnail_delete_no_thumbnail_set(self): example_shell.asset_information.default_thumbnail = None self.object_store.add(example_shell) - response = self.fmt.delete(self.thumbnail_path(example_shell.id)) + response = self.client.delete(self.thumbnail_path(example_shell.id)) self.assert_error(response, 404) @@ -204,10 +188,25 @@ def test_shell_thumbnail_delete_external_reference(self): ) self.object_store.add(example_shell) - response = self.fmt.delete(self.thumbnail_path(example_shell.id)) + response = self.client.delete(self.thumbnail_path(example_shell.id)) self.assert_error(response, 400) +@with_formatted_clients +class ExampleTest(RespsitoryEdpointTestBase): + __test__ = True + + @with_json_client + @with_xml_client + def test_shells_get(self, format_client: FormatClient): + self.object_store.update(self.two_shells_store()) + + response = format_client.get("/shells") + + self.assert_ok(response) + self.assertEqual(2, len(format_client.parse_collection(response))) + + class _ShellsEndpointsTest(RespsitoryEdpointTestBase, abc.ABC): """ @@ -220,14 +219,6 @@ class _ShellsEndpointsTest(RespsitoryEdpointTestBase, abc.ABC): __test__ = False - def two_shells_store(self): - store = model.DictIdentifiableStore() - store.add(create_example_asset_administration_shell()) - second_shell = create_example_asset_administration_shell() - second_shell.id = "https://example.org/Test_AssetAdministrationShell_Second" - store.add(second_shell) - return store - # ------------------------------------------------------------------ GET /shells def test_shells_get(self): From 19759dbea47e1d9b3206d03ff666710e7c8bfb32 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Tue, 1 Sep 2026 15:02:51 +0200 Subject: [PATCH 05/59] apply decorators everywhere --- server/test/interfaces/format_utils.py | 29 +- server/test/interfaces/test_repository.py | 457 +++++++++++----------- 2 files changed, 258 insertions(+), 228 deletions(-) diff --git a/server/test/interfaces/format_utils.py b/server/test/interfaces/format_utils.py index bea7e908d..3861b95aa 100644 --- a/server/test/interfaces/format_utils.py +++ b/server/test/interfaces/format_utils.py @@ -1,6 +1,6 @@ import abc import json -from typing import Any, Optional +from typing import Any, Optional, Callable from basyx.aas import adapter from basyx.aas.adapter._generic import XML_NS_MAP @@ -168,24 +168,36 @@ def result_success(self, response: TestResponse) -> bool: def with_json_client(func): client_types = getattr(func, "_client_types", []) - client_types.append("json") + client_types.append(("json", JsonFormatClient)) func._client_types = client_types return func def with_xml_client(func): client_types = getattr(func, "_client_types", []) - client_types.append("xml") + client_types.append(("xml", XmlFormatClient)) func._client_types = client_types return func -def with_formatted_clients(cls): - format_map: dict[str, type[FormatClient]] = {"json": JsonFormatClient, "xml": XmlFormatClient} +def with_custom_client(name: str, client_type: type[FormatClient]): + def wrapper(func): + client_types = getattr(func, "_client_types", []) + client_types.append((name, client_type)) + func._client_types = client_types + return func + return wrapper + +def inject_format_clients(cls): + """Decorator to use on :class:`unittest.TestCase` when decorating functions with + :meth:`with_json_client`, :meth:`with_xml_client` or :meth:`with_custom_client`. For each + :class:`FormatClient` that is defined via these decorators on a function, a new function is + added to the class. The new function gets the specfied :class:`FormatcClient` injected as second + parameter. The name of the new function gets the format name as suffix. + """ - def build_test(method, format_client_type): + def build_test(method: Callable[[Any, FormatClient], Any], format_client_type: type[FormatClient]): def wrapper(self): formatted_client = format_client_type(getattr(cls, "client")) return method(self, formatted_client) - return wrapper for name, method in list(vars(cls).items()): @@ -195,7 +207,6 @@ def wrapper(self): # Method was decorated -> remove original method and insert new methods delattr(cls, name) - for format_name in method_client_type: - format_client_type = format_map[format_name] + for (format_name, format_client_type) in method_client_type: setattr(cls, f"{name}_{format_name}", build_test(method, format_client_type)) return cls diff --git a/server/test/interfaces/test_repository.py b/server/test/interfaces/test_repository.py index 3a4451818..a4e8531a2 100644 --- a/server/test/interfaces/test_repository.py +++ b/server/test/interfaces/test_repository.py @@ -13,11 +13,11 @@ ) from werkzeug.test import Client, TestResponse -from .format_utils import FormatClient, JsonFormatClient, XmlFormatClient, with_json_client, with_formatted_clients, \ +from .format_utils import FormatClient, JsonFormatClient, XmlFormatClient, with_json_client, inject_format_clients, \ with_xml_client -class RespsitoryEdpointTestBase(unittest.TestCase): +class RepositoryEndpointTestBase(unittest.TestCase): __test__ = False object_store: model.DictIdentifiableStore @@ -57,7 +57,7 @@ def assert_error(self, response: TestResponse, status_code: int) -> None: self.assertIn("success", response.get_data(as_text=True), msg=response.get_data(as_text=True)) -class TestServiceDescription(RespsitoryEdpointTestBase): +class TestServiceDescription(RepositoryEndpointTestBase): __test__ = True def test_description(self): @@ -65,150 +65,8 @@ def test_description(self): self.assertEqual(200, response.status_code) -class TestShellsThumbnailEndpoint(RespsitoryEdpointTestBase): - __test__ = True - - # ------------------------------------------------------------------ GET .../asset-information/thumbnail - - def thumbnail_path(self, aas_id: str) -> str: - return f"/shells/{base64url_encode(aas_id)}/asset-information/thumbnail" - - def test_shell_thumbnail_get_success(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") - self.object_store.add(example_shell) - self.file_store.write_file.side_effect = lambda name, stream: stream.write(b"thumbnail-bytes") - - response = self.client.get(self.thumbnail_path(example_shell.id)) - - self.assertEqual(200, response.status_code) - self.assertEqual("image/png", response.mimetype) - self.assertEqual(b"thumbnail-bytes", response.get_data()) - self.file_store.write_file.assert_called_once_with("/thumbnail.png", mock.ANY) - - def test_shell_thumbnail_get_no_thumbnail_set(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = None - self.object_store.add(example_shell) - - response = self.client.get(self.thumbnail_path(example_shell.id)) - - self.assert_error(response, 404) - - def test_shell_thumbnail_get_external_reference(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource( - "https://example.org/thumbnail.png", "image/png" - ) - self.object_store.add(example_shell) - - response = self.client.get(self.thumbnail_path(example_shell.id)) - - self.assert_error(response, 400) - - # ------------------------------------------------------------------ PUT .../asset-information/thumbnail - - def test_shell_thumbnail_put_success(self): - # Also exercises the "replace an existing local thumbnail" branch, since the fixture shell already - # carries a (non-local) default_thumbnail; a fresh local one is added on top of that here. - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource("/old.png", "image/png") - self.object_store.add(example_shell) - self.file_store.add_file.return_value = "/new.png" - - response = self.client.put( - self.thumbnail_path(example_shell.id), - data={ - "fileName": "/new.png", - "file": (io.BytesIO(b"thumbnail-bytes"), "new.png", "image/png"), - }, - content_type="multipart/form-data" - ) - - self.assertEqual(204, response.status_code) - self.file_store.add_file.assert_called_once_with("/new.png", mock.ANY, "image/png") - self.file_store.delete_file.assert_called_once_with("/old.png") - retrieved_shell = self.object_store.get(example_shell.id) - self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) - new_thumbnail = retrieved_shell.asset_information.default_thumbnail - self.assertIsNotNone(new_thumbnail) - self.assertEqual("/new.png", new_thumbnail.path) - self.assertEqual("image/png", new_thumbnail.content_type) - - def test_shell_thumbnail_put_missing_filename(self): - example_shell = create_example_asset_administration_shell() - self.object_store.add(example_shell) - - response = self.client.put( - self.thumbnail_path(example_shell.id), - data={"file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png")} - ) - - self.assert_error(response, 400) - - def test_shell_thumbnail_put_shell_not_found(self): - response = self.client.put( - self.thumbnail_path("https://example.org/unknown"), - data={ - "fileName": "/thumbnail.png", - "file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png"), - } - ) - - self.assert_error(response, 404) - - # ------------------------------------------------------------------ DELETE .../asset-information/thumbnail - - def test_shell_thumbnail_delete_success(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") - self.object_store.add(example_shell) - - response = self.client.delete(self.thumbnail_path(example_shell.id)) - - self.assertEqual(204, response.status_code) - self.file_store.delete_file.assert_called_once_with("/thumbnail.png") - retrieved_shell = self.object_store.get(example_shell.id) - self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) - self.assertIsNone(retrieved_shell.asset_information.default_thumbnail) - - def test_shell_thumbnail_delete_no_thumbnail_set(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = None - self.object_store.add(example_shell) - - response = self.client.delete(self.thumbnail_path(example_shell.id)) - - self.assert_error(response, 404) - - def test_shell_thumbnail_delete_external_reference(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource( - "https://example.org/thumbnail.png", "image/png" - ) - self.object_store.add(example_shell) - - response = self.client.delete(self.thumbnail_path(example_shell.id)) - - self.assert_error(response, 400) - -@with_formatted_clients -class ExampleTest(RespsitoryEdpointTestBase): - __test__ = True - - @with_json_client - @with_xml_client - def test_shells_get(self, format_client: FormatClient): - self.object_store.update(self.two_shells_store()) - - response = format_client.get("/shells") - - self.assert_ok(response) - self.assertEqual(2, len(format_client.parse_collection(response))) - - - -class _ShellsEndpointsTest(RespsitoryEdpointTestBase, abc.ABC): +@inject_format_clients +class ShellsEndpointsTest(RepositoryEndpointTestBase): """ Endpoint tests for the implemented ``/shells`` routes of :class:`~app.interfaces.repository.WSGIApp`. @@ -217,138 +75,168 @@ class _ShellsEndpointsTest(RespsitoryEdpointTestBase, abc.ABC): swapping :attr:`format_client_cls`. """ - __test__ = False + __test__ = True # ------------------------------------------------------------------ GET /shells - def test_shells_get(self): + @with_json_client + @with_xml_client + def test_shells_get(self, format_client: FormatClient): self.object_store.update(self.two_shells_store()) - response = self.fmt.get("/shells") + response = format_client.get("/shells") self.assert_ok(response) - self.assertEqual(2, len(self.fmt.parse_collection(response))) + self.assertEqual(2, len(format_client.parse_collection(response))) # ------------------------------------------------------------------ POST /shells - def test_shells_post_success(self): + @with_json_client + @with_xml_client + def test_shells_post_success(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() - response = self.fmt.post("/shells", obj=example_shell) + response = format_client.post("/shells", obj=example_shell) self.assertEqual(201, response.status_code) self.assertIsNotNone(self.object_store.get(example_shell.id, None)) - def test_shells_post_bad(self): + @with_json_client + @with_xml_client + def test_shells_post_bad(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() example_shell.id = None # type: ignore - response = self.fmt.post("/shells", obj=example_shell) + response = format_client.post("/shells", obj=example_shell) self.assert_error(response, 400) - def test_shells_post_conflict(self): + @with_json_client + @with_xml_client + def test_shells_post_conflict(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) - response = self.fmt.post("/shells", obj=example_shell) + response = format_client.post("/shells", obj=example_shell) self.assert_error(response, 409) # ------------------------------------------------------------------ GET /shells/$reference - def test_shells_reference_get(self): + @with_json_client + @with_xml_client + def test_shells_reference_get(self, format_client: FormatClient): self.object_store.update(self.two_shells_store()) example_shell = next(iter(self.object_store)) - response = self.fmt.get("/shells/$reference") + response = format_client.get("/shells/$reference") self.assert_ok(response) - references = self.fmt.parse_collection(response) + references = format_client.parse_collection(response) self.assertEqual(2, len(references)) - self.assertIn(example_shell.id, [self.fmt.reference_target(ref) for ref in references]) + self.assertIn(example_shell.id, [format_client.reference_target(ref) for ref in references]) # ------------------------------------------------------------------ GET /shells/ - def test_shell_get_success(self): + @with_json_client + @with_xml_client + def test_shell_get_success(self, format_client: FormatClient): self.object_store.update(self.two_shells_store()) example_shell = next(iter(self.object_store)) - response = self.fmt.get(f"/shells/{base64url_encode(example_shell.id)}") + response = format_client.get(f"/shells/{base64url_encode(example_shell.id)}") self.assert_ok(response) - self.assertEqual(example_shell.id, self.fmt.identifier(self.fmt.parse_object(response))) + self.assertEqual(example_shell.id, format_client.identifier(format_client.parse_object(response))) - def test_shell_get_not_found(self): - response = self.fmt.get(f"/shells/{base64url_encode('https://example.org/unknown')}") + @with_json_client + @with_xml_client + def test_shell_get_not_found(self, format_client: FormatClient): + response = format_client.get(f"/shells/{base64url_encode('https://example.org/unknown')}") self.assert_error(response, 404) # ------------------------------------------------------------------ GET /shells//$reference - def test_shell_reference_get(self): + @with_json_client + @with_xml_client + def test_shell_reference_get(self, format_client: FormatClient): self.object_store.update(self.two_shells_store()) example_shell = next(iter(self.object_store)) - response = self.fmt.get(f"/shells/{base64url_encode(example_shell.id)}/$reference") + response = format_client.get(f"/shells/{base64url_encode(example_shell.id)}/$reference") self.assert_ok(response) - self.assertEqual(example_shell.id, self.fmt.reference_target(self.fmt.parse_object(response))) + self.assertEqual(example_shell.id, format_client.reference_target(format_client.parse_object(response))) # ------------------------------------------------------------------ PUT /shells/ - def test_shell_put_success(self): + @with_json_client + @with_xml_client + def test_shell_put_success(self, format_client: FormatClient): self.object_store.add(create_example_asset_administration_shell()) updated_shell = create_example_asset_administration_shell() updated_shell.id_short = "UpdatedIdShort" - response = self.fmt.put(f"/shells/{base64url_encode(updated_shell.id)}", obj=updated_shell) + response = format_client.put(f"/shells/{base64url_encode(updated_shell.id)}", obj=updated_shell) self.assertEqual(204, response.status_code) retrieved_shell = self.object_store.get(updated_shell.id, None) self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) self.assertEqual("UpdatedIdShort", retrieved_shell.id_short) - def test_shell_put_not_found(self): + @with_json_client + @with_xml_client + def test_shell_put_not_found(self, format_client: FormatClient): updated_shell = create_example_asset_administration_shell() - response = self.fmt.put(f"/shells/{base64url_encode('https://example.org/unknown')}", obj=updated_shell) + response = format_client.put( + f"/shells/{base64url_encode('https://example.org/unknown')}", obj=updated_shell + ) self.assert_error(response, 404) # ------------------------------------------------------------------ DELETE /shells/ - def test_shell_delete_success(self): + @with_json_client + @with_xml_client + def test_shell_delete_success(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) - response = self.fmt.delete(f"/shells/{base64url_encode(example_shell.id)}") + response = format_client.delete(f"/shells/{base64url_encode(example_shell.id)}") self.assertEqual(204, response.status_code) self.assertIsNone(self.object_store.get(example_shell.id, None)) - def test_shell_delete_not_found(self): - response = self.fmt.delete(f"/shells/{base64url_encode('https://example.org/unknown')}") + @with_json_client + @with_xml_client + def test_shell_delete_not_found(self, format_client: FormatClient): + response = format_client.delete(f"/shells/{base64url_encode('https://example.org/unknown')}") self.assert_error(response, 404) # ------------------------------------------------------------------ GET /shells//asset-information - def test_shell_asset_information_get(self): + @with_json_client + @with_xml_client + def test_shell_asset_information_get(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) - response = self.fmt.get(f"/shells/{base64url_encode(example_shell.id)}/asset-information") + response = format_client.get(f"/shells/{base64url_encode(example_shell.id)}/asset-information") self.assert_ok(response) self.assertEqual( example_shell.asset_information.global_asset_id, - self.fmt.field(self.fmt.parse_object(response), "globalAssetId"), + format_client.field(format_client.parse_object(response), "globalAssetId"), ) # ------------------------------------------------------------------ PUT /shells//asset-information - def test_shell_asset_information_put(self): + @with_json_client + @with_xml_client + def test_shell_asset_information_put(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) new_asset_information = model.AssetInformation( @@ -356,7 +244,7 @@ def test_shell_asset_information_put(self): global_asset_id="http://example.org/changed_asset", ) - response = self.fmt.put( + response = format_client.put( f"/shells/{base64url_encode(example_shell.id)}/asset-information", obj=new_asset_information, ) @@ -369,29 +257,159 @@ def test_shell_asset_information_put(self): retrieved_shell.asset_information.global_asset_id, ) + # ------------------------------------------------------------------ GET .../asset-information/thumbnail + + def thumbnail_path(self, aas_id: str) -> str: + return f"/shells/{base64url_encode(aas_id)}/asset-information/thumbnail" + + def test_shell_thumbnail_get_success(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") + self.object_store.add(example_shell) + self.file_store.write_file.side_effect = lambda name, stream: stream.write(b"thumbnail-bytes") + + response = self.client.get(self.thumbnail_path(example_shell.id)) + + self.assertEqual(200, response.status_code) + self.assertEqual("image/png", response.mimetype) + self.assertEqual(b"thumbnail-bytes", response.get_data()) + self.file_store.write_file.assert_called_once_with("/thumbnail.png", mock.ANY) + + def test_shell_thumbnail_get_no_thumbnail_set(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = None + self.object_store.add(example_shell) + + response = self.client.get(self.thumbnail_path(example_shell.id)) + + self.assert_error(response, 404) + + def test_shell_thumbnail_get_external_reference(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource( + "https://example.org/thumbnail.png", "image/png" + ) + self.object_store.add(example_shell) + + response = self.client.get(self.thumbnail_path(example_shell.id)) + + self.assert_error(response, 400) + + # ------------------------------------------------------------------ PUT .../asset-information/thumbnail + + def test_shell_thumbnail_put_success(self): + # Also exercises the "replace an existing local thumbnail" branch, since the fixture shell already + # carries a (non-local) default_thumbnail; a fresh local one is added on top of that here. + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource("/old.png", "image/png") + self.object_store.add(example_shell) + self.file_store.add_file.return_value = "/new.png" + + response = self.client.put( + self.thumbnail_path(example_shell.id), + data={ + "fileName": "/new.png", + "file": (io.BytesIO(b"thumbnail-bytes"), "new.png", "image/png"), + }, + content_type="multipart/form-data", + ) + + self.assertEqual(204, response.status_code) + self.file_store.add_file.assert_called_once_with("/new.png", mock.ANY, "image/png") + self.file_store.delete_file.assert_called_once_with("/old.png") + retrieved_shell = self.object_store.get(example_shell.id) + self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + new_thumbnail = retrieved_shell.asset_information.default_thumbnail + self.assertIsNotNone(new_thumbnail) + self.assertEqual("/new.png", new_thumbnail.path) + self.assertEqual("image/png", new_thumbnail.content_type) + + def test_shell_thumbnail_put_missing_filename(self): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + + response = self.client.put( + self.thumbnail_path(example_shell.id), + data={"file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png")}, + ) + + self.assert_error(response, 400) + + def test_shell_thumbnail_put_shell_not_found(self): + response = self.client.put( + self.thumbnail_path("https://example.org/unknown"), + data={ + "fileName": "/thumbnail.png", + "file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png"), + }, + ) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ DELETE .../asset-information/thumbnail + + def test_shell_thumbnail_delete_success(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") + self.object_store.add(example_shell) + + response = self.client.delete(self.thumbnail_path(example_shell.id)) + + self.assertEqual(204, response.status_code) + self.file_store.delete_file.assert_called_once_with("/thumbnail.png") + retrieved_shell = self.object_store.get(example_shell.id) + self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + self.assertIsNone(retrieved_shell.asset_information.default_thumbnail) + + def test_shell_thumbnail_delete_no_thumbnail_set(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = None + self.object_store.add(example_shell) + + response = self.client.delete(self.thumbnail_path(example_shell.id)) + + self.assert_error(response, 404) + + def test_shell_thumbnail_delete_external_reference(self): + example_shell = create_example_asset_administration_shell() + example_shell.asset_information.default_thumbnail = model.Resource( + "https://example.org/thumbnail.png", "image/png" + ) + self.object_store.add(example_shell) + + response = self.client.delete(self.thumbnail_path(example_shell.id)) + + self.assert_error(response, 400) + # ------------------------------------------------------------------ GET /shells//submodel-refs - def test_shell_submodel_refs_get(self): + @with_json_client + @with_xml_client + def test_shell_submodel_refs_get(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) - response = self.fmt.get(f"/shells/{base64url_encode(example_shell.id)}/submodel-refs") + response = format_client.get(f"/shells/{base64url_encode(example_shell.id)}/submodel-refs") self.assert_ok(response) - references = self.fmt.parse_collection(response) + references = format_client.parse_collection(response) self.assertEqual(1, len(references)) - self.assertEqual("https://example.org/Test_Submodel_Missing", self.fmt.reference_target(references[0])) + self.assertEqual( + "https://example.org/Test_Submodel_Missing", format_client.reference_target(references[0]) + ) # ------------------------------------------------------------------ POST /shells//submodel-refs - def test_shell_submodel_refs_post_success(self): + @with_json_client + @with_xml_client + def test_shell_submodel_refs_post_success(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) new_ref = model.ModelReference( (model.Key(model.KeyTypes.SUBMODEL, "https://example.org/NewSubmodel"),), model.Submodel ) - response = self.fmt.post(f"/shells/{base64url_encode(example_shell.id)}/submodel-refs", obj=new_ref) + response = format_client.post(f"/shells/{base64url_encode(example_shell.id)}/submodel-refs", obj=new_ref) self.assertEqual(201, response.status_code) retrieved_shell = self.object_store.get(example_shell.id) @@ -399,25 +417,31 @@ def test_shell_submodel_refs_post_success(self): identifiers = {ref.get_identifier() for ref in retrieved_shell.submodel} self.assertIn("https://example.org/NewSubmodel", identifiers) - def test_shell_submodel_refs_post_conflict(self): + @with_json_client + @with_xml_client + def test_shell_submodel_refs_post_conflict(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) existing_ref = model.ModelReference( (model.Key(model.KeyTypes.SUBMODEL, "https://example.org/Test_Submodel_Missing"),), model.Submodel ) - response = self.fmt.post(f"/shells/{base64url_encode(example_shell.id)}/submodel-refs", obj=existing_ref) + response = format_client.post( + f"/shells/{base64url_encode(example_shell.id)}/submodel-refs", obj=existing_ref + ) self.assert_error(response, 409) # ------------------------------------------------------------------ DELETE /shells//submodel-refs/ - def test_shell_submodel_refs_delete_success(self): + @with_json_client + @with_xml_client + def test_shell_submodel_refs_delete_success(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) submodel_id = "https://example.org/Test_Submodel_Missing" - response = self.fmt.delete( + response = format_client.delete( f"/shells/{base64url_encode(example_shell.id)}/submodel-refs/{base64url_encode(submodel_id)}" ) @@ -426,11 +450,13 @@ def test_shell_submodel_refs_delete_success(self): self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) self.assertEqual(0, len(list(retrieved_shell.submodel))) - def test_shell_submodel_refs_delete_not_found(self): + @with_json_client + @with_xml_client + def test_shell_submodel_refs_delete_not_found(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) - response = self.fmt.delete( + response = format_client.delete( f"/shells/{base64url_encode(example_shell.id)}/submodel-refs/" f"{base64url_encode('https://example.org/unknown')}" ) @@ -439,14 +465,16 @@ def test_shell_submodel_refs_delete_not_found(self): # ------------------------------------------------------------------ PUT /shells//submodels/ - def test_shell_submodel_refs_submodel_put(self): + @with_json_client + @with_xml_client + def test_shell_submodel_refs_submodel_put(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) self.object_store.add(create_example_submodel()) updated_submodel = create_example_submodel() updated_submodel.id_short = "UpdatedSubmodel" - response = self.fmt.put( + response = format_client.put( f"/shells/{base64url_encode(example_shell.id)}/submodels/{base64url_encode(updated_submodel.id)}", obj=updated_submodel, ) @@ -458,13 +486,15 @@ def test_shell_submodel_refs_submodel_put(self): # ------------------------------------------------------------------ DELETE /shells//submodels/ - def test_shell_submodel_refs_submodel_delete(self): + @with_json_client + @with_xml_client + def test_shell_submodel_refs_submodel_delete(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) example_submodel = create_example_submodel() self.object_store.add(example_submodel) - response = self.fmt.delete( + response = format_client.delete( f"/shells/{base64url_encode(example_shell.id)}/submodels/{base64url_encode(example_submodel.id)}" ) @@ -476,42 +506,31 @@ def test_shell_submodel_refs_submodel_delete(self): # ------------------------------------------------------------------ /shells//submodels/ redirect - def test_shell_submodel_refs_submodel_redirect(self): + @with_json_client + @with_xml_client + def test_shell_submodel_refs_submodel_redirect(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) submodel_id = "https://example.org/Test_Submodel_Missing" - response = self.fmt.get( + response = format_client.get( f"/shells/{base64url_encode(example_shell.id)}/submodels/{base64url_encode(submodel_id)}" ) self.assertEqual(307, response.status_code) self.assertIn(f"/submodels/{base64url_encode(submodel_id)}", response.headers["Location"]) - def test_shell_submodel_refs_submodel_redirect_with_path(self): + @with_json_client + @with_xml_client + def test_shell_submodel_refs_submodel_redirect_with_path(self, format_client: FormatClient): example_shell = create_example_asset_administration_shell() self.object_store.add(example_shell) submodel_id = "https://example.org/Test_Submodel_Missing" - response = self.fmt.get( - f"/shells/{base64url_encode(example_shell.id)}/submodels/{base64url_encode(submodel_id)}/submodel-elements" + response = format_client.get( + f"/shells/{base64url_encode(example_shell.id)}/submodels/{base64url_encode(submodel_id)}" + f"/submodel-elements" ) self.assertEqual(307, response.status_code) self.assertTrue(response.headers["Location"].endswith("/submodel-elements")) - - -class TestShellsEndpointsJson(_ShellsEndpointsTest): - __test__ = True - - @classmethod - def build_format_client(cls) -> FormatClient: - return JsonFormatClient(Client(cls.repository_server)) - - -class TestShellsEndpointsXml(_ShellsEndpointsTest): - __test__ = True - - @classmethod - def build_format_client(cls) -> FormatClient: - return XmlFormatClient(Client(cls.repository_server)) From 35fca6dab6a46a555762b54cfc9db1622d4f34c0 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Tue, 1 Sep 2026 18:34:49 +0200 Subject: [PATCH 06/59] Structure tests into multiple files --- server/test/interfaces/format_utils.py | 2 +- server/test/interfaces/repository/__init__.py | 0 .../test/interfaces/repository/test_base.py | 61 ++++++++++++++++++ .../test_shells.py} | 63 +++---------------- 4 files changed, 69 insertions(+), 57 deletions(-) create mode 100644 server/test/interfaces/repository/__init__.py create mode 100644 server/test/interfaces/repository/test_base.py rename server/test/interfaces/{test_repository.py => repository/test_shells.py} (90%) diff --git a/server/test/interfaces/format_utils.py b/server/test/interfaces/format_utils.py index 3861b95aa..1023c63bd 100644 --- a/server/test/interfaces/format_utils.py +++ b/server/test/interfaces/format_utils.py @@ -1,6 +1,6 @@ import abc import json -from typing import Any, Optional, Callable +from typing import Any, Callable, Optional from basyx.aas import adapter from basyx.aas.adapter._generic import XML_NS_MAP diff --git a/server/test/interfaces/repository/__init__.py b/server/test/interfaces/repository/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/test/interfaces/repository/test_base.py b/server/test/interfaces/repository/test_base.py new file mode 100644 index 000000000..de7ca12ec --- /dev/null +++ b/server/test/interfaces/repository/test_base.py @@ -0,0 +1,61 @@ +import unittest +from unittest import mock + +from app.interfaces import repository +from basyx.aas import model +from basyx.aas.adapter import aasx +from basyx.aas.examples.data.example_aas_missing_attributes import ( + create_example_asset_administration_shell, +) +from werkzeug.test import Client, TestResponse + + +class RepositoryEndpointTestBase(unittest.TestCase): + __test__ = False + + object_store: model.DictIdentifiableStore + file_store: mock.Mock + repository_server: repository.WSGIApp + client: Client + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + cls.object_store = model.DictIdentifiableStore() + cls.file_store = mock.Mock(spec=aasx.AbstractSupplementaryFileContainer) + cls.repository_server = repository.WSGIApp(cls.object_store, cls.file_store, base_path="") + cls.client = Client(cls.repository_server) + + def setUp(self) -> None: + self.object_store.clear() + self.file_store.reset_mock() + + @classmethod + def two_shells_store(cls): + store = model.DictIdentifiableStore() + store.add(create_example_asset_administration_shell()) + second_shell = create_example_asset_administration_shell() + second_shell.id = "https://example.org/Test_AssetAdministrationShell_Second" + store.add(second_shell) + return store + + # ------------------------------------------------------------------ shared assertion helpers + + def assert_ok(self, response: TestResponse) -> None: + self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) + + def assert_error(self, response: TestResponse, status_code: int) -> None: + self.assertEqual(status_code, response.status_code, msg=response.get_data(as_text=True)) + self.assertIn("success", response.get_data(as_text=True), msg=response.get_data(as_text=True)) + + +class TestServiceDescription(RepositoryEndpointTestBase): + __test__ = True + + def test_description(self): + response = self.client.get("/description") + self.assertEqual(200, response.status_code) + body = response.get_data(as_text=True) + self.assertIn("AssetAdministrationShellRepositoryServiceSpecification/SSP-001", body) + self.assertIn("SubmodelRepositoryServiceSpecification/SSP-001", body) diff --git a/server/test/interfaces/test_repository.py b/server/test/interfaces/repository/test_shells.py similarity index 90% rename from server/test/interfaces/test_repository.py rename to server/test/interfaces/repository/test_shells.py index a4e8531a2..e0d1e9c1c 100644 --- a/server/test/interfaces/test_repository.py +++ b/server/test/interfaces/repository/test_shells.py @@ -1,68 +1,19 @@ -import abc import io -import unittest from unittest import mock -from app.interfaces import repository from app.util.converters import base64url_encode from basyx.aas import model -from basyx.aas.adapter import aasx from basyx.aas.examples.data.example_aas_missing_attributes import ( create_example_asset_administration_shell, create_example_submodel, ) -from werkzeug.test import Client, TestResponse - -from .format_utils import FormatClient, JsonFormatClient, XmlFormatClient, with_json_client, inject_format_clients, \ - with_xml_client - - -class RepositoryEndpointTestBase(unittest.TestCase): - __test__ = False - - object_store: model.DictIdentifiableStore - file_store: mock.Mock - repository_server: repository.WSGIApp - client: Client - - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - - cls.object_store = model.DictIdentifiableStore() - cls.file_store = mock.Mock(spec=aasx.AbstractSupplementaryFileContainer) - cls.repository_server = repository.WSGIApp(cls.object_store, cls.file_store, base_path="") - cls.client = Client(cls.repository_server) - - def setUp(self) -> None: - self.object_store.clear() - self.file_store.reset_mock() - - @classmethod - def two_shells_store(cls): - store = model.DictIdentifiableStore() - store.add(create_example_asset_administration_shell()) - second_shell = create_example_asset_administration_shell() - second_shell.id = "https://example.org/Test_AssetAdministrationShell_Second" - store.add(second_shell) - return store - - # ------------------------------------------------------------------ shared assertion helpers - - def assert_ok(self, response: TestResponse) -> None: - self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) - - def assert_error(self, response: TestResponse, status_code: int) -> None: - self.assertEqual(status_code, response.status_code, msg=response.get_data(as_text=True)) - self.assertIn("success", response.get_data(as_text=True), msg=response.get_data(as_text=True)) - - -class TestServiceDescription(RepositoryEndpointTestBase): - __test__ = True - - def test_description(self): - response = self.client.get("/description") - self.assertEqual(200, response.status_code) +from interfaces.format_utils import ( + FormatClient, + inject_format_clients, + with_json_client, + with_xml_client, +) +from interfaces.repository.test_base import RepositoryEndpointTestBase @inject_format_clients From 6d7d0963cf34a7e2ac050371108e33110c6fa9a3 Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Tue, 1 Sep 2026 19:07:09 +0200 Subject: [PATCH 07/59] Add live server integration test, mirroring test_couchdb.py's config/skip pattern --- server/test/_helper/__init__.py | 0 server/test/_helper/test_helpers.py | 22 ++++++ .../interfaces/test_docker_integration.py | 69 +++++++++++++++++++ server/test/test_config.default.ini | 7 ++ 4 files changed, 98 insertions(+) create mode 100644 server/test/_helper/__init__.py create mode 100644 server/test/_helper/test_helpers.py create mode 100644 server/test/interfaces/test_docker_integration.py create mode 100644 server/test/test_config.default.ini diff --git a/server/test/_helper/__init__.py b/server/test/_helper/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/test/_helper/test_helpers.py b/server/test/_helper/test_helpers.py new file mode 100644 index 000000000..850fbf4bc --- /dev/null +++ b/server/test/_helper/test_helpers.py @@ -0,0 +1,22 @@ +import configparser +import os.path +import urllib.error +import urllib.request + +TEST_CONFIG = configparser.ConfigParser() +TEST_CONFIG.read( + ( + os.path.join(os.path.dirname(__file__), "..", "test_config.default.ini"), + os.path.join(os.path.dirname(__file__), "..", "test_config.ini"), + ) +) + + +# Check if the server is available. Otherwise, skip tests. +try: + urllib.request.urlopen(TEST_CONFIG["server"]["url"] + "/description", timeout=2) + SERVER_OKAY = True + SERVER_ERROR = None +except urllib.error.URLError as e: + SERVER_OKAY = False + SERVER_ERROR = e diff --git a/server/test/interfaces/test_docker_integration.py b/server/test/interfaces/test_docker_integration.py new file mode 100644 index 000000000..3747da862 --- /dev/null +++ b/server/test/interfaces/test_docker_integration.py @@ -0,0 +1,69 @@ +import json +import unittest +import urllib.error +import urllib.request + +from app.interfaces.repository import SUPPORTED_PROFILES +from app.util.converters import base64url_encode +from basyx.aas.adapter.json import AASFromJsonDecoder, AASToJsonEncoder +from basyx.aas.examples.data.example_aas import ( + AASDataChecker, + check_example_asset_administration_shell, + create_example_asset_administration_shell, +) + +from test._helper.test_helpers import SERVER_ERROR, SERVER_OKAY, TEST_CONFIG + +SERVER_BASE_URL = TEST_CONFIG["server"]["url"] + + +@unittest.skipUnless(SERVER_OKAY, f"No server reachable at {SERVER_BASE_URL}: {SERVER_ERROR}") +class ServerDockerIntegrationTest(unittest.TestCase): + """ + Smoke tests against a real, already-running server instance (e.g. started via + ``docker run -p 8080:80 basyx-python-server``), analogous to how ``test_couchdb.py`` tests + against a real CouchDB instance: skipped entirely if no server is reachable at ``SERVER_BASE_URL``. + """ + + def tearDown(self) -> None: + self._delete_shell(create_example_asset_administration_shell().id, ignore_missing=True) + + @staticmethod + def _delete_shell(shell_id: str, ignore_missing: bool = False) -> None: + request = urllib.request.Request(f"{SERVER_BASE_URL}/shells/{base64url_encode(shell_id)}", method="DELETE") + try: + urllib.request.urlopen(request) + except urllib.error.HTTPError as e: + e.close() + if not (ignore_missing and e.code == 404): + raise + + # ------------------------------------------------------------------ GET /description + + def test_description_profiles(self): + with urllib.request.urlopen(SERVER_BASE_URL + "/description") as response: + self.assertEqual(200, response.status) + data = json.loads(response.read()) + + expected_profiles = {profile.value for profile in SUPPORTED_PROFILES.profiles} + self.assertEqual(expected_profiles, set(data["profiles"])) + + # ------------------------------------------------------------------ POST/GET/DELETE /shells + + def test_shell_roundtrip(self): + shell = create_example_asset_administration_shell() + body = json.dumps(shell, cls=AASToJsonEncoder).encode("utf-8") + shell_path = f"{SERVER_BASE_URL}/shells/{base64url_encode(shell.id)}" + + post_request = urllib.request.Request( + SERVER_BASE_URL + "/shells", data=body, headers={"Content-Type": "application/json"}, method="POST" + ) + with urllib.request.urlopen(post_request) as response: + self.assertEqual(201, response.status) + + with urllib.request.urlopen(shell_path) as response: + self.assertEqual(200, response.status) + retrieved = json.loads(response.read(), cls=AASFromJsonDecoder) + + checker = AASDataChecker(raise_immediately=True) + check_example_asset_administration_shell(checker, retrieved) diff --git a/server/test/test_config.default.ini b/server/test/test_config.default.ini new file mode 100644 index 000000000..048fdef86 --- /dev/null +++ b/server/test/test_config.default.ini @@ -0,0 +1,7 @@ +# Configurations for the unittest environment + +# For customizations, please create a new file in this directory, named `test_config.ini` and add the required entries +# to that file to override the defaults defined here. + +[server] +url = http://localhost:8080/api/v3.1 From 67d809542886915adc010feaa8cba5d47a93b255 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Tue, 1 Sep 2026 19:07:39 +0200 Subject: [PATCH 08/59] Include shells query parameter tests into --- .../test/interfaces/repository/test_shells.py | 204 ++++++++++++++++++ .../test/interfaces/test_shells_asset_ids.py | 48 ----- server/test/test_api_base_path.py | 2 - 3 files changed, 204 insertions(+), 50 deletions(-) delete mode 100644 server/test/interfaces/test_shells_asset_ids.py diff --git a/server/test/interfaces/repository/test_shells.py b/server/test/interfaces/repository/test_shells.py index e0d1e9c1c..259606518 100644 --- a/server/test/interfaces/repository/test_shells.py +++ b/server/test/interfaces/repository/test_shells.py @@ -1,8 +1,12 @@ +import base64 import io +import json +from typing import Iterable from unittest import mock from app.util.converters import base64url_encode from basyx.aas import model +from basyx.aas.adapter.json import AASToJsonEncoder from basyx.aas.examples.data.example_aas_missing_attributes import ( create_example_asset_administration_shell, create_example_submodel, @@ -16,6 +20,19 @@ from interfaces.repository.test_base import RepositoryEndpointTestBase +def _encode_name_value_pair(name: str, value: str) -> str: + payload = json.dumps({"name": name, "value": value}) + return base64.urlsafe_b64encode(payload.encode()).decode() + + +def _encode_global_asset_id(value: str) -> str: + return _encode_name_value_pair("globalAssetId", value) + + +def _encode_specific_asset_id(specific_asset_id: model.SpecificAssetId) -> str: + return _encode_name_value_pair("specificAssetId", json.dumps(specific_asset_id, cls=AASToJsonEncoder)) + + @inject_format_clients class ShellsEndpointsTest(RepositoryEndpointTestBase): """ @@ -40,6 +57,193 @@ def test_shells_get(self, format_client: FormatClient): self.assert_ok(response) self.assertEqual(2, len(format_client.parse_collection(response))) + # ------------------------------------------------------------------ GET /shells?idShort=...&assetIds=... + + @staticmethod + def _specific_asset_id(name: str, value: str, subject: str) -> model.SpecificAssetId: + return model.SpecificAssetId( + name=name, + value=value, + external_subject_id=model.ExternalReference((model.Key(model.KeyTypes.GLOBAL_REFERENCE, subject),)), + ) + + @staticmethod + def _shell( + id_: str, + id_short: str, + global_asset_id: str, + specific_asset_ids: Iterable[model.SpecificAssetId] = (), + ) -> model.AssetAdministrationShell: + return model.AssetAdministrationShell( + asset_information=model.AssetInformation( + asset_kind=model.AssetKind.INSTANCE, + global_asset_id=global_asset_id, + specific_asset_id=specific_asset_ids, + ), + id_=id_, + id_short=id_short, + ) + + def shells_for_filtering_store(self): + store = model.DictIdentifiableStore() + store.add( + self._shell( + "https://example.org/shell-alpha", + "Alpha", + "https://example.org/asset-alpha", + [self._specific_asset_id("Serial", "111", "https://example.org/subject-alpha")], + ) + ) + store.add( + self._shell( + "https://example.org/shell-beta", + "Beta", + "https://example.org/asset-beta", + [ + self._specific_asset_id("Serial", "222", "https://example.org/subject-beta"), + self._specific_asset_id("Batch", "xyz", "https://example.org/subject-beta"), + ], + ) + ) + store.add(self._shell("https://example.org/shell-gamma", "Alpha", "https://example.org/asset-alpha")) + return store + + def _get_shell_ids(self, format_client: FormatClient, query: str) -> set: + response = format_client.get(f"/shells?{query}") + self.assert_ok(response) + return {format_client.identifier(node) for node in format_client.parse_collection(response)} + + @with_json_client + @with_xml_client + def test_shells_get_filter_by_id_short(self, format_client: FormatClient): + self.object_store.update(self.shells_for_filtering_store()) + + ids = self._get_shell_ids(format_client, "idShort=Alpha") + + self.assertEqual( + {"https://example.org/shell-alpha", "https://example.org/shell-gamma"}, ids + ) + + @with_json_client + @with_xml_client + def test_shells_get_filter_by_id_short_no_match(self, format_client: FormatClient): + self.object_store.update(self.shells_for_filtering_store()) + + ids = self._get_shell_ids(format_client, "idShort=Unknown") + + self.assertEqual(set(), ids) + + @with_json_client + @with_xml_client + def test_shells_get_filter_by_global_asset_id(self, format_client: FormatClient): + self.object_store.update(self.shells_for_filtering_store()) + + query = f"assetIds={_encode_global_asset_id('https://example.org/asset-alpha')}" + ids = self._get_shell_ids(format_client, query) + + self.assertEqual( + {"https://example.org/shell-alpha", "https://example.org/shell-gamma"}, ids + ) + + @with_json_client + @with_xml_client + def test_shells_get_filter_by_multiple_global_asset_ids_is_or(self, format_client: FormatClient): + self.object_store.update(self.shells_for_filtering_store()) + + query = "&".join( + [ + f"assetIds={_encode_global_asset_id('https://example.org/asset-beta')}", + f"assetIds={_encode_global_asset_id('https://example.org/nonexistent-asset')}", + ] + ) + ids = self._get_shell_ids(format_client, query) + + self.assertEqual({"https://example.org/shell-beta"}, ids) + + @with_json_client + @with_xml_client + def test_shells_get_filter_by_specific_asset_id(self, format_client: FormatClient): + self.object_store.update(self.shells_for_filtering_store()) + specific_id = self._specific_asset_id("Serial", "222", "https://example.org/subject-beta") + + query = f"assetIds={_encode_specific_asset_id(specific_id)}" + ids = self._get_shell_ids(format_client, query) + + self.assertEqual({"https://example.org/shell-beta"}, ids) + + @with_json_client + @with_xml_client + def test_shells_get_filter_by_multiple_specific_asset_ids_requires_all(self, format_client: FormatClient): + self.object_store.update(self.shells_for_filtering_store()) + serial = self._specific_asset_id("Serial", "222", "https://example.org/subject-beta") + batch = self._specific_asset_id("Batch", "xyz", "https://example.org/subject-beta") + + query = "&".join( + [f"assetIds={_encode_specific_asset_id(serial)}", f"assetIds={_encode_specific_asset_id(batch)}"] + ) + ids = self._get_shell_ids(format_client, query) + + self.assertEqual({"https://example.org/shell-beta"}, ids) + + @with_json_client + @with_xml_client + def test_shells_get_filter_by_specific_asset_ids_from_different_shells_matches_none( + self, format_client: FormatClient + ): + self.object_store.update(self.shells_for_filtering_store()) + alpha_specific_id = self._specific_asset_id("Serial", "111", "https://example.org/subject-alpha") + beta_specific_id = self._specific_asset_id("Serial", "222", "https://example.org/subject-beta") + + query = "&".join( + [ + f"assetIds={_encode_specific_asset_id(alpha_specific_id)}", + f"assetIds={_encode_specific_asset_id(beta_specific_id)}", + ] + ) + ids = self._get_shell_ids(format_client, query) + + self.assertEqual(set(), ids) + + @with_json_client + @with_xml_client + def test_shells_get_filter_by_specific_and_global_asset_id_is_and(self, format_client: FormatClient): + # shell-beta has this specificAssetId, but not this globalAssetId (that's shell-alpha's) -- combining + # both must AND the two conditions together, so neither shell matches. + self.object_store.update(self.shells_for_filtering_store()) + beta_specific_id = self._specific_asset_id("Serial", "222", "https://example.org/subject-beta") + + query = "&".join( + [ + f"assetIds={_encode_specific_asset_id(beta_specific_id)}", + f"assetIds={_encode_global_asset_id('https://example.org/asset-alpha')}", + ] + ) + ids = self._get_shell_ids(format_client, query) + + self.assertEqual(set(), ids) + + @with_json_client + @with_xml_client + def test_shells_get_filter_by_id_short_and_asset_ids_is_and(self, format_client: FormatClient): + # idShort=Alpha matches shell-alpha and shell-gamma, but only shell-alpha carries this specificAssetId. + self.object_store.update(self.shells_for_filtering_store()) + alpha_specific_id = self._specific_asset_id("Serial", "111", "https://example.org/subject-alpha") + + query = f"idShort=Alpha&assetIds={_encode_specific_asset_id(alpha_specific_id)}" + ids = self._get_shell_ids(format_client, query) + + self.assertEqual({"https://example.org/shell-alpha"}, ids) + + @with_json_client + @with_xml_client + def test_shells_get_filter_by_malformed_asset_id_returns_400(self, format_client: FormatClient): + self.object_store.update(self.shells_for_filtering_store()) + malformed = base64.urlsafe_b64encode(json.dumps({"name": "globalAssetId"}).encode()).decode() + + response = format_client.get(f"/shells?assetIds={malformed}") + + self.assert_error(response, 400) + # ------------------------------------------------------------------ POST /shells @with_json_client diff --git a/server/test/interfaces/test_shells_asset_ids.py b/server/test/interfaces/test_shells_asset_ids.py deleted file mode 100644 index e45af1b90..000000000 --- a/server/test/interfaces/test_shells_asset_ids.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) 2026 the Eclipse BaSyx Authors -# -# This program and the accompanying materials are made available under the terms of the MIT License, available in -# the LICENSE file of this project. -# -# SPDX-License-Identifier: MIT - -import base64 -import json -import unittest - -from app.interfaces.repository import WSGIApp -from basyx.aas import model -from basyx.aas.adapter.aasx import DictSupplementaryFileContainer -from basyx.aas.examples.data.example_aas import create_full_example -from werkzeug.test import Client - -BASE_PATH = "/api/v3.1" - - -def _encode_asset_id(name: str, value: str) -> str: - payload = json.dumps({"name": name, "value": value}) - return base64.urlsafe_b64encode(payload.encode()).decode() - - -class ShellsAssetIdsTest(unittest.TestCase): - def setUp(self) -> None: - self.example_data = create_full_example() - app = WSGIApp(self.example_data, DictSupplementaryFileContainer()) - self.client = Client(app) - - def test_multiple_global_asset_ids_returns_matching_results(self) -> None: - aas_list = [obj for obj in self.example_data if isinstance(obj, model.AssetAdministrationShell)] - known_id = aas_list[0].asset_information.global_asset_id - assert known_id is not None - unknown_id = "http://example.org/nonexistent_asset" - id1 = _encode_asset_id("globalAssetId", known_id) - id2 = _encode_asset_id("globalAssetId", unknown_id) - response = self.client.get(f"{BASE_PATH}/shells?assetIds={id1}&assetIds={id2}") - self.assertEqual(200, response.status_code) - result = json.loads(response.data) - returned_ids = [r["id"] for r in result] - self.assertIn(aas_list[0].id, returned_ids) - - def test_malformed_asset_id_missing_field_returns_400(self) -> None: - bad_payload = base64.urlsafe_b64encode(b'{"name": "globalAssetId"}').decode() - response = self.client.get(f"{BASE_PATH}/shells?assetIds={bad_payload}") - self.assertEqual(400, response.status_code) diff --git a/server/test/test_api_base_path.py b/server/test/test_api_base_path.py index acf1ceed8..85bfddb56 100644 --- a/server/test/test_api_base_path.py +++ b/server/test/test_api_base_path.py @@ -21,8 +21,6 @@ SERVER_ROOT / "docker" / "discovery" / "Dockerfile", SERVER_ROOT / "docker" / "registry" / "Dockerfile", SERVER_ROOT / "docker" / "repository" / "Dockerfile", - # Tests - SERVER_ROOT / "test" / "interfaces" / "test_shells_asset_ids.py", ] From fbb4b5d51fea66d1f682a03e1252b5b0b6b9e9d0 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Tue, 1 Sep 2026 20:30:45 +0200 Subject: [PATCH 09/59] Revert "Implement and test GET/PUT/DELETE for the shells asset-information thumbnail endpoint." This reverts commit 496190f4, which held the content of #618. This was added for testing purpose only. Merge `develop` into this branch, after the PR was closed to obtain the same result. --- server/app/interfaces/repository.py | 74 +--------- .../test/interfaces/repository/test_shells.py | 126 ------------------ 2 files changed, 2 insertions(+), 198 deletions(-) diff --git a/server/app/interfaces/repository.py b/server/app/interfaces/repository.py index 551125835..8f931c786 100644 --- a/server/app/interfaces/repository.py +++ b/server/app/interfaces/repository.py @@ -79,18 +79,8 @@ def __init__( ), Rule( "/asset-information/thumbnail", - methods=["GET"], - endpoint=self.get_aas_thumbnail, - ), - Rule( - "/asset-information/thumbnail", - methods=["PUT"], - endpoint=self.put_aas_thumbnail, - ), - Rule( - "/asset-information/thumbnail", - methods=["DELETE"], - endpoint=self.delete_aas_thumbnail, + methods=["GET", "PUT", "DELETE"], + endpoint=self.not_implemented, ), Rule("/submodel-refs", methods=["GET"], endpoint=self.get_aas_submodel_refs), Rule("/submodel-refs", methods=["POST"], endpoint=self.post_aas_submodel_refs), @@ -596,66 +586,6 @@ def put_aas_asset_information( self.object_store.commit(aas) return response_t() - def get_aas_thumbnail( - self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs - ) -> Response: - shell = self._get_shell(url_args) - thumbnail = shell.asset_information.default_thumbnail - if thumbnail is None or not thumbnail.path: - raise NotFound(f"{shell!r} has no default thumbnail set!") - if not thumbnail.path.startswith("/"): - raise BadRequest(f"{shell!r} references an external thumbnail: {thumbnail.path}") - bytes_io = io.BytesIO() - try: - self.file_store.write_file(thumbnail.path, bytes_io) - except KeyError: - raise NotFound(f"No thumbnail file found at path: {thumbnail.path}") - return Response(bytes_io.getvalue(), content_type=thumbnail.content_type or "application/octet-stream") - - def put_aas_thumbnail( - self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs - ) -> Response: - shell = self._get_shell(url_args) - filename = request.form.get("fileName") - if filename is None: - raise BadRequest("No 'fileName' specified!") - elif not filename.startswith("/"): - raise BadRequest(f"Given 'fileName' doesn't start with a slash (/): {filename}") - - file_storage: Optional[FileStorage] = request.files.get("file") - if file_storage is None: - raise BadRequest("Missing file to upload") - - old_thumbnail = shell.asset_information.default_thumbnail - new_path = self.file_store.add_file(filename, file_storage.stream, file_storage.mimetype) - if old_thumbnail is not None and old_thumbnail.path and old_thumbnail.path.startswith("/") \ - and old_thumbnail.path != new_path: - try: - self.file_store.delete_file(old_thumbnail.path) - except KeyError: - pass - - shell.asset_information.default_thumbnail = model.Resource(new_path, file_storage.mimetype) - self.object_store.commit(shell) - return response_t() - - def delete_aas_thumbnail( - self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs - ) -> Response: - shell = self._get_shell(url_args) - thumbnail = shell.asset_information.default_thumbnail - if thumbnail is None or not thumbnail.path: - raise NotFound(f"{shell!r} has no default thumbnail set!") - if not thumbnail.path.startswith("/"): - raise BadRequest(f"{shell!r} references an external thumbnail: {thumbnail.path}") - try: - self.file_store.delete_file(thumbnail.path) - except KeyError: - pass - shell.asset_information.default_thumbnail = None - self.object_store.commit(shell) - return response_t() - def get_aas_submodel_refs( self, request: Request, url_args: Dict, response_t: Type[APIResponse], **_kwargs ) -> Response: diff --git a/server/test/interfaces/repository/test_shells.py b/server/test/interfaces/repository/test_shells.py index 259606518..433fbdada 100644 --- a/server/test/interfaces/repository/test_shells.py +++ b/server/test/interfaces/repository/test_shells.py @@ -1,8 +1,6 @@ import base64 -import io import json from typing import Iterable -from unittest import mock from app.util.converters import base64url_encode from basyx.aas import model @@ -412,130 +410,6 @@ def test_shell_asset_information_put(self, format_client: FormatClient): retrieved_shell.asset_information.global_asset_id, ) - # ------------------------------------------------------------------ GET .../asset-information/thumbnail - - def thumbnail_path(self, aas_id: str) -> str: - return f"/shells/{base64url_encode(aas_id)}/asset-information/thumbnail" - - def test_shell_thumbnail_get_success(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") - self.object_store.add(example_shell) - self.file_store.write_file.side_effect = lambda name, stream: stream.write(b"thumbnail-bytes") - - response = self.client.get(self.thumbnail_path(example_shell.id)) - - self.assertEqual(200, response.status_code) - self.assertEqual("image/png", response.mimetype) - self.assertEqual(b"thumbnail-bytes", response.get_data()) - self.file_store.write_file.assert_called_once_with("/thumbnail.png", mock.ANY) - - def test_shell_thumbnail_get_no_thumbnail_set(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = None - self.object_store.add(example_shell) - - response = self.client.get(self.thumbnail_path(example_shell.id)) - - self.assert_error(response, 404) - - def test_shell_thumbnail_get_external_reference(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource( - "https://example.org/thumbnail.png", "image/png" - ) - self.object_store.add(example_shell) - - response = self.client.get(self.thumbnail_path(example_shell.id)) - - self.assert_error(response, 400) - - # ------------------------------------------------------------------ PUT .../asset-information/thumbnail - - def test_shell_thumbnail_put_success(self): - # Also exercises the "replace an existing local thumbnail" branch, since the fixture shell already - # carries a (non-local) default_thumbnail; a fresh local one is added on top of that here. - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource("/old.png", "image/png") - self.object_store.add(example_shell) - self.file_store.add_file.return_value = "/new.png" - - response = self.client.put( - self.thumbnail_path(example_shell.id), - data={ - "fileName": "/new.png", - "file": (io.BytesIO(b"thumbnail-bytes"), "new.png", "image/png"), - }, - content_type="multipart/form-data", - ) - - self.assertEqual(204, response.status_code) - self.file_store.add_file.assert_called_once_with("/new.png", mock.ANY, "image/png") - self.file_store.delete_file.assert_called_once_with("/old.png") - retrieved_shell = self.object_store.get(example_shell.id) - self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) - new_thumbnail = retrieved_shell.asset_information.default_thumbnail - self.assertIsNotNone(new_thumbnail) - self.assertEqual("/new.png", new_thumbnail.path) - self.assertEqual("image/png", new_thumbnail.content_type) - - def test_shell_thumbnail_put_missing_filename(self): - example_shell = create_example_asset_administration_shell() - self.object_store.add(example_shell) - - response = self.client.put( - self.thumbnail_path(example_shell.id), - data={"file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png")}, - ) - - self.assert_error(response, 400) - - def test_shell_thumbnail_put_shell_not_found(self): - response = self.client.put( - self.thumbnail_path("https://example.org/unknown"), - data={ - "fileName": "/thumbnail.png", - "file": (io.BytesIO(b"thumbnail-bytes"), "thumbnail.png", "image/png"), - }, - ) - - self.assert_error(response, 404) - - # ------------------------------------------------------------------ DELETE .../asset-information/thumbnail - - def test_shell_thumbnail_delete_success(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource("/thumbnail.png", "image/png") - self.object_store.add(example_shell) - - response = self.client.delete(self.thumbnail_path(example_shell.id)) - - self.assertEqual(204, response.status_code) - self.file_store.delete_file.assert_called_once_with("/thumbnail.png") - retrieved_shell = self.object_store.get(example_shell.id) - self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) - self.assertIsNone(retrieved_shell.asset_information.default_thumbnail) - - def test_shell_thumbnail_delete_no_thumbnail_set(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = None - self.object_store.add(example_shell) - - response = self.client.delete(self.thumbnail_path(example_shell.id)) - - self.assert_error(response, 404) - - def test_shell_thumbnail_delete_external_reference(self): - example_shell = create_example_asset_administration_shell() - example_shell.asset_information.default_thumbnail = model.Resource( - "https://example.org/thumbnail.png", "image/png" - ) - self.object_store.add(example_shell) - - response = self.client.delete(self.thumbnail_path(example_shell.id)) - - self.assert_error(response, 400) - # ------------------------------------------------------------------ GET /shells//submodel-refs @with_json_client From 74acaf6912af11f7a57c06807700090428f13f13 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Tue, 1 Sep 2026 20:33:59 +0200 Subject: [PATCH 10/59] fix relative import --- server/test/interfaces/repository/test_shells.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server/test/interfaces/repository/test_shells.py b/server/test/interfaces/repository/test_shells.py index 433fbdada..49b38f897 100644 --- a/server/test/interfaces/repository/test_shells.py +++ b/server/test/interfaces/repository/test_shells.py @@ -9,13 +9,14 @@ create_example_asset_administration_shell, create_example_submodel, ) -from interfaces.format_utils import ( + +from ..format_utils import ( FormatClient, inject_format_clients, with_json_client, with_xml_client, ) -from interfaces.repository.test_base import RepositoryEndpointTestBase +from .test_base import RepositoryEndpointTestBase def _encode_name_value_pair(name: str, value: str) -> str: @@ -36,9 +37,9 @@ class ShellsEndpointsTest(RepositoryEndpointTestBase): """ Endpoint tests for the implemented ``/shells`` routes of :class:`~app.interfaces.repository.WSGIApp`. - Bodies are written once against the format-agnostic :attr:`fmt` helper; the concrete - :class:`TestShellsEndpointsJson` / :class:`TestShellsEndpointsXml` subclasses run them once per format by - swapping :attr:`format_client_cls`. + Bodies are written once against the format-agnostic ``format_client`` helper. + For each test two variants are generated where the :class:`~..format_utils.JsonFormatClient` and + :class:`~..format_utils.XmlFormatClient` are injected respectively. """ __test__ = True From 4c08d30c0aa267b5d322ee72a5b91c5f4743d978 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Tue, 1 Sep 2026 20:46:52 +0200 Subject: [PATCH 11/59] Add tests for `/submodels` For now the `/submodel-elements` paths are excluded --- .../interfaces/repository/test_submodels.py | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 server/test/interfaces/repository/test_submodels.py diff --git a/server/test/interfaces/repository/test_submodels.py b/server/test/interfaces/repository/test_submodels.py new file mode 100644 index 000000000..3336ddeb4 --- /dev/null +++ b/server/test/interfaces/repository/test_submodels.py @@ -0,0 +1,386 @@ +import json + +from app.util.converters import base64url_encode +from basyx.aas import model +from basyx.aas.adapter.json import AASToJsonEncoder +from basyx.aas.examples.data.example_aas_missing_attributes import create_example_submodel + +from ..format_utils import ( + FormatClient, + inject_format_clients, + with_json_client, + with_xml_client, +) +from .test_base import RepositoryEndpointTestBase + + +def _encode_reference(reference: model.Reference) -> str: + return base64url_encode(json.dumps(reference, cls=AASToJsonEncoder)) + + +# semanticId carried by ``create_example_submodel()``. +EXAMPLE_SEMANTIC_ID = model.ExternalReference( + (model.Key(model.KeyTypes.GLOBAL_REFERENCE, "http://example.org/SubmodelTemplates/ExampleSubmodel"),) +) + + +@inject_format_clients +class SubmodelsEndpointsTest(RepositoryEndpointTestBase): + """ + Endpoint tests for the implemented ``/submodels`` routes of :class:`~app.interfaces.repository.WSGIApp` + that operate on the Submodel itself (the ``/submodel-elements`` subtree is covered separately). + + Bodies are written once against the format-agnostic ``format_client`` helper. + For each test two variants are generated where the :class:`~..format_utils.JsonFormatClient` and + :class:`~..format_utils.XmlFormatClient` are injected respectively. + """ + + __test__ = True + + SECOND_ID = "https://example.org/Test_Submodel_Second" + + def two_submodels_store(self) -> model.DictIdentifiableStore: + store: model.DictIdentifiableStore = model.DictIdentifiableStore() + store.add(create_example_submodel()) + second = create_example_submodel() + second.id = self.SECOND_ID + second.id_short = "SecondSubmodel" + store.add(second) + return store + + def _get_submodel_ids(self, format_client: FormatClient, query: str) -> set: + response = format_client.get(f"/submodels?{query}") + self.assert_ok(response) + return {format_client.identifier(node) for node in format_client.parse_collection(response)} + + # ------------------------------------------------------------------ GET /submodels + + @with_json_client + @with_xml_client + def test_submodels_get(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + response = format_client.get("/submodels") + + self.assert_ok(response) + self.assertEqual(2, len(format_client.parse_collection(response))) + + @with_json_client + @with_xml_client + def test_submodels_get_empty(self, format_client: FormatClient): + response = format_client.get("/submodels") + + self.assert_ok(response) + self.assertEqual(0, len(format_client.parse_collection(response))) + + # ------------------------------------------------------------------ GET /submodels?idShort=...&semanticId=... + + @with_json_client + @with_xml_client + def test_submodels_get_filter_by_id_short(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + ids = self._get_submodel_ids(format_client, "idShort=SecondSubmodel") + + self.assertEqual({self.SECOND_ID}, ids) + + @with_json_client + @with_xml_client + def test_submodels_get_filter_by_id_short_no_match(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + ids = self._get_submodel_ids(format_client, "idShort=Unknown") + + self.assertEqual(set(), ids) + + @with_json_client + @with_xml_client + def test_submodels_get_filter_by_semantic_id(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + ids = self._get_submodel_ids(format_client, f"semanticId={_encode_reference(EXAMPLE_SEMANTIC_ID)}") + + self.assertEqual({"https://example.org/Test_Submodel_Missing", self.SECOND_ID}, ids) + + @with_json_client + @with_xml_client + def test_submodels_get_filter_by_semantic_id_no_match(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + other = model.ExternalReference((model.Key(model.KeyTypes.GLOBAL_REFERENCE, "https://example.org/other"),)) + + ids = self._get_submodel_ids(format_client, f"semanticId={_encode_reference(other)}") + + self.assertEqual(set(), ids) + + # ------------------------------------------------------------------ GET /submodels?limit=...&cursor=... + + @with_json_client + @with_xml_client + def test_submodels_get_pagination_limit(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + response = format_client.get("/submodels?limit=1") + + self.assert_ok(response) + self.assertEqual(1, len(format_client.parse_collection(response))) + + @with_json_client + @with_xml_client + def test_submodels_get_pagination_cursor_walks_all_items(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + first_page = { + format_client.identifier(node) + for node in format_client.parse_collection(format_client.get("/submodels?limit=1")) + } + second_page = { + format_client.identifier(node) + for node in format_client.parse_collection(format_client.get("/submodels?limit=1&cursor=2")) + } + + self.assertEqual(1, len(first_page)) + self.assertEqual(1, len(second_page)) + self.assertEqual(set(), first_page & second_page) + self.assertEqual( + {"https://example.org/Test_Submodel_Missing", self.SECOND_ID}, first_page | second_page + ) + + def test_submodels_get_negative_limit_returns_400(self): + self.object_store.update(self.two_submodels_store()) + + response = self.client.get("/submodels?limit=-1") + + self.assert_error(response, 400) + + # ------------------------------------------------------------------ POST /submodels + + @with_json_client + @with_xml_client + def test_submodels_post_success(self, format_client: FormatClient): + example_submodel = create_example_submodel() + + response = format_client.post("/submodels", obj=example_submodel) + + self.assertEqual(201, response.status_code) + self.assertIsNotNone(self.object_store.get(example_submodel.id, None)) + + @with_json_client + @with_xml_client + def test_submodels_post_bad(self, format_client: FormatClient): + example_submodel = create_example_submodel() + example_submodel.id = None # type: ignore + + response = format_client.post("/submodels", obj=example_submodel) + + self.assert_error(response, 400) + + @with_json_client + @with_xml_client + def test_submodels_post_conflict(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = format_client.post("/submodels", obj=example_submodel) + + self.assert_error(response, 409) + + # ------------------------------------------------------------------ GET /submodels/$metadata + + @with_json_client + @with_xml_client + def test_submodels_metadata_get(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + response = format_client.get("/submodels/$metadata") + + self.assert_ok(response) + self.assertEqual(2, len(format_client.parse_collection(response))) + + def test_submodels_metadata_get_rejects_level(self): + self.object_store.add(create_example_submodel()) + + response = self.client.get("/submodels/$metadata?level=deep") + + self.assert_error(response, 400) + + # ------------------------------------------------------------------ GET /submodels/$reference + + @with_json_client + @with_xml_client + def test_submodels_reference_get(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + response = format_client.get("/submodels/$reference") + + self.assert_ok(response) + references = format_client.parse_collection(response) + self.assertEqual(2, len(references)) + self.assertEqual( + {"https://example.org/Test_Submodel_Missing", self.SECOND_ID}, + {format_client.reference_target(ref) for ref in references}, + ) + + # ------------------------------------------------------------------ GET /submodels/ + + @with_json_client + @with_xml_client + def test_submodel_get_success(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = format_client.get(f"/submodels/{base64url_encode(example_submodel.id)}") + + self.assert_ok(response) + self.assertEqual(example_submodel.id, format_client.identifier(format_client.parse_object(response))) + + @with_json_client + @with_xml_client + def test_submodel_get_not_found(self, format_client: FormatClient): + response = format_client.get(f"/submodels/{base64url_encode('https://example.org/unknown')}") + + self.assert_error(response, 404) + + def test_submodel_get_stripped_omits_submodel_elements(self): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + path = f"/submodels/{base64url_encode(example_submodel.id)}" + + full = self.client.get(path) + stripped = self.client.get(f"{path}?level=core") + + self.assert_ok(full) + self.assert_ok(stripped) + self.assertIn("submodelElements", full.get_data(as_text=True)) + self.assertNotIn("submodelElements", stripped.get_data(as_text=True)) + + def test_submodel_get_invalid_level_returns_400(self): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = self.client.get(f"/submodels/{base64url_encode(example_submodel.id)}?level=bogus") + + self.assert_error(response, 400) + + def test_submodel_get_extent_not_implemented(self): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = self.client.get( + f"/submodels/{base64url_encode(example_submodel.id)}?extent=withBlobValue" + ) + + self.assert_error(response, 501) + + # ------------------------------------------------------------------ PUT /submodels/ + + @with_json_client + @with_xml_client + def test_submodel_put_success(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + updated_submodel = create_example_submodel() + updated_submodel.id_short = "UpdatedIdShort" + + response = format_client.put( + f"/submodels/{base64url_encode(updated_submodel.id)}", obj=updated_submodel + ) + + self.assertEqual(204, response.status_code) + retrieved_submodel = self.object_store.get(updated_submodel.id, None) + self.assertIsInstance(retrieved_submodel, model.Submodel) + self.assertEqual("UpdatedIdShort", retrieved_submodel.id_short) + + @with_json_client + @with_xml_client + def test_submodel_put_not_found(self, format_client: FormatClient): + updated_submodel = create_example_submodel() + + response = format_client.put( + f"/submodels/{base64url_encode('https://example.org/unknown')}", obj=updated_submodel + ) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ DELETE /submodels/ + + @with_json_client + @with_xml_client + def test_submodel_delete_success(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = format_client.delete(f"/submodels/{base64url_encode(example_submodel.id)}") + + self.assertEqual(204, response.status_code) + self.assertIsNone(self.object_store.get(example_submodel.id, None)) + + @with_json_client + @with_xml_client + def test_submodel_delete_not_found(self, format_client: FormatClient): + response = format_client.delete(f"/submodels/{base64url_encode('https://example.org/unknown')}") + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ GET /submodels//$metadata + + @with_json_client + @with_xml_client + def test_submodel_metadata_get(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = format_client.get(f"/submodels/{base64url_encode(example_submodel.id)}/$metadata") + + self.assert_ok(response) + self.assertEqual( + example_submodel.id, format_client.identifier(format_client.parse_object(response)) + ) + + def test_submodel_metadata_get_omits_submodel_elements(self): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = self.client.get(f"/submodels/{base64url_encode(example_submodel.id)}/$metadata") + + self.assert_ok(response) + self.assertNotIn("submodelElements", response.get_data(as_text=True)) + + def test_submodel_metadata_get_rejects_level(self): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = self.client.get( + f"/submodels/{base64url_encode(example_submodel.id)}/$metadata?level=core" + ) + + self.assert_error(response, 400) + + def test_submodel_metadata_get_not_found(self): + response = self.client.get( + f"/submodels/{base64url_encode('https://example.org/unknown')}/$metadata" + ) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ GET /submodels//$reference + + @with_json_client + @with_xml_client + def test_submodel_reference_get(self, format_client: FormatClient): + example_submodel = create_example_submodel() + self.object_store.add(example_submodel) + + response = format_client.get(f"/submodels/{base64url_encode(example_submodel.id)}/$reference") + + self.assert_ok(response) + self.assertEqual( + example_submodel.id, format_client.reference_target(format_client.parse_object(response)) + ) + + @with_json_client + @with_xml_client + def test_submodel_reference_get_not_found(self, format_client: FormatClient): + response = format_client.get( + f"/submodels/{base64url_encode('https://example.org/unknown')}/$reference" + ) + + self.assert_error(response, 404) From edebc042dde5bdc6fc5bded0e100d1d6e373a4f7 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Tue, 1 Sep 2026 21:21:47 +0200 Subject: [PATCH 12/59] Add tests for `/submodel/.../submodel-elements` --- .../interfaces/repository/test_submodels.py | 577 ++++++++++++++++++ 1 file changed, 577 insertions(+) diff --git a/server/test/interfaces/repository/test_submodels.py b/server/test/interfaces/repository/test_submodels.py index 3336ddeb4..fbdedd9ba 100644 --- a/server/test/interfaces/repository/test_submodels.py +++ b/server/test/interfaces/repository/test_submodels.py @@ -1,4 +1,6 @@ +import io import json +from unittest import mock from app.util.converters import base64url_encode from basyx.aas import model @@ -287,6 +289,7 @@ def test_submodel_put_success(self, format_client: FormatClient): self.assertEqual(204, response.status_code) retrieved_submodel = self.object_store.get(updated_submodel.id, None) self.assertIsInstance(retrieved_submodel, model.Submodel) + assert isinstance(retrieved_submodel, model.Submodel) # make mypy happy self.assertEqual("UpdatedIdShort", retrieved_submodel.id_short) @with_json_client @@ -384,3 +387,577 @@ def test_submodel_reference_get_not_found(self, format_client: FormatClient): ) self.assert_error(response, 404) + + +# ExampleSubmodelCollection (a nested namespace) and one of its children, carried by +# ``create_example_submodel()``; used as ready-made id_short paths in the tests below. +NESTED_COLLECTION = "ExampleSubmodelCollection" +NESTED_PROPERTY = "ExampleSubmodelCollection.ExampleProperty" +NESTED_BLOB = "ExampleSubmodelCollection.ExampleBlob" +NESTED_FILE = "ExampleSubmodelCollection.ExampleFile" +EXAMPLE_QUALIFIER_TYPE = "http://example.org/Qualifier/ExampleQualifier" + + +@inject_format_clients +class SubmodelElementsEndpointsTest(RepositoryEndpointTestBase): + """ + Endpoint tests for the ``/submodels//submodel-elements`` subtree of + :class:`~app.interfaces.repository.WSGIApp`, including the ``$metadata`` / ``$reference`` modifiers, + the ``attachment`` file routes and the ``qualifiers`` routes. + + Bodies are written once against the format-agnostic ``format_client`` helper. + For each test two variants are generated where the :class:`~..format_utils.JsonFormatClient` and + :class:`~..format_utils.XmlFormatClient` are injected respectively. + """ + + __test__ = True + + #: Number of top-level submodel elements in ``create_example_submodel()``. + TOP_LEVEL_COUNT = 6 + + def add_example_submodel(self) -> model.Submodel: + submodel = create_example_submodel() + self.object_store.add(submodel) + return submodel + + def _stored_submodel(self, submodel_id: str) -> model.Submodel: + submodel = self.object_store.get(submodel_id) + assert isinstance(submodel, model.Submodel) + return submodel + + @staticmethod + def elements_path(submodel_id: str, id_short_path: str = "") -> str: + base = f"/submodels/{base64url_encode(submodel_id)}/submodel-elements" + return f"{base}/{id_short_path}" if id_short_path else base + + def _nested_element(self, submodel: model.Submodel, id_short_path: str) -> model.SubmodelElement: + referable: model.Referable = submodel + for id_short in id_short_path.split("."): + assert isinstance(referable, model.UniqueIdShortNamespace) + referable = referable.get_referable(id_short) + assert isinstance(referable, model.SubmodelElement) + return referable + + def _nested_property(self, submodel: model.Submodel, id_short_path: str) -> model.Property: + element = self._nested_element(submodel, id_short_path) + assert isinstance(element, model.Property) + return element + + def _nested_file(self, submodel: model.Submodel, id_short_path: str) -> model.File: + element = self._nested_element(submodel, id_short_path) + assert isinstance(element, model.File) + return element + + def _nested_blob(self, submodel: model.Submodel, id_short_path: str) -> model.Blob: + element = self._nested_element(submodel, id_short_path) + assert isinstance(element, model.Blob) + return element + + # ------------------------------------------------------------------ GET .../submodel-elements + + @with_json_client + @with_xml_client + def test_submodel_elements_get(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(self.elements_path(submodel.id)) + + self.assert_ok(response) + self.assertEqual(self.TOP_LEVEL_COUNT, len(format_client.parse_collection(response))) + + @with_json_client + @with_xml_client + def test_submodel_elements_get_pagination_limit(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}?limit=2") + + self.assert_ok(response) + self.assertEqual(2, len(format_client.parse_collection(response))) + + @with_json_client + @with_xml_client + def test_submodel_elements_get_submodel_not_found(self, format_client: FormatClient): + response = format_client.get(self.elements_path("https://example.org/unknown")) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ POST .../submodel-elements + + @with_json_client + @with_xml_client + def test_submodel_elements_post_success(self, format_client: FormatClient): + submodel = self.add_example_submodel() + new_element = model.Property("NewProperty", model.datatypes.String, "some-value") + + response = format_client.post(self.elements_path(submodel.id), obj=new_element) + + self.assertEqual(201, response.status_code) + self.assertIn("Location", response.headers) + retrieved = self._stored_submodel(submodel.id) + self.assertIsInstance(retrieved.get_referable("NewProperty"), model.Property) + + @with_json_client + @with_xml_client + def test_submodel_elements_post_conflict(self, format_client: FormatClient): + submodel = self.add_example_submodel() + duplicate = model.Property("ExampleCapability", model.datatypes.String, "v") + + response = format_client.post(self.elements_path(submodel.id), obj=duplicate) + + self.assert_error(response, 409) + + @with_json_client + @with_xml_client + def test_submodel_elements_post_submodel_not_found(self, format_client: FormatClient): + new_element = model.Property("NewProperty", model.datatypes.String, "v") + + response = format_client.post( + self.elements_path("https://example.org/unknown"), obj=new_element + ) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ GET .../submodel-elements/$metadata + + @with_json_client + @with_xml_client + def test_submodel_elements_metadata_get(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}/$metadata") + + self.assert_ok(response) + self.assertEqual(self.TOP_LEVEL_COUNT, len(format_client.parse_collection(response))) + + def test_submodel_elements_metadata_get_rejects_level(self): + submodel = self.add_example_submodel() + + response = self.client.get(f"{self.elements_path(submodel.id)}/$metadata?level=deep") + + self.assert_error(response, 400) + + # ------------------------------------------------------------------ GET .../submodel-elements/$reference + + @with_json_client + @with_xml_client + def test_submodel_elements_reference_get(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id)}/$reference") + + self.assert_ok(response) + references = format_client.parse_collection(response) + self.assertEqual(self.TOP_LEVEL_COUNT, len(references)) + self.assertIn( + "ExampleCapability", {format_client.reference_target(ref) for ref in references} + ) + + # ------------------------------------------------------------------ GET .../submodel-elements/ + + @with_json_client + @with_xml_client + def test_submodel_element_get_top_level(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(self.elements_path(submodel.id, "ExampleCapability")) + + self.assert_ok(response) + self.assertEqual( + "ExampleCapability", format_client.field(format_client.parse_object(response), "idShort") + ) + + @with_json_client + @with_xml_client + def test_submodel_element_get_nested(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(self.elements_path(submodel.id, NESTED_PROPERTY)) + + self.assert_ok(response) + self.assertEqual( + "ExampleProperty", format_client.field(format_client.parse_object(response), "idShort") + ) + + @with_json_client + @with_xml_client + def test_submodel_element_get_not_found(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(self.elements_path(submodel.id, "DoesNotExist")) + + self.assert_error(response, 404) + + @with_json_client + @with_xml_client + def test_submodel_element_get_nested_not_found(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get( + self.elements_path(submodel.id, f"{NESTED_COLLECTION}.DoesNotExist") + ) + + self.assert_error(response, 404) + + def test_submodel_element_get_path_through_non_namespace_returns_400(self): + submodel = self.add_example_submodel() + + response = self.client.get(self.elements_path(submodel.id, f"{NESTED_PROPERTY}.Child")) + + self.assert_error(response, 400) + + def test_submodel_element_get_malformed_id_short_path_returns_400(self): + submodel = self.add_example_submodel() + + response = self.client.get(self.elements_path(submodel.id, "a..b")) + + self.assert_error(response, 400) + + # ------------------------------------------------------------------ POST .../submodel-elements/ + + @with_json_client + @with_xml_client + def test_submodel_element_post_child_success(self, format_client: FormatClient): + submodel = self.add_example_submodel() + new_element = model.Property("AddedChild", model.datatypes.String, "v") + + response = format_client.post( + self.elements_path(submodel.id, NESTED_COLLECTION), obj=new_element + ) + + self.assertEqual(201, response.status_code) + retrieved = self._stored_submodel(submodel.id) + self.assertIsInstance( + self._nested_element(retrieved, f"{NESTED_COLLECTION}.AddedChild"), model.Property + ) + + @with_json_client + @with_xml_client + def test_submodel_element_post_child_conflict(self, format_client: FormatClient): + submodel = self.add_example_submodel() + duplicate = model.Property("ExampleProperty", model.datatypes.String, "v") + + response = format_client.post( + self.elements_path(submodel.id, NESTED_COLLECTION), obj=duplicate + ) + + self.assert_error(response, 409) + + def test_submodel_element_post_into_non_namespace_returns_400(self): + submodel = self.add_example_submodel() + payload = json.dumps( + model.Property("Child", model.datatypes.String, "v"), cls=AASToJsonEncoder + ) + + response = self.client.post( + self.elements_path(submodel.id, NESTED_PROPERTY), + data=payload, + content_type="application/json", + ) + + self.assert_error(response, 400) + + # ------------------------------------------------------------------ PUT .../submodel-elements/ + + @with_json_client + @with_xml_client + def test_submodel_element_put_success(self, format_client: FormatClient): + submodel = self.add_example_submodel() + updated = model.Property("ExampleProperty", model.datatypes.String, "updated-value") + + response = format_client.put( + self.elements_path(submodel.id, NESTED_PROPERTY), obj=updated + ) + + self.assertEqual(204, response.status_code) + retrieved = self._nested_property(self._stored_submodel(submodel.id), NESTED_PROPERTY) + self.assertEqual("updated-value", retrieved.value) + + @with_json_client + @with_xml_client + def test_submodel_element_put_not_found(self, format_client: FormatClient): + submodel = self.add_example_submodel() + updated = model.Property("DoesNotExist", model.datatypes.String, "v") + + response = format_client.put( + self.elements_path(submodel.id, "DoesNotExist"), obj=updated + ) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ DELETE .../submodel-elements/ + + @with_json_client + @with_xml_client + def test_submodel_element_delete_success(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.delete(self.elements_path(submodel.id, NESTED_PROPERTY)) + + self.assertEqual(204, response.status_code) + follow_up = format_client.get(self.elements_path(submodel.id, NESTED_PROPERTY)) + self.assert_error(follow_up, 404) + + @with_json_client + @with_xml_client + def test_submodel_element_delete_not_found(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.delete(self.elements_path(submodel.id, "DoesNotExist")) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ GET ...//$metadata + + @with_json_client + @with_xml_client + def test_submodel_element_metadata_get(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$metadata") + + self.assert_ok(response) + self.assertEqual( + "ExampleProperty", format_client.field(format_client.parse_object(response), "idShort") + ) + + def test_submodel_element_metadata_get_rejects_capability(self): + submodel = self.add_example_submodel() + + response = self.client.get( + f"{self.elements_path(submodel.id, 'ExampleCapability')}/$metadata" + ) + + self.assert_error(response, 400) + + def test_submodel_element_metadata_get_rejects_level(self): + submodel = self.add_example_submodel() + + response = self.client.get( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$metadata?level=core" + ) + + self.assert_error(response, 400) + + # ------------------------------------------------------------------ GET ...//$reference + + @with_json_client + @with_xml_client + def test_submodel_element_reference_get(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/$reference") + + self.assert_ok(response) + self.assertEqual( + "ExampleProperty", format_client.reference_target(format_client.parse_object(response)) + ) + + @with_json_client + @with_xml_client + def test_submodel_element_reference_get_not_found(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get( + f"{self.elements_path(submodel.id, 'DoesNotExist')}/$reference" + ) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ ...//attachment + + def test_submodel_element_attachment_get_blob(self): + submodel = self.add_example_submodel() + + response = self.client.get(f"{self.elements_path(submodel.id, NESTED_BLOB)}/attachment") + + self.assertEqual(200, response.status_code) + self.assertEqual("application/pdf", response.mimetype) + self.assertEqual(bytes([1, 2, 3, 4, 5]), response.get_data()) + + def test_submodel_element_attachment_get_on_non_file_returns_400(self): + submodel = self.add_example_submodel() + + response = self.client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/attachment") + + self.assert_error(response, 400) + + def test_submodel_element_attachment_get_file_without_value_returns_404(self): + submodel = create_example_submodel() + self._nested_file(submodel, NESTED_FILE).value = None + self.object_store.add(submodel) + + response = self.client.get(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + + self.assert_error(response, 404) + + def test_submodel_element_attachment_put_success(self): + submodel = create_example_submodel() + self._nested_file(submodel, NESTED_FILE).value = None + self.object_store.add(submodel) + self.file_store.add_file.return_value = "/uploaded.pdf" + + response = self.client.put( + f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment", + data={ + "fileName": "/uploaded.pdf", + "file": (io.BytesIO(b"pdf-bytes"), "uploaded.pdf", "application/pdf"), + }, + content_type="multipart/form-data", + ) + + self.assertEqual(204, response.status_code) + self.file_store.add_file.assert_called_once_with("/uploaded.pdf", mock.ANY, "application/pdf") + self.assertEqual( + "/uploaded.pdf", self._nested_file(self._stored_submodel(submodel.id), NESTED_FILE).value + ) + + def test_submodel_element_attachment_put_conflict_when_value_present(self): + submodel = self.add_example_submodel() + + response = self.client.put( + f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment", + data={ + "fileName": "/uploaded.pdf", + "file": (io.BytesIO(b"pdf-bytes"), "uploaded.pdf", "application/pdf"), + }, + content_type="multipart/form-data", + ) + + self.assert_error(response, 409) + + def test_submodel_element_attachment_put_on_non_file_returns_400(self): + submodel = self.add_example_submodel() + + response = self.client.put( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/attachment", + data={"fileName": "/x.pdf", "file": (io.BytesIO(b"x"), "x.pdf", "application/pdf")}, + content_type="multipart/form-data", + ) + + self.assert_error(response, 400) + + def test_submodel_element_attachment_delete_blob(self): + submodel = self.add_example_submodel() + + response = self.client.delete(f"{self.elements_path(submodel.id, NESTED_BLOB)}/attachment") + + self.assertEqual(204, response.status_code) + self.assertIsNone(self._nested_blob(self._stored_submodel(submodel.id), NESTED_BLOB).value) + + def test_submodel_element_attachment_delete_on_non_attachment_returns_400(self): + submodel = self.add_example_submodel() + + response = self.client.delete( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/attachment" + ) + + self.assert_error(response, 400) + + # ------------------------------------------------------------------ ...//qualifiers + + @with_json_client + @with_xml_client + def test_submodel_element_qualifiers_get_list(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get(f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/qualifiers") + + self.assert_ok(response) + types = {format_client.field(node, "type") for node in format_client.parse_collection(response)} + self.assertEqual({EXAMPLE_QUALIFIER_TYPE}, types) + + @with_json_client + @with_xml_client + def test_submodel_element_qualifier_get_by_type(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/qualifiers/" + f"{base64url_encode(EXAMPLE_QUALIFIER_TYPE)}" + ) + + self.assert_ok(response) + self.assertEqual( + EXAMPLE_QUALIFIER_TYPE, format_client.field(format_client.parse_object(response), "type") + ) + + @with_json_client + @with_xml_client + def test_submodel_element_qualifier_get_by_type_not_found(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.get( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/qualifiers/" + f"{base64url_encode('urn:unknown-qualifier')}" + ) + + self.assert_error(response, 404) + + @with_json_client + @with_xml_client + def test_submodel_element_qualifiers_post_success(self, format_client: FormatClient): + submodel = self.add_example_submodel() + qualifier = model.Qualifier("AddedQualifier", model.datatypes.String, "v") + + response = format_client.post( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/qualifiers", obj=qualifier + ) + + self.assertEqual(201, response.status_code) + retrieved = self._nested_element(self._stored_submodel(submodel.id), NESTED_PROPERTY) + self.assertTrue(retrieved.qualifier.contains_id("type", "AddedQualifier")) + + @with_json_client + @with_xml_client + def test_submodel_element_qualifiers_post_conflict(self, format_client: FormatClient): + submodel = self.add_example_submodel() + qualifier = model.Qualifier(EXAMPLE_QUALIFIER_TYPE, model.datatypes.String, "v") + + response = format_client.post( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/qualifiers", obj=qualifier + ) + + self.assert_error(response, 409) + + @with_json_client + @with_xml_client + def test_submodel_element_qualifier_put_success(self, format_client: FormatClient): + submodel = self.add_example_submodel() + updated = model.Qualifier(EXAMPLE_QUALIFIER_TYPE, model.datatypes.String, "changed-value") + + response = format_client.put( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/qualifiers/" + f"{base64url_encode(EXAMPLE_QUALIFIER_TYPE)}", + obj=updated, + ) + + self.assert_ok(response) + retrieved = self._nested_element(self._stored_submodel(submodel.id), NESTED_PROPERTY) + self.assertEqual("changed-value", retrieved.get_qualifier_by_type(EXAMPLE_QUALIFIER_TYPE).value) + + @with_json_client + @with_xml_client + def test_submodel_element_qualifier_delete_success(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.delete( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/qualifiers/" + f"{base64url_encode(EXAMPLE_QUALIFIER_TYPE)}" + ) + + self.assertEqual(204, response.status_code) + retrieved = self._nested_element(self._stored_submodel(submodel.id), NESTED_PROPERTY) + self.assertFalse(retrieved.qualifier.contains_id("type", EXAMPLE_QUALIFIER_TYPE)) + + @with_json_client + @with_xml_client + def test_submodel_element_qualifier_delete_not_found(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + response = format_client.delete( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/qualifiers/" + f"{base64url_encode('urn:unknown-qualifier')}" + ) + + self.assert_error(response, 404) From bb2346e8b108934de2c18f3be2da44c319971824 Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Wed, 2 Sep 2026 15:10:20 +0200 Subject: [PATCH 13/59] Fix CI port and run Docker integration tests --- .github/workflows/pr.yml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ce3008e4d..7858b26e1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -391,7 +391,7 @@ jobs: platform: linux/amd64 - name: Run container run: | - docker run -d --name basyx-python-${{ matrix.profile }} -p 9080:80 --pull=never ${{ steps.build.outputs.image-ref }} + docker run -d --name basyx-python-${{ matrix.profile }} -p 8080:80 --pull=never ${{ steps.build.outputs.image-ref }} - name: Wait for container and server initialization run: | timeout 30s bash -c ' @@ -401,7 +401,28 @@ jobs: ' - name: Check if service is alive run: | - curl -f http://localhost:9080/api/${{ env.X_API_VERSION }}/description + curl -f http://localhost:8080/api/${{ env.X_API_VERSION }}/description + - name: Set up Python ${{ env.X_PYTHON_MIN_VERSION }} + # The repository profile is the only one implementing the full /shells CRUD API that + # test_docker_integration.py exercises, so only run it for that profile. + if: matrix.profile == 'repository' + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v7 + with: + python-version: ${{ env.X_PYTHON_MIN_VERSION }} + cache: "pip" + cache-dependency-path: "**/pyproject.toml" + - name: Install Python dependencies + if: matrix.profile == 'repository' + working-directory: ./server + run: | + python -m pip install --upgrade pip + python -m pip install ../sdk + python -m pip install . + - name: Run Docker integration tests + if: matrix.profile == 'repository' + working-directory: ./server + run: | + python -m unittest test.interfaces.test_docker_integration -v - name: Stop and remove the container run: | docker stop basyx-python-${{ matrix.profile }} && docker rm basyx-python-${{ matrix.profile }} From b5b684d2cd6c8190f2ea199bec7b54a58eaf88d0 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sat, 5 Sep 2026 16:28:41 +0200 Subject: [PATCH 14/59] add missed (error) paths --- .../test/interfaces/repository/test_base.py | 5 +- .../test/interfaces/repository/test_shells.py | 22 +++ .../interfaces/repository/test_submodels.py | 156 ++++++++++++++++++ 3 files changed, 181 insertions(+), 2 deletions(-) diff --git a/server/test/interfaces/repository/test_base.py b/server/test/interfaces/repository/test_base.py index de7ca12ec..28a791edc 100644 --- a/server/test/interfaces/repository/test_base.py +++ b/server/test/interfaces/repository/test_base.py @@ -7,13 +7,14 @@ from basyx.aas.examples.data.example_aas_missing_attributes import ( create_example_asset_administration_shell, ) +from basyx.aas.model import Identifiable from werkzeug.test import Client, TestResponse class RepositoryEndpointTestBase(unittest.TestCase): __test__ = False - object_store: model.DictIdentifiableStore + object_store: model.SetIdentifiableStore[Identifiable] file_store: mock.Mock repository_server: repository.WSGIApp client: Client @@ -22,7 +23,7 @@ class RepositoryEndpointTestBase(unittest.TestCase): def setUpClass(cls) -> None: super().setUpClass() - cls.object_store = model.DictIdentifiableStore() + cls.object_store = model.SetIdentifiableStore() # DictIdentifiableStore breaks, when IDs change cls.file_store = mock.Mock(spec=aasx.AbstractSupplementaryFileContainer) cls.repository_server = repository.WSGIApp(cls.object_store, cls.file_store, base_path="") cls.client = Client(cls.repository_server) diff --git a/server/test/interfaces/repository/test_shells.py b/server/test/interfaces/repository/test_shells.py index 49b38f897..f06da0214 100644 --- a/server/test/interfaces/repository/test_shells.py +++ b/server/test/interfaces/repository/test_shells.py @@ -514,6 +514,28 @@ def test_shell_submodel_refs_submodel_put(self, format_client: FormatClient): self.assertIsInstance(retrieved_sm, model.Submodel) self.assertEqual("UpdatedSubmodel", retrieved_sm.id_short) + @with_json_client + @with_xml_client + def test_shell_submodel_refs_submodel_put_changed_id_success(self, format_client: FormatClient): + example_shell = create_example_asset_administration_shell() + self.object_store.add(example_shell) + self.object_store.add(create_example_submodel()) + updated_submodel = create_example_submodel() + old_sm_id = updated_submodel.id + updated_submodel.id = "https://example.org/Test_Submodel_Updated" + + response = format_client.put( + f"/shells/{base64url_encode(example_shell.id)}/submodels/{base64url_encode(old_sm_id)}", + obj=updated_submodel, + ) + + self.assertEqual(204, response.status_code) + retrieved_shell = self.object_store.get(example_shell.id) + self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + + self.assertIn(model.ModelReference.from_referable(updated_submodel), retrieved_shell.submodel) + self.assertNotIn(model.ModelReference.from_referable(create_example_submodel()), retrieved_shell.submodel) + # ------------------------------------------------------------------ DELETE /shells//submodels/ @with_json_client diff --git a/server/test/interfaces/repository/test_submodels.py b/server/test/interfaces/repository/test_submodels.py index fbdedd9ba..b65e288a0 100644 --- a/server/test/interfaces/repository/test_submodels.py +++ b/server/test/interfaces/repository/test_submodels.py @@ -1,5 +1,6 @@ import io import json +from io import BytesIO from unittest import mock from app.util.converters import base64url_encode @@ -775,6 +776,20 @@ def test_submodel_element_attachment_get_blob(self): self.assertEqual("application/pdf", response.mimetype) self.assertEqual(bytes([1, 2, 3, 4, 5]), response.get_data()) + def test_submodel_element_attachment_get_file(self): + submodel = self.add_example_submodel() + self._nested_file(submodel, NESTED_FILE).value = "/TestFile.pdf" + self._nested_file(submodel, NESTED_FILE).content_type = "application/pdf" + + self.file_store.write_file.side_effect = lambda name, stream: stream.write(b"file-content") + + response = self.client.get(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + + self.assertEqual(200, response.status_code) + self.assertEqual("application/pdf", response.content_type) + self.file_store.write_file.assert_any_call("/TestFile.pdf", mock.ANY) + self.assertEqual(b"file-content", response.data) + def test_submodel_element_attachment_get_on_non_file_returns_400(self): submodel = self.add_example_submodel() @@ -791,6 +806,24 @@ def test_submodel_element_attachment_get_file_without_value_returns_404(self): self.assert_error(response, 404) + def test_submodel_element_attachment_get_external_file_return_400(self): + submodel = create_example_submodel() + self._nested_file(submodel, NESTED_FILE).value = "C:\\Users\\Test\\File.pdf" + self.object_store.add(submodel) + + response = self.client.get(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + + self.assert_error(response, 400) + + def test_submodel_element_attachment_get_missing_file_return_404(self): + submodel = create_example_submodel() + self.object_store.add(submodel) + + self.file_store.write_file.side_effect = KeyError + response = self.client.get(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + + self.assert_error(response, 404) + def test_submodel_element_attachment_put_success(self): submodel = create_example_submodel() self._nested_file(submodel, NESTED_FILE).value = None @@ -837,6 +870,54 @@ def test_submodel_element_attachment_put_on_non_file_returns_400(self): self.assert_error(response, 400) + def test_submodel_element_attachment_put_missing_filename_returns_400(self): + submodel = self.add_example_submodel() + self._nested_file(submodel, NESTED_FILE).value = None + + response = self.client.put( + f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment", + data={"file": (io.BytesIO(b"x"), "x.pdf", "application/pdf")}, + content_type="multipart/form-data", + ) + + self.assert_error(response, 400) + + def test_submodel_element_attachment_put_external_filename_returns_400(self): + submodel = self.add_example_submodel() + self._nested_file(submodel, NESTED_FILE).value = None + + response = self.client.put( + f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment", + data={"fileName": "C:\\Users\\Test\\x.pdf", "file": (io.BytesIO(b"x"), "x.pdf", "application/pdf")}, + content_type="multipart/form-data", + ) + + self.assert_error(response, 400) + + def test_submodel_element_attachment_put_missing_file_returns_400(self): + submodel = self.add_example_submodel() + self._nested_file(submodel, NESTED_FILE).value = None + + response = self.client.put( + f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment", + data={"fileName": "/x.pdf"}, + content_type="multipart/form-data", + ) + + self.assert_error(response, 400) + + def test_submodel_element_attachment_put_mimetype_mismatch_returns_400(self): + submodel = self.add_example_submodel() + self._nested_file(submodel, NESTED_FILE).value = None + + response = self.client.put( + f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment", + data={"fileName": "/x.pdf", "file": (io.BytesIO(b"x"), "x.pdf", "application/xml")}, + content_type="multipart/form-data", + ) + + self.assert_error(response, 415) + def test_submodel_element_attachment_delete_blob(self): submodel = self.add_example_submodel() @@ -845,6 +926,28 @@ def test_submodel_element_attachment_delete_blob(self): self.assertEqual(204, response.status_code) self.assertIsNone(self._nested_blob(self._stored_submodel(submodel.id), NESTED_BLOB).value) + def test_submodel_element_attachment_delete_file(self): + submodel = self.add_example_submodel() + self._nested_file(submodel, NESTED_FILE).value = "/TestFile.pdf" + + response = self.client.delete(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + + self.assertEqual(204, response.status_code) + self.file_store.delete_file.assert_any_call("/TestFile.pdf") + self.assertIsNone(self._nested_file(self._stored_submodel(submodel.id), NESTED_FILE).value) + + def test_submodel_element_attachment_delete_file_ignores_store_error(self): + submodel = self.add_example_submodel() + self._nested_file(submodel, NESTED_FILE).value = "/TestFile.pdf" + + self.file_store.delete_file.side_effect = KeyError + + response = self.client.delete(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + + self.assertEqual(204, response.status_code) + self.file_store.delete_file.assert_any_call("/TestFile.pdf") + self.assertIsNone(self._nested_file(self._stored_submodel(submodel.id), NESTED_FILE).value) + def test_submodel_element_attachment_delete_on_non_attachment_returns_400(self): submodel = self.add_example_submodel() @@ -854,6 +957,24 @@ def test_submodel_element_attachment_delete_on_non_attachment_returns_400(self): self.assert_error(response, 400) + def test_submodel_element_attachment_delete_no_value_returns_404(self): + submodel = self.add_example_submodel() + self._nested_blob(submodel, NESTED_BLOB).value = None + + response = self.client.delete( + f"{self.elements_path(submodel.id, NESTED_BLOB)}/attachment" + ) + + self.assert_error(response, 404) + + def test_submodel_element_attachment_delete_external_file_returns_400(self): + submodel = self.add_example_submodel() + self._nested_file(submodel, NESTED_FILE).value = "C:\\Users\\Test\\x.pdf" + + response = self.client.delete(f"{self.elements_path(submodel.id, NESTED_FILE)}/attachment") + + self.assertEqual(400, response.status_code) + # ------------------------------------------------------------------ ...//qualifiers @with_json_client @@ -936,6 +1057,41 @@ def test_submodel_element_qualifier_put_success(self, format_client: FormatClien retrieved = self._nested_element(self._stored_submodel(submodel.id), NESTED_PROPERTY) self.assertEqual("changed-value", retrieved.get_qualifier_by_type(EXAMPLE_QUALIFIER_TYPE).value) + @with_json_client + @with_xml_client + def test_submodel_element_qualifier_put_changed_type_success(self, format_client: FormatClient): + submodel = self.add_example_submodel() + new_type = "http://example.org/Qualifier/ExampleQualifier_Changed" + updated = model.Qualifier(new_type, model.datatypes.String, "changed-value") + + response = format_client.put( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/qualifiers/{base64url_encode(EXAMPLE_QUALIFIER_TYPE)}", + obj=updated, + ) + + self.assertEqual(201, response.status_code) + retrieved = self._nested_element(self._stored_submodel(submodel.id), NESTED_PROPERTY) + self.assertTrue(retrieved.qualifier.contains_id("type", new_type)) + self.assertEqual(new_type, format_client.field(format_client.parse_object(response), "type")) + + @with_json_client + @with_xml_client + def test_submodel_element_qualifier_put_conflict(self, format_client: FormatClient): + submodel = self.add_example_submodel() + new_type = "http://example.org/Qualifier/ExampleQualifier_Changed" + self._nested_property(submodel, NESTED_PROPERTY).qualifier.add( + model.Qualifier(type_=new_type, value_type=model.datatypes.String, value="test") + ) + self.object_store.commit(submodel) + + updated = model.Qualifier(new_type, model.datatypes.String, "changed-value") + response = format_client.put( + f"{self.elements_path(submodel.id, NESTED_PROPERTY)}/qualifiers/{base64url_encode(EXAMPLE_QUALIFIER_TYPE)}", + obj=updated, + ) + + self.assertEqual(409, response.status_code) + @with_json_client @with_xml_client def test_submodel_element_qualifier_delete_success(self, format_client: FormatClient): From fcefaf3dc219c2738cbe3e218fd5d54061457ffc Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sat, 5 Sep 2026 17:33:34 +0200 Subject: [PATCH 15/59] Add unittests for `/concept-description` endpoints --- .../repository/test_concept_description.py | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 server/test/interfaces/repository/test_concept_description.py diff --git a/server/test/interfaces/repository/test_concept_description.py b/server/test/interfaces/repository/test_concept_description.py new file mode 100644 index 000000000..2ce794cca --- /dev/null +++ b/server/test/interfaces/repository/test_concept_description.py @@ -0,0 +1,194 @@ +from app.util.converters import base64url_encode +from basyx.aas import model +from basyx.aas.examples.data.example_aas_missing_attributes import ( + create_example_asset_administration_shell, + create_example_concept_description, +) + +from ..format_utils import ( + FormatClient, + inject_format_clients, + with_json_client, + with_xml_client, +) +from .test_base import RepositoryEndpointTestBase + + +@inject_format_clients +class ConceptDescriptionsEndpointsTest(RepositoryEndpointTestBase): + """ + Endpoint tests for the implemented ``/concept-descriptions`` routes of + :class:`~app.interfaces.repository.WSGIApp`. + + Bodies are written once against the format-agnostic ``format_client`` helper. For each test two + variants are generated where the :class:`~..format_utils.JsonFormatClient` and + :class:`~..format_utils.XmlFormatClient` are injected respectively. + """ + + __test__ = True + + EXAMPLE_ID = "https://example.org/Test_ConceptDescription_Missing" + SECOND_ID = "https://example.org/Test_ConceptDescription_Second" + + def two_concept_descriptions_store(self) -> model.DictIdentifiableStore: + store: model.DictIdentifiableStore = model.DictIdentifiableStore() + store.add(create_example_concept_description()) + second = create_example_concept_description() + second.id = self.SECOND_ID + second.id_short = "SecondConceptDescription" + store.add(second) + return store + + def _get_concept_description_ids(self, format_client: FormatClient, query: str) -> set: + response = format_client.get(f"/concept-descriptions?{query}") + self.assert_ok(response) + return {format_client.identifier(node) for node in format_client.parse_collection(response)} + + # ------------------------------------------------------------------ GET /concept-descriptions + + @with_json_client + @with_xml_client + def test_concept_descriptions_get(self, format_client: FormatClient): + self.object_store.update(self.two_concept_descriptions_store()) + + response = format_client.get("/concept-descriptions") + + self.assert_ok(response) + self.assertEqual(2, len(format_client.parse_collection(response))) + + @with_json_client + @with_xml_client + def test_concept_descriptions_get_empty(self, format_client: FormatClient): + response = format_client.get("/concept-descriptions") + + self.assert_ok(response) + self.assertEqual(0, len(format_client.parse_collection(response))) + + @with_json_client + @with_xml_client + def test_concept_descriptions_get_only_returns_concept_descriptions(self, format_client: FormatClient): + # The object store is shared between the shell/submodel/concept-description repositories, so the + # collection endpoint has to filter by type. + self.object_store.add(create_example_concept_description()) + self.object_store.add(create_example_asset_administration_shell()) + + ids = self._get_concept_description_ids(format_client, "") + + self.assertEqual({self.EXAMPLE_ID}, ids) + + # ------------------------------------------------------------------ POST /concept-descriptions + + @with_json_client + @with_xml_client + def test_concept_descriptions_post_success(self, format_client: FormatClient): + example_cd = create_example_concept_description() + + response = format_client.post("/concept-descriptions", obj=example_cd) + + self.assertEqual(201, response.status_code) + self.assertIn(base64url_encode(example_cd.id), response.headers["Location"]) + self.assertIsNotNone(self.object_store.get(example_cd.id, None)) + + @with_json_client + @with_xml_client + def test_concept_descriptions_post_bad(self, format_client: FormatClient): + example_cd = create_example_concept_description() + example_cd.id = None # type: ignore + + response = format_client.post("/concept-descriptions", obj=example_cd) + + self.assert_error(response, 400) + + @with_json_client + @with_xml_client + def test_concept_descriptions_post_conflict(self, format_client: FormatClient): + example_cd = create_example_concept_description() + self.object_store.add(example_cd) + + response = format_client.post("/concept-descriptions", obj=example_cd) + + self.assert_error(response, 409) + + # ------------------------------------------------------------------ GET /concept-descriptions/ + + @with_json_client + @with_xml_client + def test_concept_description_get_success(self, format_client: FormatClient): + self.object_store.update(self.two_concept_descriptions_store()) + + response = format_client.get(f"/concept-descriptions/{base64url_encode(self.EXAMPLE_ID)}") + + self.assert_ok(response) + self.assertEqual(self.EXAMPLE_ID, format_client.identifier(format_client.parse_object(response))) + + @with_json_client + @with_xml_client + def test_concept_description_get_not_found(self, format_client: FormatClient): + response = format_client.get( + f"/concept-descriptions/{base64url_encode('https://example.org/unknown')}" + ) + + self.assert_error(response, 404) + + @with_json_client + @with_xml_client + def test_concept_description_get_wrong_type_returns_404(self, format_client: FormatClient): + # An Identifiable with this id exists, but it is a shell, not a ConceptDescription. + shell = create_example_asset_administration_shell() + self.object_store.add(shell) + + response = format_client.get(f"/concept-descriptions/{base64url_encode(shell.id)}") + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ PUT /concept-descriptions/ + + @with_json_client + @with_xml_client + def test_concept_description_put_success(self, format_client: FormatClient): + self.object_store.add(create_example_concept_description()) + updated_cd = create_example_concept_description() + updated_cd.id_short = "UpdatedIdShort" + + response = format_client.put( + f"/concept-descriptions/{base64url_encode(updated_cd.id)}", obj=updated_cd + ) + + self.assertEqual(204, response.status_code) + retrieved_cd = self.object_store.get(updated_cd.id, None) + self.assertIsInstance(retrieved_cd, model.ConceptDescription) + self.assertEqual("UpdatedIdShort", retrieved_cd.id_short) + + @with_json_client + @with_xml_client + def test_concept_description_put_not_found(self, format_client: FormatClient): + updated_cd = create_example_concept_description() + updated_cd.id = "https://example.org/unknown" + + response = format_client.put( + f"/concept-descriptions/{base64url_encode(updated_cd.id)}", obj=updated_cd + ) + + self.assert_error(response, 404) + + # ------------------------------------------------------------------ DELETE /concept-descriptions/ + + @with_json_client + @with_xml_client + def test_concept_description_delete_success(self, format_client: FormatClient): + example_cd = create_example_concept_description() + self.object_store.add(example_cd) + + response = format_client.delete(f"/concept-descriptions/{base64url_encode(example_cd.id)}") + + self.assertEqual(204, response.status_code) + self.assertIsNone(self.object_store.get(example_cd.id, None)) + + @with_json_client + @with_xml_client + def test_concept_description_delete_not_found(self, format_client: FormatClient): + response = format_client.delete( + f"/concept-descriptions/{base64url_encode('https://example.org/unknown')}" + ) + + self.assert_error(response, 404) From cb26b0a5aae352f0bd21b19a391a8a513ba593c2 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sat, 5 Sep 2026 17:53:17 +0200 Subject: [PATCH 16/59] Test pagination of results Added test class `TestPagination` to `test_base.py` that ensures pagination by following `cursor` value correctly assembles all items. Additionally, all endpoints, that should support pagination are checked if they do so. --- server/test/interfaces/format_utils.py | 46 +++++++++ .../test/interfaces/repository/test_base.py | 37 ++++++++ .../repository/test_concept_description.py | 9 ++ .../test/interfaces/repository/test_shells.py | 35 +++++++ .../interfaces/repository/test_submodels.py | 94 +++++++++++-------- 5 files changed, 181 insertions(+), 40 deletions(-) diff --git a/server/test/interfaces/format_utils.py b/server/test/interfaces/format_utils.py index 1023c63bd..4baeca6b1 100644 --- a/server/test/interfaces/format_utils.py +++ b/server/test/interfaces/format_utils.py @@ -52,6 +52,36 @@ def request(self, method: str, path: str, obj: Optional[object] = None, data: An def get(self, path: str, **kwargs) -> TestResponse: return self.request("GET", path, **kwargs) + def get_paginated(self, path: str, limit: int, max_pages:int, **kwargs) -> list[list[Any]]: + """ + Iteratively query the paginated endpoint :param:`path` with the given :param:`limit` until + the server indicates the complete collection was read. + + :param path: path to perform the request to. + :param limit: the limit for the paginated request, controls maximum size of each page. + :param max_pages: request fails if more than these pages are returned by endpoint. + :param kwargs: additional arguments to pass to the query. + :return: list of returned pages, each page is a list as returned by :meth:`parse_collection`. + """ + pages: list[list[str]] = [] + cursor: str | None = None + while True: + start_or_and = "?" if "?" not in path else "&" + query = f"{path}{start_or_and}limit={limit}" + if cursor is not None: + query += f"&cursor={cursor}" + response = self.get(query, **kwargs) + assert 200 == response.status_code + page_content = self.parse_collection(response) + assert limit >= len(page_content), "paginated result contains more than limit items" + pages.append(page_content) + cursor = self.next_cursor(response) + if cursor is None: + break + assert len(pages) <= max_pages, "cursor never signalled the last page" + + return pages + def post(self, path: str, obj: Optional[object] = None, **kwargs: Any) -> TestResponse: return self.request("POST", path, obj=obj, **kwargs) @@ -94,6 +124,10 @@ def field(self, node: Any, name: str) -> Optional[str]: def result_success(self, response: TestResponse) -> bool: """The value of the ``success`` flag in a ``Result`` body.""" + @abc.abstractmethod + def next_cursor(self, response: TestResponse) -> Optional[str]: + """The paging cursor pointing at the next page, or ``None`` once the last page has been returned.""" + class JsonFormatClient(FormatClient): content_type = "application/json" @@ -126,6 +160,12 @@ def result_success(self, response: TestResponse) -> bool: body = self._payload(response) return "success" not in body or bool(body["success"]) + def next_cursor(self, response: TestResponse) -> Optional[str]: + payload = self._payload(response) + if isinstance(payload, dict): + return payload.get("paging_metadata", {}).get("cursor") + return None + class XmlFormatClient(FormatClient): content_type = "application/xml" @@ -165,6 +205,12 @@ def result_success(self, response: TestResponse) -> bool: success_elem = self._root(response).find("success") return success_elem is None or success_elem.text == "true" + def next_cursor(self, response: TestResponse) -> Optional[str]: + # The cursor is an attribute on the root; it is unconditionally serialized, so a + # missing next page shows up as the literal string "None" rather than an absent attribute. + cursor = self._root(response).get("cursor") + return cursor if cursor not in (None, "None") else None + def with_json_client(func): client_types = getattr(func, "_client_types", []) diff --git a/server/test/interfaces/repository/test_base.py b/server/test/interfaces/repository/test_base.py index 28a791edc..b77102c05 100644 --- a/server/test/interfaces/repository/test_base.py +++ b/server/test/interfaces/repository/test_base.py @@ -6,10 +6,13 @@ from basyx.aas.adapter import aasx from basyx.aas.examples.data.example_aas_missing_attributes import ( create_example_asset_administration_shell, + create_example_submodel, ) from basyx.aas.model import Identifiable from werkzeug.test import Client, TestResponse +from ..format_utils import FormatClient, inject_format_clients, with_json_client, with_xml_client + class RepositoryEndpointTestBase(unittest.TestCase): __test__ = False @@ -60,3 +63,37 @@ def test_description(self): body = response.get_data(as_text=True) self.assertIn("AssetAdministrationShellRepositoryServiceSpecification/SSP-001", body) self.assertIn("SubmodelRepositoryServiceSpecification/SSP-001", body) + +@inject_format_clients +class TestPagination(RepositoryEndpointTestBase): + + __test__ = True + + EXAMPLE_ID = "https://example.org/Test_Submodel_Missing" + SECOND_ID = "https://example.org/ExampleSubmodel_Second" + THIRD_ID = "https://example.org/ExampleSubmodel_Third" + + @with_json_client + @with_xml_client + def test_pagination_walks_all_items(self, format_client: FormatClient): + self.object_store.add(create_example_submodel()) + second_sm = create_example_submodel() + second_sm.id = self.SECOND_ID + self.object_store.add(second_sm) + third_sm = create_example_submodel() + third_sm.id = self.THIRD_ID + self.object_store.add(third_sm) + + pages = format_client.get_paginated("/submodels", limit=2, max_pages=2) + + seen = [format_client.identifier(node) for page in pages for node in page] + self.assertEqual([2, 1], [len(page) for page in pages]) + self.assertEqual(len(seen), len(set(seen)), "an item was returned on more than one page") + self.assertEqual({self.EXAMPLE_ID, self.SECOND_ID, self.THIRD_ID}, set(seen)) + + def test_submodels_get_negative_limit_returns_400(self): + self.object_store.add(create_example_submodel()) + + response = self.client.get("/submodels?limit=-1") + + self.assert_error(response, 400) diff --git a/server/test/interfaces/repository/test_concept_description.py b/server/test/interfaces/repository/test_concept_description.py index 2ce794cca..e6ac29bea 100644 --- a/server/test/interfaces/repository/test_concept_description.py +++ b/server/test/interfaces/repository/test_concept_description.py @@ -76,6 +76,15 @@ def test_concept_descriptions_get_only_returns_concept_descriptions(self, format self.assertEqual({self.EXAMPLE_ID}, ids) + @with_json_client + @with_xml_client + def test_concept_descriptions_get_supports_pagination(self, format_client: FormatClient): + self.object_store.update(self.two_concept_descriptions_store()) + + pages = format_client.get_paginated("/concept-descriptions", limit=1, max_pages=2) + + self.assertEqual([1, 1], [len(page) for page in pages]) + # ------------------------------------------------------------------ POST /concept-descriptions @with_json_client diff --git a/server/test/interfaces/repository/test_shells.py b/server/test/interfaces/repository/test_shells.py index f06da0214..634c2a38b 100644 --- a/server/test/interfaces/repository/test_shells.py +++ b/server/test/interfaces/repository/test_shells.py @@ -56,6 +56,15 @@ def test_shells_get(self, format_client: FormatClient): self.assert_ok(response) self.assertEqual(2, len(format_client.parse_collection(response))) + @with_json_client + @with_xml_client + def test_shells_get_supports_pagination(self, format_client: FormatClient): + self.object_store.update(self.two_shells_store()) + + pages = format_client.get_paginated("/shells", limit=1, max_pages=2) + + self.assertEqual([1, 1], [len(page) for page in pages]) + # ------------------------------------------------------------------ GET /shells?idShort=...&assetIds=... @staticmethod @@ -290,6 +299,15 @@ def test_shells_reference_get(self, format_client: FormatClient): self.assertEqual(2, len(references)) self.assertIn(example_shell.id, [format_client.reference_target(ref) for ref in references]) + @with_json_client + @with_xml_client + def test_shells_reference_get_supports_pagination(self, format_client: FormatClient): + self.object_store.update(self.two_shells_store()) + + pages = format_client.get_paginated("/shells/$reference", limit=1, max_pages=2) + + self.assertEqual([1, 1], [len(page) for page in pages]) + # ------------------------------------------------------------------ GET /shells/ @with_json_client @@ -428,6 +446,23 @@ def test_shell_submodel_refs_get(self, format_client: FormatClient): "https://example.org/Test_Submodel_Missing", format_client.reference_target(references[0]) ) + @with_json_client + @with_xml_client + def test_shell_submodel_refs_get_supports_pagination(self, format_client: FormatClient): + example_shell = create_example_asset_administration_shell() + example_shell.submodel.add( + model.ModelReference( + (model.Key(model.KeyTypes.SUBMODEL, "https://example.org/Second_Submodel_Ref"),), model.Submodel + ) + ) + self.object_store.add(example_shell) + + pages = format_client.get_paginated( + f"/shells/{base64url_encode(example_shell.id)}/submodel-refs", limit=1, max_pages=2 + ) + + self.assertEqual([1, 1], [len(page) for page in pages]) + # ------------------------------------------------------------------ POST /shells//submodel-refs @with_json_client diff --git a/server/test/interfaces/repository/test_submodels.py b/server/test/interfaces/repository/test_submodels.py index b65e288a0..c728dd5b7 100644 --- a/server/test/interfaces/repository/test_submodels.py +++ b/server/test/interfaces/repository/test_submodels.py @@ -76,6 +76,15 @@ def test_submodels_get_empty(self, format_client: FormatClient): self.assert_ok(response) self.assertEqual(0, len(format_client.parse_collection(response))) + @with_json_client + @with_xml_client + def test_submodels_get_supports_pagination(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + pages = format_client.get_paginated("/submodels", limit=1, max_pages=2) + + self.assertEqual([1, 1], [len(page) for page in pages]) + # ------------------------------------------------------------------ GET /submodels?idShort=...&semanticId=... @with_json_client @@ -115,46 +124,6 @@ def test_submodels_get_filter_by_semantic_id_no_match(self, format_client: Forma self.assertEqual(set(), ids) - # ------------------------------------------------------------------ GET /submodels?limit=...&cursor=... - - @with_json_client - @with_xml_client - def test_submodels_get_pagination_limit(self, format_client: FormatClient): - self.object_store.update(self.two_submodels_store()) - - response = format_client.get("/submodels?limit=1") - - self.assert_ok(response) - self.assertEqual(1, len(format_client.parse_collection(response))) - - @with_json_client - @with_xml_client - def test_submodels_get_pagination_cursor_walks_all_items(self, format_client: FormatClient): - self.object_store.update(self.two_submodels_store()) - - first_page = { - format_client.identifier(node) - for node in format_client.parse_collection(format_client.get("/submodels?limit=1")) - } - second_page = { - format_client.identifier(node) - for node in format_client.parse_collection(format_client.get("/submodels?limit=1&cursor=2")) - } - - self.assertEqual(1, len(first_page)) - self.assertEqual(1, len(second_page)) - self.assertEqual(set(), first_page & second_page) - self.assertEqual( - {"https://example.org/Test_Submodel_Missing", self.SECOND_ID}, first_page | second_page - ) - - def test_submodels_get_negative_limit_returns_400(self): - self.object_store.update(self.two_submodels_store()) - - response = self.client.get("/submodels?limit=-1") - - self.assert_error(response, 400) - # ------------------------------------------------------------------ POST /submodels @with_json_client @@ -199,6 +168,15 @@ def test_submodels_metadata_get(self, format_client: FormatClient): self.assert_ok(response) self.assertEqual(2, len(format_client.parse_collection(response))) + @with_json_client + @with_xml_client + def test_submodels_metadata_get_supports_pagination(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + pages = format_client.get_paginated("/submodels/$metadata", limit=1, max_pages=2) + + self.assertEqual([1, 1], [len(page) for page in pages]) + def test_submodels_metadata_get_rejects_level(self): self.object_store.add(create_example_submodel()) @@ -223,6 +201,15 @@ def test_submodels_reference_get(self, format_client: FormatClient): {format_client.reference_target(ref) for ref in references}, ) + @with_json_client + @with_xml_client + def test_submodels_reference_get_supports_pagination(self, format_client: FormatClient): + self.object_store.update(self.two_submodels_store()) + + pages = format_client.get_paginated("/submodels/$reference", limit=1, max_pages=2) + + self.assertEqual([1, 1], [len(page) for page in pages]) + # ------------------------------------------------------------------ GET /submodels/ @with_json_client @@ -476,6 +463,15 @@ def test_submodel_elements_get_pagination_limit(self, format_client: FormatClien self.assert_ok(response) self.assertEqual(2, len(format_client.parse_collection(response))) + @with_json_client + @with_xml_client + def test_submodel_elements_get_supports_pagination(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + pages = format_client.get_paginated(self.elements_path(submodel.id), limit=3, max_pages=2) + + self.assertEqual([3, 3], [len(page) for page in pages]) + @with_json_client @with_xml_client def test_submodel_elements_get_submodel_not_found(self, format_client: FormatClient): @@ -531,6 +527,15 @@ def test_submodel_elements_metadata_get(self, format_client: FormatClient): self.assert_ok(response) self.assertEqual(self.TOP_LEVEL_COUNT, len(format_client.parse_collection(response))) + @with_json_client + @with_xml_client + def test_submodel_elements_metadata_get_supports_pagination(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + pages = format_client.get_paginated(f"{self.elements_path(submodel.id)}/$metadata", limit=3, max_pages=2) + + self.assertEqual([3, 3], [len(page) for page in pages]) + def test_submodel_elements_metadata_get_rejects_level(self): submodel = self.add_example_submodel() @@ -554,6 +559,15 @@ def test_submodel_elements_reference_get(self, format_client: FormatClient): "ExampleCapability", {format_client.reference_target(ref) for ref in references} ) + @with_json_client + @with_xml_client + def test_submodel_elements_reference_get_supports_pagination(self, format_client: FormatClient): + submodel = self.add_example_submodel() + + pages = format_client.get_paginated(f"{self.elements_path(submodel.id)}/$reference", limit=3, max_pages=2) + + self.assertEqual([3, 3], [len(page) for page in pages]) + # ------------------------------------------------------------------ GET .../submodel-elements/ @with_json_client From 1326d6ad341b9619a2bfde2cd4d2e9577d343a7c Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sun, 6 Sep 2026 17:39:36 +0200 Subject: [PATCH 17/59] fix mypy and ruff errors --- server/test/interfaces/repository/test_base.py | 12 +++++++++++- .../repository/test_concept_description.py | 1 + server/test/interfaces/repository/test_shells.py | 7 +++++++ server/test/interfaces/repository/test_submodels.py | 1 - 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/server/test/interfaces/repository/test_base.py b/server/test/interfaces/repository/test_base.py index b77102c05..084ab86b9 100644 --- a/server/test/interfaces/repository/test_base.py +++ b/server/test/interfaces/repository/test_base.py @@ -1,4 +1,5 @@ import unittest +from typing import TypeVar from unittest import mock from app.interfaces import repository @@ -13,6 +14,7 @@ from ..format_utils import FormatClient, inject_format_clients, with_json_client, with_xml_client +T = TypeVar('T') class RepositoryEndpointTestBase(unittest.TestCase): __test__ = False @@ -66,7 +68,15 @@ def test_description(self): @inject_format_clients class TestPagination(RepositoryEndpointTestBase): - + """ + Endpoint testing of the shared pagination strategy, shared by multiple endpoints. Ensures + that all results are returned and pages do not overlap. As testing endpoints ``/submodels`` is used. + + Bodies are written once against the format-agnostic ``format_client`` helper. For each test two + variants are generated where the :class:`~..format_utils.JsonFormatClient` and + :class:`~..format_utils.XmlFormatClient` are injected respectively. + """ + __test__ = True EXAMPLE_ID = "https://example.org/Test_Submodel_Missing" diff --git a/server/test/interfaces/repository/test_concept_description.py b/server/test/interfaces/repository/test_concept_description.py index e6ac29bea..5712be663 100644 --- a/server/test/interfaces/repository/test_concept_description.py +++ b/server/test/interfaces/repository/test_concept_description.py @@ -166,6 +166,7 @@ def test_concept_description_put_success(self, format_client: FormatClient): self.assertEqual(204, response.status_code) retrieved_cd = self.object_store.get(updated_cd.id, None) self.assertIsInstance(retrieved_cd, model.ConceptDescription) + assert isinstance(retrieved_cd, model.ConceptDescription) # make mypy happy self.assertEqual("UpdatedIdShort", retrieved_cd.id_short) @with_json_client diff --git a/server/test/interfaces/repository/test_shells.py b/server/test/interfaces/repository/test_shells.py index 634c2a38b..7dc645885 100644 --- a/server/test/interfaces/repository/test_shells.py +++ b/server/test/interfaces/repository/test_shells.py @@ -355,6 +355,7 @@ def test_shell_put_success(self, format_client: FormatClient): self.assertEqual(204, response.status_code) retrieved_shell = self.object_store.get(updated_shell.id, None) self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + assert isinstance(retrieved_shell, model.AssetAdministrationShell) # make mypy happy self.assertEqual("UpdatedIdShort", retrieved_shell.id_short) @with_json_client @@ -424,6 +425,7 @@ def test_shell_asset_information_put(self, format_client: FormatClient): self.assertEqual(204, response.status_code) retrieved_shell = self.object_store.get(example_shell.id) self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + assert isinstance(retrieved_shell, model.AssetAdministrationShell) # make mypy happy self.assertEqual( "http://example.org/changed_asset", retrieved_shell.asset_information.global_asset_id, @@ -479,6 +481,7 @@ def test_shell_submodel_refs_post_success(self, format_client: FormatClient): self.assertEqual(201, response.status_code) retrieved_shell = self.object_store.get(example_shell.id) self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + assert isinstance(retrieved_shell, model.AssetAdministrationShell) # make mypy happy identifiers = {ref.get_identifier() for ref in retrieved_shell.submodel} self.assertIn("https://example.org/NewSubmodel", identifiers) @@ -513,6 +516,7 @@ def test_shell_submodel_refs_delete_success(self, format_client: FormatClient): self.assertEqual(204, response.status_code) retrieved_shell = self.object_store.get(example_shell.id) self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + assert isinstance(retrieved_shell, model.AssetAdministrationShell) # make mypy happy self.assertEqual(0, len(list(retrieved_shell.submodel))) @with_json_client @@ -547,6 +551,7 @@ def test_shell_submodel_refs_submodel_put(self, format_client: FormatClient): self.assertEqual(204, response.status_code) retrieved_sm = self.object_store.get(updated_submodel.id) self.assertIsInstance(retrieved_sm, model.Submodel) + assert isinstance(retrieved_sm, model.Submodel) # make mypy happy self.assertEqual("UpdatedSubmodel", retrieved_sm.id_short) @with_json_client @@ -567,6 +572,7 @@ def test_shell_submodel_refs_submodel_put_changed_id_success(self, format_client self.assertEqual(204, response.status_code) retrieved_shell = self.object_store.get(example_shell.id) self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + assert isinstance(retrieved_shell, model.AssetAdministrationShell) # make mypy happy self.assertIn(model.ModelReference.from_referable(updated_submodel), retrieved_shell.submodel) self.assertNotIn(model.ModelReference.from_referable(create_example_submodel()), retrieved_shell.submodel) @@ -589,6 +595,7 @@ def test_shell_submodel_refs_submodel_delete(self, format_client: FormatClient): self.assertIsNone(self.object_store.get(example_submodel.id, None)) retrieved_shell = self.object_store.get(example_shell.id) self.assertIsInstance(retrieved_shell, model.AssetAdministrationShell) + assert isinstance(retrieved_shell, model.AssetAdministrationShell) # make mypy happy self.assertEqual(0, len(list(retrieved_shell.submodel))) # ------------------------------------------------------------------ /shells//submodels/ redirect diff --git a/server/test/interfaces/repository/test_submodels.py b/server/test/interfaces/repository/test_submodels.py index c728dd5b7..fad9fdc43 100644 --- a/server/test/interfaces/repository/test_submodels.py +++ b/server/test/interfaces/repository/test_submodels.py @@ -1,6 +1,5 @@ import io import json -from io import BytesIO from unittest import mock from app.util.converters import base64url_encode From 22ff3273e4e9f48f5aa44bae1fcc4b5d10f1ae42 Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Sat, 5 Sep 2026 16:05:27 +0300 Subject: [PATCH 18/59] Make Docker integration tests fail loudly instead of silently skipping in CI --- .github/workflows/pr.yml | 3 +++ server/test/_helper/test_helpers.py | 9 ++++++++- .../test/interfaces/test_docker_integration.py | 18 ++++++++++++++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7858b26e1..400c423ef 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -421,6 +421,9 @@ jobs: - name: Run Docker integration tests if: matrix.profile == 'repository' working-directory: ./server + env: + # Fail instead of silently skipping if the container isn't actually reachable (see test_helpers.py). + REQUIRE_SERVER_INTEGRATION_TESTS: "1" run: | python -m unittest test.interfaces.test_docker_integration -v - name: Stop and remove the container diff --git a/server/test/_helper/test_helpers.py b/server/test/_helper/test_helpers.py index 850fbf4bc..6db4a1d95 100644 --- a/server/test/_helper/test_helpers.py +++ b/server/test/_helper/test_helpers.py @@ -1,4 +1,5 @@ import configparser +import os import os.path import urllib.error import urllib.request @@ -12,7 +13,13 @@ ) -# Check if the server is available. Otherwise, skip tests. +# By default, the Docker integration tests are skipped whenever no server is reachable, so that a plain local +# `python -m unittest` run doesn't require a running Docker container. Set this environment variable to any +# non-empty value (e.g. in CI) to instead make those tests fail loudly if no server is reachable, so a broken +# Docker container can't silently cause the tests to be skipped without anyone noticing. +REQUIRE_SERVER = bool(os.environ.get("REQUIRE_SERVER_INTEGRATION_TESTS")) + +# Check if the server is available. Otherwise, skip tests (unless REQUIRE_SERVER is set, see above). try: urllib.request.urlopen(TEST_CONFIG["server"]["url"] + "/description", timeout=2) SERVER_OKAY = True diff --git a/server/test/interfaces/test_docker_integration.py b/server/test/interfaces/test_docker_integration.py index 3747da862..b571bcadb 100644 --- a/server/test/interfaces/test_docker_integration.py +++ b/server/test/interfaces/test_docker_integration.py @@ -12,19 +12,33 @@ create_example_asset_administration_shell, ) -from test._helper.test_helpers import SERVER_ERROR, SERVER_OKAY, TEST_CONFIG +from test._helper.test_helpers import REQUIRE_SERVER, SERVER_ERROR, SERVER_OKAY, TEST_CONFIG SERVER_BASE_URL = TEST_CONFIG["server"]["url"] -@unittest.skipUnless(SERVER_OKAY, f"No server reachable at {SERVER_BASE_URL}: {SERVER_ERROR}") +@unittest.skipUnless( + SERVER_OKAY or REQUIRE_SERVER, f"No server reachable at {SERVER_BASE_URL}: {SERVER_ERROR}" +) class ServerDockerIntegrationTest(unittest.TestCase): """ Smoke tests against a real, already-running server instance (e.g. started via ``docker run -p 8080:80 basyx-python-server``), analogous to how ``test_couchdb.py`` tests against a real CouchDB instance: skipped entirely if no server is reachable at ``SERVER_BASE_URL``. + + Set the ``REQUIRE_SERVER_INTEGRATION_TESTS`` environment variable to make this test class fail instead of + being skipped when no server is reachable (see ``test._helper.test_helpers``). CI uses this to ensure a + broken Docker container is reported as a failure rather than silently skipping the tests. """ + @classmethod + def setUpClass(cls) -> None: + if not SERVER_OKAY: + raise RuntimeError( + f"REQUIRE_SERVER_INTEGRATION_TESTS is set, but no server is reachable at " + f"{SERVER_BASE_URL}: {SERVER_ERROR}" + ) + def tearDown(self) -> None: self._delete_shell(create_example_asset_administration_shell().id, ignore_missing=True) From 134c33ac83a8063c41022b5be38c64be191472ff Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Sun, 6 Sep 2026 18:10:11 +0200 Subject: [PATCH 19/59] Add duplicate-POST, update, and not-found cases to Docker integration test --- .../interfaces/test_docker_integration.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/server/test/interfaces/test_docker_integration.py b/server/test/interfaces/test_docker_integration.py index b571bcadb..3c32c5bd1 100644 --- a/server/test/interfaces/test_docker_integration.py +++ b/server/test/interfaces/test_docker_integration.py @@ -81,3 +81,55 @@ def test_shell_roundtrip(self): checker = AASDataChecker(raise_immediately=True) check_example_asset_administration_shell(checker, retrieved) + + def test_shell_duplicate_post(self): + shell = create_example_asset_administration_shell() + body = json.dumps(shell, cls=AASToJsonEncoder).encode("utf-8") + post_request = urllib.request.Request( + SERVER_BASE_URL + "/shells", data=body, headers={"Content-Type": "application/json"}, method="POST" + ) + + with urllib.request.urlopen(post_request) as response: + self.assertEqual(201, response.status) + + with self.assertRaises(urllib.error.HTTPError) as cm: + urllib.request.urlopen(post_request) + self.assertEqual(409, cm.exception.code) + + def test_shell_update(self): + shell = create_example_asset_administration_shell() + body = json.dumps(shell, cls=AASToJsonEncoder).encode("utf-8") + shell_path = f"{SERVER_BASE_URL}/shells/{base64url_encode(shell.id)}" + + post_request = urllib.request.Request( + SERVER_BASE_URL + "/shells", data=body, headers={"Content-Type": "application/json"}, method="POST" + ) + with urllib.request.urlopen(post_request) as response: + self.assertEqual(201, response.status) + + shell.id_short = "UpdatedIdShort" + updated_body = json.dumps(shell, cls=AASToJsonEncoder).encode("utf-8") + put_request = urllib.request.Request( + shell_path, data=updated_body, headers={"Content-Type": "application/json"}, method="PUT" + ) + with urllib.request.urlopen(put_request) as response: + self.assertEqual(204, response.status) + + with urllib.request.urlopen(shell_path) as response: + self.assertEqual(200, response.status) + retrieved = json.loads(response.read(), cls=AASFromJsonDecoder) + self.assertEqual("UpdatedIdShort", retrieved.id_short) + + # ------------------------------------------------------------------ GET/PUT/DELETE on a missing /shells/ + + def test_shell_not_found(self): + missing_shell_path = f"{SERVER_BASE_URL}/shells/{base64url_encode('https://example.org/unknown-shell')}" + + with self.assertRaises(urllib.error.HTTPError) as cm: + urllib.request.urlopen(missing_shell_path) + self.assertEqual(404, cm.exception.code) + + delete_request = urllib.request.Request(missing_shell_path, method="DELETE") + with self.assertRaises(urllib.error.HTTPError) as cm: + urllib.request.urlopen(delete_request) + self.assertEqual(404, cm.exception.code) From d2ca6c4559eaf31c149bf3528003bd245d315b70 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sun, 6 Sep 2026 20:44:01 +0200 Subject: [PATCH 20/59] Implement tests for registry --- server/test/interfaces/format_utils.py | 3 +- server/test/interfaces/test_registry.py | 540 ++++++++++++++++++++++++ 2 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 server/test/interfaces/test_registry.py diff --git a/server/test/interfaces/format_utils.py b/server/test/interfaces/format_utils.py index 4baeca6b1..2f169613c 100644 --- a/server/test/interfaces/format_utils.py +++ b/server/test/interfaces/format_utils.py @@ -2,6 +2,7 @@ import json from typing import Any, Callable, Optional +import app.adapter from basyx.aas import adapter from basyx.aas.adapter._generic import XML_NS_MAP from lxml import etree @@ -133,7 +134,7 @@ class JsonFormatClient(FormatClient): content_type = "application/json" def serialize(self, obj: object) -> bytes: - return json.dumps(obj, cls=adapter.json.AASToJsonEncoder).encode("utf-8") + return json.dumps(obj, cls=app.adapter.jsonization.ServerAASToJsonEncoder).encode("utf-8") def _payload(self, response: TestResponse) -> Any: return json.loads(response.get_data(as_text=True)) diff --git a/server/test/interfaces/test_registry.py b/server/test/interfaces/test_registry.py new file mode 100644 index 000000000..c6d25726d --- /dev/null +++ b/server/test/interfaces/test_registry.py @@ -0,0 +1,540 @@ +""" +Endpoint tests for :class:`~app.interfaces.registry.RegistryAPI`. + +The routes and status codes follow the *Asset Administration Shell Registry* and *Submodel Registry* +Service Specifications (SSP-001, "full" profile) from ``aas-specs-api``. Only JSON is exercised: the +registry only stores :class:`~app.model.descriptor.Descriptor` objects, which the SDK XML serializer +cannot handle, and the spec only defines ``application/json`` for these routes. + +Requests and responses go through the shared :class:`~..format_utils.JsonFormatClient` (as in +``test/interfaces/repository/test_shells.py``), which serializes request bodies with the server's +``ServerAASToJsonEncoder`` so :class:`~app.model.descriptor.Descriptor` objects can be sent. +""" + +import unittest +from typing import Any + +from app.interfaces.registry import RegistryAPI +from app.model import DictDescriptorStore +from app.model.descriptor import AssetAdministrationShellDescriptor, SubmodelDescriptor +from app.model.endpoint import Endpoint, ProtocolInformation +from app.util.converters import base64url_encode +from basyx.aas import model +from werkzeug.test import Client, TestResponse + +from .format_utils import JsonFormatClient + + +class _InMemoryDescriptorStore(DictDescriptorStore): + """ + In-memory descriptor store with a no-op ``commit`` and a ``clear``. + + :class:`~app.model.provider.DictDescriptorStore` inherits ``commit`` from the SDK's + ``AbstractObjectStore``, where it raises ``NotImplementedError``; the registry calls it after every + write. This mirrors what ``SetIdentifiableStore`` provides for the repository tests. + """ + + def commit(self, x: Any) -> None: + pass + + def clear(self) -> None: + self._backend.clear() + + +def _endpoint(interface: str = "AAS-3.0", href: str = "https://example.org/endpoint") -> Endpoint: + return Endpoint(interface=interface, protocol_information=ProtocolInformation(href=href)) + + +def _aas_descriptor(id_: str, **kwargs: Any) -> AssetAdministrationShellDescriptor: + kwargs.setdefault("endpoints", [_endpoint("AAS-3.0")]) + return AssetAdministrationShellDescriptor(id_=id_, **kwargs) + + +def _submodel_descriptor(id_: str, **kwargs: Any) -> SubmodelDescriptor: + kwargs.setdefault("endpoints", [_endpoint("SUBMODEL-3.0")]) + return SubmodelDescriptor(id_=id_, **kwargs) + + +class RegistryEndpointTestBase(unittest.TestCase): + __test__ = False + + AAS_ID = "https://example.org/shell-descriptors/1" + AAS_ID_2 = "https://example.org/shell-descriptors/2" + SM_ID = "https://example.org/submodel-descriptors/1" + SM_ID_2 = "https://example.org/submodel-descriptors/2" + UNKNOWN_ID = "https://example.org/unknown" + + store: _InMemoryDescriptorStore + registry_server: RegistryAPI + client: Client + format_client: JsonFormatClient + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.store = _InMemoryDescriptorStore() + cls.registry_server = RegistryAPI(cls.store, base_path="") + cls.client = Client(cls.registry_server) + cls.format_client = JsonFormatClient(cls.client) + + def setUp(self) -> None: + self.store.clear() + + # ------------------------------------------------------------------ assertion helpers + + def assert_ok(self, response: TestResponse) -> None: + self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) + + def assert_error(self, response: TestResponse, status_code: int) -> None: + self.assertEqual(status_code, response.status_code, msg=response.get_data(as_text=True)) + self.assertIn("success", response.get_data(as_text=True), msg=response.get_data(as_text=True)) + + def ids(self, response: TestResponse) -> list: + return [self.format_client.identifier(node) for node in self.format_client.parse_collection(response)] + + +# ====================================================================== /description + + +class RegistryServiceDescriptionTest(RegistryEndpointTestBase): + __test__ = True + + def test_description_ok(self) -> None: + response = self.format_client.get("/description") + + self.assert_ok(response) + profiles = self.format_client.parse_object(response)["profiles"] + self.assertIn( + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-001", profiles + ) + self.assertIn("https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-001", profiles) + self.assertIn( + "https://admin-shell.io/aas/API/3/1/AssetAdministrationShellRegistryServiceSpecification/SSP-002", profiles + ) + self.assertIn("https://admin-shell.io/aas/API/3/1/SubmodelRegistryServiceSpecification/SSP-002", profiles) + + +# ====================================================================== /shell-descriptors + + +class ShellDescriptorsEndpointTest(RegistryEndpointTestBase): + """Tests for the ``/shell-descriptors`` and ``/shell-descriptors/{aasIdentifier}`` routes.""" + + __test__ = True + + # ------------------------------------------------------------------ GET /shell-descriptors + + def test_get_all_empty(self) -> None: + response = self.format_client.get("/shell-descriptors") + + self.assert_ok(response) + self.assertEqual([], self.format_client.parse_collection(response)) + + def test_get_all_returns_registered_descriptors(self) -> None: + self.store.add(_aas_descriptor(self.AAS_ID)) + self.store.add(_aas_descriptor(self.AAS_ID_2)) + + response = self.format_client.get("/shell-descriptors") + + self.assert_ok(response) + self.assertEqual({self.AAS_ID, self.AAS_ID_2}, set(self.ids(response))) + + def test_get_all_only_returns_aas_descriptors(self) -> None: + # The store is shared between the AAS- and Submodel-registry routes, so this route must filter by type. + self.store.add(_aas_descriptor(self.AAS_ID)) + self.store.add(_submodel_descriptor(self.SM_ID)) + + response = self.format_client.get("/shell-descriptors") + + self.assert_ok(response) + self.assertEqual([self.AAS_ID], self.ids(response)) + + def test_get_all_supports_pagination(self) -> None: + self.store.add(_aas_descriptor(self.AAS_ID)) + self.store.add(_aas_descriptor(self.AAS_ID_2)) + + pages = self.format_client.get_paginated("/shell-descriptors", limit=1, max_pages=2) + + self.assertEqual([1, 1], [len(page) for page in pages]) + seen = [self.format_client.identifier(node) for page in pages for node in page] + self.assertEqual({self.AAS_ID, self.AAS_ID_2}, set(seen)) + self.assertEqual(len(seen), len(set(seen)), "an item was returned on more than one page") + + def test_get_all_negative_limit_returns_400(self) -> None: + self.store.add(_aas_descriptor(self.AAS_ID)) + + self.assert_error(self.format_client.get("/shell-descriptors?limit=-1"), 400) + + def test_get_all_filter_by_asset_kind(self) -> None: + self.store.add(_aas_descriptor(self.AAS_ID, asset_kind=model.AssetKind.INSTANCE)) + self.store.add(_aas_descriptor(self.AAS_ID_2, asset_kind=model.AssetKind.TYPE)) + + response = self.format_client.get("/shell-descriptors?assetKind=INSTANCE") + + self.assert_ok(response) + self.assertEqual([self.AAS_ID], self.ids(response)) + + def test_get_all_filter_by_asset_kind_invalid_returns_400(self) -> None: + self.store.add(_aas_descriptor(self.AAS_ID, asset_kind=model.AssetKind.INSTANCE)) + + # The enum member names are upper-case; the serialized ("Instance") spelling is rejected. + self.assert_error(self.format_client.get("/shell-descriptors?assetKind=Instance"), 400) + + def test_get_all_filter_by_asset_type(self) -> None: + self.store.add(_aas_descriptor(self.AAS_ID, asset_type="https://example.org/type/a")) + self.store.add(_aas_descriptor(self.AAS_ID_2, asset_type="https://example.org/type/b")) + + response = self.format_client.get( + f"/shell-descriptors?assetType={base64url_encode('https://example.org/type/b')}" + ) + + self.assert_ok(response) + self.assertEqual([self.AAS_ID_2], self.ids(response)) + + def test_get_all_filter_by_asset_type_no_match(self) -> None: + self.store.add(_aas_descriptor(self.AAS_ID, asset_type="https://example.org/type/a")) + + response = self.format_client.get( + f"/shell-descriptors?assetType={base64url_encode('https://example.org/type/none')}" + ) + + self.assert_ok(response) + self.assertEqual([], self.format_client.parse_collection(response)) + + # ------------------------------------------------------------------ POST /shell-descriptors + + def test_post_success(self) -> None: + descriptor = _aas_descriptor(self.AAS_ID) + + response = self.format_client.post("/shell-descriptors", obj=descriptor) + + self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) + self.assertIn(base64url_encode(self.AAS_ID), response.headers["Location"]) + self.assertEqual(self.AAS_ID, self.format_client.identifier(self.format_client.parse_object(response))) + self.assertIsNotNone(self.store.get(self.AAS_ID)) + + def test_post_missing_id_returns_400(self) -> None: + response = self.format_client.post( + "/shell-descriptors", data=b'{"endpoints": []}', content_type="application/json" + ) + + self.assert_error(response, 400) + + def test_post_malformed_json_returns_400(self) -> None: + response = self.format_client.post("/shell-descriptors", data=b"not json", content_type="application/json") + + self.assert_error(response, 400) + + def test_post_unsupported_content_type_returns_415(self) -> None: + response = self.format_client.post( + "/shell-descriptors", + data=self.format_client.serialize(_aas_descriptor(self.AAS_ID)), + content_type="text/plain", + ) + + self.assert_error(response, 415) + + def test_post_conflict_returns_409(self) -> None: + self.store.add(_aas_descriptor(self.AAS_ID)) + + self.assert_error(self.format_client.post("/shell-descriptors", obj=_aas_descriptor(self.AAS_ID)), 409) + + # ------------------------------------------------------------------ GET /shell-descriptors/{aasIdentifier} + + def test_get_by_id_success(self) -> None: + self.store.add(_aas_descriptor(self.AAS_ID)) + + response = self.format_client.get(f"/shell-descriptors/{base64url_encode(self.AAS_ID)}") + + self.assert_ok(response) + self.assertEqual(self.AAS_ID, self.format_client.identifier(self.format_client.parse_object(response))) + + def test_get_by_id_not_found_returns_404(self) -> None: + self.assert_error(self.format_client.get(f"/shell-descriptors/{base64url_encode(self.UNKNOWN_ID)}"), 404) + + # ------------------------------------------------------------------ PUT /shell-descriptors/{aasIdentifier} + + def test_put_creates_when_absent_returns_201(self) -> None: + response = self.format_client.put( + f"/shell-descriptors/{base64url_encode(self.AAS_ID)}", obj=_aas_descriptor(self.AAS_ID) + ) + + self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) + self.assertIn(base64url_encode(self.AAS_ID), response.headers["Location"]) + self.assertIsNotNone(self.store.get(self.AAS_ID)) + + def test_put_updates_when_present_returns_204(self) -> None: + self.store.add(_aas_descriptor(self.AAS_ID, id_short="Original")) + updated = _aas_descriptor(self.AAS_ID, id_short="Updated") + + response = self.format_client.put(f"/shell-descriptors/{base64url_encode(self.AAS_ID)}", obj=updated) + + self.assertEqual(204, response.status_code, msg=response.get_data(as_text=True)) + stored = self.store.get(self.AAS_ID) + assert isinstance(stored, AssetAdministrationShellDescriptor) # make mypy happy + self.assertEqual("Updated", stored.id_short) + + # ------------------------------------------------------------------ DELETE /shell-descriptors/{aasIdentifier} + + def test_delete_success_returns_204(self) -> None: + self.store.add(_aas_descriptor(self.AAS_ID)) + + response = self.format_client.delete(f"/shell-descriptors/{base64url_encode(self.AAS_ID)}") + + self.assertEqual(204, response.status_code, msg=response.get_data(as_text=True)) + self.assertIsNone(self.store.get(self.AAS_ID)) + + def test_delete_not_found_returns_404(self) -> None: + self.assert_error(self.format_client.delete(f"/shell-descriptors/{base64url_encode(self.UNKNOWN_ID)}"), 404) + + +# ====================================================================== /shell-descriptors/{aasId}/submodel-descriptors + + +class SubmodelDescriptorsThroughSuperpathEndpointTest(RegistryEndpointTestBase): + """Tests for the ``/shell-descriptors/{aasIdentifier}/submodel-descriptors`` routes.""" + + __test__ = True + + def setUp(self) -> None: + super().setUp() + self.store.add(_aas_descriptor(self.AAS_ID)) + + @property + def _base(self) -> str: + return f"/shell-descriptors/{base64url_encode(self.AAS_ID)}/submodel-descriptors" + + @property + def _missing_base(self) -> str: + return f"/shell-descriptors/{base64url_encode(self.UNKNOWN_ID)}/submodel-descriptors" + + # ------------------------------------------------------------------ GET (collection) + + def test_get_all_empty(self) -> None: + response = self.format_client.get(self._base) + + self.assert_ok(response) + self.assertEqual([], self.format_client.parse_collection(response)) + + def test_get_all_returns_nested_descriptors(self) -> None: + self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)) + self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID_2)) + + response = self.format_client.get(self._base) + + self.assert_ok(response) + self.assertEqual({self.SM_ID, self.SM_ID_2}, set(self.ids(response))) + + def test_get_all_supports_pagination(self) -> None: + self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)) + self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID_2)) + + pages = self.format_client.get_paginated(self._base, limit=1, max_pages=2) + + self.assertEqual([1, 1], [len(page) for page in pages]) + + def test_get_all_aas_not_found_returns_404(self) -> None: + self.assert_error(self.format_client.get(self._missing_base), 404) + + # ------------------------------------------------------------------ POST + + def test_post_success(self) -> None: + response = self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)) + + self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) + self.assertIn(base64url_encode(self.SM_ID), response.headers["Location"]) + self.assertEqual([self.SM_ID], self.ids(self.format_client.get(self._base))) + + def test_post_conflict_returns_409(self) -> None: + self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)) + + self.assert_error(self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)), 409) + + def test_post_aas_not_found_returns_404(self) -> None: + self.assert_error(self.format_client.post(self._missing_base, obj=_submodel_descriptor(self.SM_ID)), 404) + + # ------------------------------------------------------------------ GET (single) + + def test_get_by_id_success(self) -> None: + self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)) + + response = self.format_client.get(f"{self._base}/{base64url_encode(self.SM_ID)}") + + self.assert_ok(response) + self.assertEqual(self.SM_ID, self.format_client.identifier(self.format_client.parse_object(response))) + + def test_get_by_id_not_found_returns_404(self) -> None: + self.assert_error(self.format_client.get(f"{self._base}/{base64url_encode(self.UNKNOWN_ID)}"), 404) + + def test_get_by_id_aas_not_found_returns_404(self) -> None: + self.assert_error(self.format_client.get(f"{self._missing_base}/{base64url_encode(self.SM_ID)}"), 404) + + # ------------------------------------------------------------------ PUT + + def test_put_updates_when_present_returns_204(self) -> None: + self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID, id_short="Original")) + updated = _submodel_descriptor(self.SM_ID, id_short="Updated") + + response = self.format_client.put(f"{self._base}/{base64url_encode(self.SM_ID)}", obj=updated) + + self.assertEqual(204, response.status_code, msg=response.get_data(as_text=True)) + fetched = self.format_client.parse_object( + self.format_client.get(f"{self._base}/{base64url_encode(self.SM_ID)}") + ) + self.assertEqual("Updated", self.format_client.field(fetched, "idShort")) + + def test_put_creates_when_absent_returns_201(self) -> None: + response = self.format_client.put( + f"{self._base}/{base64url_encode(self.SM_ID)}", obj=_submodel_descriptor(self.SM_ID) + ) + + self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) + self.assertIn(base64url_encode(self.SM_ID), response.headers["Location"]) + self.assertEqual([self.SM_ID], self.ids(self.format_client.get(self._base))) + + def test_put_aas_not_found_returns_404(self) -> None: + self.assert_error( + self.format_client.put( + f"{self._missing_base}/{base64url_encode(self.SM_ID)}", obj=_submodel_descriptor(self.SM_ID) + ), + 404, + ) + + # ------------------------------------------------------------------ DELETE + + def test_delete_success_returns_204(self) -> None: + self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)) + + response = self.format_client.delete(f"{self._base}/{base64url_encode(self.SM_ID)}") + + self.assertEqual(204, response.status_code, msg=response.get_data(as_text=True)) + self.assertEqual([], self.format_client.parse_collection(self.format_client.get(self._base))) + + def test_delete_not_found_returns_404(self) -> None: + self.assert_error(self.format_client.delete(f"{self._base}/{base64url_encode(self.UNKNOWN_ID)}"), 404) + + def test_delete_aas_not_found_returns_404(self) -> None: + self.assert_error(self.format_client.delete(f"{self._missing_base}/{base64url_encode(self.SM_ID)}"), 404) + + +# ====================================================================== /submodel-descriptors + + +class SubmodelDescriptorsEndpointTest(RegistryEndpointTestBase): + """Tests for the standalone ``/submodel-descriptors`` and ``/submodel-descriptors/{submodelIdentifier}`` routes.""" + + __test__ = True + + # ------------------------------------------------------------------ GET /submodel-descriptors + + def test_get_all_empty(self) -> None: + response = self.format_client.get("/submodel-descriptors") + + self.assert_ok(response) + self.assertEqual([], self.format_client.parse_collection(response)) + + def test_get_all_returns_registered_descriptors(self) -> None: + self.store.add(_submodel_descriptor(self.SM_ID)) + self.store.add(_submodel_descriptor(self.SM_ID_2)) + + response = self.format_client.get("/submodel-descriptors") + + self.assert_ok(response) + self.assertEqual({self.SM_ID, self.SM_ID_2}, set(self.ids(response))) + + def test_get_all_only_returns_submodel_descriptors(self) -> None: + self.store.add(_submodel_descriptor(self.SM_ID)) + self.store.add(_aas_descriptor(self.AAS_ID)) + + response = self.format_client.get("/submodel-descriptors") + + self.assert_ok(response) + self.assertEqual([self.SM_ID], self.ids(response)) + + def test_get_all_supports_pagination(self) -> None: + self.store.add(_submodel_descriptor(self.SM_ID)) + self.store.add(_submodel_descriptor(self.SM_ID_2)) + + pages = self.format_client.get_paginated("/submodel-descriptors", limit=1, max_pages=2) + + self.assertEqual([1, 1], [len(page) for page in pages]) + + def test_get_all_negative_limit_returns_400(self) -> None: + self.store.add(_submodel_descriptor(self.SM_ID)) + + self.assert_error(self.format_client.get("/submodel-descriptors?limit=-1"), 400) + + # ------------------------------------------------------------------ POST /submodel-descriptors + + def test_post_success(self) -> None: + response = self.format_client.post("/submodel-descriptors", obj=_submodel_descriptor(self.SM_ID)) + + self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) + self.assertIn(base64url_encode(self.SM_ID), response.headers["Location"]) + self.assertIsNotNone(self.store.get(self.SM_ID)) + + def test_post_missing_id_returns_400(self) -> None: + response = self.format_client.post( + "/submodel-descriptors", data=b'{"endpoints": []}', content_type="application/json" + ) + + self.assert_error(response, 400) + + def test_post_conflict_returns_409(self) -> None: + self.store.add(_submodel_descriptor(self.SM_ID)) + + self.assert_error(self.format_client.post("/submodel-descriptors", obj=_submodel_descriptor(self.SM_ID)), 409) + + # ------------------------------------------------------------------ GET /submodel-descriptors/{submodelIdentifier} + + def test_get_by_id_success(self) -> None: + self.store.add(_submodel_descriptor(self.SM_ID)) + + response = self.format_client.get(f"/submodel-descriptors/{base64url_encode(self.SM_ID)}") + + self.assert_ok(response) + self.assertEqual(self.SM_ID, self.format_client.identifier(self.format_client.parse_object(response))) + + def test_get_by_id_not_found_returns_404(self) -> None: + self.assert_error(self.format_client.get(f"/submodel-descriptors/{base64url_encode(self.UNKNOWN_ID)}"), 404) + + def test_get_by_id_wrong_type_returns_404(self) -> None: + # A descriptor with this id exists, but it is an AAS descriptor, not a submodel descriptor. + self.store.add(_aas_descriptor(self.AAS_ID)) + + self.assert_error(self.format_client.get(f"/submodel-descriptors/{base64url_encode(self.AAS_ID)}"), 404) + + # ------------------------------------------------------------------ PUT /submodel-descriptors/{submodelIdentifier} + + def test_put_creates_when_absent_returns_201(self) -> None: + response = self.format_client.put( + f"/submodel-descriptors/{base64url_encode(self.SM_ID)}", obj=_submodel_descriptor(self.SM_ID) + ) + + self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) + self.assertIn(base64url_encode(self.SM_ID), response.headers["Location"]) + self.assertIsNotNone(self.store.get(self.SM_ID)) + + def test_put_updates_when_present_returns_204(self) -> None: + self.store.add(_submodel_descriptor(self.SM_ID, id_short="Original")) + updated = _submodel_descriptor(self.SM_ID, id_short="Updated") + + response = self.format_client.put(f"/submodel-descriptors/{base64url_encode(self.SM_ID)}", obj=updated) + + self.assertEqual(204, response.status_code, msg=response.get_data(as_text=True)) + stored = self.store.get(self.SM_ID) + assert isinstance(stored, SubmodelDescriptor) # make mypy happy + self.assertEqual("Updated", stored.id_short) + + # --------------------------------------------------------------- DELETE /submodel-descriptors/{submodelIdentifier} + + def test_delete_success_returns_204(self) -> None: + self.store.add(_submodel_descriptor(self.SM_ID)) + + response = self.format_client.delete(f"/submodel-descriptors/{base64url_encode(self.SM_ID)}") + + self.assertEqual(204, response.status_code, msg=response.get_data(as_text=True)) + self.assertIsNone(self.store.get(self.SM_ID)) + + def test_delete_not_found_returns_404(self) -> None: + self.assert_error(self.format_client.delete(f"/submodel-descriptors/{base64url_encode(self.UNKNOWN_ID)}"), 404) From ddccd15d8d76b58f2d7ca0c9b149cf7ee626aa10 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sun, 6 Sep 2026 20:44:56 +0200 Subject: [PATCH 21/59] Implement tests for discovery --- server/test/interfaces/test_discovery.py | 295 +++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 server/test/interfaces/test_discovery.py diff --git a/server/test/interfaces/test_discovery.py b/server/test/interfaces/test_discovery.py new file mode 100644 index 000000000..c526ea020 --- /dev/null +++ b/server/test/interfaces/test_discovery.py @@ -0,0 +1,295 @@ +""" +Endpoint tests for :class:`~app.interfaces.discovery.DiscoveryAPI`. + +The routes follow the *Discovery Service Specification* (SSP-001, "full" profile) from ``aas-specs-api``. +Where this server deviates from the spec's documented status codes, the test asserts the *implemented* +behavior and the deviation is called out in a comment. + +Requests and responses go through the shared :class:`~..format_utils.JsonFormatClient` (as in +``test/interfaces/repository/test_shells.py``). Only ``GET /lookup/shells/{aasIdentifier}`` returns +AAS model objects (``SpecificAssetId``), so it is additionally run against the XML +:class:`~..format_utils.FormatClient`; every other route returns plain strings / JSON objects that the +SDK XML serializer cannot render, so those are JSON only. +""" + +import json +import unittest +from typing import List, Tuple + +from app.interfaces.discovery import DiscoveryAPI, DiscoveryStore +from app.util.converters import base64url_encode +from werkzeug.test import Client, TestResponse + +from .format_utils import FormatClient, JsonFormatClient, inject_format_clients, with_json_client, with_xml_client + + +def _b64url_json(payload: object) -> str: + return base64url_encode(json.dumps(payload)) + + +class DiscoveryEndpointTestBase(unittest.TestCase): + __test__ = False + + AAS_ID = "https://example.org/aas/1" + AAS_ID_2 = "https://example.org/aas/2" + UNKNOWN_ID = "https://example.org/unknown" + + store: DiscoveryStore + discovery_server: DiscoveryAPI + client: Client + format_client: JsonFormatClient + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.store = DiscoveryStore() + cls.discovery_server = DiscoveryAPI(cls.store, base_path="") + cls.client = Client(cls.discovery_server) + cls.format_client = JsonFormatClient(cls.client) + + def setUp(self) -> None: + self.store.aas_id_to_asset_ids.clear() + self.store.asset_id_to_aas_ids.clear() + + # ------------------------------------------------------------------ helpers + + def register(self, aas_id: str, asset_ids: List[Tuple[str, str]]) -> None: + payload = [{"name": name, "value": value} for name, value in asset_ids] + self.assert_ok(self.format_client.post(f"/lookup/shells/{base64url_encode(aas_id)}", obj=payload)) + + def assert_ok(self, response: TestResponse) -> None: + self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) + + def assert_error(self, response: TestResponse, status_code: int) -> None: + self.assertEqual(status_code, response.status_code, msg=response.get_data(as_text=True)) + self.assertIn("success", response.get_data(as_text=True), msg=response.get_data(as_text=True)) + + +# ====================================================================== /description + + +class DiscoveryServiceDescriptionTest(DiscoveryEndpointTestBase): + __test__ = True + + def test_description_ok(self) -> None: + response = self.format_client.get("/description") + + self.assert_ok(response) + profiles = self.format_client.parse_object(response)["profiles"] + self.assertIn("https://admin-shell.io/aas/API/3/1/DiscoveryServiceSpecification/SSP-001", profiles) + self.assertIn("https://admin-shell.io/aas/API/3/1/DiscoveryServiceSpecification/SSP-002", profiles) + + +# ====================================================================== POST /lookup/shellsByAssetLink + + +class SearchShellsByAssetLinkEndpointTest(DiscoveryEndpointTestBase): + __test__ = True + + def test_search_match(self) -> None: + self.register(self.AAS_ID, [("serial", "123")]) + + response = self.format_client.post("/lookup/shellsByAssetLink", obj=[{"name": "serial", "value": "123"}]) + + self.assert_ok(response) + self.assertEqual([self.AAS_ID], self.format_client.parse_collection(response)) + + def test_search_no_match_returns_empty(self) -> None: + self.register(self.AAS_ID, [("serial", "123")]) + + response = self.format_client.post( + "/lookup/shellsByAssetLink", obj=[{"name": "serial", "value": "does-not-exist"}] + ) + + self.assert_ok(response) + self.assertEqual([], self.format_client.parse_collection(response)) + + def test_search_empty_body_returns_empty(self) -> None: + self.register(self.AAS_ID, [("serial", "123")]) + + response = self.format_client.post("/lookup/shellsByAssetLink", obj=[]) + + self.assert_ok(response) + self.assertEqual([], self.format_client.parse_collection(response)) + + def test_search_multiple_links_unions_results(self) -> None: + self.register(self.AAS_ID, [("serial", "1")]) + self.register(self.AAS_ID_2, [("serial", "2")]) + + response = self.format_client.post( + "/lookup/shellsByAssetLink", + obj=[{"name": "serial", "value": "1"}, {"name": "serial", "value": "2"}], + ) + + self.assert_ok(response) + self.assertEqual({self.AAS_ID, self.AAS_ID_2}, set(self.format_client.parse_collection(response))) + + def test_search_supports_pagination(self) -> None: + self.register(self.AAS_ID, [("serial", "1")]) + self.register(self.AAS_ID_2, [("serial", "1")]) + + response = self.format_client.post("/lookup/shellsByAssetLink?limit=1", obj=[{"name": "serial", "value": "1"}]) + + self.assert_ok(response) + self.assertEqual(1, len(self.format_client.parse_collection(response))) + self.assertIsNotNone(self.format_client.next_cursor(response)) + + def test_search_malformed_json_returns_400(self) -> None: + response = self.format_client.post( + "/lookup/shellsByAssetLink", data=b"{not json", content_type="application/json" + ) + + self.assert_error(response, 400) + + def test_search_asset_link_missing_value_returns_400(self) -> None: + self.assert_error(self.format_client.post("/lookup/shellsByAssetLink", obj=[{"name": "serial"}]), 400) + + +# ====================================================================== GET /lookup/shells (deprecated) + + +class GetShellsByAssetLinkQueryEndpointTest(DiscoveryEndpointTestBase): + """The deprecated ``GET /lookup/shells?assetIds=...`` route (kept for BaSyx UI interoperability).""" + + __test__ = True + + def test_query_match(self) -> None: + self.register(self.AAS_ID, [("serial", "123")]) + + response = self.format_client.get(f"/lookup/shells?assetIds={_b64url_json({'name': 'serial', 'value': '123'})}") + + self.assert_ok(response) + self.assertEqual([self.AAS_ID], self.format_client.parse_collection(response)) + + def test_query_accepts_list_payload(self) -> None: + self.register(self.AAS_ID, [("serial", "123")]) + + response = self.format_client.get( + f"/lookup/shells?assetIds={_b64url_json([{'name': 'serial', 'value': '123'}])}" + ) + + self.assert_ok(response) + self.assertEqual([self.AAS_ID], self.format_client.parse_collection(response)) + + def test_query_missing_parameter_returns_400(self) -> None: + self.assert_error(self.format_client.get("/lookup/shells"), 400) + + def test_query_invalid_base64_returns_400(self) -> None: + self.assert_error(self.format_client.get("/lookup/shells?assetIds=not-base64!!!"), 400) + + def test_query_decoded_value_not_json_returns_400(self) -> None: + self.assert_error(self.format_client.get(f"/lookup/shells?assetIds={base64url_encode('not json')}"), 400) + + def test_query_payload_not_object_or_list_returns_400(self) -> None: + self.assert_error(self.format_client.get(f"/lookup/shells?assetIds={_b64url_json(123)}"), 400) + + def test_query_payload_item_not_object_returns_400(self) -> None: + self.assert_error(self.format_client.get(f"/lookup/shells?assetIds={_b64url_json([1, 2])}"), 400) + + +# ====================================================================== /lookup/shells/{aasIdentifier} + + +@inject_format_clients +class AssetLinksByIdEndpointTest(DiscoveryEndpointTestBase): + """Tests for GET/POST/DELETE on ``/lookup/shells/{aasIdentifier}``.""" + + __test__ = True + + # ------------------------------------------------------------------ GET (returns SpecificAssetId objects) + + @with_json_client + @with_xml_client + def test_get_returns_stored_asset_ids(self, format_client: FormatClient) -> None: + self.register(self.AAS_ID, [("serial", "123"), ("globalAssetId", "https://example.org/asset/1")]) + + response = format_client.get(f"/lookup/shells/{base64url_encode(self.AAS_ID)}") + + self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) + pairs = { + (format_client.field(node, "name"), format_client.field(node, "value")) + for node in format_client.parse_collection(response) + } + self.assertEqual({("serial", "123"), ("globalAssetId", "https://example.org/asset/1")}, pairs) + + @with_json_client + @with_xml_client + def test_get_unknown_aas_returns_empty(self, format_client: FormatClient) -> None: + # Spec allows 404 here; this server returns 200 with an empty collection instead. + response = format_client.get(f"/lookup/shells/{base64url_encode(self.UNKNOWN_ID)}") + + self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) + self.assertEqual([], format_client.parse_collection(response)) + + # ------------------------------------------------------------------ POST + + def test_post_creates_asset_links(self) -> None: + response = self.format_client.post( + f"/lookup/shells/{base64url_encode(self.AAS_ID)}", + obj=[{"name": "serial", "value": "123"}], + ) + + # Spec documents 201 for creation; this server responds 200 with the updated mapping. + self.assert_ok(response) + body = self.format_client.parse_object(response) + self.assertEqual([("serial", "123")], [(a["name"], a["value"]) for a in body[self.AAS_ID]]) + self.assertEqual( + [self.AAS_ID], + self.format_client.parse_collection( + self.format_client.post("/lookup/shellsByAssetLink", obj=[{"name": "serial", "value": "123"}]) + ), + ) + + def test_post_accepts_single_object_body(self) -> None: + response = self.format_client.post( + f"/lookup/shells/{base64url_encode(self.AAS_ID)}", obj={"name": "serial", "value": "123"} + ) + + self.assert_ok(response) + self.assertEqual( + [("serial", "123")], + [(a["name"], a["value"]) for a in self.format_client.parse_object(response)[self.AAS_ID]], + ) + + def test_post_is_additive(self) -> None: + self.register(self.AAS_ID, [("serial", "123")]) + + response = self.format_client.post( + f"/lookup/shells/{base64url_encode(self.AAS_ID)}", obj=[{"name": "batch", "value": "xyz"}] + ) + + self.assert_ok(response) + stored = {(a["name"], a["value"]) for a in self.format_client.parse_object(response)[self.AAS_ID]} + self.assertEqual({("serial", "123"), ("batch", "xyz")}, stored) + + def test_post_malformed_json_returns_400(self) -> None: + response = self.format_client.post( + f"/lookup/shells/{base64url_encode(self.AAS_ID)}", data=b"{not json", content_type="application/json" + ) + + self.assert_error(response, 400) + + # ------------------------------------------------------------------ DELETE + + def test_delete_removes_asset_links(self) -> None: + self.register(self.AAS_ID, [("serial", "123")]) + + response = self.format_client.delete(f"/lookup/shells/{base64url_encode(self.AAS_ID)}") + + self.assertEqual(204, response.status_code, msg=response.get_data(as_text=True)) + self.assertEqual( + [], + self.format_client.parse_object(self.format_client.get(f"/lookup/shells/{base64url_encode(self.AAS_ID)}")), + ) + self.assertEqual( + [], + self.format_client.parse_collection( + self.format_client.post("/lookup/shellsByAssetLink", obj=[{"name": "serial", "value": "123"}]) + ), + ) + + def test_delete_unknown_aas_returns_204(self) -> None: + # Spec allows 404 here; this server always returns 204. + response = self.format_client.delete(f"/lookup/shells/{base64url_encode(self.UNKNOWN_ID)}") + + self.assertEqual(204, response.status_code, msg=response.get_data(as_text=True)) From b91c59a2f68fd8552896aa6cb0e86766ba6ae27a Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sun, 6 Sep 2026 21:22:15 +0200 Subject: [PATCH 22/59] Change pagination test to directly call function To separate testing of the pagination logic from working endpoints, the shared function for creatin paginated responses is now tested directly. The base tests on paginated endpoints remain. --- .../repository/{test_base.py => helpers.py} | 45 ------------ .../repository/test_concept_description.py | 2 +- .../test/interfaces/repository/test_shells.py | 2 +- .../interfaces/repository/test_submodels.py | 2 +- server/test/interfaces/test_base.py | 71 +++++++++++++++++++ 5 files changed, 74 insertions(+), 48 deletions(-) rename server/test/interfaces/repository/{test_base.py => helpers.py} (55%) create mode 100644 server/test/interfaces/test_base.py diff --git a/server/test/interfaces/repository/test_base.py b/server/test/interfaces/repository/helpers.py similarity index 55% rename from server/test/interfaces/repository/test_base.py rename to server/test/interfaces/repository/helpers.py index 084ab86b9..0ebcae2a0 100644 --- a/server/test/interfaces/repository/test_base.py +++ b/server/test/interfaces/repository/helpers.py @@ -7,13 +7,10 @@ from basyx.aas.adapter import aasx from basyx.aas.examples.data.example_aas_missing_attributes import ( create_example_asset_administration_shell, - create_example_submodel, ) from basyx.aas.model import Identifiable from werkzeug.test import Client, TestResponse -from ..format_utils import FormatClient, inject_format_clients, with_json_client, with_xml_client - T = TypeVar('T') class RepositoryEndpointTestBase(unittest.TestCase): @@ -65,45 +62,3 @@ def test_description(self): body = response.get_data(as_text=True) self.assertIn("AssetAdministrationShellRepositoryServiceSpecification/SSP-001", body) self.assertIn("SubmodelRepositoryServiceSpecification/SSP-001", body) - -@inject_format_clients -class TestPagination(RepositoryEndpointTestBase): - """ - Endpoint testing of the shared pagination strategy, shared by multiple endpoints. Ensures - that all results are returned and pages do not overlap. As testing endpoints ``/submodels`` is used. - - Bodies are written once against the format-agnostic ``format_client`` helper. For each test two - variants are generated where the :class:`~..format_utils.JsonFormatClient` and - :class:`~..format_utils.XmlFormatClient` are injected respectively. - """ - - __test__ = True - - EXAMPLE_ID = "https://example.org/Test_Submodel_Missing" - SECOND_ID = "https://example.org/ExampleSubmodel_Second" - THIRD_ID = "https://example.org/ExampleSubmodel_Third" - - @with_json_client - @with_xml_client - def test_pagination_walks_all_items(self, format_client: FormatClient): - self.object_store.add(create_example_submodel()) - second_sm = create_example_submodel() - second_sm.id = self.SECOND_ID - self.object_store.add(second_sm) - third_sm = create_example_submodel() - third_sm.id = self.THIRD_ID - self.object_store.add(third_sm) - - pages = format_client.get_paginated("/submodels", limit=2, max_pages=2) - - seen = [format_client.identifier(node) for page in pages for node in page] - self.assertEqual([2, 1], [len(page) for page in pages]) - self.assertEqual(len(seen), len(set(seen)), "an item was returned on more than one page") - self.assertEqual({self.EXAMPLE_ID, self.SECOND_ID, self.THIRD_ID}, set(seen)) - - def test_submodels_get_negative_limit_returns_400(self): - self.object_store.add(create_example_submodel()) - - response = self.client.get("/submodels?limit=-1") - - self.assert_error(response, 400) diff --git a/server/test/interfaces/repository/test_concept_description.py b/server/test/interfaces/repository/test_concept_description.py index 5712be663..e04234c87 100644 --- a/server/test/interfaces/repository/test_concept_description.py +++ b/server/test/interfaces/repository/test_concept_description.py @@ -11,7 +11,7 @@ with_json_client, with_xml_client, ) -from .test_base import RepositoryEndpointTestBase +from .helpers import RepositoryEndpointTestBase @inject_format_clients diff --git a/server/test/interfaces/repository/test_shells.py b/server/test/interfaces/repository/test_shells.py index 7dc645885..081047503 100644 --- a/server/test/interfaces/repository/test_shells.py +++ b/server/test/interfaces/repository/test_shells.py @@ -16,7 +16,7 @@ with_json_client, with_xml_client, ) -from .test_base import RepositoryEndpointTestBase +from .helpers import RepositoryEndpointTestBase def _encode_name_value_pair(name: str, value: str) -> str: diff --git a/server/test/interfaces/repository/test_submodels.py b/server/test/interfaces/repository/test_submodels.py index fad9fdc43..0340019f0 100644 --- a/server/test/interfaces/repository/test_submodels.py +++ b/server/test/interfaces/repository/test_submodels.py @@ -13,7 +13,7 @@ with_json_client, with_xml_client, ) -from .test_base import RepositoryEndpointTestBase +from .helpers import RepositoryEndpointTestBase def _encode_reference(reference: model.Reference) -> str: diff --git a/server/test/interfaces/test_base.py b/server/test/interfaces/test_base.py new file mode 100644 index 000000000..7d94ca14e --- /dev/null +++ b/server/test/interfaces/test_base.py @@ -0,0 +1,71 @@ +import unittest +from typing import Optional +from unittest import mock + +from app.interfaces.base import BaseWSGIApp +from werkzeug.exceptions import BadRequest + + +class TestPagination(unittest.TestCase): + """ + Testing of the shared pagination strategy, shared by multiple endpoints. + """ + + __test__ = True + + @staticmethod + def _build_request(limit: Optional[str] = None, cursor: Optional[str] = None): + request = mock.Mock() + def mock_get(key, default = None): + if key == "limit": + return limit or default + elif key == "cursor": + return cursor or default + else: + return default + request.args.get.side_effect = mock_get + return request + + def test_pagination_on_empty_set(self): + page, metadata = BaseWSGIApp._get_slice(self._build_request('3'), []) + self.assertEqual(0, len(list(page))) + if not metadata: + self.fail("no metadata") + self.assertIsNone(metadata.cursor) + + def test_pagination_with_one_page(self): + page, metadata = BaseWSGIApp._get_slice(self._build_request("3"), [1, 2]) + self.assertEqual([1, 2], list(page)) + if not metadata: + self.fail("no metadata") + self.assertIsNone(metadata.cursor) + + def test_pagination_walks_all_items(self): + all_objects = [i for i in range(20)] + + pages: list[list] = [] + cursor = None + for _ in range(1000): + page, metadata = BaseWSGIApp._get_slice(self._build_request('6', cursor), all_objects) + result = list(page) + pages.append(result) + if not metadata: + self.fail("no paging_metadata returned") + cursor = metadata.cursor + if cursor is None: + break + if len(pages) > 4 or len(pages) < 1: + self.fail("cursor never signalled end") + + self.assertEqual([6, 6, 6, 2], [len(page) for page in pages]) + seen = [i for page in pages for i in page] + self.assertEqual(len(seen), len(set(seen)), "an item was returned on more than one page") + self.assertEqual(set(range(20)), set(seen)) + + def test_pagination_requires_positive_limit(self): + with self.assertRaises(BadRequest): + BaseWSGIApp._get_slice(self._build_request('-1'), []) + + def test_pagination_requires_positive_cursor(self): + with self.assertRaises(BadRequest): + BaseWSGIApp._get_slice(self._build_request('5', '-1'), []) From 46161d4fda88d8a804ce09d44abcffbccdded665 Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Mon, 7 Sep 2026 09:58:17 +0200 Subject: [PATCH 23/59] Fix DictDescriptorStore missing commit() causing 500s on descriptor writes --- server/app/model/provider.py | 5 +++++ ..._integration.py => test_docker_integration_repository.py} | 0 2 files changed, 5 insertions(+) rename server/test/interfaces/{test_docker_integration.py => test_docker_integration_repository.py} (100%) diff --git a/server/app/model/provider.py b/server/app/model/provider.py index 472f09979..aaf9874ef 100644 --- a/server/app/model/provider.py +++ b/server/app/model/provider.py @@ -38,6 +38,11 @@ def discard(self, x: _DESCRIPTOR_TYPE) -> None: if self._backend.get(x.id) is x: del self._backend[x.id] + def commit(self, x: _DESCRIPTOR_TYPE) -> None: + # This is an in-memory store: mutations to a stored descriptor are already visible without persisting them + # anywhere, so there is nothing to do here. + pass + def __contains__(self, x: object) -> bool: if isinstance(x, model.Identifier): return x in self._backend diff --git a/server/test/interfaces/test_docker_integration.py b/server/test/interfaces/test_docker_integration_repository.py similarity index 100% rename from server/test/interfaces/test_docker_integration.py rename to server/test/interfaces/test_docker_integration_repository.py From 54b5a6e916ba46d4834c19cd6f265d737392097e Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Mon, 7 Sep 2026 09:58:58 +0200 Subject: [PATCH 24/59] Add Docker integration tests for the registry and discovery profile --- .../test_docker_integration_discovery.py | 102 ++++++++++++++ .../test_docker_integration_registry.py | 125 ++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 server/test/interfaces/test_docker_integration_discovery.py create mode 100644 server/test/interfaces/test_docker_integration_registry.py diff --git a/server/test/interfaces/test_docker_integration_discovery.py b/server/test/interfaces/test_docker_integration_discovery.py new file mode 100644 index 000000000..05c340932 --- /dev/null +++ b/server/test/interfaces/test_docker_integration_discovery.py @@ -0,0 +1,102 @@ +import json +import unittest +import urllib.error +import urllib.request + +from app.interfaces.discovery import SUPPORTED_PROFILES +from app.util.converters import base64url_encode + +from test._helper.test_helpers import REQUIRE_SERVER, SERVER_ERROR, SERVER_OKAY, TEST_CONFIG + +SERVER_BASE_URL = TEST_CONFIG["server"]["url"] + + +@unittest.skipUnless( + SERVER_OKAY or REQUIRE_SERVER, f"No server reachable at {SERVER_BASE_URL}: {SERVER_ERROR}" +) +class DiscoveryDockerIntegrationTest(unittest.TestCase): + """ + Smoke tests against a real, already-running discovery server instance (e.g. started via + ``docker run -p 8080:80 basyx-python-discovery``), analogous to ``test_docker_integration_repository.py`` for the + repository profile: skipped entirely if no server is reachable at ``SERVER_BASE_URL``. + + Set the ``REQUIRE_SERVER_INTEGRATION_TESTS`` environment variable to make this test class fail instead of + being skipped when no server is reachable (see ``test._helper.test_helpers``). + """ + + AAS_ID = "https://example.org/Test_AssetAdministrationShell_Discovery" + ASSET_LINK = {"name": "MySerialNumber", "value": "SN-12345"} + + @classmethod + def setUpClass(cls) -> None: + if not SERVER_OKAY: + raise RuntimeError( + f"REQUIRE_SERVER_INTEGRATION_TESTS is set, but no server is reachable at " + f"{SERVER_BASE_URL}: {SERVER_ERROR}" + ) + + def tearDown(self) -> None: + delete_request = urllib.request.Request( + f"{SERVER_BASE_URL}/lookup/shells/{base64url_encode(self.AAS_ID)}", method="DELETE" + ) + urllib.request.urlopen(delete_request).close() + + # ------------------------------------------------------------------ GET /description + + def test_description_profiles(self): + with urllib.request.urlopen(SERVER_BASE_URL + "/description") as response: + self.assertEqual(200, response.status) + data = json.loads(response.read()) + + expected_profiles = {profile.value for profile in SUPPORTED_PROFILES.profiles} + self.assertEqual(expected_profiles, set(data["profiles"])) + + # ------------------------------------------------------------------ POST/GET/DELETE /lookup/shells/ + + def test_asset_link_roundtrip(self): + aas_asset_links_path = f"{SERVER_BASE_URL}/lookup/shells/{base64url_encode(self.AAS_ID)}" + body = json.dumps([self.ASSET_LINK]).encode("utf-8") + + post_request = urllib.request.Request( + aas_asset_links_path, data=body, headers={"Content-Type": "application/json"}, method="POST" + ) + with urllib.request.urlopen(post_request) as response: + self.assertEqual(200, response.status) + + with urllib.request.urlopen(aas_asset_links_path) as response: + self.assertEqual(200, response.status) + retrieved = json.loads(response.read()) + self.assertIn(self.ASSET_LINK, retrieved) + + delete_request = urllib.request.Request(aas_asset_links_path, method="DELETE") + with urllib.request.urlopen(delete_request) as response: + self.assertEqual(204, response.status) + + with urllib.request.urlopen(aas_asset_links_path) as response: + self.assertEqual(200, response.status) + self.assertEqual([], json.loads(response.read())) + + # ------------------------------------------------------------------ POST /lookup/shellsByAssetLink + + def test_lookup_by_asset_link(self): + aas_asset_links_path = f"{SERVER_BASE_URL}/lookup/shells/{base64url_encode(self.AAS_ID)}" + link_request = urllib.request.Request( + aas_asset_links_path, + data=json.dumps([self.ASSET_LINK]).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(link_request) as response: + self.assertEqual(200, response.status) + + lookup_request = urllib.request.Request( + SERVER_BASE_URL + "/lookup/shellsByAssetLink", + data=json.dumps([self.ASSET_LINK]).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(lookup_request) as response: + self.assertEqual(200, response.status) + data = json.loads(response.read()) + + self.assertIn(self.AAS_ID, data["result"]) diff --git a/server/test/interfaces/test_docker_integration_registry.py b/server/test/interfaces/test_docker_integration_registry.py new file mode 100644 index 000000000..f8900a7f7 --- /dev/null +++ b/server/test/interfaces/test_docker_integration_registry.py @@ -0,0 +1,125 @@ +import json +import unittest +import urllib.error +import urllib.request + +from app.adapter.jsonization import ServerAASToJsonEncoder +from app.interfaces.registry import SUPPORTED_PROFILES +from app.model import AssetAdministrationShellDescriptor +from app.util.converters import base64url_encode + +from test._helper.test_helpers import REQUIRE_SERVER, SERVER_ERROR, SERVER_OKAY, TEST_CONFIG + +SERVER_BASE_URL = TEST_CONFIG["server"]["url"] + + +@unittest.skipUnless( + SERVER_OKAY or REQUIRE_SERVER, f"No server reachable at {SERVER_BASE_URL}: {SERVER_ERROR}" +) +class RegistryDockerIntegrationTest(unittest.TestCase): + """ + Smoke tests against a real, already-running registry server instance (e.g. started via + ``docker run -p 8080:80 basyx-python-registry``), analogous to ``test_docker_integration_repository.py`` for the + repository profile: skipped entirely if no server is reachable at ``SERVER_BASE_URL``. + + Set the ``REQUIRE_SERVER_INTEGRATION_TESTS`` environment variable to make this test class fail instead of + being skipped when no server is reachable (see ``test._helper.test_helpers``). + """ + + DESCRIPTOR_ID = "https://example.org/Test_AssetAdministrationShellDescriptor" + + @classmethod + def setUpClass(cls) -> None: + if not SERVER_OKAY: + raise RuntimeError( + f"REQUIRE_SERVER_INTEGRATION_TESTS is set, but no server is reachable at " + f"{SERVER_BASE_URL}: {SERVER_ERROR}" + ) + + def tearDown(self) -> None: + self._delete_descriptor(self.DESCRIPTOR_ID, ignore_missing=True) + + @staticmethod + def _delete_descriptor(descriptor_id: str, ignore_missing: bool = False) -> None: + request = urllib.request.Request( + f"{SERVER_BASE_URL}/shell-descriptors/{base64url_encode(descriptor_id)}", method="DELETE" + ) + try: + urllib.request.urlopen(request) + except urllib.error.HTTPError as e: + e.close() + if not (ignore_missing and e.code == 404): + raise + + # ------------------------------------------------------------------ GET /description + + def test_description_profiles(self): + with urllib.request.urlopen(SERVER_BASE_URL + "/description") as response: + self.assertEqual(200, response.status) + data = json.loads(response.read()) + + expected_profiles = {profile.value for profile in SUPPORTED_PROFILES.profiles} + self.assertEqual(expected_profiles, set(data["profiles"])) + + # ------------------------------------------------------------------ POST/GET/DELETE /shell-descriptors + + def test_shell_descriptor_roundtrip(self): + descriptor = AssetAdministrationShellDescriptor(id_=self.DESCRIPTOR_ID, id_short="TestDescriptor") + body = json.dumps(descriptor, cls=ServerAASToJsonEncoder).encode("utf-8") + descriptor_path = f"{SERVER_BASE_URL}/shell-descriptors/{base64url_encode(descriptor.id)}" + + post_request = urllib.request.Request( + SERVER_BASE_URL + "/shell-descriptors", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(post_request) as response: + self.assertEqual(201, response.status) + + with urllib.request.urlopen(descriptor_path) as response: + self.assertEqual(200, response.status) + retrieved = json.loads(response.read()) + self.assertEqual(descriptor.id, retrieved["id"]) + self.assertEqual("TestDescriptor", retrieved["idShort"]) + + delete_request = urllib.request.Request(descriptor_path, method="DELETE") + with urllib.request.urlopen(delete_request) as response: + self.assertEqual(204, response.status) + + with self.assertRaises(urllib.error.HTTPError) as cm: + urllib.request.urlopen(descriptor_path) + self.assertEqual(404, cm.exception.code) + + def test_shell_descriptor_duplicate_post(self): + descriptor = AssetAdministrationShellDescriptor(id_=self.DESCRIPTOR_ID) + body = json.dumps(descriptor, cls=ServerAASToJsonEncoder).encode("utf-8") + post_request = urllib.request.Request( + SERVER_BASE_URL + "/shell-descriptors", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + with urllib.request.urlopen(post_request) as response: + self.assertEqual(201, response.status) + + with self.assertRaises(urllib.error.HTTPError) as cm: + urllib.request.urlopen(post_request) + self.assertEqual(409, cm.exception.code) + + # ------------------------------------------------------------------ GET/DELETE on a missing /shell-descriptors/ + + def test_shell_descriptor_not_found(self): + missing_path = ( + f"{SERVER_BASE_URL}/shell-descriptors/{base64url_encode('https://example.org/unknown-descriptor')}" + ) + + with self.assertRaises(urllib.error.HTTPError) as cm: + urllib.request.urlopen(missing_path) + self.assertEqual(404, cm.exception.code) + + delete_request = urllib.request.Request(missing_path, method="DELETE") + with self.assertRaises(urllib.error.HTTPError) as cm: + urllib.request.urlopen(delete_request) + self.assertEqual(404, cm.exception.code) From faa4131fd2214c1ea2d39ce51946edbf6165412e Mon Sep 17 00:00:00 2001 From: hpoeche Date: Mon, 7 Sep 2026 10:08:30 +0200 Subject: [PATCH 25/59] Discovery: Replace API call for test arrangement In the first version of the tests for the Discovery API, when data needed to be added to the DiscoveryStore, this was done through the `POST /lookup/shells/` endpoint. To decouple endpoint tests from each other, the data insertion is now done directly via the DiscoveryStore. --- server/test/interfaces/test_discovery.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/test/interfaces/test_discovery.py b/server/test/interfaces/test_discovery.py index c526ea020..75b5b0cf1 100644 --- a/server/test/interfaces/test_discovery.py +++ b/server/test/interfaces/test_discovery.py @@ -20,6 +20,7 @@ from app.util.converters import base64url_encode from werkzeug.test import Client, TestResponse +from basyx.aas.model import SpecificAssetId from .format_utils import FormatClient, JsonFormatClient, inject_format_clients, with_json_client, with_xml_client @@ -54,8 +55,10 @@ def setUp(self) -> None: # ------------------------------------------------------------------ helpers def register(self, aas_id: str, asset_ids: List[Tuple[str, str]]) -> None: - payload = [{"name": name, "value": value} for name, value in asset_ids] - self.assert_ok(self.format_client.post(f"/lookup/shells/{base64url_encode(aas_id)}", obj=payload)) + assets: list[SpecificAssetId] = [SpecificAssetId(name, value) for name, value in asset_ids] + self.store.add_specific_asset_ids_to_aas(aas_id, assets) + for asset in assets: + self.store._add_aas_id_to_specific_asset_id(asset, aas_id) def assert_ok(self, response: TestResponse) -> None: self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) From 908656612e43447b46db7656bd2ed83755663165 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Mon, 7 Sep 2026 10:14:26 +0200 Subject: [PATCH 26/59] Concrete testing of APIResponse serialization --- server/test/interfaces/test_base.py | 206 +++++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 6 deletions(-) diff --git a/server/test/interfaces/test_base.py b/server/test/interfaces/test_base.py index 7d94ca14e..47dfb9374 100644 --- a/server/test/interfaces/test_base.py +++ b/server/test/interfaces/test_base.py @@ -1,8 +1,14 @@ +import datetime +import json +import re import unittest from typing import Optional from unittest import mock -from app.interfaces.base import BaseWSGIApp +from app.interfaces import base +from basyx.aas import model +from basyx.aas.adapter._generic import XML_NS_MAP +from lxml import etree from werkzeug.exceptions import BadRequest @@ -27,14 +33,14 @@ def mock_get(key, default = None): return request def test_pagination_on_empty_set(self): - page, metadata = BaseWSGIApp._get_slice(self._build_request('3'), []) + page, metadata = base.BaseWSGIApp._get_slice(self._build_request('3'), []) self.assertEqual(0, len(list(page))) if not metadata: self.fail("no metadata") self.assertIsNone(metadata.cursor) def test_pagination_with_one_page(self): - page, metadata = BaseWSGIApp._get_slice(self._build_request("3"), [1, 2]) + page, metadata = base.BaseWSGIApp._get_slice(self._build_request("3"), [1, 2]) self.assertEqual([1, 2], list(page)) if not metadata: self.fail("no metadata") @@ -46,7 +52,7 @@ def test_pagination_walks_all_items(self): pages: list[list] = [] cursor = None for _ in range(1000): - page, metadata = BaseWSGIApp._get_slice(self._build_request('6', cursor), all_objects) + page, metadata = base.BaseWSGIApp._get_slice(self._build_request('6', cursor), all_objects) result = list(page) pages.append(result) if not metadata: @@ -64,8 +70,196 @@ def test_pagination_walks_all_items(self): def test_pagination_requires_positive_limit(self): with self.assertRaises(BadRequest): - BaseWSGIApp._get_slice(self._build_request('-1'), []) + base.BaseWSGIApp._get_slice(self._build_request('-1'), []) def test_pagination_requires_positive_cursor(self): with self.assertRaises(BadRequest): - BaseWSGIApp._get_slice(self._build_request('5', '-1'), []) + base.BaseWSGIApp._get_slice(self._build_request('5', '-1'), []) + + +class TestJsonResponse(unittest.TestCase): + + __test__ = True + + def test_empty_response(self): + response = base.JsonResponse(None) + self.assertEqual(204, response.status_code) + self.assertEqual(0, len(response.get_data())) + + def test_example_single_object(self): + response = base.JsonResponse(model.Submodel(id_="https://example.org/Example_Submodel")) + + self.assertEqual("application/json", response.content_type) + self.assertEqual(200, response.status_code) + parsed_body = json.loads(response.get_data(as_text=True)) + if not isinstance(parsed_body, dict): + self.fail("Response is no JSON object") + self.assertEqual("Submodel", parsed_body["modelType"]) + self.assertEqual("https://example.org/Example_Submodel", parsed_body["id"]) + + def test_example_list(self): + response = base.JsonResponse([ + model.Submodel(id_="https://example.org/Example_Submodel"), + model.Submodel(id_="https://example.org/Second_Submodel") + ]) + + self.assertEqual("application/json", response.content_type) + self.assertEqual(200, response.status_code) + parsed_body = json.loads(response.get_data(as_text=True)) + if not isinstance(parsed_body, list): + self.fail("Response is no JSON list") + ids = [sm["id"] for sm in parsed_body] + self.assertEqual(2, len(ids)) + self.assertEqual(len(ids), len(set(ids))) + + def test_example_empty_list(self): + response = base.JsonResponse([]) + + self.assertEqual("application/json", response.content_type) + self.assertEqual(200, response.status_code) + parsed_body = json.loads(response.get_data(as_text=True)) + + if not isinstance(parsed_body, list): + self.fail("Response is no JSON list") + self.assertEqual(0, len(parsed_body)) + + def test_paging_metadata(self): + response = base.JsonResponse( + obj=[model.Submodel(id_="https://example.org/Example_Submodel")], + paging_metadata=base.PagingMetadata(cursor="asdf") + ) + + self.assertEqual("application/json", response.content_type) + self.assertEqual(200, response.status_code) + parsed_body = json.loads(response.get_data(as_text=True)) + if not isinstance(parsed_body, dict): + self.fail("Response is no JSON object") + self.assertEqual("asdf", parsed_body["paging_metadata"]["cursor"]) + if not isinstance(parsed_body["result"], list): + self.fail("Result part contains no list") + self.assertEqual("https://example.org/Example_Submodel", parsed_body["result"][0]["id"]) + + def test_paging_metadata_no_cursor(self): + response = base.JsonResponse( + obj=[model.Submodel(id_="https://example.org/Example_Submodel")], + paging_metadata=base.PagingMetadata(cursor=None), + ) + + self.assertEqual("application/json", response.content_type) + self.assertEqual(200, response.status_code) + parsed_body = json.loads(response.get_data(as_text=True)) + if not isinstance(parsed_body, dict): + self.fail("Response is no JSON object") + self.assertIn("paging_metadata", parsed_body) + self.assertNotIn("cursor", parsed_body["paging_metadata"]) + + def test_result(self): + response = base.JsonResponse( + base.Result(False, [ + base.Message("BAD_CODE", "test", base.MessageType.ERROR, datetime.datetime.now()) + ]) + ) + + self.assertEqual("application/json", response.content_type) + self.assertEqual(200, response.status_code) + parsed_body = json.loads(response.get_data(as_text=True)) + if not isinstance(parsed_body, dict): + self.fail("Response is no JSON object") + self.assertFalse(parsed_body["success"]) + if not isinstance(parsed_body["messages"], list): + self.fail("messages is no list") + message = parsed_body["messages"][0] + self.assertEqual("BAD_CODE", message["code"]) + self.assertEqual("test", message["text"]) + self.assertEqual("Error", message["messageType"]) + isodatetime = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}$") + self.assertIsNotNone(isodatetime.match(message["timestamp"])) + +class TestXmlResponse(unittest.TestCase): + __test__ = True + + def test_empty_response(self): + response = base.XmlResponse(None) + self.assertEqual(204, response.status_code) + self.assertEqual(0, len(response.get_data())) + + def test_example_single_object(self): + response = base.XmlResponse(model.Submodel(id_="https://example.org/Example_Submodel")) + + self.assertEqual("application/xml", response.content_type) + self.assertEqual(200, response.status_code) + parsed_body = etree.fromstring(response.get_data()) + + returned_id = parsed_body.findtext("aas:id", namespaces=XML_NS_MAP) + self.assertEqual("https://example.org/Example_Submodel", returned_id) + + def test_example_list(self): + response = base.XmlResponse( + [ + model.Submodel(id_="https://example.org/Example_Submodel"), + model.Submodel(id_="https://example.org/Second_Submodel"), + ] + ) + + self.assertEqual("application/xml", response.content_type) + self.assertEqual(200, response.status_code) + parsed_body = etree.fromstring(response.get_data()) + + ids = [elem.text for elem in parsed_body.findall("aas:submodel/aas:id", namespaces=XML_NS_MAP)] + self.assertEqual(2, len(ids)) + self.assertEqual(len(ids), len(set(ids))) + + def test_example_empty_list(self): + response = base.XmlResponse([]) + + self.assertEqual("application/xml", response.content_type) + self.assertEqual(200, response.status_code) + parsed_body = etree.fromstring(response.get_data()) + + self.assertEqual(0, len(list(parsed_body.iterchildren()))) + + def test_paging_metadata(self): + response = base.XmlResponse( + obj=[model.Submodel(id_="https://example.org/Example_Submodel")], + paging_metadata=base.PagingMetadata(cursor="asdf"), + ) + + self.assertEqual("application/xml", response.content_type) + self.assertEqual(200, response.status_code) + parsed_body = etree.fromstring(response.get_data()) + + self.assertEqual("asdf", parsed_body.get("cursor")) + ids = [elem.text for elem in parsed_body.findall("aas:submodel/aas:id", namespaces=XML_NS_MAP)] + self.assertEqual(["https://example.org/Example_Submodel"], ids) + + def test_paging_metadata_no_cursor(self): + response = base.XmlResponse( + obj=[model.Submodel(id_="https://example.org/Example_Submodel")], + paging_metadata=None, + ) + + self.assertEqual("application/xml", response.content_type) + self.assertEqual(200, response.status_code) + parsed_body = etree.fromstring(response.get_data()) + + self.assertIsNone(parsed_body.get("cursor")) + + def test_result(self): + response = base.XmlResponse( + base.Result(False, [base.Message("BAD_CODE", "test", base.MessageType.ERROR, datetime.datetime.now())]) + ) + + self.assertEqual("application/xml", response.content_type) + self.assertEqual(200, response.status_code) + parsed_body = etree.fromstring(response.get_data()) + + self.assertEqual("response", parsed_body.tag) + self.assertEqual("false", parsed_body.findtext("success")) + + messages = parsed_body.findall("messages/message") + self.assertEqual(1, len(messages)) + self.assertEqual("BAD_CODE", messages[0].findtext("code")) + self.assertEqual("test", messages[0].findtext("text")) + self.assertEqual("Error", messages[0].findtext("messageType")) + isodatetime = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}$") + self.assertIsNotNone(isodatetime.match(messages[0].findtext("timestamp") or "")) From 5208257fd792afb1825f5af449d531438c97c3f5 Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Mon, 7 Sep 2026 10:38:34 +0200 Subject: [PATCH 27/59] Fix CI server docker job --- .github/workflows/pr.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 400c423ef..c0b60913f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -403,29 +403,26 @@ jobs: run: | curl -f http://localhost:8080/api/${{ env.X_API_VERSION }}/description - name: Set up Python ${{ env.X_PYTHON_MIN_VERSION }} - # The repository profile is the only one implementing the full /shells CRUD API that - # test_docker_integration.py exercises, so only run it for that profile. - if: matrix.profile == 'repository' uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v7 with: python-version: ${{ env.X_PYTHON_MIN_VERSION }} cache: "pip" cache-dependency-path: "**/pyproject.toml" - name: Install Python dependencies - if: matrix.profile == 'repository' working-directory: ./server run: | python -m pip install --upgrade pip python -m pip install ../sdk python -m pip install . - name: Run Docker integration tests - if: matrix.profile == 'repository' + # Each profile has its own smoke-test module, matching its subset of the API (e.g. only the repository + # profile implements the full /shells CRUD API that test_docker_integration_repository.py exercises). working-directory: ./server env: # Fail instead of silently skipping if the container isn't actually reachable (see test_helpers.py). REQUIRE_SERVER_INTEGRATION_TESTS: "1" run: | - python -m unittest test.interfaces.test_docker_integration -v + python -m unittest test.interfaces.test_docker_integration_${{ matrix.profile }} -v - name: Stop and remove the container run: | docker stop basyx-python-${{ matrix.profile }} && docker rm basyx-python-${{ matrix.profile }} From 82e4fd45e05c599e9b887376a52b00658b6ea0a7 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Mon, 7 Sep 2026 10:40:20 +0200 Subject: [PATCH 28/59] Clearify comment --- .github/workflows/pr.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c0b60913f..68812d1d2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -415,8 +415,7 @@ jobs: python -m pip install ../sdk python -m pip install . - name: Run Docker integration tests - # Each profile has its own smoke-test module, matching its subset of the API (e.g. only the repository - # profile implements the full /shells CRUD API that test_docker_integration_repository.py exercises). + # Each profile has its own test module working-directory: ./server env: # Fail instead of silently skipping if the container isn't actually reachable (see test_helpers.py). From cf66bb57cc62161b125dc2f74ff9b1d211cb6a76 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Mon, 7 Sep 2026 10:47:42 +0200 Subject: [PATCH 29/59] Move integration tests to own module --- server/test/docker_integration/__init__.py | 0 .../test_docker_integration_discovery.py | 0 .../test_docker_integration_registry.py | 0 .../test_docker_integration_repository.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 server/test/docker_integration/__init__.py rename server/test/{interfaces => docker_integration}/test_docker_integration_discovery.py (100%) rename server/test/{interfaces => docker_integration}/test_docker_integration_registry.py (100%) rename server/test/{interfaces => docker_integration}/test_docker_integration_repository.py (100%) diff --git a/server/test/docker_integration/__init__.py b/server/test/docker_integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/test/interfaces/test_docker_integration_discovery.py b/server/test/docker_integration/test_docker_integration_discovery.py similarity index 100% rename from server/test/interfaces/test_docker_integration_discovery.py rename to server/test/docker_integration/test_docker_integration_discovery.py diff --git a/server/test/interfaces/test_docker_integration_registry.py b/server/test/docker_integration/test_docker_integration_registry.py similarity index 100% rename from server/test/interfaces/test_docker_integration_registry.py rename to server/test/docker_integration/test_docker_integration_registry.py diff --git a/server/test/interfaces/test_docker_integration_repository.py b/server/test/docker_integration/test_docker_integration_repository.py similarity index 100% rename from server/test/interfaces/test_docker_integration_repository.py rename to server/test/docker_integration/test_docker_integration_repository.py From 29fdb730c00d33f6129b150c911438a67e03ab33 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Mon, 7 Sep 2026 10:49:35 +0200 Subject: [PATCH 30/59] Drop integration tests duplicated by endpoint test --- .../test_docker_integration_discovery.py | 25 --------- .../test_docker_integration_registry.py | 33 ------------ .../test_docker_integration_repository.py | 52 ------------------- 3 files changed, 110 deletions(-) diff --git a/server/test/docker_integration/test_docker_integration_discovery.py b/server/test/docker_integration/test_docker_integration_discovery.py index 05c340932..49aeb212f 100644 --- a/server/test/docker_integration/test_docker_integration_discovery.py +++ b/server/test/docker_integration/test_docker_integration_discovery.py @@ -75,28 +75,3 @@ def test_asset_link_roundtrip(self): with urllib.request.urlopen(aas_asset_links_path) as response: self.assertEqual(200, response.status) self.assertEqual([], json.loads(response.read())) - - # ------------------------------------------------------------------ POST /lookup/shellsByAssetLink - - def test_lookup_by_asset_link(self): - aas_asset_links_path = f"{SERVER_BASE_URL}/lookup/shells/{base64url_encode(self.AAS_ID)}" - link_request = urllib.request.Request( - aas_asset_links_path, - data=json.dumps([self.ASSET_LINK]).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(link_request) as response: - self.assertEqual(200, response.status) - - lookup_request = urllib.request.Request( - SERVER_BASE_URL + "/lookup/shellsByAssetLink", - data=json.dumps([self.ASSET_LINK]).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="POST", - ) - with urllib.request.urlopen(lookup_request) as response: - self.assertEqual(200, response.status) - data = json.loads(response.read()) - - self.assertIn(self.AAS_ID, data["result"]) diff --git a/server/test/docker_integration/test_docker_integration_registry.py b/server/test/docker_integration/test_docker_integration_registry.py index f8900a7f7..a36b7e61a 100644 --- a/server/test/docker_integration/test_docker_integration_registry.py +++ b/server/test/docker_integration/test_docker_integration_registry.py @@ -90,36 +90,3 @@ def test_shell_descriptor_roundtrip(self): with self.assertRaises(urllib.error.HTTPError) as cm: urllib.request.urlopen(descriptor_path) self.assertEqual(404, cm.exception.code) - - def test_shell_descriptor_duplicate_post(self): - descriptor = AssetAdministrationShellDescriptor(id_=self.DESCRIPTOR_ID) - body = json.dumps(descriptor, cls=ServerAASToJsonEncoder).encode("utf-8") - post_request = urllib.request.Request( - SERVER_BASE_URL + "/shell-descriptors", - data=body, - headers={"Content-Type": "application/json"}, - method="POST", - ) - - with urllib.request.urlopen(post_request) as response: - self.assertEqual(201, response.status) - - with self.assertRaises(urllib.error.HTTPError) as cm: - urllib.request.urlopen(post_request) - self.assertEqual(409, cm.exception.code) - - # ------------------------------------------------------------------ GET/DELETE on a missing /shell-descriptors/ - - def test_shell_descriptor_not_found(self): - missing_path = ( - f"{SERVER_BASE_URL}/shell-descriptors/{base64url_encode('https://example.org/unknown-descriptor')}" - ) - - with self.assertRaises(urllib.error.HTTPError) as cm: - urllib.request.urlopen(missing_path) - self.assertEqual(404, cm.exception.code) - - delete_request = urllib.request.Request(missing_path, method="DELETE") - with self.assertRaises(urllib.error.HTTPError) as cm: - urllib.request.urlopen(delete_request) - self.assertEqual(404, cm.exception.code) diff --git a/server/test/docker_integration/test_docker_integration_repository.py b/server/test/docker_integration/test_docker_integration_repository.py index 3c32c5bd1..b571bcadb 100644 --- a/server/test/docker_integration/test_docker_integration_repository.py +++ b/server/test/docker_integration/test_docker_integration_repository.py @@ -81,55 +81,3 @@ def test_shell_roundtrip(self): checker = AASDataChecker(raise_immediately=True) check_example_asset_administration_shell(checker, retrieved) - - def test_shell_duplicate_post(self): - shell = create_example_asset_administration_shell() - body = json.dumps(shell, cls=AASToJsonEncoder).encode("utf-8") - post_request = urllib.request.Request( - SERVER_BASE_URL + "/shells", data=body, headers={"Content-Type": "application/json"}, method="POST" - ) - - with urllib.request.urlopen(post_request) as response: - self.assertEqual(201, response.status) - - with self.assertRaises(urllib.error.HTTPError) as cm: - urllib.request.urlopen(post_request) - self.assertEqual(409, cm.exception.code) - - def test_shell_update(self): - shell = create_example_asset_administration_shell() - body = json.dumps(shell, cls=AASToJsonEncoder).encode("utf-8") - shell_path = f"{SERVER_BASE_URL}/shells/{base64url_encode(shell.id)}" - - post_request = urllib.request.Request( - SERVER_BASE_URL + "/shells", data=body, headers={"Content-Type": "application/json"}, method="POST" - ) - with urllib.request.urlopen(post_request) as response: - self.assertEqual(201, response.status) - - shell.id_short = "UpdatedIdShort" - updated_body = json.dumps(shell, cls=AASToJsonEncoder).encode("utf-8") - put_request = urllib.request.Request( - shell_path, data=updated_body, headers={"Content-Type": "application/json"}, method="PUT" - ) - with urllib.request.urlopen(put_request) as response: - self.assertEqual(204, response.status) - - with urllib.request.urlopen(shell_path) as response: - self.assertEqual(200, response.status) - retrieved = json.loads(response.read(), cls=AASFromJsonDecoder) - self.assertEqual("UpdatedIdShort", retrieved.id_short) - - # ------------------------------------------------------------------ GET/PUT/DELETE on a missing /shells/ - - def test_shell_not_found(self): - missing_shell_path = f"{SERVER_BASE_URL}/shells/{base64url_encode('https://example.org/unknown-shell')}" - - with self.assertRaises(urllib.error.HTTPError) as cm: - urllib.request.urlopen(missing_shell_path) - self.assertEqual(404, cm.exception.code) - - delete_request = urllib.request.Request(missing_shell_path, method="DELETE") - with self.assertRaises(urllib.error.HTTPError) as cm: - urllib.request.urlopen(delete_request) - self.assertEqual(404, cm.exception.code) From ce6984a60948ba80460bf814e63759a08c949d56 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Mon, 7 Sep 2026 11:05:00 +0200 Subject: [PATCH 31/59] Revert "Fix DictDescriptorStore missing commit() causing 500s on descriptor writes" This reverts commit 46161d4f. Creating issue for this to solve in later PR --- server/app/model/provider.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/server/app/model/provider.py b/server/app/model/provider.py index aaf9874ef..472f09979 100644 --- a/server/app/model/provider.py +++ b/server/app/model/provider.py @@ -38,11 +38,6 @@ def discard(self, x: _DESCRIPTOR_TYPE) -> None: if self._backend.get(x.id) is x: del self._backend[x.id] - def commit(self, x: _DESCRIPTOR_TYPE) -> None: - # This is an in-memory store: mutations to a stored descriptor are already visible without persisting them - # anywhere, so there is nothing to do here. - pass - def __contains__(self, x: object) -> bool: if isinstance(x, model.Identifier): return x in self._backend From 0eaedffb68b706102df49921096a4fcfa3e05a1b Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Mon, 7 Sep 2026 11:46:56 +0200 Subject: [PATCH 32/59] Add server-test job --- .github/workflows/pr.yml | 35 +++++++++++++++++++++++++++++ server/pyproject.toml | 1 + server/test/_helper/test_helpers.py | 8 +++---- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 68812d1d2..10a54c2ad 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -343,6 +343,41 @@ jobs: run: | python -m build + server-test: + # This job runs the unittests on the python versions specified down at the matrix + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.12"] + defaults: + run: + working-directory: ./server + + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 #v5.1.0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v7 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + cache-dependency-path: "**/pyproject.toml" + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install ../sdk + python -m pip install .[dev] + - name: Test with coverage + unittest + env: + # No server/container is started in this job, so the Docker integration tests should just skip here, + # not be required to pass (see test_helpers.py). + REQUIRE_SERVER_INTEGRATION_TESTS: "0" + run: | + python -m coverage run --source=app -m unittest + - name: Report test coverage + if: ${{ always() }} + run: | + python -m coverage report -m + server-static-analysis: # This job runs static code analysis, namely ruff and mypy runs-on: ubuntu-latest diff --git a/server/pyproject.toml b/server/pyproject.toml index e87990397..38ab0a9f9 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ [project.optional-dependencies] dev = [ + "coverage", "mypy", "pycodestyle", "ruff==0.16.0", diff --git a/server/test/_helper/test_helpers.py b/server/test/_helper/test_helpers.py index 6db4a1d95..89b2444e9 100644 --- a/server/test/_helper/test_helpers.py +++ b/server/test/_helper/test_helpers.py @@ -14,10 +14,10 @@ # By default, the Docker integration tests are skipped whenever no server is reachable, so that a plain local -# `python -m unittest` run doesn't require a running Docker container. Set this environment variable to any -# non-empty value (e.g. in CI) to instead make those tests fail loudly if no server is reachable, so a broken -# Docker container can't silently cause the tests to be skipped without anyone noticing. -REQUIRE_SERVER = bool(os.environ.get("REQUIRE_SERVER_INTEGRATION_TESTS")) +# `python -m unittest` run doesn't require a running Docker container. Set this environment variable to "1"/"true" +# (e.g. in CI) to instead make those tests fail loudly if no server is reachable, so a broken Docker container +# can't silently cause the tests to be skipped without anyone noticing. +REQUIRE_SERVER = os.environ.get("REQUIRE_SERVER_INTEGRATION_TESTS", "false").lower() in {"1", "true", "yes"} # Check if the server is available. Otherwise, skip tests (unless REQUIRE_SERVER is set, see above). try: From c3dc8d83eff3df9e55059fd26737a793f8651212 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Mon, 7 Sep 2026 11:38:40 +0200 Subject: [PATCH 33/59] Adapt CI to move of integration tests + fix ruff errors --- .github/workflows/pr.yml | 2 +- server/test/interfaces/test_base.py | 4 ++-- server/test/interfaces/test_discovery.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 10a54c2ad..a169e3735 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -456,7 +456,7 @@ jobs: # Fail instead of silently skipping if the container isn't actually reachable (see test_helpers.py). REQUIRE_SERVER_INTEGRATION_TESTS: "1" run: | - python -m unittest test.interfaces.test_docker_integration_${{ matrix.profile }} -v + python -m unittest test.docker_integration.test_docker_integration_${{ matrix.profile }} -v - name: Stop and remove the container run: | docker stop basyx-python-${{ matrix.profile }} && docker rm basyx-python-${{ matrix.profile }} diff --git a/server/test/interfaces/test_base.py b/server/test/interfaces/test_base.py index 47dfb9374..fc695911e 100644 --- a/server/test/interfaces/test_base.py +++ b/server/test/interfaces/test_base.py @@ -208,14 +208,14 @@ def test_example_list(self): ids = [elem.text for elem in parsed_body.findall("aas:submodel/aas:id", namespaces=XML_NS_MAP)] self.assertEqual(2, len(ids)) self.assertEqual(len(ids), len(set(ids))) - + def test_example_empty_list(self): response = base.XmlResponse([]) self.assertEqual("application/xml", response.content_type) self.assertEqual(200, response.status_code) parsed_body = etree.fromstring(response.get_data()) - + self.assertEqual(0, len(list(parsed_body.iterchildren()))) def test_paging_metadata(self): diff --git a/server/test/interfaces/test_discovery.py b/server/test/interfaces/test_discovery.py index 75b5b0cf1..05c235095 100644 --- a/server/test/interfaces/test_discovery.py +++ b/server/test/interfaces/test_discovery.py @@ -18,9 +18,9 @@ from app.interfaces.discovery import DiscoveryAPI, DiscoveryStore from app.util.converters import base64url_encode +from basyx.aas.model import SpecificAssetId from werkzeug.test import Client, TestResponse -from basyx.aas.model import SpecificAssetId from .format_utils import FormatClient, JsonFormatClient, inject_format_clients, with_json_client, with_xml_client From 2b24e1ed5f0839fd9681f4f3a56d331f77e4f392 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Mon, 7 Sep 2026 12:31:28 +0200 Subject: [PATCH 34/59] Enable `STORAGE_PERSISTENCY` in integration tests (#626) Currently, the `DictDescriptorStore` used in the Registry when disabling persistent storage, throws an exception on `commit()` calls. To avoid failing pipeline and because persistent storage is more realistic end-user behavior, the integration tests now run with persistent storage. --- .github/workflows/pr.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a169e3735..31da5a9aa 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -425,8 +425,10 @@ jobs: publish: false platform: linux/amd64 - name: Run container + # Enable STORAGE_PERSISTENCY as registry fails with in-memory store (#626) + # TODO(#626): revisit when `DictDescriptorStore` throws now exception on `commit()` run: | - docker run -d --name basyx-python-${{ matrix.profile }} -p 8080:80 --pull=never ${{ steps.build.outputs.image-ref }} + docker run -d --name basyx-python-${{ matrix.profile }} -p 8080:80 -eSTORAGE_PERSISTENCY=True --pull=never ${{ steps.build.outputs.image-ref }} - name: Wait for container and server initialization run: | timeout 30s bash -c ' From ddf31be98296b12c92fd8d5d54f5aeb35e9808e9 Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Tue, 8 Sep 2026 14:34:33 +0200 Subject: [PATCH 35/59] Add delete verification to repository integration test roundtrip and minor comment changes --- .../test_docker_integration_discovery.py | 3 +-- .../test_docker_integration_registry.py | 3 +-- .../test_docker_integration_repository.py | 11 +++++++++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/server/test/docker_integration/test_docker_integration_discovery.py b/server/test/docker_integration/test_docker_integration_discovery.py index 49aeb212f..dcea34d1e 100644 --- a/server/test/docker_integration/test_docker_integration_discovery.py +++ b/server/test/docker_integration/test_docker_integration_discovery.py @@ -16,8 +16,7 @@ ) class DiscoveryDockerIntegrationTest(unittest.TestCase): """ - Smoke tests against a real, already-running discovery server instance (e.g. started via - ``docker run -p 8080:80 basyx-python-discovery``), analogous to ``test_docker_integration_repository.py`` for the + Testing tests against a real, already-running discovery server instance, analogous to ``test_docker_integration_repository.py`` for the repository profile: skipped entirely if no server is reachable at ``SERVER_BASE_URL``. Set the ``REQUIRE_SERVER_INTEGRATION_TESTS`` environment variable to make this test class fail instead of diff --git a/server/test/docker_integration/test_docker_integration_registry.py b/server/test/docker_integration/test_docker_integration_registry.py index a36b7e61a..2b61d4856 100644 --- a/server/test/docker_integration/test_docker_integration_registry.py +++ b/server/test/docker_integration/test_docker_integration_registry.py @@ -18,8 +18,7 @@ ) class RegistryDockerIntegrationTest(unittest.TestCase): """ - Smoke tests against a real, already-running registry server instance (e.g. started via - ``docker run -p 8080:80 basyx-python-registry``), analogous to ``test_docker_integration_repository.py`` for the + Testing against a real, already-running registry server instance, analogous to ``test_docker_integration_repository.py`` for the repository profile: skipped entirely if no server is reachable at ``SERVER_BASE_URL``. Set the ``REQUIRE_SERVER_INTEGRATION_TESTS`` environment variable to make this test class fail instead of diff --git a/server/test/docker_integration/test_docker_integration_repository.py b/server/test/docker_integration/test_docker_integration_repository.py index b571bcadb..4483d22d5 100644 --- a/server/test/docker_integration/test_docker_integration_repository.py +++ b/server/test/docker_integration/test_docker_integration_repository.py @@ -22,8 +22,7 @@ ) class ServerDockerIntegrationTest(unittest.TestCase): """ - Smoke tests against a real, already-running server instance (e.g. started via - ``docker run -p 8080:80 basyx-python-server``), analogous to how ``test_couchdb.py`` tests + Tests against a real, already-running server instance, analogous to how ``test_couchdb.py`` tests against a real CouchDB instance: skipped entirely if no server is reachable at ``SERVER_BASE_URL``. Set the ``REQUIRE_SERVER_INTEGRATION_TESTS`` environment variable to make this test class fail instead of @@ -81,3 +80,11 @@ def test_shell_roundtrip(self): checker = AASDataChecker(raise_immediately=True) check_example_asset_administration_shell(checker, retrieved) + + delete_request = urllib.request.Request(shell_path, method="DELETE") + with urllib.request.urlopen(delete_request) as response: + self.assertEqual(204, response.status) + + with self.assertRaises(urllib.error.HTTPError) as cm: + urllib.request.urlopen(shell_path) + self.assertEqual(404, cm.exception.code) From 15f896782bcaf839ee7f14f616d6e4da594f57b7 Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Sat, 12 Sep 2026 20:18:34 +0200 Subject: [PATCH 36/59] Add tests for DiscoveryStore file persistence and load_directory --- server/test/interfaces/test_discovery.py | 51 ++++++++++ server/test/model/test_provider.py | 114 ++++++++++++++++++++++- 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/server/test/interfaces/test_discovery.py b/server/test/interfaces/test_discovery.py index 05c235095..b3a032a89 100644 --- a/server/test/interfaces/test_discovery.py +++ b/server/test/interfaces/test_discovery.py @@ -13,6 +13,7 @@ """ import json +import os import unittest from typing import List, Tuple @@ -296,3 +297,53 @@ def test_delete_unknown_aas_returns_204(self) -> None: response = self.format_client.delete(f"/lookup/shells/{base64url_encode(self.UNKNOWN_ID)}") self.assertEqual(204, response.status_code, msg=response.get_data(as_text=True)) + + +# ====================================================================== DiscoveryStore.from_file()/to_file() + + +class DiscoveryStorePersistenceTest(unittest.TestCase): + STORE_FILE = os.path.join(os.path.dirname(__file__), "discovery_store_persistence_test.json") + + def tearDown(self) -> None: + for path in (self.STORE_FILE, f"{self.STORE_FILE}.tmp"): + if os.path.exists(path): + os.remove(path) + + def test_roundtrip_rebuilds_reverse_index(self) -> None: + store = DiscoveryStore() + asset_1 = SpecificAssetId("globalAssetId", "urn:asset:1") + asset_2 = SpecificAssetId("serialNumber", "SN-42") + asset_3 = SpecificAssetId("globalAssetId", "urn:asset:1") # shared by both AAS + store.add_specific_asset_ids_to_aas("https://example.org/aas/1", [asset_1, asset_2]) + store.add_specific_asset_ids_to_aas("https://example.org/aas/2", [asset_3]) + store.to_file(self.STORE_FILE) + + loaded = DiscoveryStore.from_file(self.STORE_FILE) + + self.assertEqual(store.aas_id_to_asset_ids, loaded.aas_id_to_asset_ids) + self.assertEqual( + {"https://example.org/aas/1", "https://example.org/aas/2"}, + loaded.asset_id_to_aas_ids[asset_1], + ) + self.assertEqual({"https://example.org/aas/1"}, loaded.asset_id_to_aas_ids[asset_2]) + + def test_roundtrip_empty_store(self) -> None: + store = DiscoveryStore() + store.to_file(self.STORE_FILE) + + loaded = DiscoveryStore.from_file(self.STORE_FILE) + + self.assertEqual({}, loaded.aas_id_to_asset_ids) + self.assertEqual({}, loaded.asset_id_to_aas_ids) + + def test_to_file_does_not_leave_temp_file_behind(self) -> None: + store = DiscoveryStore() + store.add_specific_asset_ids_to_aas( + "https://example.org/aas/1", [SpecificAssetId("globalAssetId", "urn:asset:1")] + ) + + store.to_file(self.STORE_FILE) + + self.assertTrue(os.path.exists(self.STORE_FILE)) + self.assertFalse(os.path.exists(f"{self.STORE_FILE}.tmp")) diff --git a/server/test/model/test_provider.py b/server/test/model/test_provider.py index 567eb6fa2..d956e66ca 100644 --- a/server/test/model/test_provider.py +++ b/server/test/model/test_provider.py @@ -1,7 +1,14 @@ +import json +import os +import shutil import unittest +from typing import Any, Dict, Iterable, Union -from app import model -from app.model.provider import DictDescriptorStore +from app import adapter, model +from app.backend.local_file import DESCRIPTOR_TYPE_TO_STRING +from app.model.provider import DictDescriptorStore, load_directory + +_Descriptor = Union[model.AssetAdministrationShellDescriptor, model.SubmodelDescriptor] class DictDescriptorStoreTest(unittest.TestCase): @@ -57,3 +64,106 @@ def test_store_update(self) -> None: descriptor_store1.update(descriptor_store2) self.assertIsInstance(descriptor_store1, DictDescriptorStore) self.assertIn(self.sd2, descriptor_store1) + + +class LoadDirectoryTest(unittest.TestCase): + """ + Tests for :func:`~app.model.provider.load_directory`. + + Descriptor JSON files must carry a ``modelType`` field for each descriptor so that + ``ServerAASFromJsonDecoder`` recognizes and converts them (descriptors aren't ``Referable``, so the SDK's + encoder never writes ``modelType`` on its own -- see ``LocalFileDescriptorStore.add()``/``commit()``, + which inject it for the same reason). ``_descriptor_to_json_dict()`` below replicates that injection to + build valid fixture files. + """ + + TEST_DIR = os.path.join(os.path.dirname(__file__), "load_directory_test_folder") + + def setUp(self) -> None: + os.makedirs(self.TEST_DIR, exist_ok=True) + self.mock_endpoint = model.Endpoint( + interface="AAS-3.0", protocol_information=model.ProtocolInformation(href="https://example.org/") + ) + + def tearDown(self) -> None: + shutil.rmtree(self.TEST_DIR) + + @staticmethod + def _descriptor_to_json_dict(desc: _Descriptor) -> Dict[str, Any]: + data: Dict[str, Any] = json.loads(json.dumps(desc, cls=adapter.ServerAASToJsonEncoder)) + data["modelType"] = DESCRIPTOR_TYPE_TO_STRING[type(desc)] + return data + + def _write_file( + self, + filename: str, + aas_descriptors: Iterable[model.AssetAdministrationShellDescriptor] = (), + submodel_descriptors: Iterable[model.SubmodelDescriptor] = (), + ) -> None: + data = { + "assetAdministrationShellDescriptors": [self._descriptor_to_json_dict(d) for d in aas_descriptors], + "submodelDescriptors": [self._descriptor_to_json_dict(d) for d in submodel_descriptors], + } + with open(os.path.join(self.TEST_DIR, filename), "w") as f: + json.dump(data, f) + + def test_loads_descriptors_from_single_file(self) -> None: + aasd = model.AssetAdministrationShellDescriptor( + id_="https://example.org/AASDescriptor/1", endpoints=[self.mock_endpoint] + ) + sd = model.SubmodelDescriptor(id_="https://example.org/SubmodelDescriptor/1", endpoints=[self.mock_endpoint]) + self._write_file("descriptors.json", aas_descriptors=[aasd], submodel_descriptors=[sd]) + + store = load_directory(self.TEST_DIR) + + self.assertEqual(2, len(store)) + self.assertIn("https://example.org/AASDescriptor/1", store) + self.assertIn("https://example.org/SubmodelDescriptor/1", store) + + def test_merges_descriptors_from_multiple_files(self) -> None: + aasd1 = model.AssetAdministrationShellDescriptor( + id_="https://example.org/AASDescriptor/1", endpoints=[self.mock_endpoint] + ) + aasd2 = model.AssetAdministrationShellDescriptor( + id_="https://example.org/AASDescriptor/2", endpoints=[self.mock_endpoint] + ) + self._write_file("a.json", aas_descriptors=[aasd1]) + self._write_file("b.json", aas_descriptors=[aasd2]) + + store = load_directory(self.TEST_DIR) + + self.assertEqual(2, len(store)) + self.assertIn("https://example.org/AASDescriptor/1", store) + self.assertIn("https://example.org/AASDescriptor/2", store) + + def test_duplicate_ids_across_files_are_skipped_not_raised(self) -> None: + aasd1 = model.AssetAdministrationShellDescriptor( + id_="https://example.org/AASDescriptor/1", endpoints=[self.mock_endpoint] + ) + aasd2 = model.AssetAdministrationShellDescriptor( + id_="https://example.org/AASDescriptor/1", endpoints=[self.mock_endpoint] + ) + self._write_file("a.json", aas_descriptors=[aasd1]) + self._write_file("b.json", aas_descriptors=[aasd2]) + + store = load_directory(self.TEST_DIR) # must not raise despite the duplicate id + + self.assertEqual(1, len(store)) + self.assertIn("https://example.org/AASDescriptor/1", store) + + def test_ignores_non_json_files(self) -> None: + with open(os.path.join(self.TEST_DIR, "readme.txt"), "w") as f: + f.write("not a descriptor file") + aasd = model.AssetAdministrationShellDescriptor( + id_="https://example.org/AASDescriptor/1", endpoints=[self.mock_endpoint] + ) + self._write_file("a.json", aas_descriptors=[aasd]) + + store = load_directory(self.TEST_DIR) + + self.assertEqual(1, len(store)) + + def test_empty_directory_returns_empty_store(self) -> None: + store = load_directory(self.TEST_DIR) + + self.assertEqual(0, len(store)) From 45922ca4c25c96e629630e9e12d10666e30d45f3 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Fri, 18 Sep 2026 10:43:14 +0200 Subject: [PATCH 37/59] Test reqistry with full `Descriptor` objects --- server/test/adapter/__init__.py | 0 server/test/adapter/descriptor_utils.py | 90 ++++++++++++++++ server/test/adapter/test_jsonization.py | 96 +++++++++++++++++ server/test/interfaces/test_registry.py | 133 ++++++++++++------------ 4 files changed, 251 insertions(+), 68 deletions(-) create mode 100644 server/test/adapter/__init__.py create mode 100644 server/test/adapter/descriptor_utils.py create mode 100644 server/test/adapter/test_jsonization.py diff --git a/server/test/adapter/__init__.py b/server/test/adapter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/test/adapter/descriptor_utils.py b/server/test/adapter/descriptor_utils.py new file mode 100644 index 000000000..23063a53c --- /dev/null +++ b/server/test/adapter/descriptor_utils.py @@ -0,0 +1,90 @@ +from typing import Any + +from app.model import DictDescriptorStore, SecurityAttributeObject, SecurityTypeEnum +from app.model.descriptor import AssetAdministrationShellDescriptor, SubmodelDescriptor +from app.model.endpoint import Endpoint, ProtocolInformation +from basyx.aas import model + + +def example_endpoint(interface: str = "AAS-3.0", href: str = "https://example.org/endpoint") -> Endpoint: + return Endpoint( + interface=interface, + protocol_information=ProtocolInformation( + href=href, + endpoint_protocol="ExampleProtocol", + endpoint_protocol_version=["1", "2"], + subprotocol="ExampleSubprotocol", + subprotocol_body="ExampleProtocolBody", + subprotocol_body_encoding="ExampleEncoding", + security_attributes=[SecurityAttributeObject( + type_=SecurityTypeEnum.NONE, + key="ExampleKey", + value="ExampleValue" + )] + )) + +def example_aas_descriptor(id_: str, **kwargs: Any) -> AssetAdministrationShellDescriptor: + aas_descriptor_args = dict({ + "description": model.MultiLanguageTextType( + {"en-US": "Example description", "de": "Beispiel Description"} + ), + "display_name": model.MultiLanguageNameType( + {"en-US": "Exaple display name", "de": "Beispiel Anzeigename"} + ), + # TODO: Descriptor serialization fails with extensions, fix and enable test + # "extension": [model.Extension( + # name="Example Descriptor Extension", + # value_type=model.datatypes.String, + # value="ExampleExtensionValue" + # )], + "administration": model.AdministrativeInformation( + version="9", template_id="http://example.org/AdministrativeInformation" + ), + "asset_kind": model.AssetKind.INSTANCE, + "asset_type": "http://example.org/TestAssetType/", + "endpoints": [example_endpoint("AAS-3.0")], + "global_asset_id": "http://example.org/TestAsset/", + "id_short": "TestAsset", + "specific_asset_id": [model.SpecificAssetId( + name="TestKey", + value="TestValue", + )], + "submodel_descriptors": [] + }) + aas_descriptor_args.update(kwargs) + return AssetAdministrationShellDescriptor(id_=id_, **aas_descriptor_args) # type: ignore + + +def example_submodel_descriptor(id_: str, **kwargs: Any) -> SubmodelDescriptor: + descriptor_args = dict( + { + # TODO: SubmodelDescriptor.__init__() does not take args of Descriptor.__init__() + # "description": model.MultiLanguageTextType( + # {"en-US": "Example description", "de": "Beispiel Description"} + # ), + # "display_name": model.MultiLanguageNameType( + # {"en-US": "Exaple display name", "de": "Beispiel Anzeigename"} + # ), + # TODO: equal to AssetAdministrationShellDescriptor + # "extension": [model.Extension( + # name="Example Descriptor Extension", + # value_type=model.datatypes.String, + # value="ExampleExtensionValue" + # )], + "endpoints": [example_endpoint("SUBMODEL-3.0")], + "administration": model.AdministrativeInformation( + version="9", template_id="http://example.org/AdministrativeInformation" + ), + "id_short": "TestSubmodel", + "semantic_id": model.ExternalReference( + (model.Key(model.KeyTypes.GLOBAL_REFERENCE, "http://example.org/SubmodelDescription/"),) + ), + "supplemental_semantic_id": [ + model.ExternalReference( + (model.Key(model.KeyTypes.GLOBAL_REFERENCE, "http://example.org/SupplementalID/"),) + ) + ], + } + ) + descriptor_args.update(kwargs) + return SubmodelDescriptor(id_=id_, **descriptor_args) # type: ignore diff --git a/server/test/adapter/test_jsonization.py b/server/test/adapter/test_jsonization.py new file mode 100644 index 000000000..c989e5812 --- /dev/null +++ b/server/test/adapter/test_jsonization.py @@ -0,0 +1,96 @@ +import json +import unittest +from typing import Any + +from app.adapter import ServerAASToJsonEncoder +from .descriptor_utils import example_submodel_descriptor, example_aas_descriptor, example_endpoint + + +class TestDescriptorSerialization(unittest.TestCase): + """ + Tests the serialization of full :class:`~app.model.descriptor.Descriptor` objects + """ + + def _assert_example_protocol(self, protocol: dict[str, Any]) -> None: + self.assertEqual("https://example.org/endpoint", protocol["href"]) + self.assertEqual("ExampleProtocol", protocol["endpointProtocol"]) + self.assertEqual(["1", "2"], protocol["endpointProtocolVersion"]) + self.assertEqual("ExampleSubprotocol", protocol["subprotocol"]) + self.assertEqual("ExampleProtocolBody", protocol["subprotocolBody"]) + self.assertEqual("ExampleEncoding", protocol["subprotocolBodyEncoding"]) + + # TODO: Endpoint security is currently not serialized + # self.assertIsInstance(protocol["securityAttributes"], list) + # self.assertEqual(1, len(protocol["securityAttributes"])) + # self.assertEqual("NONE", protocol["securityAttributes"][0]["type"]) + # self.assertEqual("ExampleKey", protocol["securityAttributes"][0]["key"]) + # self.assertEqual("ExampleValue", protocol["securityAttributes"][0]["value"]) + + def test_ass_descriptor_serialization(self) -> None: + aas_descriptor = example_aas_descriptor(id_="http://example.org/Test_AAS") + + dump = json.dumps(aas_descriptor, cls=ServerAASToJsonEncoder) + result = json.loads(dump) + + self.assertEqual("http://example.org/Test_AAS", result["id"]) + + # description is list of model.LangStringTextType (covered in basyx.aas.adpater) + self.assertIsInstance(result["description"], list) + self.assertEqual(2, len(result["description"])) + + # displayName is list of model.LangStringNameType (covered in basyx.aas.adapter) + self.assertIsInstance(result["displayName"], list) + self.assertEqual(2, len(result["displayName"])) + + # TODO: enable when Descriptor serializes extensions + # extensions attribute is list of model.Extension + # self.assertIn("extensions", result) + # self.assertIsInstance(result["extensions"], list) + + # administration is model.AdministrativeInformation + self.assertIsInstance(result["administration"], dict) + self.assertEqual("http://example.org/AdministrativeInformation", result["administration"]["templateId"]) + + self.assertEqual("Instance", result["assetKind"]) + + self.assertEqual("http://example.org/TestAssetType/", result["assetType"]) + + self.assertIsInstance(result["endpoints"], list) + self.assertEqual(1, len(result["endpoints"])) + self.assertEqual("AAS-3.0", result["endpoints"][0]["interface"]) + self._assert_example_protocol(result["endpoints"][0]["protocolInformation"]) + + self.assertEqual("http://example.org/TestAsset/", result["globalAssetId"]) + self.assertEqual("TestAsset", result["idShort"]) + + # specificAssetIds attribute is list of model.SpecificAssetId (covered in basyx.aas.adpater) + self.assertIsInstance(result["specificAssetIds"], list) + self.assertEqual(1, len(result["specificAssetIds"])) + + def test_submodel_descriptor_serialization(self) -> None: + sm_descriptor = example_submodel_descriptor(id_="http://example.org/Test_Submodel") + + dump = json.dumps(sm_descriptor, cls=ServerAASToJsonEncoder) + result = json.loads(dump) + + # TODO: enable when Descriptor serializes extensions + # extensions attribute is list of model.Extension + # self.assertIn("extensions", result) + # self.assertIsInstance(result["extensions"], list) + + self.assertIsInstance(result["endpoints"], list) + self.assertEqual(1, len(result["endpoints"])) + self.assertEqual("SUBMODEL-3.0", result["endpoints"][0]["interface"]) + self._assert_example_protocol(result["endpoints"][0]["protocolInformation"]) + + self.assertEqual("TestSubmodel", result["idShort"]) + # semanticId is model.Reference (covered in basyx.aas.adapter) + self.assertEqual("http://example.org/SubmodelDescription/", result["semanticId"]["keys"][0]["value"]) + + # TODO: SubmodelDescriptor currently uses trailing "s" + # supplementalSemanticId is list of model.Reference + # self.assertIsInstance(result["supplementalSemanticId"], list) + # self.assertEqual( + # "http://example.org/SupplementalID/", + # result["supplementalSemanticId"][0]["keys"][0]["value"] + # ) diff --git a/server/test/interfaces/test_registry.py b/server/test/interfaces/test_registry.py index c6d25726d..38e2b08ed 100644 --- a/server/test/interfaces/test_registry.py +++ b/server/test/interfaces/test_registry.py @@ -17,11 +17,11 @@ from app.interfaces.registry import RegistryAPI from app.model import DictDescriptorStore from app.model.descriptor import AssetAdministrationShellDescriptor, SubmodelDescriptor -from app.model.endpoint import Endpoint, ProtocolInformation from app.util.converters import base64url_encode from basyx.aas import model from werkzeug.test import Client, TestResponse +from ..adapter.descriptor_utils import example_aas_descriptor, example_submodel_descriptor from .format_utils import JsonFormatClient @@ -41,20 +41,6 @@ def clear(self) -> None: self._backend.clear() -def _endpoint(interface: str = "AAS-3.0", href: str = "https://example.org/endpoint") -> Endpoint: - return Endpoint(interface=interface, protocol_information=ProtocolInformation(href=href)) - - -def _aas_descriptor(id_: str, **kwargs: Any) -> AssetAdministrationShellDescriptor: - kwargs.setdefault("endpoints", [_endpoint("AAS-3.0")]) - return AssetAdministrationShellDescriptor(id_=id_, **kwargs) - - -def _submodel_descriptor(id_: str, **kwargs: Any) -> SubmodelDescriptor: - kwargs.setdefault("endpoints", [_endpoint("SUBMODEL-3.0")]) - return SubmodelDescriptor(id_=id_, **kwargs) - - class RegistryEndpointTestBase(unittest.TestCase): __test__ = False @@ -131,8 +117,8 @@ def test_get_all_empty(self) -> None: self.assertEqual([], self.format_client.parse_collection(response)) def test_get_all_returns_registered_descriptors(self) -> None: - self.store.add(_aas_descriptor(self.AAS_ID)) - self.store.add(_aas_descriptor(self.AAS_ID_2)) + self.store.add(example_aas_descriptor(self.AAS_ID)) + self.store.add(example_aas_descriptor(self.AAS_ID_2)) response = self.format_client.get("/shell-descriptors") @@ -141,8 +127,8 @@ def test_get_all_returns_registered_descriptors(self) -> None: def test_get_all_only_returns_aas_descriptors(self) -> None: # The store is shared between the AAS- and Submodel-registry routes, so this route must filter by type. - self.store.add(_aas_descriptor(self.AAS_ID)) - self.store.add(_submodel_descriptor(self.SM_ID)) + self.store.add(example_aas_descriptor(self.AAS_ID)) + self.store.add(example_submodel_descriptor(self.SM_ID)) response = self.format_client.get("/shell-descriptors") @@ -150,8 +136,8 @@ def test_get_all_only_returns_aas_descriptors(self) -> None: self.assertEqual([self.AAS_ID], self.ids(response)) def test_get_all_supports_pagination(self) -> None: - self.store.add(_aas_descriptor(self.AAS_ID)) - self.store.add(_aas_descriptor(self.AAS_ID_2)) + self.store.add(example_aas_descriptor(self.AAS_ID)) + self.store.add(example_aas_descriptor(self.AAS_ID_2)) pages = self.format_client.get_paginated("/shell-descriptors", limit=1, max_pages=2) @@ -161,13 +147,13 @@ def test_get_all_supports_pagination(self) -> None: self.assertEqual(len(seen), len(set(seen)), "an item was returned on more than one page") def test_get_all_negative_limit_returns_400(self) -> None: - self.store.add(_aas_descriptor(self.AAS_ID)) + self.store.add(example_aas_descriptor(self.AAS_ID)) self.assert_error(self.format_client.get("/shell-descriptors?limit=-1"), 400) def test_get_all_filter_by_asset_kind(self) -> None: - self.store.add(_aas_descriptor(self.AAS_ID, asset_kind=model.AssetKind.INSTANCE)) - self.store.add(_aas_descriptor(self.AAS_ID_2, asset_kind=model.AssetKind.TYPE)) + self.store.add(example_aas_descriptor(self.AAS_ID, asset_kind=model.AssetKind.INSTANCE)) + self.store.add(example_aas_descriptor(self.AAS_ID_2, asset_kind=model.AssetKind.TYPE)) response = self.format_client.get("/shell-descriptors?assetKind=INSTANCE") @@ -175,14 +161,14 @@ def test_get_all_filter_by_asset_kind(self) -> None: self.assertEqual([self.AAS_ID], self.ids(response)) def test_get_all_filter_by_asset_kind_invalid_returns_400(self) -> None: - self.store.add(_aas_descriptor(self.AAS_ID, asset_kind=model.AssetKind.INSTANCE)) + self.store.add(example_aas_descriptor(self.AAS_ID, asset_kind=model.AssetKind.INSTANCE)) # The enum member names are upper-case; the serialized ("Instance") spelling is rejected. self.assert_error(self.format_client.get("/shell-descriptors?assetKind=Instance"), 400) def test_get_all_filter_by_asset_type(self) -> None: - self.store.add(_aas_descriptor(self.AAS_ID, asset_type="https://example.org/type/a")) - self.store.add(_aas_descriptor(self.AAS_ID_2, asset_type="https://example.org/type/b")) + self.store.add(example_aas_descriptor(self.AAS_ID, asset_type="https://example.org/type/a")) + self.store.add(example_aas_descriptor(self.AAS_ID_2, asset_type="https://example.org/type/b")) response = self.format_client.get( f"/shell-descriptors?assetType={base64url_encode('https://example.org/type/b')}" @@ -192,7 +178,7 @@ def test_get_all_filter_by_asset_type(self) -> None: self.assertEqual([self.AAS_ID_2], self.ids(response)) def test_get_all_filter_by_asset_type_no_match(self) -> None: - self.store.add(_aas_descriptor(self.AAS_ID, asset_type="https://example.org/type/a")) + self.store.add(example_aas_descriptor(self.AAS_ID, asset_type="https://example.org/type/a")) response = self.format_client.get( f"/shell-descriptors?assetType={base64url_encode('https://example.org/type/none')}" @@ -204,7 +190,7 @@ def test_get_all_filter_by_asset_type_no_match(self) -> None: # ------------------------------------------------------------------ POST /shell-descriptors def test_post_success(self) -> None: - descriptor = _aas_descriptor(self.AAS_ID) + descriptor = example_aas_descriptor(self.AAS_ID) response = self.format_client.post("/shell-descriptors", obj=descriptor) @@ -213,6 +199,17 @@ def test_post_success(self) -> None: self.assertEqual(self.AAS_ID, self.format_client.identifier(self.format_client.parse_object(response))) self.assertIsNotNone(self.store.get(self.AAS_ID)) + def test_post_including_submodel_descriptors(self) -> None: + descriptor = example_aas_descriptor(self.AAS_ID, submodel_descriptors=[example_submodel_descriptor(self.SM_ID)]) + + response = self.format_client.post("/shell-descriptors", obj=descriptor) + + self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) + retrieved_descriptor = self.store.get(self.AAS_ID) + if not isinstance(retrieved_descriptor, AssetAdministrationShellDescriptor): + self.fail("AAS Descriptor was not created in store") + self.assertGreater(len(retrieved_descriptor.submodel_descriptors), 0) + def test_post_missing_id_returns_400(self) -> None: response = self.format_client.post( "/shell-descriptors", data=b'{"endpoints": []}', content_type="application/json" @@ -228,21 +225,21 @@ def test_post_malformed_json_returns_400(self) -> None: def test_post_unsupported_content_type_returns_415(self) -> None: response = self.format_client.post( "/shell-descriptors", - data=self.format_client.serialize(_aas_descriptor(self.AAS_ID)), + data=self.format_client.serialize(example_aas_descriptor(self.AAS_ID)), content_type="text/plain", ) self.assert_error(response, 415) def test_post_conflict_returns_409(self) -> None: - self.store.add(_aas_descriptor(self.AAS_ID)) + self.store.add(example_aas_descriptor(self.AAS_ID)) - self.assert_error(self.format_client.post("/shell-descriptors", obj=_aas_descriptor(self.AAS_ID)), 409) + self.assert_error(self.format_client.post("/shell-descriptors", obj=example_aas_descriptor(self.AAS_ID)), 409) # ------------------------------------------------------------------ GET /shell-descriptors/{aasIdentifier} def test_get_by_id_success(self) -> None: - self.store.add(_aas_descriptor(self.AAS_ID)) + self.store.add(example_aas_descriptor(self.AAS_ID)) response = self.format_client.get(f"/shell-descriptors/{base64url_encode(self.AAS_ID)}") @@ -256,7 +253,7 @@ def test_get_by_id_not_found_returns_404(self) -> None: def test_put_creates_when_absent_returns_201(self) -> None: response = self.format_client.put( - f"/shell-descriptors/{base64url_encode(self.AAS_ID)}", obj=_aas_descriptor(self.AAS_ID) + f"/shell-descriptors/{base64url_encode(self.AAS_ID)}", obj=example_aas_descriptor(self.AAS_ID) ) self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) @@ -264,8 +261,8 @@ def test_put_creates_when_absent_returns_201(self) -> None: self.assertIsNotNone(self.store.get(self.AAS_ID)) def test_put_updates_when_present_returns_204(self) -> None: - self.store.add(_aas_descriptor(self.AAS_ID, id_short="Original")) - updated = _aas_descriptor(self.AAS_ID, id_short="Updated") + self.store.add(example_aas_descriptor(self.AAS_ID, id_short="Original")) + updated = example_aas_descriptor(self.AAS_ID, id_short="Updated") response = self.format_client.put(f"/shell-descriptors/{base64url_encode(self.AAS_ID)}", obj=updated) @@ -277,7 +274,7 @@ def test_put_updates_when_present_returns_204(self) -> None: # ------------------------------------------------------------------ DELETE /shell-descriptors/{aasIdentifier} def test_delete_success_returns_204(self) -> None: - self.store.add(_aas_descriptor(self.AAS_ID)) + self.store.add(example_aas_descriptor(self.AAS_ID)) response = self.format_client.delete(f"/shell-descriptors/{base64url_encode(self.AAS_ID)}") @@ -298,7 +295,7 @@ class SubmodelDescriptorsThroughSuperpathEndpointTest(RegistryEndpointTestBase): def setUp(self) -> None: super().setUp() - self.store.add(_aas_descriptor(self.AAS_ID)) + self.store.add(example_aas_descriptor(self.AAS_ID)) @property def _base(self) -> str: @@ -317,8 +314,8 @@ def test_get_all_empty(self) -> None: self.assertEqual([], self.format_client.parse_collection(response)) def test_get_all_returns_nested_descriptors(self) -> None: - self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)) - self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID_2)) + self.format_client.post(self._base, obj=example_submodel_descriptor(self.SM_ID)) + self.format_client.post(self._base, obj=example_submodel_descriptor(self.SM_ID_2)) response = self.format_client.get(self._base) @@ -326,8 +323,8 @@ def test_get_all_returns_nested_descriptors(self) -> None: self.assertEqual({self.SM_ID, self.SM_ID_2}, set(self.ids(response))) def test_get_all_supports_pagination(self) -> None: - self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)) - self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID_2)) + self.format_client.post(self._base, obj=example_submodel_descriptor(self.SM_ID)) + self.format_client.post(self._base, obj=example_submodel_descriptor(self.SM_ID_2)) pages = self.format_client.get_paginated(self._base, limit=1, max_pages=2) @@ -339,24 +336,24 @@ def test_get_all_aas_not_found_returns_404(self) -> None: # ------------------------------------------------------------------ POST def test_post_success(self) -> None: - response = self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)) + response = self.format_client.post(self._base, obj=example_submodel_descriptor(self.SM_ID)) self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) self.assertIn(base64url_encode(self.SM_ID), response.headers["Location"]) self.assertEqual([self.SM_ID], self.ids(self.format_client.get(self._base))) def test_post_conflict_returns_409(self) -> None: - self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)) + self.format_client.post(self._base, obj=example_submodel_descriptor(self.SM_ID)) - self.assert_error(self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)), 409) + self.assert_error(self.format_client.post(self._base, obj=example_submodel_descriptor(self.SM_ID)), 409) def test_post_aas_not_found_returns_404(self) -> None: - self.assert_error(self.format_client.post(self._missing_base, obj=_submodel_descriptor(self.SM_ID)), 404) + self.assert_error(self.format_client.post(self._missing_base, obj=example_submodel_descriptor(self.SM_ID)), 404) # ------------------------------------------------------------------ GET (single) def test_get_by_id_success(self) -> None: - self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)) + self.format_client.post(self._base, obj=example_submodel_descriptor(self.SM_ID)) response = self.format_client.get(f"{self._base}/{base64url_encode(self.SM_ID)}") @@ -372,8 +369,8 @@ def test_get_by_id_aas_not_found_returns_404(self) -> None: # ------------------------------------------------------------------ PUT def test_put_updates_when_present_returns_204(self) -> None: - self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID, id_short="Original")) - updated = _submodel_descriptor(self.SM_ID, id_short="Updated") + self.format_client.post(self._base, obj=example_submodel_descriptor(self.SM_ID, id_short="Original")) + updated = example_submodel_descriptor(self.SM_ID, id_short="Updated") response = self.format_client.put(f"{self._base}/{base64url_encode(self.SM_ID)}", obj=updated) @@ -385,7 +382,7 @@ def test_put_updates_when_present_returns_204(self) -> None: def test_put_creates_when_absent_returns_201(self) -> None: response = self.format_client.put( - f"{self._base}/{base64url_encode(self.SM_ID)}", obj=_submodel_descriptor(self.SM_ID) + f"{self._base}/{base64url_encode(self.SM_ID)}", obj=example_submodel_descriptor(self.SM_ID) ) self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) @@ -395,7 +392,7 @@ def test_put_creates_when_absent_returns_201(self) -> None: def test_put_aas_not_found_returns_404(self) -> None: self.assert_error( self.format_client.put( - f"{self._missing_base}/{base64url_encode(self.SM_ID)}", obj=_submodel_descriptor(self.SM_ID) + f"{self._missing_base}/{base64url_encode(self.SM_ID)}", obj=example_submodel_descriptor(self.SM_ID) ), 404, ) @@ -403,7 +400,7 @@ def test_put_aas_not_found_returns_404(self) -> None: # ------------------------------------------------------------------ DELETE def test_delete_success_returns_204(self) -> None: - self.format_client.post(self._base, obj=_submodel_descriptor(self.SM_ID)) + self.format_client.post(self._base, obj=example_submodel_descriptor(self.SM_ID)) response = self.format_client.delete(f"{self._base}/{base64url_encode(self.SM_ID)}") @@ -434,8 +431,8 @@ def test_get_all_empty(self) -> None: self.assertEqual([], self.format_client.parse_collection(response)) def test_get_all_returns_registered_descriptors(self) -> None: - self.store.add(_submodel_descriptor(self.SM_ID)) - self.store.add(_submodel_descriptor(self.SM_ID_2)) + self.store.add(example_submodel_descriptor(self.SM_ID)) + self.store.add(example_submodel_descriptor(self.SM_ID_2)) response = self.format_client.get("/submodel-descriptors") @@ -443,8 +440,8 @@ def test_get_all_returns_registered_descriptors(self) -> None: self.assertEqual({self.SM_ID, self.SM_ID_2}, set(self.ids(response))) def test_get_all_only_returns_submodel_descriptors(self) -> None: - self.store.add(_submodel_descriptor(self.SM_ID)) - self.store.add(_aas_descriptor(self.AAS_ID)) + self.store.add(example_submodel_descriptor(self.SM_ID)) + self.store.add(example_aas_descriptor(self.AAS_ID)) response = self.format_client.get("/submodel-descriptors") @@ -452,22 +449,22 @@ def test_get_all_only_returns_submodel_descriptors(self) -> None: self.assertEqual([self.SM_ID], self.ids(response)) def test_get_all_supports_pagination(self) -> None: - self.store.add(_submodel_descriptor(self.SM_ID)) - self.store.add(_submodel_descriptor(self.SM_ID_2)) + self.store.add(example_submodel_descriptor(self.SM_ID)) + self.store.add(example_submodel_descriptor(self.SM_ID_2)) pages = self.format_client.get_paginated("/submodel-descriptors", limit=1, max_pages=2) self.assertEqual([1, 1], [len(page) for page in pages]) def test_get_all_negative_limit_returns_400(self) -> None: - self.store.add(_submodel_descriptor(self.SM_ID)) + self.store.add(example_submodel_descriptor(self.SM_ID)) self.assert_error(self.format_client.get("/submodel-descriptors?limit=-1"), 400) # ------------------------------------------------------------------ POST /submodel-descriptors def test_post_success(self) -> None: - response = self.format_client.post("/submodel-descriptors", obj=_submodel_descriptor(self.SM_ID)) + response = self.format_client.post("/submodel-descriptors", obj=example_submodel_descriptor(self.SM_ID)) self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) self.assertIn(base64url_encode(self.SM_ID), response.headers["Location"]) @@ -481,14 +478,14 @@ def test_post_missing_id_returns_400(self) -> None: self.assert_error(response, 400) def test_post_conflict_returns_409(self) -> None: - self.store.add(_submodel_descriptor(self.SM_ID)) + self.store.add(example_submodel_descriptor(self.SM_ID)) - self.assert_error(self.format_client.post("/submodel-descriptors", obj=_submodel_descriptor(self.SM_ID)), 409) + self.assert_error(self.format_client.post("/submodel-descriptors", obj=example_submodel_descriptor(self.SM_ID)), 409) # ------------------------------------------------------------------ GET /submodel-descriptors/{submodelIdentifier} def test_get_by_id_success(self) -> None: - self.store.add(_submodel_descriptor(self.SM_ID)) + self.store.add(example_submodel_descriptor(self.SM_ID)) response = self.format_client.get(f"/submodel-descriptors/{base64url_encode(self.SM_ID)}") @@ -500,7 +497,7 @@ def test_get_by_id_not_found_returns_404(self) -> None: def test_get_by_id_wrong_type_returns_404(self) -> None: # A descriptor with this id exists, but it is an AAS descriptor, not a submodel descriptor. - self.store.add(_aas_descriptor(self.AAS_ID)) + self.store.add(example_aas_descriptor(self.AAS_ID)) self.assert_error(self.format_client.get(f"/submodel-descriptors/{base64url_encode(self.AAS_ID)}"), 404) @@ -508,7 +505,7 @@ def test_get_by_id_wrong_type_returns_404(self) -> None: def test_put_creates_when_absent_returns_201(self) -> None: response = self.format_client.put( - f"/submodel-descriptors/{base64url_encode(self.SM_ID)}", obj=_submodel_descriptor(self.SM_ID) + f"/submodel-descriptors/{base64url_encode(self.SM_ID)}", obj=example_submodel_descriptor(self.SM_ID) ) self.assertEqual(201, response.status_code, msg=response.get_data(as_text=True)) @@ -516,8 +513,8 @@ def test_put_creates_when_absent_returns_201(self) -> None: self.assertIsNotNone(self.store.get(self.SM_ID)) def test_put_updates_when_present_returns_204(self) -> None: - self.store.add(_submodel_descriptor(self.SM_ID, id_short="Original")) - updated = _submodel_descriptor(self.SM_ID, id_short="Updated") + self.store.add(example_submodel_descriptor(self.SM_ID, id_short="Original")) + updated = example_submodel_descriptor(self.SM_ID, id_short="Updated") response = self.format_client.put(f"/submodel-descriptors/{base64url_encode(self.SM_ID)}", obj=updated) @@ -529,7 +526,7 @@ def test_put_updates_when_present_returns_204(self) -> None: # --------------------------------------------------------------- DELETE /submodel-descriptors/{submodelIdentifier} def test_delete_success_returns_204(self) -> None: - self.store.add(_submodel_descriptor(self.SM_ID)) + self.store.add(example_submodel_descriptor(self.SM_ID)) response = self.format_client.delete(f"/submodel-descriptors/{base64url_encode(self.SM_ID)}") From e8d00259f43939b876ecdee3bd7700c31bee61f4 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Fri, 18 Sep 2026 10:53:53 +0200 Subject: [PATCH 38/59] Test serialization of `Descriptor` objects Found issues #630, #631, #632 during test creation. We comment the code out, for now, to not block the PR by these issues. The issues hold instructions to uncomment these sections. --- server/test/adapter/descriptor_utils.py | 25 ++++++++++++++----------- server/test/adapter/test_jsonization.py | 23 +++++++++++++++++------ 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/server/test/adapter/descriptor_utils.py b/server/test/adapter/descriptor_utils.py index 23063a53c..4510acd6a 100644 --- a/server/test/adapter/descriptor_utils.py +++ b/server/test/adapter/descriptor_utils.py @@ -1,6 +1,6 @@ from typing import Any -from app.model import DictDescriptorStore, SecurityAttributeObject, SecurityTypeEnum +from app.model import SecurityAttributeObject, SecurityTypeEnum from app.model.descriptor import AssetAdministrationShellDescriptor, SubmodelDescriptor from app.model.endpoint import Endpoint, ProtocolInformation from basyx.aas import model @@ -31,7 +31,7 @@ def example_aas_descriptor(id_: str, **kwargs: Any) -> AssetAdministrationShellD "display_name": model.MultiLanguageNameType( {"en-US": "Exaple display name", "de": "Beispiel Anzeigename"} ), - # TODO: Descriptor serialization fails with extensions, fix and enable test + # TODO: Descriptor deserialization fails with extensions, fix and enable test (issue #630) # "extension": [model.Extension( # name="Example Descriptor Extension", # value_type=model.datatypes.String, @@ -58,14 +58,7 @@ def example_aas_descriptor(id_: str, **kwargs: Any) -> AssetAdministrationShellD def example_submodel_descriptor(id_: str, **kwargs: Any) -> SubmodelDescriptor: descriptor_args = dict( { - # TODO: SubmodelDescriptor.__init__() does not take args of Descriptor.__init__() - # "description": model.MultiLanguageTextType( - # {"en-US": "Example description", "de": "Beispiel Description"} - # ), - # "display_name": model.MultiLanguageNameType( - # {"en-US": "Exaple display name", "de": "Beispiel Anzeigename"} - # ), - # TODO: equal to AssetAdministrationShellDescriptor + # TODO: equal to AssetAdministrationShellDescriptor (issue #630) # "extension": [model.Extension( # name="Example Descriptor Extension", # value_type=model.datatypes.String, @@ -87,4 +80,14 @@ def example_submodel_descriptor(id_: str, **kwargs: Any) -> SubmodelDescriptor: } ) descriptor_args.update(kwargs) - return SubmodelDescriptor(id_=id_, **descriptor_args) # type: ignore + descriptor = SubmodelDescriptor(id_=id_, **descriptor_args) # type: ignore + + descriptor.description = model.MultiLanguageTextType( + {"en-US": "Example description", "de": "Beispiel Description"} + ) + + descriptor.display_name = model.MultiLanguageNameType( + {"en-US": "Exaple display name", "de": "Beispiel Anzeigename"} + ) + + return descriptor diff --git a/server/test/adapter/test_jsonization.py b/server/test/adapter/test_jsonization.py index c989e5812..55a20f906 100644 --- a/server/test/adapter/test_jsonization.py +++ b/server/test/adapter/test_jsonization.py @@ -3,10 +3,11 @@ from typing import Any from app.adapter import ServerAASToJsonEncoder -from .descriptor_utils import example_submodel_descriptor, example_aas_descriptor, example_endpoint +from .descriptor_utils import example_aas_descriptor, example_submodel_descriptor -class TestDescriptorSerialization(unittest.TestCase): + +class TestRegistryObjectSerialization(unittest.TestCase): """ Tests the serialization of full :class:`~app.model.descriptor.Descriptor` objects """ @@ -19,7 +20,7 @@ def _assert_example_protocol(self, protocol: dict[str, Any]) -> None: self.assertEqual("ExampleProtocolBody", protocol["subprotocolBody"]) self.assertEqual("ExampleEncoding", protocol["subprotocolBodyEncoding"]) - # TODO: Endpoint security is currently not serialized + # TODO: Endpoint security is currently not serialized (issue #632) # self.assertIsInstance(protocol["securityAttributes"], list) # self.assertEqual(1, len(protocol["securityAttributes"])) # self.assertEqual("NONE", protocol["securityAttributes"][0]["type"]) @@ -42,7 +43,7 @@ def test_ass_descriptor_serialization(self) -> None: self.assertIsInstance(result["displayName"], list) self.assertEqual(2, len(result["displayName"])) - # TODO: enable when Descriptor serializes extensions + # TODO: enable when Descriptor serializes extensions (issue 630) # extensions attribute is list of model.Extension # self.assertIn("extensions", result) # self.assertIsInstance(result["extensions"], list) @@ -73,7 +74,17 @@ def test_submodel_descriptor_serialization(self) -> None: dump = json.dumps(sm_descriptor, cls=ServerAASToJsonEncoder) result = json.loads(dump) - # TODO: enable when Descriptor serializes extensions + self.assertEqual("http://example.org/Test_Submodel", result["id"]) + + # description is list of model.LangStringTextType (covered in basyx.aas.adpater) + self.assertIsInstance(result["description"], list) + self.assertEqual(2, len(result["description"])) + + # displayName is list of model.LangStringNameType (covered in basyx.aas.adapter) + self.assertIsInstance(result["displayName"], list) + self.assertEqual(2, len(result["displayName"])) + + # TODO: enable when Descriptor serializes extensions (issue #630) # extensions attribute is list of model.Extension # self.assertIn("extensions", result) # self.assertIsInstance(result["extensions"], list) @@ -87,7 +98,7 @@ def test_submodel_descriptor_serialization(self) -> None: # semanticId is model.Reference (covered in basyx.aas.adapter) self.assertEqual("http://example.org/SubmodelDescription/", result["semanticId"]["keys"][0]["value"]) - # TODO: SubmodelDescriptor currently uses trailing "s" + # TODO: SubmodelDescriptor currently uses trailing "s" (issue #631) # supplementalSemanticId is list of model.Reference # self.assertIsInstance(result["supplementalSemanticId"], list) # self.assertEqual( From 61faa01355235bac93d6a99f27bbbaee3cb52b21 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Fri, 18 Sep 2026 16:38:29 +0200 Subject: [PATCH 39/59] Fix: Adapt CI server-test to python 3.11 --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6adb2c0ab..aa52ff372 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -342,7 +342,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.12"] + python-version: ["3.11", "3.12"] defaults: run: working-directory: ./server From dedfc4d8b0d19de6573180a4dd69700922485192 Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Sat, 19 Sep 2026 15:59:59 +0200 Subject: [PATCH 40/59] Address review feedback on server test suite --- server/test/interfaces/test_discovery.py | 70 ++++++------ server/test/model/test_provider.py | 130 +++++++++++------------ 2 files changed, 96 insertions(+), 104 deletions(-) diff --git a/server/test/interfaces/test_discovery.py b/server/test/interfaces/test_discovery.py index b3a032a89..a50504d30 100644 --- a/server/test/interfaces/test_discovery.py +++ b/server/test/interfaces/test_discovery.py @@ -14,6 +14,7 @@ import json import os +import tempfile import unittest from typing import List, Tuple @@ -303,47 +304,46 @@ def test_delete_unknown_aas_returns_204(self) -> None: class DiscoveryStorePersistenceTest(unittest.TestCase): - STORE_FILE = os.path.join(os.path.dirname(__file__), "discovery_store_persistence_test.json") - - def tearDown(self) -> None: - for path in (self.STORE_FILE, f"{self.STORE_FILE}.tmp"): - if os.path.exists(path): - os.remove(path) - def test_roundtrip_rebuilds_reverse_index(self) -> None: - store = DiscoveryStore() - asset_1 = SpecificAssetId("globalAssetId", "urn:asset:1") - asset_2 = SpecificAssetId("serialNumber", "SN-42") - asset_3 = SpecificAssetId("globalAssetId", "urn:asset:1") # shared by both AAS - store.add_specific_asset_ids_to_aas("https://example.org/aas/1", [asset_1, asset_2]) - store.add_specific_asset_ids_to_aas("https://example.org/aas/2", [asset_3]) - store.to_file(self.STORE_FILE) - - loaded = DiscoveryStore.from_file(self.STORE_FILE) - - self.assertEqual(store.aas_id_to_asset_ids, loaded.aas_id_to_asset_ids) - self.assertEqual( - {"https://example.org/aas/1", "https://example.org/aas/2"}, - loaded.asset_id_to_aas_ids[asset_1], - ) - self.assertEqual({"https://example.org/aas/1"}, loaded.asset_id_to_aas_ids[asset_2]) + with tempfile.TemporaryDirectory() as tmp_dir: + store_file = os.path.join(tmp_dir, "discovery_store.json") + store = DiscoveryStore() + asset_1 = SpecificAssetId("globalAssetId", "urn:asset:1") + asset_2 = SpecificAssetId("serialNumber", "SN-42") + asset_3 = SpecificAssetId("globalAssetId", "urn:asset:1") # shared by both AAS + store.add_specific_asset_ids_to_aas("https://example.org/aas/1", [asset_1, asset_2]) + store.add_specific_asset_ids_to_aas("https://example.org/aas/2", [asset_3]) + store.to_file(store_file) + + loaded = DiscoveryStore.from_file(store_file) + + self.assertEqual(store.aas_id_to_asset_ids, loaded.aas_id_to_asset_ids) + self.assertEqual( + {"https://example.org/aas/1", "https://example.org/aas/2"}, + loaded.asset_id_to_aas_ids[asset_1], + ) + self.assertEqual({"https://example.org/aas/1"}, loaded.asset_id_to_aas_ids[asset_2]) def test_roundtrip_empty_store(self) -> None: - store = DiscoveryStore() - store.to_file(self.STORE_FILE) + with tempfile.TemporaryDirectory() as tmp_dir: + store_file = os.path.join(tmp_dir, "discovery_store.json") + store = DiscoveryStore() + store.to_file(store_file) - loaded = DiscoveryStore.from_file(self.STORE_FILE) + loaded = DiscoveryStore.from_file(store_file) - self.assertEqual({}, loaded.aas_id_to_asset_ids) - self.assertEqual({}, loaded.asset_id_to_aas_ids) + self.assertEqual({}, loaded.aas_id_to_asset_ids) + self.assertEqual({}, loaded.asset_id_to_aas_ids) def test_to_file_does_not_leave_temp_file_behind(self) -> None: - store = DiscoveryStore() - store.add_specific_asset_ids_to_aas( - "https://example.org/aas/1", [SpecificAssetId("globalAssetId", "urn:asset:1")] - ) + with tempfile.TemporaryDirectory() as tmp_dir: + store_file = os.path.join(tmp_dir, "discovery_store.json") + store = DiscoveryStore() + store.add_specific_asset_ids_to_aas( + "https://example.org/aas/1", [SpecificAssetId("globalAssetId", "urn:asset:1")] + ) - store.to_file(self.STORE_FILE) + store.to_file(store_file) - self.assertTrue(os.path.exists(self.STORE_FILE)) - self.assertFalse(os.path.exists(f"{self.STORE_FILE}.tmp")) + self.assertTrue(os.path.exists(store_file)) + self.assertFalse(os.path.exists(f"{store_file}.tmp")) diff --git a/server/test/model/test_provider.py b/server/test/model/test_provider.py index 041d87daf..015a67066 100644 --- a/server/test/model/test_provider.py +++ b/server/test/model/test_provider.py @@ -7,15 +7,15 @@ import json import os -import shutil +import tempfile import unittest -from typing import Any, Dict, Iterable, Union +from typing import Iterable, Union from app import adapter, model from app.backend.local_file import DESCRIPTOR_TYPE_TO_STRING from app.model.provider import DictDescriptorStore, load_directory -_Descriptor = Union[model.AssetAdministrationShellDescriptor, model.SubmodelDescriptor] +from ..adapter.descriptor_utils import example_aas_descriptor, example_submodel_descriptor class DictDescriptorStoreTest(unittest.TestCase): @@ -80,97 +80,89 @@ class LoadDirectoryTest(unittest.TestCase): Descriptor JSON files must carry a ``modelType`` field for each descriptor so that ``ServerAASFromJsonDecoder`` recognizes and converts them (descriptors aren't ``Referable``, so the SDK's encoder never writes ``modelType`` on its own -- see ``LocalFileDescriptorStore.add()``/``commit()``, - which inject it for the same reason). ``_descriptor_to_json_dict()`` below replicates that injection to - build valid fixture files. + which inject it for the same reason). ``_write_file()`` below replicates that injection to build valid + fixture files. """ - TEST_DIR = os.path.join(os.path.dirname(__file__), "load_directory_test_folder") - - def setUp(self) -> None: - os.makedirs(self.TEST_DIR, exist_ok=True) - self.mock_endpoint = model.Endpoint( - interface="AAS-3.0", protocol_information=model.ProtocolInformation(href="https://example.org/") - ) - - def tearDown(self) -> None: - shutil.rmtree(self.TEST_DIR) - @staticmethod - def _descriptor_to_json_dict(desc: _Descriptor) -> Dict[str, Any]: - data: Dict[str, Any] = json.loads(json.dumps(desc, cls=adapter.ServerAASToJsonEncoder)) - data["modelType"] = DESCRIPTOR_TYPE_TO_STRING[type(desc)] - return data - def _write_file( - self, + directory: str, filename: str, aas_descriptors: Iterable[model.AssetAdministrationShellDescriptor] = (), submodel_descriptors: Iterable[model.SubmodelDescriptor] = (), ) -> None: + def to_json_dict( + desc: Union[model.AssetAdministrationShellDescriptor, model.SubmodelDescriptor], + ) -> dict: + data = json.loads(json.dumps(desc, cls=adapter.ServerAASToJsonEncoder)) + data["modelType"] = DESCRIPTOR_TYPE_TO_STRING[type(desc)] + return data + data = { - "assetAdministrationShellDescriptors": [self._descriptor_to_json_dict(d) for d in aas_descriptors], - "submodelDescriptors": [self._descriptor_to_json_dict(d) for d in submodel_descriptors], + "assetAdministrationShellDescriptors": [to_json_dict(d) for d in aas_descriptors], + "submodelDescriptors": [to_json_dict(d) for d in submodel_descriptors], } - with open(os.path.join(self.TEST_DIR, filename), "w") as f: + with open(os.path.join(directory, filename), "w") as f: json.dump(data, f) def test_loads_descriptors_from_single_file(self) -> None: - aasd = model.AssetAdministrationShellDescriptor( - id_="https://example.org/AASDescriptor/1", endpoints=[self.mock_endpoint] - ) - sd = model.SubmodelDescriptor(id_="https://example.org/SubmodelDescriptor/1", endpoints=[self.mock_endpoint]) - self._write_file("descriptors.json", aas_descriptors=[aasd], submodel_descriptors=[sd]) + with tempfile.TemporaryDirectory() as tmp_dir: + aasd = example_aas_descriptor("https://example.org/AASDescriptor/1") + sd = example_submodel_descriptor("https://example.org/SubmodelDescriptor/1") + self._write_file(tmp_dir, "descriptors.json", aas_descriptors=[aasd], submodel_descriptors=[sd]) - store = load_directory(self.TEST_DIR) + store = load_directory(tmp_dir) - self.assertEqual(2, len(store)) - self.assertIn("https://example.org/AASDescriptor/1", store) - self.assertIn("https://example.org/SubmodelDescriptor/1", store) + self.assertEqual(2, len(store)) + self.assertIn("https://example.org/AASDescriptor/1", store) + self.assertIn("https://example.org/SubmodelDescriptor/1", store) def test_merges_descriptors_from_multiple_files(self) -> None: - aasd1 = model.AssetAdministrationShellDescriptor( - id_="https://example.org/AASDescriptor/1", endpoints=[self.mock_endpoint] - ) - aasd2 = model.AssetAdministrationShellDescriptor( - id_="https://example.org/AASDescriptor/2", endpoints=[self.mock_endpoint] - ) - self._write_file("a.json", aas_descriptors=[aasd1]) - self._write_file("b.json", aas_descriptors=[aasd2]) - - store = load_directory(self.TEST_DIR) - - self.assertEqual(2, len(store)) - self.assertIn("https://example.org/AASDescriptor/1", store) - self.assertIn("https://example.org/AASDescriptor/2", store) + with tempfile.TemporaryDirectory() as tmp_dir: + aasd1 = example_aas_descriptor("https://example.org/AASDescriptor/1") + sd1 = example_submodel_descriptor("https://example.org/SubmodelDescriptor/1") + aasd2 = example_aas_descriptor("https://example.org/AASDescriptor/2") + sd2 = example_submodel_descriptor("https://example.org/SubmodelDescriptor/2") + self._write_file(tmp_dir, "a.json", aas_descriptors=[aasd1], submodel_descriptors=[sd1]) + self._write_file(tmp_dir, "b.json", aas_descriptors=[aasd2], submodel_descriptors=[sd2]) + + store = load_directory(tmp_dir) + + self.assertEqual(4, len(store)) + self.assertIn("https://example.org/AASDescriptor/1", store) + self.assertIn("https://example.org/AASDescriptor/2", store) + self.assertIn("https://example.org/SubmodelDescriptor/1", store) + self.assertIn("https://example.org/SubmodelDescriptor/2", store) def test_duplicate_ids_across_files_are_skipped_not_raised(self) -> None: - aasd1 = model.AssetAdministrationShellDescriptor( - id_="https://example.org/AASDescriptor/1", endpoints=[self.mock_endpoint] - ) - aasd2 = model.AssetAdministrationShellDescriptor( - id_="https://example.org/AASDescriptor/1", endpoints=[self.mock_endpoint] - ) - self._write_file("a.json", aas_descriptors=[aasd1]) - self._write_file("b.json", aas_descriptors=[aasd2]) + with tempfile.TemporaryDirectory() as tmp_dir: + aasd1 = example_aas_descriptor("https://example.org/AASDescriptor/1") + aasd2 = example_aas_descriptor("https://example.org/AASDescriptor/1") + sd1 = example_submodel_descriptor("https://example.org/SubmodelDescriptor/1") + sd2 = example_submodel_descriptor("https://example.org/SubmodelDescriptor/1") + self._write_file(tmp_dir, "a.json", aas_descriptors=[aasd1], submodel_descriptors=[sd1]) + self._write_file(tmp_dir, "b.json", aas_descriptors=[aasd2], submodel_descriptors=[sd2]) - store = load_directory(self.TEST_DIR) # must not raise despite the duplicate id + store = load_directory(tmp_dir) # must not raise despite the duplicate ids - self.assertEqual(1, len(store)) - self.assertIn("https://example.org/AASDescriptor/1", store) + self.assertEqual(2, len(store)) + self.assertIn("https://example.org/AASDescriptor/1", store) + self.assertIn("https://example.org/SubmodelDescriptor/1", store) def test_ignores_non_json_files(self) -> None: - with open(os.path.join(self.TEST_DIR, "readme.txt"), "w") as f: - f.write("not a descriptor file") - aasd = model.AssetAdministrationShellDescriptor( - id_="https://example.org/AASDescriptor/1", endpoints=[self.mock_endpoint] - ) - self._write_file("a.json", aas_descriptors=[aasd]) + with tempfile.TemporaryDirectory() as tmp_dir: + with open(os.path.join(tmp_dir, "readme.txt"), "w") as f: + f.write("not a descriptor file") + aasd = example_aas_descriptor("https://example.org/AASDescriptor/1") + sd = example_submodel_descriptor("https://example.org/SubmodelDescriptor/1") + self._write_file(tmp_dir, "a.json", aas_descriptors=[aasd], submodel_descriptors=[sd]) - store = load_directory(self.TEST_DIR) + store = load_directory(tmp_dir) - self.assertEqual(1, len(store)) + self.assertEqual(2, len(store)) def test_empty_directory_returns_empty_store(self) -> None: - store = load_directory(self.TEST_DIR) + with tempfile.TemporaryDirectory() as tmp_dir: + store = load_directory(tmp_dir) - self.assertEqual(0, len(store)) + self.assertEqual(0, len(store)) From 34a420b6ad2bd54ff67803ac827510df4aad4028 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Fri, 18 Sep 2026 16:44:57 +0200 Subject: [PATCH 41/59] Add missing copyright headers --- server/test/_helper/test_helpers.py | 7 +++++++ server/test/adapter/descriptor_utils.py | 7 +++++++ server/test/adapter/test_jsonization.py | 7 +++++++ .../test_docker_integration_discovery.py | 7 +++++++ .../docker_integration/test_docker_integration_registry.py | 7 +++++++ .../test_docker_integration_repository.py | 7 +++++++ server/test/interfaces/format_utils.py | 7 +++++++ server/test/interfaces/repository/helpers.py | 7 +++++++ .../test/interfaces/repository/test_concept_description.py | 7 +++++++ server/test/interfaces/repository/test_shells.py | 7 +++++++ server/test/interfaces/repository/test_submodels.py | 7 +++++++ server/test/interfaces/test_base.py | 7 +++++++ server/test/interfaces/test_discovery.py | 7 +++++++ server/test/interfaces/test_registry.py | 7 +++++++ 14 files changed, 98 insertions(+) diff --git a/server/test/_helper/test_helpers.py b/server/test/_helper/test_helpers.py index 89b2444e9..409174266 100644 --- a/server/test/_helper/test_helpers.py +++ b/server/test/_helper/test_helpers.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + import configparser import os import os.path diff --git a/server/test/adapter/descriptor_utils.py b/server/test/adapter/descriptor_utils.py index 4510acd6a..a54b187a0 100644 --- a/server/test/adapter/descriptor_utils.py +++ b/server/test/adapter/descriptor_utils.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + from typing import Any from app.model import SecurityAttributeObject, SecurityTypeEnum diff --git a/server/test/adapter/test_jsonization.py b/server/test/adapter/test_jsonization.py index 55a20f906..802ca0b37 100644 --- a/server/test/adapter/test_jsonization.py +++ b/server/test/adapter/test_jsonization.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + import json import unittest from typing import Any diff --git a/server/test/docker_integration/test_docker_integration_discovery.py b/server/test/docker_integration/test_docker_integration_discovery.py index dcea34d1e..3adacbdb5 100644 --- a/server/test/docker_integration/test_docker_integration_discovery.py +++ b/server/test/docker_integration/test_docker_integration_discovery.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + import json import unittest import urllib.error diff --git a/server/test/docker_integration/test_docker_integration_registry.py b/server/test/docker_integration/test_docker_integration_registry.py index 2b61d4856..e8ea6915a 100644 --- a/server/test/docker_integration/test_docker_integration_registry.py +++ b/server/test/docker_integration/test_docker_integration_registry.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + import json import unittest import urllib.error diff --git a/server/test/docker_integration/test_docker_integration_repository.py b/server/test/docker_integration/test_docker_integration_repository.py index 4483d22d5..3f4561a29 100644 --- a/server/test/docker_integration/test_docker_integration_repository.py +++ b/server/test/docker_integration/test_docker_integration_repository.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + import json import unittest import urllib.error diff --git a/server/test/interfaces/format_utils.py b/server/test/interfaces/format_utils.py index 2f169613c..85b9a7604 100644 --- a/server/test/interfaces/format_utils.py +++ b/server/test/interfaces/format_utils.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + import abc import json from typing import Any, Callable, Optional diff --git a/server/test/interfaces/repository/helpers.py b/server/test/interfaces/repository/helpers.py index 0ebcae2a0..4dd413456 100644 --- a/server/test/interfaces/repository/helpers.py +++ b/server/test/interfaces/repository/helpers.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + import unittest from typing import TypeVar from unittest import mock diff --git a/server/test/interfaces/repository/test_concept_description.py b/server/test/interfaces/repository/test_concept_description.py index e04234c87..8523ad86d 100644 --- a/server/test/interfaces/repository/test_concept_description.py +++ b/server/test/interfaces/repository/test_concept_description.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + from app.util.converters import base64url_encode from basyx.aas import model from basyx.aas.examples.data.example_aas_missing_attributes import ( diff --git a/server/test/interfaces/repository/test_shells.py b/server/test/interfaces/repository/test_shells.py index 081047503..06be108f8 100644 --- a/server/test/interfaces/repository/test_shells.py +++ b/server/test/interfaces/repository/test_shells.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + import base64 import json from typing import Iterable diff --git a/server/test/interfaces/repository/test_submodels.py b/server/test/interfaces/repository/test_submodels.py index 0340019f0..5bc397c42 100644 --- a/server/test/interfaces/repository/test_submodels.py +++ b/server/test/interfaces/repository/test_submodels.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + import io import json from unittest import mock diff --git a/server/test/interfaces/test_base.py b/server/test/interfaces/test_base.py index fc695911e..af93ab9bb 100644 --- a/server/test/interfaces/test_base.py +++ b/server/test/interfaces/test_base.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + import datetime import json import re diff --git a/server/test/interfaces/test_discovery.py b/server/test/interfaces/test_discovery.py index a50504d30..077a76b70 100644 --- a/server/test/interfaces/test_discovery.py +++ b/server/test/interfaces/test_discovery.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + """ Endpoint tests for :class:`~app.interfaces.discovery.DiscoveryAPI`. diff --git a/server/test/interfaces/test_registry.py b/server/test/interfaces/test_registry.py index 38e2b08ed..fa3382074 100644 --- a/server/test/interfaces/test_registry.py +++ b/server/test/interfaces/test_registry.py @@ -1,3 +1,10 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + """ Endpoint tests for :class:`~app.interfaces.registry.RegistryAPI`. From aeaa5063ff214012529c4fcc25d0f83930189a6b Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sat, 19 Sep 2026 21:12:24 +0200 Subject: [PATCH 42/59] Test `run_repository.py` loads files according to env variables --- server/test/services/__init__.py | 0 server/test/services/support.py | 61 +++++++ server/test/services/test_run_repository.py | 169 ++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 server/test/services/__init__.py create mode 100644 server/test/services/support.py create mode 100644 server/test/services/test_run_repository.py diff --git a/server/test/services/__init__.py b/server/test/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/test/services/support.py b/server/test/services/support.py new file mode 100644 index 000000000..a2f4cd169 --- /dev/null +++ b/server/test/services/support.py @@ -0,0 +1,61 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT +""" +Shared fixture builders for testing the server's ``run_*`` start-up scripts. +""" + +import importlib +import os +from contextlib import contextmanager +from types import ModuleType +from typing import Iterator, Mapping, Set +from unittest import mock + +from basyx.aas.adapter.json import write_aas_json_file +from basyx.aas.adapter.xml import write_aas_xml_file +from basyx.aas.examples.data.example_aas_missing_attributes import ( + create_example_asset_administration_shell, + create_example_submodel, +) +from basyx.aas.model import Identifier +from basyx.aas.model.provider import DictIdentifiableStore + + +def write_repository_input(directory: str) -> Set[Identifier]: + """ + Write one shell (JSON) and one submodel (XML) into ``directory``, exercising both loaders + used by :func:`~basyx.aas.adapter.load_directory`. + + :return: The set of ids that were written + """ + shell = create_example_asset_administration_shell() + submodel = create_example_submodel() + + shell_store: DictIdentifiableStore = DictIdentifiableStore() + shell_store.add(shell) + write_aas_json_file(os.path.join(directory, "data.json"), shell_store) + + submodel_store: DictIdentifiableStore = DictIdentifiableStore() + submodel_store.add(submodel) + write_aas_xml_file(os.path.join(directory, "data.xml"), submodel_store) + + return {shell.id, submodel.id} + + +@contextmanager +def run_module(module_name: str, env: Mapping[str, str]) -> Iterator[ModuleType]: + """ + Helper to import (and thereby run) the `app.services.run_*.py` files under different environments. + + :param module_name: Fully-qualified name of the module to reload, e.g. ``"app.services.run_repository"`` + :param env: Environment variables to apply for the duration of the ``with`` block + :return: The imported module + """ + module = importlib.import_module(module_name) + with mock.patch.dict(os.environ, env, clear=False): + importlib.reload(module) + yield module diff --git a/server/test/services/test_run_repository.py b/server/test/services/test_run_repository.py new file mode 100644 index 000000000..1b8352b7f --- /dev/null +++ b/server/test/services/test_run_repository.py @@ -0,0 +1,169 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT +import os +import tempfile +import unittest +from unittest import mock + +from basyx.aas.adapter.aasx import DictSupplementaryFileContainer +from basyx.aas.backend.local_file import LocalFileIdentifiableStore +from basyx.aas.examples.data.example_aas_missing_attributes import create_example_asset_administration_shell +from basyx.aas.model import AssetAdministrationShell +from basyx.aas.model.provider import DictIdentifiableStore + +from .support import run_module, write_repository_input + +DEFAULT_ENV = dict({ + "STORAGE_PERSISTENCY": "false" +}) + +class EntrypointTest(unittest.TestCase): + def test_loads_input_directory(self) -> None: + with tempfile.TemporaryDirectory() as input_dir: + expected_ids = write_repository_input(input_dir) + + env = dict(DEFAULT_ENV) + env["INPUT"] = input_dir + with run_module("app.services.run_repository", env) as repo_run: + storage = repo_run.application.object_store + self.assertIsInstance(storage, DictIdentifiableStore) + self.assertEqual(expected_ids, {identifiable.id for identifiable in storage}) + + self.assertIsInstance(repo_run.application.file_store, DictSupplementaryFileContainer) + + def test_missing_input_directory(self) -> None: + with tempfile.TemporaryDirectory() as input_dir: + env = dict(DEFAULT_ENV) + env["INPUT"] = os.path.join(input_dir, "non_existent") + with self.assertLogs(level="WARNING") as logs, run_module("app.services.run_repository", env) as repo_run: + storage = repo_run.application.object_store + self.assertIsInstance(storage, DictIdentifiableStore) + self.assertEqual(0, len(storage)) + + self.assertTrue(any("non_existent\" not found" in message for message in logs.output)) + + def test_empty_input_directory(self) -> None: + with tempfile.TemporaryDirectory() as input_dir: + env = dict(DEFAULT_ENV) + env["INPUT"] = input_dir + with run_module("app.services.run_repository", env) as repo_run: + storage = repo_run.application.object_store + self.assertIsInstance(storage, DictIdentifiableStore) + self.assertEqual(0, len(storage)) + + def test_defaults(self) -> None: + with run_module("app.services.run_repository", DEFAULT_ENV) as mod: + self.assertEqual("/input", mod.env_input) + self.assertEqual("/storage", mod.env_storage) + self.assertIsInstance(mod.storage_files, DictIdentifiableStore) + + def test_first_run_creates_storage_directory(self) -> None: + with tempfile.TemporaryDirectory() as input_dir, tempfile.TemporaryDirectory() as tmp_dir: + expected_ids = write_repository_input(input_dir) + storage_dir = os.path.join(tmp_dir, "storage") + env = dict({ + "INPUT": input_dir, + "STORAGE": storage_dir, + "STORAGE_PERSISTENCY": "true", + "STORAGE_OVERWRITE": "false" + }) + with run_module("app.services.run_repository", env) as repo_run: + storage = repo_run.application.object_store + + self.assertTrue(os.path.isdir(storage_dir)) + if not isinstance(storage, LocalFileIdentifiableStore): + self.fail("Storage is not persistent!") + self.assertEqual(expected_ids, {identifiable.id for identifiable in storage}) + + def test_no_overwrite(self) -> None: + with tempfile.TemporaryDirectory() as input_dir, tempfile.TemporaryDirectory() as tmp_dir: + expected_ids = write_repository_input(input_dir) + storage_dir = os.path.join(tmp_dir, "storage") + + # Add altered AAS to storage directory + local_storage = LocalFileIdentifiableStore(storage_dir) + local_storage.check_directory(create=True) + aas = create_example_asset_administration_shell() + aas.asset_information.asset_type = "http://example.org/ChangedAssetType" + local_storage.add(aas) + del local_storage + + env = dict({ + "INPUT": input_dir, + "STORAGE": storage_dir, + "STORAGE_PERSISTENCY": "true", + "STORAGE_OVERWRITE": "false" + }) + with run_module("app.services.run_repository", env) as repo_run: + storage = repo_run.application.object_store + + self.assertTrue(os.path.isdir(storage_dir)) + if not isinstance(storage, LocalFileIdentifiableStore): + self.fail("Storage is not persistent!") + self.assertEqual(expected_ids, {identifiable.id for identifiable in storage}) + # Assert altered AAS is not overwritten + retrieved_aas = storage.get(aas.id) + if not isinstance(retrieved_aas, AssetAdministrationShell): + self.fail("No AAS in synced storage!") + self.assertEqual(aas.asset_information.asset_type, retrieved_aas.asset_information.asset_type) + + + def test_overwrite(self) -> None: + with tempfile.TemporaryDirectory() as input_dir, tempfile.TemporaryDirectory() as tmp_dir: + expected_ids = write_repository_input(input_dir) + storage_dir = os.path.join(tmp_dir, "storage") + + # Add altered AAS to storage directory + local_storage = LocalFileIdentifiableStore(storage_dir) + local_storage.check_directory(create=True) + aas = create_example_asset_administration_shell() + aas.asset_information.asset_type = "http://example.org/ChangedAssetType" + local_storage.add(aas) + del local_storage + + env = dict({ + "INPUT": input_dir, + "STORAGE": storage_dir, + "STORAGE_PERSISTENCY": "true", + "STORAGE_OVERWRITE": "true" + }) + with run_module("app.services.run_repository", env) as repo_run: + storage = repo_run.application.object_store + + self.assertTrue(os.path.isdir(storage_dir)) + if not isinstance(storage, LocalFileIdentifiableStore): + self.fail("Storage is not persistent!") + self.assertEqual(expected_ids, {identifiable.id for identifiable in storage}) + # Assert AAS was overwritten + retrieved_aas = storage.get(aas.id) + if not isinstance(retrieved_aas, AssetAdministrationShell): + self.fail("No AAS in synced storage!") + self.assertNotEqual(aas.asset_information.asset_type, retrieved_aas.asset_information.asset_type) + + def test_base_path(self) -> None: + env = dict({ + **DEFAULT_ENV, + "API_BASE_PATH": "/custom-path" + }) + with mock.patch("app.interfaces.repository.WSGIApp", autospec=True) as app_mock: + with run_module("app.services.run_repository", env) as repo_run: + app_mock.assert_called_with(mock.ANY, mock.ANY, base_path="/custom-path") + + def test_persistency_truthy_values_use_local_file_store(self) -> None: + for value in ("1", "true", "yes", "True", "YES"): + with self.subTest(value=value): + with tempfile.TemporaryDirectory() as storage_dir: + env = {"STORAGE_PERSISTENCY": value, "STORAGE": storage_dir} + with run_module("app.services.run_repository", env) as mod: + self.assertIsInstance(mod.storage_files, LocalFileIdentifiableStore) + + def test_persistency_falsy_or_unset_uses_dict_store(self) -> None: + for value in (None, "false", "0", "no"): + with self.subTest(value=value): + env = {} if value is None else {"STORAGE_PERSISTENCY": value} + with run_module("app.services.run_repository", env) as mod: + self.assertIsInstance(mod.storage_files, DictIdentifiableStore) From 4105cce013a369d98f808f52adfc96a0e9f8b78a Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sun, 20 Sep 2026 13:15:37 +0200 Subject: [PATCH 43/59] Test `run_registry.py` loads and overwrites files correctly --- server/test/services/support.py | 30 ++++ server/test/services/test_run_registry.py | 168 ++++++++++++++++++++ server/test/services/test_run_repository.py | 14 +- 3 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 server/test/services/test_run_registry.py diff --git a/server/test/services/support.py b/server/test/services/support.py index a2f4cd169..ce1e3f4b9 100644 --- a/server/test/services/support.py +++ b/server/test/services/support.py @@ -9,12 +9,14 @@ """ import importlib +import json import os from contextlib import contextmanager from types import ModuleType from typing import Iterator, Mapping, Set from unittest import mock +from app.adapter import ServerAASToJsonEncoder from basyx.aas.adapter.json import write_aas_json_file from basyx.aas.adapter.xml import write_aas_xml_file from basyx.aas.examples.data.example_aas_missing_attributes import ( @@ -24,6 +26,8 @@ from basyx.aas.model import Identifier from basyx.aas.model.provider import DictIdentifiableStore +from ..adapter.descriptor_utils import example_aas_descriptor, example_submodel_descriptor + def write_repository_input(directory: str) -> Set[Identifier]: """ @@ -45,6 +49,32 @@ def write_repository_input(directory: str) -> Set[Identifier]: return {shell.id, submodel.id} +def write_registry_input(directory: str) -> Set[Identifier]: + """ + Write one shell descriptor and one submodel descriptor into ``directory``. + + :return: The set of ids that were written + """ + aasd = example_aas_descriptor("https://example.org/AASDescriptor") + sd = example_submodel_descriptor("https://example.org/SubmodelDescriptor") + + data = { + "assetAdministrationShellDescriptors": [aasd], + "submodelDescriptors": [sd] + } + + # Hack in the "modelType" to deserialize into the right classes + json_save = json.loads(json.dumps(data, cls=ServerAASToJsonEncoder)) + for aas in json_save["assetAdministrationShellDescriptors"]: + aas["modelType"] = "AssetAdministrationShellDescriptor" + for aas in json_save["submodelDescriptors"]: + aas["modelType"] = "SubmodelDescriptor" + + with open(os.path.join(directory, "data.json"), "w") as f: + json.dump(json_save, f) + + return {aasd.id, sd.id} + @contextmanager def run_module(module_name: str, env: Mapping[str, str]) -> Iterator[ModuleType]: diff --git a/server/test/services/test_run_registry.py b/server/test/services/test_run_registry.py new file mode 100644 index 000000000..696560995 --- /dev/null +++ b/server/test/services/test_run_registry.py @@ -0,0 +1,168 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + +import os +import tempfile +import unittest +from unittest import mock + +from app.backend import LocalFileDescriptorStore +from app.model import AssetAdministrationShellDescriptor, DictDescriptorStore + +from ..adapter.descriptor_utils import example_aas_descriptor +from .support import run_module, write_registry_input + +DEFAULT_ENV = dict({ + "STORAGE_PERSISTENCY": "false" +}) + +class RepositoryEntrypointTest(unittest.TestCase): + + def test_loads_input_directory(self) -> None: + with tempfile.TemporaryDirectory() as input_dir: + expected_ids = write_registry_input(input_dir) + + env = dict(DEFAULT_ENV) + env["INPUT"] = input_dir + with run_module("app.services.run_registry", env) as registry_run: + storage = registry_run.application.object_store + self.assertIsInstance(storage, DictDescriptorStore) + self.assertEqual(expected_ids, {identifiable.id for identifiable in storage}) + + def test_missing_input_directory(self) -> None: + with (tempfile.TemporaryDirectory() as input_dir): + env = dict(DEFAULT_ENV) + env["INPUT"] = os.path.join(input_dir, "non_existent") + with self.assertLogs("app.services.run_registry", level="WARNING") as logs: + with run_module("app.services.run_registry", env) as registry_run: + storage = registry_run.application.object_store + self.assertIsInstance(storage, DictDescriptorStore) + self.assertEqual(0, len(storage)) + + self.assertTrue(any('non_existent" not found' in message for message in logs.output)) + + def test_empty_input_directory(self) -> None: + with tempfile.TemporaryDirectory() as input_dir: + env = dict(DEFAULT_ENV) + env["INPUT"] = input_dir + with run_module("app.services.run_registry", env) as registry_run: + storage = registry_run.application.object_store + self.assertIsInstance(storage, DictDescriptorStore) + self.assertEqual(0, len(storage)) + + def test_defaults(self) -> None: + with run_module("app.services.run_registry", DEFAULT_ENV) as mod: + self.assertEqual("/input", mod.env_input) + self.assertEqual("/storage", mod.env_storage) + self.assertIsInstance(mod.storage_files, DictDescriptorStore) + + def test_first_run_creates_storage_directory(self) -> None: + with tempfile.TemporaryDirectory() as input_dir, tempfile.TemporaryDirectory() as tmp_dir: + expected_ids = write_registry_input(input_dir) + storage_dir = os.path.join(tmp_dir, "storage") + env = dict({ + "INPUT": input_dir, + "STORAGE": storage_dir, + "STORAGE_PERSISTENCY": "true", + "STORAGE_OVERWRITE": "false" + }) + with run_module("app.services.run_registry", env) as registry_run: + storage = registry_run.application.object_store + + self.assertTrue(os.path.isdir(storage_dir)) + if not isinstance(storage, LocalFileDescriptorStore): + self.fail("Storage is not persistent!") + self.assertEqual(expected_ids, {identifiable.id for identifiable in storage}) + + def test_no_overwrite(self) -> None: + with tempfile.TemporaryDirectory() as input_dir, tempfile.TemporaryDirectory() as tmp_dir: + expected_ids = write_registry_input(input_dir) + storage_dir = os.path.join(tmp_dir, "storage") + + # Add altered AAS descriptor to storage directory + local_storage = LocalFileDescriptorStore(storage_dir) + local_storage.check_directory(create=True) + aasd = example_aas_descriptor("https://example.org/AASDescriptor") + aasd.asset_type = "http://example.org/ChangedAssetType" + local_storage.add(aasd) + del local_storage + + env = dict({ + "INPUT": input_dir, + "STORAGE": storage_dir, + "STORAGE_PERSISTENCY": "true", + "STORAGE_OVERWRITE": "false" + }) + with run_module("app.services.run_registry", env) as registry_run: + storage = registry_run.application.object_store + + self.assertTrue(os.path.isdir(storage_dir)) + if not isinstance(storage, LocalFileDescriptorStore): + self.fail("Storage is not persistent!") + self.assertEqual(expected_ids, {identifiable.id for identifiable in storage}) + # Assert altered AAS descriptor is not overwritten + retrieved_aasd = storage.get(aasd.id) + if not isinstance(retrieved_aasd, AssetAdministrationShellDescriptor): + self.fail("No AAS descriptor in synced storage!") + self.assertEqual(aasd.asset_type, retrieved_aasd.asset_type) + + + def test_overwrite(self) -> None: + with tempfile.TemporaryDirectory() as input_dir, tempfile.TemporaryDirectory() as tmp_dir: + expected_ids = write_registry_input(input_dir) + storage_dir = os.path.join(tmp_dir, "storage") + + #Add altered AAS descriptor to storage directory + local_storage = LocalFileDescriptorStore(storage_dir) + local_storage.check_directory(create=True) + aasd = example_aas_descriptor("https://example.org/AASDescriptor") + aasd.asset_type = "http://example.org/ChangedAssetType" + local_storage.add(aasd) + del local_storage + + env = dict({ + "INPUT": input_dir, + "STORAGE": storage_dir, + "STORAGE_PERSISTENCY": "true", + "STORAGE_OVERWRITE": "true" + }) + with run_module("app.services.run_registry", env) as registry_run: + storage = registry_run.application.object_store + + self.assertTrue(os.path.isdir(storage_dir)) + if not isinstance(storage, LocalFileDescriptorStore): + self.fail("Storage is not persistent!") + self.assertEqual(expected_ids, {identifiable.id for identifiable in storage}) + # Assert altered AAS descriptor was overwritten + retrieved_aasd = storage.get(aasd.id) + if not isinstance(retrieved_aasd, AssetAdministrationShellDescriptor): + self.fail("No AAS descriptor in synced storage!") + self.assertNotEqual(aasd.asset_type, retrieved_aasd.asset_type) + + def test_base_path(self) -> None: + env = dict({ + **DEFAULT_ENV, + "API_BASE_PATH": "/custom-path" + }) + with mock.patch("app.interfaces.registry.RegistryAPI", autospec=True) as app_mock: + with run_module("app.services.run_registry", env): + app_mock.assert_called_with(mock.ANY, base_path="/custom-path") + + def test_persistency_truthy_values_use_local_file_store(self) -> None: + for value in ("1", "true", "yes", "True", "YES"): + with self.subTest(value=value): + with tempfile.TemporaryDirectory() as storage_dir: + env = {"STORAGE_PERSISTENCY": value, "STORAGE": storage_dir} + with run_module("app.services.run_registry", env) as mod: + self.assertIsInstance(mod.storage_files, LocalFileDescriptorStore) + + def test_persistency_falsy_or_unset_uses_dict_store(self) -> None: + for value in (None, "false", "0", "no"): + with self.subTest(value=value): + env = {} if value is None else {"STORAGE_PERSISTENCY": value} + with run_module("app.services.run_registry", env) as mod: + self.assertIsInstance(mod.storage_files, DictDescriptorStore) diff --git a/server/test/services/test_run_repository.py b/server/test/services/test_run_repository.py index 1b8352b7f..c715cd85f 100644 --- a/server/test/services/test_run_repository.py +++ b/server/test/services/test_run_repository.py @@ -4,6 +4,7 @@ # the LICENSE file of this project. # # SPDX-License-Identifier: MIT + import os import tempfile import unittest @@ -21,7 +22,7 @@ "STORAGE_PERSISTENCY": "false" }) -class EntrypointTest(unittest.TestCase): +class RepositoryEntrypointTest(unittest.TestCase): def test_loads_input_directory(self) -> None: with tempfile.TemporaryDirectory() as input_dir: expected_ids = write_repository_input(input_dir) @@ -39,10 +40,11 @@ def test_missing_input_directory(self) -> None: with tempfile.TemporaryDirectory() as input_dir: env = dict(DEFAULT_ENV) env["INPUT"] = os.path.join(input_dir, "non_existent") - with self.assertLogs(level="WARNING") as logs, run_module("app.services.run_repository", env) as repo_run: - storage = repo_run.application.object_store - self.assertIsInstance(storage, DictIdentifiableStore) - self.assertEqual(0, len(storage)) + with self.assertLogs("app.services.run_repository", level="WARNING") as logs: + with run_module("app.services.run_repository", env) as repo_run: + storage = repo_run.application.object_store + self.assertIsInstance(storage, DictIdentifiableStore) + self.assertEqual(0, len(storage)) self.assertTrue(any("non_existent\" not found" in message for message in logs.output)) @@ -150,7 +152,7 @@ def test_base_path(self) -> None: "API_BASE_PATH": "/custom-path" }) with mock.patch("app.interfaces.repository.WSGIApp", autospec=True) as app_mock: - with run_module("app.services.run_repository", env) as repo_run: + with run_module("app.services.run_repository", env): app_mock.assert_called_with(mock.ANY, mock.ANY, base_path="/custom-path") def test_persistency_truthy_values_use_local_file_store(self) -> None: From e138bd9a9594224893bc6df3656a5b301ac8dc4d Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Mon, 21 Sep 2026 13:58:13 +0200 Subject: [PATCH 44/59] Test loads file on run and saves on exit --- server/test/services/test_run_discovery.py | 93 ++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 server/test/services/test_run_discovery.py diff --git a/server/test/services/test_run_discovery.py b/server/test/services/test_run_discovery.py new file mode 100644 index 000000000..ba14e0154 --- /dev/null +++ b/server/test/services/test_run_discovery.py @@ -0,0 +1,93 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + +import os +import tempfile +import unittest +from contextlib import contextmanager + +from app.interfaces.discovery import DiscoveryStore +from basyx.aas.model import SpecificAssetId + +from .support import run_module + + +@contextmanager +def _run_discovery(env): + """ + Wraps :func:`~.support.run_module` for ``app.services.run_discovery``. + + That module registers a ``persist_store`` atexit hook on every (re)load, including the very first, unpatched + import that ``run_module()`` itself performs. Since ``persist_store()`` reads ``storage_path``/``discovery_store`` + from the module's globals at call time rather than binding them when defined, resetting ``storage_path`` once + this ``with`` block ends keeps any such leftover hook a no-op at real interpreter shutdown, instead of it trying + to write into a temp directory the test has already cleaned up. + """ + with run_module("app.services.run_discovery", env) as discovery_run: + try: + yield discovery_run + finally: + setattr(discovery_run, "storage_path", None) + + +class DiscoveryEntrypointTest(unittest.TestCase): + def test_no_storage_path_starts_with_empty_in_memory_store(self) -> None: + with _run_discovery({}) as discovery_run: + store = discovery_run.application.persistent_store + self.assertIsInstance(store, DiscoveryStore) + self.assertEqual({}, store.aas_id_to_asset_ids) + + def test_loads_existing_storage_file_on_run(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + storage_path = os.path.join(tmp_dir, "discovery_store.json") + store = DiscoveryStore() + store.add_specific_asset_ids_to_aas( + "https://example.org/aas/1", [SpecificAssetId("globalAssetId", "urn:asset:1")] + ) + store.to_file(storage_path) + + with _run_discovery({"storage_path": storage_path}) as discovery_run: + loaded = discovery_run.application.persistent_store + self.assertEqual(store.aas_id_to_asset_ids, loaded.aas_id_to_asset_ids) + + def test_creates_storage_file_and_missing_directories_if_absent(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + storage_path = os.path.join(tmp_dir, "nested", "discovery_store.json") + + with _run_discovery({"storage_path": storage_path}) as discovery_run: + store = discovery_run.application.persistent_store + self.assertIsInstance(store, DiscoveryStore) + self.assertEqual({}, store.aas_id_to_asset_ids) + + self.assertTrue(os.path.exists(storage_path)) + self.assertEqual({}, DiscoveryStore.from_file(storage_path).aas_id_to_asset_ids) + + def test_persists_store_to_file_on_exit(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + storage_path = os.path.join(tmp_dir, "discovery_store.json") + + with _run_discovery({"storage_path": storage_path}) as discovery_run: + discovery_run.discovery_store.add_specific_asset_ids_to_aas( + "https://example.org/aas/1", [SpecificAssetId("globalAssetId", "urn:asset:1")] + ) + + # Simulate the atexit hook firing on shutdown instead of actually exiting the interpreter. + discovery_run.persist_store() + + reloaded = DiscoveryStore.from_file(storage_path) + self.assertEqual( + {"https://example.org/aas/1": {SpecificAssetId("globalAssetId", "urn:asset:1")}}, + reloaded.aas_id_to_asset_ids, + ) + + def test_persist_store_without_storage_path_is_a_no_op(self) -> None: + with _run_discovery({}) as discovery_run: + discovery_run.discovery_store.add_specific_asset_ids_to_aas( + "https://example.org/aas/1", [SpecificAssetId("globalAssetId", "urn:asset:1")] + ) + + discovery_run.persist_store() # must not raise despite the missing storage_path From 7a791f3aef2147d4665bf70611ebdb0effa2cefa Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sun, 20 Sep 2026 13:17:31 +0200 Subject: [PATCH 45/59] Fix: Rename test class for `run_registry.py` --- server/test/services/test_run_registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/test/services/test_run_registry.py b/server/test/services/test_run_registry.py index 696560995..e46f133c8 100644 --- a/server/test/services/test_run_registry.py +++ b/server/test/services/test_run_registry.py @@ -20,7 +20,7 @@ "STORAGE_PERSISTENCY": "false" }) -class RepositoryEntrypointTest(unittest.TestCase): +class RegistryEntrypointTest(unittest.TestCase): def test_loads_input_directory(self) -> None: with tempfile.TemporaryDirectory() as input_dir: From c8071886935fadba2a5934795150aba40bdd7426 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Tue, 22 Sep 2026 19:16:20 +0200 Subject: [PATCH 46/59] Update the `CONTRIBUTING.md` to run server tests --- CONTRIBUTING.md | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e6c103cc..ac9452298 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -180,21 +180,41 @@ Note that there are more checks that run in the CI once you open a Pull Request. If you want to run the additional checks, please refer to the [CI definition](./.github/workflows/pr.yml). ### Testing the Server -Currently, the automated server tests are still under development. -To test that the server is working, we expect to at least be able to build the docker images and run a container -of it without error. +For testing the server locally, you need to install the required tools. To do so, run the following command in the +`/server` directory (relative to the repository root). -For that, you need to have Docker installed on your system. -In the directory with the `Dockerfile`: +```bash +pip install .[dev] +``` + +Afterward, you are ready to run the checks locally: ```bash ruff check -docker build -t basyx-python-server . -docker run --name basyx-python-server basyx-python-server +mypy app test +python -m unittest +coverage run --source app --branch -m unittest +coverage report -m +``` + +We aim to cover our code with tests by at least 80%. + +Additionally, you should check that the Docker image builds succeed and the containers start without error. +For that, you need to have Docker installed on your system. +Run the following commands from the repository root: +```bash +docker build -t basyx-python-repository -f server/docker/repository/Dockerfile . +docker run --name basyx-python-repository basyx-python-repository ``` Wait until you see the line: ``` INFO success: quit_on_failure entered RUNNING state ``` +Repeat this for all three server profiles `repository`, `registry` and `discovery`, by swapping all occurrences of +`repository` in the commands above. + +This should help you sort out the most important bugs in your code. +Note that there are more checks that run in the CI once you open a Pull Request. +If you want to run the additional checks, please refer to the [CI definition](./.github/workflows/pr.yml). ### Testing the Compliance Tool For the Compliance Tool, you can install the required tools like this (from the `./compliance_tool` directory): From 25d6bfdad806489e0f2f782e043a8324b0c4c618 Mon Sep 17 00:00:00 2001 From: Joshua Benning Date: Fri, 25 Sep 2026 12:04:56 +0200 Subject: [PATCH 47/59] Resolve line length violations --- .../test_docker_integration_discovery.py | 5 +++-- .../test_docker_integration_registry.py | 5 +++-- server/test/interfaces/test_registry.py | 8 +++++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/server/test/docker_integration/test_docker_integration_discovery.py b/server/test/docker_integration/test_docker_integration_discovery.py index 3adacbdb5..d15f65b8b 100644 --- a/server/test/docker_integration/test_docker_integration_discovery.py +++ b/server/test/docker_integration/test_docker_integration_discovery.py @@ -23,8 +23,9 @@ ) class DiscoveryDockerIntegrationTest(unittest.TestCase): """ - Testing tests against a real, already-running discovery server instance, analogous to ``test_docker_integration_repository.py`` for the - repository profile: skipped entirely if no server is reachable at ``SERVER_BASE_URL``. + Testing tests against a real, already-running discovery server instance, analogous to + ``test_docker_integration_repository.py`` for the repository profile: skipped entirely if no server is reachable at + ``SERVER_BASE_URL``. Set the ``REQUIRE_SERVER_INTEGRATION_TESTS`` environment variable to make this test class fail instead of being skipped when no server is reachable (see ``test._helper.test_helpers``). diff --git a/server/test/docker_integration/test_docker_integration_registry.py b/server/test/docker_integration/test_docker_integration_registry.py index e8ea6915a..378ae8ebd 100644 --- a/server/test/docker_integration/test_docker_integration_registry.py +++ b/server/test/docker_integration/test_docker_integration_registry.py @@ -25,8 +25,9 @@ ) class RegistryDockerIntegrationTest(unittest.TestCase): """ - Testing against a real, already-running registry server instance, analogous to ``test_docker_integration_repository.py`` for the - repository profile: skipped entirely if no server is reachable at ``SERVER_BASE_URL``. + Testing against a real, already-running registry server instance, analogous to + ``test_docker_integration_repository.py`` for the repository profile: skipped entirely if no server is reachable at + ``SERVER_BASE_URL``. Set the ``REQUIRE_SERVER_INTEGRATION_TESTS`` environment variable to make this test class fail instead of being skipped when no server is reachable (see ``test._helper.test_helpers``). diff --git a/server/test/interfaces/test_registry.py b/server/test/interfaces/test_registry.py index fa3382074..1f090ebf6 100644 --- a/server/test/interfaces/test_registry.py +++ b/server/test/interfaces/test_registry.py @@ -487,7 +487,13 @@ def test_post_missing_id_returns_400(self) -> None: def test_post_conflict_returns_409(self) -> None: self.store.add(example_submodel_descriptor(self.SM_ID)) - self.assert_error(self.format_client.post("/submodel-descriptors", obj=example_submodel_descriptor(self.SM_ID)), 409) + self.assert_error( + self.format_client.post( + "/submodel-descriptors", + obj=example_submodel_descriptor(self.SM_ID), + ), + 409, + ) # ------------------------------------------------------------------ GET /submodel-descriptors/{submodelIdentifier} From fb8181ebb73a269a58facd0bc042fc694dc198f5 Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Sun, 27 Sep 2026 12:08:01 +0200 Subject: [PATCH 48/59] Address review feedback on the server test suite --- .gitignore | 1 + server/test/interfaces/format_utils.py | 14 +++++++++ server/test/interfaces/repository/helpers.py | 29 +++---------------- .../repository/test_concept_description.py | 2 -- .../repository/test_service_description.py | 22 ++++++++++++++ .../test/interfaces/repository/test_shells.py | 2 -- .../interfaces/repository/test_submodels.py | 4 --- server/test/interfaces/test_base.py | 5 ---- server/test/interfaces/test_discovery.py | 28 +++++++----------- server/test/interfaces/test_registry.py | 23 ++------------- 10 files changed, 54 insertions(+), 76 deletions(-) create mode 100644 server/test/interfaces/repository/test_service_description.py diff --git a/.gitignore b/.gitignore index 1efa31acc..ec28070b3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ docs/build/ # Customized config files sdk/test/test_config.ini +server/test/test_config.ini # Schema files needed for testing sdk/test/adapter/schemas diff --git a/server/test/interfaces/format_utils.py b/server/test/interfaces/format_utils.py index 85b9a7604..a3e2b3e77 100644 --- a/server/test/interfaces/format_utils.py +++ b/server/test/interfaces/format_utils.py @@ -7,6 +7,7 @@ import abc import json +import unittest from typing import Any, Callable, Optional import app.adapter @@ -220,6 +221,19 @@ def next_cursor(self, response: TestResponse) -> Optional[str]: return cursor if cursor not in (None, "None") else None +class AssertResponseMixin(unittest.TestCase): + """ + Shared response assertions for the repository, registry and discovery endpoint test bases. + """ + + def assert_ok(self, response: TestResponse) -> None: + self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) + + def assert_error(self, response: TestResponse, status_code: int) -> None: + self.assertEqual(status_code, response.status_code, msg=response.get_data(as_text=True)) + self.assertIn("success", response.get_data(as_text=True), msg=response.get_data(as_text=True)) + + def with_json_client(func): client_types = getattr(func, "_client_types", []) client_types.append(("json", JsonFormatClient)) diff --git a/server/test/interfaces/repository/helpers.py b/server/test/interfaces/repository/helpers.py index 4dd413456..4644b5046 100644 --- a/server/test/interfaces/repository/helpers.py +++ b/server/test/interfaces/repository/helpers.py @@ -5,7 +5,6 @@ # # SPDX-License-Identifier: MIT -import unittest from typing import TypeVar from unittest import mock @@ -16,13 +15,13 @@ create_example_asset_administration_shell, ) from basyx.aas.model import Identifiable -from werkzeug.test import Client, TestResponse +from werkzeug.test import Client -T = TypeVar('T') +from ..format_utils import AssertResponseMixin -class RepositoryEndpointTestBase(unittest.TestCase): - __test__ = False +T = TypeVar('T') +class RepositoryEndpointTestBase(AssertResponseMixin): object_store: model.SetIdentifiableStore[Identifiable] file_store: mock.Mock repository_server: repository.WSGIApp @@ -49,23 +48,3 @@ def two_shells_store(cls): second_shell.id = "https://example.org/Test_AssetAdministrationShell_Second" store.add(second_shell) return store - - # ------------------------------------------------------------------ shared assertion helpers - - def assert_ok(self, response: TestResponse) -> None: - self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) - - def assert_error(self, response: TestResponse, status_code: int) -> None: - self.assertEqual(status_code, response.status_code, msg=response.get_data(as_text=True)) - self.assertIn("success", response.get_data(as_text=True), msg=response.get_data(as_text=True)) - - -class TestServiceDescription(RepositoryEndpointTestBase): - __test__ = True - - def test_description(self): - response = self.client.get("/description") - self.assertEqual(200, response.status_code) - body = response.get_data(as_text=True) - self.assertIn("AssetAdministrationShellRepositoryServiceSpecification/SSP-001", body) - self.assertIn("SubmodelRepositoryServiceSpecification/SSP-001", body) diff --git a/server/test/interfaces/repository/test_concept_description.py b/server/test/interfaces/repository/test_concept_description.py index 8523ad86d..77eea1e4f 100644 --- a/server/test/interfaces/repository/test_concept_description.py +++ b/server/test/interfaces/repository/test_concept_description.py @@ -32,8 +32,6 @@ class ConceptDescriptionsEndpointsTest(RepositoryEndpointTestBase): :class:`~..format_utils.XmlFormatClient` are injected respectively. """ - __test__ = True - EXAMPLE_ID = "https://example.org/Test_ConceptDescription_Missing" SECOND_ID = "https://example.org/Test_ConceptDescription_Second" diff --git a/server/test/interfaces/repository/test_service_description.py b/server/test/interfaces/repository/test_service_description.py new file mode 100644 index 000000000..9d45407f2 --- /dev/null +++ b/server/test/interfaces/repository/test_service_description.py @@ -0,0 +1,22 @@ +# Copyright (c) 2026 the Eclipse BaSyx Authors +# +# This program and the accompanying materials are made available under the terms of the MIT License, available in +# the LICENSE file of this project. +# +# SPDX-License-Identifier: MIT + +""" +Endpoint test for the ``/description`` route of :class:`~app.interfaces.repository.WSGIApp`. +""" + +from .helpers import RepositoryEndpointTestBase + + +class RepositoryServiceDescriptionTest(RepositoryEndpointTestBase): + + def test_description(self) -> None: + response = self.client.get("/description") + self.assertEqual(200, response.status_code) + body = response.get_data(as_text=True) + self.assertIn("AssetAdministrationShellRepositoryServiceSpecification/SSP-001", body) + self.assertIn("SubmodelRepositoryServiceSpecification/SSP-001", body) diff --git a/server/test/interfaces/repository/test_shells.py b/server/test/interfaces/repository/test_shells.py index 06be108f8..9ad41f694 100644 --- a/server/test/interfaces/repository/test_shells.py +++ b/server/test/interfaces/repository/test_shells.py @@ -49,8 +49,6 @@ class ShellsEndpointsTest(RepositoryEndpointTestBase): :class:`~..format_utils.XmlFormatClient` are injected respectively. """ - __test__ = True - # ------------------------------------------------------------------ GET /shells @with_json_client diff --git a/server/test/interfaces/repository/test_submodels.py b/server/test/interfaces/repository/test_submodels.py index 5bc397c42..622b80600 100644 --- a/server/test/interfaces/repository/test_submodels.py +++ b/server/test/interfaces/repository/test_submodels.py @@ -44,8 +44,6 @@ class SubmodelsEndpointsTest(RepositoryEndpointTestBase): :class:`~..format_utils.XmlFormatClient` are injected respectively. """ - __test__ = True - SECOND_ID = "https://example.org/Test_Submodel_Second" def two_submodels_store(self) -> model.DictIdentifiableStore: @@ -404,8 +402,6 @@ class SubmodelElementsEndpointsTest(RepositoryEndpointTestBase): :class:`~..format_utils.XmlFormatClient` are injected respectively. """ - __test__ = True - #: Number of top-level submodel elements in ``create_example_submodel()``. TOP_LEVEL_COUNT = 6 diff --git a/server/test/interfaces/test_base.py b/server/test/interfaces/test_base.py index af93ab9bb..afeed463f 100644 --- a/server/test/interfaces/test_base.py +++ b/server/test/interfaces/test_base.py @@ -24,8 +24,6 @@ class TestPagination(unittest.TestCase): Testing of the shared pagination strategy, shared by multiple endpoints. """ - __test__ = True - @staticmethod def _build_request(limit: Optional[str] = None, cursor: Optional[str] = None): request = mock.Mock() @@ -86,8 +84,6 @@ def test_pagination_requires_positive_cursor(self): class TestJsonResponse(unittest.TestCase): - __test__ = True - def test_empty_response(self): response = base.JsonResponse(None) self.assertEqual(204, response.status_code) @@ -183,7 +179,6 @@ def test_result(self): self.assertIsNotNone(isodatetime.match(message["timestamp"])) class TestXmlResponse(unittest.TestCase): - __test__ = True def test_empty_response(self): response = base.XmlResponse(None) diff --git a/server/test/interfaces/test_discovery.py b/server/test/interfaces/test_discovery.py index 077a76b70..e5e551a1f 100644 --- a/server/test/interfaces/test_discovery.py +++ b/server/test/interfaces/test_discovery.py @@ -28,18 +28,23 @@ from app.interfaces.discovery import DiscoveryAPI, DiscoveryStore from app.util.converters import base64url_encode from basyx.aas.model import SpecificAssetId -from werkzeug.test import Client, TestResponse +from werkzeug.test import Client -from .format_utils import FormatClient, JsonFormatClient, inject_format_clients, with_json_client, with_xml_client +from .format_utils import ( + AssertResponseMixin, + FormatClient, + JsonFormatClient, + inject_format_clients, + with_json_client, + with_xml_client, +) def _b64url_json(payload: object) -> str: return base64url_encode(json.dumps(payload)) -class DiscoveryEndpointTestBase(unittest.TestCase): - __test__ = False - +class DiscoveryEndpointTestBase(AssertResponseMixin): AAS_ID = "https://example.org/aas/1" AAS_ID_2 = "https://example.org/aas/2" UNKNOWN_ID = "https://example.org/unknown" @@ -69,19 +74,11 @@ def register(self, aas_id: str, asset_ids: List[Tuple[str, str]]) -> None: for asset in assets: self.store._add_aas_id_to_specific_asset_id(asset, aas_id) - def assert_ok(self, response: TestResponse) -> None: - self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) - - def assert_error(self, response: TestResponse, status_code: int) -> None: - self.assertEqual(status_code, response.status_code, msg=response.get_data(as_text=True)) - self.assertIn("success", response.get_data(as_text=True), msg=response.get_data(as_text=True)) - # ====================================================================== /description class DiscoveryServiceDescriptionTest(DiscoveryEndpointTestBase): - __test__ = True def test_description_ok(self) -> None: response = self.format_client.get("/description") @@ -96,7 +93,6 @@ def test_description_ok(self) -> None: class SearchShellsByAssetLinkEndpointTest(DiscoveryEndpointTestBase): - __test__ = True def test_search_match(self) -> None: self.register(self.AAS_ID, [("serial", "123")]) @@ -163,8 +159,6 @@ def test_search_asset_link_missing_value_returns_400(self) -> None: class GetShellsByAssetLinkQueryEndpointTest(DiscoveryEndpointTestBase): """The deprecated ``GET /lookup/shells?assetIds=...`` route (kept for BaSyx UI interoperability).""" - __test__ = True - def test_query_match(self) -> None: self.register(self.AAS_ID, [("serial", "123")]) @@ -206,8 +200,6 @@ def test_query_payload_item_not_object_returns_400(self) -> None: class AssetLinksByIdEndpointTest(DiscoveryEndpointTestBase): """Tests for GET/POST/DELETE on ``/lookup/shells/{aasIdentifier}``.""" - __test__ = True - # ------------------------------------------------------------------ GET (returns SpecificAssetId objects) @with_json_client diff --git a/server/test/interfaces/test_registry.py b/server/test/interfaces/test_registry.py index 1f090ebf6..622aabea2 100644 --- a/server/test/interfaces/test_registry.py +++ b/server/test/interfaces/test_registry.py @@ -18,7 +18,6 @@ ``ServerAASToJsonEncoder`` so :class:`~app.model.descriptor.Descriptor` objects can be sent. """ -import unittest from typing import Any from app.interfaces.registry import RegistryAPI @@ -29,7 +28,7 @@ from werkzeug.test import Client, TestResponse from ..adapter.descriptor_utils import example_aas_descriptor, example_submodel_descriptor -from .format_utils import JsonFormatClient +from .format_utils import AssertResponseMixin, JsonFormatClient class _InMemoryDescriptorStore(DictDescriptorStore): @@ -48,9 +47,7 @@ def clear(self) -> None: self._backend.clear() -class RegistryEndpointTestBase(unittest.TestCase): - __test__ = False - +class RegistryEndpointTestBase(AssertResponseMixin): AAS_ID = "https://example.org/shell-descriptors/1" AAS_ID_2 = "https://example.org/shell-descriptors/2" SM_ID = "https://example.org/submodel-descriptors/1" @@ -73,14 +70,7 @@ def setUpClass(cls) -> None: def setUp(self) -> None: self.store.clear() - # ------------------------------------------------------------------ assertion helpers - - def assert_ok(self, response: TestResponse) -> None: - self.assertEqual(200, response.status_code, msg=response.get_data(as_text=True)) - - def assert_error(self, response: TestResponse, status_code: int) -> None: - self.assertEqual(status_code, response.status_code, msg=response.get_data(as_text=True)) - self.assertIn("success", response.get_data(as_text=True), msg=response.get_data(as_text=True)) + # ------------------------------------------------------------------ helpers def ids(self, response: TestResponse) -> list: return [self.format_client.identifier(node) for node in self.format_client.parse_collection(response)] @@ -90,7 +80,6 @@ def ids(self, response: TestResponse) -> list: class RegistryServiceDescriptionTest(RegistryEndpointTestBase): - __test__ = True def test_description_ok(self) -> None: response = self.format_client.get("/description") @@ -113,8 +102,6 @@ def test_description_ok(self) -> None: class ShellDescriptorsEndpointTest(RegistryEndpointTestBase): """Tests for the ``/shell-descriptors`` and ``/shell-descriptors/{aasIdentifier}`` routes.""" - __test__ = True - # ------------------------------------------------------------------ GET /shell-descriptors def test_get_all_empty(self) -> None: @@ -298,8 +285,6 @@ def test_delete_not_found_returns_404(self) -> None: class SubmodelDescriptorsThroughSuperpathEndpointTest(RegistryEndpointTestBase): """Tests for the ``/shell-descriptors/{aasIdentifier}/submodel-descriptors`` routes.""" - __test__ = True - def setUp(self) -> None: super().setUp() self.store.add(example_aas_descriptor(self.AAS_ID)) @@ -427,8 +412,6 @@ def test_delete_aas_not_found_returns_404(self) -> None: class SubmodelDescriptorsEndpointTest(RegistryEndpointTestBase): """Tests for the standalone ``/submodel-descriptors`` and ``/submodel-descriptors/{submodelIdentifier}`` routes.""" - __test__ = True - # ------------------------------------------------------------------ GET /submodel-descriptors def test_get_all_empty(self) -> None: From 7813a07979bbb0f622b6dc37b1f5244729ff2607 Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Sun, 27 Sep 2026 13:02:34 +0200 Subject: [PATCH 49/59] Drop module docstring for consistency --- server/test/interfaces/repository/test_service_description.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/server/test/interfaces/repository/test_service_description.py b/server/test/interfaces/repository/test_service_description.py index 9d45407f2..9f2f4dd03 100644 --- a/server/test/interfaces/repository/test_service_description.py +++ b/server/test/interfaces/repository/test_service_description.py @@ -5,10 +5,6 @@ # # SPDX-License-Identifier: MIT -""" -Endpoint test for the ``/description`` route of :class:`~app.interfaces.repository.WSGIApp`. -""" - from .helpers import RepositoryEndpointTestBase From 0fede308cb0cbe132bfe6719911080e57a8fafc5 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sun, 27 Sep 2026 18:52:46 +0200 Subject: [PATCH 50/59] Correct comments around server integration tests --- .github/workflows/pr.yml | 2 +- server/test/_helper/test_helpers.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index aa52ff372..b9a42b546 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -402,7 +402,7 @@ jobs: server-docker: # This job tests for all three server profiles, if the docker image is built successfully, a container - # can be started and the server sends a `200 OK` response on the mandatory /description endpoint + # can be started and the integration tests run successfully runs-on: ubuntu-latest strategy: matrix: diff --git a/server/test/_helper/test_helpers.py b/server/test/_helper/test_helpers.py index 409174266..00a921780 100644 --- a/server/test/_helper/test_helpers.py +++ b/server/test/_helper/test_helpers.py @@ -20,10 +20,7 @@ ) -# By default, the Docker integration tests are skipped whenever no server is reachable, so that a plain local -# `python -m unittest` run doesn't require a running Docker container. Set this environment variable to "1"/"true" -# (e.g. in CI) to instead make those tests fail loudly if no server is reachable, so a broken Docker container -# can't silently cause the tests to be skipped without anyone noticing. +# Set this environment variable, to fail (instead of skip) the integration test when server is not reachable REQUIRE_SERVER = os.environ.get("REQUIRE_SERVER_INTEGRATION_TESTS", "false").lower() in {"1", "true", "yes"} # Check if the server is available. Otherwise, skip tests (unless REQUIRE_SERVER is set, see above). From 1198fe936fda89b5210ae0cb2ff3901650640b2b Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sun, 27 Sep 2026 18:53:28 +0200 Subject: [PATCH 51/59] Use relative imports for test modules everywhere --- .../docker_integration/test_docker_integration_discovery.py | 2 +- .../test/docker_integration/test_docker_integration_registry.py | 2 +- .../docker_integration/test_docker_integration_repository.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/test/docker_integration/test_docker_integration_discovery.py b/server/test/docker_integration/test_docker_integration_discovery.py index d15f65b8b..fdb168d72 100644 --- a/server/test/docker_integration/test_docker_integration_discovery.py +++ b/server/test/docker_integration/test_docker_integration_discovery.py @@ -13,7 +13,7 @@ from app.interfaces.discovery import SUPPORTED_PROFILES from app.util.converters import base64url_encode -from test._helper.test_helpers import REQUIRE_SERVER, SERVER_ERROR, SERVER_OKAY, TEST_CONFIG +from .._helper.test_helpers import REQUIRE_SERVER, SERVER_ERROR, SERVER_OKAY, TEST_CONFIG SERVER_BASE_URL = TEST_CONFIG["server"]["url"] diff --git a/server/test/docker_integration/test_docker_integration_registry.py b/server/test/docker_integration/test_docker_integration_registry.py index 378ae8ebd..1dc8a0deb 100644 --- a/server/test/docker_integration/test_docker_integration_registry.py +++ b/server/test/docker_integration/test_docker_integration_registry.py @@ -15,7 +15,7 @@ from app.model import AssetAdministrationShellDescriptor from app.util.converters import base64url_encode -from test._helper.test_helpers import REQUIRE_SERVER, SERVER_ERROR, SERVER_OKAY, TEST_CONFIG +from .._helper.test_helpers import REQUIRE_SERVER, SERVER_ERROR, SERVER_OKAY, TEST_CONFIG SERVER_BASE_URL = TEST_CONFIG["server"]["url"] diff --git a/server/test/docker_integration/test_docker_integration_repository.py b/server/test/docker_integration/test_docker_integration_repository.py index 3f4561a29..7869def85 100644 --- a/server/test/docker_integration/test_docker_integration_repository.py +++ b/server/test/docker_integration/test_docker_integration_repository.py @@ -19,7 +19,7 @@ create_example_asset_administration_shell, ) -from test._helper.test_helpers import REQUIRE_SERVER, SERVER_ERROR, SERVER_OKAY, TEST_CONFIG +from .._helper.test_helpers import REQUIRE_SERVER, SERVER_ERROR, SERVER_OKAY, TEST_CONFIG SERVER_BASE_URL = TEST_CONFIG["server"]["url"] From 149a6dc048ecfb8432a06a99d1fb15ab3f98116b Mon Sep 17 00:00:00 2001 From: hpoeche Date: Sun, 27 Sep 2026 19:09:49 +0200 Subject: [PATCH 52/59] Mark `_InMemoryDescriptorStore` as workaround for #626 --- server/test/interfaces/test_registry.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/server/test/interfaces/test_registry.py b/server/test/interfaces/test_registry.py index 622aabea2..609d05681 100644 --- a/server/test/interfaces/test_registry.py +++ b/server/test/interfaces/test_registry.py @@ -31,21 +31,12 @@ from .format_utils import AssertResponseMixin, JsonFormatClient +# TODO(#626): Replace usages with original `DictDescriptorStore`, once `commit()` does not throw NotImplementedError class _InMemoryDescriptorStore(DictDescriptorStore): - """ - In-memory descriptor store with a no-op ``commit`` and a ``clear``. - - :class:`~app.model.provider.DictDescriptorStore` inherits ``commit`` from the SDK's - ``AbstractObjectStore``, where it raises ``NotImplementedError``; the registry calls it after every - write. This mirrors what ``SetIdentifiableStore`` provides for the repository tests. - """ def commit(self, x: Any) -> None: pass - def clear(self) -> None: - self._backend.clear() - class RegistryEndpointTestBase(AssertResponseMixin): AAS_ID = "https://example.org/shell-descriptors/1" From fe75becad682f4c1d17c0dd92afa78329a23efb9 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Fri, 2 Oct 2026 10:02:00 +0200 Subject: [PATCH 53/59] Draft for testing `README.md` --- server/test/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 server/test/README.md diff --git a/server/test/README.md b/server/test/README.md new file mode 100644 index 000000000..764f87200 --- /dev/null +++ b/server/test/README.md @@ -0,0 +1,12 @@ +# Server Testing + +General, how to execute tests + +## Unit Tests + +### Format Agnostic Testing +- Decorators, Format Clients + +## Integration Tests + +### `REQUIRE_SERVER_INTEGRATION` environment variable \ No newline at end of file From ab351646839ef83e8a922759b1b02e0c6f22eac5 Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Fri, 2 Oct 2026 11:22:38 +0200 Subject: [PATCH 54/59] Add Integration Test to testing README and change server README to include testing --- server/README.md | 7 +++++++ server/test/README.md | 28 +++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/server/README.md b/server/README.md index 50449a7d5..b45204218 100644 --- a/server/README.md +++ b/server/README.md @@ -86,6 +86,13 @@ Currently, we offer: - [registry_standalone](example_configurations/registry_standalone/README.md): Standalone registry server - [discovery_standalone](example_configurations/discovery_standalone/README.md): Standalone discovery server +## Testing + +See [`test/README.md`](test/README.md) for an overview of the server's test suite (endpoint tests, shared-logic +tests, entrypoint tests and Docker integration tests) and how to write and run tests against it. For the exact +commands to run the checks locally before opening a Pull Request, see +[`CONTRIBUTING.md`](../CONTRIBUTING.md#testing-the-server). + ## Running without Docker (Debugging Only) The server can also be run directly on the host system without Docker, NGINX and supervisord. Although this is not suitable for production, it may be useful for debugging. diff --git a/server/test/README.md b/server/test/README.md index 764f87200..7fadf5c00 100644 --- a/server/test/README.md +++ b/server/test/README.md @@ -9,4 +9,30 @@ General, how to execute tests ## Integration Tests -### `REQUIRE_SERVER_INTEGRATION` environment variable \ No newline at end of file +[`docker_integration/`](docker_integration) contains one test module per server profile (repository, registry, +discovery). Unlike the unit tests above, these do not start a server themselves — each test runs a +`/description` check plus a full create/retrieve/delete roundtrip against a **real, already-running** server, +e.g. a Docker container started from the built image. This is the only layer that actually exercises uWSGI, nginx +and supervisor together, so it catches deployment/configuration problems the unit tests cannot see. + +### `REQUIRE_SERVER_INTEGRATION_TESTS` environment variable + +By default, the integration tests are **skipped** if no server is reachable at the URL configured in +`server/test/test_config.default.ini` (override it in your own, `.gitignore`d `server/test/test_config.ini`). This +is implemented in [`_helper/test_helpers.py`](_helper/test_helpers.py): it probes `GET /description` once at +import time and exposes the result as `SERVER_OKAY`. Every integration test class is decorated with +`@unittest.skipUnless(SERVER_OKAY or REQUIRE_SERVER, ...)`, so plain `python -m unittest` still passes locally +without a container running. + +Set `REQUIRE_SERVER_INTEGRATION_TESTS` (to `1`/`true`/`yes`) to turn a missing server into a hard **failure** +instead of a skip: + +```bash +REQUIRE_SERVER_INTEGRATION_TESTS=1 python -m unittest test.docker_integration.test_docker_integration_repository -v +``` + +This is what the `server-docker` CI job does for all three profiles: it builds the image, starts the container, +waits until it reports ready, and only then runs the matching test module with `REQUIRE_SERVER_INTEGRATION_TESTS=1` +set — so a container that fails to start is reported as a test failure, not a silently skipped test. The plain +`server-test` CI job, which never starts a container, explicitly sets `REQUIRE_SERVER_INTEGRATION_TESTS=0`, so +these tests are skipped there, as expected. \ No newline at end of file From daa239c4178e4f647fd1e465ec245a99b7d9949e Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Tue, 6 Oct 2026 13:38:10 +0200 Subject: [PATCH 55/59] Fix false-positive on substring match --- server/test/interfaces/format_utils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/test/interfaces/format_utils.py b/server/test/interfaces/format_utils.py index a3e2b3e77..661741b5a 100644 --- a/server/test/interfaces/format_utils.py +++ b/server/test/interfaces/format_utils.py @@ -231,7 +231,13 @@ def assert_ok(self, response: TestResponse) -> None: def assert_error(self, response: TestResponse, status_code: int) -> None: self.assertEqual(status_code, response.status_code, msg=response.get_data(as_text=True)) - self.assertIn("success", response.get_data(as_text=True), msg=response.get_data(as_text=True)) + body_text = response.get_data(as_text=True) + if "json" in (response.content_type or ""): + success = json.loads(body_text).get("success", False) + else: + success_elem = etree.fromstring(response.data).find("success") + success = success_elem is not None and success_elem.text == "true" + self.assertFalse(success, msg=body_text) def with_json_client(func): From 5a13074cdcefb1301a1a2e3aaaef948d8a70e7ae Mon Sep 17 00:00:00 2001 From: Paul Gerber Date: Tue, 6 Oct 2026 16:10:48 +0200 Subject: [PATCH 56/59] server: Align `_write_file()` modelType injection with `support.py` Drop the per-descriptor `DESCRIPTOR_TYPE_TO_STRING` lookup and nested helper function; serialize both descriptor lists once and inject the literal modelType string in two plain loops instead, matching the pattern already used in `services/support.py`. --- server/test/model/test_provider.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/server/test/model/test_provider.py b/server/test/model/test_provider.py index 015a67066..40b233f8a 100644 --- a/server/test/model/test_provider.py +++ b/server/test/model/test_provider.py @@ -9,10 +9,9 @@ import os import tempfile import unittest -from typing import Iterable, Union +from typing import Iterable from app import adapter, model -from app.backend.local_file import DESCRIPTOR_TYPE_TO_STRING from app.model.provider import DictDescriptorStore, load_directory from ..adapter.descriptor_utils import example_aas_descriptor, example_submodel_descriptor @@ -91,19 +90,20 @@ def _write_file( aas_descriptors: Iterable[model.AssetAdministrationShellDescriptor] = (), submodel_descriptors: Iterable[model.SubmodelDescriptor] = (), ) -> None: - def to_json_dict( - desc: Union[model.AssetAdministrationShellDescriptor, model.SubmodelDescriptor], - ) -> dict: - data = json.loads(json.dumps(desc, cls=adapter.ServerAASToJsonEncoder)) - data["modelType"] = DESCRIPTOR_TYPE_TO_STRING[type(desc)] - return data - data = { - "assetAdministrationShellDescriptors": [to_json_dict(d) for d in aas_descriptors], - "submodelDescriptors": [to_json_dict(d) for d in submodel_descriptors], + "assetAdministrationShellDescriptors": list(aas_descriptors), + "submodelDescriptors": list(submodel_descriptors), } + + # Hack in the "modelType" to deserialize into the right classes + json_save = json.loads(json.dumps(data, cls=adapter.ServerAASToJsonEncoder)) + for aas in json_save["assetAdministrationShellDescriptors"]: + aas["modelType"] = "AssetAdministrationShellDescriptor" + for sm in json_save["submodelDescriptors"]: + sm["modelType"] = "SubmodelDescriptor" + with open(os.path.join(directory, filename), "w") as f: - json.dump(data, f) + json.dump(json_save, f) def test_loads_descriptors_from_single_file(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: From 3fd08afed46692658efcaf59d73b0423b2a5c9bc Mon Sep 17 00:00:00 2001 From: hpoeche Date: Tue, 6 Oct 2026 14:29:32 +0200 Subject: [PATCH 57/59] Add section about unit tests to README.md --- server/test/README.md | 142 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 2 deletions(-) diff --git a/server/test/README.md b/server/test/README.md index 7fadf5c00..f2087b356 100644 --- a/server/test/README.md +++ b/server/test/README.md @@ -1,11 +1,149 @@ # Server Testing -General, how to execute tests +The `server` package holds the code for three different server profiles: `repository`, `registry` and `discovery`. +We use unit testing as well as integration testing to ensure these servers are working as expected, from the Python +code up to a running Docker container. + +To execute all tests, run `python -m unittest` from the parent `server` directory. If no server is running on your system yet, +the integration tests are skipped. For more details refer to the section about [Integration Tests](#integration-tests). ## Unit Tests +Unit tests are used to cover the complete python implementation of our server. We structure the tests equivalent to the +source files. + +In the [`adapter`](adapter) directory serialization of server specific objects is tested (defined in `ResultToJsonEncoder`). +Note that serialization of AAS objects is not tested, because this is already covered in the `sdk` project. + +The `DictDescriptorStore` and `LocalFileDescriptorStore` are also tested directly for correctness in directories +[`model`](model) and [`backend`](backend) respectively. + +The [`servcies`](services) directory holds tests for the startup of the different servers via the `run_*.py` scripts +(`server/app/services`). The test cases verify that the set environment variables are respected. + +The central API logic is defined in `server/app/interfaces`, which is tested in [`interfaces`](interfaces). +Code from `base.py` that is shared among the three interfaces (`repository.py`, `registry.py`, `discovery.py`) +is tested separately. This includes pagination logic, `JsonResponse` and `XmlResponse`. + +For the API endpoints of all three interfaces, we write test cases covering the successful path as well as error cases. +We chose not to fully parse responses with the deserializers from the `sdk` to decouple the API testing from testing +the deserialization logic. Instead, we check that the results contain the expected objects (via IDs), that +modifying operations correctly modify the objects in the backing storage (e.g. `object_store` for repository) and that +the responses reflect these changes correctly. + +The `registry.py` and `discovery.py` interfaces only allow JSON as content type and are small enough to be tested in +one file each. For the `repository.py` tests are split into separate files inside [`interfaces/repository`](interfaces/repository) +to keep files of manageable size. Find details on how test cases for this interface are written in the subsection +[Format Agnostic Testing](#format-agnostic-testing). ### Format Agnostic Testing -- Decorators, Format Clients +Our `repository` implementation supports `application/json` and `application/xml` as content types of requests and +responses. This doubles the surface of the API that needs to be tested in the unit tests. We want to ensure that +regardless of the data format the request and response body is serialized to, the server behavior is as expected. +In a naive approach this would require each test case to be mirrored for JSON and XML format, which brings all the +disadvantages of duplicated code. + +To solve this challenge, we decided to write the tests in [`interfaces/repository`](interfaces/repository) with +format agnostic methods for requesting and result verification. We encapsulate the format agnostic methods in an +abstract superclass [`FormatClient`](interfaces/format_utils.py) which offers methods to send requests as well as simple +parsing of responses in order to retrieve IDs and other field values. +This allows us to write test cases using the abstract interface of this class for requesting and correctness checking +of the result. The concrete subclasses implement the serialization and deserialization to and from a specific format. +In our case we derive the subclasses `JsonFormatClient` and `XmlFormatClient`. For an example see +[Writing test cases](#writing-test-cases). + +#### Sending requests +Our `FormatClient` wraps the [`Client`](https://werkzeug.palletsprojects.com/en/stable/test/#werkzeug.test.Client) +class of the `Werkzeug` package. It offers methods for the used HTTP requests (`get()`, `post()`, `put()`, `patch()`, +`delete()`) and delegates the execution of these requests to the Werkzeug `Client`. +Subclasses need to define a class variable `content_type` and override the abstract method `serialize()`. +This method is used to serialize request body objects, passed to `post()`, `put()` and `patch()`, into string format, +in order to send the request. All requests are equipped with an `Accept` header. + +We use `ServerAASToJsonEncoder` and `object_to_xml_element()` respectively inside the `serialize()` methods +of our concrete subclasses. + +#### Validating responses + +For this purpose, our `FormatClient` offers abstract methods `parse_object(response)` and `parse_collection(response)` +to convert the response body into an intermediate representation (e.g. `dict` or `etree._Element`). To access certain +values of these representations, `identifier(node)`, `field(node, name)` and `reference_target(node)` can be used. + +We use `json.loads()` and `etree.fromstring()` in our concrete subclasses to parse the response bodies. + + +#### Writing test cases +The interface of the abstract `FormatClient` described above can then be used to write format agnostic test cases. +In order to inject a `FormatClient` instance into `test_*()` methods and to keep the different format +instantiations of the same test from interfering with each other, we use decorators on both the test class and the +test methods. + +Consider the following `SimpleAPITest` class as an example: + +```python +@inject_format_clients +class SimpleAPITest(unittest.TestCase): + object_store: model.SetIdentifiableStore[model.Identifiable] + client: werkzeug.test.Client # must exist for @inject_format_clients to work + server: repository.WSGIApp + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.object_store = model.SetIdentifiableStore() + cls.server = repository.WSGIApp(...) + cls.client = werkzeug.test.Client(cls.server) + + def setUp(self): + self.object_store.clear() + + @with_json_client + @with_xml_client + def test_shells_get(self, format_client: FormatClient): + self.object_store.update(...) + + response = format_client.get("/shells?idShort=Alpha") + ids = {format_client.identifier(node) for node in format_client.parse_collection(response)} + + self.assertEqual( + {"https://example.org/shell-alpha", "https://example.org/shell-gamma"}, ids + ) +``` + +We use the class decorator `@inject_format_clients` on the test class. This decorator requires the class to have a class +attribute `client` which will be used in the constructor of the `FormatClient` subclasses. We can then add test cases +that take a `FormatClient` as second parameter and decorate them with one or both function decorators +`@with_json_client` and `@with_xml_client`. For each of the function decorators a separate test case +(`test_*_json()` and `test_*_xml()`) with the correct `JsonFormatClient` or `XmlFormatClient` will be created. +For more details see below. + +#### Detail on the decorators +The function decorators `@with_json_client` and `@with_xml_client` mark the function by adding information to the +`_client_types` attribute of the function object. +At runtime, `@inject_format_clients` scans the class for methods that carry a non-empty `_client_types`. +Such a method (`test_shells_get(self, format_client)` in the example) is removed from the class and replaced by +one method per decorated format (`test_shells_get_json(self)`, `test_shells_get_xml(self)`). +The new methods construct an instance of the corresponding `FormatClient` subclass using `cls.client` and call the +original method with it. The decorated class is equivalent to this: + +```python +class SimpleAPITest(unittest.TestCase): + # Omitting class attributes and setUpClass()/setUp() + + def test_shells_get_json(self): + format_client_instance = JsonFormatClient(self.client) + def test_shells_get(self, format_client): + ... # Body of the original SimpleAPITest.test_shells_get method + return test_shells_get(self, format_client_instance) + + def test_shells_get_xml(self): + format_client_instance = XmlFormatClient(self.client) + def test_shells_get(self, format_client): + ... # Body of the original SimpleAPITest.test_shells_get method + return test_shells_get(self, format_client_instance) +``` +As the decorated class no longer has a method called `test_shells_get`, unittest may throw an error if you try to +execute `python -m unittest SimpleAPITest.test_shells_get`. Use the function names with their appended format +(`SimpleAPITest.test_shells_get_json` and `SimpleAPITest.test_shells_get_xml`) instead. ## Integration Tests From 960c0fb4263a445107423dd2ff039e9a876fc230 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Wed, 7 Oct 2026 11:49:32 +0200 Subject: [PATCH 58/59] Use `versions.toml` api_base_path in integration tests Splits the definition of the server `url` value into `host` and `base_path` in the `test_config.default.ini`. The values are concatenated in `test_helpers.py` to define the `url` value. If `base_path` is set to `DEFAULT`, its value is read from `versions.toml`. This is the default value for `base_path` so the tests do not fail on API version bumps. --- server/test/_helper/test_helpers.py | 7 +++++-- server/test/test_config.default.ini | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/server/test/_helper/test_helpers.py b/server/test/_helper/test_helpers.py index 00a921780..3a79fb3de 100644 --- a/server/test/_helper/test_helpers.py +++ b/server/test/_helper/test_helpers.py @@ -6,11 +6,12 @@ # SPDX-License-Identifier: MIT import configparser -import os import os.path import urllib.error import urllib.request +from app._config import API_BASE_PATH + TEST_CONFIG = configparser.ConfigParser() TEST_CONFIG.read( ( @@ -18,7 +19,9 @@ os.path.join(os.path.dirname(__file__), "..", "test_config.ini"), ) ) - +if TEST_CONFIG.get("server", "base_path", fallback="default").lower() == "default": + TEST_CONFIG.set("server", "base_path", API_BASE_PATH) +TEST_CONFIG.set("server", "url", TEST_CONFIG.get("server", "host") + TEST_CONFIG.get("server", "base_path")) # Set this environment variable, to fail (instead of skip) the integration test when server is not reachable REQUIRE_SERVER = os.environ.get("REQUIRE_SERVER_INTEGRATION_TESTS", "false").lower() in {"1", "true", "yes"} diff --git a/server/test/test_config.default.ini b/server/test/test_config.default.ini index 048fdef86..6efb5e048 100644 --- a/server/test/test_config.default.ini +++ b/server/test/test_config.default.ini @@ -2,6 +2,8 @@ # For customizations, please create a new file in this directory, named `test_config.ini` and add the required entries # to that file to override the defaults defined here. +# Set `base_path` to `DEFAULT` to use the value defined in the central `versions.toml` [server] -url = http://localhost:8080/api/v3.1 +host = http://localhost:8080 +base_path = DEFAULT From 2a8eb69c93f9e7f93812b27e22e3f840f60765f7 Mon Sep 17 00:00:00 2001 From: hpoeche Date: Wed, 7 Oct 2026 12:03:52 +0200 Subject: [PATCH 59/59] Remove workaround for `DictDescriptorStore` (#626) --- .github/workflows/pr.yml | 4 +--- server/test/interfaces/test_registry.py | 12 ++---------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 45735d742..f2e475f2b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -431,10 +431,8 @@ jobs: publish: false platform: linux/amd64 - name: Run container - # Enable STORAGE_PERSISTENCY as registry fails with in-memory store (#626) - # TODO(#626): revisit when `DictDescriptorStore` throws now exception on `commit()` run: | - docker run -d --name basyx-python-${{ matrix.profile }} -p 8080:80 -eSTORAGE_PERSISTENCY=True --pull=never ${{ steps.build.outputs.image-ref }} + docker run -d --name basyx-python-${{ matrix.profile }} -p 8080:80 --pull=never ${{ steps.build.outputs.image-ref }} - name: Wait for container and server initialization run: | timeout 30s bash -c ' diff --git a/server/test/interfaces/test_registry.py b/server/test/interfaces/test_registry.py index 609d05681..14d78bf78 100644 --- a/server/test/interfaces/test_registry.py +++ b/server/test/interfaces/test_registry.py @@ -18,7 +18,6 @@ ``ServerAASToJsonEncoder`` so :class:`~app.model.descriptor.Descriptor` objects can be sent. """ -from typing import Any from app.interfaces.registry import RegistryAPI from app.model import DictDescriptorStore @@ -31,13 +30,6 @@ from .format_utils import AssertResponseMixin, JsonFormatClient -# TODO(#626): Replace usages with original `DictDescriptorStore`, once `commit()` does not throw NotImplementedError -class _InMemoryDescriptorStore(DictDescriptorStore): - - def commit(self, x: Any) -> None: - pass - - class RegistryEndpointTestBase(AssertResponseMixin): AAS_ID = "https://example.org/shell-descriptors/1" AAS_ID_2 = "https://example.org/shell-descriptors/2" @@ -45,7 +37,7 @@ class RegistryEndpointTestBase(AssertResponseMixin): SM_ID_2 = "https://example.org/submodel-descriptors/2" UNKNOWN_ID = "https://example.org/unknown" - store: _InMemoryDescriptorStore + store: DictDescriptorStore registry_server: RegistryAPI client: Client format_client: JsonFormatClient @@ -53,7 +45,7 @@ class RegistryEndpointTestBase(AssertResponseMixin): @classmethod def setUpClass(cls) -> None: super().setUpClass() - cls.store = _InMemoryDescriptorStore() + cls.store = DictDescriptorStore() cls.registry_server = RegistryAPI(cls.store, base_path="") cls.client = Client(cls.registry_server) cls.format_client = JsonFormatClient(cls.client)