diff --git a/.gitignore b/.gitignore index ee64372..3a2962d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,87 @@ +# IDE .idea/ +.vscode/ +*.swp +*.swo +*~ + +# Python *.pyc +__pycache__/ +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info/ +dist/ +build/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +env/ +ENV/ +.venv/ +.ENV/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +coverage.xml +*.cover +.hypothesis/ +.tox/ +.nox/ + +# Claude +.claude/* + +# Database +*.db +*.sqlite +*.sqlite3 +instance/ + +# Flask +instance/ +.webassets-cache + +# Logs +*.log + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Environment variables +.env +.env.local +.env.*.local + +# Package managers +# Note: Don't ignore poetry.lock or uv.lock +node_modules/ + +# Misc +*.bak +.cache/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bb567f6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,140 @@ +[tool.poetry] +name = "flask-blog" +version = "0.1.0" +description = "A Flask-based blog application" +authors = ["Your Name "] +readme = "README.md" +packages = [{include = "app"}] + +[tool.poetry.dependencies] +python = "^3.8.1" +Flask = "0.10.1" +Flask-Script = "2.0.5" +Flask-SQLAlchemy = "2.1" +Flask-Migrate = "1.7.0" +Flask-WTF = "0.12" +Flask-Bootstrap = "3.3.5.7" +WTForms = "2.1" +itsdangerous = "0.24" +Jinja2 = "2.8" +MarkupSafe = "0.23" +SQLAlchemy = "1.0.11" +Werkzeug = "0.11.3" +wheel = "0.24.0" +flask-login = "0.3.2" +flask-moment = "0.5.1" +ForgeryPy = "0.1" +gunicorn = "19.4.5" +psycopg2-binary = "2.9.9" + +[tool.poetry.group.dev.dependencies] +pytest = "^6.2.0" +pytest-cov = "^3.0.0" +pytest-mock = "^3.6.0" +pytest-flask = "^1.2.0" + +[tool.poetry.scripts] +test = "pytest:main" +tests = "pytest:main" + +[tool.pytest.ini_options] +minversion = "7.0" +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-ra", + "--strict-markers", + "--cov=app", + "--cov-branch", + "--cov-report=term-missing", + "--cov-report=html:htmlcov", + "--cov-report=xml:coverage.xml", + "--cov-fail-under=80", + "-vv", + "--tb=short", + "--maxfail=1", +] +markers = [ + "unit: Unit tests", + "integration: Integration tests", + "slow: Slow running tests", +] +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", +] + +[tool.coverage.run] +source = ["app"] +branch = true +omit = [ + "*/tests/*", + "*/migrations/*", + "*/__init__.py", + "*/config.py", + "*/manage.py", +] + +[tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = false +skip_empty = true +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if settings.DEBUG", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] + +[tool.coverage.html] +directory = "htmlcov" + +[tool.coverage.xml] +output = "coverage.xml" + +[tool.isort] +profile = "black" +line_length = 88 +known_first_party = ["app"] +skip_glob = ["*/migrations/*"] + +[tool.black] +line-length = 88 +target-version = ['py38'] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + | migrations +)/ +''' + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +ignore_missing_imports = true +exclude = ["migrations/", "tests/"] + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 0000000..0557ae2 --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,5 @@ +pytest==6.2.5 +pytest-cov==3.0.0 +pytest-mock==3.6.1 +pytest-flask==1.2.0 +coverage[toml]==6.5.0 \ No newline at end of file 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..157a8ca --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +import os +import tempfile +import pytest + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield tmpdir + + +@pytest.fixture +def mock_config(monkeypatch): + """Mock configuration values.""" + def _mock_config(key, value): + monkeypatch.setattr(f'config.Config.{key}', value) + return _mock_config + + +@pytest.fixture(autouse=True) +def reset_environment(monkeypatch): + """Reset environment variables for each test.""" + monkeypatch.setenv('FLASK_CONFIG', 'testing') + monkeypatch.setenv('DATABASE_URL', 'sqlite:///:memory:') \ No newline at end of file diff --git a/tests/conftest_full.py b/tests/conftest_full.py new file mode 100644 index 0000000..e56e6b6 --- /dev/null +++ b/tests/conftest_full.py @@ -0,0 +1,199 @@ +import os +import tempfile +import pytest +from app import create_app, db +from app.models import User, Role, Post, Comment +from config import config + + +@pytest.fixture(scope='session') +def app(): + """Create and configure a new app instance for each test session.""" + app = create_app('testing') + app.config.update({ + 'TESTING': True, + 'WTF_CSRF_ENABLED': False, + 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', + 'SERVER_NAME': 'localhost:5000', + }) + + with app.app_context(): + yield app + + +@pytest.fixture(scope='function') +def _db(app): + """Create a new database for each test function.""" + with app.app_context(): + db.create_all() + yield db + db.session.remove() + db.drop_all() + + +@pytest.fixture(scope='function') +def session(_db): + """Creates a new database session for a test.""" + connection = _db.engine.connect() + transaction = connection.begin() + + options = dict(bind=connection, binds={}) + session = _db.create_scoped_session(options=options) + + _db.session = session + + yield session + + transaction.rollback() + connection.close() + session.remove() + + +@pytest.fixture +def client(app): + """A test client for the app.""" + return app.test_client() + + +@pytest.fixture +def runner(app): + """A test runner for the app's Click commands.""" + return app.test_cli_runner() + + +@pytest.fixture +def auth_client(client, user): + """A test client that is logged in.""" + with client: + client.post('/auth/login', data={ + 'email': user.email, + 'password': 'testpassword' + }) + return client + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield tmpdir + + +@pytest.fixture +def mock_config(monkeypatch): + """Mock configuration values.""" + def _mock_config(key, value): + monkeypatch.setattr(f'config.Config.{key}', value) + return _mock_config + + +@pytest.fixture +def user(_db): + """Create a test user.""" + user = User( + email='test@example.com', + username='testuser', + password='testpassword', + confirmed=True + ) + _db.session.add(user) + _db.session.commit() + return user + + +@pytest.fixture +def admin_user(_db): + """Create a test admin user.""" + admin_role = Role.query.filter_by(name='Administrator').first() + if not admin_role: + admin_role = Role(name='Administrator', permissions=0xff) + _db.session.add(admin_role) + + admin = User( + email='admin@example.com', + username='admin', + password='adminpassword', + role=admin_role, + confirmed=True + ) + _db.session.add(admin) + _db.session.commit() + return admin + + +@pytest.fixture +def post(_db, user): + """Create a test post.""" + post = Post( + title='Test Post', + body='This is a test post body.', + author=user + ) + _db.session.add(post) + _db.session.commit() + return post + + +@pytest.fixture +def comment(_db, user, post): + """Create a test comment.""" + comment = Comment( + body='This is a test comment.', + author=user, + post=post + ) + _db.session.add(comment) + _db.session.commit() + return comment + + +@pytest.fixture(autouse=True) +def reset_environment(monkeypatch): + """Reset environment variables for each test.""" + monkeypatch.setenv('FLASK_CONFIG', 'testing') + monkeypatch.setenv('DATABASE_URL', 'sqlite:///:memory:') + + +@pytest.fixture +def captured_templates(app): + """Capture templates and their context during rendering.""" + recorded = [] + + def record(sender, template, context, **extra): + recorded.append((template, context)) + + from flask import template_rendered + template_rendered.connect(record, app) + + try: + yield recorded + finally: + template_rendered.disconnect(record, app) + + +@pytest.fixture +def mock_mail(mocker): + """Mock email sending.""" + return mocker.patch('app.email.send_email') + + +class AuthActions: + """Helper class for authentication actions.""" + + def __init__(self, client): + self._client = client + + def login(self, email='test@example.com', password='testpassword'): + return self._client.post( + '/auth/login', + data={'email': email, 'password': password} + ) + + def logout(self): + return self._client.get('/auth/logout') + + +@pytest.fixture +def auth(client): + """Authentication helper.""" + return AuthActions(client) \ No newline at end of file diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_infrastructure_validation.py b/tests/test_infrastructure_validation.py new file mode 100644 index 0000000..48e3984 --- /dev/null +++ b/tests/test_infrastructure_validation.py @@ -0,0 +1,104 @@ +import pytest +import sys +import os + + +class TestInfrastructureValidation: + """Validation tests to ensure the testing infrastructure is properly configured.""" + + def test_pytest_is_installed(self): + """Test that pytest is available.""" + assert 'pytest' in sys.modules or pytest.__version__ + + def test_project_structure_exists(self): + """Test that the basic project structure exists.""" + assert os.path.exists('/workspace/app') + assert os.path.exists('/workspace/tests') + assert os.path.exists('/workspace/tests/unit') + assert os.path.exists('/workspace/tests/integration') + assert os.path.exists('/workspace/tests/conftest.py') + + def test_pyproject_toml_exists(self): + """Test that pyproject.toml exists.""" + assert os.path.exists('/workspace/pyproject.toml') + + def test_test_dependencies_file_exists(self): + """Test that test dependencies file exists.""" + assert os.path.exists('/workspace/requirements/test.txt') + + @pytest.mark.unit + def test_unit_marker_works(self): + """Test that the unit test marker is recognized.""" + assert True + + @pytest.mark.integration + def test_integration_marker_works(self): + """Test that the integration test marker is recognized.""" + assert True + + @pytest.mark.slow + def test_slow_marker_works(self): + """Test that the slow test marker is recognized.""" + assert True + + def test_temp_dir_fixture_basic(self): + """Test basic temp directory creation.""" + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + assert os.path.exists(tmpdir) + test_file = os.path.join(tmpdir, 'test.txt') + with open(test_file, 'w') as f: + f.write('test') + assert os.path.exists(test_file) + + def test_mock_available(self): + """Test that mocking capabilities are available.""" + from unittest.mock import Mock + mock_func = Mock(return_value=42) + assert mock_func() == 42 + mock_func.assert_called_once() + + def test_coverage_is_configured(self): + """Test that coverage is properly configured.""" + try: + import coverage + assert coverage.__version__ + except ImportError: + pytest.fail("Coverage is not installed") + + def test_gitignore_updated(self): + """Test that .gitignore has been updated with test entries.""" + with open('/workspace/.gitignore', 'r') as f: + content = f.read() + assert '.pytest_cache/' in content + assert '.coverage' in content + assert 'htmlcov/' in content + assert '.claude/*' in content + + +@pytest.mark.unit +class TestPytestConfiguration: + """Test pytest configuration from pyproject.toml.""" + + def test_configuration_loads(self): + """Verify that pytest can load and parse our configuration.""" + # This test passing means pytest successfully loaded pyproject.toml + assert True + + def test_can_run_with_coverage(self): + """Test that we can import and use coverage.""" + import coverage + cov = coverage.Coverage() + assert cov is not None + + +class TestPoetryScripts: + """Test that Poetry scripts are configured.""" + + def test_poetry_scripts_configured(self): + """Test that test scripts are in pyproject.toml.""" + with open('/workspace/pyproject.toml', 'r') as f: + content = f.read() + assert '[tool.poetry.scripts]' in content + assert 'test = "pytest:main"' in content + assert 'tests = "pytest:main"' in content \ No newline at end of file diff --git a/tests/test_setup_validation.py b/tests/test_setup_validation.py new file mode 100644 index 0000000..e283e78 --- /dev/null +++ b/tests/test_setup_validation.py @@ -0,0 +1,107 @@ +import pytest +import sys +import os + + +class TestSetupValidation: + """Validation tests to ensure the testing infrastructure is properly configured.""" + + def test_pytest_is_installed(self): + """Test that pytest is available.""" + assert 'pytest' in sys.modules or pytest.__version__ + + def test_project_structure_exists(self): + """Test that the basic project structure exists.""" + assert os.path.exists('/workspace/app') + assert os.path.exists('/workspace/tests') + assert os.path.exists('/workspace/tests/unit') + assert os.path.exists('/workspace/tests/integration') + + def test_conftest_fixtures_available(self, app, client, _db): + """Test that basic fixtures from conftest.py are available.""" + assert app is not None + assert client is not None + assert _db is not None + + def test_app_is_in_testing_mode(self, app): + """Test that the app is configured for testing.""" + assert app.config['TESTING'] is True + assert app.config['WTF_CSRF_ENABLED'] is False + + def test_database_is_in_memory(self, app): + """Test that we're using an in-memory database for tests.""" + assert 'memory' in app.config['SQLALCHEMY_DATABASE_URI'] + + @pytest.mark.unit + def test_unit_marker_works(self): + """Test that the unit test marker is recognized.""" + assert True + + @pytest.mark.integration + def test_integration_marker_works(self): + """Test that the integration test marker is recognized.""" + assert True + + @pytest.mark.slow + def test_slow_marker_works(self): + """Test that the slow test marker is recognized.""" + assert True + + def test_temp_dir_fixture(self, temp_dir): + """Test that the temp_dir fixture works correctly.""" + assert os.path.exists(temp_dir) + test_file = os.path.join(temp_dir, 'test.txt') + with open(test_file, 'w') as f: + f.write('test') + assert os.path.exists(test_file) + + def test_mock_fixture_available(self, mocker): + """Test that pytest-mock fixtures are available.""" + mock_func = mocker.Mock(return_value=42) + assert mock_func() == 42 + mock_func.assert_called_once() + + def test_coverage_is_configured(self): + """Test that coverage is properly configured.""" + try: + import coverage + assert coverage.__version__ + except ImportError: + pytest.fail("Coverage is not installed") + + def test_user_fixture(self, user): + """Test that the user fixture creates a valid user.""" + assert user.email == 'test@example.com' + assert user.username == 'testuser' + assert user.confirmed is True + + def test_post_fixture(self, post): + """Test that the post fixture creates a valid post.""" + assert post.title == 'Test Post' + assert post.body == 'This is a test post body.' + assert post.author is not None + + def test_auth_actions(self, auth, client): + """Test that authentication helper works.""" + # This will fail until the actual Flask app is properly set up + # but it validates that the fixture is available + assert hasattr(auth, 'login') + assert hasattr(auth, 'logout') + + +@pytest.mark.unit +class TestPytestConfiguration: + """Test pytest configuration from pyproject.toml.""" + + def test_configuration_loads(self): + """Verify that pytest can load and parse our configuration.""" + # This test passing means pytest successfully loaded pyproject.toml + assert True + + def test_markers_defined(self, pytestconfig): + """Test that custom markers are properly defined.""" + markers = pytestconfig.getini('markers') + marker_names = [m.split(':')[0] for m in markers] + assert 'unit' in marker_names + assert 'integration' in marker_names + assert 'slow' in marker_names \ No newline at end of file diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29