Skip to content

Commit cb6da10

Browse files
authored
refactor(content-health-monitor): add tests (#259)
* Fix global scope access in content_health_utils.py * Add unit tests for Python functions * Add additional tests * Update README * Better implementation of shared globals * Update unit tests to use globals module * Add integration tests * Update README.md * Use a state object * Update unit tests to use state object * Refactor integration tests to use pytest and state object * Fix bad import * Add a makefile to make running the tests easier * Update the manifest using the new makefile target * Update makefile to install rsconnect-python and always use uv * Update README * Refactor integration tests to import once at module level, remove unnecessary reimport test
1 parent 6726785 commit cb6da10

File tree

7 files changed

+1252
-42
lines changed

7 files changed

+1252
-42
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Makefile for Content Health Monitor
2+
3+
# Default target
4+
.PHONY: all
5+
all: setup test
6+
7+
# Setup the virtual environment with dependencies
8+
.PHONY: setup
9+
setup:
10+
@echo "Setting up virtual environment and installing dependencies..."
11+
pip install uv
12+
uv venv
13+
uv pip install -r requirements.txt
14+
uv pip install pytest rsconnect-python
15+
16+
# Run all tests
17+
.PHONY: test
18+
test:
19+
@echo "Running all tests..."
20+
uv run pytest -v
21+
22+
# Run unit tests only
23+
.PHONY: test-unit
24+
test-unit:
25+
@echo "Running unit tests..."
26+
uv run pytest test_content_health_utils.py -v
27+
28+
# Run integration tests only
29+
.PHONY: test-integration
30+
test-integration:
31+
@echo "Running integration tests..."
32+
uv run pytest test_integration.py -v
33+
34+
# Clean up - remove virtual environment and cache files
35+
.PHONY: clean
36+
clean:
37+
@echo "Cleaning up..."
38+
rm -rf .venv
39+
rm -rf .pytest_cache
40+
rm -rf __pycache__
41+
rm -rf *.pyc
42+
43+
# This recipe updates the manifest, with a few helpful modifications:
44+
# - Uses rsconnect-python to generate the manifest
45+
# - Explicitly includes the necessary files in the manifest
46+
# - Copies over `extension` and `environment` blocks from the old manifest to
47+
# the new one. These are Gallery-specific blocks, and not created by
48+
# rsconnect-python.
49+
.PHONY: update-manifest
50+
update-manifest:
51+
cp manifest.json manifest.old.json
52+
uv run rsconnect write-manifest quarto content-health-monitor.qmd content_health_utils.py --overwrite
53+
jq -n --slurpfile old manifest.old.json --slurpfile new manifest.json \
54+
'{"version": $$new[0].version, "locale": $$new[0].locale, "metadata": $$new[0].metadata, "extension": $$old[0].extension, "environment": $$old[0].environment} * ($$new[0] | del(.version, .locale, .metadata))' \
55+
> manifest.merged.json
56+
mv manifest.merged.json manifest.json
57+
rm -f manifest.old.json
58+
59+
# Help command to show available targets
60+
.PHONY: help
61+
help:
62+
@echo "Available targets:"
63+
@echo " setup - Setup virtual environment and install dependencies"
64+
@echo " test - Run all tests"
65+
@echo " test-unit - Run unit tests only"
66+
@echo " test-integration - Run integration tests only"
67+
@echo " clean - Clean up virtual environment and cache files"
68+
@echo " update-manifest - Update manifest.json preserving extension and environment blocks"
69+
@echo " help - Show this help message"
70+
@echo " all - Setup environment and run tests (default)"

extensions/content-health-monitor/README.md

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,31 @@ For more advanced validation, modify the `validate()` function in `content_healt
2828

2929
# Testing
3030

31-
If customizing the code in this report here are some general test scenarios to consider:
32-
33-
- No MONITORED_CONTENT_GUID env var set
34-
- If scheduled, DOES NOT send an email
35-
- Invalid GUID
36-
- If scheduled, sends an email
37-
- Valid GUID for content you are a owner of
38-
- Valid GUID for content you are a collaborator of
39-
- Valid GUID for content you are a viewer of
40-
- Valid GUID for content you do not have access to (not in ACL, etc.)
41-
- In the above setup, where the monitored content passing validation, if scheduled, DOES NOT send an email
42-
- Valid GUID, content is published but fails validation
43-
- If scheduled, sends an email
31+
The Content Health Monitor includes a comprehensive test suite. You can run the tests using the provided Makefile or manually with pytest.
32+
33+
## Using the Makefile
34+
35+
The project includes a Makefile with several targets to simplify common tasks:
36+
37+
```bash
38+
# Setup environment and install dependencies
39+
make setup
40+
41+
# Run all tests
42+
make test
43+
44+
# Run only unit tests
45+
make test-unit
46+
47+
# Run only integration tests
48+
make test-integration
49+
50+
# Update manifest.json (preserves extension and environment blocks)
51+
make update-manifest
52+
53+
# Show help with all available targets
54+
make help
55+
56+
# Clean up virtual environment and cache files
57+
make clean
58+
```

extensions/content-health-monitor/content-health-monitor.qmd

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,33 +16,35 @@ import datetime
1616
from posit import connect
1717
from IPython.display import HTML, display
1818
from content_health_utils import *
19+
from content_health_utils import MonitorState
1920
20-
# Initialize state variables
21-
show_instructions = False # Used to display on-screen setup instructions if environment variables are missing
22-
instructions = [] # List to store setup instructions for the user
21+
# Create state object to share between this file and utils
22+
state = MonitorState()
23+
24+
# Initialize state variables that are only used in this Quarto document
2325
show_error = False # Used to display on-screen error messages if API errors occur
2426
error_message = None # Error message to display if API errors occur
2527
error_guid = None # GUID that caused the error
2628
content_result = None # Variable to store content monitoring result
2729
2830
# Read environment variables
29-
connect_server = get_env_var("CONNECT_SERVER") # Automatically provided by Connect, must be set when previewing locally
30-
api_key = get_env_var("CONNECT_API_KEY") # Automatically provided by Connect, must be set when previewing locally
31-
monitored_content_guid = get_env_var("MONITORED_CONTENT_GUID")
31+
connect_server = get_env_var("CONNECT_SERVER", state) # Automatically provided by Connect, must be set when previewing locally
32+
api_key = get_env_var("CONNECT_API_KEY", state) # Automatically provided by Connect, must be set when previewing locally
33+
monitored_content_guid = get_env_var("MONITORED_CONTENT_GUID", state)
3234
3335
# Extract GUID if it's a string or URL containing a GUID
3436
if monitored_content_guid:
3537
monitored_content_guid = extract_guid(monitored_content_guid)
3638
3739
# Only instantiate the client if we have the required environment variables
3840
client = None
39-
if not show_instructions:
41+
if not state.show_instructions:
4042
try:
4143
# Instantiate a Connect client using posit-sdk where api_key and url are automatically read from our environment vars
4244
client = connect.Client()
4345
except ValueError as e:
44-
show_instructions = True
45-
instructions.append(f"<b>Error initializing Connect client:</b> {str(e)}")
46+
state.show_instructions = True
47+
state.instructions.append(f"<b>Error initializing Connect client:</b> {str(e)}")
4648
4749
4850
# ------ DATA GATHERING SECTION ------ #
@@ -53,7 +55,7 @@ check_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
5355
current_user_name = "the publisher" # Default, used if no name is available
5456
5557
# Get current user's full name if client is available
56-
if not show_instructions and client:
58+
if not state.show_instructions and client:
5759
try:
5860
user_name = get_current_user_full_name(client)
5961
if user_name: # Only update if we got a valid name
@@ -62,8 +64,8 @@ if not show_instructions and client:
6264
print(f"Warning: Could not get current user name: {str(e)}")
6365
6466
# Only proceed with content validation if we have all requirements
65-
# (show_instructions=False already tells us env vars are set and client was initialized)
66-
if not show_instructions:
67+
# (state.show_instructions=False already tells us env vars are set and client was initialized)
68+
if not state.show_instructions:
6769
try:
6870
# Check if server is reachable
6971
check_server_reachable(connect_server, api_key)
@@ -95,17 +97,17 @@ content becomes unreachable.
9597
9698
9799
# Prepare instructions HTML if needed
98-
if show_instructions:
99-
instructions_html = "".join(f"<div style='margin-bottom: 10px;'>{instruction}</div>" for instruction in instructions)
100+
if state.show_instructions:
101+
instructions_html = "".join(f"<div style='margin-bottom: 10px;'>{instruction}</div>" for instruction in state.instructions)
100102
else:
101103
instructions_html = None
102104
103105
# Create all HTML components up front based on state
104106
html_components = {
105-
'instructions': create_instructions_box(instructions_html) if show_instructions else None,
107+
'instructions': create_instructions_box(instructions_html) if state.show_instructions else None,
106108
'error': create_error_box(error_guid, error_message) if show_error else None,
107109
'report': create_report_display(content_result, check_time, current_user_name) if content_result and not has_error(content_result) else None,
108-
'no_results': create_no_results_box() if not (show_instructions or show_error or
110+
'no_results': create_no_results_box() if not (state.show_instructions or show_error or
109111
(content_result and not has_error(content_result))) else None
110112
}
111113

extensions/content-health-monitor/content_health_utils.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@
44
import requests
55
from posit import connect
66

7+
class MonitorState:
8+
"""State container for content health monitor"""
9+
10+
def __init__(self):
11+
"""Initialize with default values"""
12+
# Flag to indicate if setup instructions should be displayed
13+
self.show_instructions = False
14+
# List to store setup instructions for the user
15+
self.instructions = []
16+
717
# Define status constants
818
STATUS_PASS = "PASS"
919
STATUS_FAIL = "FAIL"
@@ -45,16 +55,11 @@
4555
CSS_GRID_STYLE = "display: grid; grid-template-columns: 150px auto; grid-gap: 8px; padding: 10px 0;"
4656

4757
# Helper function to read environment variables and add instructions if missing
48-
def get_env_var(var_name, description=""):
58+
def get_env_var(var_name, state, description=""):
4959
"""Get environment variable and add instruction if missing"""
50-
# These variables are defined in the Quarto document
51-
# We'll need to use them from the global scope
52-
import sys
53-
caller_globals = sys._getframe(1).f_globals
54-
5560
value = os.environ.get(var_name, "")
5661
if not value:
57-
caller_globals["show_instructions"] = True
62+
state.show_instructions = True
5863

5964
# Generic instruction for most variables
6065
if var_name != "MONITORED_CONTENT_GUID":
@@ -69,7 +74,7 @@ def get_env_var(var_name, description=""):
6974

7075
if description:
7176
instruction += f" {description}"
72-
caller_globals["instructions"].append(instruction)
77+
state.instructions.append(instruction)
7378
return value
7479

7580
# Helper function to extract error messages from exceptions

extensions/content-health-monitor/manifest.json

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"metadata": {
55
"appmode": "quarto-static"
66
},
7-
"extension": {
7+
"extension": {
88
"name": "content-health-monitor",
99
"title": "Content Health Monitor",
1010
"description": "This report uses the publisher’s API key to monitor a single piece of content. It checks whether the content is reachable, but does not validate its functionality. When scheduled to run regularly, it will send an email alert if the content becomes unreachable.",
@@ -23,7 +23,7 @@
2323
}
2424
},
2525
"quarto": {
26-
"version": "1.4.557",
26+
"version": "1.6.43",
2727
"engines": [
2828
"jupyter"
2929
]
@@ -32,16 +32,19 @@
3232
"version": "3.11.7",
3333
"package_manager": {
3434
"name": "pip",
35-
"version": "23.2.1",
35+
"version": "25.1.1",
3636
"package_file": "requirements.txt"
3737
}
3838
},
3939
"files": {
4040
"requirements.txt": {
41-
"checksum": "09254fc2dfa7d869ffbc3da2abb1d224"
41+
"checksum": "5f89d52674b219c0b0ed85f1a5785641"
4242
},
4343
"content-health-monitor.qmd": {
44-
"checksum": "732af0f4ea846c37d3a7641c3bbd6ba3"
44+
"checksum": "7a31430ef0d92505829e214ac85f8633"
45+
},
46+
"content_health_utils.py": {
47+
"checksum": "73627372e41f90a2259ea5c0969d19a9"
4548
}
4649
}
4750
}

0 commit comments

Comments
 (0)