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>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-01 16:43:56 +00:00
parent 069be988ab
commit 6c643e9962
4 changed files with 123 additions and 30 deletions
+82 -29
View File
@@ -109,13 +109,81 @@ jobs:
run: pip-audit -r requirements-dev.txt --desc on 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: test-quick:
name: Tests name: Quick Tests
runs-on: ubuntu-latest 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: services:
redis: redis:
image: redis:7 image: redis:7
@@ -150,34 +218,19 @@ jobs:
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Run Tests - name: Run Integration Tests
run: pytest tests/ -v --cov=app --cov-report=xml --cov-report=term --junitxml=junit.xml -o junit_family=legacy -m "not e2e" 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 - name: Upload integration test results
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() }} if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: test-results name: test-results-integration
path: | path: junit-integration.xml
junit.xml
coverage.xml
mypy: mypy:
name: Mypy name: Mypy
@@ -207,7 +260,7 @@ jobs:
build: build:
name: Build & Push Docker Image name: Build & Push Docker Image
runs-on: ubuntu-latest 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' if: github.event_name == 'push'
steps: steps:
+1
View File
@@ -147,6 +147,7 @@ addopts = [
"--cov-report=term-missing", "--cov-report=term-missing",
"--cov-report=html", "--cov-report=html",
"--cov-branch", "--cov-branch",
"--timeout=120",
] ]
markers = [ markers = [
"unit: Unit tests for individual functions/methods", "unit: Unit tests for individual functions/methods",
+1
View File
@@ -6,6 +6,7 @@ pytest>=8.0.0
pytest-cov>=4.1.0 pytest-cov>=4.1.0
pytest-asyncio>=0.23.0 pytest-asyncio>=0.23.0
pytest-mock>=3.12.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 httpx>=0.26.0 # For async test client
testcontainers>=3.7.1 # For integration tests with real containers testcontainers>=3.7.1 # For integration tests with real containers
fpdf2>=2.8.0 # For generating test PDF documents in integration tests fpdf2>=2.8.0 # For generating test PDF documents in integration tests
+39 -1
View File
@@ -580,10 +580,36 @@ class TestSendToAllDestinations:
# Error should be recorded in results # Error should be recorded in results
assert "dropbox_error" in result.result["tasks"] 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.log_task_progress")
@patch("app.tasks.send_to_all.SessionLocal") @patch("app.tasks.send_to_all.SessionLocal")
@patch("app.tasks.send_to_all.settings") @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.""" """Test file_id lookup fallback when not provided."""
from app.models import FileRecord from app.models import FileRecord
@@ -592,6 +618,18 @@ class TestSendToAllDestinations:
mock_settings.workdir = str(tmp_path) 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 database session
mock_db = MagicMock() mock_db = MagicMock()
mock_query = MagicMock() mock_query = MagicMock()