Repository: elisa-tech/BASIL
Pull request #138: API app UT: add tests for api<->SW requirement mapping
Author: login: AliaksandrKustsinski
Created: 2025-04-04T12:17:49Z | Merged: 2025-04-14T08:40:16Z

Add unit tests for ApiSwRequirementsMapping endpoint of the API app.
In addition, I've added 'offset' as a required field for ApiSwRequirementsMapping, since it is used by POST method.

The PR is related to https://github.com/elisa-tech/BASIL/issues/32.


Diff:
diff --git a/api/api.py b/api/api.py
index d16b217..3559d0b 100644
--- a/api/api.py
+++ b/api/api.py
@@ -3538,7 +3538,7 @@ def delete(self):
 
 
 class ApiSwRequirementsMapping(Resource):
-    fields = ["api-id", "sw-requirement", "section", "coverage"]
+    fields = ["api-id", "sw-requirement", "section", "offset", "coverage"]
 
     def get(self):
         args = get_query_string_args(request.args)
diff --git a/api/test/conftest.py b/api/test/conftest.py
index d1d09c9..3f611e7 100644
--- a/api/test/conftest.py
+++ b/api/test/conftest.py
@@ -1,4 +1,6 @@
 import pytest
+import string
+import random
 from api import api
 from db import db_orm
 from db.models.db_base import Base
@@ -17,6 +19,17 @@
 UT_USER_ROLE = 'USER'
 
 
+class Utilities:
+    @staticmethod
+    def generate_random_hex_string8():
+        return ''.join(random.choices(string.hexdigits, k=8))
+
+
+@pytest.fixture
+def utilities(scope="session", autouse=True):
+    return Utilities
+
+
 @pytest.fixture(scope="module")
 def client():
     with api.app.test_client() as client:
diff --git a/api/test/test_api_documents_mapping.py b/api/test/test_api_documents_mapping.py
index fde1585..7c68461 100644
--- a/api/test/test_api_documents_mapping.py
+++ b/api/test/test_api_documents_mapping.py
@@ -1,8 +1,6 @@
 import os
 import pytest
 import tempfile
-import string
-import random
 from db import db_orm
 from db.models.user import UserModel
 from db.models.api import ApiModel
@@ -32,16 +30,12 @@
 _UT_DOC_TITLE = 'ut_doc_title_1234'
 
 
-def _random_hex_string8():
-    return ''.join(random.choices(string.hexdigits, k=8))
-
-
 def _get_sections_mapped_by_docs(mapped_sections):
     return [section for section in mapped_sections if section['documents']]
 
 
 @pytest.fixture()
-def unmapped_api_db(client_db, ut_user_db):
+def unmapped_api_db(client_db, ut_user_db, utilities):
     """ Create Api with no mapped sections """
 
     dbi = db_orm.DbInterface(DB_NAME)
@@ -54,9 +48,9 @@ def unmapped_api_db(client_db, ut_user_db):
     raw_spec.close()
 
     # create API
