diff --git a/api/api.py b/api/api.py index 9879ed1..6ecc178 100644 --- a/api/api.py +++ b/api/api.py @@ -76,6 +76,7 @@ BORDER_COLOR_TEST_CASE, BORDER_COLOR_TEST_SPECIFICATION, code_to_html, + combine_tmt_path, document_to_html, extend_unmapped_sections_for_auto_fix, get_api_specification, @@ -4118,7 +4119,7 @@ def post( ) return api_response.return_conflict() - test_case_path = os.path.join(repository, relative_path) + test_case_path = combine_tmt_path(repository, relative_path) # In case of local file if test_case_path.startswith(os.path.sep): if not is_safe_local_user_file_path(test_case_path): @@ -4216,10 +4217,7 @@ def put( setattr(test_case, field, request_data["test-case"][field.replace("_", "-")]) if modified_tc: - test_case_path = os.path.join( - test_case.repository, - str(test_case.relative_path).lstrip(os.path.sep), - ) + test_case_path = combine_tmt_path(test_case.repository, test_case.relative_path) # In case of local file if test_case_path.startswith(os.path.sep): if not is_safe_local_user_file_path(test_case_path): @@ -6291,7 +6289,7 @@ def get( # if repository is a local path, return the file content if test_case_mapping.test_case.repository.startswith("/"): - test_case_path = os.path.join( + test_case_path = combine_tmt_path( test_case_mapping.test_case.repository, test_case_mapping.test_case.relative_path ) @@ -7204,7 +7202,7 @@ def post(self, api_response: ApiResponse = None): ) return api_response.return_conflict() - test_case_path = os.path.join(repository, relative_path) + test_case_path = combine_tmt_path(repository, relative_path) # In case of local file if test_case_path.startswith(os.path.sep): if not is_safe_local_user_file_path(test_case_path): @@ -7321,7 +7319,7 @@ def put(self, api_response: ApiResponse = None): setattr(test_case, field, request_data["test-case"][field.replace("_", "-")]) if modified_tc: - test_case_path = os.path.join( + test_case_path = combine_tmt_path( request_data["test-case"]["repository"], request_data["test-case"]["relative-path"] ) # In case of local file @@ -7585,7 +7583,7 @@ def post( ) return api_response.return_conflict() - test_case_path = os.path.join(repository, relative_path) + test_case_path = combine_tmt_path(repository, relative_path) # In case of local file if test_case_path.startswith(os.path.sep): if not is_safe_local_user_file_path(test_case_path): @@ -7698,7 +7696,7 @@ def put( setattr(test_case, field, request_data["test-case"][field.replace("_", "-")]) if modified_tc: - test_case_path = os.path.join( + test_case_path = combine_tmt_path( request_data["test-case"]["repository"], request_data["test-case"]["relative-path"] ) # In case of local file diff --git a/api/api_utils.py b/api/api_utils.py index 1725d17..cf04eb5 100644 --- a/api/api_utils.py +++ b/api/api_utils.py @@ -786,6 +786,24 @@ def is_safe_user_path(user_root, requested_path): return abs_target == abs_root or abs_target.startswith(abs_root + os.sep) +def combine_tmt_path(repository: str, relative_path: str) -> str: + """Join a TMT test repository path with a relative path. + + User-file relative paths from the UI often start with a leading separator + (for example ``/api/user-files//tmt-dummy-test``). ``os.path.join`` + treats a later absolute segment as a new root and would discard + *repository*. This helper strips leading separators from *relative_path* + before joining so the result stays under *repository*. + """ + repo = str(repository or "") + rel = str(relative_path or "").lstrip("/" + os.sep) + if not repo: + return rel + if not rel: + return repo + return os.path.join(repo, rel) + + def is_safe_local_user_file_path(path: str) -> bool: from api import USER_FILES_BASE_DIR return path.startswith(os.path.abspath(USER_FILES_BASE_DIR) + os.sep) diff --git a/api/test/test_api_utils.py b/api/test/test_api_utils.py index 8eef057..829d313 100644 --- a/api/test/test_api_utils.py +++ b/api/test/test_api_utils.py @@ -17,6 +17,7 @@ from api_utils import ( LINK_BASIL_INSTANCE_HTML_MESSAGE, add_html_link_to_email_body, + combine_tmt_path, load_settings ) @@ -83,3 +84,41 @@ def test_add_html_link_to_email_body(client, admin_authentication): body = add_html_link_to_email_body(settings=settings, body=initial_body) assert body != "" assert LINK_BASIL_INSTANCE_HTML_MESSAGE in body + + +@pytest.mark.parametrize( + "repository, relative_path, expected", + [ + ( + "/BASIL-API", + "/api/user-files/2/tmt/tmt-dummy-test", + "/BASIL-API/api/user-files/2/tmt/tmt-dummy-test", + ), + ( + "/BASIL-API", + "api/user-files/2/tmt/tmt-dummy-test", + "/BASIL-API/api/user-files/2/tmt/tmt-dummy-test", + ), + ( + "/opt/basil", + "examples/tmt/local/tmt-dummy-test.fmf", + "/opt/basil/examples/tmt/local/tmt-dummy-test.fmf", + ), + ("/repo", "", "/repo"), + ("", "tests/foo.fmf", "tests/foo.fmf"), + ("/repo/", "/nested/test", "/repo/nested/test"), + (None, "/api/user-files/2/test", "api/user-files/2/test"), + ], +) +def test_combine_tmt_path(repository, relative_path, expected): + assert combine_tmt_path(repository, relative_path) == expected + + +def test_combine_tmt_path_does_not_drop_repository_when_relative_is_absolute(): + """os.path.join discards repository when relative_path is absolute; combine_tmt_path must not.""" + repository = "/BASIL-API" + relative_path = "/api/user-files/2/tmt/tmt-dummy-test" + assert os.path.join(repository, relative_path) == relative_path + assert combine_tmt_path(repository, relative_path) == ( + "/BASIL-API/api/user-files/2/tmt/tmt-dummy-test" + ) diff --git a/api/test/test_sw_requirement_test_case_mapping.py b/api/test/test_sw_requirement_test_case_mapping.py index a26162b..bd4cad9 100644 --- a/api/test/test_sw_requirement_test_case_mapping.py +++ b/api/test/test_sw_requirement_test_case_mapping.py @@ -515,6 +515,36 @@ def test_put_ok(client, client_db, user_authentication, mapped_api_sr_tc_db, uti assert response.status_code == HTTPStatus.OK +def test_put_user_file_relative_path(client, client_db, user_authentication, mapped_api_sr_tc_db): + """Edit Test Case from user files sends repository=BASIL root and /api/user-files/... relative-path.""" + import api as basil_api + + api, sw_requirement, api_sr_mapping, sr_tc_mapping = mapped_api_sr_tc_db + auth = user_authentication.json + user_id = auth["id"] + basil_root = os.path.dirname(os.path.dirname(os.path.abspath(basil_api.USER_FILES_BASE_DIR))) + relative_path = f"/api/user-files/{user_id}/tmt/tmt-dummy-test" + + ut_test_case_dict = sr_tc_mapping.test_case.as_dict() + ut_test_case_dict = {k.replace("_", "-"): v for k, v in ut_test_case_dict.items()} + ut_test_case_dict["repository"] = basil_root + ut_test_case_dict["relative-path"] = relative_path + + mapping_data = { + "api-id": api.id, + "coverage": sr_tc_mapping.coverage, + "relation-id": sr_tc_mapping.id, + "sw-requirement": {"id": sw_requirement.id}, + "test-case": ut_test_case_dict, + "user-id": auth["id"], + "token": auth["token"], + } + response = client.put(_MAPPING_SW_REQUIREMENT_TEST_CASES_URL, json=mapping_data) + assert response.status_code == HTTPStatus.OK + assert response.json["test_case"]["repository"] == basil_root + assert response.json["test_case"]["relative_path"] == relative_path + + # Test DELETE diff --git a/api/test/test_test_case_local_file_implementation.py b/api/test/test_test_case_local_file_implementation.py index 172e561..037780f 100644 --- a/api/test/test_test_case_local_file_implementation.py +++ b/api/test/test_test_case_local_file_implementation.py @@ -443,6 +443,35 @@ def test_get_ok_api_relation_local_file( _remove_if_exists(path) +def test_get_ok_api_relation_local_file_ui_style_relative_path( + client, client_db, user_authentication, mapped_api_tc_db, utilities +): + """UI stores repository as BASIL root and relative_path as /api/user-files//file.""" + api, test_case, api_tc_mapping = mapped_api_tc_db + auth = user_authentication.json + user_id = auth["id"] + content = "ui-style-path\n" + base = os.path.join(os.path.abspath(basil_api.USER_FILES_BASE_DIR), str(user_id)) + os.makedirs(base, exist_ok=True) + filename = f"ut_tclocal_{utilities.generate_random_hex_string8()}.txt" + path = os.path.join(base, filename) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + basil_root = os.path.dirname(os.path.dirname(os.path.abspath(basil_api.USER_FILES_BASE_DIR))) + test_case.repository = basil_root + test_case.relative_path = f"/api/user-files/{user_id}/{filename}" + client_db.session.add(test_case) + client_db.session.commit() + try: + response = _get_local_file_impl( + client, auth, api.id, test_case.id, api_tc_mapping.id, "api" + ) + assert response.status_code == HTTPStatus.OK + assert response.get_data(as_text=True) == content + finally: + _remove_if_exists(path) + + def test_get_bad_request_unsafe_local_path( client, client_db, user_authentication, mapped_api_tc_db, utilities ): diff --git a/api/test/test_test_specification_test_case_mapping.py b/api/test/test_test_specification_test_case_mapping.py index fe79f5d..a162b58 100644 --- a/api/test/test_test_specification_test_case_mapping.py +++ b/api/test/test_test_specification_test_case_mapping.py @@ -628,6 +628,36 @@ def test_put_ok(client, client_db, user_authentication, mapped_api_ts_tc_db, uti assert response.status_code == HTTPStatus.OK +def test_put_user_file_relative_path(client, client_db, user_authentication, mapped_api_ts_tc_db): + """Edit Test Case from user files sends repository=BASIL root and /api/user-files/... relative-path.""" + import api as basil_api + + api, test_specification, api_ts_mapping, ts_tc_mapping = mapped_api_ts_tc_db + auth = user_authentication.json + user_id = auth["id"] + basil_root = os.path.dirname(os.path.dirname(os.path.abspath(basil_api.USER_FILES_BASE_DIR))) + relative_path = f"/api/user-files/{user_id}/tmt/tmt-dummy-test" + + ut_test_case_dict = ts_tc_mapping.test_case.as_dict() + ut_test_case_dict = {k.replace("_", "-"): v for k, v in ut_test_case_dict.items()} + ut_test_case_dict["repository"] = basil_root + ut_test_case_dict["relative-path"] = relative_path + + mapping_data = { + "api-id": api.id, + "coverage": ts_tc_mapping.coverage, + "relation-id": ts_tc_mapping.id, + "test-specification": {"id": test_specification.id}, + "test-case": ut_test_case_dict, + "user-id": auth["id"], + "token": auth["token"], + } + response = client.put(_MAPPING_TEST_SPECIFICATION_TEST_CASES_URL, json=mapping_data) + assert response.status_code == HTTPStatus.OK + assert response.json["test_case"]["repository"] == basil_root + assert response.json["test_case"]["relative_path"] == relative_path + + # Test DELETE diff --git a/api/test/test_testrun_tmt_validation.py b/api/test/test_testrun_tmt_validation.py index d8bfd30..8dec456 100644 --- a/api/test/test_testrun_tmt_validation.py +++ b/api/test/test_testrun_tmt_validation.py @@ -179,6 +179,34 @@ def test_user_can_access_own_folder(self, basil_root_path, user_x): except SystemExit: pytest.fail("User should be able to access their own folder") + def test_user_can_access_own_folder_with_ui_style_path(self, basil_root_path, user_x): + """UI stores repository as BASIL root and relative_path as /api/user-files//...""" + + test_case = MockTestCase( + repository=basil_root_path, + relative_path=f"/api/user-files/{user_x.id}/tmt/tmt-dummy-test", + ) + + config = { + "id": 1, + "title": "Test Config", + "provision_type": "container", + "context": {"plan_type": "local"}, + "git_repo_ref": "", + "env": { + "basil_test_repo_path": test_case.repository, + "basil_test_relative_path": test_case.relative_path, + }, + } + + runner = MockRunner(user_x, test_case, config) + + try: + plugin = TestRunnerTmtPlugin(runner=runner) + assert plugin is not None + except SystemExit: + pytest.fail("User should be able to access their own folder via UI-style TMT path") + def test_user_can_access_basil_examples(self, basil_root_path, user_x): """Test that any user can access BASIL example files""" diff --git a/api/testrun_lava.py b/api/testrun_lava.py index 6587553..8c4cb11 100644 --- a/api/testrun_lava.py +++ b/api/testrun_lava.py @@ -8,7 +8,7 @@ import requests import yaml -from api_utils import get_api_specification +from api_utils import combine_tmt_path, get_api_specification from testrun_base import TestRunnerBasePlugin logger = logging.getLogger(__name__) @@ -117,8 +117,8 @@ def __init__(self, runner=None, *args, **kwargs): } else: # read the file - test_file_path = os.path.join( - self.runner.mapping.test_case.repository, self.runner.mapping.test_case.relative_path.lstrip("/") + test_file_path = combine_tmt_path( + self.runner.mapping.test_case.repository, self.runner.mapping.test_case.relative_path ) if os.path.exists(test_file_path): diff --git a/api/testrun_tmt.py b/api/testrun_tmt.py index 3f57b86..b929963 100644 --- a/api/testrun_tmt.py +++ b/api/testrun_tmt.py @@ -6,6 +6,7 @@ import subprocess import yaml +from api_utils import combine_tmt_path from testrun_base import TestRunnerBasePlugin logger = logging.getLogger(__name__) @@ -108,7 +109,7 @@ def internal_validate(self): # Calculate the user folder path user_folder_path = os.path.join(basil_path, "api", "user-files", user_id_str) # Calculate resulting test path considering also possible ../ (or multiple ../) - test_path = os.path.join( + test_path = combine_tmt_path( self.config["env"]["basil_test_repo_path"], self.config["env"]["basil_test_relative_path"] ) diff --git a/app/cypress/e2e/test_case_from_user_files.cy.js b/app/cypress/e2e/test_case_from_user_files.cy.js new file mode 100644 index 0000000..929e413 --- /dev/null +++ b/app/cypress/e2e/test_case_from_user_files.cy.js @@ -0,0 +1,125 @@ +/// + +/** + * Test Case implementation from User Files + * + * Covers every mapping parent that uses TestCaseForm (and therefore + * splitUserFileToTmtPath): + * - Test Case mapped to the Software Component (API) + * - Test Case mapped to a Test Specification + * - Test Case mapped to a Software Requirement + * + * For each parent, add and edit via "From user files" and assert the saved + * repository + relative-path match splitUserFileToTmtPath(). + */ + +import '../support/e2e.js' +import api_data_fixture from '../fixtures/api.json' +import const_data from '../fixtures/consts.json' +import sr_data_fixture from '../fixtures/sw_requirement.json' +import tc_data_fixture from '../fixtures/test_case.json' +import ts_data_fixture from '../fixtures/test_specification.json' +import { createUniqWorkItems } from '../support/utils.js' + +let api_data = createUniqWorkItems(api_data_fixture, ['api']) +let ts_data = createUniqWorkItems(ts_data_fixture, ['title']) +let sr_data = createUniqWorkItems(sr_data_fixture, ['title']) +let tc_data = createUniqWorkItems(tc_data_fixture, ['title']) + +const unique = Date.now().toString() +const userFileName = 'tmt-dummy-test-' + unique + '.fmf' +const basil_root_path = Cypress.config('projectRoot').replace('/app', '/') +const sourceTestFile = basil_root_path + 'examples/tmt/local/tmt-dummy-test.fmf' + +const uiTimeout = 20000 + +const visitMapping = (apiId, view) => { + cy.visit(const_data.app_base_url + '/mapping/' + apiId) + cy.wait(const_data.long_wait) + if (view) { + cy.get(const_data.mapping.select_view_id, { timeout: uiTimeout }).select(view, { force: true }) + cy.wait(const_data.long_wait) + } +} + +describe( + 'Test Case from user files', + { + defaultCommandTimeout: 15000, + requestTimeout: 15000, + viewportWidth: 1280, + viewportHeight: 900, + scrollBehavior: 'center' + }, + () => { + let apiId + + beforeEach(() => { + cy.login_admin() + }) + + it('Setup: Create SW Component', () => { + cy.get('#btn-add-sw-component').click() + cy.fill_form_api('0', 'add', api_data.first, true, false) + cy.get('#btn-modal-api-confirm').click() + cy.wait(2000) + + cy.filter_api_from_dashboard(api_data.first) + cy.get(const_data.api.table_listing_id) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .invoke('text') + .then((id) => { + apiId = String(id).trim() + }) + }) + + it('Upload a TMT user file', () => { + cy.readFile(sourceTestFile).then((contents) => { + cy.get('#nav-item-user-files').click() + cy.wait(const_data.long_wait) + cy.get('#btn-add-user-file').click() + cy.wait(const_data.fast_wait) + cy.get('#user-file-upload-browse-button').selectFile( + { + contents: Cypress.Buffer.from(contents), + fileName: userFileName, + mimeType: 'text/plain' + }, + { action: 'drag-drop' } + ) + cy.get('#btn-user-file-modal-confirm').click() + cy.wait(const_data.long_wait) + cy.get('#table-user-files', { timeout: uiTimeout }).should('contain.text', userFileName) + }) + }) + + it('Add and edit Test Case from user files mapped to the Software Component', () => { + visitMapping(apiId, 'test-cases') + cy.assign_test_case_from_user_file(-1, 0, '', tc_data.first, userFileName) + cy.edit_test_case_from_user_file(0, tc_data.first_mod, userFileName) + cy.delete_work_item(0, 'test-case') + }) + + it('Add and edit Test Case from user files mapped to a Test Specification', () => { + visitMapping(apiId, 'test-specifications') + cy.assign_work_item(-1, 0, '', 'test-specification', ts_data.first) + cy.assign_test_case_from_user_file(0, 1, 'test-specification', tc_data.second, userFileName) + cy.edit_test_case_from_user_file(1, tc_data.second_mod, userFileName) + cy.delete_work_item(1, 'test-case') + cy.delete_work_item(0, 'test-specification') + }) + + it('Add and edit Test Case from user files mapped to a Software Requirement', () => { + visitMapping(apiId) + cy.assign_work_item(-1, 0, '', 'sw-requirement', sr_data.first) + cy.assign_test_case_from_user_file(0, 1, 'sw-requirement', tc_data.third, userFileName) + cy.edit_test_case_from_user_file(1, tc_data.third_mod, userFileName) + cy.delete_work_item(1, 'test-case') + cy.delete_work_item(0, 'sw-requirement') + }) + } +) diff --git a/app/cypress/support/commands.js b/app/cypress/support/commands.js index 1cff4cc..66189a7 100644 --- a/app/cypress/support/commands.js +++ b/app/cypress/support/commands.js @@ -25,6 +25,7 @@ // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) import const_data from '../fixtures/consts.json' +import { splitUserFileToTmtPath } from '../../src/app/Constants/constants' export function registerCommands() { Cypress.Commands.add('clear_db', () => { @@ -506,4 +507,144 @@ export function registerCommands() { } }) }) + + Cypress.Commands.add('fill_test_case_from_user_file', (_action, _obj, _filename) => { + cy.get('[id*="input-test-case-' + _action + '-title-"]') + .clear() + .type(_obj.title) + .should('have.value', _obj.title) + cy.get('[id*="input-test-case-' + _action + '-description-"]') + .clear() + .type(_obj.description) + cy.get('[id*="input-test-case-' + _action + '-coverage-"]') + .clear() + .type(String(_obj.coverage)) + .should('have.value', String(_obj.coverage)) + + cy.get('#btn-mapping-test-case-from-user-files').click() + cy.get('[id*="select-test-case-' + _action + '-file-"]', { timeout: 15000 }).should('be.visible') + cy.get('[id*="select-test-case-' + _action + '-file-"] option', { timeout: 15000 }).should(($opts) => { + expect($opts.length).to.be.greaterThan(1) + const texts = [...$opts].map((o) => o.textContent || '') + expect( + texts.some((t) => t.includes(_filename)), + 'user file ' + _filename + ' in select options' + ).to.eq(true) + }) + cy.get('[id*="select-test-case-' + _action + '-file-"] option').then(($opts) => { + const match = [...$opts].find((o) => (o.textContent || '').includes(_filename)) + expect(match, 'option for ' + _filename).to.exist + cy.get('[id*="select-test-case-' + _action + '-file-"]').select(match.value) + }) + }) + + Cypress.Commands.add('submit_test_case_from_user_file', (_action, _method) => { + const alias = 'saveTestCaseFromUserFile' + cy.intercept({ method: _method, url: '**/mapping/**/test-cases' }).as(alias) + cy.get('[id*="select-test-case-' + _action + '-file-"]') + .invoke('val') + .then((filepath) => { + expect(filepath, 'selected user-file filepath').to.be.a('string').and.not.equal('') + cy.get('#btn-mapping-test-case-submit').click() + cy.wait('@' + alias).then((interception) => { + expect(interception.response.statusCode).to.be.oneOf([200, 201]) + const body = + typeof interception.request.body === 'string' + ? JSON.parse(interception.request.body) + : interception.request.body + const expected = splitUserFileToTmtPath(filepath) + const tc = body['test-case'] + expect(tc.repository, 'repository from splitUserFileToTmtPath').to.eq(expected.repository) + expect(tc['relative-path'], 'relative-path from splitUserFileToTmtPath').to.eq(expected.relativePath) + }) + }) + }) + + Cypress.Commands.add('assign_test_case_from_user_file', (_parent_index, _index, _parent_type, _obj, _filename) => { + const tableOptions = { timeout: 15000 } + let card + + if (_parent_index > -1) { + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) + + card.find('button[class*="pf-v5-c-menu-toggle"]').each(($el, index) => { + if (index == _parent_index) { + cy.wrap($el).click() + } + }) + + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) + + card.each(($el, index) => { + if (index == _parent_index) { + cy.wrap($el) + .find('button[id*="btn-menu-' + _parent_type + '-assign-test-case-"]') + .click() + } + }) + } else { + cy.get(const_data.mapping.table_matching_id).find('tbody').find('tr').eq(0).find('td').eq(0).find('button').click() + cy.get('#btn-mapping-section-test-case-0').click() + } + + cy.fill_test_case_from_user_file('add', _obj, _filename) + cy.wait(const_data.long_wait) + cy.submit_test_case_from_user_file('add', 'POST') + cy.wait(const_data.long_wait * 3) + cy.check_work_item(_index, 'test-case', _obj) + }) + + Cypress.Commands.add('edit_test_case_from_user_file', (_index, _obj, _filename) => { + const tableOptions = { timeout: 15000 } + + let card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) + + card.find('button[class*="pf-v5-c-menu-toggle"]').each(($el, index) => { + if (index == _index) { + cy.wrap($el).click() + } + }) + + card = cy + .get(const_data.mapping.table_matching_id, tableOptions) + .find('tbody') + .find('tr') + .eq(0) + .find('td') + .eq(1) + .find('.pf-v5-c-card', tableOptions) + + card.each(($el, index) => { + if (index == _index) { + cy.wrap($el) + .find('button[id^="btn-menu-test-case-edit"]') + .click() + cy.fill_test_case_from_user_file('edit', _obj, _filename) + cy.submit_test_case_from_user_file('edit', 'PUT') + cy.wait(const_data.long_wait) + cy.check_work_item(_index, 'test-case', _obj) + } + }) + }) } diff --git a/app/jest.config.js b/app/jest.config.js new file mode 100644 index 0000000..17fc2f4 --- /dev/null +++ b/app/jest.config.js @@ -0,0 +1,9 @@ +/** @type {import('jest').Config} */ +module.exports = { + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': ['ts-jest', { isolatedModules: true }] + }, + // .ts unit tests (e.g. constants.test.ts). The existing App RTL suite is .tsx. + testMatch: ['**/?(*.)+(test).ts'] +} diff --git a/app/src/app/Constants/constants.test.ts b/app/src/app/Constants/constants.test.ts new file mode 100644 index 0000000..b801ca8 --- /dev/null +++ b/app/src/app/Constants/constants.test.ts @@ -0,0 +1,38 @@ +import { splitUserFileToTmtPath } from './constants' + +describe('splitUserFileToTmtPath', () => { + test('splits a container user-file path and strips .fmf', () => { + expect(splitUserFileToTmtPath('/BASIL-API/api/user-files/2/tmt/tmt-dummy-test.fmf')).toEqual({ + repository: '/BASIL-API', + relativePath: '/api/user-files/2/tmt/tmt-dummy-test' + }) + }) + + test('splits a local checkout user-file path and strips .fmf', () => { + expect(splitUserFileToTmtPath('/Users/dev/BASIL/api/user-files/2/tmt/tmt-dummy-test.fmf')).toEqual({ + repository: '/Users/dev/BASIL', + relativePath: '/api/user-files/2/tmt/tmt-dummy-test' + }) + }) + + test('keeps a non-fmf remainder unchanged', () => { + expect(splitUserFileToTmtPath('/BASIL-API/api/user-files/2/notes.txt')).toEqual({ + repository: '/BASIL-API', + relativePath: '/api/user-files/2/notes.txt' + }) + }) + + test('returns the full path as repository when /api/ is missing', () => { + expect(splitUserFileToTmtPath('/tmp/outside.fmf')).toEqual({ + repository: '/tmp/outside.fmf', + relativePath: '' + }) + }) + + test('handles an empty filepath', () => { + expect(splitUserFileToTmtPath('')).toEqual({ + repository: '', + relativePath: '' + }) + }) +}) diff --git a/app/src/app/Constants/constants.tsx b/app/src/app/Constants/constants.tsx index 082ed4f..0f0dfeb 100644 --- a/app/src/app/Constants/constants.tsx +++ b/app/src/app/Constants/constants.tsx @@ -588,6 +588,28 @@ export const removeExtension = (filename: string, extension: string) => { return filename.endsWith(extension) ? filename.slice(0, -extension.length) : filename } +export const USER_FILES_API_PATH_MARKER = '/api/' +export const TMT_TEST_FILE_EXTENSION = '.fmf' + +/** + * Split a user-file absolute path into the Test Case repository + relative path + * pair stored by BASIL and consumed by TMT. + * + * Example: + * /BASIL-API/api/user-files/2/tmt/tmt-dummy-test.fmf + * repository: /BASIL-API + * relativePath: /api/user-files/2/tmt/tmt-dummy-test + * + * The leading slash on relativePath is intentional. The API joins the two + * parts with combine_tmt_path(), which strips it before os.path.join. + */ +export const splitUserFileToTmtPath = (filepath: string): { repository: string; relativePath: string } => { + const safePath = filepath || '' + const repository = safePath.split(USER_FILES_API_PATH_MARKER)[0] + const relativePath = removeExtension(safePath.slice(repository.length), TMT_TEST_FILE_EXTENSION) + return { repository, relativePath } +} + export const isValidId = (id_str: string) => { return /^\d+$/.test(id_str) && Number(id_str) > 0 && Number.isSafeInteger(Number(id_str)) } diff --git a/app/src/app/Mapping/Form/TestCaseForm.tsx b/app/src/app/Mapping/Form/TestCaseForm.tsx index 4945f9d..e74a2de 100644 --- a/app/src/app/Mapping/Form/TestCaseForm.tsx +++ b/app/src/app/Mapping/Form/TestCaseForm.tsx @@ -256,19 +256,18 @@ export const TestCaseForm: React.FunctionComponent = ({ setMessageValue('') - const tc_repository: string = implementationSource == 'url' ? repositoryValue : implementationFilePath.split('/api/')[0] - const tc_relative_path: string = + const tcPath = implementationSource == 'url' - ? relativePathValue - : Constants.removeExtension(implementationFilePath.slice(tc_repository.length), '.fmf') + ? { repository: repositoryValue, relativePath: relativePathValue } + : Constants.splitUserFileToTmtPath(implementationFilePath) const data = { 'api-id': api.id, 'test-case': { title: titleValue, description: descriptionValue, - repository: tc_repository, - 'relative-path': tc_relative_path + repository: tcPath.repository, + 'relative-path': tcPath.relativePath }, section: modalSection, offset: modalOffset,