diff --git a/.gitignore b/.gitignore index 9500af4..a354bcc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,17 @@ __pycache__/ *.pyo *.pyd .Python +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.vscode/ +.pytest_cache/ +instance/ +*.db +*.sqlite +*.sqlite3 *.so *.egg *.egg-info/ @@ -12,4 +23,9 @@ build/ instance/*.db .env .DS_Store -*.log \ No newline at end of file +*.log +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.cache/ diff --git a/README.md b/README.md index 0146ecd..d8f45e7 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,35 @@ Set a coding goal, set a deadline, and watch the countdown tick. The timer only 4. **Open locally in your browser**: Navigate to `http://localhost:5000` +## Testing + +The project includes automated tests using pytest to ensure code quality and prevent regressions. + +### Running Tests + +1. **Install test dependencies** (included in requirements.txt): + ```bash + pip install -r requirements.txt + ``` + +2. **Run the test suite**: + ```bash + pytest + ``` + +3. **Run tests with coverage**: + ```bash + pytest --cov=application --cov-report=html + ``` + +### Test Structure + +- `tests/test_api.py`: API endpoint tests (health check, etc.) +- `tests/test_models.py`: Model unit tests (Goal.to_dict(), etc.) +- `tests/conftest.py`: Shared fixtures for Flask app and database setup + +Tests use an in-memory SQLite database for isolation and speed. + ## Usage 1. **Login with GitHub** - Authenticate via OAuth2 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3a0c46d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 8341ecf..505431a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,6 @@ requests python-dotenv gunicorn psycopg2-binary +pytest>=7.4.0 +pytest-flask +pytest-cov diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b5df69f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +import pytest +import os + +# Set test environment variables before importing application +os.environ['DATABASE_URL'] = 'sqlite:///:memory:' +os.environ['SECRET_KEY'] = 'test-secret-key' +os.environ['BASE_URL'] = 'http://localhost:5000' + +from application import application, db + +@pytest.fixture(scope='session') +def app(): + """Create and configure a test app instance.""" + # App is already configured with test settings + test_app = application + + # Create database tables + with test_app.app_context(): + db.create_all() + + yield test_app + +@pytest.fixture(scope='session') +def client(app): + """A test client for the app.""" + return app.test_client() + +@pytest.fixture(scope='function') +def session(app): + """Create a new database session for a test.""" + with app.app_context(): + # Start a transaction + db.session.begin_nested() + + yield db.session + + # Rollback the transaction after the test + db.session.rollback() \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..aa78463 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,29 @@ +import json +from datetime import datetime + +def test_health_endpoint(client): + """Test the /api/health endpoint returns healthy status.""" + response = client.get('/api/health') + + # Check status code + assert response.status_code == 200 + + # Parse JSON response + data = json.loads(response.data) + + # Check required fields are present + assert 'status' in data + assert 'timestamp' in data + assert 'service' in data + + # Check status is healthy + assert data['status'] == 'healthy' + + # Check service name + assert data['service'] == 'git-done-api' + + # Check timestamp is valid ISO format + try: + datetime.fromisoformat(data['timestamp']) + except ValueError: + pytest.fail("Timestamp is not in valid ISO format") \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..7e75cd5 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,108 @@ +import pytest +from datetime import datetime, timedelta, UTC +from application import Goal + +def test_goal_to_dict_required_fields(session): + """Test Goal.to_dict() with all required fields.""" + # Create a test goal + deadline = datetime.now(UTC) + timedelta(days=7) + goal = Goal( + user_github_id='12345', + description='Test goal', + deadline=deadline, + repo_url='https://github.com/user/repo', + completion_condition='fix bug', + status='active', + repo_owner='user', + repo_name='repo', + embed_token='test-token' + ) + session.add(goal) + session.commit() + + result = goal.to_dict() + + # Check all expected fields are present + expected_fields = [ + 'id', 'user_github_id', 'description', 'deadline', 'repo_url', + 'completion_condition', 'status', 'created_at', 'embed_token', 'embed_url' + ] + + for field in expected_fields: + assert field in result, f"Missing field: {field}" + + # Check data types + assert isinstance(result['id'], int) + assert isinstance(result['user_github_id'], str) + assert isinstance(result['description'], str) + assert isinstance(result['deadline'], str) + assert isinstance(result['repo_url'], str) + assert isinstance(result['completion_condition'], str) + assert isinstance(result['status'], str) + assert isinstance(result['created_at'], str) + assert isinstance(result['embed_token'], str) + assert isinstance(result['embed_url'], str) + + # Check datetime serialization + try: + datetime.fromisoformat(result['deadline']) + datetime.fromisoformat(result['created_at']) + except ValueError as e: + pytest.fail(f"Invalid datetime format: {e}") + + # Check embed_url format + assert result['embed_url'] == 'http://localhost:5000/embed/test-token' + +def test_goal_to_dict_with_completed_at(session): + """Test Goal.to_dict() with completed_at field.""" + deadline = datetime.now(UTC) + timedelta(days=7) + completed_at = datetime.now(UTC) + goal = Goal( + user_github_id='12345', + description='Completed goal', + deadline=deadline, + repo_url='https://github.com/user/repo', + completion_condition='fix bug', + status='completed', + completed_at=completed_at, + repo_owner='user', + repo_name='repo', + embed_token='test-token-completed' + ) + session.add(goal) + session.commit() + + result = goal.to_dict() + + # Check completed_at is serialized + assert 'completed_at' in result + assert result['completed_at'] is not None + assert isinstance(result['completed_at'], str) + + # Check it's valid ISO format + try: + datetime.fromisoformat(result['completed_at']) + except ValueError as e: + pytest.fail(f"Invalid completed_at datetime format: {e}") + +def test_goal_to_dict_without_embed_token(session): + """Test Goal.to_dict() without embed_token.""" + deadline = datetime.now(UTC) + timedelta(days=7) + goal = Goal( + user_github_id='12345', + description='Goal without embed token', + deadline=deadline, + repo_url='https://github.com/user/repo', + completion_condition='fix bug', + status='active', + repo_owner='user', + repo_name='repo' + ) + session.add(goal) + session.commit() + + result = goal.to_dict() + + # Check embed_token and embed_url are None + assert result['embed_token'] is None + assert result['embed_url'] is None \ No newline at end of file