diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91de6f32..9d020930 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: 15 + 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/docs/CIWorkflow.md b/docs/CIWorkflow.md index a2f17f3a..6f62ce52 100644 --- a/docs/CIWorkflow.md +++ b/docs/CIWorkflow.md @@ -11,21 +11,45 @@ The CI workflow (`.github/workflows/tests.yaml`) runs automatically on every pus | Job | Tool | Purpose | Enforced | |--------|--------|---------------------------------------------|----------| | `lint` | Ruff | Fast Python linter (replaces Flake8, Black, isort, Bandit) | ✅ | +| `html-lint` | djLint | HTML template accessibility linter | ✅ | | `dependency-scan` | pip-audit | Dependency vulnerability scanning against OSV/PyPA advisories | ✅ | -| `test` | pytest | Unit/integration tests with coverage | ✅ | +| `test-quick` | pytest | Unit + basic integration tests with coverage (~2 min) | ✅ | +| `test-integration` | pytest | Docker container and external service tests (~5 min) | ✅ | | `mypy` | mypy | Static type checking | ✅ | -`lint` and `dependency-scan` start **in parallel** at the beginning of the pipeline — neither depends on the other. `test` and `mypy` run only after both have passed. +### Pipeline Flow + +``` +Stage 1 (parallel): lint, html-lint, dependency-scan + │ +Stage 2 (parallel): test-quick + mypy + │ +Stage 3: test-integration (only after test-quick passes) + │ +Stage 4: build (only after all above pass) +``` + +The pipeline follows a **fail-early** strategy: fast linters and quick tests run first to catch regressions early. Heavier integration tests only run after the quick tests pass, saving CI time when basic issues are present. > **Note:** DocuElevate uses Ruff, a modern all-in-one Python linter that consolidates the functionality of Flake8, Black, isort, and Bandit. This streamlined approach reduces CI complexity while maintaining code quality and security standards. -### Tests +### Quick Tests (`test-quick`) -- Runs pytest with coverage reporting (XML + terminal). -- Excludes E2E tests that require Docker-in-Docker (`-m "not e2e"`). -- Uses Redis and RabbitMQ service containers. -- Uploads coverage and JUnit XML results to Codecov. -- Uploads `junit.xml` and `coverage.xml` as workflow artifacts (always, even on failure). +- **Timeout:** 15 minutes (job), 120 seconds (per test via `pytest-timeout`) +- Runs the majority of tests (~2,790 unit + basic integration tests) +- Excludes tests marked `e2e`, `requires_docker`, `requires_external`, or `slow` +- Uses a Redis service container for tests that need it +- Collects coverage and uploads to Codecov +- Uploads `junit.xml` and `coverage.xml` as workflow artifacts + +### Integration Tests (`test-integration`) + +- **Timeout:** 20 minutes (job), 300 seconds (per test via `pytest-timeout`) +- Runs tests marked `requires_docker`, `requires_external`, or `slow` (excluding `e2e`) +- Uses Redis and RabbitMQ service containers +- Docker daemon available for testcontainers (WebDAV, OAuth mock server, etc.) +- Only runs after quick tests pass (fail-early gate) +- Uploads `junit-integration.xml` as a workflow artifact ### Ruff Lint & Format @@ -48,8 +72,9 @@ The CI workflow (`.github/workflows/tests.yaml`) runs automatically on every pus The following artifacts are uploaded after every run: | Artifact | Contents | Condition | -|------------------|--------------------------------------|----------------------| -| `test-results` | `junit.xml`, `coverage.xml` | Always (unless cancelled) | +|-------------------------------|--------------------------------------|----------------------| +| `test-results-quick` | `junit.xml`, `coverage.xml` | Always (unless cancelled) | +| `test-results-integration` | `junit-integration.xml` | Always (unless cancelled) | ## Running Linters Locally @@ -68,8 +93,14 @@ ruff format --check app/ tests/ # Run type checking mypy app/ -# Run tests -pytest tests/ -v --cov=app --cov-report=term -m "not e2e" +# Run quick tests (same as CI quick stage) +pytest tests/ -v --timeout=120 --cov=app --cov-report=term -m "not e2e and not requires_docker and not requires_external and not slow" + +# Run integration tests (requires Docker) +pytest tests/ -v --timeout=300 -m "(requires_docker or requires_external or slow) and not e2e" + +# Run all tests except e2e +pytest tests/ -v --timeout=120 -m "not e2e" ``` Or use pre-commit hooks to run checks automatically on each commit (recommended): 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/README_INTEGRATION_TESTS.md b/tests/README_INTEGRATION_TESTS.md index fe99c9e8..3ecd3ca9 100644 --- a/tests/README_INTEGRATION_TESTS.md +++ b/tests/README_INTEGRATION_TESTS.md @@ -361,12 +361,28 @@ docker volume prune -f ### GitHub Actions - Current Configuration -The DocuElevate CI workflow (`.github/workflows/tests.yaml`) **excludes E2E tests** by default because they require Docker-in-Docker (testcontainers), which requires additional configuration in GitHub Actions. +The DocuElevate CI pipeline (`.github/workflows/ci.yml`) uses a **two-stage test strategy** for fast feedback: + +1. **Quick Tests** (`test-quick`) — Runs ~2,790 unit and basic integration tests in ~2 minutes. Excludes tests marked `e2e`, `requires_docker`, `requires_external`, or `slow`. This stage gates the integration tests. + +2. **Integration Tests** (`test-integration`) — Runs ~28 Docker-based and external service tests. Only starts after quick tests pass. Uses testcontainers for WebDAV, OAuth, etc. + +Both stages use `pytest-timeout` to prevent individual tests from hanging: +- Quick tests: 120 seconds per test, 15-minute job timeout +- Integration tests: 300 seconds per test, 20-minute job timeout ```yaml -- name: Run Tests - # Exclude E2E tests - they require Docker-in-Docker (testcontainers) - run: pytest tests/ -v --cov=app -m "not e2e" +# Quick tests (Stage 2b) +- name: Run Quick Tests + run: > + pytest tests/ -v --timeout=120 + -m "not e2e and not requires_docker and not requires_external and not slow" + +# Integration tests (Stage 2c) — only after quick tests pass +- name: Run Integration Tests + run: > + pytest tests/ -v --timeout=300 + -m "(requires_docker or requires_external or slow) and not e2e" ``` **To run E2E tests locally:** 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()