-    ut_api = ApiModel(_UT_API_NAME + '#' + _random_hex_string8(),
+    ut_api = ApiModel(_UT_API_NAME + '#' + utilities.generate_random_hex_string8(),
                       _UT_API_LIBRARY, _UT_API_LIBRARY_VERSION, raw_spec.name,
-                      _UT_API_CATEGORY, _random_hex_string8(), raw_spec.name + 'impl',
+                      _UT_API_CATEGORY, utilities.generate_random_hex_string8(), raw_spec.name + 'impl',
                       _UT_API_IMPLEMENTATION_FILE_FROM_ROW, _UT_API_IMPLEMENTATION_FILE_TO_ROW,
                       _UT_API_TAGS, user)
     dbi.session.add(ut_api)
@@ -70,7 +64,7 @@ def unmapped_api_db(client_db, ut_user_db):
 
 
 @pytest.fixture()
-def mapped_api_db(client_db, ut_user_db):
+def mapped_api_db(client_db, ut_user_db, utilities):
     """ Create Api with one mapped section """
 
     dbi = db_orm.DbInterface(DB_NAME)
@@ -83,9 +77,9 @@ def mapped_api_db(client_db, ut_user_db):
     raw_spec.close()
 
     # create API
-    ut_api = ApiModel(_UT_API_NAME + '#' + _random_hex_string8(),
+    ut_api = ApiModel(_UT_API_NAME + '#' + utilities.generate_random_hex_string8(),
                       _UT_API_LIBRARY, _UT_API_LIBRARY_VERSION, raw_spec.name,
-                      _UT_API_CATEGORY, _random_hex_string8(), raw_spec.name + 'impl',
+                      _UT_API_CATEGORY, utilities.generate_random_hex_string8(), raw_spec.name + 'impl',
                       _UT_API_IMPLEMENTATION_FILE_FROM_ROW, _UT_API_IMPLEMENTATION_FILE_TO_ROW,
                       _UT_API_TAGS, user)
     ut_document = DocumentModel('doc_title', 'doc_description', 'doc_type', 'doc_spdx_relation',
diff --git a/api/test/test_api_sw_requirement_mapping.py b/api/test/test_api_sw_requirement_mapping.py
new file mode 100644
index 0000000..2276871
--- /dev/null
+++ b/api/test/test_api_sw_requirement_mapping.py
@@ -0,0 +1,215 @@
+import os
+import pytest
+import tempfile
+from db import db_orm
+from db.models.user import UserModel
+from db.models.api import ApiModel
+from db.models.sw_requirement import SwRequirementModel
+from db.models.api_sw_requirement import ApiSwRequirementModel
+from conftest import UT_USER_EMAIL
+from conftest import DB_NAME
+
+
+_MAPPING_API_SW_REQUIREMENTS_URL = '/mapping/api/sw-requirements'
+
+_UT_API_NAME = 'ut_api'
+_UT_API_LIBRARY = 'ut_api_library'
+_UT_API_LIBRARY_VERSION = 'v1.0.0'
+_UT_API_CATEGORY = 'ut_api_category'
+_UT_API_IMPLEMENTATION_FILE_FROM_ROW = 0
+_UT_API_IMPLEMENTATION_FILE_TO_ROW = 42
+_UT_API_TAGS = 'ut_api_tags'
+
+_UT_API_SPEC_SECTION_NO_MAPPING = 'This section has no mapping.'
+_UT_API_SPEC_SECTION_WITH_MAPPING = 'This section has mapping.'
+_UT_API_SPEC_SECTION_TO_BE_MAPPED = 'This section is to be mapped.'
+_UT_API_RAW_UNMAPPED_SPEC = f'BASIL UT: {_UT_API_SPEC_SECTION_NO_MAPPING} Used for {_MAPPING_API_SW_REQUIREMENTS_URL}.'
+_UT_API_RAW_MAPPED_SPEC = f'BASIL UT: {_UT_API_SPEC_SECTION_WITH_MAPPING} {_UT_API_SPEC_SECTION_TO_BE_MAPPED} ' \
+                          f'Used for {_MAPPING_API_SW_REQUIREMENTS_URL}.'
+
+
+def _get_sections_mapped_by_sw_requirements(mapped_sections):
+    return [section for section in mapped_sections if section['sw_requirements']]
+
+
+@pytest.fixture()
+def unmapped_api_db(client_db, ut_user_db, utilities):
+    """ Create Api with no mapped sections """
+
+    dbi = db_orm.DbInterface(DB_NAME)
+
+    user = dbi.session.query(UserModel).filter(UserModel.email == UT_USER_EMAIL).one()
+
+    # create raw API specification
+    raw_spec = tempfile.NamedTemporaryFile(mode="w", delete=False)
+    raw_spec.write(_UT_API_RAW_UNMAPPED_SPEC)
+    raw_spec.close()
+
+    # create API
+    ut_api = ApiModel(_UT_API_NAME + '#' + utilities.generate_random_hex_string8(),
+                      _UT_API_LIBRARY, _UT_API_LIBRARY_VERSION, raw_spec.name,
+                      _UT_API_CATEGORY, utilities.generate_random_hex_string8(), raw_spec.name + 'impl',
+                      _UT_API_IMPLEMENTATION_FILE_FROM_ROW, _UT_API_IMPLEMENTATION_FILE_TO_ROW,
+                      _UT_API_TAGS, user)
+    dbi.session.add(ut_api)
+    dbi.session.commit()
+
+    yield ut_api.id
+
+    # remove the raw_spec tempfile
+    if os.path.isfile(raw_spec.name):
+        os.remove(raw_spec.name)
+
+
+@pytest.fixture()
+def mapped_api_db(client_db, ut_user_db, utilities):
+    """ Create Api with one mapped section """
+
+    dbi = db_orm.DbInterface(DB_NAME)
+
+    user = dbi.session.query(UserModel).filter(UserModel.email == UT_USER_EMAIL).one()
+
+    # create raw API specification
+    raw_spec = tempfile.NamedTemporaryFile(mode="w", delete=False)
+    raw_spec.write(_UT_API_RAW_MAPPED_SPEC)
+    raw_spec.close()
+
+    # create API
+    ut_api = ApiModel(_UT_API_NAME + '#' + utilities.generate_random_hex_string8(),
+                      _UT_API_LIBRARY, _UT_API_LIBRARY_VERSION, raw_spec.name,
+                      _UT_API_CATEGORY, utilities.generate_random_hex_string8(), raw_spec.name + 'impl',
+                      _UT_API_IMPLEMENTATION_FILE_FROM_ROW, _UT_API_IMPLEMENTATION_FILE_TO_ROW,
+                      _UT_API_TAGS, user)
+    ut_sw_requirement = SwRequirementModel(f'SW req #{utilities.generate_random_hex_string8()}',
+                                           'SW shall work as well as possible.', user)
+    ut_api_requirement_mapping = ApiSwRequirementModel(ut_api, ut_sw_requirement, _UT_API_SPEC_SECTION_WITH_MAPPING,
+                                                       _UT_API_RAW_MAPPED_SPEC.find(_UT_API_SPEC_SECTION_WITH_MAPPING),
+                                                       0, user)
+
+    dbi.session.add(ut_api)
+    dbi.session.add(ut_sw_requirement)
+    dbi.session.add(ut_api_requirement_mapping)
+    dbi.session.commit()
+
+    yield ut_api.id
+
+    # remove the raw_spec tempfile
+    if os.path.isfile(raw_spec.name):
+        os.remove(raw_spec.name)
+
+
+def test_login(user_authentication):
+    """ Just ensure we are logged in """
+    assert user_authentication.status_code == 200
+
+
+def test_api_sw_requirement_post_ok(client, user_authentication, unmapped_api_db, utilities):
+    """ Nominal test for mapping a section """
+
+    api_id = unmapped_api_db
+    sw_requirement_title = f'SW req #{utilities.generate_random_hex_string8()}'
+
+    # ensure there is no mapped SW requirements for this API
+    response = client.get(_MAPPING_API_SW_REQUIREMENTS_URL, query_string={'api-id': api_id})
+    assert response.status_code == 200
+    mapped_sections = _get_sections_mapped_by_sw_requirements(response.json['mapped'])
+    assert len(mapped_sections) == 0
+
+    # map a SW requirement to unmapped section
+    mapping_data = {
+        'user-id': user_authentication.json['id'],
+        'token': user_authentication.json['token'],
+        'api-id': api_id,
+        'section': _UT_API_SPEC_SECTION_NO_MAPPING,
+        'offset': _UT_API_RAW_UNMAPPED_SPEC.find(_UT_API_SPEC_SECTION_NO_MAPPING),
+        'coverage': 0,
+        'sw-requirement': {
+            'title': sw_requirement_title,
+            'description': 'SW requirement description'
+        }
+    }
+    response = client.post(_MAPPING_API_SW_REQUIREMENTS_URL, json=mapping_data)
+    assert response.status_code == 200
+
+    # ensure SW requirement is added
+    response = client.get(_MAPPING_API_SW_REQUIREMENTS_URL, query_string={'api-id': api_id})
+    assert response.status_code == 200
+    mapped_sections = _get_sections_mapped_by_sw_requirements(response.json['mapped'])
+    assert len(mapped_sections) == 1  # there should be only one mapped section
+    assert mapped_sections[0]['section'] == _UT_API_SPEC_SECTION_NO_MAPPING
+    assert mapped_sections[0]['offset'] == _UT_API_RAW_UNMAPPED_SPEC.find(_UT_API_SPEC_SECTION_NO_MAPPING)
+    mapped_sw_requirements = mapped_sections[0]['sw_requirements']
+    assert len(mapped_sw_requirements) == 1  # there should be only one SW requirement
+    assert mapped_sw_requirements[0]['sw_requirement']['title'] == sw_requirement_title
+    assert mapped_sw_requirements[0]['created_by'] == UT_USER_EMAIL
+
+
+def test_api_sw_requirement_put_ok(client, user_authentication, mapped_api_db):
+    """ Nominal test for mapping update """
+
+    api_id = mapped_api_db
+    # ensure there is a mapped SW requirement for this API
+    response = client.get(_MAPPING_API_SW_REQUIREMENTS_URL, query_string={'api-id': api_id})
+    assert response.status_code == 200
+    # there should be only one mapped section: _UT_API_SPEC_SECTION_WITH_MAPPING
+    mapped_sections = _get_sections_mapped_by_sw_requirements(response.json['mapped'])
+    assert len(mapped_sections) == 1
+    assert mapped_sections[0]['section'] == _UT_API_SPEC_SECTION_WITH_MAPPING
+    # get the relation ID and SW requirement
+    relation_id = mapped_sections[0]['sw_requirements'][0]['relation_id']
+    sw_requirement = mapped_sections[0]['sw_requirements'][0]['sw_requirement']
+
+    # update mapping from _UT_API_SPEC_SECTION_WITH_MAPPING to _UT_API_SPEC_SECTION_TO_BE_MAPPED
+    mapping_data = {
+        'user-id': user_authentication.json['id'],
+        'token': user_authentication.json['token'],
+        'relation-id': relation_id,
+        'api-id': api_id,
+        'section': _UT_API_SPEC_SECTION_TO_BE_MAPPED,
+        'offset': _UT_API_RAW_MAPPED_SPEC.find(_UT_API_SPEC_SECTION_TO_BE_MAPPED),
+        'coverage': 0,
+        'sw-requirement': sw_requirement
+    }
+    response = client.put(_MAPPING_API_SW_REQUIREMENTS_URL, json=mapping_data)
+    assert response.status_code == 200
+
+    # ensure mapping is updated
+    response = client.get(_MAPPING_API_SW_REQUIREMENTS_URL, query_string={'api-id': api_id})
+    assert response.status_code == 200
+    mapped_sections = _get_sections_mapped_by_sw_requirements(response.json['mapped'])
+    # there should be only one mapped section: _UT_API_SPEC_SECTION_TO_BE_MAPPED
+    assert len(mapped_sections) == 1
+    assert mapped_sections[0]['section'] == _UT_API_SPEC_SECTION_TO_BE_MAPPED
+    assert mapped_sections[0]['offset'] == _UT_API_RAW_MAPPED_SPEC.find(_UT_API_SPEC_SECTION_TO_BE_MAPPED)
+    assert len(mapped_sections[0]['sw_requirements']) == 1  # there should be only one SW requirement
+
+
+def test_api_sw_requirement_delete_ok(client, user_authentication, mapped_api_db):
+    """ Nominal test for mapping removal """
+
+    api_id = mapped_api_db
+    # ensure there is a mapped SW requirement for this API
+    response = client.get(_MAPPING_API_SW_REQUIREMENTS_URL, query_string={'api-id': api_id})
+    assert response.status_code == 200
+    mapped_sections = _get_sections_mapped_by_sw_requirements(response.json['mapped'])
+    # there should be only one mapped section: _UT_API_SPEC_SECTION_WITH_MAPPING
+    assert len(mapped_sections) == 1
+    assert mapped_sections[0]['section'] == _UT_API_SPEC_SECTION_WITH_MAPPING
+    # get the relation ID
+    relation_id = mapped_sections[0]['sw_requirements'][0]['relation_id']
+
+    # delete mapping
+    mapping_data = {
+        'user-id': user_authentication.json['id'],
+        'token': user_authentication.json['token'],
+        'relation-id': relation_id,
+        'api-id': api_id
+    }
+    response = client.delete(_MAPPING_API_SW_REQUIREMENTS_URL, json=mapping_data)
+    assert response.status_code == 200
+
+    # ensure the relation is deleted
+    response = client.get(_MAPPING_API_SW_REQUIREMENTS_URL, query_string={'api-id': api_id})
+    assert response.status_code == 200
+    mapped_sections = _get_sections_mapped_by_sw_requirements(response.json['mapped'])
+    assert len(mapped_sections) == 0


Reviews:
user: login: pellecchialuigi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-04T14:11:11Z
user: login: AliaksandrKustsinski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T15:02:19Z

Review comments:
diff_hunk: @@ -0,0 +1,220 @@
+import os
+import pytest
+import tempfile
+import string
+import random
+from db import db_orm
+from db.models.user import UserModel
+from db.models.api import ApiModel
+from db.models.sw_requirement import SwRequirementModel
+from db.models.api_sw_requirement import ApiSwRequirementModel
+from conftest import UT_USER_EMAIL
+from conftest import DB_NAME
+
+
+_MAPPING_API_SW_REQUIREMENTS_URL = '/mapping/api/sw-requirements'
+
+_UT_API_NAME = 'ut_api'
+_UT_API_LIBRARY = 'ut_api_library'
+_UT_API_LIBRARY_VERSION = 'v1.0.0'
+_UT_API_CATEGORY = 'ut_api_category'
+_UT_API_IMPLEMENTATION_FILE_FROM_ROW = 0
+_UT_API_IMPLEMENTATION_FILE_TO_ROW = 42
+_UT_API_TAGS = 'ut_api_tags'
+
+_UT_API_SPEC_SECTION_NO_MAPPING = 'This section has no mapping.'
+_UT_API_SPEC_SECTION_WITH_MAPPING = 'This section has mapping.'
+_UT_API_SPEC_SECTION_TO_BE_MAPPED = 'This section is to be mapped.'
+_UT_API_RAW_UNMAPPED_SPEC = f'BASIL UT: {_UT_API_SPEC_SECTION_NO_MAPPING} Used for {_MAPPING_API_SW_REQUIREMENTS_URL}.'
+_UT_API_RAW_MAPPED_SPEC = f'BASIL UT: {_UT_API_SPEC_SECTION_WITH_MAPPING} {_UT_API_SPEC_SECTION_TO_BE_MAPPED} ' \
+                          f'Used for {_MAPPING_API_SW_REQUIREMENTS_URL}.'
+
+
+def _random_hex_string8():
path: api/test/test_api_sw_requirement_mapping.py
user: login: pellecchialuigi
body: This function already exists in test_api_documents_mapping.py
May I ask you to centralize such functions to avoid code duplication?
created_at: 2025-04-04T14:11:11Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,220 @@
+import os
+import pytest
+import tempfile
+import string
+import random
+from db import db_orm
+from db.models.user import UserModel
+from db.models.api import ApiModel
+from db.models.sw_requirement import SwRequirementModel
+from db.models.api_sw_requirement import ApiSwRequirementModel
+from conftest import UT_USER_EMAIL
+from conftest import DB_NAME
+
+
+_MAPPING_API_SW_REQUIREMENTS_URL = '/mapping/api/sw-requirements'
+
+_UT_API_NAME = 'ut_api'
+_UT_API_LIBRARY = 'ut_api_library'
+_UT_API_LIBRARY_VERSION = 'v1.0.0'
+_UT_API_CATEGORY = 'ut_api_category'
+_UT_API_IMPLEMENTATION_FILE_FROM_ROW = 0
+_UT_API_IMPLEMENTATION_FILE_TO_ROW = 42
+_UT_API_TAGS = 'ut_api_tags'
+
+_UT_API_SPEC_SECTION_NO_MAPPING = 'This section has no mapping.'
+_UT_API_SPEC_SECTION_WITH_MAPPING = 'This section has mapping.'
+_UT_API_SPEC_SECTION_TO_BE_MAPPED = 'This section is to be mapped.'
+_UT_API_RAW_UNMAPPED_SPEC = f'BASIL UT: {_UT_API_SPEC_SECTION_NO_MAPPING} Used for {_MAPPING_API_SW_REQUIREMENTS_URL}.'
+_UT_API_RAW_MAPPED_SPEC = f'BASIL UT: {_UT_API_SPEC_SECTION_WITH_MAPPING} {_UT_API_SPEC_SECTION_TO_BE_MAPPED} ' \
+                          f'Used for {_MAPPING_API_SW_REQUIREMENTS_URL}.'
+
+
+def _random_hex_string8():
path: api/test/test_api_sw_requirement_mapping.py
user: login: AliaksandrKustsinski
body: Sure, I've added a dedicated fixture to put the shared functions in it.
created_at: 2025-04-04T15:02:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: pellecchialuigi
created_at: 2025-04-07T12:22:00Z
body: Another aspect we can cover is about missed parameters in the payload. WDYT?
author_association: COLLABORATOR
user: login: AliaksandrKustsinski
created_at: 2025-04-07T14:51:37Z
body: Of course, it is possible. For now, I have little knowledge about usage of this API: most used endpoints, most frequent scenarios, etc. If you have any recommendations for tests (endpoint priorities, test scenarios, etc), please share them in https://github.com/elisa-tech/BASIL/issues/32. I'll follow them. 
author_association: CONTRIBUTOR
user: login: pellecchialuigi
created_at: 2025-04-07T20:57:26Z
body: I'll file separate issues, one for each endpoint. I'll add more context to each issue.
Thanks
author_association: COLLABORATOR
user: login: pellecchialuigi
created_at: 2025-04-08T07:42:48Z
body: @AliaksandrKustsinski I filed #139 to describe this endpoint and possible scenarios.
There are some verification that will be generic, like: if user doesn't have write permission PUT, POST, DELETE should return UNAUTHORIZED.
We can also approach the implementation incrementally and have this PR merged as starting point.
That can also simplify the review.

NOTE: In the issue I filed there are some TODO as I found some miss verification in the api code. I can take care of that in separate PRs.

author_association: COLLABORATOR
user: login: AliaksandrKustsinski
created_at: 2025-04-09T07:17:54Z
body: Wow, perfect! And it looks like a beginning for https://github.com/elisa-tech/BASIL/issues/17 :)
author_association: CONTRIBUTOR
user: login: pellecchialuigi
created_at: 2025-04-09T14:40:15Z
body: For the API documentation I'm not sure if creating something textual as this or something with open api, swagger. 
Do you have any past experience with that? Any suggestions?
Moreover how do you prefer to proceed with this PR, we can also merge this and approach enhancements in other PRs. As you prefer.
author_association: COLLABORATOR
user: login: AliaksandrKustsinski
created_at: 2025-04-10T10:39:50Z
body: > Do you have any past experience with that? Any suggestions?

Long time ago I used Swagger (somewhere around its release date). I think, it's a good choice.

> Moreover how do you prefer to proceed with this PR, we can also merge this and approach enhancements in other PRs.

Yes, let's merge this one. The improvements are to be made as a separate PR (or PRs) related to https://github.com/elisa-tech/BASIL/issues/139.

author_association: CONTRIBUTOR