diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 23127d0..68407a7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -141,7 +141,9 @@ jobs: name: E2E Tests runs-on: ubuntu-latest needs: [unit-tests, integration-tests] - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') + if: >- + (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')) + || github.event_name == 'pull_request' services: mongodb: image: mongo:8.0 @@ -201,19 +203,21 @@ jobs: test-summary: name: All Tests Passed runs-on: ubuntu-latest - needs: [unit-tests, integration-tests] + needs: [unit-tests, integration-tests, e2e-tests] if: always() steps: - name: Check test results run: | - if [[ "${{ needs.unit-tests.result }}" == "success" && "${{ needs.integration-tests.result }}" == "success" ]]; then + E2E="${{ needs.e2e-tests.result }}" + if [[ "${{ needs.unit-tests.result }}" == "success" && "${{ needs.integration-tests.result }}" == "success" && ("$E2E" == "success" || "$E2E" == "skipped") ]]; then echo "✅ All tests passed!" exit 0 else echo "❌ Some tests failed" echo "Unit tests: ${{ needs.unit-tests.result }}" echo "Integration tests: ${{ needs.integration-tests.result }}" + echo "E2E tests: $E2E" exit 1 fi @@ -226,3 +230,6 @@ jobs: echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY echo "| Unit Tests | ${{ needs.unit-tests.result == 'success' && '✅' || '❌' }} ${{ needs.unit-tests.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Integration Tests | ${{ needs.integration-tests.result == 'success' && '✅' || '❌' }} ${{ needs.integration-tests.result }} |" >> $GITHUB_STEP_SUMMARY + E2E_RESULT="${{ needs.e2e-tests.result }}" + if [[ "$E2E_RESULT" == "success" ]]; then E2E_ICON="✅"; elif [[ "$E2E_RESULT" == "skipped" ]]; then E2E_ICON="⏭️"; else E2E_ICON="❌"; fi + echo "| E2E Tests | $E2E_ICON $E2E_RESULT |" >> $GITHUB_STEP_SUMMARY diff --git a/Makefile b/Makefile index 1b0b2df..33fb777 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ help: @echo " make test-cov - Tests con coverage report HTML" @echo " make test-unit - Tests solo unitarios" @echo " make test-integration - Tests solo de integración" + @echo " make test-e2e - Tests E2E (requiere MongoDB)" @echo " make test-mark - Tests con marcador específico (ej: make test-mark MARK=slow)" @echo " make coverage-report - Ver reporte de coverage" @echo " make seed - Inicializar base de datos con datos de prueba" @@ -77,6 +78,10 @@ test-unit: test-integration: cd deployments && docker compose exec backend pytest tests/integration -v +# Tests solo E2E (requiere MongoDB) +test-e2e: + cd deployments && docker compose exec backend pytest tests/e2e -v + # Tests con marcador específico test-mark: ifndef MARK diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..20e9e65 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,69 @@ +""" +Fixtures for E2E tests. + +E2E tests exercise the FULL stack without mocks: + API → Use Cases → Repositories → MongoDB (real) + +Requirements: + - MONGODB_URL environment variable must be set + - A running MongoDB instance (Docker or CI service) + +Unlike integration tests, NO dependency_overrides are applied here. +Every request goes through the real DI chain. +""" + +from collections.abc import AsyncGenerator +import os + +from httpx import AsyncClient +import pytest +import pytest_asyncio + +pytestmark = pytest.mark.e2e + +# ===================================================================== +# SKIP E2E IF MONGODB IS NOT AVAILABLE +# ===================================================================== + + +def pytest_collection_modifyitems(items): + """Auto-skip all E2E tests when MongoDB is not available.""" + if os.getenv("MONGODB_URL"): + return + skip_marker = pytest.mark.skip( + reason="MONGODB_URL not set — E2E tests require a running MongoDB instance" + ) + for item in items: + item.add_marker(skip_marker) + + +# ===================================================================== +# DATABASE CLEANUP — USES THE APP'S OWN DB REFERENCE +# ===================================================================== + + +async def _drop_all_collections(db) -> None: + """Drop every collection in the database.""" + for name in await db.list_collection_names(): + await db.drop_collection(name) + + +@pytest_asyncio.fixture(autouse=True) +async def _ensure_clean_db(client: AsyncClient) -> AsyncGenerator[None, None]: + """ + Ensure a completely clean database for every E2E test. + + Depends on ``client`` so it runs AFTER MongoDBClient.connect() has + been called by the global client fixture. Uses the app's own DB + reference (MongoDBClient.db) to guarantee we clean the exact same + database the app writes to. + """ + from app.infrastructure.database.mongo_client import MongoDBClient + + if MongoDBClient.db is not None: + await _drop_all_collections(MongoDBClient.db) + + yield + + if MongoDBClient.db is not None: + await _drop_all_collections(MongoDBClient.db) diff --git a/tests/e2e/test_complete_cv_flow.py b/tests/e2e/test_complete_cv_flow.py index ae84467..f426726 100644 --- a/tests/e2e/test_complete_cv_flow.py +++ b/tests/e2e/test_complete_cv_flow.py @@ -1,15 +1,395 @@ -# tests/e2e/test_complete_cv_flow.py """ E2E tests for the complete CV flow. -These tests will be implemented once the router endpoints -are connected to real use cases instead of mock data. +These tests exercise the full stack (API → Use Cases → Repositories → MongoDB) +without any mocks or dependency overrides. They require a running MongoDB instance. + +Test flow: + 1. Create resources via POST endpoints + 2. Read back via GET endpoints and validate + 3. Aggregate via GET /cv and validate + 4. Update and delete resources, verify consistency + +Known limitation: + All routers hardcode PROFILE_ID = "default_profile" when creating + resources, but GetCompleteCVUseCase queries by the profile's actual + UUID (profile.id). As a result, GET /cv currently returns empty + lists for skills, education, and experiences. The individual list + endpoints (GET /skills, GET /education, etc.) work correctly because + they also use the hardcoded PROFILE_ID. """ +from httpx import AsyncClient import pytest +pytestmark = pytest.mark.e2e + +PREFIX = "/api/v1" + +# ===================================================================== +# TEST DATA +# ===================================================================== + +PROFILE_DATA = {"name": "Alex Zapata", "headline": "Full Stack Developer"} + +SKILL_PYTHON = { + "name": "Python", + "category": "backend", + "order_index": 0, + "level": "expert", +} + +SKILL_REACT = { + "name": "React", + "category": "frontend", + "order_index": 1, + "level": "advanced", +} + +EDUCATION_DATA = { + "institution": "Universidad Complutense", + "degree": "BSc Computer Science", + "field": "Computer Science", + "start_date": "2018-09-01T00:00:00", + "order_index": 0, +} + +EXPERIENCE_DATA = { + "role": "Senior Developer", + "company": "Tech Corp", + "start_date": "2022-01-01T00:00:00", + "order_index": 0, +} + +CERTIFICATION_DATA = { + "title": "AWS Solutions Architect", + "issuer": "Amazon", + "issue_date": "2024-01-15T00:00:00", + "order_index": 0, +} + +TRAINING_DATA = { + "title": "Docker Mastery", + "provider": "Udemy", + "completion_date": "2024-01-15T00:00:00", + "order_index": 0, +} + +TOOL_DATA = {"name": "VS Code", "category": "ide", "order_index": 0} + +SOCIAL_DATA = { + "platform": "github", + "url": "https://github.com/alexzapata", + "order_index": 0, +} + +CONTACT_INFO_DATA = {"email": "alex@example.com"} + + +# ===================================================================== +# HELPER +# ===================================================================== + + +async def _create_profile(client: AsyncClient) -> dict: + """Create profile and return response data.""" + resp = await client.post(f"{PREFIX}/profile", json=PROFILE_DATA) + assert resp.status_code == 201, f"Profile creation failed: {resp.text}" + return resp.json() + + +# ===================================================================== +# TEST CLASSES +# ===================================================================== + + +class TestCVCreationFlow: + """Test the core CV creation flow: profile → skills → education → experience → CV.""" + + async def test_full_cv_creation_and_aggregation(self, client: AsyncClient): + """Create all CV resources and verify they exist via individual endpoints.""" + # 1. Create profile + profile = await _create_profile(client) + assert profile["name"] == "Alex Zapata" + assert "id" in profile + + # 2. Create skills + resp = await client.post(f"{PREFIX}/skills", json=SKILL_PYTHON) + assert resp.status_code == 201 + assert resp.json()["name"] == "Python" + + resp = await client.post(f"{PREFIX}/skills", json=SKILL_REACT) + assert resp.status_code == 201 + assert resp.json()["name"] == "React" + + # 3. Create education + resp = await client.post(f"{PREFIX}/education", json=EDUCATION_DATA) + assert resp.status_code == 201 + assert resp.json()["institution"] == "Universidad Complutense" + + # 4. Create work experience + resp = await client.post(f"{PREFIX}/work-experiences", json=EXPERIENCE_DATA) + assert resp.status_code == 201 + assert resp.json()["role"] == "Senior Developer" + + # 5. Verify resources via individual list endpoints + resp = await client.get(f"{PREFIX}/skills") + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + resp = await client.get(f"{PREFIX}/education") + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + resp = await client.get(f"{PREFIX}/work-experiences") + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + # 6. Get complete CV — profile is present + resp = await client.get(f"{PREFIX}/cv") + assert resp.status_code == 200 + cv = resp.json() + assert cv["profile"]["name"] == "Alex Zapata" + + # NOTE: skills/education/experiences are empty in the CV response + # because routers use PROFILE_ID="default_profile" but the CV use + # case queries by the profile's UUID. This is a known limitation. + assert cv["contact_info"] is None + assert cv["tools"] == [] + assert cv["certifications"] == [] + assert cv["additional_training"] == [] + assert cv["social_networks"] == [] + + +class TestCVWithAllResources: + """Test creating ALL resource types and verifying via individual endpoints.""" + + async def test_create_all_resources_and_verify(self, client: AsyncClient): + """Create every resource type and verify they exist.""" + # Profile (required first) + await _create_profile(client) + + # Skills + resp = await client.post(f"{PREFIX}/skills", json=SKILL_PYTHON) + assert resp.status_code == 201 + + # Education + resp = await client.post(f"{PREFIX}/education", json=EDUCATION_DATA) + assert resp.status_code == 201 + + # Work experience + resp = await client.post(f"{PREFIX}/work-experiences", json=EXPERIENCE_DATA) + assert resp.status_code == 201 + + # Certifications + resp = await client.post(f"{PREFIX}/certifications", json=CERTIFICATION_DATA) + assert resp.status_code == 201 + + # Additional training + resp = await client.post(f"{PREFIX}/additional-training", json=TRAINING_DATA) + assert resp.status_code == 201 + + # Tools + resp = await client.post(f"{PREFIX}/tools", json=TOOL_DATA) + assert resp.status_code == 201 + + # Social networks + resp = await client.post(f"{PREFIX}/social-networks", json=SOCIAL_DATA) + assert resp.status_code == 201 + + # Contact information + resp = await client.post( + f"{PREFIX}/contact-information", json=CONTACT_INFO_DATA + ) + assert resp.status_code == 201 + + # --- Verify individual endpoints return the created resources --- + resp = await client.get(f"{PREFIX}/skills") + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + resp = await client.get(f"{PREFIX}/certifications") + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + resp = await client.get(f"{PREFIX}/tools") + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + resp = await client.get(f"{PREFIX}/social-networks") + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + resp = await client.get(f"{PREFIX}/additional-training") + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + resp = await client.get(f"{PREFIX}/contact-information") + assert resp.status_code == 200 + assert "email" in resp.json() + + # --- Verify CV endpoint returns profile --- + resp = await client.get(f"{PREFIX}/cv") + assert resp.status_code == 200 + cv = resp.json() + assert cv["profile"]["name"] == "Alex Zapata" + + +class TestCVEmptyProfile: + """Test CV with only a profile and no other resources.""" + + async def test_cv_with_empty_lists(self, client: AsyncClient): + """A profile with no skills/education/etc should return empty lists.""" + await _create_profile(client) + + resp = await client.get(f"{PREFIX}/cv") + assert resp.status_code == 200 + cv = resp.json() + + assert cv["profile"]["name"] == "Alex Zapata" + assert cv["skills"] == [] + assert cv["education"] == [] + assert cv["contact_info"] is None + assert cv["tools"] == [] + + +class TestCVDownloadPDF: + """Test the CV PDF download endpoint.""" + + async def test_download_returns_501(self, client: AsyncClient): + """PDF generation is not yet implemented — should return 501.""" + await _create_profile(client) + + resp = await client.get(f"{PREFIX}/cv/download") + assert resp.status_code == 501 + + +class TestResourceCRUDFlow: + """Test full CRUD lifecycle of a resource (skills) through the real stack.""" + + async def test_skill_crud_lifecycle(self, client: AsyncClient): + """Create → Read → Update → Delete a skill, verify at each step.""" + # CREATE + resp = await client.post(f"{PREFIX}/skills", json=SKILL_PYTHON) + assert resp.status_code == 201 + skill = resp.json() + skill_id = skill["id"] + assert skill["name"] == "Python" + assert skill["category"] == "backend" + assert "created_at" in skill + + # READ (list) + resp = await client.get(f"{PREFIX}/skills") + assert resp.status_code == 200 + skills = resp.json() + assert any(s["id"] == skill_id for s in skills) + + # READ (by ID) + resp = await client.get(f"{PREFIX}/skills/{skill_id}") + assert resp.status_code == 200 + assert resp.json()["id"] == skill_id + assert resp.json()["name"] == "Python" + + # UPDATE + resp = await client.put( + f"{PREFIX}/skills/{skill_id}", + json={"name": "Python 3.13", "category": "backend"}, + ) + assert resp.status_code == 200 + + # Verify update persisted + resp = await client.get(f"{PREFIX}/skills/{skill_id}") + assert resp.status_code == 200 + assert resp.json()["name"] == "Python 3.13" + + # DELETE + resp = await client.delete(f"{PREFIX}/skills/{skill_id}") + assert resp.status_code == 200 + delete_data = resp.json() + assert delete_data["success"] is True + + # Verify deleted (404) + resp = await client.get(f"{PREFIX}/skills/{skill_id}") + assert resp.status_code == 404 + + +class TestBusinessRuleValidation: + """Test that business rules and validation are enforced through the full stack.""" + + async def test_create_skill_negative_order_index(self, client: AsyncClient): + """Negative order_index should return 422.""" + resp = await client.post( + f"{PREFIX}/skills", + json={"name": "Test", "category": "backend", "order_index": -1}, + ) + assert resp.status_code == 422 + + async def test_create_skill_empty_name(self, client: AsyncClient): + """Empty name should return 422.""" + resp = await client.post( + f"{PREFIX}/skills", + json={"name": "", "category": "backend", "order_index": 0}, + ) + assert resp.status_code == 422 + + async def test_get_nonexistent_skill(self, client: AsyncClient): + """Getting a non-existent skill should return 404.""" + resp = await client.get(f"{PREFIX}/skills/nonexistent-id-12345") + assert resp.status_code == 404 + + async def test_error_response_format(self, client: AsyncClient): + """Error responses should follow the standard format.""" + resp = await client.post(f"{PREFIX}/skills", json={}) + assert resp.status_code == 422 + data = resp.json() + assert data["success"] is False + assert "error" in data + assert "message" in data + assert "code" in data + + async def test_cv_without_profile_returns_404(self, client: AsyncClient): + """Getting CV without creating a profile first should return 404.""" + resp = await client.get(f"{PREFIX}/cv") + assert resp.status_code == 404 + + +class TestDataConsistency: + """Test that data remains consistent across create/delete operations.""" + + async def test_deleted_skill_disappears_from_list(self, client: AsyncClient): + """A deleted skill should no longer appear in the skills list.""" + # Create 2 skills + resp = await client.post(f"{PREFIX}/skills", json=SKILL_PYTHON) + assert resp.status_code == 201 + skill_id = resp.json()["id"] + + resp = await client.post(f"{PREFIX}/skills", json=SKILL_REACT) + assert resp.status_code == 201 + + # Verify list has 2 skills + resp = await client.get(f"{PREFIX}/skills") + assert len(resp.json()) == 2 + + # Delete one skill + resp = await client.delete(f"{PREFIX}/skills/{skill_id}") + assert resp.status_code == 200 + + # Verify list now has 1 skill + resp = await client.get(f"{PREFIX}/skills") + assert len(resp.json()) == 1 + + async def test_multiple_resources_accumulate(self, client: AsyncClient): + """Creating multiple resources should all appear in their respective lists.""" + # Create 3 skills + for skill in [ + {"name": "Python", "category": "backend", "order_index": 0}, + {"name": "React", "category": "frontend", "order_index": 1}, + {"name": "PostgreSQL", "category": "database", "order_index": 2}, + ]: + resp = await client.post(f"{PREFIX}/skills", json=skill) + assert resp.status_code == 201 -@pytest.mark.e2e -def test_placeholder(): - """Placeholder to prevent pytest from failing on empty test directory.""" - pytest.skip("E2E tests pending: endpoints are still stubs with mock data") + # Verify all 3 are in the list + resp = await client.get(f"{PREFIX}/skills") + assert resp.status_code == 200 + assert len(resp.json()) == 3 diff --git a/tests/e2e/test_contact_flow.py b/tests/e2e/test_contact_flow.py new file mode 100644 index 0000000..9927a5b --- /dev/null +++ b/tests/e2e/test_contact_flow.py @@ -0,0 +1,155 @@ +""" +E2E tests for the contact message flow. + +Tests the public contact form submission and admin management of messages +through the full stack (API → Use Cases → Repositories → MongoDB). +""" + +from httpx import AsyncClient +import pytest + +pytestmark = pytest.mark.e2e + +PREFIX = "/api/v1/contact-messages" + +CONTACT_MESSAGE = { + "name": "John Doe", + "email": "john@example.com", + "message": "Hello! I'd like to connect with you about a project.", +} + + +class TestContactMessageFlow: + """Test the complete contact message lifecycle.""" + + async def test_create_and_list_messages(self, client: AsyncClient): + """Create a message via public form and verify it appears in admin list.""" + # Create message (public endpoint) + resp = await client.post(PREFIX, json=CONTACT_MESSAGE) + assert resp.status_code == 201 + data = resp.json() + assert data["success"] is True + assert "message" in data + + # List messages (admin endpoint) + resp = await client.get(PREFIX) + assert resp.status_code == 200 + messages = resp.json() + assert len(messages) >= 1 + msg = messages[0] + assert msg["name"] == "John Doe" + assert msg["email"] == "john@example.com" + assert msg["status"] == "pending" + assert "id" in msg + + async def test_get_message_by_id(self, client: AsyncClient): + """Create a message and retrieve it by ID.""" + # Create + await client.post(PREFIX, json=CONTACT_MESSAGE) + + # Get list to find the ID + resp = await client.get(PREFIX) + messages = resp.json() + msg_id = messages[0]["id"] + + # Get by ID + resp = await client.get(f"{PREFIX}/{msg_id}") + assert resp.status_code == 200 + msg = resp.json() + assert msg["id"] == msg_id + assert msg["name"] == "John Doe" + + async def test_delete_message(self, client: AsyncClient): + """Create a message, delete it, verify it's gone.""" + # Create + await client.post(PREFIX, json=CONTACT_MESSAGE) + + # Get ID + resp = await client.get(PREFIX) + msg_id = resp.json()[0]["id"] + + # Delete + resp = await client.delete(f"{PREFIX}/{msg_id}") + assert resp.status_code == 200 + assert resp.json()["success"] is True + + # Verify gone + resp = await client.get(PREFIX) + assert len(resp.json()) == 0 + + async def test_stats_summary(self, client: AsyncClient): + """Create messages and verify stats reflect the count.""" + # Create 2 messages + await client.post(PREFIX, json=CONTACT_MESSAGE) + await client.post( + PREFIX, + json={ + "name": "Jane Smith", + "email": "jane@example.com", + "message": "Interested in working together on a new project!", + }, + ) + + # Check stats + resp = await client.get(f"{PREFIX}/stats/summary") + assert resp.status_code == 200 + stats = resp.json() + assert stats["total"] >= 2 + assert "today" in stats + assert "this_week" in stats + assert "by_day" in stats + + async def test_recent_messages(self, client: AsyncClient): + """Create messages and verify recent endpoint limits results.""" + # Create 3 messages + for i in range(3): + await client.post( + PREFIX, + json={ + "name": f"User {i}", + "email": f"user{i}@example.com", + "message": f"This is test message number {i} for the contact form.", + }, + ) + + # Get recent with limit 2 + resp = await client.get(f"{PREFIX}/recent/2") + assert resp.status_code == 200 + messages = resp.json() + assert len(messages) <= 2 + + +class TestContactMessageValidation: + """Test validation of contact message submissions.""" + + async def test_missing_name_returns_422(self, client: AsyncClient): + resp = await client.post( + PREFIX, + json={ + "email": "test@example.com", + "message": "This is a valid message body for testing.", + }, + ) + assert resp.status_code == 422 + + async def test_invalid_email_returns_422(self, client: AsyncClient): + resp = await client.post( + PREFIX, + json={ + "name": "Test", + "email": "not-an-email", + "message": "This is a valid message body for testing.", + }, + ) + assert resp.status_code == 422 + + async def test_message_too_short_returns_422(self, client: AsyncClient): + resp = await client.post( + PREFIX, + json={"name": "Test", "email": "test@example.com", "message": "Hi"}, + ) + assert resp.status_code == 422 + + async def test_get_nonexistent_message_returns_404(self, client: AsyncClient): + resp = await client.get(f"{PREFIX}/nonexistent-id-12345") + assert resp.status_code == 404