diff --git a/mod_api/__init__.py b/mod_api/__init__.py new file mode 100644 index 00000000..c235abcd --- /dev/null +++ b/mod_api/__init__.py @@ -0,0 +1 @@ +"""REST API module for test-result consumption.""" diff --git a/mod_api/controllers.py b/mod_api/controllers.py new file mode 100644 index 00000000..f88ba8aa --- /dev/null +++ b/mod_api/controllers.py @@ -0,0 +1,135 @@ +"""Versioned REST API endpoints for test-result data.""" + +from typing import Any, Dict, List + +from flask import Blueprint, jsonify +from sqlalchemy import and_ + +from exceptions import TestNotFoundException +from mod_regression.models import Category, RegressionTestOutput, regressionTestLinkTable +from mod_test.controllers import get_test_results +from mod_test.models import Test, TestResultFile + +mod_api = Blueprint('api', __name__) + + +def _require_test(test_id: int) -> Test: + test = Test.query.filter(Test.id == test_id).first() + if test is None: + raise TestNotFoundException(f"Test with id {test_id} does not exist") + return test + + +@mod_api.errorhandler(TestNotFoundException) +def test_not_found(error: TestNotFoundException): + return jsonify({'status': 'failure', 'error': error.message}), 404 + + +@mod_api.route('/v1/tests//summary', methods=['GET']) +def test_summary(test_id: int): + test = _require_test(test_id) + customized_ids = test.get_customized_regressiontests() + return jsonify({ + 'status': 'success', + 'data': { + 'test_id': test.id, + 'platform': test.platform.value, + 'test_type': test.test_type.value, + 'commit': test.commit, + 'pr_nr': test.pr_nr, + 'finished': test.finished, + 'failed': test.failed, + 'sample_progress': { + 'current': len(test.results), + 'total': len(customized_ids), + 'percentage': int((len(test.results) / len(customized_ids)) * 100) if customized_ids else 0, + }, + }, + }) + + +@mod_api.route('/v1/tests//results', methods=['GET']) +def test_results(test_id: int): + test = _require_test(test_id) + categories: List[Dict[str, Any]] = [] + for entry in get_test_results(test): + category = entry['category'] + tests: List[Dict[str, Any]] = [] + for category_test in entry['tests']: + result = category_test['result'] + tests.append({ + 'regression_test_id': category_test['test'].id, + 'command': category_test['test'].command, + 'expected_rc': category_test['test'].expected_rc if result is None else result.expected_rc, + 'exit_code': None if result is None else result.exit_code, + 'runtime': None if result is None else result.runtime, + 'error': category_test['error'], + }) + categories.append({ + 'id': category.id, + 'name': category.name, + 'error': entry['error'], + 'tests': tests, + }) + + return jsonify({'status': 'success', 'data': categories}) + + +@mod_api.route('/v1/tests//files', methods=['GET']) +def test_result_files(test_id: int): + test = _require_test(test_id) + files = TestResultFile.query.filter(TestResultFile.test_id == test.id).all() + data = [{ + 'regression_test_id': item.regression_test_id, + 'regression_test_output_id': item.regression_test_output_id, + 'expected_hash': item.expected, + 'got_hash': item.got, + } for item in files] + return jsonify({'status': 'success', 'data': data}) + + +@mod_api.route('/v1/tests//progress', methods=['GET']) +def test_progress(test_id: int): + test = _require_test(test_id) + progress = [{ + 'status': entry.status.value, + 'message': entry.message, + 'timestamp': entry.timestamp.isoformat(), + } for entry in test.progress] + current_step = progress[-1]['status'] if progress else 'unknown' + return jsonify({ + 'status': 'success', + 'data': { + 'summary': { + 'complete': test.finished, + 'failed': test.failed, + 'current_step': current_step, + 'event_count': len(progress), + }, + 'events': progress, + }, + }) + + +@mod_api.route('/v1/categories', methods=['GET']) +def categories(): + populated_categories = regressionTestLinkTable.select().with_only_columns( + regressionTestLinkTable.c.category_id + ).subquery() + category_rows = Category.query.filter(Category.id.in_(populated_categories)).order_by(Category.name.asc()).all() + + data = [] + for category in category_rows: + active_outputs = RegressionTestOutput.query.filter(and_( + RegressionTestOutput.regression_id.in_([rt.id for rt in category.regression_tests]), + RegressionTestOutput.ignore.is_(False), + )).count() + data.append({ + 'id': category.id, + 'name': category.name, + 'description': category.description, + 'regression_test_count': len(category.regression_tests), + 'output_file_count': active_outputs, + }) + + return jsonify({'status': 'success', 'data': data}) diff --git a/run.py b/run.py index e277c6d9..4be428ea 100755 --- a/run.py +++ b/run.py @@ -29,6 +29,7 @@ from mod_customized.controllers import mod_customized from mod_health.controllers import mod_health from mod_home.controllers import mod_home +from mod_api.controllers import mod_api from mod_regression.controllers import mod_regression from mod_sample.controllers import mod_sample from mod_test.controllers import mod_test @@ -273,3 +274,4 @@ def teardown(exception: Optional[Exception]): app.register_blueprint(mod_ci) app.register_blueprint(mod_customized, url_prefix='/custom') app.register_blueprint(mod_health) +app.register_blueprint(mod_api, url_prefix='/api') diff --git a/tests/test_api/__init__.py b/tests/test_api/__init__.py new file mode 100644 index 00000000..06134ab7 --- /dev/null +++ b/tests/test_api/__init__.py @@ -0,0 +1 @@ +"""API endpoint tests.""" diff --git a/tests/test_api/test_controllers.py b/tests/test_api/test_controllers.py new file mode 100644 index 00000000..863d5469 --- /dev/null +++ b/tests/test_api/test_controllers.py @@ -0,0 +1,73 @@ +"""Tests for REST API endpoints.""" + +from tests.base import BaseTestCase + + +class TestApiControllers(BaseTestCase): + """API test coverage for v1 endpoints.""" + + def test_summary_success(self): + response = self.app.test_client().get('/api/v1/tests/1/summary') + self.assertEqual(response.status_code, 200) + payload = response.json + self.assertEqual(payload['status'], 'success') + self.assertEqual(payload['data']['test_id'], 1) + self.assertEqual(payload['data']['sample_progress']['current'], 2) + + def test_summary_not_found(self): + response = self.app.test_client().get('/api/v1/tests/9999/summary') + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json['status'], 'failure') + + def test_results_success(self): + response = self.app.test_client().get('/api/v1/tests/1/results') + self.assertEqual(response.status_code, 200) + payload = response.json + self.assertEqual(payload['status'], 'success') + self.assertGreaterEqual(len(payload['data']), 1) + first_category = payload['data'][0] + self.assertIn('tests', first_category) + first_test = first_category['tests'][0] + self.assertIn('expected_rc', first_test) + self.assertIn('exit_code', first_test) + if first_test['exit_code'] is not None: + self.assertEqual(first_test['expected_rc'], first_test['exit_code']) + + def test_results_not_found(self): + response = self.app.test_client().get('/api/v1/tests/9999/results') + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json['status'], 'failure') + + def test_files_success(self): + response = self.app.test_client().get('/api/v1/tests/1/files') + self.assertEqual(response.status_code, 200) + payload = response.json + self.assertEqual(payload['status'], 'success') + self.assertEqual(len(payload['data']), 2) + self.assertIn('expected_hash', payload['data'][0]) + self.assertIn('got_hash', payload['data'][0]) + + def test_files_not_found(self): + response = self.app.test_client().get('/api/v1/tests/9999/files') + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json['status'], 'failure') + + def test_progress_success(self): + response = self.app.test_client().get('/api/v1/tests/1/progress') + self.assertEqual(response.status_code, 200) + payload = response.json + self.assertEqual(payload['status'], 'success') + self.assertEqual(len(payload['data']['events']), 3) + self.assertIn('current_step', payload['data']['summary']) + + def test_progress_not_found(self): + response = self.app.test_client().get('/api/v1/tests/9999/progress') + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json['status'], 'failure') + + def test_categories_success(self): + response = self.app.test_client().get('/api/v1/categories') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json['status'], 'success') + names = {item['name'] for item in response.json['data']} + self.assertIn('Broken', names)