opencompletion.com/tests/integration/test_app_activity_functions.py
Russell Ballestrini aa1651ae81 Fix integration tests: ensure Flask instance directory exists
The integration tests were failing in GitHub Actions with 'unable to open
database file' errors because the Flask instance directory didn't exist.

The app.py code at line 40 creates a database URI using app.instance_path,
which requires that directory to exist. In GitHub Actions, this directory
doesn't exist by default, causing SQLite to fail when trying to create
the database file (even though tests override to use :memory:).

Solution: Create instance directory in setUp() before app context is pushed.

Changes:
- Add os.makedirs(app.app.instance_path, exist_ok=True) in setUp()
- Also fixed temp file paths to use absolute paths for research directory
- All 9 integration tests now pass locally

This ensures tests work in both local and GitHub Actions environments.
2025-11-10 17:30:53 -05:00

568 lines
20 KiB
Python

#!/usr/bin/env python3
"""
Integration tests for app.py activity functions with real Flask environment and database
These tests use a real Flask test environment with in-memory SQLite database
to actually execute the activity functions and improve app.py coverage.
"""
import unittest
import os
import sys
import tempfile
import json
from pathlib import Path
# Add parent directory to path to import the app
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
# Import Flask and testing utilities
import pytest
from flask import Flask
from flask_socketio import SocketIO
# Import the main application
import app
import activity
from models import db, Room, ActivityState, Message
class TestFlaskAppActivityFunctions(unittest.TestCase):
"""Integration tests for app.py activity functions with real Flask environment"""
def setUp(self):
"""Set up test Flask application with in-memory database"""
# Ensure instance directory exists (GitHub Actions might not have it)
import os
os.makedirs(app.app.instance_path, exist_ok=True)
# Store original database URI
self.original_db_uri = app.app.config.get("SQLALCHEMY_DATABASE_URI")
# Configure test app BEFORE pushing context
app.app.config["TESTING"] = True
app.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app.app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.app.config["LOCAL_ACTIVITIES"] = True # Use local YAML files
app.app.config["WTF_CSRF_ENABLED"] = False
# Create test client and push context
self.client = app.app.test_client()
self.app_context = app.app.app_context()
self.app_context.push()
# Force db to use the new in-memory database by clearing the engine
# This allows the in-memory database to be created
# Note: Access db.engine AFTER pushing app context
if hasattr(db, "engine"):
db.engine.dispose()
db.session.remove()
# Re-initialize db with test config to use in-memory database
try:
db.drop_all()
except Exception:
pass
# Initialize database
db.create_all()
# Create test room
self.test_room = Room(name="test_room")
db.session.add(self.test_room)
db.session.commit()
# Store original socketio for cleanup
self.original_socketio = app.socketio
# Initialize activity module with app's socketio and db
# Note: activity module is already initialized when imported,
# so we don't need to re-initialize it for tests
def tearDown(self):
"""Clean up test environment"""
db.session.remove()
try:
db.drop_all()
except Exception:
pass
self.app_context.pop()
# Restore original socketio and database URI
app.socketio = self.original_socketio
if self.original_db_uri:
app.app.config["SQLALCHEMY_DATABASE_URI"] = self.original_db_uri
def create_test_activity_file(self, content):
"""Create a temporary activity YAML file"""
# Get absolute path to research directory
base_dir = Path(__file__).parent.parent.parent
research_dir = base_dir / "research"
research_dir.mkdir(exist_ok=True)
# Create temporary file in research directory
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", dir=research_dir, delete=False
) as f:
f.write(content)
# Return just the filename relative to research directory
return Path(f.name).name
def test_get_activity_content_local(self):
"""Test loading activity content from local files"""
test_yaml_content = """
title: "Test Activity"
description: "A simple test activity"
default_max_attempts_per_step: 3
sections:
- section_id: "section_1"
title: "Test Section"
steps:
- step_id: "step_1"
title: "Test Step"
content_blocks:
- "Welcome to the test activity"
question: "What is 2+2?"
buckets:
- "correct"
- "incorrect"
tokens_for_ai: "Categorize the mathematical response"
transitions:
correct:
content_blocks:
- "Correct! Well done."
incorrect:
content_blocks:
- "That's not right. Try again."
counts_as_attempt: true
"""
activity_file = self.create_test_activity_file(test_yaml_content)
try:
# Test the actual get_activity_content function
result = activity.get_activity_content(f"research/{activity_file}")
# Verify structure
self.assertEqual(result["title"], "Test Activity")
self.assertEqual(result["default_max_attempts_per_step"], 3)
self.assertEqual(len(result["sections"]), 1)
self.assertEqual(result["sections"][0]["section_id"], "section_1")
finally:
# Clean up
base_dir = Path(__file__).parent.parent.parent
os.unlink(base_dir / "research" / activity_file)
def test_start_activity_integration(self):
"""Test starting an activity with real database operations"""
test_yaml_content = """
title: "Integration Test Activity"
default_max_attempts_per_step: 2
sections:
- section_id: "intro"
title: "Introduction"
steps:
- step_id: "welcome"
title: "Welcome Step"
content_blocks:
- "Welcome to this integration test!"
- step_id: "question_step"
title: "Question"
content_blocks:
- "Now for a question..."
question: "What is your name?"
buckets:
- "any_response"
tokens_for_ai: "Accept any response"
transitions:
any_response:
content_blocks:
- "Thank you for your response!"
"""
activity_file = self.create_test_activity_file(test_yaml_content)
try:
# Mock socketio emissions to avoid actual socket connections
app.socketio = type(
"MockSocketIO",
(),
{
"emit": lambda *args, **kwargs: None,
"sleep": lambda *args, **kwargs: None,
},
)()
# Test start_activity function
activity.start_activity(
"test_room", f"research/{activity_file}", "testuser"
)
# Verify activity state was created in database
activity_state = ActivityState.query.filter_by(
room_id=self.test_room.id
).first()
self.assertIsNotNone(activity_state)
self.assertEqual(activity_state.section_id, "intro")
# The function advances through steps until it finds a question
# So it should stop at "question_step" not "welcome"
self.assertEqual(activity_state.step_id, "question_step")
self.assertEqual(activity_state.max_attempts, 2)
self.assertEqual(activity_state.s3_file_path, f"research/{activity_file}")
finally:
# Clean up
base_dir = Path(__file__).parent.parent.parent
os.unlink(base_dir / "research" / activity_file)
def test_handle_activity_response_integration(self):
"""Test handling activity responses with real categorization and database updates"""
test_yaml_content = """
title: "Response Test Activity"
default_max_attempts_per_step: 3
sections:
- section_id: "test_section"
title: "Test Section"
steps:
- step_id: "math_question"
title: "Math Question"
question: "What is 5+5?"
buckets:
- "correct"
- "incorrect"
tokens_for_ai: "Categorize: if answer is 10 or ten, say 'correct', otherwise 'incorrect'"
transitions:
correct:
content_blocks:
- "Excellent! That's correct."
metadata_add:
score: "n+10"
correct_answers: "n+1"
incorrect:
content_blocks:
- "Not quite right. Try again."
counts_as_attempt: true
"""
activity_file = self.create_test_activity_file(test_yaml_content)
try:
# Mock socketio emissions
app.socketio = type(
"MockSocketIO",
(),
{
"emit": lambda *args, **kwargs: None,
"sleep": lambda *args, **kwargs: None,
},
)()
# Create activity state manually
activity_state = ActivityState(
room_id=self.test_room.id,
section_id="test_section",
step_id="math_question",
max_attempts=3,
s3_file_path=f"research/{activity_file}",
attempts=0,
)
activity_state.dict_metadata = {"score": 0, "correct_answers": 0}
activity_state.json_metadata = json.dumps(activity_state.dict_metadata)
db.session.add(activity_state)
db.session.commit()
# Test handling a correct response
activity.handle_activity_response("test_room", "10", "testuser")
# Refresh activity state from database
db.session.refresh(activity_state)
# Verify metadata was updated (if categorization worked)
updated_metadata = json.loads(activity_state.json_metadata)
# The exact assertion depends on whether the AI categorization succeeded
# At minimum, we verify the function executed without error
self.assertIsInstance(updated_metadata, dict)
finally:
# Clean up
base_dir = Path(__file__).parent.parent.parent
os.unlink(base_dir / "research" / activity_file)
def test_display_activity_metadata_integration(self):
"""Test displaying activity metadata with real database state"""
# Mock socketio emissions and capture them
emitted_messages = []
def mock_emit(*args, **kwargs):
# Handle different emit signatures flexibly
# Skip self argument if it's a MockSocketIO object
filtered_args = [
arg
for arg in args
if not hasattr(arg, "__class__")
or "MockSocketIO" not in str(arg.__class__)
]
event = filtered_args[0] if filtered_args else kwargs.get("event")
data = filtered_args[1] if len(filtered_args) > 1 else kwargs.get("data")
room = kwargs.get("room")
emitted_messages.append({"event": event, "data": data, "room": room})
# Mock activity.socketio directly (not app.socketio)
activity.socketio = type(
"MockSocketIO",
(),
{"emit": mock_emit, "sleep": lambda *args, **kwargs: None},
)()
# Create activity state with metadata
activity_state = ActivityState(
room_id=self.test_room.id,
section_id="test_section",
step_id="test_step",
max_attempts=3,
s3_file_path="test_activity.yaml",
)
activity_state.dict_metadata = {
"player_name": "TestPlayer",
"score": 150,
"level": 5,
"achievements": ["first_win", "perfect_score"],
}
activity_state.json_metadata = json.dumps(activity_state.dict_metadata)
db.session.add(activity_state)
db.session.commit()
# Test display_activity_metadata function
activity.display_activity_metadata("test_room", "testuser")
# Verify that a message was emitted
self.assertTrue(len(emitted_messages) > 0)
# Check if metadata message was emitted
metadata_message = None
for msg in emitted_messages:
if msg["event"] == "chat_message" and msg["data"].get("content"):
metadata_message = msg
break
self.assertIsNotNone(metadata_message, "Should have emitted metadata message")
self.assertEqual(metadata_message["room"], "test_room")
# Verify the content contains the metadata
content = metadata_message["data"]["content"]
self.assertIn("TestPlayer", content)
self.assertIn("150", content) # score
def test_cancel_activity_integration(self):
"""Test canceling an activity with real database operations"""
# Mock socketio emissions
emitted_messages = []
def mock_emit(*args, **kwargs):
# Handle different emit signatures flexibly
# Skip self argument if it's a MockSocketIO object
filtered_args = [
arg
for arg in args
if not hasattr(arg, "__class__")
or "MockSocketIO" not in str(arg.__class__)
]
event = filtered_args[0] if filtered_args else kwargs.get("event")
data = filtered_args[1] if len(filtered_args) > 1 else kwargs.get("data")
room = kwargs.get("room")
emitted_messages.append({"event": event, "data": data, "room": room})
# Mock activity.socketio directly (not app.socketio)
activity.socketio = type(
"MockSocketIO",
(),
{"emit": mock_emit, "sleep": lambda *args, **kwargs: None},
)()
# Create activity state
activity_state = ActivityState(
room_id=self.test_room.id,
section_id="test_section",
step_id="test_step",
max_attempts=3,
s3_file_path="test_activity.yaml",
)
db.session.add(activity_state)
db.session.commit()
# Verify activity exists
self.assertIsNotNone(
ActivityState.query.filter_by(room_id=self.test_room.id).first()
)
# Test cancel_activity function
activity.cancel_activity("test_room", "testuser")
# Verify activity was deleted from database
self.assertIsNone(
ActivityState.query.filter_by(room_id=self.test_room.id).first()
)
# Verify cancellation messages were emitted
self.assertTrue(
len(emitted_messages) > 0, "Should have emitted cancellation messages"
)
# Check for chat_message with cancellation content
chat_messages = [
msg
for msg in emitted_messages
if msg["event"] == "chat_message" and msg["data"]
]
self.assertTrue(len(chat_messages) > 0, "Should have a chat message")
cancel_message = chat_messages[0]
self.assertEqual(cancel_message["room"], "test_room")
self.assertIn("canceled", cancel_message["data"]["content"].lower())
# Check for activity_status update
status_messages = [
msg for msg in emitted_messages if msg["event"] == "activity_status"
]
self.assertTrue(len(status_messages) > 0, "Should have activity_status")
self.assertFalse(status_messages[0]["data"]["active"])
def test_execute_processing_script_integration(self):
"""Test processing script execution with real metadata manipulation"""
script = """
import random
import math
# Test various operations
user_input = metadata.get('user_response', 'default')
metadata['processed_input'] = user_input.upper()
metadata['input_length'] = len(user_input)
metadata['random_bonus'] = random.randint(10, 50)
metadata['calculated_score'] = math.sqrt(metadata.get('base_score', 100))
# Test complex operations
if 'achievements' not in metadata:
metadata['achievements'] = []
metadata['achievements'].append('processed_response')
script_result = {
'status': 'success',
'processing_complete': True,
'metadata': {
'bonus_applied': True,
'processing_timestamp': 'mock_timestamp'
}
}
"""
metadata = {
"user_response": "test input",
"base_score": 144,
"existing_data": "preserved",
}
# Test the actual execute_processing_script function
result = activity.execute_processing_script(metadata, script)
# Verify script execution results
self.assertEqual(result["status"], "success")
self.assertTrue(result["processing_complete"])
self.assertTrue(result["metadata"]["bonus_applied"])
# Verify metadata modifications
self.assertEqual(metadata["processed_input"], "TEST INPUT")
self.assertEqual(metadata["input_length"], 10)
self.assertIn("random_bonus", metadata)
self.assertEqual(metadata["calculated_score"], 12.0) # sqrt(144)
self.assertIn("processed_response", metadata["achievements"])
self.assertEqual(metadata["existing_data"], "preserved") # Should be unchanged
def test_get_next_step_integration(self):
"""Test step navigation with real activity content"""
activity_content = {
"sections": [
{
"section_id": "section_1",
"steps": [
{"step_id": "step_1", "title": "Step 1"},
{"step_id": "step_2", "title": "Step 2"},
{"step_id": "step_3", "title": "Step 3"},
],
},
{
"section_id": "section_2",
"steps": [
{"step_id": "step_1", "title": "Section 2 Step 1"},
{"step_id": "step_2", "title": "Section 2 Step 2"},
],
},
]
}
# Test navigation within section
next_section, next_step = activity.get_next_step(
activity_content, "section_1", "step_1"
)
self.assertEqual(next_section["section_id"], "section_1")
self.assertEqual(next_step["step_id"], "step_2")
# Test navigation across sections
next_section, next_step = activity.get_next_step(
activity_content, "section_1", "step_3"
)
self.assertEqual(next_section["section_id"], "section_2")
self.assertEqual(next_step["step_id"], "step_1")
# Test at end of activity
next_section, next_step = activity.get_next_step(
activity_content, "section_2", "step_2"
)
self.assertIsNone(next_section)
self.assertIsNone(next_step)
def test_categorize_response_integration(self):
"""Test response categorization with real AI endpoint (if available)"""
# Test with simple categorization
question = "What is 2 + 2?"
response = "4"
buckets = ["correct", "incorrect"]
tokens_for_ai = (
"If the answer is 4 or four, categorize as 'correct', otherwise 'incorrect'"
)
# Test the actual categorization function
result = activity.categorize_response(
question, response, buckets, tokens_for_ai
)
# Result should be either "correct", "incorrect", or an error message
self.assertIsInstance(result, str)
self.assertTrue(
result in ["correct", "incorrect"] or result.startswith("Error:")
)
def test_translate_text_integration(self):
"""Test text translation functionality"""
# Test English bypass
english_text = "Hello, world!"
result = activity.translate_text(english_text, "English")
self.assertEqual(result, english_text)
# Test case insensitive
result = activity.translate_text(english_text, "english")
self.assertEqual(result, english_text)
# Test with compound language
result = activity.translate_text(english_text, "English please")
self.assertEqual(result, english_text)
# Test other language (will use AI endpoint if available)
result = activity.translate_text("Hello", "Spanish")
self.assertIsInstance(result, str) # Should return some string result
if __name__ == "__main__":
unittest.main(verbosity=2)