From 50c8a9f5e3056f89e8e7013f6a61365b35d3cd98 Mon Sep 17 00:00:00 2001 From: Waqar Ahmed Date: Sat, 19 Oct 2024 05:35:35 +0500 Subject: [PATCH] Add unit tests for apps ci package This commit adds unit tests for apps ci package testing various aspects of the different scripts and utils we have which are used for development and deploying of apps. --- .github/workflows/tests.yml | 1 + .../pytest/unit/test_get_apps_to_publish.py | 23 +++ apps_ci/pytest/unit/test_get_changed_app.py | 55 +++++++ apps_ci/pytest/unit/test_is_main_dep.py | 97 +++++++++++++ .../pytest/unit/test_publish_updated_apps.py | 56 ++++++++ .../pytest/unit/test_rename_versioned_dir.py | 75 ++++++++++ .../pytest/unit/test_update_app_version.py | 73 ++++++++++ .../pytest/unit/test_update_catalog_file.py | 134 ++++++++++++++++++ 8 files changed, 514 insertions(+) create mode 100644 apps_ci/pytest/unit/test_get_apps_to_publish.py create mode 100644 apps_ci/pytest/unit/test_get_changed_app.py create mode 100644 apps_ci/pytest/unit/test_is_main_dep.py create mode 100644 apps_ci/pytest/unit/test_publish_updated_apps.py create mode 100644 apps_ci/pytest/unit/test_rename_versioned_dir.py create mode 100644 apps_ci/pytest/unit/test_update_app_version.py create mode 100644 apps_ci/pytest/unit/test_update_catalog_file.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9ca5a75..2c5f0f5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,3 +26,4 @@ jobs: PYTHONPATH=$(pwd) pytest apps_validation/pytest/unit/ PYTHONPATH=$(pwd) pytest catalog_reader/pytest/unit/ PYTHONPATH=$(pwd) pytest apps_schema/pytest/unit/ + PYTHONPATH=$(pwd) pytest apps_ci/pytest/unit/ diff --git a/apps_ci/pytest/unit/test_get_apps_to_publish.py b/apps_ci/pytest/unit/test_get_apps_to_publish.py new file mode 100644 index 0000000..8d5b438 --- /dev/null +++ b/apps_ci/pytest/unit/test_get_apps_to_publish.py @@ -0,0 +1,23 @@ +import collections + +import pytest + +from apps_ci.scripts.catalog_update import get_apps_to_publish + + +@pytest.mark.parametrize('isdir,', [ + [True, True, True], + [True, False, False], + [False, False, False] +]) +def test_get_apps_to_publish(mocker, isdir): + mocker.patch('os.listdir', side_effect=[ + ['community'], + ['app1', 'app2'] + ]) + mocker.patch('os.path.isdir', side_effect=isdir) + mocker.patch('apps_ci.scripts.catalog_update.version_has_been_bumped', return_value=True) + mocker.patch('apps_ci.scripts.catalog_update.get_app_version', return_value='1.0.1') + + result = get_apps_to_publish('/path/to/catalog') + assert isinstance(result, collections.defaultdict) diff --git a/apps_ci/pytest/unit/test_get_changed_app.py b/apps_ci/pytest/unit/test_get_changed_app.py new file mode 100644 index 0000000..b64b3e0 --- /dev/null +++ b/apps_ci/pytest/unit/test_get_changed_app.py @@ -0,0 +1,55 @@ +import collections +import os + +import pytest + +from apps_ci.git import get_changed_apps +from apps_exceptions import CatalogDoesNotExist + + +@pytest.mark.parametrize('path, base_branch, path_exists, is_train_valid, should_work', [ + ( + '/valid/path/to/catalog', + 'master', + True, + True, + True + ), + ( + '/valid/path/to/catalog', + 'master', + True, + False, + True + ), + ( + '/invalid/path/to/catalog', + 'master', + False, + False, + False + ) +]) +def test_get_changed_apps(mocker, path, base_branch, path_exists, is_train_valid, should_work): + mocker.patch('os.path.exists', return_value=path_exists) + mock_subprocess_run = mocker.patch('subprocess.run') + mock_subprocess_run.return_value.stdout = b''' + ix-dev/community/actual-budget/1.1.9/values.yaml + ix-dev/community/another-app/1.0.0/metadata.yaml + ix-dev/community/another-app/1.0.0/upgrade_info.json + ''' + mock_get_ci_development_directory = mocker.patch('catalog_reader.dev_directory.get_ci_development_directory') + mock_get_ci_development_directory.return_value = '/mock/ci/development' + mocker.patch('apps_ci.git.is_train_valid', return_value=is_train_valid) + if should_work: + result = get_changed_apps(path, base_branch) + + os.path.exists.assert_called_once_with(path) + mock_subprocess_run.assert_called_once_with( + ['git', '-C', path, '--no-pager', 'diff', '--name-only', base_branch], + capture_output=True, check=True, + ) + assert isinstance(result, collections.defaultdict) + else: + with pytest.raises(CatalogDoesNotExist): + get_changed_apps(path, base_branch) diff --git a/apps_ci/pytest/unit/test_is_main_dep.py b/apps_ci/pytest/unit/test_is_main_dep.py new file mode 100644 index 0000000..6dde662 --- /dev/null +++ b/apps_ci/pytest/unit/test_is_main_dep.py @@ -0,0 +1,97 @@ +import pathlib +import textwrap + +import pytest + +from apps_ci.images_info import is_main_dep +from apps_exceptions import AppDoesNotExist, ValidationErrors + + +@pytest.mark.parametrize('yaml_data, dep_name, is_dir, is_file, should_work', [ + ( + textwrap.dedent( + ''' + images: + image: + repository: ABC + tag: some_tag + db_image: + repository: ABC + tag: some_tag + ''' + ), + 'ABC', + True, + True, + True + + ), + ( + textwrap.dedent( + ''' + images: + image: + repository: ABC + tag: some_tag + db_image: + repository: ABC + tag: some_tag + ''' + ), + 'ABC', + False, + False, + False + + ), + ( + textwrap.dedent( + ''' + images: + image: + repository: some_repo + tag: some_tag + db_image: + repository: some_repo + tag: some_tag + ''' + ), + 'ABC', + True, + True, + False + + ), + ( + textwrap.dedent( + ''' + images: + image: + repository: ABC + tag: some_tag + db_image: + repository: ABC + tag: some_tag + ''' + ), + 'ABC', + True, + False, + False + + ), +]) +def test_is_main_dep(mocker, yaml_data, dep_name, is_dir, is_file, should_work): + mock_file = mocker.mock_open(read_data=yaml_data) + mocker.patch('builtins.open', mock_file) + mocker.patch('pathlib.Path.is_dir', return_value=is_dir) + mocker.patch('pathlib.Path.is_file', return_value=is_file) + if should_work: + result = is_main_dep(pathlib.Path('/valid/path'), dep_name) + assert result is True + elif dep_name not in yaml_data: + result = is_main_dep(pathlib.Path('/valid/path'), dep_name) + assert result is False + else: + with pytest.raises((AppDoesNotExist, ValidationErrors)): + is_main_dep(pathlib.Path('/valid/path'), dep_name) diff --git a/apps_ci/pytest/unit/test_publish_updated_apps.py b/apps_ci/pytest/unit/test_publish_updated_apps.py new file mode 100644 index 0000000..49a827b --- /dev/null +++ b/apps_ci/pytest/unit/test_publish_updated_apps.py @@ -0,0 +1,56 @@ +import collections + +import pytest + +from apps_ci.scripts.catalog_update import publish_updated_apps + + +@pytest.mark.parametrize('version, app_name, isdir, listdir_ver, should_work', [ + ( + '1.0.1', + 'test_app', + [True, True, True], + ['1.0.0', '1.0.1'], + True + ), + ( + '1.0.0', + 'test_app', + [False], + ['1.0.0'], + False + ), + ( + '1.0.1', + 'test_app', + [True, True, True], + ['1.0.0'], + True + ), +]) +def test_publish_updated_apps(mocker, capsys, version, app_name, isdir, listdir_ver, should_work): + to_publish_apps = collections.defaultdict(list) + to_publish_apps[version].append({'name': app_name, 'version': version}) + mocker.patch('os.path.isdir', side_effect=isdir) + mocker.patch('os.makedirs') + mocker.patch('os.listdir', return_value=listdir_ver) + mocker.patch('os.path.exists', side_effect=[False, True]) + mocker.patch('shutil.copy') + mocker.patch('shutil.copytree') + mocker.patch('shutil.move') + mocker.patch('shutil.rmtree') + + mocker.patch('apps_ci.scripts.catalog_update.get_apps_to_publish', return_value=to_publish_apps) + mocker.patch('apps_ci.scripts.catalog_update.get_to_keep_versions', return_value=[version]) + + if should_work: + publish_updated_apps('/path/to/catalog') + expected_out = ( + f'\x1b[92mOK\x1b[0m]\tPublished ' + f'\'{app_name}\' having \'{version}\' version ' + f'to \'{version}\' train successfully!' + ) + assert expected_out in capsys.readouterr().out + + else: + assert publish_updated_apps('/path/to/catalog') is None diff --git a/apps_ci/pytest/unit/test_rename_versioned_dir.py b/apps_ci/pytest/unit/test_rename_versioned_dir.py new file mode 100644 index 0000000..d85e1b4 --- /dev/null +++ b/apps_ci/pytest/unit/test_rename_versioned_dir.py @@ -0,0 +1,75 @@ +import pathlib + +import pytest + +from apps_ci.version_bump import rename_versioned_dir, bump_version +from apps_exceptions import AppDoesNotExist, ValidationErrors + + +@pytest.mark.parametrize('version, new_version, train_name, is_dir, should_work', [ + ( + '1.0.0', + '1.0.1', + 'community', + [True, False, False], + True + ), + ( + '1.0', + '1.0.1', + 'community', + [True, False, False], + False + ), + ( + '1.0.0', + '1.0.1', + 'community', + [False, True, True], + False + ), + ( + '1.0.0', + '1.0.1', + 'stable', + [True, True, True], + False + ), + +]) +def test_rename_versioned_dir(mocker, version, new_version, train_name, is_dir, should_work): + mocker.patch('pathlib.Path.is_dir', side_effect=is_dir) + mocker.patch('pathlib.Path.rename', return_value=None) + if should_work: + result = rename_versioned_dir(version, new_version, train_name, pathlib.Path('/valid/path')) + assert result is None + else: + with pytest.raises((AppDoesNotExist, ValidationErrors)): + rename_versioned_dir(version, new_version, train_name, pathlib.Path('/valid/path')) + + +@pytest.mark.parametrize('version, bump, expected', [ + ( + '1.0.0', + 'minor', + '1.1.0' + ), + ( + '1.0.0', + 'major', + '2.0.0' + ), + ( + '1.0.0', + 'patch', + '1.0.1' + ) +]) +def test_bump_version(version, bump, expected): + result = bump_version(version, bump) + assert result == expected + + +def test_bump_version_Fail(): + with pytest.raises(ValueError): + bump_version('1.0', 'minor') diff --git a/apps_ci/pytest/unit/test_update_app_version.py b/apps_ci/pytest/unit/test_update_app_version.py new file mode 100644 index 0000000..1d2a0da --- /dev/null +++ b/apps_ci/pytest/unit/test_update_app_version.py @@ -0,0 +1,73 @@ +import pytest + +from apps_ci.scripts.bump_version import update_app_version +from apps_exceptions import AppDoesNotExist, ValidationErrors + + +@pytest.mark.parametrize('path, bump, dep, dep_version, yaml, expected_out', [ + ( + '/valid/path', + 'minor', + 'some_repo', + '1.0.2', + ''' + version: 1.0.0 + icon_url: https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg + sources: + - https://hub.docker.com/r/emby/embyserver + ''', + '\x1b[92mOK\x1b[0m]\tUpdated app \'path\', ' + 'set app_version to \'1.0.2\' and bumped version from ' + '\'1.0.0\' to \'1.1.0\'\n' + ) +]) +def test_update_app_version(mocker, capsys, path, bump, dep, dep_version, yaml, expected_out): + mocker.patch('os.path.exists', return_value=True) + mocker.patch('pathlib.Path.is_file', return_value=True) + mock_file = mocker.mock_open(read_data=yaml) + mocker.patch('builtins.open', mock_file) + mocker.patch('apps_ci.scripts.bump_version.is_main_dep', return_value=True) + mocker.patch('apps_ci.scripts.bump_version.rename_versioned_dir', return_value=None) + update_app_version(path, bump, dep, dep_version) + assert expected_out in capsys.readouterr().out + + +@pytest.mark.parametrize('path, bump, dep, dep_version, exists, is_file, yaml', [ + ( + '/valid/path', + 'minor', + 'some_repo', + '1.0.2', + False, + True, + ''' + version: 1.0.0 + icon_url: https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg + sources: + - https://hub.docker.com/r/emby/embyserver + ''' + ), + ( + '/valid/path', + 'minor', + 'some_repo', + '1.0.2', + True, + False, + ''' + version: 1.0.0 + icon_url: https://www.chia.net/wp-content/uploads/2022/09/chia-logo.svg + sources: + - https://hub.docker.com/r/emby/embyserver + ''' + ), +]) +def test_update_app_version_Errors(mocker, path, bump, dep, dep_version, exists, is_file, yaml): + mocker.patch('os.path.exists', return_value=exists) + mocker.patch('pathlib.Path.is_file', return_value=is_file) + mock_file = mocker.mock_open(read_data=yaml) + mocker.patch('builtins.open', mock_file) + mocker.patch('apps_ci.scripts.bump_version.is_main_dep', return_value=True) + mocker.patch('apps_ci.scripts.bump_version.rename_versioned_dir', return_value=None) + with pytest.raises((AppDoesNotExist, ValidationErrors)): + update_app_version(path, bump, dep, dep_version) diff --git a/apps_ci/pytest/unit/test_update_catalog_file.py b/apps_ci/pytest/unit/test_update_catalog_file.py new file mode 100644 index 0000000..ff45928 --- /dev/null +++ b/apps_ci/pytest/unit/test_update_catalog_file.py @@ -0,0 +1,134 @@ +import pytest + +from apps_ci.scripts.catalog_update import update_catalog_file +from apps_exceptions import ValidationErrors + + +@pytest.mark.parametrize('catalog_data, version_data, expected', [ + ( + { + 'community': { + 'actual_budget': { + 'name': 'Actual Budget', + 'categories': ['finance', 'budgeting'], + 'app_readme': 'https://example.com/actual-budget/readme', + 'location': '/path/to/actual_budget', + 'healthy': True, + 'healthy_error': None, + 'last_update': '2024-10-01 14:30:00', + 'recommended': [], + 'latest_version': '1.2.0', + 'latest_app_version': '1.2.0', + 'latest_human_version': 'One point two', + 'description': 'A budget management application.', + 'title': 'Actual Budget App', + 'icon_url': 'https://example.com/actual-budget/icon.png', + 'maintainers': [ + { + 'name': 'Alice Smith', + 'url': 'https://example.com/alice', + 'email': 'alice@example.com' + }, + ], + 'home': 'https://example.com/actual-budget', + 'tags': ['budget', 'finance', 'money'], + 'screenshots': [ + 'https://example.com/actual-budget/screenshot1.png', + ], + 'sources': [ + 'https://github.com/example/actual-budget', + ] + }, + } + }, + { + 'community': { + 'actual_budget': { + 'versions': { + '1.0.1': { + 'name': 'chia', + 'categories': [], + 'app_readme': None, + 'location': '/mnt/mypool/ix-applications/catalogs/' + 'github_com_truenas_charts_git_master/charts/chia', + 'healthy': True, + 'healthy_error': None, + 'supported': True, + 'required_features': [], + 'version': '1.0.1', + 'human_version': '1.15.12', + 'home': None, + 'readme': None, + 'changelog': None, + 'last_update': '1200-20-00 00:00:00', + 'maintainers': {}, + 'app_metadata': { + 'name': 'chia', + 'train': 'stable', + 'version': '1.0.1', + 'app_version': '1.0.1', + 'title': 'chia', + 'description': 'desc', + 'home': 'None', + 'sources': [], + 'maintainers': [], + 'run_as_context': [], + 'capabilities': [], + 'host_mounts': [] + }, + 'schema': { + 'groups': [], + 'questions': [] + } + } + } + } + } + }, + [ + '\x1b[92mOK\x1b[0m]\tUpdated \'/valid/path/catalog.json\' successfully!\n[\x1b[92mOK\x1b[0m]', + 'Updated \'/valid/path/trains/community/actual_budget/app_versions.json' + ] + ), + ( + { + 'community': { + 'actual_budget': { + 'name': 'Actual Budget', + 'categories': ['finance', 'budgeting'], + 'app_readme': 'https://example.com/actual-budget/readme', + 'location': '/path/to/actual_budget', + 'healthy': True, + 'healthy_error': None, + 'last_update': '2024-10-01 14:30:00', + 'recommended': [], + }, + } + }, + { + 'community': { + 'actual_budget': { + 'versions': { + '1.0.1': { + 'name': 'chia', + 'categories': [], + } + } + } + } + }, + 'Error' + ) +]) +def test_update_catalog_file(mocker, capsys, catalog_data, version_data, expected): + mocker.patch('apps_ci.scripts.catalog_update.get_trains', return_value=[catalog_data, version_data]) + mock_file = mocker.mock_open(read_data='') + mocker.patch('builtins.open', mock_file) + if expected != 'Error': + update_catalog_file('/valid/path/') + stdout = capsys.readouterr() + for expected_out in expected: + assert expected_out in stdout.out + else: + with pytest.raises(ValidationErrors): + update_catalog_file('/valid/path/')