From f9a9def213e68646451686dc5f73c6fd05e4cc8b Mon Sep 17 00:00:00 2001 From: Mackenzie Grimes - NOAA Affiliate Date: Wed, 18 Dec 2024 17:32:47 -0700 Subject: [PATCH] increase test_ncd_web_service.py unit test coverage --- python/nwsc_dummy_service/ncd_web_service.py | 4 +- python/nwsc_dummy_service/test/__init__.py | 3 +- .../test/nwsc_dummy_integration_test.py | 11 - .../test/test_ncd_web_service.py | 196 ++++++++++++++++++ 4 files changed, 200 insertions(+), 14 deletions(-) delete mode 100644 python/nwsc_dummy_service/test/nwsc_dummy_integration_test.py create mode 100644 python/nwsc_dummy_service/test/test_ncd_web_service.py diff --git a/python/nwsc_dummy_service/ncd_web_service.py b/python/nwsc_dummy_service/ncd_web_service.py index ae49797..b79bab6 100644 --- a/python/nwsc_dummy_service/ncd_web_service.py +++ b/python/nwsc_dummy_service/ncd_web_service.py @@ -104,7 +104,7 @@ class AppWrapper: """Web server class wrapping Flask operations""" def __init__(self, base_dir: str): """Build Flask app instance, mapping handler to each endpoint""" - self.app = Flask(__name__) + self.app = Flask(__name__, static_folder=None) # no need for a static folder health_route = HealthRoute() events_route = EventsRoute(base_dir) @@ -126,7 +126,7 @@ def create_app(args: Namespace = None) -> Flask: return AppWrapper(base_dir).app -if __name__ == '__main__': +if __name__ == '__main__': # pragma: no cover parser = ArgumentParser() parser.add_argument('--port', dest='port', default=5000, type=int, help='The port the web server will listen on.') diff --git a/python/nwsc_dummy_service/test/__init__.py b/python/nwsc_dummy_service/test/__init__.py index a8379f2..1e12c5a 100644 --- a/python/nwsc_dummy_service/test/__init__.py +++ b/python/nwsc_dummy_service/test/__init__.py @@ -1,3 +1,4 @@ """Add nwsc_dummy_service top-level module to sys.path""" +import os import sys -sys.path.append('..') +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) diff --git a/python/nwsc_dummy_service/test/nwsc_dummy_integration_test.py b/python/nwsc_dummy_service/test/nwsc_dummy_integration_test.py deleted file mode 100644 index 8bdb24e..0000000 --- a/python/nwsc_dummy_service/test/nwsc_dummy_integration_test.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Integration test for NWS Connect Dummy Web Service""" -# ---------------------------------------------------------------------------------- -# Created on Wed Dec 18 2024 -# -# Copyright (c) 2024 Colorado State University. All rights reserved. (1) -# -# Contributors: -# Mackenzie Grimes (1) -# -# ---------------------------------------------------------------------------------- -pass diff --git a/python/nwsc_dummy_service/test/test_ncd_web_service.py b/python/nwsc_dummy_service/test/test_ncd_web_service.py new file mode 100644 index 0000000..e6d720c --- /dev/null +++ b/python/nwsc_dummy_service/test/test_ncd_web_service.py @@ -0,0 +1,196 @@ +"""Unit tests for ncd_web_service.py""" +# ---------------------------------------------------------------------------------- +# Created on Wed Dec 18 2024 +# +# Copyright (c) 2024 Colorado State University. All rights reserved. (1) +# +# Contributors: +# Mackenzie Grimes (1) +# +# ---------------------------------------------------------------------------------- +import json +from datetime import timedelta +from unittest.mock import Mock + +from flask import Request, Response +from pytest import fixture, MonkeyPatch +from werkzeug.datastructures import MultiDict + +from python.nwsc_dummy_service.ncd_web_service import (AppWrapper, Flask, Namespace, ProfileStore, + create_app, datetime, GSL_KEY) + +# constants +EXAMPLE_DATETIME = datetime(2024, 1, 1, 12, 34) +EXAMPLE_UUID = '9835b194-74de-4321-aa6b-d769972dc7cb' + + +# fixtures +@fixture +def mock_datetime(monkeypatch: MonkeyPatch) -> Mock: + mock_obj = Mock(name='MockDatetime') + mock_obj.now.return_value = EXAMPLE_DATETIME + monkeypatch.setattr('python.nwsc_dummy_service.ncd_web_service.datetime', mock_obj) + + return mock_obj + + +@fixture +def mock_profile_store(monkeypatch: MonkeyPatch) -> Mock: + mock_obj = Mock(name='MockProfileStore', spec=ProfileStore) + monkeypatch.setattr('python.nwsc_dummy_service.ncd_web_service.ProfileStore', mock_obj) + return mock_obj + + +@fixture +def mock_jsonify(monkeypatch: MonkeyPatch) -> Mock: + def mock_func(*args, **_kwargs): + return Response(bytes(json.dumps(args[0]), 'utf-8'), content_type='application/json') + + mock_obj = Mock(name='MockJsonify') + mock_obj.side_effect = mock_func + monkeypatch.setattr('python.nwsc_dummy_service.ncd_web_service.jsonify', mock_obj) + return mock_obj + + +@fixture +def mock_current_app(monkeypatch: MonkeyPatch) -> Mock: + mock_obj = Mock(name='MockCurrentApp', spec=Flask) + mock_obj.logger.info.return_value = None + mock_obj.logger.error.return_value = None + monkeypatch.setattr('python.nwsc_dummy_service.ncd_web_service.current_app', mock_obj) + return mock_obj + + +@fixture +def mock_request(monkeypatch: MonkeyPatch, mock_current_app, mock_jsonify) -> Mock: + mock_obj = Mock(name='MockFlaskRequest', spec=Request) + mock_obj.origin = 'http://example.com:5000' + mock_obj.method = 'GET' + mock_obj.headers = MultiDict({'X-Api-Key': GSL_KEY}) + monkeypatch.setattr('python.nwsc_dummy_service.ncd_web_service.request', mock_obj) + return mock_obj + + +@fixture +def wrapper(mock_profile_store, mock_datetime, mock_request) -> AppWrapper: + return AppWrapper('/fake/base/dir') + + +def test_create_app(mock_profile_store: Mock): + args = Namespace() + args.base_dir = '/fake/base/dir' + + _app = create_app(args) + + assert isinstance(_app, Flask) + endpoint_dict = _app.view_functions + assert sorted(list(endpoint_dict.keys())) == ['events', 'health'] + + +def test_health_route(wrapper: AppWrapper, mock_datetime: Mock): + # simulate that server has been running for 5 minutes + mock_datetime.now.return_value = EXAMPLE_DATETIME + timedelta(minutes=5) + + result: tuple[Response, int] = wrapper.app.view_functions['health']() + + response, status_code = result + assert status_code == 200 + assert response.json == { + 'startedAt': '2024-01-01T12:34:00.000Z', + 'uptime': 5 * 60 + } + +def test_events_bad_key(wrapper: AppWrapper, mock_request: Mock): + mock_request.headers = MultiDict({'X-Api-Key': 'A_BAD_KEY'}) + + result: tuple[Response, int] = wrapper.app.view_functions['events']() + + assert result[1] == 401 + + +def test_get_bad_data_source(wrapper: AppWrapper, mock_request: Mock): + mock_request.args = MultiDict({'dataSource': 'A BAD DATA SOURCE'}) + + result: tuple[Response, int] = wrapper.app.view_functions['events']() + + assert result[1] == 400 + + +def test_get_bad_status(wrapper: AppWrapper, mock_request: Mock): + mock_request.args = MultiDict({'dataSource': 'NBM', 'status': 'NOT REAL STATUS'}) + + result: tuple[Response, int] = wrapper.app.view_functions['events']() + + response, status_code = result + assert status_code == 400 + assert response.json == {'profiles': [], 'errors': ['Invalid profile status: NOT REAL STATUS']} + + +def test_get_existing_profiles(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): + mock_request.args = MultiDict({'dataSource': 'NBM', 'status': 'existing'}) + example_profile_list = [{'id': EXAMPLE_UUID, 'name': 'My Profile'}] + mock_profile_store.return_value.get_all.return_value = example_profile_list + + result: tuple[Response, int] = wrapper.app.view_functions['events']() + + response, status_code = result + assert status_code == 200 + assert response.json == {'profiles': example_profile_list, 'errors': []} + mock_profile_store.return_value.get_all.assert_called_with() # filter_new_profiles not set + + +def test_get_new_profiles(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): + mock_request.args = MultiDict({'dataSource': 'NBM', 'status': 'new'}) + example_profile = {'id': EXAMPLE_UUID, 'name': 'My Profile'} + mock_profile_store.return_value.get_all.return_value = [example_profile] + + result: tuple[Response, int] = wrapper.app.view_functions['events']() + + response, status_code = result + assert status_code == 200 + assert response.json == {'profiles': [example_profile], 'errors': []} + + func_call_args = mock_profile_store.return_value.get_all.mock_calls + assert func_call_args[0][2] == {'filter_new_profiles': True} # filter_new_profiles set to True + + +def test_create_profile_success(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): + mock_request.method = 'POST' + mock_request.json = {'id': EXAMPLE_UUID, 'name': 'My Profile'} + mock_profile_store.return_value.save.return_value = EXAMPLE_UUID # save() success + + result: tuple[Response, int] = wrapper.app.view_functions['events']() + + assert result[1] == 201 + + +def test_create_previous_profile_failure(wrapper: AppWrapper, + mock_request: Mock, + mock_profile_store: Mock): + mock_request.method = 'POST' + mock_request.json = {'id': EXAMPLE_UUID, 'name': 'My Profile'} + mock_profile_store.return_value.save.return_value = None # save() rejected, profile must exist + + result: tuple[Response, int] = wrapper.app.view_functions['events']() + + assert result[1] == 400 + + +def test_delete_profile_success(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): + mock_request.method = 'DELETE' + mock_request.args = MultiDict({'uuid': EXAMPLE_UUID}) + mock_profile_store.return_value.delete.return_value = True # delete worked + + result: tuple[Response, int] = wrapper.app.view_functions['events']() + + assert result[1] == 204 + + +def test_delete_profile_failure(wrapper: AppWrapper, mock_request: Mock, mock_profile_store: Mock): + mock_request.method = 'DELETE' + mock_request.args = MultiDict({'uuid': EXAMPLE_UUID}) + mock_profile_store.return_value.delete.return_value = False # delete() rejected, profile must exist + + result: tuple[Response, int] = wrapper.app.view_functions['events']() + + assert result[1] == 404