From d59c3e90f4369b9b986a8805d05348f6a348be6c Mon Sep 17 00:00:00 2001 From: GabrielPalmar Date: Tue, 12 Aug 2025 11:30:49 -0500 Subject: [PATCH 1/6] feat(readyz): Readiness endpoint to check info and cache --- .gitignore | 4 +- Dockerfile | 2 +- app/main.py | 14 +++++ app/opensense.py | 14 ++++- app/readiness.py | 65 +++++++++++++++++++++++ tests/fixtures/vcr_cassettes/version.yaml | 2 +- tests/test_modules.py | 37 +++++++++++++ version.txt | 2 +- 8 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 app/readiness.py 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/main.py b/app/main.py index b5f5340..31ecaed 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__) @@ -36,5 +37,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 + else: + 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..60e4fa9 100644 --- a/app/opensense.py +++ b/app/opensense.py @@ -1,5 +1,6 @@ '''Module to get entries from OpenSenseMap API and get the average temperature''' from datetime import datetime, timezone, timedelta +import re import os import requests import redis @@ -25,6 +26,8 @@ REDIS_AVAILABLE = False print("Could not connect to Redis.") +_sensor_stats = {"total_sensors": 0, "null_count": 0} + def get_temperature(): '''Function to get the average temperature from OpenSenseMap API.''' cache_key = "temperature_data" @@ -52,17 +55,24 @@ 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 @@ -81,4 +91,4 @@ def get_temperature(): 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..2457da6 --- /dev/null +++ b/app/readiness.py @@ -0,0 +1,65 @@ +'''Module to check the readiness of the stored information''' +import os +from opensense import get_temperature +import redis + +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)) + +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.") + +def check_caching(): + '''Check if caching content is older than 5 minutes''' + if not REDIS_AVAILABLE: + return False + + # Get the TTL (time to live) of the cached temperature data + cache_key = "temperature_data" + ttl = redis_client.ttl(cache_key) + + if ttl == -2: # Key doesn't exist (expired) + return True + elif ttl == -1: # Key exists but has no expiry + return True + elif ttl <= (5 * 60) and ttl > 0: # Cache exists and has time remaining (valid) + return False + + return True + +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_valid = check_caching() + + # Return 503 if BOTH conditions are met: + # 1. More than 50% of boxes are unreachable AND + # 2. Cache is older than 5 minutes + if boxes_status == 400 and cache_valid: + return 503 + + return 200 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..87c443a 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -5,6 +5,7 @@ from app.storage import store_temperature_data from app.main import app from app import opensense +from app import readiness def test_app_exists(): """Test that the Flask app exists""" @@ -194,3 +195,39 @@ def test_store_temperature_data_invalid_response_error(): assert "MinIO S3 error occurred" in result assert "Invalid response" in result + +def test_readiness_reachability(): + '''Test readiness endpoint reachability''' + with mock.patch('app.readiness.check_readiness') as mock_check: + mock_check.return_value = True + + result = readiness.reachable_boxes() + + assert result is True + +def test_readiness_unreachability(): + '''Test readiness endpoint unreachability''' + with mock.patch('app.readiness.check_readiness') as mock_check: + mock_check.return_value = False + + result = readiness.reachable_boxes() + + assert result is False + +def test_readiness_check(): + '''Test readiness endpoint check''' + with mock.patch('app.readiness.check_readiness') as mock_check: + mock_check.return_value = True + + result = readiness.readiness_check() + + assert result is 200 + +def test_readiness_check_unreachable(): + '''Test readiness endpoint unreachability''' + with mock.patch('app.readiness.check_readiness') as mock_check: + mock_check.return_value = False + + result = readiness.readiness_check() + + assert result is 503 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 From 5f19b72e5088a19ccc0080aa03d7e64b6d4e95f5 Mon Sep 17 00:00:00 2001 From: GabrielPalmar Date: Tue, 12 Aug 2025 12:03:48 -0500 Subject: [PATCH 2/6] fix(ci): Pylint issues --- app/config.py | 27 ++++++++++++++++++++++++++ app/main.py | 10 +++++----- app/opensense.py | 49 ++++++++++++++++++++++-------------------------- app/readiness.py | 42 ++++++++++++----------------------------- 4 files changed, 66 insertions(+), 62 deletions(-) create mode 100644 app/config.py diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..12e61bd --- /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 \ No newline at end of file diff --git a/app/main.py b/app/main.py index 31ecaed..0515353 100644 --- a/app/main.py +++ b/app/main.py @@ -44,11 +44,11 @@ def readyz(): if status_code == 200: return {"status": "ready"}, 200 - else: - return { - "status": "not ready", - "error": "More than 50% of sensors unreachable and cache expired" - }, 503 + + 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 60e4fa9..4c84e66 100644 --- a/app/opensense.py +++ b/app/opensense.py @@ -1,33 +1,31 @@ '''Module to get entries from OpenSenseMap API and get the average temperature''' from datetime import datetime, timezone, timedelta import re -import os 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" @@ -77,12 +75,9 @@ def get_temperature(): total_sum = sum(temp_list) average = total_sum / 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: diff --git a/app/readiness.py b/app/readiness.py index 2457da6..a4b5e59 100644 --- a/app/readiness.py +++ b/app/readiness.py @@ -1,45 +1,27 @@ '''Module to check the readiness of the stored information''' -import os -from opensense import get_temperature -import redis +from app.opensense import get_temperature +from app.config import create_redis_client -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)) - -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() def check_caching(): '''Check if caching content is older than 5 minutes''' if not REDIS_AVAILABLE: - return False + return True # No Redis = cache is old # Get the TTL (time to live) of the cached temperature data cache_key = "temperature_data" ttl = redis_client.ttl(cache_key) if ttl == -2: # Key doesn't exist (expired) - return True + return True # Cache is old elif ttl == -1: # Key exists but has no expiry - return True - elif ttl <= (5 * 60) and ttl > 0: # Cache exists and has time remaining (valid) - return False + return True # Cache is old + elif ttl > 0: # Cache exists and has time remaining + return False # Cache is fresh - return True + return True # Default: cache is old def reachable_boxes(): '''Check if 50% + 1 of boxes are reachable''' @@ -54,12 +36,12 @@ def reachable_boxes(): def readiness_check(): '''Combined readiness check for the /readyz endpoint''' boxes_status = reachable_boxes() - cache_valid = check_caching() + cache_is_old = check_caching() # Rename: True = old, False = fresh # Return 503 if BOTH conditions are met: # 1. More than 50% of boxes are unreachable AND # 2. Cache is older than 5 minutes - if boxes_status == 400 and cache_valid: + if boxes_status == 400 and cache_is_old: # Now it reads correctly return 503 return 200 From 75b1a70415afdfa71bab565e4777fa3598446808 Mon Sep 17 00:00:00 2001 From: GabrielPalmar Date: Tue, 12 Aug 2025 12:25:14 -0500 Subject: [PATCH 3/6] fix(ci): SQ issues --- app/config.py | 2 +- app/opensense.py | 9 ++++--- app/readiness.py | 4 +-- tests/test_modules.py | 60 ++++++++++++++++++++++++------------------- 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/app/config.py b/app/config.py index 12e61bd..5f97eda 100644 --- a/app/config.py +++ b/app/config.py @@ -24,4 +24,4 @@ def create_redis_client(): return redis_client, True except (redis.ConnectionError, redis.TimeoutError) as e: print(f"Could not connect to Redis: {e}") - return None, False \ No newline at end of file + return None, False diff --git a/app/opensense.py b/app/opensense.py index 4c84e66..92d4b15 100644 --- a/app/opensense.py +++ b/app/opensense.py @@ -28,13 +28,14 @@ def classify_temperature(average): 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.") @@ -81,7 +82,7 @@ def get_temperature(): 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}") diff --git a/app/readiness.py b/app/readiness.py index a4b5e59..b70e070 100644 --- a/app/readiness.py +++ b/app/readiness.py @@ -16,9 +16,9 @@ def check_caching(): if ttl == -2: # Key doesn't exist (expired) return True # Cache is old - elif ttl == -1: # Key exists but has no expiry + if ttl == -1: # Key exists but has no expiry return True # Cache is old - elif ttl > 0: # Cache exists and has time remaining + if ttl > 0: # Cache exists and has time remaining return False # Cache is fresh return True # Default: cache is old diff --git a/tests/test_modules.py b/tests/test_modules.py index 87c443a..db5f1d1 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -30,9 +30,10 @@ def test_metrics_endpoint(): assert response.status_code == 200 def test_opensense_get_temperature(): - """Test that opensense.get_temperature returns a string""" - result = opensense.get_temperature() + """Test that opensense.get_temperature returns a tuple""" + result, stats = opensense.get_temperature() # Unpack the tuple assert isinstance(result, str) + assert isinstance(stats, dict) assert re.match( r"Average temperature: (\d+\.\d{2}) °C \((Warning: Too cold|Good|Warning: Too hot)\)", result @@ -42,12 +43,16 @@ def mock_response(temp_value): """Mock response for OpenSenseMap API""" class MockResponse: """Mock response class to simulate OpenSenseMap API response.""" + def __init__(self): + self.text = "mock response text" # Add this line + def json(self): """Return a mock JSON response.""" return [{ 'sensors': [ { 'title': 'Temperatur', + 'unit': '°C', # Add unit field 'lastMeasurement': {'value': str(temp_value)} } ] @@ -57,13 +62,13 @@ def json(self): 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() + result, _ = opensense.get_temperature() # Unpack tuple 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() + result, _ = opensense.get_temperature() # Unpack tuple assert 'Too hot' in result def test_opensense_cache_get(): @@ -71,7 +76,7 @@ def test_opensense_cache_get(): 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() + result, _ = opensense.get_temperature() # Unpack tuple assert result == "cached_result" mock_requests.assert_not_called() @@ -81,7 +86,7 @@ def test_opensense_cache_setex(): 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() + result, _ = opensense.get_temperature() # Unpack tuple assert 'Average temperature' in result mock_setex.assert_called() @@ -114,9 +119,9 @@ def test_store_temperature_data_integration(): mock_client.put_object.return_value = None mock_client.list_buckets.return_value = [] - # Mock temperature data + # Mock temperature data - return tuple with mock.patch('app.opensense.get_temperature') as mock_temp: - mock_temp.return_value = "Average temperature: 22.5 °C (Good)\nFrom: test\n" + mock_temp.return_value = ("Average temperature: 22.5 °C (Good)\nFrom: test\n", {"total_sensors": 0, "null_count": 0}) # Import and call the actual function result = store_temperature_data() @@ -138,7 +143,7 @@ def test_store_temperature_data_bucket_creation(): 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" + mock_temp.return_value = ("Average temperature: 22.5 °C (Good)\nFrom: test\n", {"total_sensors": 0, "null_count": 0}) result = store_temperature_data() @@ -166,12 +171,15 @@ def test_store_temperature_data_s3_error(): host_id="test-host-id", response=None ) + + # Mock get_temperature to return tuple + with mock.patch('app.opensense.get_temperature') as mock_temp: + mock_temp.return_value = ("Test temperature data", {"total_sensors": 0, "null_count": 0}) - result = store_temperature_data() - - assert "MinIO S3 error occurred" in result - assert "Access denied" in result + 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''' @@ -189,7 +197,7 @@ def test_store_temperature_data_invalid_response_error(): ) with mock.patch('app.opensense.get_temperature') as mock_temp: - mock_temp.return_value = "Test temperature data" + mock_temp.return_value = ("Test temperature data", {"total_sensors": 0, "null_count": 0}) result = store_temperature_data() @@ -198,36 +206,36 @@ def test_store_temperature_data_invalid_response_error(): def test_readiness_reachability(): '''Test readiness endpoint reachability''' - with mock.patch('app.readiness.check_readiness') as mock_check: - mock_check.return_value = True + with mock.patch('app.readiness.reachable_boxes') as mock_check: + mock_check.return_value = 200 result = readiness.reachable_boxes() - assert result is True + assert result == 200 def test_readiness_unreachability(): '''Test readiness endpoint unreachability''' - with mock.patch('app.readiness.check_readiness') as mock_check: - mock_check.return_value = False + with mock.patch('app.readiness.reachable_boxes') as mock_check: + mock_check.return_value = 400 result = readiness.reachable_boxes() - assert result is False + assert result == 400 def test_readiness_check(): '''Test readiness endpoint check''' - with mock.patch('app.readiness.check_readiness') as mock_check: - mock_check.return_value = True + with mock.patch('app.readiness.reachable_boxes', return_value=200), \ + mock.patch('app.readiness.check_caching', return_value=False): result = readiness.readiness_check() - assert result is 200 + assert result == 200 def test_readiness_check_unreachable(): '''Test readiness endpoint unreachability''' - with mock.patch('app.readiness.check_readiness') as mock_check: - mock_check.return_value = False + with mock.patch('app.readiness.reachable_boxes', return_value=400), \ + mock.patch('app.readiness.check_caching', return_value=True): result = readiness.readiness_check() - assert result is 503 + assert result == 503 From 87dd61bdf0664966541ae670df835ca59d865d7d Mon Sep 17 00:00:00 2001 From: GabrielPalmar Date: Tue, 12 Aug 2025 12:48:05 -0500 Subject: [PATCH 4/6] fix(ci): Resolving issues --- app/main.py | 3 +- app/readiness.py | 20 +++++++----- app/storage.py | 10 +++--- k8s/deployment.yml | 8 ++--- tests/test_modules.py | 76 +++++++++++++++++++++++++++++++++++-------- 5 files changed, 84 insertions(+), 33 deletions(-) diff --git a/app/main.py b/app/main.py index 0515353..7d514ff 100644 --- a/app/main.py +++ b/app/main.py @@ -25,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(): diff --git a/app/readiness.py b/app/readiness.py index b70e070..914bfa0 100644 --- a/app/readiness.py +++ b/app/readiness.py @@ -10,18 +10,20 @@ def check_caching(): if not REDIS_AVAILABLE: return True # No Redis = cache is old - # Get the TTL (time to live) of the cached temperature data cache_key = "temperature_data" ttl = redis_client.ttl(cache_key) - if ttl == -2: # Key doesn't exist (expired) - return True # Cache is old - if ttl == -1: # Key exists but has no expiry - return True # Cache is old - if ttl > 0: # Cache exists and has time remaining - return False # Cache is fresh - - return True # Default: cache is old + # Cache is considered "old" when: + # - Key doesn't exist (ttl == -2) + # - Key has no expiry (ttl == -1) + # - Less than 5 minutes remaining (ttl > 0 and ttl <= 300) + if ttl == -2 or ttl == -1: + return True + + # If cache exists and has more than 5 minutes, it's fresh + # Since your CACHE_TTL is 300 seconds (5 minutes), cache is always "old" + # unless you increase CACHE_TTL to more than 300 seconds + return False def reachable_boxes(): '''Check if 50% + 1 of boxes are reachable''' diff --git a/app/storage.py b/app/storage.py index 9fcc91d..b81716c 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/test_modules.py b/tests/test_modules.py index db5f1d1..a7518fc 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -45,7 +45,7 @@ class MockResponse: """Mock response class to simulate OpenSenseMap API response.""" def __init__(self): self.text = "mock response text" # Add this line - + def json(self): """Return a mock JSON response.""" return [{ @@ -73,8 +73,12 @@ def test_opensense_get_temperature_too_hot(): def test_opensense_cache_get(): """Test that cached data is used if available""" + # Create a mock redis client + 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.get', return_value="cached_result"), \ + mock.patch('app.opensense.redis_client', mock_redis_client), \ mock.patch('app.opensense.requests.get') as mock_requests: result, _ = opensense.get_temperature() # Unpack tuple assert result == "cached_result" @@ -82,13 +86,16 @@ def test_opensense_cache_get(): def test_opensense_cache_setex(): """Test that data is cached after fetching""" + # Create a mock redis client + 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.get', return_value=None), \ - mock.patch('app.opensense.redis_client.setex') as mock_setex, \ + mock.patch('app.opensense.redis_client', mock_redis_client), \ mock.patch('app.opensense.requests.get', return_value=mock_response(25)): result, _ = opensense.get_temperature() # Unpack tuple assert 'Average temperature' in result - mock_setex.assert_called() + mock_redis_client.setex.assert_called() def test_store_endpoint(): """Test store endpoint with test client""" @@ -120,8 +127,13 @@ def test_store_temperature_data_integration(): mock_client.list_buckets.return_value = [] # Mock temperature data - return tuple - with mock.patch('app.opensense.get_temperature') as mock_temp: - mock_temp.return_value = ("Average temperature: 22.5 °C (Good)\nFrom: test\n", {"total_sensors": 0, "null_count": 0}) + with mock.patch('app.storage.opensense.get_temperature') as mock_temp: + mock_temp.return_value = ( + "Average temperature: 22.5 °C (Good)\nFrom: test\n", + { + "total_sensors": 0, + "null_count": 0 + }) # Import and call the actual function result = store_temperature_data() @@ -142,8 +154,13 @@ def test_store_temperature_data_bucket_creation(): 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", {"total_sensors": 0, "null_count": 0}) + with mock.patch('app.storage.opensense.get_temperature') as mock_temp: + mock_temp.return_value = ( + "Average temperature: 22.5 °C (Good)\nFrom: test\n", + { + "total_sensors": 0, + "null_count": 0 + }) result = store_temperature_data() @@ -171,10 +188,16 @@ def test_store_temperature_data_s3_error(): host_id="test-host-id", response=None ) - + # Mock get_temperature to return tuple - with mock.patch('app.opensense.get_temperature') as mock_temp: - mock_temp.return_value = ("Test temperature data", {"total_sensors": 0, "null_count": 0}) + with mock.patch('app.storage.opensense.get_temperature') as mock_temp: + mock_temp.return_value = ( + "Test temperature data", + { + "total_sensors": 0, + "null_count": 0 + } + ) result = store_temperature_data() @@ -196,8 +219,14 @@ def test_store_temperature_data_invalid_response_error(): body=b"{}" ) - with mock.patch('app.opensense.get_temperature') as mock_temp: - mock_temp.return_value = ("Test temperature data", {"total_sensors": 0, "null_count": 0}) + with mock.patch('app.storage.opensense.get_temperature') as mock_temp: + mock_temp.return_value = ( + "Test temperature data", + { + "total_sensors": 0, + "null_count": 0 + } + ) result = store_temperature_data() @@ -239,3 +268,22 @@ def test_readiness_check_unreachable(): result = readiness.readiness_check() assert result == 503 + +def test_readyz_endpoint(): + '''Test /readyz endpoint''' + client = app.test_client() + + # Test when service is ready + with mock.patch('app.readiness.readiness_check', return_value=200): + response = client.get('/readyz') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ready' + + # Test when service is not ready + with mock.patch('app.readiness.readiness_check', return_value=503): + response = client.get('/readyz') + assert response.status_code == 503 + data = response.get_json() + assert data['status'] == 'not ready' + assert 'error' in data From 5d6176174d20e6d060b8bb281ef2227bdb5e3182 Mon Sep 17 00:00:00 2001 From: GabrielPalmar Date: Mon, 18 Aug 2025 12:20:15 -0500 Subject: [PATCH 5/6] fix(test): Refactored test modules --- app/opensense.py | 3 +- app/readiness.py | 19 +- app/storage.py | 4 +- tests/test_modules.py | 615 +++++++++++++++++++++++------------------- 4 files changed, 349 insertions(+), 292 deletions(-) diff --git a/app/opensense.py b/app/opensense.py index 92d4b15..f88c2a9 100644 --- a/app/opensense.py +++ b/app/opensense.py @@ -73,8 +73,7 @@ def get_temperature(): 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 # Use the dictionary-based classification status = classify_temperature(average) diff --git a/app/readiness.py b/app/readiness.py index 914bfa0..86167cd 100644 --- a/app/readiness.py +++ b/app/readiness.py @@ -2,27 +2,19 @@ from app.opensense import get_temperature from app.config import create_redis_client -# Use shared 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 # No Redis = cache is old + return True cache_key = "temperature_data" ttl = redis_client.ttl(cache_key) - # Cache is considered "old" when: - # - Key doesn't exist (ttl == -2) - # - Key has no expiry (ttl == -1) - # - Less than 5 minutes remaining (ttl > 0 and ttl <= 300) - if ttl == -2 or ttl == -1: + if ttl in (-2, -1): return True - # If cache exists and has more than 5 minutes, it's fresh - # Since your CACHE_TTL is 300 seconds (5 minutes), cache is always "old" - # unless you increase CACHE_TTL to more than 300 seconds return False def reachable_boxes(): @@ -38,12 +30,9 @@ def reachable_boxes(): def readiness_check(): '''Combined readiness check for the /readyz endpoint''' boxes_status = reachable_boxes() - cache_is_old = check_caching() # Rename: True = old, False = fresh + cache_is_old = check_caching() - # Return 503 if BOTH conditions are met: - # 1. More than 50% of boxes are unreachable AND - # 2. Cache is older than 5 minutes - if boxes_status == 400 and cache_is_old: # Now it reads correctly + if boxes_status == 400 and cache_is_old: return 503 return 200 diff --git a/app/storage.py b/app/storage.py index b81716c..db8f5aa 100644 --- a/app/storage.py +++ b/app/storage.py @@ -32,9 +32,9 @@ def store_temperature_data(): destination_file = f"temperature_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S%f')}.txt" # Get the temperature data - unpack the tuple - temperature_result, _ = opensense.get_temperature() + temperature_result, _ = opensense.get_temperature() - text_bytes = temperature_result.encode('utf-8') + text_bytes = temperature_result.encode('utf-8') text_stream = io.BytesIO(text_bytes) # Make the bucket if it doesn't exist. diff --git a/tests/test_modules.py b/tests/test_modules.py index a7518fc..bddd399 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,5 +1,6 @@ '''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 @@ -7,283 +8,351 @@ from app import opensense from app import readiness -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 tuple""" - result, stats = opensense.get_temperature() # Unpack the tuple - assert isinstance(result, str) - assert isinstance(stats, dict) - 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 __init__(self): - self.text = "mock response text" # Add this line - - def json(self): - """Return a mock JSON response.""" - return [{ - 'sensors': [ - { - 'title': 'Temperatur', - 'unit': '°C', # Add unit field - '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() # Unpack tuple - 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() # Unpack tuple - assert 'Too hot' in result - -def test_opensense_cache_get(): - """Test that cached data is used if available""" - # Create a mock redis client - 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() # Unpack tuple - assert result == "cached_result" - mock_requests.assert_not_called() - -def test_opensense_cache_setex(): - """Test that data is cached after fetching""" - # Create a mock redis client - 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=mock_response(25)): - result, _ = opensense.get_temperature() # Unpack tuple - assert 'Average temperature' in result - mock_redis_client.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 - return tuple - with mock.patch('app.storage.opensense.get_temperature') as mock_temp: - mock_temp.return_value = ( - "Average temperature: 22.5 °C (Good)\nFrom: test\n", - { - "total_sensors": 0, - "null_count": 0 - }) - - # 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.storage.opensense.get_temperature') as mock_temp: - mock_temp.return_value = ( - "Average temperature: 22.5 °C (Good)\nFrom: test\n", - { - "total_sensors": 0, - "null_count": 0 - }) - - 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 - ) - - # Mock get_temperature to return tuple - with mock.patch('app.storage.opensense.get_temperature') as mock_temp: - mock_temp.return_value = ( - "Test temperature data", +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': [ { - "total_sensors": 0, - "null_count": 0 + 'title': 'Temperatur', + 'unit': '°C', + 'lastMeasurement': {'value': str(self.temp_value)} } - ) - - 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"{}" + ] + }] + + +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} ) - with mock.patch('app.storage.opensense.get_temperature') as mock_temp: - mock_temp.return_value = ( - "Test temperature data", - { - "total_sensors": 0, - "null_count": 0 - } + 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 ) - result = store_temperature_data() - - assert "MinIO S3 error occurred" in result - assert "Invalid response" in result - -def test_readiness_reachability(): - '''Test readiness endpoint reachability''' - with mock.patch('app.readiness.reachable_boxes') as mock_check: - mock_check.return_value = 200 - - result = readiness.reachable_boxes() - - assert result == 200 - -def test_readiness_unreachability(): - '''Test readiness endpoint unreachability''' - with mock.patch('app.readiness.reachable_boxes') as mock_check: - mock_check.return_value = 400 - - result = readiness.reachable_boxes() - - assert result == 400 - -def test_readiness_check(): - '''Test readiness endpoint check''' - with mock.patch('app.readiness.reachable_boxes', return_value=200), \ - mock.patch('app.readiness.check_caching', return_value=False): - - result = readiness.readiness_check() - - assert result == 200 - -def test_readiness_check_unreachable(): - '''Test readiness endpoint unreachability''' - with mock.patch('app.readiness.reachable_boxes', return_value=400), \ - mock.patch('app.readiness.check_caching', return_value=True): - - result = readiness.readiness_check() - - assert result == 503 - -def test_readyz_endpoint(): - '''Test /readyz endpoint''' - client = app.test_client() - - # Test when service is ready - with mock.patch('app.readiness.readiness_check', return_value=200): - response = client.get('/readyz') - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'ready' + 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"{}" + ) - # Test when service is not ready - with mock.patch('app.readiness.readiness_check', return_value=503): - response = client.get('/readyz') - assert response.status_code == 503 - data = response.get_json() - assert data['status'] == 'not ready' - assert 'error' in data + 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 = OSError("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() From e3a2f388ff59038d97d556c400ac363b0ba7a27c Mon Sep 17 00:00:00 2001 From: GabrielPalmar Date: Mon, 18 Aug 2025 12:30:58 -0500 Subject: [PATCH 6/6] fix(test): Resolved issue for SQ --- tests/test_modules.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_modules.py b/tests/test_modules.py index bddd399..43e655d 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -245,8 +245,7 @@ def test_store_temperature_data_connection_error(self): 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 = OSError("Network unreachable") - + 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()