diff --git a/.gitignore b/.gitignore index b03ec9b..48cc42c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /env +/.venv /__pycache__ /out.txt /tests/__pycache__ -/.pytest_cache \ No newline at end of file +/.pytest_cache +/local \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 570def6..be8306d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.13.5-alpine@sha256:37b14db89f587f9eaa890e4a442a3fe55db452b69cca1403cc730bd0fbdc8aaf +FROM python:3.13.6-alpine@sha256:f196fd275fdad7287ccb4b0a85c2e402bb8c794d205cf6158909041c1ee9f38d RUN addgroup -S appgroup && adduser -S -G appgroup appuser diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..5f97eda --- /dev/null +++ b/app/config.py @@ -0,0 +1,27 @@ +'''Shared configuration module''' +import os +import redis + +# Redis configuration +REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') +REDIS_PORT = int(os.environ.get('REDIS_PORT', 6379)) +REDIS_DB = int(os.environ.get('REDIS_DB', 0)) +CACHE_TTL = int(os.environ.get('CACHE_TTL', 300)) + +def create_redis_client(): + '''Create and return Redis client with error handling''' + try: + redis_client = redis.StrictRedis( + host=REDIS_HOST, + port=REDIS_PORT, + db=REDIS_DB, + decode_responses=True, + socket_connect_timeout=240, + socket_timeout=240 + ) + redis_client.ping() + print("Connected to Redis successfully!") + return redis_client, True + except (redis.ConnectionError, redis.TimeoutError) as e: + print(f"Could not connect to Redis: {e}") + return None, False diff --git a/app/main.py b/app/main.py index b5f5340..7d514ff 100644 --- a/app/main.py +++ b/app/main.py @@ -5,6 +5,7 @@ from prometheus_client import generate_latest, CONTENT_TYPE_LATEST from app import opensense from app import storage +from app import readiness app = Flask(__name__) @@ -24,7 +25,8 @@ def print_version(): @app.route('/temperature') def get_temperature(): '''Function to get the current temperature.''' - return opensense.get_temperature() + f"From: {IPADDR}\n" + result, _ = opensense.get_temperature() + return result + f"From: {IPADDR}\n" @app.route('/metrics') def metrics(): @@ -36,5 +38,18 @@ def store(): '''Function to store results in MinIO.''' return storage.store_temperature_data() +@app.route('/readyz') +def readyz(): + '''Readiness probe endpoint''' + status_code = readiness.readiness_check() + + if status_code == 200: + return {"status": "ready"}, 200 + + return { + "status": "not ready", + "error": "More than 50% of sensors unreachable and cache expired" + }, 503 + if __name__ == "__main__": app.run() diff --git a/app/opensense.py b/app/opensense.py index 5bb9858..f88c2a9 100644 --- a/app/opensense.py +++ b/app/opensense.py @@ -1,39 +1,41 @@ '''Module to get entries from OpenSenseMap API and get the average temperature''' from datetime import datetime, timezone, timedelta -import os +import re import requests import redis +from app.config import create_redis_client, CACHE_TTL -REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') -REDIS_PORT = int(os.environ.get('REDIS_PORT', 6379)) -REDIS_DB = int(os.environ.get('REDIS_DB', 0)) -CACHE_TTL = int(os.environ.get('CACHE_TTL', 300)) - -try: - redis_client = redis.StrictRedis( - host=REDIS_HOST, - port=REDIS_PORT, - db=REDIS_DB, - decode_responses=True, - socket_connect_timeout=240, - socket_timeout=240 - ) - redis_client.ping() - REDIS_AVAILABLE = True - print("Connected to Redis successfully!") -except (redis.ConnectionError, redis.TimeoutError) as e: - REDIS_AVAILABLE = False - print("Could not connect to Redis.") +# Use shared Redis client +redis_client, REDIS_AVAILABLE = create_redis_client() + +_sensor_stats = {"total_sensors": 0, "null_count": 0} + +def classify_temperature(average): + '''Classify temperature based on ranges using dictionary approach''' + # Define temperature ranges and their classifications + temp_classifications = { + "cold": (float('-inf'), 10, "Warning: Too cold"), + "good": (10, 36, "Good"), + "hot": (36, float('inf'), "Warning: Too hot") + } + + # Find the appropriate classification + for _, (min_temp, max_temp, status) in temp_classifications.items(): + if min_temp < average <= max_temp: + return status + + return "Unknown" # Default case def get_temperature(): '''Function to get the average temperature from OpenSenseMap API.''' - cache_key = "temperature_data" if REDIS_AVAILABLE: try: - cached_data = redis_client.get(cache_key) + cached_data = redis_client.get("temperature_data") if cached_data: print("Using cached data from Redis.") - return cached_data + # Return cached data with default stats (since we don't have fresh stats) + default_stats = {"total_sensors": 0, "null_count": 0} + return cached_data, default_stats except redis.RedisError as e: print(f"Redis error: {e}. Proceeding without cache.") @@ -52,33 +54,36 @@ def get_temperature(): response = requests.get("https://api.opensensemap.org/boxes", params=params, timeout=300) print('Data retrieved successfully!') + _sensor_stats["total_sensors"] = sum( + 1 for line in response.text.splitlines() if re.search(r'^\s*"sensors"\s*:\s*\[', line) + ) + res = [d.get('sensors') for d in response.json() if 'sensors' in d] temp_list = [] + _sensor_stats["null_count"] = 0 # Initialize counter for null measurements for sensor_list in res: for measure in sensor_list: - if measure.get('title') == "Temperatur" and 'lastMeasurement' in measure: + if measure.get('unit') == "\u00b0C" and 'lastMeasurement' in measure: last_measurement = measure['lastMeasurement'] if last_measurement is not None and 'value' in last_measurement: last_measurement_int = float(last_measurement['value']) temp_list.append(last_measurement_int) + else: + _sensor_stats["null_count"] += 1 - total_sum = sum(temp_list) - average = total_sum / len(temp_list) if temp_list else 0 + average = sum(temp_list) / len(temp_list) if temp_list else 0 - if average <= 10: - result = f'Average temperature: {average:.2f} °C (Warning: Too cold)\n' - elif 10 < average <= 36: - result = f'Average temperature: {average:.2f} °C (Good)\n' - else: - result = f'Average temperature: {average:.2f} °C (Warning: Too hot)\n' + # Use the dictionary-based classification + status = classify_temperature(average) + result = f'Average temperature: {average:.2f} °C ({status})\n' if REDIS_AVAILABLE: try: - redis_client.setex(cache_key, CACHE_TTL, result) + redis_client.setex("temperature_data", CACHE_TTL, result) print("Data cached in Redis.") except redis.RedisError as e: print(f"Redis error while caching data: {e}") - return result + return result, _sensor_stats diff --git a/app/readiness.py b/app/readiness.py new file mode 100644 index 0000000..86167cd --- /dev/null +++ b/app/readiness.py @@ -0,0 +1,38 @@ +'''Module to check the readiness of the stored information''' +from app.opensense import get_temperature +from app.config import create_redis_client + +redis_client, REDIS_AVAILABLE = create_redis_client() + +def check_caching(): + '''Check if caching content is older than 5 minutes''' + if not REDIS_AVAILABLE: + return True + + cache_key = "temperature_data" + ttl = redis_client.ttl(cache_key) + + if ttl in (-2, -1): + return True + + return False + +def reachable_boxes(): + '''Check if 50% + 1 of boxes are reachable''' + _, sensor_stats = get_temperature() + total = sensor_stats["total_sensors"] + null_count = sensor_stats["null_count"] + if total > 0 and null_count > (total * 0.5): + print("Warning: More than 50% of sensors are unreachable") + return 400 + return 200 + +def readiness_check(): + '''Combined readiness check for the /readyz endpoint''' + boxes_status = reachable_boxes() + cache_is_old = check_caching() + + if boxes_status == 400 and cache_is_old: + return 503 + + return 200 diff --git a/app/storage.py b/app/storage.py index 9fcc91d..db8f5aa 100644 --- a/app/storage.py +++ b/app/storage.py @@ -31,10 +31,10 @@ def store_temperature_data(): bucket_name = "temperature-data" destination_file = f"temperature_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S%f')}.txt" - # Get the original temperature data format - temperature_data = opensense.get_temperature() + # Get the temperature data - unpack the tuple + temperature_result, _ = opensense.get_temperature() - text_bytes = temperature_data.encode('utf-8') + text_bytes = temperature_result.encode('utf-8') text_stream = io.BytesIO(text_bytes) # Make the bucket if it doesn't exist. @@ -63,5 +63,5 @@ def store_temperature_data(): return error_msg if __name__ == "__main__": - result = store_temperature_data() - print(result) + RESULT = store_temperature_data() + print(RESULT) diff --git a/k8s/deployment.yml b/k8s/deployment.yml index 21876bc..f5cee57 100644 --- a/k8s/deployment.yml +++ b/k8s/deployment.yml @@ -54,11 +54,11 @@ spec: failureThreshold: 3 readinessProbe: httpGet: - path: /version + path: /readyz port: 5000 - initialDelaySeconds: 60 - periodSeconds: 120 - timeoutSeconds: 10 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 failureThreshold: 3 volumeMounts: - name: tmp-volume diff --git a/tests/fixtures/vcr_cassettes/version.yaml b/tests/fixtures/vcr_cassettes/version.yaml index 1b0e3f1..849ca34 100644 --- a/tests/fixtures/vcr_cassettes/version.yaml +++ b/tests/fixtures/vcr_cassettes/version.yaml @@ -14,7 +14,7 @@ interactions: uri: http://127.0.0.1:5000/version response: body: - string: "Current app version: 0.5.0" + string: "Current app version: 0.7.0" headers: Connection: - close diff --git a/tests/test_modules.py b/tests/test_modules.py index 0573ee9..43e655d 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,196 +1,357 @@ '''This module contains tests for the Flask and OpenSense modules.''' import re +import unittest import unittest.mock as mock from minio.error import S3Error, InvalidResponseError from app.storage import store_temperature_data from app.main import app from app import opensense - -def test_app_exists(): - """Test that the Flask app exists""" - assert app is not None - -def test_version_endpoint(): - """Test version endpoint with test client""" - client = app.test_client() - response = client.get('/version') - assert response.status_code == 200 - -def test_temperature_endpoint(): - """Test temperature endpoint with test client""" - client = app.test_client() - response = client.get('/temperature') - assert response.status_code in [200, 500] - -def test_metrics_endpoint(): - """Test metrics endpoint""" - client = app.test_client() - response = client.get('/metrics') - assert response.status_code == 200 - -def test_opensense_get_temperature(): - """Test that opensense.get_temperature returns a string""" - result = opensense.get_temperature() - assert isinstance(result, str) - assert re.match( - r"Average temperature: (\d+\.\d{2}) °C \((Warning: Too cold|Good|Warning: Too hot)\)", - result - ) - -def mock_response(temp_value): - """Mock response for OpenSenseMap API""" - class MockResponse: - """Mock response class to simulate OpenSenseMap API response.""" - def json(self): - """Return a mock JSON response.""" - return [{ - 'sensors': [ - { - 'title': 'Temperatur', - 'lastMeasurement': {'value': str(temp_value)} - } - ] - }] - return MockResponse() - -def test_opensense_get_temperature_too_cold(): - """Test opensense.get_temperature for too cold condition""" - with mock.patch('app.opensense.requests.get', return_value=mock_response(5)): - result = opensense.get_temperature() - assert 'Too cold' in result - -def test_opensense_get_temperature_too_hot(): - """Test opensense.get_temperature for too hot condition""" - with mock.patch('app.opensense.requests.get', return_value=mock_response(40)): - result = opensense.get_temperature() - assert 'Too hot' in result - -def test_opensense_cache_get(): - """Test that cached data is used if available""" - with mock.patch('app.opensense.REDIS_AVAILABLE', True), \ - mock.patch('app.opensense.redis_client.get', return_value="cached_result"), \ - mock.patch('app.opensense.requests.get') as mock_requests: - result = opensense.get_temperature() - assert result == "cached_result" - mock_requests.assert_not_called() - -def test_opensense_cache_setex(): - """Test that data is cached after fetching""" - with mock.patch('app.opensense.REDIS_AVAILABLE', True), \ - mock.patch('app.opensense.redis_client.get', return_value=None), \ - mock.patch('app.opensense.redis_client.setex') as mock_setex, \ - mock.patch('app.opensense.requests.get', return_value=mock_response(25)): - result = opensense.get_temperature() - assert 'Average temperature' in result - mock_setex.assert_called() - -def test_store_endpoint(): - """Test store endpoint with test client""" - client = app.test_client() - response = client.get('/store') - # Should return 200 or 500 depending on MinIO availability - assert response.status_code in [200, 500] - -def test_store_temperature_data(): - '''Test that store endpoint works''' - with mock.patch('app.storage.store_temperature_data') as mock_store: - mock_store.return_value = "Temperature data successfully uploaded" - - client = app.test_client() - response = client.get('/store') - - assert response.status_code == 200 - assert "successfully uploaded" in response.get_data(as_text=True) - mock_store.assert_called_once() - -def test_store_temperature_data_integration(): - '''Test that storage function works with mocked MinIO''' - with mock.patch('app.storage.Minio') as mock_minio_class: - # Mock MinIO client - mock_client = mock.MagicMock() - mock_minio_class.return_value = mock_client - mock_client.bucket_exists.return_value = True - mock_client.put_object.return_value = None - mock_client.list_buckets.return_value = [] - - # Mock temperature data - with mock.patch('app.opensense.get_temperature') as mock_temp: - mock_temp.return_value = "Average temperature: 22.5 °C (Good)\nFrom: test\n" - - # Import and call the actual function - result = store_temperature_data() - - # Test the result - assert "successfully uploaded" in result - mock_client.put_object.assert_called_once() - -def test_store_temperature_data_bucket_creation(): - '''Test bucket creation when bucket doesn't exist''' - with mock.patch('app.storage.Minio') as mock_minio_class: - mock_client = mock.MagicMock() - mock_minio_class.return_value = mock_client - - # Mock bucket doesn't exist initially - mock_client.bucket_exists.return_value = False - mock_client.make_bucket.return_value = None - mock_client.put_object.return_value = None - mock_client.list_buckets.return_value = [] - - with mock.patch('app.opensense.get_temperature') as mock_temp: - mock_temp.return_value = "Average temperature: 22.5 °C (Good)\nFrom: test\n" - - result = store_temperature_data() - - # Verify bucket creation was called - mock_client.bucket_exists.assert_called_once_with("temperature-data") - mock_client.make_bucket.assert_called_once_with("temperature-data") - mock_client.put_object.assert_called_once() - - assert "successfully uploaded" in result - assert "temperature-data" in result - -def test_store_temperature_data_s3_error(): - '''Test S3Error exception handling''' - with mock.patch('app.storage.Minio') as mock_minio_class: - mock_client = mock.MagicMock() - mock_minio_class.return_value = mock_client - - # Mock S3Error during bucket check - mock_client.list_buckets.return_value = [] - mock_client.bucket_exists.side_effect = S3Error( - code="AccessDenied", - message="Access denied", - resource="/temperature-data", - request_id="test-request-id", - host_id="test-host-id", - response=None +from app import readiness + +class TestFlaskApp(unittest.TestCase): + """Test cases for Flask application endpoints""" + + def setUp(self): + """Set up test client""" + self.client = app.test_client() + self.app_context = app.app_context() + self.app_context.push() + + def tearDown(self): + """Clean up after tests""" + self.app_context.pop() + + def test_app_exists(self): + """Test that the Flask app exists""" + self.assertIsNotNone(app) + + def test_version_endpoint(self): + """Test version endpoint returns 200""" + response = self.client.get('/version') + self.assertEqual(response.status_code, 200) + + def test_temperature_endpoint(self): + """Test temperature endpoint returns 200 or 500""" + response = self.client.get('/temperature') + self.assertIn(response.status_code, [200, 500]) + + def test_metrics_endpoint(self): + """Test metrics endpoint returns 200""" + response = self.client.get('/metrics') + self.assertEqual(response.status_code, 200) + + def test_store_endpoint_success(self): + """Test store endpoint with successful storage""" + with mock.patch('app.storage.store_temperature_data') as mock_store: + mock_store.return_value = "Temperature data successfully uploaded" + + response = self.client.get('/store') + + self.assertEqual(response.status_code, 200) + self.assertIn("successfully uploaded", response.get_data(as_text=True)) + mock_store.assert_called_once() + + def test_readyz_endpoint_ready(self): + """Test /readyz endpoint when service is ready""" + with mock.patch('app.readiness.readiness_check', return_value=200): + response = self.client.get('/readyz') + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertEqual(data['status'], 'ready') + + def test_readyz_endpoint_not_ready(self): + """Test /readyz endpoint when service is not ready""" + with mock.patch('app.readiness.readiness_check', return_value=503): + response = self.client.get('/readyz') + self.assertEqual(response.status_code, 503) + data = response.get_json() + self.assertEqual(data['status'], 'not ready') + self.assertIn('error', data) + + +class MockOpenSenseResponse: + """Mock response class to simulate OpenSenseMap API response.""" + + def __init__(self, temp_value): + self.text = "mock response text" + self.temp_value = temp_value + + def json(self): + """Return a mock JSON response.""" + return [{ + 'sensors': [ + { + 'title': 'Temperatur', + 'unit': '°C', + 'lastMeasurement': {'value': str(self.temp_value)} + } + ] + }] + + +class TestOpenSense(unittest.TestCase): + """Test cases for OpenSense module""" + + def test_get_temperature_returns_tuple(self): + """Test that opensense.get_temperature returns a tuple with correct format""" + result, stats = opensense.get_temperature() + self.assertIsInstance(result, str) + self.assertIsInstance(stats, dict) + self.assertIn('total_sensors', stats) + self.assertIn('null_count', stats) + self.assertIsNotNone(re.match( + r"Average temperature: (\d+\.\d{2}) °C \((Warning: Too cold|Good|Warning: Too hot)\)", + result + )) + + def test_temperature_too_cold(self): + """Test opensense.get_temperature for too cold condition (< 10°C)""" + with mock.patch('app.opensense.requests.get', + return_value=MockOpenSenseResponse(5)): + result, _ = opensense.get_temperature() + self.assertIn('Too cold', result) + + def test_temperature_good_range(self): + """Test opensense.get_temperature for good temperature range (10-30°C)""" + with mock.patch('app.opensense.requests.get', + return_value=MockOpenSenseResponse(20)): + result, _ = opensense.get_temperature() + self.assertIn('Good', result) + + def test_temperature_too_hot(self): + """Test opensense.get_temperature for too hot condition (> 30°C)""" + with mock.patch('app.opensense.requests.get', + return_value=MockOpenSenseResponse(40)): + result, _ = opensense.get_temperature() + self.assertIn('Too hot', result) + + def test_cache_hit(self): + """Test that cached data is returned when available""" + mock_redis_client = mock.MagicMock() + mock_redis_client.get.return_value = "cached_result" + + with mock.patch('app.opensense.REDIS_AVAILABLE', True), \ + mock.patch('app.opensense.redis_client', mock_redis_client), \ + mock.patch('app.opensense.requests.get') as mock_requests: + + result, _ = opensense.get_temperature() + self.assertEqual(result, "cached_result") + mock_requests.assert_not_called() + mock_redis_client.get.assert_called_once_with("temperature_data") + + def test_cache_miss_and_store(self): + """Test that data is fetched and cached on cache miss""" + mock_redis_client = mock.MagicMock() + mock_redis_client.get.return_value = None + + with mock.patch('app.opensense.REDIS_AVAILABLE', True), \ + mock.patch('app.opensense.redis_client', mock_redis_client), \ + mock.patch('app.opensense.requests.get', + return_value=MockOpenSenseResponse(25)): + + result, _ = opensense.get_temperature() + self.assertIn('Average temperature', result) + mock_redis_client.setex.assert_called() + # Verify cache key and TTL + call_args = mock_redis_client.setex.call_args + self.assertEqual(call_args[0][0], "temperature_data") + self.assertGreater(call_args[0][1], 0) # TTL should be positive + + +class TestStorage(unittest.TestCase): + """Test cases for storage functionality""" + + def setUp(self): + """Set up common test data""" + self.mock_temp_data = ( + "Average temperature: 22.5 °C (Good)\nFrom: test\n", + {"total_sensors": 10, "null_count": 1} ) - result = store_temperature_data() - - assert "MinIO S3 error occurred" in result - assert "Access denied" in result - - -def test_store_temperature_data_invalid_response_error(): - '''Test InvalidResponseError exception handling''' - with mock.patch('app.storage.Minio') as mock_minio_class: - mock_client = mock.MagicMock() - mock_minio_class.return_value = mock_client - - # Mock InvalidResponseError during put_object - mock_client.list_buckets.return_value = [] - mock_client.bucket_exists.return_value = True - mock_client.put_object.side_effect = InvalidResponseError( - "Invalid response", - content_type="application/json", - body=b"{}" - ) - - with mock.patch('app.opensense.get_temperature') as mock_temp: - mock_temp.return_value = "Test temperature data" - - result = store_temperature_data() - - assert "MinIO S3 error occurred" in result - assert "Invalid response" in result + def test_store_temperature_data_success(self): + """Test successful temperature data storage""" + with mock.patch('app.storage.Minio') as mock_minio_class: + mock_client = mock.MagicMock() + mock_minio_class.return_value = mock_client + mock_client.bucket_exists.return_value = True + mock_client.list_buckets.return_value = [] + + with mock.patch('app.storage.opensense.get_temperature', + return_value=self.mock_temp_data): + result = store_temperature_data() + + self.assertIn("successfully uploaded", result) + mock_client.put_object.assert_called_once() + + def test_store_temperature_data_create_bucket(self): + """Test bucket creation when it doesn't exist""" + with mock.patch('app.storage.Minio') as mock_minio_class: + mock_client = mock.MagicMock() + mock_minio_class.return_value = mock_client + mock_client.bucket_exists.return_value = False + mock_client.list_buckets.return_value = [] + + with mock.patch('app.storage.opensense.get_temperature', + return_value=self.mock_temp_data): + result = store_temperature_data() + + mock_client.make_bucket.assert_called_once_with("temperature-data") + self.assertIn("successfully uploaded", result) + + def test_store_temperature_data_s3_error(self): + """Test S3Error exception handling""" + with mock.patch('app.storage.Minio') as mock_minio_class: + mock_client = mock.MagicMock() + mock_minio_class.return_value = mock_client + mock_client.list_buckets.return_value = [] + mock_client.bucket_exists.side_effect = S3Error( + code="AccessDenied", + message="Access denied", + resource="/temperature-data", + request_id="test-request-id", + host_id="test-host-id", + response=None + ) + + with mock.patch('app.storage.opensense.get_temperature', + return_value=self.mock_temp_data): + result = store_temperature_data() + + self.assertIn("MinIO S3 error occurred", result) + self.assertIn("Access denied", result) + + def test_store_temperature_data_invalid_response(self): + """Test InvalidResponseError exception handling""" + with mock.patch('app.storage.Minio') as mock_minio_class: + mock_client = mock.MagicMock() + mock_minio_class.return_value = mock_client + mock_client.list_buckets.return_value = [] + mock_client.bucket_exists.return_value = True + mock_client.put_object.side_effect = InvalidResponseError( + "Invalid response", + content_type="application/json", + body=b"{}" + ) + + with mock.patch('app.storage.opensense.get_temperature', + return_value=self.mock_temp_data): + result = store_temperature_data() + + self.assertIn("MinIO S3 error occurred", result) + self.assertIn("Invalid response", result) + + def test_store_temperature_data_connection_error(self): + """Test connection error handling""" + with mock.patch('app.storage.Minio') as mock_minio_class: + mock_client = mock.MagicMock() + mock_minio_class.return_value = mock_client + mock_client.list_buckets.side_effect = ConnectionError("Network unreachable") + with mock.patch('app.storage.opensense.get_temperature', + return_value=self.mock_temp_data): + result = store_temperature_data() + + self.assertIn("Cannot connect to MinIO server", result) + self.assertIn("Network unreachable", result) + + +class TestReadiness(unittest.TestCase): + """Test cases for readiness checks""" + + def test_check_caching_redis_unavailable(self): + """Test check_caching when Redis is not available""" + with mock.patch('app.readiness.REDIS_AVAILABLE', False): + result = readiness.check_caching() + self.assertTrue(result) # No Redis = cache is old + + def test_check_caching_key_not_exists(self): + """Test check_caching when cache key doesn't exist""" + mock_redis_client = mock.MagicMock() + mock_redis_client.ttl.return_value = -2 # Key doesn't exist + + with mock.patch('app.readiness.REDIS_AVAILABLE', True), \ + mock.patch('app.readiness.redis_client', mock_redis_client): + result = readiness.check_caching() + self.assertTrue(result) + + def test_check_caching_key_no_expiry(self): + """Test check_caching when cache key has no expiry""" + mock_redis_client = mock.MagicMock() + mock_redis_client.ttl.return_value = -1 # Key exists but no expiry + + with mock.patch('app.readiness.REDIS_AVAILABLE', True), \ + mock.patch('app.readiness.redis_client', mock_redis_client): + result = readiness.check_caching() + self.assertTrue(result) + + def test_check_caching_fresh_cache(self): + """Test check_caching when cache is fresh""" + mock_redis_client = mock.MagicMock() + mock_redis_client.ttl.return_value = 150 # 2.5 minutes remaining + + with mock.patch('app.readiness.REDIS_AVAILABLE', True), \ + mock.patch('app.readiness.redis_client', mock_redis_client): + result = readiness.check_caching() + self.assertFalse(result) # Cache is fresh + + def test_reachable_boxes_healthy(self): + """Test reachable_boxes when most sensors are working""" + mock_stats = {"total_sensors": 100, "null_count": 10} + + with mock.patch('app.readiness.get_temperature', + return_value=("temp_result", mock_stats)): + result = readiness.reachable_boxes() + self.assertEqual(result, 200) + + def test_reachable_boxes_unhealthy(self): + """Test reachable_boxes when > 50% sensors are unreachable""" + mock_stats = {"total_sensors": 100, "null_count": 51} + + with mock.patch('app.readiness.get_temperature', + return_value=("temp_result", mock_stats)), \ + mock.patch('builtins.print'): # Suppress print output + result = readiness.reachable_boxes() + self.assertEqual(result, 400) + + def test_reachable_boxes_edge_cases(self): + """Test reachable_boxes edge cases""" + # No sensors + with mock.patch('app.readiness.get_temperature', + return_value=("temp", {"total_sensors": 0, "null_count": 0})): + self.assertEqual(readiness.reachable_boxes(), 200) + + # Exactly 50% unreachable (should be OK) + with mock.patch('app.readiness.get_temperature', + return_value=("temp", {"total_sensors": 100, "null_count": 50})): + self.assertEqual(readiness.reachable_boxes(), 200) + + def test_readiness_check_all_good(self): + """Test readiness_check when everything is healthy""" + with mock.patch('app.readiness.check_caching', return_value=False), \ + mock.patch('app.readiness.reachable_boxes', return_value=200): + result = readiness.readiness_check() + self.assertEqual(result, 200) + + def test_readiness_check_both_bad(self): + """Test readiness_check when both checks fail""" + with mock.patch('app.readiness.check_caching', return_value=True), \ + mock.patch('app.readiness.reachable_boxes', return_value=400): + result = readiness.readiness_check() + self.assertEqual(result, 503) + + def test_readiness_check_partial_failure(self): + """Test readiness_check when only one check fails""" + # Only cache is old + with mock.patch('app.readiness.check_caching', return_value=True), \ + mock.patch('app.readiness.reachable_boxes', return_value=200): + result = readiness.readiness_check() + self.assertEqual(result, 200) + + # Only sensors are unreachable + with mock.patch('app.readiness.check_caching', return_value=False), \ + mock.patch('app.readiness.reachable_boxes', return_value=400): + result = readiness.readiness_check() + self.assertEqual(result, 200) + + +if __name__ == '__main__': + unittest.main() diff --git a/version.txt b/version.txt index 09a3acf..bcaffe1 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.6.0 \ No newline at end of file +0.7.0 \ No newline at end of file