name: Backend Tests
on:
  push:
    branches-ignore:
      - 'translations**'
  pull_request:
    branches-ignore:
      - 'translations**'
  workflow_dispatch:
concurrency:
  group: backend-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true
env:
  DEFAULT_UV_VERSION: "0.10.x"
  NLTK_DATA: "/usr/share/nltk_data"
jobs:
  changes:
    name: Detect Backend Changes
    runs-on: ubuntu-slim
    outputs:
      backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          persist-credentials: false
      - name: Decide run mode
        id: force
        run: |
          if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
            echo "run_all=true" >> "$GITHUB_OUTPUT"
          elif [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "dev" ) ]]; then
            echo "run_all=true" >> "$GITHUB_OUTPUT"
          else
            echo "run_all=false" >> "$GITHUB_OUTPUT"
          fi
      - name: Set diff range
        id: range
        if: steps.force.outputs.run_all != 'true'
        run: |
          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
            echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
          elif [[ "${{ github.event.created }}" == "true" ]]; then
            echo "base=${{ github.event.repository.default_branch }}" >> "$GITHUB_OUTPUT"
          else
            echo "base=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
          fi
          echo "ref=${{ github.sha }}" >> "$GITHUB_OUTPUT"
      - name: Detect changes
        id: filter
        if: steps.force.outputs.run_all != 'true'
        uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
        with:
          base: ${{ steps.range.outputs.base }}
          ref: ${{ steps.range.outputs.ref }}
          filters: |
            backend:
              - 'src/**'
              - 'pyproject.toml'
              - 'uv.lock'
              - 'docker/compose/docker-compose.ci-test.yml'
              - '.github/workflows/ci-backend.yml'
  test:
    needs: changes
    if: needs.changes.outputs.backend_changed == 'true'
    name: "Python ${{ matrix.python-version }}"
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        python-version: ['3.11', '3.12', '3.13', '3.14']
      fail-fast: false
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Start containers
        run: |
          docker compose --file docker/compose/docker-compose.ci-test.yml pull --quiet
          docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
      - name: Set up Python
        id: setup-python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "${{ matrix.python-version }}"
      - name: Install uv
        uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
        with:
          version: ${{ env.DEFAULT_UV_VERSION }}
          enable-cache: true
          python-version: ${{ steps.setup-python.outputs.python-version }}
      - name: Install system dependencies
        run: |
          sudo apt-get update -qq
          sudo apt-get install -qq --no-install-recommends \
            unpaper tesseract-ocr imagemagick ghostscript poppler-utils
      - name: Configure ImageMagick
        run: |
          sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
      - name: Install Python dependencies
        run: |
          uv sync \
            --python ${{ steps.setup-python.outputs.python-version }} \
            --group testing \
            --frozen
      - name: List installed Python dependencies
        run: |
          uv pip list
      - name: Install NLTK data
        run: |
          uv run python -m nltk.downloader punkt punkt_tab snowball_data stopwords -d ${{ env.NLTK_DATA }}
      - name: Run tests
        env:
          NLTK_DATA: ${{ env.NLTK_DATA }}
          PAPERLESS_CI_TEST: 1
        run: |
          uv run \
            --python ${{ steps.setup-python.outputs.python-version }} \
            --dev \
            --frozen \
            pytest
      - name: Upload test results to Codecov
        if: always()
        uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
        with:
          flags: backend-python-${{ matrix.python-version }}
          files: junit.xml
          report_type: test_results
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
        with:
          flags: backend-python-${{ matrix.python-version }}
          files: coverage.xml
          report_type: coverage
      - name: Stop containers
        if: always()
        run: |
          docker compose --file docker/compose/docker-compose.ci-test.yml logs
          docker compose --file docker/compose/docker-compose.ci-test.yml down
  typing:
    needs: changes
    if: needs.changes.outputs.backend_changed == 'true'
    name: Check project typing
    runs-on: ubuntu-24.04
    env:
      DEFAULT_PYTHON: "3.12"
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false
      - name: Set up Python
        id: setup-python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "${{ env.DEFAULT_PYTHON }}"
      - name: Install uv
        uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
        with:
          version: ${{ env.DEFAULT_UV_VERSION }}
          enable-cache: true
          python-version: ${{ steps.setup-python.outputs.python-version }}
      - name: Install Python dependencies
        run: |
          uv sync \
            --python ${{ steps.setup-python.outputs.python-version }} \
            --group testing \
            --group typing \
            --frozen
      - name: List installed Python dependencies
        run: |
          uv pip list
      - name: Check typing (pyrefly)
        continue-on-error: true
        run: |
          uv run pyrefly \
            check \
            src/
      - name: Cache Mypy
        uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
        with:
          path: .mypy_cache
          # Keyed by OS, Python version, and dependency hashes
          key: ${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-${{ hashFiles('pyproject.toml', 'uv.lock') }}
          restore-keys: |
            ${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-
            ${{ runner.os }}-mypy-
      - name: Check typing (mypy)
        continue-on-error: true
        run: |
          uv run mypy \
            --show-error-codes \
            --warn-unused-configs \
            src/ | uv run mypy-baseline filter
  gate:
    name: Backend CI Gate
    needs: [changes, test, typing]
    if: always()
    runs-on: ubuntu-slim
    steps:
      - name: Check gate
        run: |
          if [[ "${{ needs.changes.outputs.backend_changed }}" != "true" ]]; then
            echo "No backend-relevant changes detected."
            exit 0
          fi

          if [[ "${{ needs.test.result }}" != "success" ]]; then
            echo "::error::Backend test job result: ${{ needs.test.result }}"
            exit 1
          fi

          if [[ "${{ needs.typing.result }}" != "success" ]]; then
            echo "::error::Backend typing job result: ${{ needs.typing.result }}"
            exit 1
          fi

          echo "Backend checks passed."
