From 069be988ab26afb47f6f505d40ac1f659a1edb6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:23:46 +0000 Subject: [PATCH 1/4] Initial plan From 6c643e99624b113a040f92d2f2881736e496f287 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:43:56 +0000 Subject: [PATCH 2/4] fix(ci): add pytest-timeout, fix hanging test, split CI into quick + integration stages - Add pytest-timeout>=2.3.0 to requirements-dev.txt with 120s global default - Fix test_fallback_file_id_lookup: add missing _should_upload_* mocks that caused .delay() calls to hang on Redis broker connection - Split CI test job into two stages: - Quick Tests (timeout: 10min, ~2min run): unit + basic integration - Integration Tests (timeout: 20min): Docker containers, external services - Quick tests gate integration tests for fast-fail feedback - Build job now depends on both test stages Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .github/workflows/ci.yml | 111 ++++++++++++++++++++++++++++---------- pyproject.toml | 1 + requirements-dev.txt | 1 + tests/test_send_to_all.py | 40 +++++++++++++- 4 files changed, 123 insertions(+), 30 deletions(-) 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() From c100250b23390e9f85ab8231ff5cfa6eac9274f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:45:44 +0000 Subject: [PATCH 3/4] docs: update CI workflow and integration test documentation for two-stage pipeline Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- docs/CIWorkflow.md | 55 ++++++++++++++++++++++++------- tests/README_INTEGRATION_TESTS.md | 24 +++++++++++--- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/docs/CIWorkflow.md b/docs/CIWorkflow.md index a2f17f3a..7692a3ff 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:** 10 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/tests/README_INTEGRATION_TESTS.md b/tests/README_INTEGRATION_TESTS.md index fe99c9e8..7a6bec15 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, 10-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:** From 6a21adcdd47c38e0b1e9a9cc67a0b730de75936c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:46:57 +0000 Subject: [PATCH 4/4] fix(ci): increase quick test job timeout to 15 minutes for CI runner headroom Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- docs/CIWorkflow.md | 2 +- tests/README_INTEGRATION_TESTS.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19c33e14..9d020930 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,7 +115,7 @@ jobs: test-quick: name: Quick Tests runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 needs: [lint, html-lint, dependency-scan] services: redis: diff --git a/docs/CIWorkflow.md b/docs/CIWorkflow.md index 7692a3ff..6f62ce52 100644 --- a/docs/CIWorkflow.md +++ b/docs/CIWorkflow.md @@ -35,7 +35,7 @@ The pipeline follows a **fail-early** strategy: fast linters and quick tests run ### Quick Tests (`test-quick`) -- **Timeout:** 10 minutes (job), 120 seconds (per test via `pytest-timeout`) +- **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 diff --git a/tests/README_INTEGRATION_TESTS.md b/tests/README_INTEGRATION_TESTS.md index 7a6bec15..3ecd3ca9 100644 --- a/tests/README_INTEGRATION_TESTS.md +++ b/tests/README_INTEGRATION_TESTS.md @@ -368,7 +368,7 @@ The DocuElevate CI pipeline (`.github/workflows/ci.yml`) uses a **two-stage test 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, 10-minute job timeout +- Quick tests: 120 seconds per test, 15-minute job timeout - Integration tests: 300 seconds per test, 20-minute job timeout ```yaml