diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..7b330fc --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# Tests package initialization diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 0000000..6d6f7b3 --- /dev/null +++ b/backend/tests/test_config.py @@ -0,0 +1,227 @@ +""" +Comprehensive unit tests for app.core.config module. +Tests boundary values, equivalence classes, and actual behavior of Settings class. +""" +import pytest +from pydantic import ValidationError +from app.core.config import Settings + + +class TestSettingsDefaults: + """Test default values of Settings class.""" + + def test_project_name_default(self, monkeypatch): + """Test PROJECT_NAME has correct default value.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.PROJECT_NAME == "Insurance Management Platform" + + def test_api_v1_str_default(self, monkeypatch): + """Test API_V1_STR has correct default value.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.API_V1_STR == "/api/v1" + + def test_access_token_expire_minutes_default(self, monkeypatch): + """Test ACCESS_TOKEN_EXPIRE_MINUTES has correct default value (7 days).""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.ACCESS_TOKEN_EXPIRE_MINUTES == 60 * 24 * 7 + assert settings.ACCESS_TOKEN_EXPIRE_MINUTES == 10080 + + def test_cors_origins_default(self, monkeypatch): + """Test CORS_ORIGINS has correct default value.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.CORS_ORIGINS == "http://localhost:3000" + + +class TestSettingsRequiredFields: + """Test required fields validation.""" + + def test_secret_key_required(self, monkeypatch): + """Test SECRET_KEY is required and raises error when missing.""" + monkeypatch.delenv("SECRET_KEY", raising=False) + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + with pytest.raises(ValidationError) as exc_info: + Settings() + errors = exc_info.value.errors() + assert any(error['loc'] == ('SECRET_KEY',) for error in errors) + + def test_database_url_required(self, monkeypatch): + """Test DATABASE_URL is required and raises error when missing.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.delenv("DATABASE_URL", raising=False) + with pytest.raises(ValidationError) as exc_info: + Settings() + errors = exc_info.value.errors() + assert any(error['loc'] == ('DATABASE_URL',) for error in errors) + + +class TestSettingsEnvironmentVariables: + """Test environment variable loading and overriding.""" + + def test_secret_key_from_env(self, monkeypatch): + """Test SECRET_KEY loads from environment variable.""" + test_secret = "my-super-secret-key-12345" + monkeypatch.setenv("SECRET_KEY", test_secret) + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.SECRET_KEY == test_secret + + def test_database_url_from_env(self, monkeypatch): + """Test DATABASE_URL loads from environment variable.""" + test_db_url = "postgresql+asyncpg://user:pass@localhost:5432/insurance_db" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", test_db_url) + settings = Settings() + assert settings.DATABASE_URL == test_db_url + + def test_project_name_override(self, monkeypatch): + """Test PROJECT_NAME can be overridden by environment variable.""" + custom_name = "Custom Insurance Platform" + monkeypatch.setenv("PROJECT_NAME", custom_name) + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.PROJECT_NAME == custom_name + + def test_api_v1_str_override(self, monkeypatch): + """Test API_V1_STR can be overridden by environment variable.""" + custom_api_path = "/api/v2" + monkeypatch.setenv("API_V1_STR", custom_api_path) + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.API_V1_STR == custom_api_path + + def test_cors_origins_override(self, monkeypatch): + """Test CORS_ORIGINS can be overridden by environment variable.""" + custom_origins = "http://example.com,https://app.example.com" + monkeypatch.setenv("CORS_ORIGINS", custom_origins) + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.CORS_ORIGINS == custom_origins + + def test_access_token_expire_minutes_override(self, monkeypatch): + """Test ACCESS_TOKEN_EXPIRE_MINUTES can be overridden by environment variable.""" + custom_expire = 30 + monkeypatch.setenv("ACCESS_TOKEN_EXPIRE_MINUTES", str(custom_expire)) + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.ACCESS_TOKEN_EXPIRE_MINUTES == custom_expire + + +class TestSettingsBoundaryValues: + """Test boundary values for Settings fields.""" + + @pytest.mark.parametrize("expire_minutes", [ + 0, # minimum/zero + 1, # minimum positive + 60, # 1 hour + 1440, # 1 day + 10080, # 7 days (default) + 43200, # 30 days + 525600, # 1 year + ]) + def test_access_token_expire_minutes_boundary_values(self, expire_minutes, monkeypatch): + """Test ACCESS_TOKEN_EXPIRE_MINUTES with various boundary values.""" + monkeypatch.setenv("ACCESS_TOKEN_EXPIRE_MINUTES", str(expire_minutes)) + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.ACCESS_TOKEN_EXPIRE_MINUTES == expire_minutes + + def test_empty_secret_key(self, monkeypatch): + """Test empty SECRET_KEY is accepted (though not recommended).""" + monkeypatch.setenv("SECRET_KEY", "") + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.SECRET_KEY == "" + + def test_very_long_secret_key(self, monkeypatch): + """Test very long SECRET_KEY is accepted.""" + long_secret = "a" * 1000 + monkeypatch.setenv("SECRET_KEY", long_secret) + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.SECRET_KEY == long_secret + assert len(settings.SECRET_KEY) == 1000 + + def test_secret_key_with_special_characters(self, monkeypatch): + """Test SECRET_KEY with special characters.""" + special_secret = "!@#$%^&*()_+-=[]{}|;:',.<>?/~`" + monkeypatch.setenv("SECRET_KEY", special_secret) + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.SECRET_KEY == special_secret + + +class TestSettingsEquivalenceClasses: + """Test equivalence classes for different input types.""" + + @pytest.mark.parametrize("db_url,expected", [ + ("postgresql://user:pass@localhost/db", "postgresql://user:pass@localhost/db"), + ("postgresql+asyncpg://user:pass@localhost/db", "postgresql+asyncpg://user:pass@localhost/db"), + ("sqlite:///./test.db", "sqlite:///./test.db"), + ("postgresql://user:pass@host:5432/db", "postgresql://user:pass@host:5432/db"), + ]) + def test_database_url_equivalence_classes(self, db_url, expected, monkeypatch): + """Test different valid DATABASE_URL formats.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", db_url) + settings = Settings() + assert settings.DATABASE_URL == expected + + @pytest.mark.parametrize("api_path", [ + "/api/v1", + "/api/v2", + "/v1", + "/api", + "", + "/custom/api/path", + ]) + def test_api_v1_str_equivalence_classes(self, api_path, monkeypatch): + """Test different API path formats.""" + monkeypatch.setenv("API_V1_STR", api_path) + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + settings = Settings() + assert settings.API_V1_STR == api_path + + +class TestSettingsInstance: + """Test the global settings instance.""" + + def test_settings_instance_exists(self): + """Test that global settings instance is created.""" + from app.core.config import settings + assert settings is not None + assert isinstance(settings, Settings) + + def test_settings_instance_is_singleton_like(self): + """Test that settings instance behaves consistently.""" + from app.core.config import settings as settings1 + from app.core.config import settings as settings2 + assert settings1 is settings2 + + +class TestSettingsModelConfig: + """Test model configuration behavior.""" + + def test_extra_fields_ignored(self, monkeypatch): + """Test that extra fields in environment are ignored due to extra='ignore'.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + monkeypatch.setenv("UNKNOWN_FIELD", "should-be-ignored") + monkeypatch.setenv("ANOTHER_UNKNOWN", "also-ignored") + # Should not raise error due to extra="ignore" + settings = Settings() + assert not hasattr(settings, "UNKNOWN_FIELD") + assert not hasattr(settings, "ANOTHER_UNKNOWN") diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py new file mode 100644 index 0000000..a1d645c --- /dev/null +++ b/backend/tests/test_main.py @@ -0,0 +1,364 @@ +""" +Comprehensive unit tests for app.main module. +Tests FastAPI application configuration, CORS middleware, and endpoints. +""" +import pytest +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient +from app.main import app + + +class TestAppConfiguration: + """Test FastAPI application configuration.""" + + def test_app_is_fastapi_instance(self): + """Test that app is a FastAPI instance.""" + assert isinstance(app, FastAPI) + + def test_app_title(self): + """Test that app has correct title.""" + assert app.title == "Insurance Management API" + + def test_app_description(self): + """Test that app has correct description.""" + assert app.description == "API for the Insurance Management System" + + def test_app_version(self): + """Test that app has correct version.""" + assert app.version == "1.0.0" + + def test_app_has_middleware(self): + """Test that app has middleware configured.""" + assert len(app.user_middleware) > 0 + + def test_app_has_cors_middleware(self): + """Test that CORS middleware is configured.""" + has_cors = any( + middleware.cls == CORSMiddleware + for middleware in app.user_middleware + ) + assert has_cors is True + + +class TestCORSMiddleware: + """Test CORS middleware configuration.""" + + def test_cors_middleware_exists(self): + """Test that CORS middleware is added to the app.""" + cors_middleware = None + for middleware in app.user_middleware: + if middleware.cls == CORSMiddleware: + cors_middleware = middleware + break + + assert cors_middleware is not None + + def test_cors_origins_from_environment(self, monkeypatch): + """Test that CORS origins can be configured via environment.""" + # The app is already initialized, but we can test the behavior + # by checking that the middleware was added + has_cors = any( + middleware.cls == CORSMiddleware + for middleware in app.user_middleware + ) + assert has_cors is True + + def test_cors_middleware_options(self): + """Test CORS middleware configuration options.""" + cors_middleware = None + for middleware in app.user_middleware: + if middleware.cls == CORSMiddleware: + cors_middleware = middleware + break + + assert cors_middleware is not None + options = cors_middleware.options + + # Check that required CORS options are set + assert "allow_origins" in options + assert "allow_credentials" in options + assert "allow_methods" in options + assert "allow_headers" in options + + def test_cors_allow_credentials_is_true(self): + """Test that allow_credentials is True.""" + cors_middleware = None + for middleware in app.user_middleware: + if middleware.cls == CORSMiddleware: + cors_middleware = middleware + break + + assert cors_middleware is not None + assert cors_middleware.options["allow_credentials"] is True + + def test_cors_allow_methods_is_all(self): + """Test that all HTTP methods are allowed.""" + cors_middleware = None + for middleware in app.user_middleware: + if middleware.cls == CORSMiddleware: + cors_middleware = middleware + break + + assert cors_middleware is not None + assert cors_middleware.options["allow_methods"] == ["*"] + + def test_cors_allow_headers_is_all(self): + """Test that all headers are allowed.""" + cors_middleware = None + for middleware in app.user_middleware: + if middleware.cls == CORSMiddleware: + cors_middleware = middleware + break + + assert cors_middleware is not None + assert cors_middleware.options["allow_headers"] == ["*"] + + +class TestRootEndpoint: + """Test root endpoint functionality.""" + + @pytest.fixture + def client(self): + """Create test client fixture.""" + return TestClient(app) + + def test_root_endpoint_exists(self, client): + """Test that root endpoint exists and is accessible.""" + response = client.get("/") + assert response.status_code == 200 + + def test_root_endpoint_returns_json(self, client): + """Test that root endpoint returns JSON.""" + response = client.get("/") + assert response.headers["content-type"] == "application/json" + + def test_root_endpoint_response_structure(self, client): + """Test that root endpoint returns correct structure.""" + response = client.get("/") + data = response.json() + assert "message" in data + + def test_root_endpoint_message_content(self, client): + """Test that root endpoint returns correct message.""" + response = client.get("/") + data = response.json() + assert data["message"] == "Welcome to the Insurance Management API" + + def test_root_endpoint_with_different_methods(self, client): + """Test that root endpoint only accepts GET method.""" + response_post = client.post("/") + assert response_post.status_code == 405 # Method Not Allowed + + response_put = client.put("/") + assert response_put.status_code == 405 + + response_delete = client.delete("/") + assert response_delete.status_code == 405 + + def test_root_endpoint_response_is_dict(self, client): + """Test that root endpoint returns a dictionary.""" + response = client.get("/") + data = response.json() + assert isinstance(data, dict) + + +class TestHealthCheckEndpoint: + """Test health check endpoint functionality.""" + + @pytest.fixture + def client(self): + """Create test client fixture.""" + return TestClient(app) + + def test_health_endpoint_exists(self, client): + """Test that health endpoint exists and is accessible.""" + response = client.get("/health") + assert response.status_code == 200 + + def test_health_endpoint_returns_json(self, client): + """Test that health endpoint returns JSON.""" + response = client.get("/health") + assert response.headers["content-type"] == "application/json" + + def test_health_endpoint_response_structure(self, client): + """Test that health endpoint returns correct structure.""" + response = client.get("/health") + data = response.json() + assert "status" in data + + def test_health_endpoint_status_value(self, client): + """Test that health endpoint returns status 'ok'.""" + response = client.get("/health") + data = response.json() + assert data["status"] == "ok" + + def test_health_endpoint_with_different_methods(self, client): + """Test that health endpoint only accepts GET method.""" + response_post = client.post("/health") + assert response_post.status_code == 405 # Method Not Allowed + + response_put = client.put("/health") + assert response_put.status_code == 405 + + response_delete = client.delete("/health") + assert response_delete.status_code == 405 + + def test_health_endpoint_response_is_dict(self, client): + """Test that health endpoint returns a dictionary.""" + response = client.get("/health") + data = response.json() + assert isinstance(data, dict) + + def test_health_endpoint_multiple_calls(self, client): + """Test that health endpoint is idempotent.""" + response1 = client.get("/health") + response2 = client.get("/health") + response3 = client.get("/health") + + assert response1.status_code == 200 + assert response2.status_code == 200 + assert response3.status_code == 200 + + assert response1.json() == response2.json() == response3.json() + + +class TestEndpointBoundaryValues: + """Test endpoints with boundary and edge case values.""" + + @pytest.fixture + def client(self): + """Create test client fixture.""" + return TestClient(app) + + def test_root_endpoint_with_query_params(self, client): + """Test root endpoint with query parameters (should be ignored).""" + response = client.get("/?param=value&other=test") + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Welcome to the Insurance Management API" + + def test_health_endpoint_with_query_params(self, client): + """Test health endpoint with query parameters (should be ignored).""" + response = client.get("/health?check=full") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + + def test_root_endpoint_with_headers(self, client): + """Test root endpoint with custom headers.""" + response = client.get("/", headers={"X-Custom-Header": "test"}) + assert response.status_code == 200 + + def test_health_endpoint_with_headers(self, client): + """Test health endpoint with custom headers.""" + response = client.get("/health", headers={"X-Custom-Header": "test"}) + assert response.status_code == 200 + + def test_invalid_endpoint_returns_404(self, client): + """Test that invalid endpoints return 404.""" + response = client.get("/invalid-endpoint") + assert response.status_code == 404 + + def test_root_with_trailing_slash(self, client): + """Test root endpoint behavior with trailing slash.""" + response = client.get("/") + assert response.status_code == 200 + + def test_health_with_trailing_slash(self, client): + """Test health endpoint behavior with trailing slash.""" + response_no_slash = client.get("/health") + response_with_slash = client.get("/health/") + + # Both should work or redirect + assert response_no_slash.status_code in [200, 307, 308] + + +class TestAppRoutes: + """Test application routes configuration.""" + + def test_app_has_routes(self): + """Test that app has routes configured.""" + assert len(app.routes) > 0 + + def test_root_route_exists_in_routes(self): + """Test that root route is registered.""" + route_paths = [route.path for route in app.routes] + assert "/" in route_paths + + def test_health_route_exists_in_routes(self): + """Test that health route is registered.""" + route_paths = [route.path for route in app.routes] + assert "/health" in route_paths + + def test_routes_have_correct_methods(self): + """Test that routes have correct HTTP methods.""" + for route in app.routes: + if hasattr(route, "path"): + if route.path == "/": + assert "GET" in route.methods + elif route.path == "/health": + assert "GET" in route.methods + + +class TestCORSFunctionality: + """Test CORS functionality with test client.""" + + @pytest.fixture + def client(self): + """Create test client fixture.""" + return TestClient(app) + + def test_cors_preflight_request(self, client): + """Test CORS preflight OPTIONS request.""" + response = client.options( + "/health", + headers={ + "Origin": "http://localhost:3000", + "Access-Control-Request-Method": "GET" + } + ) + # Should handle preflight request + assert response.status_code in [200, 204] + + def test_cors_headers_in_response(self, client): + """Test that CORS headers are present in response.""" + response = client.get( + "/", + headers={"Origin": "http://localhost:3000"} + ) + assert response.status_code == 200 + # CORS middleware should add appropriate headers + + +class TestAppIntegration: + """Integration tests for the application.""" + + @pytest.fixture + def client(self): + """Create test client fixture.""" + return TestClient(app) + + def test_multiple_endpoint_calls(self, client): + """Test calling multiple endpoints in sequence.""" + response1 = client.get("/") + response2 = client.get("/health") + response3 = client.get("/") + + assert response1.status_code == 200 + assert response2.status_code == 200 + assert response3.status_code == 200 + + assert response1.json()["message"] == "Welcome to the Insurance Management API" + assert response2.json()["status"] == "ok" + + def test_app_responds_to_concurrent_requests(self, client): + """Test that app can handle multiple requests.""" + responses = [] + for _ in range(10): + response = client.get("/health") + responses.append(response) + + for response in responses: + assert response.status_code == 200 + assert response.json()["status"] == "ok" diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000..5d1c18f --- /dev/null +++ b/backend/tests/test_models.py @@ -0,0 +1,576 @@ +""" +Comprehensive unit tests for database models. +Tests User, Policy, Claim, and Payment models with boundary values and relationships. +""" +import pytest +from datetime import datetime +from sqlalchemy import inspect, Column +from app.db.session import Base +from app.models.user import User +from app.models.policy import Policy, PolicyType +from app.models.claim import Claim +from app.models.payment import Payment + + +class TestUserModel: + """Test User model structure and defaults.""" + + def test_user_model_inherits_from_base(self): + """Test that User inherits from Base.""" + assert issubclass(User, Base) + + def test_user_table_name(self): + """Test that User has correct table name.""" + assert User.__tablename__ == "users" + + def test_user_has_required_columns(self): + """Test that User has all required columns.""" + mapper = inspect(User) + column_names = [col.key for col in mapper.columns] + + assert "id" in column_names + assert "email" in column_names + assert "hashed_password" in column_names + assert "role" in column_names + assert "is_active" in column_names + assert "created_at" in column_names + assert "updated_at" in column_names + + def test_user_id_is_primary_key(self): + """Test that id is the primary key.""" + mapper = inspect(User) + primary_keys = [key.name for key in mapper.primary_key] + assert "id" in primary_keys + + def test_user_email_is_unique(self): + """Test that email column is unique.""" + assert User.email.unique is True + + def test_user_email_is_indexed(self): + """Test that email column is indexed.""" + assert User.email.index is True + + def test_user_email_is_not_nullable(self): + """Test that email column is not nullable.""" + assert User.email.nullable is False + + def test_user_hashed_password_is_not_nullable(self): + """Test that hashed_password column is not nullable.""" + assert User.hashed_password.nullable is False + + def test_user_role_default_value(self): + """Test that role has default value 'customer'.""" + assert User.role.default.arg == "customer" + + def test_user_is_active_default_value(self): + """Test that is_active has default value True.""" + assert User.is_active.default.arg is True + + def test_user_has_policies_relationship(self): + """Test that User has policies relationship.""" + assert hasattr(User, "policies") + + def test_user_has_claims_relationship(self): + """Test that User has claims relationship.""" + assert hasattr(User, "claims") + + def test_user_column_types(self): + """Test column data types.""" + mapper = inspect(User) + columns = {col.key: col for col in mapper.columns} + + assert columns["id"].type.python_type == int + assert columns["is_active"].type.python_type == bool + + @pytest.mark.parametrize("role", ["admin", "agent", "customer", "manager", ""]) + def test_user_role_accepts_various_values(self, role): + """Test that role column accepts different string values.""" + user = User( + email=f"test_{role}@example.com", + hashed_password="hashed_pwd", + role=role + ) + assert user.role == role + + @pytest.mark.parametrize("email_length", [1, 50, 100, 255]) + def test_user_email_boundary_lengths(self, email_length): + """Test email with various boundary lengths.""" + email = f"{'a' * (email_length - 13)}@example.com" + user = User(email=email, hashed_password="hashed") + assert len(user.email) <= 255 + + +class TestPolicyModel: + """Test Policy model structure and defaults.""" + + def test_policy_model_inherits_from_base(self): + """Test that Policy inherits from Base.""" + assert issubclass(Policy, Base) + + def test_policy_table_name(self): + """Test that Policy has correct table name.""" + assert Policy.__tablename__ == "policies" + + def test_policy_has_required_columns(self): + """Test that Policy has all required columns.""" + mapper = inspect(Policy) + column_names = [col.key for col in mapper.columns] + + assert "id" in column_names + assert "user_id" in column_names + assert "policy_type" in column_names + assert "premium" in column_names + assert "coverage_amount" in column_names + assert "start_date" in column_names + assert "end_date" in column_names + assert "is_active" in column_names + assert "created_at" in column_names + assert "updated_at" in column_names + + def test_policy_id_is_primary_key(self): + """Test that id is the primary key.""" + mapper = inspect(Policy) + primary_keys = [key.name for key in mapper.primary_key] + assert "id" in primary_keys + + def test_policy_user_id_is_foreign_key(self): + """Test that user_id is a foreign key.""" + mapper = inspect(Policy) + foreign_keys = [fk.parent.name for fk in mapper.columns["user_id"].foreign_keys] + assert len(foreign_keys) > 0 + + def test_policy_user_id_not_nullable(self): + """Test that user_id is not nullable.""" + assert Policy.user_id.nullable is False + + def test_policy_type_not_nullable(self): + """Test that policy_type is not nullable.""" + assert Policy.policy_type.nullable is False + + def test_policy_premium_not_nullable(self): + """Test that premium is not nullable.""" + assert Policy.premium.nullable is False + + def test_policy_coverage_amount_not_nullable(self): + """Test that coverage_amount is not nullable.""" + assert Policy.coverage_amount.nullable is False + + def test_policy_is_active_default(self): + """Test that is_active has default value True.""" + assert Policy.is_active.default.arg is True + + def test_policy_has_user_relationship(self): + """Test that Policy has user relationship.""" + assert hasattr(Policy, "user") + + def test_policy_has_claims_relationship(self): + """Test that Policy has claims relationship.""" + assert hasattr(Policy, "claims") + + def test_policy_has_payments_relationship(self): + """Test that Policy has payments relationship.""" + assert hasattr(Policy, "payments") + + def test_policy_column_types(self): + """Test column data types.""" + mapper = inspect(Policy) + columns = {col.key: col for col in mapper.columns} + + assert columns["id"].type.python_type == int + assert columns["user_id"].type.python_type == int + assert columns["premium"].type.python_type == float + assert columns["coverage_amount"].type.python_type == float + assert columns["is_active"].type.python_type == bool + + @pytest.mark.parametrize("premium", [0.0, 0.01, 100.0, 1000.0, 10000.0, 999999.99]) + def test_policy_premium_boundary_values(self, premium): + """Test premium with various boundary values.""" + policy = Policy( + user_id=1, + policy_type="health", + premium=premium, + coverage_amount=100000.0, + start_date=datetime.utcnow(), + end_date=datetime.utcnow() + ) + assert policy.premium == premium + + @pytest.mark.parametrize("coverage", [0.0, 1000.0, 50000.0, 100000.0, 1000000.0]) + def test_policy_coverage_amount_boundary_values(self, coverage): + """Test coverage_amount with various boundary values.""" + policy = Policy( + user_id=1, + policy_type="health", + premium=100.0, + coverage_amount=coverage, + start_date=datetime.utcnow(), + end_date=datetime.utcnow() + ) + assert policy.coverage_amount == coverage + + @pytest.mark.parametrize("policy_type", ["health", "vehicle", "life", "property"]) + def test_policy_type_equivalence_classes(self, policy_type): + """Test policy_type with different values.""" + policy = Policy( + user_id=1, + policy_type=policy_type, + premium=100.0, + coverage_amount=50000.0, + start_date=datetime.utcnow(), + end_date=datetime.utcnow() + ) + assert policy.policy_type == policy_type + + +class TestPolicyTypeEnum: + """Test PolicyType enum.""" + + def test_policy_type_enum_exists(self): + """Test that PolicyType enum exists.""" + assert PolicyType is not None + + def test_policy_type_has_health(self): + """Test that PolicyType has health value.""" + assert hasattr(PolicyType, "health") + assert PolicyType.health.value == "health" + + def test_policy_type_has_vehicle(self): + """Test that PolicyType has vehicle value.""" + assert hasattr(PolicyType, "vehicle") + assert PolicyType.vehicle.value == "vehicle" + + def test_policy_type_has_life(self): + """Test that PolicyType has life value.""" + assert hasattr(PolicyType, "life") + assert PolicyType.life.value == "life" + + def test_policy_type_enum_values(self): + """Test all PolicyType enum values.""" + expected_values = ["health", "vehicle", "life"] + actual_values = [member.value for member in PolicyType] + assert set(actual_values) == set(expected_values) + + +class TestClaimModel: + """Test Claim model structure and defaults.""" + + def test_claim_model_inherits_from_base(self): + """Test that Claim inherits from Base.""" + assert issubclass(Claim, Base) + + def test_claim_table_name(self): + """Test that Claim has correct table name.""" + assert Claim.__tablename__ == "claims" + + def test_claim_has_required_columns(self): + """Test that Claim has all required columns.""" + mapper = inspect(Claim) + column_names = [col.key for col in mapper.columns] + + assert "id" in column_names + assert "user_id" in column_names + assert "policy_id" in column_names + assert "amount_requested" in column_names + assert "description" in column_names + assert "status" in column_names + assert "document_url" in column_names + assert "created_at" in column_names + assert "updated_at" in column_names + + def test_claim_id_is_primary_key(self): + """Test that id is the primary key.""" + mapper = inspect(Claim) + primary_keys = [key.name for key in mapper.primary_key] + assert "id" in primary_keys + + def test_claim_user_id_is_foreign_key(self): + """Test that user_id is a foreign key.""" + mapper = inspect(Claim) + foreign_keys = [fk.parent.name for fk in mapper.columns["user_id"].foreign_keys] + assert len(foreign_keys) > 0 + + def test_claim_policy_id_is_foreign_key(self): + """Test that policy_id is a foreign key.""" + mapper = inspect(Claim) + foreign_keys = [fk.parent.name for fk in mapper.columns["policy_id"].foreign_keys] + assert len(foreign_keys) > 0 + + def test_claim_amount_requested_not_nullable(self): + """Test that amount_requested is not nullable.""" + assert Claim.amount_requested.nullable is False + + def test_claim_description_not_nullable(self): + """Test that description is not nullable.""" + assert Claim.description.nullable is False + + def test_claim_status_default_value(self): + """Test that status has default value 'pending'.""" + assert Claim.status.default.arg == "pending" + + def test_claim_document_url_is_nullable(self): + """Test that document_url is nullable.""" + assert Claim.document_url.nullable is True + + def test_claim_has_user_relationship(self): + """Test that Claim has user relationship.""" + assert hasattr(Claim, "user") + + def test_claim_has_policy_relationship(self): + """Test that Claim has policy relationship.""" + assert hasattr(Claim, "policy") + + def test_claim_column_types(self): + """Test column data types.""" + mapper = inspect(Claim) + columns = {col.key: col for col in mapper.columns} + + assert columns["id"].type.python_type == int + assert columns["user_id"].type.python_type == int + assert columns["policy_id"].type.python_type == int + assert columns["amount_requested"].type.python_type == float + + @pytest.mark.parametrize("amount", [0.0, 0.01, 100.0, 1000.0, 50000.0, 999999.99]) + def test_claim_amount_requested_boundary_values(self, amount): + """Test amount_requested with various boundary values.""" + claim = Claim( + user_id=1, + policy_id=1, + amount_requested=amount, + description="Test claim" + ) + assert claim.amount_requested == amount + + @pytest.mark.parametrize("status", ["pending", "approved", "rejected", "processing"]) + def test_claim_status_equivalence_classes(self, status): + """Test status with different values.""" + claim = Claim( + user_id=1, + policy_id=1, + amount_requested=1000.0, + description="Test claim", + status=status + ) + assert claim.status == status + + @pytest.mark.parametrize("desc_length", [1, 50, 200, 500, 1000]) + def test_claim_description_boundary_lengths(self, desc_length): + """Test description with various lengths.""" + description = "A" * desc_length + claim = Claim( + user_id=1, + policy_id=1, + amount_requested=1000.0, + description=description + ) + assert len(claim.description) == desc_length + + def test_claim_document_url_can_be_none(self): + """Test that document_url can be None.""" + claim = Claim( + user_id=1, + policy_id=1, + amount_requested=1000.0, + description="Test", + document_url=None + ) + assert claim.document_url is None + + def test_claim_document_url_can_be_empty_string(self): + """Test that document_url can be empty string.""" + claim = Claim( + user_id=1, + policy_id=1, + amount_requested=1000.0, + description="Test", + document_url="" + ) + assert claim.document_url == "" + + +class TestPaymentModel: + """Test Payment model structure and defaults.""" + + def test_payment_model_inherits_from_base(self): + """Test that Payment inherits from Base.""" + assert issubclass(Payment, Base) + + def test_payment_table_name(self): + """Test that Payment has correct table name.""" + assert Payment.__tablename__ == "payments" + + def test_payment_has_required_columns(self): + """Test that Payment has all required columns.""" + mapper = inspect(Payment) + column_names = [col.key for col in mapper.columns] + + assert "id" in column_names + assert "policy_id" in column_names + assert "amount" in column_names + assert "status" in column_names + assert "transaction_id" in column_names + assert "created_at" in column_names + assert "updated_at" in column_names + + def test_payment_id_is_primary_key(self): + """Test that id is the primary key.""" + mapper = inspect(Payment) + primary_keys = [key.name for key in mapper.primary_key] + assert "id" in primary_keys + + def test_payment_policy_id_is_foreign_key(self): + """Test that policy_id is a foreign key.""" + mapper = inspect(Payment) + foreign_keys = [fk.parent.name for fk in mapper.columns["policy_id"].foreign_keys] + assert len(foreign_keys) > 0 + + def test_payment_amount_not_nullable(self): + """Test that amount is not nullable.""" + assert Payment.amount.nullable is False + + def test_payment_status_default_value(self): + """Test that status has default value 'pending'.""" + assert Payment.status.default.arg == "pending" + + def test_payment_transaction_id_is_unique(self): + """Test that transaction_id is unique.""" + assert Payment.transaction_id.unique is True + + def test_payment_transaction_id_is_indexed(self): + """Test that transaction_id is indexed.""" + assert Payment.transaction_id.index is True + + def test_payment_transaction_id_is_nullable(self): + """Test that transaction_id is nullable.""" + assert Payment.transaction_id.nullable is True + + def test_payment_has_policy_relationship(self): + """Test that Payment has policy relationship.""" + assert hasattr(Payment, "policy") + + def test_payment_column_types(self): + """Test column data types.""" + mapper = inspect(Payment) + columns = {col.key: col for col in mapper.columns} + + assert columns["id"].type.python_type == int + assert columns["policy_id"].type.python_type == int + assert columns["amount"].type.python_type == float + + @pytest.mark.parametrize("amount", [0.0, 0.01, 50.0, 500.0, 5000.0, 99999.99]) + def test_payment_amount_boundary_values(self, amount): + """Test amount with various boundary values.""" + payment = Payment( + policy_id=1, + amount=amount + ) + assert payment.amount == amount + + @pytest.mark.parametrize("status", ["pending", "paid", "failed", "processing", "refunded"]) + def test_payment_status_equivalence_classes(self, status): + """Test status with different values.""" + payment = Payment( + policy_id=1, + amount=100.0, + status=status + ) + assert payment.status == status + + def test_payment_transaction_id_can_be_none(self): + """Test that transaction_id can be None.""" + payment = Payment( + policy_id=1, + amount=100.0, + transaction_id=None + ) + assert payment.transaction_id is None + + @pytest.mark.parametrize("txn_id_length", [1, 10, 50, 100]) + def test_payment_transaction_id_boundary_lengths(self, txn_id_length): + """Test transaction_id with various lengths.""" + transaction_id = "T" * txn_id_length + payment = Payment( + policy_id=1, + amount=100.0, + transaction_id=transaction_id + ) + assert len(payment.transaction_id) == txn_id_length + + +class TestModelRelationships: + """Test relationships between models.""" + + def test_user_to_policies_relationship_name(self): + """Test User.policies relationship exists.""" + assert hasattr(User, "policies") + + def test_user_to_claims_relationship_name(self): + """Test User.claims relationship exists.""" + assert hasattr(User, "claims") + + def test_policy_to_user_relationship_name(self): + """Test Policy.user relationship exists.""" + assert hasattr(Policy, "user") + + def test_policy_to_claims_relationship_name(self): + """Test Policy.claims relationship exists.""" + assert hasattr(Policy, "claims") + + def test_policy_to_payments_relationship_name(self): + """Test Policy.payments relationship exists.""" + assert hasattr(Policy, "payments") + + def test_claim_to_user_relationship_name(self): + """Test Claim.user relationship exists.""" + assert hasattr(Claim, "user") + + def test_claim_to_policy_relationship_name(self): + """Test Claim.policy relationship exists.""" + assert hasattr(Claim, "policy") + + def test_payment_to_policy_relationship_name(self): + """Test Payment.policy relationship exists.""" + assert hasattr(Payment, "policy") + + +class TestModelInstantiation: + """Test that models can be instantiated.""" + + def test_user_can_be_instantiated(self): + """Test User model can be instantiated.""" + user = User(email="test@example.com", hashed_password="hashed") + assert user.email == "test@example.com" + assert user.hashed_password == "hashed" + + def test_policy_can_be_instantiated(self): + """Test Policy model can be instantiated.""" + now = datetime.utcnow() + policy = Policy( + user_id=1, + policy_type="health", + premium=100.0, + coverage_amount=50000.0, + start_date=now, + end_date=now + ) + assert policy.user_id == 1 + assert policy.policy_type == "health" + + def test_claim_can_be_instantiated(self): + """Test Claim model can be instantiated.""" + claim = Claim( + user_id=1, + policy_id=1, + amount_requested=1000.0, + description="Medical claim" + ) + assert claim.user_id == 1 + assert claim.policy_id == 1 + + def test_payment_can_be_instantiated(self): + """Test Payment model can be instantiated.""" + payment = Payment( + policy_id=1, + amount=100.0 + ) + assert payment.policy_id == 1 + assert payment.amount == 100.0 diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py new file mode 100644 index 0000000..180c948 --- /dev/null +++ b/backend/tests/test_security.py @@ -0,0 +1,352 @@ +""" +Comprehensive unit tests for app.core.security module. +Tests password hashing, verification, and JWT token creation with boundary values. +""" +import pytest +from datetime import datetime, timedelta +from jose import jwt, JWTError +from app.core.security import ( + create_access_token, + verify_password, + get_password_hash, + pwd_context, + ALGORITHM +) + + +class TestPasswordHashing: + """Test password hashing functionality.""" + + def test_get_password_hash_returns_string(self): + """Test that get_password_hash returns a string.""" + password = "testpassword123" + hashed = get_password_hash(password) + assert isinstance(hashed, str) + assert len(hashed) > 0 + + def test_get_password_hash_returns_different_hash_each_time(self): + """Test that hashing the same password produces different hashes (salt).""" + password = "testpassword123" + hash1 = get_password_hash(password) + hash2 = get_password_hash(password) + assert hash1 != hash2 + + def test_get_password_hash_with_empty_string(self): + """Test hashing empty string.""" + password = "" + hashed = get_password_hash(password) + assert isinstance(hashed, str) + assert len(hashed) > 0 + + def test_get_password_hash_with_very_long_password(self): + """Test hashing very long password (boundary value).""" + password = "a" * 1000 + hashed = get_password_hash(password) + assert isinstance(hashed, str) + assert len(hashed) > 0 + + def test_get_password_hash_with_special_characters(self): + """Test hashing password with special characters.""" + password = "P@ssw0rd!@#$%^&*()_+-=[]{}|;:',.<>?/~`" + hashed = get_password_hash(password) + assert isinstance(hashed, str) + assert len(hashed) > 0 + + def test_get_password_hash_with_unicode_characters(self): + """Test hashing password with unicode characters.""" + password = "пароль密码🔒" + hashed = get_password_hash(password) + assert isinstance(hashed, str) + assert len(hashed) > 0 + + @pytest.mark.parametrize("password", [ + "short", # short password + "a", # single character + "12345678", # numeric only + "UPPERCASE", # uppercase only + "lowercase", # lowercase only + "MixedCase123!", # mixed case with numbers and special chars + ]) + def test_get_password_hash_equivalence_classes(self, password): + """Test password hashing with different equivalence classes.""" + hashed = get_password_hash(password) + assert isinstance(hashed, str) + assert len(hashed) > 0 + + +class TestPasswordVerification: + """Test password verification functionality.""" + + def test_verify_password_correct_password(self): + """Test verifying correct password returns True.""" + password = "correctpassword" + hashed = get_password_hash(password) + assert verify_password(password, hashed) is True + + def test_verify_password_incorrect_password(self): + """Test verifying incorrect password returns False.""" + password = "correctpassword" + wrong_password = "wrongpassword" + hashed = get_password_hash(password) + assert verify_password(wrong_password, hashed) is False + + def test_verify_password_case_sensitive(self): + """Test that password verification is case-sensitive.""" + password = "Password123" + hashed = get_password_hash(password) + assert verify_password("password123", hashed) is False + assert verify_password("PASSWORD123", hashed) is False + assert verify_password("Password123", hashed) is True + + def test_verify_password_empty_plain_password(self): + """Test verifying with empty plain password.""" + password = "somepassword" + hashed = get_password_hash(password) + assert verify_password("", hashed) is False + + def test_verify_password_empty_hashed_password_against_empty_plain(self): + """Test verifying empty password against its hash.""" + password = "" + hashed = get_password_hash(password) + assert verify_password(password, hashed) is True + + def test_verify_password_with_special_characters(self): + """Test verification with special characters.""" + password = "P@ss!#$%^&*()123" + hashed = get_password_hash(password) + assert verify_password(password, hashed) is True + assert verify_password("P@ss!#$%^&*()124", hashed) is False + + def test_verify_password_with_whitespace(self): + """Test that whitespace is significant in password verification.""" + password = "pass word" + hashed = get_password_hash(password) + assert verify_password("pass word", hashed) is True + assert verify_password("password", hashed) is False + assert verify_password(" pass word", hashed) is False + assert verify_password("pass word ", hashed) is False + + def test_verify_password_invalid_hash_format(self): + """Test verification with invalid hash format returns False.""" + password = "testpassword" + invalid_hash = "not-a-valid-hash" + result = verify_password(password, invalid_hash) + assert result is False + + +class TestPwdContext: + """Test password context configuration.""" + + def test_pwd_context_uses_bcrypt(self): + """Test that pwd_context is configured to use bcrypt.""" + assert "bcrypt" in pwd_context.schemes() + + def test_pwd_context_deprecated_auto(self): + """Test that deprecated is set to auto.""" + # This verifies the context is properly initialized + password = "test" + hashed = pwd_context.hash(password) + assert pwd_context.verify(password, hashed) + + +class TestCreateAccessToken: + """Test JWT access token creation.""" + + def test_create_access_token_returns_string(self, monkeypatch): + """Test that create_access_token returns a string.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql://test") + from app.core.config import settings + + token = create_access_token(subject="user123") + assert isinstance(token, str) + assert len(token) > 0 + + def test_create_access_token_contains_subject(self, monkeypatch): + """Test that token contains the correct subject.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql://test") + from app.core.config import settings + + subject = "user@example.com" + token = create_access_token(subject=subject) + decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + assert decoded["sub"] == subject + + def test_create_access_token_with_integer_subject(self, monkeypatch): + """Test token creation with integer subject (converted to string).""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql://test") + from app.core.config import settings + + subject = 12345 + token = create_access_token(subject=subject) + decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + assert decoded["sub"] == "12345" + + def test_create_access_token_default_expiration(self, monkeypatch): + """Test that token uses default expiration from settings.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql://test") + monkeypatch.setenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60") + from app.core.config import settings + + token = create_access_token(subject="user123") + decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + + exp_datetime = datetime.fromtimestamp(decoded["exp"]) + now = datetime.utcnow() + time_diff = exp_datetime - now + + # Should be approximately 60 minutes (with small tolerance for execution time) + assert 59 <= time_diff.total_seconds() / 60 <= 61 + + def test_create_access_token_custom_expiration(self, monkeypatch): + """Test token creation with custom expiration delta.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql://test") + from app.core.config import settings + + custom_delta = timedelta(minutes=30) + token = create_access_token(subject="user123", expires_delta=custom_delta) + decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + + exp_datetime = datetime.fromtimestamp(decoded["exp"]) + now = datetime.utcnow() + time_diff = exp_datetime - now + + # Should be approximately 30 minutes + assert 29 <= time_diff.total_seconds() / 60 <= 31 + + def test_create_access_token_contains_exp_claim(self, monkeypatch): + """Test that token contains 'exp' claim.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql://test") + from app.core.config import settings + + token = create_access_token(subject="user123") + decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + assert "exp" in decoded + assert isinstance(decoded["exp"], (int, float)) + + def test_create_access_token_contains_sub_claim(self, monkeypatch): + """Test that token contains 'sub' claim.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql://test") + from app.core.config import settings + + token = create_access_token(subject="user123") + decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + assert "sub" in decoded + + @pytest.mark.parametrize("expires_minutes", [ + 1, # 1 minute + 5, # 5 minutes + 60, # 1 hour + 1440, # 1 day + 10080, # 7 days + ]) + def test_create_access_token_boundary_expirations(self, expires_minutes, monkeypatch): + """Test token creation with various expiration boundary values.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql://test") + from app.core.config import settings + + expires_delta = timedelta(minutes=expires_minutes) + token = create_access_token(subject="user123", expires_delta=expires_delta) + decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + + exp_datetime = datetime.fromtimestamp(decoded["exp"]) + now = datetime.utcnow() + time_diff = exp_datetime - now + + # Allow 1 minute tolerance for execution time + expected_seconds = expires_minutes * 60 + assert expected_seconds - 60 <= time_diff.total_seconds() <= expected_seconds + 60 + + def test_create_access_token_with_empty_subject(self, monkeypatch): + """Test token creation with empty subject.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql://test") + from app.core.config import settings + + token = create_access_token(subject="") + decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + assert decoded["sub"] == "" + + @pytest.mark.parametrize("subject", [ + "user@example.com", + "123", + "user-id-with-dashes", + "user_id_with_underscores", + "user.id.with.dots", + ]) + def test_create_access_token_subject_equivalence_classes(self, subject, monkeypatch): + """Test token creation with different subject formats.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql://test") + from app.core.config import settings + + token = create_access_token(subject=subject) + decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + assert decoded["sub"] == str(subject) + + +class TestJWTTokenVerification: + """Test JWT token decoding and verification.""" + + def test_token_can_be_decoded_with_correct_secret(self, monkeypatch): + """Test that token can be decoded with the correct secret.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql://test") + from app.core.config import settings + + subject = "user123" + token = create_access_token(subject=subject) + decoded = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + assert decoded["sub"] == subject + + def test_token_cannot_be_decoded_with_wrong_secret(self, monkeypatch): + """Test that token cannot be decoded with wrong secret.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("DATABASE_URL", "postgresql://test") + + token = create_access_token(subject="user123") + + with pytest.raises(JWTError): + jwt.decode(token, "wrong-secret-key", algorithms=[ALGORITHM]) + + def test_token_with_algorithm_verification(self, monkeypatch): + """Test that ALGORITHM constant is HS256.""" + assert ALGORITHM == "HS256" + + +class TestSecurityIntegration: + """Integration tests for security functions.""" + + def test_hash_and_verify_workflow(self): + """Test complete workflow of hashing and verifying password.""" + original_password = "MySecurePassword123!" + + # Hash the password + hashed = get_password_hash(original_password) + + # Verify correct password + assert verify_password(original_password, hashed) is True + + # Verify incorrect password + assert verify_password("WrongPassword", hashed) is False + + def test_multiple_passwords_different_hashes(self): + """Test that different passwords produce different hashes.""" + password1 = "password1" + password2 = "password2" + + hash1 = get_password_hash(password1) + hash2 = get_password_hash(password2) + + assert hash1 != hash2 + assert verify_password(password1, hash1) is True + assert verify_password(password2, hash2) is True + assert verify_password(password1, hash2) is False + assert verify_password(password2, hash1) is False diff --git a/backend/tests/test_session.py b/backend/tests/test_session.py new file mode 100644 index 0000000..ecca003 --- /dev/null +++ b/backend/tests/test_session.py @@ -0,0 +1,272 @@ +""" +Comprehensive unit tests for app.db.session module. +Tests database session management, engine creation, and async session functionality. +""" +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine, async_sessionmaker +from sqlalchemy.orm import DeclarativeMeta +from app.db.session import engine, AsyncSessionLocal, Base, get_db + + +class TestDatabaseEngine: + """Test database engine configuration.""" + + def test_engine_is_async_engine(self): + """Test that engine is an AsyncEngine instance.""" + assert isinstance(engine, AsyncEngine) + + def test_engine_echo_is_false(self): + """Test that engine echo is set to False.""" + assert engine.echo is False + + def test_engine_url_from_settings(self, monkeypatch): + """Test that engine uses DATABASE_URL from settings.""" + # Engine is created at import time, so we verify it exists + assert engine is not None + assert hasattr(engine, 'url') + + +class TestAsyncSessionLocal: + """Test AsyncSessionLocal session maker configuration.""" + + def test_async_session_local_is_session_maker(self): + """Test that AsyncSessionLocal is an async_sessionmaker.""" + assert isinstance(AsyncSessionLocal, async_sessionmaker) + + def test_async_session_local_class_is_async_session(self): + """Test that AsyncSessionLocal creates AsyncSession instances.""" + assert AsyncSessionLocal.class_ is AsyncSession + + def test_async_session_local_expire_on_commit_false(self): + """Test that expire_on_commit is False.""" + assert AsyncSessionLocal.kw.get("expire_on_commit") is False + + def test_async_session_local_autoflush_false(self): + """Test that autoflush is False.""" + assert AsyncSessionLocal.kw.get("autoflush") is False + + def test_async_session_local_bind_is_engine(self): + """Test that AsyncSessionLocal is bound to the engine.""" + assert AsyncSessionLocal.kw.get("bind") is engine + + +class TestBase: + """Test declarative base configuration.""" + + def test_base_is_declarative_meta(self): + """Test that Base is a DeclarativeMeta.""" + assert isinstance(Base, DeclarativeMeta) + + def test_base_has_metadata(self): + """Test that Base has metadata attribute.""" + assert hasattr(Base, 'metadata') + assert Base.metadata is not None + + def test_base_can_be_subclassed(self): + """Test that Base can be used as a parent class for models.""" + from sqlalchemy import Column, Integer, String + + class TestModel(Base): + __tablename__ = "test_table" + id = Column(Integer, primary_key=True) + name = Column(String(50)) + + assert hasattr(TestModel, '__tablename__') + assert TestModel.__tablename__ == "test_table" + assert hasattr(TestModel, 'id') + assert hasattr(TestModel, 'name') + + +class TestGetDb: + """Test get_db dependency function.""" + + @pytest.mark.asyncio + async def test_get_db_is_async_generator(self): + """Test that get_db is an async generator function.""" + import inspect + assert inspect.isasyncgenfunction(get_db) + + @pytest.mark.asyncio + async def test_get_db_yields_async_session(self): + """Test that get_db yields an AsyncSession instance.""" + gen = get_db() + session = await gen.__anext__() + + assert isinstance(session, AsyncSession) + + # Cleanup + try: + await gen.__anext__() + except StopAsyncIteration: + pass + + @pytest.mark.asyncio + async def test_get_db_closes_session_after_use(self): + """Test that get_db properly closes the session after yielding.""" + gen = get_db() + session = await gen.__anext__() + + assert isinstance(session, AsyncSession) + + # Consume the generator to trigger cleanup + try: + await gen.__anext__() + except StopAsyncIteration: + # This is expected - the generator should stop after cleanup + pass + + # Session should be closed after generator exhaustion + # Note: We can't directly test if closed, but the pattern ensures cleanup + + @pytest.mark.asyncio + async def test_get_db_can_be_used_in_context_manager(self): + """Test that get_db can be used with async context manager pattern.""" + async def use_db(): + gen = get_db() + try: + session = await gen.__anext__() + assert isinstance(session, AsyncSession) + return session + finally: + try: + await gen.__anext__() + except StopAsyncIteration: + pass + + session = await use_db() + assert isinstance(session, AsyncSession) + + @pytest.mark.asyncio + async def test_get_db_multiple_calls_return_different_sessions(self): + """Test that multiple calls to get_db return different session instances.""" + gen1 = get_db() + gen2 = get_db() + + session1 = await gen1.__anext__() + session2 = await gen2.__anext__() + + assert isinstance(session1, AsyncSession) + assert isinstance(session2, AsyncSession) + assert session1 is not session2 + + # Cleanup + for gen in [gen1, gen2]: + try: + await gen.__anext__() + except StopAsyncIteration: + pass + + +class TestSessionIntegration: + """Integration tests for session management.""" + + @pytest.mark.asyncio + async def test_session_has_required_methods(self): + """Test that session has required database operation methods.""" + gen = get_db() + session = await gen.__anext__() + + # Check for common session methods + assert hasattr(session, 'add') + assert hasattr(session, 'commit') + assert hasattr(session, 'rollback') + assert hasattr(session, 'close') + assert hasattr(session, 'execute') + assert hasattr(session, 'flush') + assert hasattr(session, 'refresh') + + # Cleanup + try: + await gen.__anext__() + except StopAsyncIteration: + pass + + @pytest.mark.asyncio + async def test_session_can_execute_query(self): + """Test that session can execute a basic query.""" + from sqlalchemy import text + + gen = get_db() + session = await gen.__anext__() + + # Execute a simple query (SELECT 1) + result = await session.execute(text("SELECT 1 as value")) + row = result.first() + + assert row is not None + assert row[0] == 1 + + # Cleanup + try: + await gen.__anext__() + except StopAsyncIteration: + pass + + +class TestDatabaseConfiguration: + """Test overall database configuration.""" + + def test_all_components_exist(self): + """Test that all required database components are exported.""" + from app.db import session + + assert hasattr(session, 'engine') + assert hasattr(session, 'AsyncSessionLocal') + assert hasattr(session, 'Base') + assert hasattr(session, 'get_db') + + def test_engine_and_session_are_connected(self): + """Test that AsyncSessionLocal uses the engine.""" + assert AsyncSessionLocal.kw.get("bind") is engine + + def test_base_metadata_is_independent(self): + """Test that Base metadata is separate from other bases.""" + from sqlalchemy.orm import declarative_base + + another_base = declarative_base() + assert Base.metadata is not another_base.metadata + + +class TestBoundaryConditions: + """Test boundary conditions for database session.""" + + @pytest.mark.asyncio + async def test_get_db_called_multiple_times_sequentially(self): + """Test calling get_db multiple times in sequence.""" + sessions = [] + + for _ in range(3): + gen = get_db() + session = await gen.__anext__() + sessions.append(session) + + # Cleanup each generator + try: + await gen.__anext__() + except StopAsyncIteration: + pass + + # All sessions should be AsyncSession instances + for session in sessions: + assert isinstance(session, AsyncSession) + + # All sessions should be different instances + assert sessions[0] is not sessions[1] + assert sessions[1] is not sessions[2] + assert sessions[0] is not sessions[2] + + @pytest.mark.asyncio + async def test_get_db_exception_handling(self): + """Test that get_db properly handles exceptions during session use.""" + gen = get_db() + session = await gen.__anext__() + + assert isinstance(session, AsyncSession) + + # Simulate exception by closing generator with exception + try: + # Force the generator to handle potential exceptions + await gen.aclose() + except Exception: + # Should not raise exception during cleanup + pass diff --git a/frontend/__tests__/layout.test.tsx b/frontend/__tests__/layout.test.tsx new file mode 100644 index 0000000..1c63365 --- /dev/null +++ b/frontend/__tests__/layout.test.tsx @@ -0,0 +1,433 @@ +/** + * Comprehensive unit tests for RootLayout component. + * Tests rendering, props, metadata, and font configuration. + */ +import { render, screen } from '@testing-library/react'; +import RootLayout, { metadata } from '@/app/layout'; + +describe('RootLayout Component', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render( + +
Test Content
+
+ ); + expect(container).toBeInTheDocument(); + }); + + it('should render children prop correctly', () => { + render( + +
Child Content
+
+ ); + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + expect(screen.getByText('Child Content')).toBeInTheDocument(); + }); + + it('should render html element', () => { + const { container } = render( + +
Test
+
+ ); + const htmlElement = container.querySelector('html'); + expect(htmlElement).toBeInTheDocument(); + }); + + it('should render body element', () => { + const { container } = render( + +
Test
+
+ ); + const bodyElement = container.querySelector('body'); + expect(bodyElement).toBeInTheDocument(); + }); + }); + + describe('HTML Attributes', () => { + it('should have lang attribute set to "en"', () => { + const { container } = render( + +
Test
+
+ ); + const htmlElement = container.querySelector('html'); + expect(htmlElement).toHaveAttribute('lang', 'en'); + }); + + it('should have className containing font variables on html', () => { + const { container } = render( + +
Test
+
+ ); + const htmlElement = container.querySelector('html'); + const className = htmlElement?.getAttribute('class') || ''; + + expect(className).toContain('--font-geist-sans'); + expect(className).toContain('--font-geist-mono'); + }); + + it('should have h-full class on html element', () => { + const { container } = render( + +
Test
+
+ ); + const htmlElement = container.querySelector('html'); + expect(htmlElement?.className).toContain('h-full'); + }); + + it('should have antialiased class on html element', () => { + const { container } = render( + +
Test
+
+ ); + const htmlElement = container.querySelector('html'); + expect(htmlElement?.className).toContain('antialiased'); + }); + }); + + describe('Body Attributes', () => { + it('should have min-h-full class on body', () => { + const { container } = render( + +
Test
+
+ ); + const bodyElement = container.querySelector('body'); + expect(bodyElement?.className).toContain('min-h-full'); + }); + + it('should have flex class on body', () => { + const { container } = render( + +
Test
+
+ ); + const bodyElement = container.querySelector('body'); + expect(bodyElement?.className).toContain('flex'); + }); + + it('should have flex-col class on body', () => { + const { container } = render( + +
Test
+
+ ); + const bodyElement = container.querySelector('body'); + expect(bodyElement?.className).toContain('flex-col'); + }); + }); + + describe('Children Rendering', () => { + it('should render multiple children', () => { + render( + +
First Child
+
Second Child
+
+ ); + expect(screen.getByTestId('child-1')).toBeInTheDocument(); + expect(screen.getByTestId('child-2')).toBeInTheDocument(); + }); + + it('should render complex nested children', () => { + render( + +
+
Header
+
Main Content
+ +
+
+ ); + expect(screen.getByText('Header')).toBeInTheDocument(); + expect(screen.getByText('Main Content')).toBeInTheDocument(); + expect(screen.getByText('Footer')).toBeInTheDocument(); + }); + + it('should render empty children without crashing', () => { + const { container } = render( + + <> + + ); + expect(container).toBeInTheDocument(); + }); + + it('should render text node as children', () => { + render( + + Plain text content + + ); + expect(screen.getByText('Plain text content')).toBeInTheDocument(); + }); + }); + + describe('Boundary Values', () => { + it('should handle very long text content in children', () => { + const longText = 'A'.repeat(1000); + render( + +
{longText}
+
+ ); + expect(screen.getByText(longText)).toBeInTheDocument(); + }); + + it('should handle children with special characters', () => { + const specialText = '!@#$%^&*()_+-=[]{}|;:\'",.<>?/~`'; + render( + +
{specialText}
+
+ ); + expect(screen.getByText(specialText)).toBeInTheDocument(); + }); + + it('should handle children with unicode characters', () => { + const unicodeText = '你好世界 مرحبا 🌍🚀'; + render( + +
{unicodeText}
+
+ ); + expect(screen.getByText(unicodeText)).toBeInTheDocument(); + }); + }); + + describe('Structure and Hierarchy', () => { + it('should have correct DOM hierarchy', () => { + const { container } = render( + +
Test
+
+ ); + + const html = container.querySelector('html'); + const body = html?.querySelector('body'); + const content = body?.querySelector('[data-testid="content"]'); + + expect(html).toBeInTheDocument(); + expect(body).toBeInTheDocument(); + expect(content).toBeInTheDocument(); + }); + + it('should render body as direct child of html', () => { + const { container } = render( + +
Test
+
+ ); + + const html = container.querySelector('html'); + const body = html?.querySelector('body'); + + expect(body?.parentElement).toBe(html); + }); + + it('should render children inside body', () => { + const { container } = render( + +
Child
+
+ ); + + const body = container.querySelector('body'); + const child = screen.getByTestId('child-content'); + + expect(body).toContainElement(child); + }); + }); + + describe('Props Validation', () => { + it('should accept readonly children prop', () => { + const TestComponent = () => ( + +
Test
+
+ ); + + const { container } = render(); + expect(container).toBeInTheDocument(); + }); + + it('should work with React fragments as children', () => { + render( + + <> +
First
+
Second
+ +
+ ); + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.getByText('Second')).toBeInTheDocument(); + }); + }); + + describe('Component Rerendering', () => { + it('should update when children change', () => { + const { rerender } = render( + +
Initial Content
+
+ ); + + expect(screen.getByText('Initial Content')).toBeInTheDocument(); + + rerender( + +
Updated Content
+
+ ); + + expect(screen.getByText('Updated Content')).toBeInTheDocument(); + expect(screen.queryByText('Initial Content')).not.toBeInTheDocument(); + }); + + it('should maintain structure on rerender', () => { + const { container, rerender } = render( + +
Content 1
+
+ ); + + const htmlBefore = container.querySelector('html'); + + rerender( + +
Content 2
+
+ ); + + const htmlAfter = container.querySelector('html'); + expect(htmlAfter).toHaveAttribute('lang', 'en'); + expect(htmlAfter?.className).toContain('h-full'); + }); + }); +}); + +describe('Metadata Export', () => { + describe('Structure', () => { + it('should export metadata object', () => { + expect(metadata).toBeDefined(); + expect(typeof metadata).toBe('object'); + }); + + it('should have title property', () => { + expect(metadata).toHaveProperty('title'); + }); + + it('should have description property', () => { + expect(metadata).toHaveProperty('description'); + }); + }); + + describe('Values', () => { + it('should have correct title value', () => { + expect(metadata.title).toBe('Create Next App'); + }); + + it('should have correct description value', () => { + expect(metadata.description).toBe('Generated by create next app'); + }); + + it('should have title as string', () => { + expect(typeof metadata.title).toBe('string'); + }); + + it('should have description as string', () => { + expect(typeof metadata.description).toBe('string'); + }); + }); + + describe('Boundary Values', () => { + it('should have non-empty title', () => { + expect(metadata.title).not.toBe(''); + expect(metadata.title.length).toBeGreaterThan(0); + }); + + it('should have non-empty description', () => { + expect(metadata.description).not.toBe(''); + expect(metadata.description.length).toBeGreaterThan(0); + }); + + it('should have reasonable title length', () => { + expect(metadata.title.length).toBeLessThan(100); + }); + + it('should have reasonable description length', () => { + expect(metadata.description.length).toBeLessThan(200); + }); + }); +}); + +describe('Font Configuration', () => { + describe('Font Variables', () => { + it('should apply font variables to html element', () => { + const { container } = render( + +
Test
+
+ ); + + const html = container.querySelector('html'); + const className = html?.getAttribute('class') || ''; + + expect(className).toContain('--font-geist-sans'); + }); + + it('should apply both sans and mono font variables', () => { + const { container } = render( + +
Test
+
+ ); + + const html = container.querySelector('html'); + const className = html?.getAttribute('class') || ''; + + expect(className).toContain('--font-geist-sans'); + expect(className).toContain('--font-geist-mono'); + }); + }); + + describe('CSS Classes', () => { + it('should apply all required CSS classes to html', () => { + const { container } = render( + +
Test
+
+ ); + + const html = container.querySelector('html'); + const classes = html?.className.split(' ') || []; + + expect(classes.length).toBeGreaterThan(0); + expect(classes).toContain('h-full'); + expect(classes).toContain('antialiased'); + }); + + it('should apply all required CSS classes to body', () => { + const { container } = render( + +
Test
+
+ ); + + const body = container.querySelector('body'); + const classes = body?.className.split(' ') || []; + + expect(classes).toContain('min-h-full'); + expect(classes).toContain('flex'); + expect(classes).toContain('flex-col'); + }); + }); +}); diff --git a/frontend/__tests__/page.test.tsx b/frontend/__tests__/page.test.tsx new file mode 100644 index 0000000..fc064ba --- /dev/null +++ b/frontend/__tests__/page.test.tsx @@ -0,0 +1,473 @@ +/** + * Comprehensive unit tests for Home page component. + * Tests rendering, images, links, styling, and user interactions. + */ +import { render, screen } from '@testing-library/react'; +import Home from '@/app/page'; + +// Mock next/image +jest.mock('next/image', () => ({ + __esModule: true, + default: (props: any) => { + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + return ; + }, +})); + +describe('Home Component', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render(); + expect(container).toBeInTheDocument(); + }); + + it('should render main container div', () => { + const { container } = render(); + const mainDiv = container.querySelector('div.flex.flex-col.flex-1'); + expect(mainDiv).toBeInTheDocument(); + }); + + it('should render main element', () => { + const { container } = render(); + const mainElement = container.querySelector('main'); + expect(mainElement).toBeInTheDocument(); + }); + }); + + describe('Next.js Logo', () => { + it('should render Next.js logo image', () => { + render(); + const logo = screen.getByAltText('Next.js logo'); + expect(logo).toBeInTheDocument(); + }); + + it('should have correct logo src', () => { + render(); + const logo = screen.getByAltText('Next.js logo'); + expect(logo).toHaveAttribute('src', '/next.svg'); + }); + + it('should have correct logo width', () => { + render(); + const logo = screen.getByAltText('Next.js logo'); + expect(logo).toHaveAttribute('width', '100'); + }); + + it('should have correct logo height', () => { + render(); + const logo = screen.getByAltText('Next.js logo'); + expect(logo).toHaveAttribute('height', '20'); + }); + + it('should have priority loading on logo', () => { + render(); + const logo = screen.getByAltText('Next.js logo'); + expect(logo).toHaveAttribute('priority'); + }); + + it('should have dark:invert class on logo', () => { + render(); + const logo = screen.getByAltText('Next.js logo'); + expect(logo.className).toContain('dark:invert'); + }); + }); + + describe('Heading Text', () => { + it('should render main heading', () => { + render(); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading).toBeInTheDocument(); + }); + + it('should have correct heading text', () => { + render(); + const heading = screen.getByText('To get started, edit the page.tsx file.'); + expect(heading).toBeInTheDocument(); + }); + + it('should have heading as h1 element', () => { + render(); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading.tagName).toBe('H1'); + }); + + it('should have correct heading classes', () => { + render(); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading.className).toContain('text-3xl'); + expect(heading.className).toContain('font-semibold'); + }); + }); + + describe('Description Paragraph', () => { + it('should render description paragraph', () => { + render(); + const description = screen.getByText(/Looking for a starting point/); + expect(description).toBeInTheDocument(); + }); + + it('should contain Templates link text', () => { + render(); + expect(screen.getByText('Templates')).toBeInTheDocument(); + }); + + it('should contain Learning link text', () => { + render(); + expect(screen.getByText('Learning')).toBeInTheDocument(); + }); + + it('should have paragraph with correct styling', () => { + const { container } = render(); + const paragraph = container.querySelector('p.max-w-md'); + expect(paragraph).toBeInTheDocument(); + }); + }); + + describe('Templates Link', () => { + it('should render Templates link', () => { + render(); + const link = screen.getByRole('link', { name: 'Templates' }); + expect(link).toBeInTheDocument(); + }); + + it('should have correct Templates link href', () => { + render(); + const link = screen.getByRole('link', { name: 'Templates' }); + expect(link).toHaveAttribute('href', 'https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app'); + }); + + it('should have correct link styling', () => { + render(); + const link = screen.getByRole('link', { name: 'Templates' }); + expect(link.className).toContain('font-medium'); + }); + }); + + describe('Learning Link', () => { + it('should render Learning link', () => { + render(); + const link = screen.getByRole('link', { name: 'Learning' }); + expect(link).toBeInTheDocument(); + }); + + it('should have correct Learning link href', () => { + render(); + const link = screen.getByRole('link', { name: 'Learning' }); + expect(link).toHaveAttribute('href', 'https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app'); + }); + + it('should have correct link styling', () => { + render(); + const link = screen.getByRole('link', { name: 'Learning' }); + expect(link.className).toContain('font-medium'); + }); + }); + + describe('Deploy Now Button', () => { + it('should render Deploy Now link', () => { + render(); + const link = screen.getByRole('link', { name: /Deploy Now/ }); + expect(link).toBeInTheDocument(); + }); + + it('should have correct Deploy link href', () => { + render(); + const link = screen.getByRole('link', { name: /Deploy Now/ }); + expect(link).toHaveAttribute('href', 'https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app'); + }); + + it('should have target="_blank" attribute', () => { + render(); + const link = screen.getByRole('link', { name: /Deploy Now/ }); + expect(link).toHaveAttribute('target', '_blank'); + }); + + it('should have rel="noopener noreferrer" attribute', () => { + render(); + const link = screen.getByRole('link', { name: /Deploy Now/ }); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('should contain Vercel logomark image', () => { + render(); + const vercelLogo = screen.getByAltText('Vercel logomark'); + expect(vercelLogo).toBeInTheDocument(); + }); + + it('should have correct Vercel logo dimensions', () => { + render(); + const vercelLogo = screen.getByAltText('Vercel logomark'); + expect(vercelLogo).toHaveAttribute('width', '16'); + expect(vercelLogo).toHaveAttribute('height', '16'); + }); + }); + + describe('Documentation Button', () => { + it('should render Documentation link', () => { + render(); + const link = screen.getByRole('link', { name: 'Documentation' }); + expect(link).toBeInTheDocument(); + }); + + it('should have correct Documentation link href', () => { + render(); + const link = screen.getByRole('link', { name: 'Documentation' }); + expect(link).toHaveAttribute('href', 'https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app'); + }); + + it('should have target="_blank" attribute', () => { + render(); + const link = screen.getByRole('link', { name: 'Documentation' }); + expect(link).toHaveAttribute('target', '_blank'); + }); + + it('should have rel="noopener noreferrer" attribute', () => { + render(); + const link = screen.getByRole('link', { name: 'Documentation' }); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + }); + + describe('Layout and Structure', () => { + it('should have correct container classes', () => { + const { container } = render(); + const mainContainer = container.querySelector('div.flex.flex-col.flex-1'); + expect(mainContainer?.className).toContain('items-center'); + expect(mainContainer?.className).toContain('justify-center'); + }); + + it('should have main element with correct classes', () => { + const { container } = render(); + const mainElement = container.querySelector('main'); + expect(mainElement?.className).toContain('flex'); + expect(mainElement?.className).toContain('flex-1'); + expect(mainElement?.className).toContain('w-full'); + }); + + it('should have content div with flex layout', () => { + const { container } = render(); + const contentDiv = container.querySelector('div.flex.flex-col'); + expect(contentDiv).toBeInTheDocument(); + }); + + it('should have button container with flex layout', () => { + const { container } = render(); + const buttonContainer = container.querySelector('div.flex.flex-col.gap-4'); + expect(buttonContainer).toBeInTheDocument(); + }); + }); + + describe('Responsive Classes', () => { + it('should have responsive classes on main element', () => { + const { container } = render(); + const mainElement = container.querySelector('main'); + expect(mainElement?.className).toContain('sm:items-start'); + }); + + it('should have responsive classes on content div', () => { + const { container } = render(); + const contentDiv = container.querySelector('div.flex.flex-col.items-center'); + expect(contentDiv?.className).toContain('sm:items-start'); + expect(contentDiv?.className).toContain('sm:text-left'); + }); + + it('should have responsive classes on button container', () => { + const { container } = render(); + const buttonContainer = container.querySelector('div.flex.flex-col.gap-4'); + expect(buttonContainer?.className).toContain('sm:flex-row'); + }); + }); + + describe('Color Schemes', () => { + it('should have dark mode classes on container', () => { + const { container } = render(); + const mainContainer = container.querySelector('div.bg-zinc-50'); + expect(mainContainer?.className).toContain('dark:bg-black'); + }); + + it('should have dark mode classes on main element', () => { + const { container } = render(); + const mainElement = container.querySelector('main'); + expect(mainElement?.className).toContain('dark:bg-black'); + }); + + it('should have dark mode classes on heading', () => { + render(); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading.className).toContain('dark:text-zinc-50'); + }); + }); + + describe('Images', () => { + it('should render exactly 2 images', () => { + render(); + const images = screen.getAllByRole('img'); + expect(images).toHaveLength(2); + }); + + it('should have all images with alt text', () => { + render(); + const images = screen.getAllByRole('img'); + images.forEach(img => { + expect(img).toHaveAttribute('alt'); + expect(img.getAttribute('alt')).not.toBe(''); + }); + }); + + it('should have images with correct sources', () => { + render(); + const nextLogo = screen.getByAltText('Next.js logo'); + const vercelLogo = screen.getByAltText('Vercel logomark'); + + expect(nextLogo).toHaveAttribute('src', '/next.svg'); + expect(vercelLogo).toHaveAttribute('src', '/vercel.svg'); + }); + }); + + describe('Links Count and Validation', () => { + it('should render exactly 4 links', () => { + render(); + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(4); + }); + + it('should have all external links with security attributes', () => { + render(); + const externalLinks = [ + screen.getByRole('link', { name: /Deploy Now/ }), + screen.getByRole('link', { name: 'Documentation' }), + ]; + + externalLinks.forEach(link => { + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + }); + + it('should have all links with valid href attributes', () => { + render(); + const links = screen.getAllByRole('link'); + + links.forEach(link => { + const href = link.getAttribute('href'); + expect(href).toBeTruthy(); + expect(href).toMatch(/^https?:\/\//); + }); + }); + }); + + describe('Accessibility', () => { + it('should have proper heading hierarchy', () => { + render(); + const h1 = screen.getByRole('heading', { level: 1 }); + expect(h1).toBeInTheDocument(); + }); + + it('should have descriptive link text', () => { + render(); + + const templates = screen.getByRole('link', { name: 'Templates' }); + const learning = screen.getByRole('link', { name: 'Learning' }); + const documentation = screen.getByRole('link', { name: 'Documentation' }); + + expect(templates).toHaveTextContent('Templates'); + expect(learning).toHaveTextContent('Learning'); + expect(documentation).toHaveTextContent('Documentation'); + }); + + it('should have all images with meaningful alt text', () => { + render(); + const nextLogo = screen.getByAltText('Next.js logo'); + const vercelLogo = screen.getByAltText('Vercel logomark'); + + expect(nextLogo.getAttribute('alt')).toBe('Next.js logo'); + expect(vercelLogo.getAttribute('alt')).toBe('Vercel logomark'); + }); + }); + + describe('Boundary Values', () => { + it('should render with all text content present', () => { + render(); + const container = document.body; + expect(container.textContent).toBeTruthy(); + expect(container.textContent?.length).toBeGreaterThan(0); + }); + + it('should have non-empty main content', () => { + const { container } = render(); + const main = container.querySelector('main'); + expect(main?.textContent).toBeTruthy(); + expect(main?.textContent?.length).toBeGreaterThan(50); + }); + }); + + describe('Component Structure', () => { + it('should be a default export function', () => { + expect(typeof Home).toBe('function'); + }); + + it('should return valid JSX', () => { + const result = render(); + expect(result.container.firstChild).toBeTruthy(); + }); + + it('should have consistent structure on multiple renders', () => { + const { container: container1 } = render(); + const { container: container2 } = render(); + + expect(container1.innerHTML).toBe(container2.innerHTML); + }); + }); + + describe('CSS Classes Completeness', () => { + it('should have all Tailwind utility classes applied', () => { + const { container } = render(); + const allElements = container.querySelectorAll('*'); + + // Check that elements with classes exist + const elementsWithClasses = Array.from(allElements).filter( + el => el.className && el.className.length > 0 + ); + + expect(elementsWithClasses.length).toBeGreaterThan(0); + }); + + it('should have max-width constraints on content', () => { + const { container } = render(); + const maxWidthElements = container.querySelectorAll('[class*="max-w"]'); + expect(maxWidthElements.length).toBeGreaterThan(0); + }); + + it('should have gap spacing in layouts', () => { + const { container } = render(); + const gapElements = container.querySelectorAll('[class*="gap-"]'); + expect(gapElements.length).toBeGreaterThan(0); + }); + }); + + describe('Button Styles', () => { + it('should have Deploy button with correct background', () => { + const { container } = render(); + const deployButton = screen.getByRole('link', { name: /Deploy Now/ }); + expect(deployButton.className).toContain('bg-foreground'); + }); + + it('should have Deploy button with rounded corners', () => { + render(); + const deployButton = screen.getByRole('link', { name: /Deploy Now/ }); + expect(deployButton.className).toContain('rounded-full'); + }); + + it('should have Documentation button with border', () => { + render(); + const docButton = screen.getByRole('link', { name: 'Documentation' }); + expect(docButton.className).toContain('border'); + }); + + it('should have buttons with hover effects', () => { + render(); + const deployButton = screen.getByRole('link', { name: /Deploy Now/ }); + expect(deployButton.className).toMatch(/hover:/); + }); + }); +}); diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 0000000..bfc8883 --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,41 @@ +/** @type {import('jest').Config} */ +const config = { + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/jest.setup.js'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '\\.(css|less|scss|sass)$': 'identity-obj-proxy', + }, + transform: { + '^.+\\.(js|jsx|ts|tsx)$': ['@swc/jest', { + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + }, + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }], + }, + testMatch: [ + '**/__tests__/**/*.(test|spec).[jt]s?(x)', + '**/?(*.)+(spec|test).[jt]s?(x)', + ], + collectCoverageFrom: [ + 'src/**/*.{js,jsx,ts,tsx}', + '!src/**/*.d.ts', + '!src/**/*.stories.{js,jsx,ts,tsx}', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + testPathIgnorePatterns: ['/node_modules/', '/.next/'], + transformIgnorePatterns: [ + '/node_modules/', + '^.+\\.module\\.(css|sass|scss)$', + ], +}; + +module.exports = config; diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js new file mode 100644 index 0000000..fc41512 --- /dev/null +++ b/frontend/jest.setup.js @@ -0,0 +1,13 @@ +import '@testing-library/jest-dom'; + +// Mock next/font/google +jest.mock('next/font/google', () => ({ + Geist: () => ({ + variable: '--font-geist-sans', + className: 'geist-sans', + }), + Geist_Mono: () => ({ + variable: '--font-geist-mono', + className: 'geist-mono', + }), +}));