diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91de6f32..19c33e14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,13 +109,81 @@ jobs: run: pip-audit -r requirements-dev.txt --desc on # ══════════════════════════════════════════════════════════════════════════ - # Stage 2b: Tests & Type Checking (run in parallel after lint passes) + # Stage 2b: Quick Tests (unit + basic integration — fast fail gate) # ══════════════════════════════════════════════════════════════════════════ - test: - name: Tests + test-quick: + name: Quick Tests runs-on: ubuntu-latest - needs: [lint, html-lint, dependency-scan] # Wait for lint, HTML a11y lint, and dependency scan before running tests + timeout-minutes: 10 + needs: [lint, html-lint, dependency-scan] + services: + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run Quick Tests + run: > + pytest tests/ -v + --timeout=120 + --cov=app --cov-report=xml --cov-report=term + --junitxml=junit.xml -o junit_family=legacy + -m "not e2e and not requires_docker and not requires_external and not slow" + + - name: Upload coverage reports to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + fail_ci_if_error: false + + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./junit.xml + report_type: test_results + fail_ci_if_error: false + + - name: Upload test artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: test-results-quick + path: | + junit.xml + coverage.xml + + # ══════════════════════════════════════════════════════════════════════════ + # Stage 2c: Integration Tests (Docker containers, external services) + # ══════════════════════════════════════════════════════════════════════════ + + test-integration: + name: Integration Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [test-quick] # Only run after quick tests pass (fail early) services: redis: image: redis:7 @@ -150,34 +218,19 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-dev.txt - - name: Run Tests - run: pytest tests/ -v --cov=app --cov-report=xml --cov-report=term --junitxml=junit.xml -o junit_family=legacy -m "not e2e" + - name: Run Integration Tests + run: > + pytest tests/ -v + --timeout=300 + --junitxml=junit-integration.xml -o junit_family=legacy + -m "(requires_docker or requires_external or slow) and not e2e" - - name: Upload coverage reports to Codecov - if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage.xml - fail_ci_if_error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./junit.xml - report_type: test_results - fail_ci_if_error: false - - - name: Upload test artifacts + - name: Upload integration test results if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 with: - name: test-results - path: | - junit.xml - coverage.xml + name: test-results-integration + path: junit-integration.xml mypy: name: Mypy @@ -207,7 +260,7 @@ jobs: build: name: Build & Push Docker Image runs-on: ubuntu-latest - needs: [test, lint, html-lint, mypy, dependency-scan] + needs: [test-quick, test-integration, lint, html-lint, mypy, dependency-scan] if: github.event_name == 'push' steps: diff --git a/pyproject.toml b/pyproject.toml index 5283e328..0eb4d27e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,6 +147,7 @@ addopts = [ "--cov-report=term-missing", "--cov-report=html", "--cov-branch", + "--timeout=120", ] markers = [ "unit: Unit tests for individual functions/methods", diff --git a/requirements-dev.txt b/requirements-dev.txt index 5066d101..7caf3e74 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,6 +6,7 @@ pytest>=8.0.0 pytest-cov>=4.1.0 pytest-asyncio>=0.23.0 pytest-mock>=3.12.0 +pytest-timeout>=2.3.0 # Per-test timeout enforcement to prevent CI hangs httpx>=0.26.0 # For async test client testcontainers>=3.7.1 # For integration tests with real containers fpdf2>=2.8.0 # For generating test PDF documents in integration tests diff --git a/tests/test_send_to_all.py b/tests/test_send_to_all.py index 4971d4de..2f549844 100644 --- a/tests/test_send_to_all.py +++ b/tests/test_send_to_all.py @@ -580,10 +580,36 @@ class TestSendToAllDestinations: # Error should be recorded in results assert "dropbox_error" in result.result["tasks"] + @patch("app.tasks.send_to_all._should_upload_to_s3") + @patch("app.tasks.send_to_all._should_upload_to_onedrive") + @patch("app.tasks.send_to_all._should_upload_to_email") + @patch("app.tasks.send_to_all._should_upload_to_sftp") + @patch("app.tasks.send_to_all._should_upload_to_ftp") + @patch("app.tasks.send_to_all._should_upload_to_webdav") + @patch("app.tasks.send_to_all._should_upload_to_google_drive") + @patch("app.tasks.send_to_all._should_upload_to_paperless") + @patch("app.tasks.send_to_all._should_upload_to_nextcloud") + @patch("app.tasks.send_to_all._should_upload_to_dropbox") @patch("app.tasks.send_to_all.log_task_progress") @patch("app.tasks.send_to_all.SessionLocal") @patch("app.tasks.send_to_all.settings") - def test_fallback_file_id_lookup(self, mock_settings, mock_session_local, mock_log, tmp_path): + def test_fallback_file_id_lookup( + self, + mock_settings, + mock_session_local, + mock_log, + mock_dropbox, + mock_nextcloud, + mock_paperless, + mock_google, + mock_webdav, + mock_ftp, + mock_sftp, + mock_email, + mock_onedrive, + mock_s3, + tmp_path, + ): """Test file_id lookup fallback when not provided.""" from app.models import FileRecord @@ -592,6 +618,18 @@ class TestSendToAllDestinations: mock_settings.workdir = str(tmp_path) + # Disable all upload services — this test only checks the DB lookup fallback + mock_dropbox.return_value = False + mock_nextcloud.return_value = False + mock_paperless.return_value = False + mock_google.return_value = False + mock_webdav.return_value = False + mock_ftp.return_value = False + mock_sftp.return_value = False + mock_email.return_value = False + mock_onedrive.return_value = False + mock_s3.return_value = False + # Mock database session mock_db = MagicMock() mock_query = MagicMock()